Compare commits
	
		
			15 Commits
		
	
	
		
			3.2.0+125
			...
			6892afb974
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 6892afb974 | |||
| 007b46b080 | |||
| 67d130dc34 | |||
| 7e923c77fe | |||
| a593b52812 | |||
|  | 520dc80303 | ||
| 001897bbcd | |||
|  | bab29c23e3 | ||
| 76b39f2df3 | |||
| 509b3e145b | |||
| 2b80ebc2d0 | |||
| 0ab908dd2a | |||
| 6007467e7a | |||
| 3745157c42 | |||
| 94481ec7bd | 
| @@ -334,6 +334,7 @@ | |||||||
|   "walletCreate": "Create a Wallet", |   "walletCreate": "Create a Wallet", | ||||||
|   "settingsServerUrl": "Server URL", |   "settingsServerUrl": "Server URL", | ||||||
|   "settingsApplied": "The settings has been applied.", |   "settingsApplied": "The settings has been applied.", | ||||||
|  |   "settingsCustomFontsHelper": "Use comma to seprate.", | ||||||
|   "notifications": "Notifications", |   "notifications": "Notifications", | ||||||
|   "posts": "Posts", |   "posts": "Posts", | ||||||
|   "settingsBackgroundImage": "Background Image", |   "settingsBackgroundImage": "Background Image", | ||||||
|   | |||||||
| @@ -300,6 +300,7 @@ | |||||||
|   "walletCreate": "创建钱包", |   "walletCreate": "创建钱包", | ||||||
|   "settingsServerUrl": "服务器 URL", |   "settingsServerUrl": "服务器 URL", | ||||||
|   "settingsApplied": "设置已应用。", |   "settingsApplied": "设置已应用。", | ||||||
|  |   "settingsCustomFontsHelper": "用逗号分隔。", | ||||||
|   "notifications": "通知", |   "notifications": "通知", | ||||||
|   "posts": "帖子", |   "posts": "帖子", | ||||||
|   "settingsBackgroundImage": "背景图片", |   "settingsBackgroundImage": "背景图片", | ||||||
|   | |||||||
| @@ -245,7 +245,7 @@ PODS: | |||||||
|     - PromisesObjC (= 2.4.0) |     - PromisesObjC (= 2.4.0) | ||||||
|   - receive_sharing_intent (1.8.1): |   - receive_sharing_intent (1.8.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - record_ios (1.0.0): |   - record_ios (1.1.0): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - SAMKeychain (1.5.3) |   - SAMKeychain (1.5.3) | ||||||
|   - SDWebImage (5.21.1): |   - SDWebImage (5.21.1): | ||||||
| @@ -510,7 +510,7 @@ SPEC CHECKSUMS: | |||||||
|   PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 |   PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 | ||||||
|   PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 |   PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 | ||||||
|   receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00 |   receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00 | ||||||
|   record_ios: fee1c924aa4879b882ebca2b4bce6011bcfc3d8b |   record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374 | ||||||
|   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c |   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c | ||||||
|   SDWebImage: f29024626962457f3470184232766516dee8dfea |   SDWebImage: f29024626962457f3470184232766516dee8dfea | ||||||
|   share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a |   share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a | ||||||
|   | |||||||
| @@ -1,484 +0,0 @@ | |||||||
| import 'package:dio/dio.dart'; |  | ||||||
| import 'package:island/database/drift_db.dart'; |  | ||||||
| import 'package:island/database/message.dart'; |  | ||||||
| import 'package:island/models/chat.dart'; |  | ||||||
| import 'package:island/models/file.dart'; |  | ||||||
| import 'package:island/services/file.dart'; |  | ||||||
| import 'package:island/widgets/alert.dart'; |  | ||||||
| import 'package:uuid/uuid.dart'; |  | ||||||
|  |  | ||||||
| class MessageRepository { |  | ||||||
|   final SnChatRoom room; |  | ||||||
|   final SnChatMember identity; |  | ||||||
|   final Dio _apiClient; |  | ||||||
|   final AppDatabase _database; |  | ||||||
|  |  | ||||||
|   final Map<String, LocalChatMessage> pendingMessages = {}; |  | ||||||
|   final Map<String, Map<int, double>> fileUploadProgress = {}; |  | ||||||
|   int? _totalCount; |  | ||||||
|  |  | ||||||
|   MessageRepository(this.room, this.identity, this._apiClient, this._database); |  | ||||||
|  |  | ||||||
|   Future<LocalChatMessage?> getLastMessages() async { |  | ||||||
|     final dbMessages = await _database.getMessagesForRoom( |  | ||||||
|       room.id, |  | ||||||
|       offset: 0, |  | ||||||
|       limit: 1, |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     if (dbMessages.isEmpty) { |  | ||||||
|       return null; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     return _database.companionToMessage(dbMessages.first); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<bool> syncMessages() async { |  | ||||||
|     final lastMessage = await getLastMessages(); |  | ||||||
|     if (lastMessage == null) return false; |  | ||||||
|     try { |  | ||||||
|       final resp = await _apiClient.post( |  | ||||||
|         '/sphere/chat/${room.id}/sync', |  | ||||||
|         data: { |  | ||||||
|           'last_sync_timestamp': |  | ||||||
|               lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch, |  | ||||||
|         }, |  | ||||||
|       ); |  | ||||||
|  |  | ||||||
|       final response = MessageSyncResponse.fromJson(resp.data); |  | ||||||
|       for (final change in response.changes) { |  | ||||||
|         switch (change.action) { |  | ||||||
|           case MessageChangeAction.create: |  | ||||||
|             await receiveMessage(change.message!); |  | ||||||
|             break; |  | ||||||
|           case MessageChangeAction.update: |  | ||||||
|             await receiveMessageUpdate(change.message!); |  | ||||||
|             break; |  | ||||||
|           case MessageChangeAction.delete: |  | ||||||
|             await receiveMessageDeletion(change.messageId.toString()); |  | ||||||
|             break; |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|     } catch (err) { |  | ||||||
|       showErrorAlert(err); |  | ||||||
|     } |  | ||||||
|     return true; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<List<LocalChatMessage>> listMessages({ |  | ||||||
|     int offset = 0, |  | ||||||
|     int take = 20, |  | ||||||
|     bool synced = false, |  | ||||||
|   }) async { |  | ||||||
|     try { |  | ||||||
|       // For initial load, fetch latest messages in the background to sync. |  | ||||||
|       if (offset == 0 && !synced) { |  | ||||||
|         // Not awaiting this is intentional, for a quicker UI response. |  | ||||||
|         // The UI should rely on a stream from the database to get updates. |  | ||||||
|         _fetchAndCacheMessages(room.id, offset: 0, take: take).catchError((_) { |  | ||||||
|           // Best effort, errors will be handled by later fetches. |  | ||||||
|           return <LocalChatMessage>[]; |  | ||||||
|         }); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       final localMessages = await _getCachedMessages( |  | ||||||
|         room.id, |  | ||||||
|         offset: offset, |  | ||||||
|         take: take, |  | ||||||
|       ); |  | ||||||
|  |  | ||||||
|       // If local cache has messages, return them. This is the common case for scrolling up. |  | ||||||
|       if (localMessages.isNotEmpty) { |  | ||||||
|         return localMessages; |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       // If local cache is empty, we've probably reached the end of cached history. |  | ||||||
|       // Fetch from remote. This will also be hit on first load if cache is empty. |  | ||||||
|       return await _fetchAndCacheMessages(room.id, offset: offset, take: take); |  | ||||||
|     } catch (e) { |  | ||||||
|       // Final fallback to cache in case of network errors during fetch. |  | ||||||
|       final localMessages = await _getCachedMessages( |  | ||||||
|         room.id, |  | ||||||
|         offset: offset, |  | ||||||
|         take: take, |  | ||||||
|       ); |  | ||||||
|  |  | ||||||
|       if (localMessages.isNotEmpty) { |  | ||||||
|         return localMessages; |  | ||||||
|       } |  | ||||||
|       rethrow; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<List<LocalChatMessage>> _getCachedMessages( |  | ||||||
|     String roomId, { |  | ||||||
|     int offset = 0, |  | ||||||
|     int take = 20, |  | ||||||
|   }) async { |  | ||||||
|     // Get messages from local database |  | ||||||
|     final dbMessages = await _database.getMessagesForRoom( |  | ||||||
|       roomId, |  | ||||||
|       offset: offset, |  | ||||||
|       limit: take, |  | ||||||
|     ); |  | ||||||
|     final dbLocalMessages = |  | ||||||
|         dbMessages.map(_database.companionToMessage).toList(); |  | ||||||
|  |  | ||||||
|     // Combine with pending messages for the first page |  | ||||||
|     if (offset == 0) { |  | ||||||
|       final pendingForRoom = |  | ||||||
|           pendingMessages.values.where((msg) => msg.roomId == roomId).toList(); |  | ||||||
|  |  | ||||||
|       final allMessages = [...pendingForRoom, ...dbLocalMessages]; |  | ||||||
|       allMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt)); |  | ||||||
|  |  | ||||||
|       // Remove duplicates by ID, preserving the order |  | ||||||
|       final uniqueMessages = <LocalChatMessage>[]; |  | ||||||
|       final seenIds = <String>{}; |  | ||||||
|       for (final message in allMessages) { |  | ||||||
|         if (seenIds.add(message.id)) { |  | ||||||
|           uniqueMessages.add(message); |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|       return uniqueMessages; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     return dbLocalMessages; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<List<LocalChatMessage>> _fetchAndCacheMessages( |  | ||||||
|     String roomId, { |  | ||||||
|     int offset = 0, |  | ||||||
|     int take = 20, |  | ||||||
|   }) async { |  | ||||||
|     // Use cached total count if available, otherwise fetch it |  | ||||||
|     if (_totalCount == null) { |  | ||||||
|       final response = await _apiClient.get( |  | ||||||
|         '/sphere/chat/$roomId/messages', |  | ||||||
|         queryParameters: {'offset': 0, 'take': 1}, |  | ||||||
|       ); |  | ||||||
|       _totalCount = int.parse(response.headers['x-total']?.firstOrNull ?? '0'); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (offset >= _totalCount!) { |  | ||||||
|       return []; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     final response = await _apiClient.get( |  | ||||||
|       '/sphere/chat/$roomId/messages', |  | ||||||
|       queryParameters: {'offset': offset, 'take': take}, |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     final List<dynamic> data = response.data; |  | ||||||
|     // Update total count from response headers |  | ||||||
|     _totalCount = int.parse(response.headers['x-total']?.firstOrNull ?? '0'); |  | ||||||
|  |  | ||||||
|     final messages = |  | ||||||
|         data.map((json) { |  | ||||||
|           final remoteMessage = SnChatMessage.fromJson(json); |  | ||||||
|           return LocalChatMessage.fromRemoteMessage( |  | ||||||
|             remoteMessage, |  | ||||||
|             MessageStatus.sent, |  | ||||||
|           ); |  | ||||||
|         }).toList(); |  | ||||||
|  |  | ||||||
|     for (final message in messages) { |  | ||||||
|       await _database.saveMessage(_database.messageToCompanion(message)); |  | ||||||
|       if (message.nonce != null) { |  | ||||||
|         pendingMessages.removeWhere( |  | ||||||
|           (_, pendingMsg) => pendingMsg.nonce == message.nonce, |  | ||||||
|         ); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     return messages; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<LocalChatMessage> sendMessage( |  | ||||||
|     String token, |  | ||||||
|     String baseUrl, |  | ||||||
|     String roomId, |  | ||||||
|     String content, |  | ||||||
|     String nonce, { |  | ||||||
|     required List<UniversalFile> attachments, |  | ||||||
|     Map<String, dynamic>? meta, |  | ||||||
|     SnChatMessage? replyingTo, |  | ||||||
|     SnChatMessage? forwardingTo, |  | ||||||
|     SnChatMessage? editingTo, |  | ||||||
|     Function(LocalChatMessage)? onPending, |  | ||||||
|     Function(String, Map<int, double>)? onProgress, |  | ||||||
|   }) async { |  | ||||||
|     // Generate a unique nonce for this message |  | ||||||
|     final nonce = const Uuid().v4(); |  | ||||||
|  |  | ||||||
|     // Create a local message with pending status |  | ||||||
|     final mockMessage = SnChatMessage( |  | ||||||
|       id: 'pending_$nonce', |  | ||||||
|       chatRoomId: roomId, |  | ||||||
|       senderId: identity.id, |  | ||||||
|       content: content, |  | ||||||
|       createdAt: DateTime.now(), |  | ||||||
|       updatedAt: DateTime.now(), |  | ||||||
|       nonce: nonce, |  | ||||||
|       sender: identity, |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     final localMessage = LocalChatMessage.fromRemoteMessage( |  | ||||||
|       mockMessage, |  | ||||||
|       MessageStatus.pending, |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     // Store in memory and database |  | ||||||
|     pendingMessages[localMessage.id] = localMessage; |  | ||||||
|     fileUploadProgress[localMessage.id] = {}; |  | ||||||
|     await _database.saveMessage(_database.messageToCompanion(localMessage)); |  | ||||||
|     onPending?.call(localMessage); |  | ||||||
|  |  | ||||||
|     try { |  | ||||||
|       var cloudAttachments = List.empty(growable: true); |  | ||||||
|       // Upload files |  | ||||||
|       for (var idx = 0; idx < attachments.length; idx++) { |  | ||||||
|         final cloudFile = |  | ||||||
|             await putMediaToCloud( |  | ||||||
|               fileData: attachments[idx], |  | ||||||
|               atk: token, |  | ||||||
|               baseUrl: baseUrl, |  | ||||||
|               filename: attachments[idx].data.name ?? 'Post media', |  | ||||||
|               mimetype: |  | ||||||
|                   attachments[idx].data.mimeType ?? |  | ||||||
|                   switch (attachments[idx].type) { |  | ||||||
|                     UniversalFileType.image => 'image/unknown', |  | ||||||
|                     UniversalFileType.video => 'video/unknown', |  | ||||||
|                     UniversalFileType.audio => 'audio/unknown', |  | ||||||
|                     UniversalFileType.file => 'application/octet-stream', |  | ||||||
|                   }, |  | ||||||
|               onProgress: (progress, _) { |  | ||||||
|                 fileUploadProgress[localMessage.id]?[idx] = progress; |  | ||||||
|                 onProgress?.call( |  | ||||||
|                   localMessage.id, |  | ||||||
|                   fileUploadProgress[localMessage.id] ?? {}, |  | ||||||
|                 ); |  | ||||||
|               }, |  | ||||||
|             ).future; |  | ||||||
|         if (cloudFile == null) { |  | ||||||
|           throw ArgumentError('Failed to upload the file...'); |  | ||||||
|         } |  | ||||||
|         cloudAttachments.add(cloudFile); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       // Send to server |  | ||||||
|       final response = await _apiClient.request( |  | ||||||
|         editingTo == null |  | ||||||
|             ? '/sphere/chat/$roomId/messages' |  | ||||||
|             : '/sphere/chat/$roomId/messages/${editingTo.id}', |  | ||||||
|         data: { |  | ||||||
|           'content': content, |  | ||||||
|           'attachments_id': cloudAttachments.map((e) => e.id).toList(), |  | ||||||
|           'replied_message_id': replyingTo?.id, |  | ||||||
|           'forwarded_message_id': forwardingTo?.id, |  | ||||||
|           'meta': meta, |  | ||||||
|           'nonce': nonce, |  | ||||||
|         }, |  | ||||||
|         options: Options(method: editingTo == null ? 'POST' : 'PATCH'), |  | ||||||
|       ); |  | ||||||
|  |  | ||||||
|       // Update with server response |  | ||||||
|       final remoteMessage = SnChatMessage.fromJson(response.data); |  | ||||||
|       final updatedMessage = LocalChatMessage.fromRemoteMessage( |  | ||||||
|         remoteMessage, |  | ||||||
|         MessageStatus.sent, |  | ||||||
|       ); |  | ||||||
|  |  | ||||||
|       // Remove from pending and update in database |  | ||||||
|       pendingMessages.remove(localMessage.id); |  | ||||||
|       await _database.deleteMessage(localMessage.id); |  | ||||||
|       await _database.saveMessage(_database.messageToCompanion(updatedMessage)); |  | ||||||
|  |  | ||||||
|       return updatedMessage; |  | ||||||
|     } catch (e) { |  | ||||||
|       // Update status to failed |  | ||||||
|       localMessage.status = MessageStatus.failed; |  | ||||||
|       pendingMessages[localMessage.id] = localMessage; |  | ||||||
|       await _database.updateMessageStatus( |  | ||||||
|         localMessage.id, |  | ||||||
|         MessageStatus.failed, |  | ||||||
|       ); |  | ||||||
|       rethrow; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<LocalChatMessage> retryMessage(String pendingMessageId) async { |  | ||||||
|     final message = await getMessageById(pendingMessageId); |  | ||||||
|     if (message == null) { |  | ||||||
|       throw Exception('Message not found'); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Update status back to pending |  | ||||||
|     message.status = MessageStatus.pending; |  | ||||||
|     pendingMessages[pendingMessageId] = message; |  | ||||||
|     await _database.updateMessageStatus( |  | ||||||
|       pendingMessageId, |  | ||||||
|       MessageStatus.pending, |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     try { |  | ||||||
|       // Send to server |  | ||||||
|       var remoteMessage = message.toRemoteMessage(); |  | ||||||
|       final response = await _apiClient.post( |  | ||||||
|         '/sphere/chat/${message.roomId}/messages', |  | ||||||
|         data: { |  | ||||||
|           'content': remoteMessage.content, |  | ||||||
|           'attachments_id': remoteMessage.attachments, |  | ||||||
|           'meta': remoteMessage.meta, |  | ||||||
|           'nonce': message.nonce, |  | ||||||
|         }, |  | ||||||
|       ); |  | ||||||
|  |  | ||||||
|       // Update with server response |  | ||||||
|       remoteMessage = SnChatMessage.fromJson(response.data); |  | ||||||
|       final updatedMessage = LocalChatMessage.fromRemoteMessage( |  | ||||||
|         remoteMessage, |  | ||||||
|         MessageStatus.sent, |  | ||||||
|       ); |  | ||||||
|  |  | ||||||
|       // Remove from pending and update in database |  | ||||||
|       pendingMessages.remove(pendingMessageId); |  | ||||||
|       await _database.deleteMessage(pendingMessageId); |  | ||||||
|       await _database.saveMessage(_database.messageToCompanion(updatedMessage)); |  | ||||||
|  |  | ||||||
|       return updatedMessage; |  | ||||||
|     } catch (e) { |  | ||||||
|       // Update status to failed |  | ||||||
|       message.status = MessageStatus.failed; |  | ||||||
|       pendingMessages[pendingMessageId] = message; |  | ||||||
|       await _database.updateMessageStatus( |  | ||||||
|         pendingMessageId, |  | ||||||
|         MessageStatus.failed, |  | ||||||
|       ); |  | ||||||
|       rethrow; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<LocalChatMessage> receiveMessage(SnChatMessage remoteMessage) async { |  | ||||||
|     final localMessage = LocalChatMessage.fromRemoteMessage( |  | ||||||
|       remoteMessage, |  | ||||||
|       MessageStatus.sent, |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     if (remoteMessage.nonce != null) { |  | ||||||
|       pendingMessages.removeWhere( |  | ||||||
|         (_, pendingMsg) => pendingMsg.nonce == remoteMessage.nonce, |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     await _database.saveMessage(_database.messageToCompanion(localMessage)); |  | ||||||
|     return localMessage; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<LocalChatMessage> receiveMessageUpdate( |  | ||||||
|     SnChatMessage remoteMessage, |  | ||||||
|   ) async { |  | ||||||
|     final localMessage = LocalChatMessage.fromRemoteMessage( |  | ||||||
|       remoteMessage, |  | ||||||
|       MessageStatus.sent, |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     await _database.updateMessage(_database.messageToCompanion(localMessage)); |  | ||||||
|     return localMessage; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<void> receiveMessageDeletion(String messageId) async { |  | ||||||
|     // Remove from pending messages if exists |  | ||||||
|     pendingMessages.remove(messageId); |  | ||||||
|  |  | ||||||
|     // Delete from local database |  | ||||||
|     await _database.deleteMessage(messageId); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<LocalChatMessage> updateMessage( |  | ||||||
|     String messageId, |  | ||||||
|     String content, { |  | ||||||
|     List<SnCloudFile>? attachments, |  | ||||||
|     Map<String, dynamic>? meta, |  | ||||||
|   }) async { |  | ||||||
|     final message = pendingMessages[messageId]; |  | ||||||
|     if (message != null) { |  | ||||||
|       // Update pending message |  | ||||||
|       final rmMessage = message.toRemoteMessage(); |  | ||||||
|       final updatedRemoteMessage = rmMessage.copyWith( |  | ||||||
|         content: content, |  | ||||||
|         meta: meta ?? rmMessage.meta, |  | ||||||
|       ); |  | ||||||
|       final updatedLocalMessage = LocalChatMessage.fromRemoteMessage( |  | ||||||
|         updatedRemoteMessage, |  | ||||||
|         MessageStatus.pending, |  | ||||||
|       ); |  | ||||||
|       pendingMessages[messageId] = updatedLocalMessage; |  | ||||||
|       await _database.updateMessage( |  | ||||||
|         _database.messageToCompanion(updatedLocalMessage), |  | ||||||
|       ); |  | ||||||
|       return message; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     try { |  | ||||||
|       // Update on server |  | ||||||
|       final response = await _apiClient.put( |  | ||||||
|         '/sphere/chat/${room.id}/messages/$messageId', |  | ||||||
|         data: {'content': content, 'attachments': attachments, 'meta': meta}, |  | ||||||
|       ); |  | ||||||
|  |  | ||||||
|       // Update local copy |  | ||||||
|       final remoteMessage = SnChatMessage.fromJson(response.data); |  | ||||||
|       final updatedMessage = LocalChatMessage.fromRemoteMessage( |  | ||||||
|         remoteMessage, |  | ||||||
|         MessageStatus.sent, |  | ||||||
|       ); |  | ||||||
|       await _database.updateMessage( |  | ||||||
|         _database.messageToCompanion(updatedMessage), |  | ||||||
|       ); |  | ||||||
|       return updatedMessage; |  | ||||||
|     } catch (e) { |  | ||||||
|       rethrow; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<void> deleteMessage(String messageId) async { |  | ||||||
|     try { |  | ||||||
|       await _apiClient.delete('/sphere/chat/${room.id}/messages/$messageId'); |  | ||||||
|       pendingMessages.remove(messageId); |  | ||||||
|       await _database.deleteMessage(messageId); |  | ||||||
|     } catch (e) { |  | ||||||
|       rethrow; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<LocalChatMessage?> getMessageById(String messageId) async { |  | ||||||
|     try { |  | ||||||
|       // Attempt to get the message from the local database |  | ||||||
|       final localMessage = |  | ||||||
|           await (_database.select(_database.chatMessages) |  | ||||||
|             ..where((tbl) => tbl.id.equals(messageId))).getSingleOrNull(); |  | ||||||
|       if (localMessage != null) { |  | ||||||
|         return _database.companionToMessage(localMessage); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       // If not found locally, fetch from the server |  | ||||||
|       final response = await _apiClient.get( |  | ||||||
|         '/sphere/chat/${room.id}/messages/$messageId', |  | ||||||
|       ); |  | ||||||
|       final remoteMessage = SnChatMessage.fromJson(response.data); |  | ||||||
|       final message = LocalChatMessage.fromRemoteMessage( |  | ||||||
|         remoteMessage, |  | ||||||
|         MessageStatus.sent, |  | ||||||
|       ); |  | ||||||
|  |  | ||||||
|       // Save the fetched message to the local database |  | ||||||
|       await _database.saveMessage(_database.messageToCompanion(message)); |  | ||||||
|       return message; |  | ||||||
|     } catch (e) { |  | ||||||
|       if (e is DioException) return null; |  | ||||||
|       // Handle errors |  | ||||||
|       rethrow; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| @@ -11,8 +11,8 @@ sealed class SnScrappedLink with _$SnScrappedLink { | |||||||
|     required String title, |     required String title, | ||||||
|     required String? description, |     required String? description, | ||||||
|     required String? imageUrl, |     required String? imageUrl, | ||||||
|     required String faviconUrl, |     required String? faviconUrl, | ||||||
|     required String siteName, |     required String? siteName, | ||||||
|     required String? contentType, |     required String? contentType, | ||||||
|     required String? author, |     required String? author, | ||||||
|     required DateTime? publishedDate, |     required DateTime? publishedDate, | ||||||
|   | |||||||
| @@ -15,7 +15,7 @@ T _$identity<T>(T value) => value; | |||||||
| /// @nodoc | /// @nodoc | ||||||
| mixin _$SnScrappedLink { | mixin _$SnScrappedLink { | ||||||
|  |  | ||||||
|  String get type; String get url; String get title; String? get description; String? get imageUrl; String get faviconUrl; String get siteName; String? get contentType; String? get author; DateTime? get publishedDate; |  String get type; String get url; String get title; String? get description; String? get imageUrl; String? get faviconUrl; String? get siteName; String? get contentType; String? get author; DateTime? get publishedDate; | ||||||
| /// Create a copy of SnScrappedLink | /// Create a copy of SnScrappedLink | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @JsonKey(includeFromJson: false, includeToJson: false) | @JsonKey(includeFromJson: false, includeToJson: false) | ||||||
| @@ -48,7 +48,7 @@ abstract mixin class $SnScrappedLinkCopyWith<$Res>  { | |||||||
|   factory $SnScrappedLinkCopyWith(SnScrappedLink value, $Res Function(SnScrappedLink) _then) = _$SnScrappedLinkCopyWithImpl; |   factory $SnScrappedLinkCopyWith(SnScrappedLink value, $Res Function(SnScrappedLink) _then) = _$SnScrappedLinkCopyWithImpl; | ||||||
| @useResult | @useResult | ||||||
| $Res call({ | $Res call({ | ||||||
|  String type, String url, String title, String? description, String? imageUrl, String faviconUrl, String siteName, String? contentType, String? author, DateTime? publishedDate |  String type, String url, String title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -65,16 +65,16 @@ class _$SnScrappedLinkCopyWithImpl<$Res> | |||||||
|  |  | ||||||
| /// Create a copy of SnScrappedLink | /// Create a copy of SnScrappedLink | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? url = null,Object? title = null,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = null,Object? siteName = null,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) { | @pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? url = null,Object? title = null,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = freezed,Object? siteName = freezed,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) { | ||||||
|   return _then(_self.copyWith( |   return _then(_self.copyWith( | ||||||
| type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||||
| as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable | as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable | ||||||
| as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable | as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable | ||||||
| as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable | as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable | ||||||
| as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable | as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable | ||||||
| as String?,faviconUrl: null == faviconUrl ? _self.faviconUrl : faviconUrl // ignore: cast_nullable_to_non_nullable | as String?,faviconUrl: freezed == faviconUrl ? _self.faviconUrl : faviconUrl // ignore: cast_nullable_to_non_nullable | ||||||
| as String,siteName: null == siteName ? _self.siteName : siteName // ignore: cast_nullable_to_non_nullable | as String?,siteName: freezed == siteName ? _self.siteName : siteName // ignore: cast_nullable_to_non_nullable | ||||||
| as String,contentType: freezed == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable | as String?,contentType: freezed == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable | ||||||
| as String?,author: freezed == author ? _self.author : author // ignore: cast_nullable_to_non_nullable | as String?,author: freezed == author ? _self.author : author // ignore: cast_nullable_to_non_nullable | ||||||
| as String?,publishedDate: freezed == publishedDate ? _self.publishedDate : publishedDate // ignore: cast_nullable_to_non_nullable | as String?,publishedDate: freezed == publishedDate ? _self.publishedDate : publishedDate // ignore: cast_nullable_to_non_nullable | ||||||
| as DateTime?, | as DateTime?, | ||||||
| @@ -159,7 +159,7 @@ return $default(_that);case _: | |||||||
| /// } | /// } | ||||||
| /// ``` | /// ``` | ||||||
|  |  | ||||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String type,  String url,  String title,  String? description,  String? imageUrl,  String faviconUrl,  String siteName,  String? contentType,  String? author,  DateTime? publishedDate)?  $default,{required TResult orElse(),}) {final _that = this; | @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String type,  String url,  String title,  String? description,  String? imageUrl,  String? faviconUrl,  String? siteName,  String? contentType,  String? author,  DateTime? publishedDate)?  $default,{required TResult orElse(),}) {final _that = this; | ||||||
| switch (_that) { | switch (_that) { | ||||||
| case _SnScrappedLink() when $default != null: | case _SnScrappedLink() when $default != null: | ||||||
| return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUrl,_that.faviconUrl,_that.siteName,_that.contentType,_that.author,_that.publishedDate);case _: | return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUrl,_that.faviconUrl,_that.siteName,_that.contentType,_that.author,_that.publishedDate);case _: | ||||||
| @@ -180,7 +180,7 @@ return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUr | |||||||
| /// } | /// } | ||||||
| /// ``` | /// ``` | ||||||
|  |  | ||||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String type,  String url,  String title,  String? description,  String? imageUrl,  String faviconUrl,  String siteName,  String? contentType,  String? author,  DateTime? publishedDate)  $default,) {final _that = this; | @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String type,  String url,  String title,  String? description,  String? imageUrl,  String? faviconUrl,  String? siteName,  String? contentType,  String? author,  DateTime? publishedDate)  $default,) {final _that = this; | ||||||
| switch (_that) { | switch (_that) { | ||||||
| case _SnScrappedLink(): | case _SnScrappedLink(): | ||||||
| return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUrl,_that.faviconUrl,_that.siteName,_that.contentType,_that.author,_that.publishedDate);} | return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUrl,_that.faviconUrl,_that.siteName,_that.contentType,_that.author,_that.publishedDate);} | ||||||
| @@ -197,7 +197,7 @@ return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUr | |||||||
| /// } | /// } | ||||||
| /// ``` | /// ``` | ||||||
|  |  | ||||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String type,  String url,  String title,  String? description,  String? imageUrl,  String faviconUrl,  String siteName,  String? contentType,  String? author,  DateTime? publishedDate)?  $default,) {final _that = this; | @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String type,  String url,  String title,  String? description,  String? imageUrl,  String? faviconUrl,  String? siteName,  String? contentType,  String? author,  DateTime? publishedDate)?  $default,) {final _that = this; | ||||||
| switch (_that) { | switch (_that) { | ||||||
| case _SnScrappedLink() when $default != null: | case _SnScrappedLink() when $default != null: | ||||||
| return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUrl,_that.faviconUrl,_that.siteName,_that.contentType,_that.author,_that.publishedDate);case _: | return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUrl,_that.faviconUrl,_that.siteName,_that.contentType,_that.author,_that.publishedDate);case _: | ||||||
| @@ -220,8 +220,8 @@ class _SnScrappedLink implements SnScrappedLink { | |||||||
| @override final  String title; | @override final  String title; | ||||||
| @override final  String? description; | @override final  String? description; | ||||||
| @override final  String? imageUrl; | @override final  String? imageUrl; | ||||||
| @override final  String faviconUrl; | @override final  String? faviconUrl; | ||||||
| @override final  String siteName; | @override final  String? siteName; | ||||||
| @override final  String? contentType; | @override final  String? contentType; | ||||||
| @override final  String? author; | @override final  String? author; | ||||||
| @override final  DateTime? publishedDate; | @override final  DateTime? publishedDate; | ||||||
| @@ -259,7 +259,7 @@ abstract mixin class _$SnScrappedLinkCopyWith<$Res> implements $SnScrappedLinkCo | |||||||
|   factory _$SnScrappedLinkCopyWith(_SnScrappedLink value, $Res Function(_SnScrappedLink) _then) = __$SnScrappedLinkCopyWithImpl; |   factory _$SnScrappedLinkCopyWith(_SnScrappedLink value, $Res Function(_SnScrappedLink) _then) = __$SnScrappedLinkCopyWithImpl; | ||||||
| @override @useResult | @override @useResult | ||||||
| $Res call({ | $Res call({ | ||||||
|  String type, String url, String title, String? description, String? imageUrl, String faviconUrl, String siteName, String? contentType, String? author, DateTime? publishedDate |  String type, String url, String title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -276,16 +276,16 @@ class __$SnScrappedLinkCopyWithImpl<$Res> | |||||||
|  |  | ||||||
| /// Create a copy of SnScrappedLink | /// Create a copy of SnScrappedLink | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? url = null,Object? title = null,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = null,Object? siteName = null,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) { | @override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? url = null,Object? title = null,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = freezed,Object? siteName = freezed,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) { | ||||||
|   return _then(_SnScrappedLink( |   return _then(_SnScrappedLink( | ||||||
| type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||||
| as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable | as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable | ||||||
| as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable | as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable | ||||||
| as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable | as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable | ||||||
| as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable | as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable | ||||||
| as String?,faviconUrl: null == faviconUrl ? _self.faviconUrl : faviconUrl // ignore: cast_nullable_to_non_nullable | as String?,faviconUrl: freezed == faviconUrl ? _self.faviconUrl : faviconUrl // ignore: cast_nullable_to_non_nullable | ||||||
| as String,siteName: null == siteName ? _self.siteName : siteName // ignore: cast_nullable_to_non_nullable | as String?,siteName: freezed == siteName ? _self.siteName : siteName // ignore: cast_nullable_to_non_nullable | ||||||
| as String,contentType: freezed == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable | as String?,contentType: freezed == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable | ||||||
| as String?,author: freezed == author ? _self.author : author // ignore: cast_nullable_to_non_nullable | as String?,author: freezed == author ? _self.author : author // ignore: cast_nullable_to_non_nullable | ||||||
| as String?,publishedDate: freezed == publishedDate ? _self.publishedDate : publishedDate // ignore: cast_nullable_to_non_nullable | as String?,publishedDate: freezed == publishedDate ? _self.publishedDate : publishedDate // ignore: cast_nullable_to_non_nullable | ||||||
| as DateTime?, | as DateTime?, | ||||||
|   | |||||||
| @@ -13,8 +13,8 @@ _SnScrappedLink _$SnScrappedLinkFromJson(Map<String, dynamic> json) => | |||||||
|       title: json['title'] as String, |       title: json['title'] as String, | ||||||
|       description: json['description'] as String?, |       description: json['description'] as String?, | ||||||
|       imageUrl: json['image_url'] as String?, |       imageUrl: json['image_url'] as String?, | ||||||
|       faviconUrl: json['favicon_url'] as String, |       faviconUrl: json['favicon_url'] as String?, | ||||||
|       siteName: json['site_name'] as String, |       siteName: json['site_name'] as String?, | ||||||
|       contentType: json['content_type'] as String?, |       contentType: json['content_type'] as String?, | ||||||
|       author: json['author'] as String?, |       author: json['author'] as String?, | ||||||
|       publishedDate: |       publishedDate: | ||||||
|   | |||||||
| @@ -23,6 +23,8 @@ const kAppSoundEffects = 'app_sound_effects'; | |||||||
| const kAppAprilFoolFeatures = 'app_april_fool_features'; | const kAppAprilFoolFeatures = 'app_april_fool_features'; | ||||||
| const kAppWindowSize = 'app_window_size'; | const kAppWindowSize = 'app_window_size'; | ||||||
| const kAppEnterToSend = 'app_enter_to_send'; | const kAppEnterToSend = 'app_enter_to_send'; | ||||||
|  | const kFeaturedPostsCollapsedId = | ||||||
|  |     'featured_posts_collapsed_id'; // Key for storing the ID of the collapsed featured post | ||||||
|  |  | ||||||
| const Map<String, FilterQuality> kImageQualityLevel = { | const Map<String, FilterQuality> kImageQualityLevel = { | ||||||
|   'settingsImageQualityLowest': FilterQuality.none, |   'settingsImageQualityLowest': FilterQuality.none, | ||||||
|   | |||||||
| @@ -1,6 +1,8 @@ | |||||||
|  | import 'dart:io' show Platform; | ||||||
|  | import 'package:animations/animations.dart'; | ||||||
| import 'package:firebase_analytics/firebase_analytics.dart'; | import 'package:firebase_analytics/firebase_analytics.dart'; | ||||||
|  |  | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter/foundation.dart' show kIsWeb; | ||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
| import 'package:island/screens/about.dart'; | import 'package:island/screens/about.dart'; | ||||||
| @@ -56,12 +58,34 @@ final rootNavigatorKey = GlobalKey<NavigatorState>(); | |||||||
| final _shellNavigatorKey = GlobalKey<NavigatorState>(); | final _shellNavigatorKey = GlobalKey<NavigatorState>(); | ||||||
| final _tabsShellKey = GlobalKey<NavigatorState>(); | final _tabsShellKey = GlobalKey<NavigatorState>(); | ||||||
|  |  | ||||||
|  | Widget _tabPagesTransitionBuilder( | ||||||
|  |   BuildContext context, | ||||||
|  |   Animation<double> animation, | ||||||
|  |   Animation<double> secondaryAnimation, | ||||||
|  |   Widget child, | ||||||
|  | ) { | ||||||
|  |   return FadeThroughTransition( | ||||||
|  |     animation: animation, | ||||||
|  |     secondaryAnimation: secondaryAnimation, | ||||||
|  |     fillColor: Theme.of(context).colorScheme.surface, | ||||||
|  |     child: child, | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | bool get _supportsAnalytics => | ||||||
|  |     kIsWeb || | ||||||
|  |     Platform.isAndroid || | ||||||
|  |     Platform.isIOS || | ||||||
|  |     Platform.isMacOS || | ||||||
|  |     Platform.isWindows; | ||||||
|  |  | ||||||
| // Provider for the router | // Provider for the router | ||||||
| final routerProvider = Provider<GoRouter>((ref) { | final routerProvider = Provider<GoRouter>((ref) { | ||||||
|   return GoRouter( |   return GoRouter( | ||||||
|     navigatorKey: rootNavigatorKey, |     navigatorKey: rootNavigatorKey, | ||||||
|     initialLocation: '/', |     initialLocation: '/', | ||||||
|     observers: [ |     observers: [ | ||||||
|  |       if (_supportsAnalytics) | ||||||
|         FirebaseAnalyticsObserver(analytics: FirebaseAnalytics.instance), |         FirebaseAnalyticsObserver(analytics: FirebaseAnalytics.instance), | ||||||
|     ], |     ], | ||||||
|     routes: [ |     routes: [ | ||||||
| @@ -339,7 +363,12 @@ final routerProvider = Provider<GoRouter>((ref) { | |||||||
|               GoRoute( |               GoRoute( | ||||||
|                 name: 'explore', |                 name: 'explore', | ||||||
|                 path: '/', |                 path: '/', | ||||||
|                 builder: (context, state) => const ExploreScreen(), |                 pageBuilder: | ||||||
|  |                     (context, state) => CustomTransitionPage( | ||||||
|  |                       key: const ValueKey('explore'), | ||||||
|  |                       child: const ExploreScreen(), | ||||||
|  |                       transitionsBuilder: _tabPagesTransitionBuilder, | ||||||
|  |                     ), | ||||||
|               ), |               ), | ||||||
|               GoRoute( |               GoRoute( | ||||||
|                 name: 'postSearch', |                 name: 'postSearch', | ||||||
| @@ -389,8 +418,12 @@ final routerProvider = Provider<GoRouter>((ref) { | |||||||
|  |  | ||||||
|               // Chat tab |               // Chat tab | ||||||
|               ShellRoute( |               ShellRoute( | ||||||
|                 builder: |                 pageBuilder: | ||||||
|                     (context, state, child) => ChatShellScreen(child: child), |                     (context, state, child) => CustomTransitionPage( | ||||||
|  |                       key: const ValueKey('chat'), | ||||||
|  |                       child: ChatShellScreen(child: child), | ||||||
|  |                       transitionsBuilder: _tabPagesTransitionBuilder, | ||||||
|  |                     ), | ||||||
|                 routes: [ |                 routes: [ | ||||||
|                   GoRoute( |                   GoRoute( | ||||||
|                     name: 'chatList', |                     name: 'chatList', | ||||||
| @@ -433,7 +466,12 @@ final routerProvider = Provider<GoRouter>((ref) { | |||||||
|               GoRoute( |               GoRoute( | ||||||
|                 name: 'realmList', |                 name: 'realmList', | ||||||
|                 path: '/realms', |                 path: '/realms', | ||||||
|                 builder: (context, state) => const RealmListScreen(), |                 pageBuilder: | ||||||
|  |                     (context, state) => CustomTransitionPage( | ||||||
|  |                       key: const ValueKey('realms'), | ||||||
|  |                       child: const RealmListScreen(), | ||||||
|  |                       transitionsBuilder: _tabPagesTransitionBuilder, | ||||||
|  |                     ), | ||||||
|                 routes: [ |                 routes: [ | ||||||
|                   GoRoute( |                   GoRoute( | ||||||
|                     name: 'realmNew', |                     name: 'realmNew', | ||||||
| @@ -461,8 +499,12 @@ final routerProvider = Provider<GoRouter>((ref) { | |||||||
|  |  | ||||||
|               // Account tab |               // Account tab | ||||||
|               ShellRoute( |               ShellRoute( | ||||||
|                 builder: |                 pageBuilder: | ||||||
|                     (context, state, child) => AccountShellScreen(child: child), |                     (context, state, child) => CustomTransitionPage( | ||||||
|  |                       key: const ValueKey('account'), | ||||||
|  |                       child: AccountShellScreen(child: child), | ||||||
|  |                       transitionsBuilder: _tabPagesTransitionBuilder, | ||||||
|  |                     ), | ||||||
|                 routes: [ |                 routes: [ | ||||||
|                   GoRoute( |                   GoRoute( | ||||||
|                     name: 'account', |                     name: 'account', | ||||||
|   | |||||||
| @@ -178,7 +178,8 @@ class _AboutScreenState extends ConsumerState<AboutScreen> { | |||||||
|                                 context, |                                 context, | ||||||
|                                 icon: Symbols.label, |                                 icon: Symbols.label, | ||||||
|                                 label: 'aboutDeviceName'.tr(), |                                 label: 'aboutDeviceName'.tr(), | ||||||
|                                 value: _deviceInfo?.data['name'], |                                 value: | ||||||
|  |                                     _deviceInfo?.data['name'] ?? 'unknown'.tr(), | ||||||
|                               ), |                               ), | ||||||
|                               _buildInfoItem( |                               _buildInfoItem( | ||||||
|                                 context, |                                 context, | ||||||
|   | |||||||
| @@ -1,6 +1,8 @@ | |||||||
| import 'dart:async'; | import 'dart:async'; | ||||||
| import 'dart:convert'; | import 'dart:convert'; | ||||||
|  | import 'dart:developer' as developer; | ||||||
| import 'dart:io'; | import 'dart:io'; | ||||||
|  | import 'package:dio/dio.dart'; | ||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/foundation.dart'; | import 'package:flutter/foundation.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| @@ -10,14 +12,15 @@ import 'package:flutter_hooks/flutter_hooks.dart'; | |||||||
| import 'package:gap/gap.dart'; | import 'package:gap/gap.dart'; | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
| import 'package:image_picker/image_picker.dart'; | import 'package:image_picker/image_picker.dart'; | ||||||
|  | import 'package:island/database/drift_db.dart'; | ||||||
| import 'package:island/database/message.dart'; | import 'package:island/database/message.dart'; | ||||||
| import 'package:island/database/message_repository.dart'; |  | ||||||
| import 'package:island/models/chat.dart'; | import 'package:island/models/chat.dart'; | ||||||
| import 'package:island/models/file.dart'; | import 'package:island/models/file.dart'; | ||||||
| import 'package:island/pods/config.dart'; | import 'package:island/pods/config.dart'; | ||||||
| import 'package:island/pods/database.dart'; | import 'package:island/pods/database.dart'; | ||||||
| import 'package:island/pods/network.dart'; | import 'package:island/pods/network.dart'; | ||||||
| import 'package:island/pods/websocket.dart'; | import 'package:island/pods/websocket.dart'; | ||||||
|  | import 'package:island/services/file.dart'; | ||||||
| import 'package:island/services/responsive.dart'; | import 'package:island/services/responsive.dart'; | ||||||
| import 'package:island/widgets/alert.dart'; | import 'package:island/widgets/alert.dart'; | ||||||
| import 'package:island/widgets/app_scaffold.dart'; | import 'package:island/widgets/app_scaffold.dart'; | ||||||
| @@ -39,17 +42,46 @@ import 'package:island/widgets/stickers/picker.dart'; | |||||||
|  |  | ||||||
| part 'room.g.dart'; | part 'room.g.dart'; | ||||||
|  |  | ||||||
| final messageRepositoryProvider = | final isSyncingProvider = StateProvider.autoDispose<bool>((ref) => false); | ||||||
|     FutureProvider.family<MessageRepository, String>((ref, roomId) async { |  | ||||||
|       final room = await ref.watch(chatroomProvider(roomId).future); | final appLifecycleStateProvider = StreamProvider<AppLifecycleState>((ref) { | ||||||
|       final identity = await ref.watch(chatroomIdentityProvider(roomId).future); |   final controller = StreamController<AppLifecycleState>(); | ||||||
|       final apiClient = ref.watch(apiClientProvider); |  | ||||||
|       final database = ref.watch(databaseProvider); |   final observer = _AppLifecycleObserver((state) { | ||||||
|       return MessageRepository(room!, identity!, apiClient, database); |     if (controller.isClosed) return; | ||||||
|  |     controller.add(state); | ||||||
|   }); |   }); | ||||||
|  |   WidgetsBinding.instance.addObserver(observer); | ||||||
|  |  | ||||||
|  |   ref.onDispose(() { | ||||||
|  |     WidgetsBinding.instance.removeObserver(observer); | ||||||
|  |     controller.close(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   return controller.stream; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | class _AppLifecycleObserver extends WidgetsBindingObserver { | ||||||
|  |   final ValueChanged<AppLifecycleState> onChange; | ||||||
|  |   _AppLifecycleObserver(this.onChange); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void didChangeAppLifecycleState(AppLifecycleState state) { | ||||||
|  |     onChange(state); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
| @riverpod | @riverpod | ||||||
| class MessagesNotifier extends _$MessagesNotifier { | class MessagesNotifier extends _$MessagesNotifier { | ||||||
|  |   late final Dio _apiClient; | ||||||
|  |   late final AppDatabase _database; | ||||||
|  |   late final SnChatRoom _room; | ||||||
|  |   late final SnChatMember _identity; | ||||||
|  |  | ||||||
|  |   final Map<String, LocalChatMessage> _pendingMessages = {}; | ||||||
|  |   final Map<String, Map<int, double>> _fileUploadProgress = {}; | ||||||
|  |   int? _totalCount; | ||||||
|  |  | ||||||
|   late final String _roomId; |   late final String _roomId; | ||||||
|   int _currentPage = 0; |   int _currentPage = 0; | ||||||
|   static const int _pageSize = 20; |   static const int _pageSize = 20; | ||||||
| @@ -58,38 +90,209 @@ class MessagesNotifier extends _$MessagesNotifier { | |||||||
|   @override |   @override | ||||||
|   FutureOr<List<LocalChatMessage>> build(String roomId) async { |   FutureOr<List<LocalChatMessage>> build(String roomId) async { | ||||||
|     _roomId = roomId; |     _roomId = roomId; | ||||||
|  |     _apiClient = ref.watch(apiClientProvider); | ||||||
|  |     _database = ref.watch(databaseProvider); | ||||||
|  |     final room = await ref.watch(chatroomProvider(roomId).future); | ||||||
|  |     final identity = await ref.watch(chatroomIdentityProvider(roomId).future); | ||||||
|  |     if (room == null || identity == null) { | ||||||
|  |       throw Exception('Room or identity not found'); | ||||||
|  |     } | ||||||
|  |     _room = room; | ||||||
|  |     _identity = identity; | ||||||
|  |  | ||||||
|  |     developer.log('MessagesNotifier built for room $roomId', name: 'MessagesNotifier'); | ||||||
|  |  | ||||||
|  |     ref.listen(appLifecycleStateProvider, (_, next) { | ||||||
|  |       if (next.hasValue && next.value == AppLifecycleState.resumed) { | ||||||
|  |         developer.log('App resumed, syncing messages', name: 'MessagesNotifier'); | ||||||
|  |         syncMessages(); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |  | ||||||
|     return await loadInitial(); |     return await loadInitial(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<List<LocalChatMessage>> loadInitial() async { |   Future<List<LocalChatMessage>> _getCachedMessages({ | ||||||
|     try { |     int offset = 0, | ||||||
|       final repository = await ref.read( |     int take = 20, | ||||||
|         messageRepositoryProvider(_roomId).future, |   }) async { | ||||||
|  |     developer.log('Getting cached messages from offset $offset, take $take', name: 'MessagesNotifier'); | ||||||
|  |     final dbMessages = await _database.getMessagesForRoom( | ||||||
|  |       _roomId, | ||||||
|  |       offset: offset, | ||||||
|  |       limit: take, | ||||||
|     ); |     ); | ||||||
|       final synced = await repository.syncMessages(); |     final dbLocalMessages = | ||||||
|       final messages = await repository.listMessages( |         dbMessages.map(_database.companionToMessage).toList(); | ||||||
|         offset: 0, |  | ||||||
|         take: _pageSize, |     if (offset == 0) { | ||||||
|         synced: synced, |       final pendingForRoom = | ||||||
|  |           _pendingMessages.values.where((msg) => msg.roomId == _roomId).toList(); | ||||||
|  |  | ||||||
|  |       final allMessages = [...pendingForRoom, ...dbLocalMessages]; | ||||||
|  |       allMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt)); | ||||||
|  |  | ||||||
|  |       final uniqueMessages = <LocalChatMessage>[]; | ||||||
|  |       final seenIds = <String>{}; | ||||||
|  |       for (final message in allMessages) { | ||||||
|  |         if (seenIds.add(message.id)) { | ||||||
|  |           uniqueMessages.add(message); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       return uniqueMessages; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return dbLocalMessages; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<List<LocalChatMessage>> _fetchAndCacheMessages({ | ||||||
|  |     int offset = 0, | ||||||
|  |     int take = 20, | ||||||
|  |   }) async { | ||||||
|  |     developer.log('Fetching messages from API, offset $offset, take $take', name: 'MessagesNotifier'); | ||||||
|  |     if (_totalCount == null) { | ||||||
|  |       final response = await _apiClient.get( | ||||||
|  |         '/sphere/chat/$_roomId/messages', | ||||||
|  |         queryParameters: {'offset': 0, 'take': 1}, | ||||||
|       ); |       ); | ||||||
|       _currentPage = 0; |       _totalCount = int.parse(response.headers['x-total']?.firstOrNull ?? '0'); | ||||||
|       _hasMore = messages.length == _pageSize; |     } | ||||||
|  |  | ||||||
|  |     if (offset >= _totalCount!) { | ||||||
|  |       return []; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     final response = await _apiClient.get( | ||||||
|  |       '/sphere/chat/$_roomId/messages', | ||||||
|  |       queryParameters: {'offset': offset, 'take': take}, | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     final List<dynamic> data = response.data; | ||||||
|  |     _totalCount = int.parse(response.headers['x-total']?.firstOrNull ?? '0'); | ||||||
|  |  | ||||||
|  |     final messages = data.map((json) { | ||||||
|  |       final remoteMessage = SnChatMessage.fromJson(json); | ||||||
|  |       return LocalChatMessage.fromRemoteMessage( | ||||||
|  |         remoteMessage, | ||||||
|  |         MessageStatus.sent, | ||||||
|  |       ); | ||||||
|  |     }).toList(); | ||||||
|  |  | ||||||
|  |     for (final message in messages) { | ||||||
|  |       await _database.saveMessage(_database.messageToCompanion(message)); | ||||||
|  |       if (message.nonce != null) { | ||||||
|  |         _pendingMessages.removeWhere( | ||||||
|  |           (_, pendingMsg) => pendingMsg.nonce == message.nonce, | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     return messages; |     return messages; | ||||||
|     } catch (_) { |   } | ||||||
|  |  | ||||||
|  |   Future<void> syncMessages() async { | ||||||
|  |     developer.log('Starting message sync', name: 'MessagesNotifier'); | ||||||
|  |     ref.read(isSyncingProvider.notifier).state = true; | ||||||
|  |     try { | ||||||
|  |       final dbMessages = await _database.getMessagesForRoom( | ||||||
|  |         _room.id, | ||||||
|  |         offset: 0, | ||||||
|  |         limit: 1, | ||||||
|  |       ); | ||||||
|  |       final lastMessage = | ||||||
|  |           dbMessages.isEmpty ? null : _database.companionToMessage(dbMessages.first); | ||||||
|  |  | ||||||
|  |       if (lastMessage == null) { | ||||||
|  |         developer.log('No local messages, fetching from network', name: 'MessagesNotifier'); | ||||||
|  |         final newMessages = await _fetchAndCacheMessages(offset: 0, take: _pageSize); | ||||||
|  |         state = AsyncValue.data(newMessages); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       final resp = await _apiClient.post( | ||||||
|  |         '/sphere/chat/${_room.id}/sync', | ||||||
|  |         data: { | ||||||
|  |           'last_sync_timestamp': | ||||||
|  |               lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch, | ||||||
|  |         }, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       final response = MessageSyncResponse.fromJson(resp.data); | ||||||
|  |       developer.log('Sync response: ${response.changes.length} changes', name: 'MessagesNotifier'); | ||||||
|  |       for (final change in response.changes) { | ||||||
|  |         switch (change.action) { | ||||||
|  |           case MessageChangeAction.create: | ||||||
|  |             await receiveMessage(change.message!); | ||||||
|  |             break; | ||||||
|  |           case MessageChangeAction.update: | ||||||
|  |             await receiveMessageUpdate(change.message!); | ||||||
|  |             break; | ||||||
|  |           case MessageChangeAction.delete: | ||||||
|  |             await receiveMessageDeletion(change.messageId.toString()); | ||||||
|  |             break; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } catch (err, stackTrace) { | ||||||
|  |       developer.log('Error syncing messages', name: 'MessagesNotifier', error: err, stackTrace: stackTrace); | ||||||
|  |       showErrorAlert(err); | ||||||
|  |     } finally { | ||||||
|  |       developer.log('Finished message sync', name: 'MessagesNotifier'); | ||||||
|  |       ref.read(isSyncingProvider.notifier).state = false; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<List<LocalChatMessage>> listMessages({ | ||||||
|  |     int offset = 0, | ||||||
|  |     int take = 20, | ||||||
|  |     bool synced = false, | ||||||
|  |   }) async { | ||||||
|  |     try { | ||||||
|  |       if (offset == 0 && !synced) { | ||||||
|  |         _fetchAndCacheMessages(offset: 0, take: take).catchError((_) { | ||||||
|  |           return <LocalChatMessage>[]; | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       final localMessages = await _getCachedMessages( | ||||||
|  |         offset: offset, | ||||||
|  |         take: take, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       if (localMessages.isNotEmpty) { | ||||||
|  |         return localMessages; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       return await _fetchAndCacheMessages(offset: offset, take: take); | ||||||
|  |     } catch (e) { | ||||||
|  |       final localMessages = await _getCachedMessages( | ||||||
|  |         offset: offset, | ||||||
|  |         take: take, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       if (localMessages.isNotEmpty) { | ||||||
|  |         return localMessages; | ||||||
|  |       } | ||||||
|       rethrow; |       rethrow; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   Future<List<LocalChatMessage>> loadInitial() async { | ||||||
|  |     developer.log('Loading initial messages', name: 'MessagesNotifier'); | ||||||
|  |     syncMessages(); | ||||||
|  |     final messages = await _getCachedMessages(offset: 0, take: _pageSize); | ||||||
|  |     _currentPage = 0; | ||||||
|  |     _hasMore = messages.length == _pageSize; | ||||||
|  |     return messages; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   Future<void> loadMore() async { |   Future<void> loadMore() async { | ||||||
|     if (!_hasMore || state is AsyncLoading) return; |     if (!_hasMore || state is AsyncLoading) return; | ||||||
|  |     developer.log('Loading more messages', name: 'MessagesNotifier'); | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       final currentMessages = state.value ?? []; |       final currentMessages = state.value ?? []; | ||||||
|       _currentPage++; |       _currentPage++; | ||||||
|       final repository = await ref.read( |       final newMessages = await listMessages( | ||||||
|         messageRepositoryProvider(_roomId).future, |  | ||||||
|       ); |  | ||||||
|       final newMessages = await repository.listMessages( |  | ||||||
|         offset: _currentPage * _pageSize, |         offset: _currentPage * _pageSize, | ||||||
|         take: _pageSize, |         take: _pageSize, | ||||||
|       ); |       ); | ||||||
| @@ -99,7 +302,8 @@ class MessagesNotifier extends _$MessagesNotifier { | |||||||
|       } |       } | ||||||
|  |  | ||||||
|       state = AsyncValue.data([...currentMessages, ...newMessages]); |       state = AsyncValue.data([...currentMessages, ...newMessages]); | ||||||
|     } catch (err) { |     } catch (err, stackTrace) { | ||||||
|  |       developer.log('Error loading more messages', name: 'MessagesNotifier', error: err, stackTrace: stackTrace); | ||||||
|       showErrorAlert(err); |       showErrorAlert(err); | ||||||
|       _currentPage--; |       _currentPage--; | ||||||
|     } |     } | ||||||
| @@ -113,77 +317,196 @@ class MessagesNotifier extends _$MessagesNotifier { | |||||||
|     SnChatMessage? replyingTo, |     SnChatMessage? replyingTo, | ||||||
|     Function(String, Map<int, double>)? onProgress, |     Function(String, Map<int, double>)? onProgress, | ||||||
|   }) async { |   }) async { | ||||||
|     try { |     final nonce = const Uuid().v4(); | ||||||
|       final repository = await ref.read( |     developer.log('Sending message with nonce $nonce', name: 'MessagesNotifier'); | ||||||
|         messageRepositoryProvider(_roomId).future, |  | ||||||
|       ); |  | ||||||
|     final baseUrl = ref.read(serverUrlProvider); |     final baseUrl = ref.read(serverUrlProvider); | ||||||
|     final token = await getToken(ref.watch(tokenProvider)); |     final token = await getToken(ref.watch(tokenProvider)); | ||||||
|     if (token == null) throw ArgumentError('Access token is null'); |     if (token == null) throw ArgumentError('Access token is null'); | ||||||
|  |  | ||||||
|       final currentMessages = state.value ?? []; |     final mockMessage = SnChatMessage( | ||||||
|       await repository.sendMessage( |       id: 'pending_$nonce', | ||||||
|         token, |       chatRoomId: _roomId, | ||||||
|         baseUrl, |       senderId: _identity.id, | ||||||
|         _roomId, |       content: content, | ||||||
|         content, |       createdAt: DateTime.now(), | ||||||
|         const Uuid().v4(), |       updatedAt: DateTime.now(), | ||||||
|         attachments: attachments, |       nonce: nonce, | ||||||
|         editingTo: editingTo, |       sender: _identity, | ||||||
|         forwardingTo: forwardingTo, |  | ||||||
|         replyingTo: replyingTo, |  | ||||||
|         onPending: (pending) { |  | ||||||
|           state = AsyncValue.data([pending, ...currentMessages]); |  | ||||||
|         }, |  | ||||||
|         onProgress: onProgress, |  | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|       // Refresh messages |     final localMessage = LocalChatMessage.fromRemoteMessage( | ||||||
|       final messages = await repository.listMessages( |       mockMessage, | ||||||
|         offset: 0, |       MessageStatus.pending, | ||||||
|         take: _pageSize, |  | ||||||
|     ); |     ); | ||||||
|       state = AsyncValue.data(messages); |  | ||||||
|     } catch (err) { |     _pendingMessages[localMessage.id] = localMessage; | ||||||
|       showErrorAlert(err); |     _fileUploadProgress[localMessage.id] = {}; | ||||||
|  |     await _database.saveMessage(_database.messageToCompanion(localMessage)); | ||||||
|  |  | ||||||
|  |     final currentMessages = state.value ?? []; | ||||||
|  |     state = AsyncValue.data([localMessage, ...currentMessages]); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       var cloudAttachments = List.empty(growable: true); | ||||||
|  |       for (var idx = 0; idx < attachments.length; idx++) { | ||||||
|  |         final cloudFile = await putMediaToCloud( | ||||||
|  |           fileData: attachments[idx], | ||||||
|  |           atk: token, | ||||||
|  |           baseUrl: baseUrl, | ||||||
|  |           filename: attachments[idx].data.name ?? 'Post media', | ||||||
|  |           mimetype: attachments[idx].data.mimeType ?? | ||||||
|  |               switch (attachments[idx].type) { | ||||||
|  |                 UniversalFileType.image => 'image/unknown', | ||||||
|  |                 UniversalFileType.video => 'video/unknown', | ||||||
|  |                 UniversalFileType.audio => 'audio/unknown', | ||||||
|  |                 UniversalFileType.file => 'application/octet-stream', | ||||||
|  |               }, | ||||||
|  |           onProgress: (progress, _) { | ||||||
|  |             _fileUploadProgress[localMessage.id]?[idx] = progress; | ||||||
|  |             onProgress?.call( | ||||||
|  |               localMessage.id, | ||||||
|  |               _fileUploadProgress[localMessage.id] ?? {}, | ||||||
|  |             ); | ||||||
|  |           }, | ||||||
|  |         ).future; | ||||||
|  |         if (cloudFile == null) { | ||||||
|  |           throw ArgumentError('Failed to upload the file...'); | ||||||
|  |         } | ||||||
|  |         cloudAttachments.add(cloudFile); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       final response = await _apiClient.request( | ||||||
|  |         editingTo == null | ||||||
|  |             ? '/sphere/chat/$_roomId/messages' | ||||||
|  |             : '/sphere/chat/$_roomId/messages/${editingTo.id}', | ||||||
|  |         data: { | ||||||
|  |           'content': content, | ||||||
|  |           'attachments_id': cloudAttachments.map((e) => e.id).toList(), | ||||||
|  |           'replied_message_id': replyingTo?.id, | ||||||
|  |           'forwarded_message_id': forwardingTo?.id, | ||||||
|  |           'meta': {}, | ||||||
|  |           'nonce': nonce, | ||||||
|  |         }, | ||||||
|  |         options: Options(method: editingTo == null ? 'POST' : 'PATCH'), | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       final remoteMessage = SnChatMessage.fromJson(response.data); | ||||||
|  |       final updatedMessage = LocalChatMessage.fromRemoteMessage( | ||||||
|  |         remoteMessage, | ||||||
|  |         MessageStatus.sent, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       _pendingMessages.remove(localMessage.id); | ||||||
|  |       await _database.deleteMessage(localMessage.id); | ||||||
|  |       await _database.saveMessage(_database.messageToCompanion(updatedMessage)); | ||||||
|  |  | ||||||
|  |       final newMessages = (state.value ?? []).map((m) { | ||||||
|  |         if (m.id == localMessage.id) { | ||||||
|  |           return updatedMessage; | ||||||
|  |         } | ||||||
|  |         return m; | ||||||
|  |       }).toList(); | ||||||
|  |       state = AsyncValue.data(newMessages); | ||||||
|  |       developer.log('Message with nonce $nonce sent successfully', name: 'MessagesNotifier'); | ||||||
|  |     } catch (e, stackTrace) { | ||||||
|  |       developer.log('Failed to send message with nonce $nonce', name: 'MessagesNotifier', error: e, stackTrace: stackTrace); | ||||||
|  |       localMessage.status = MessageStatus.failed; | ||||||
|  |       _pendingMessages[localMessage.id] = localMessage; | ||||||
|  |       await _database.updateMessageStatus( | ||||||
|  |         localMessage.id, | ||||||
|  |         MessageStatus.failed, | ||||||
|  |       ); | ||||||
|  |       final newMessages = (state.value ?? []).map((m) { | ||||||
|  |         if (m.id == localMessage.id) { | ||||||
|  |           return m..status = MessageStatus.failed; | ||||||
|  |         } | ||||||
|  |         return m; | ||||||
|  |       }).toList(); | ||||||
|  |       state = AsyncValue.data(newMessages); | ||||||
|  |       showErrorAlert(e); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> retryMessage(String pendingMessageId) async { |   Future<void> retryMessage(String pendingMessageId) async { | ||||||
|     try { |     developer.log('Retrying message $pendingMessageId', name: 'MessagesNotifier'); | ||||||
|       final repository = await ref.read( |     final message = await fetchMessageById(pendingMessageId); | ||||||
|         messageRepositoryProvider(_roomId).future, |     if (message == null) { | ||||||
|       ); |       throw Exception('Message not found'); | ||||||
|       final updatedMessage = await repository.retryMessage(pendingMessageId); |  | ||||||
|  |  | ||||||
|       // Update the message in the list |  | ||||||
|       final currentMessages = state.value ?? []; |  | ||||||
|       final index = currentMessages.indexWhere((m) => m.id == pendingMessageId); |  | ||||||
|       if (index >= 0) { |  | ||||||
|         final newList = [...currentMessages]; |  | ||||||
|         newList[index] = updatedMessage; |  | ||||||
|         state = AsyncValue.data(newList); |  | ||||||
|     } |     } | ||||||
|     } catch (err) { |  | ||||||
|       showErrorAlert(err); |     message.status = MessageStatus.pending; | ||||||
|  |     _pendingMessages[pendingMessageId] = message; | ||||||
|  |     await _database.updateMessageStatus( | ||||||
|  |       pendingMessageId, | ||||||
|  |       MessageStatus.pending, | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       var remoteMessage = message.toRemoteMessage(); | ||||||
|  |       final response = await _apiClient.post( | ||||||
|  |         '/sphere/chat/${message.roomId}/messages', | ||||||
|  |         data: { | ||||||
|  |           'content': remoteMessage.content, | ||||||
|  |           'attachments_id': remoteMessage.attachments, | ||||||
|  |           'meta': remoteMessage.meta, | ||||||
|  |           'nonce': message.nonce, | ||||||
|  |         }, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       remoteMessage = SnChatMessage.fromJson(response.data); | ||||||
|  |       final updatedMessage = LocalChatMessage.fromRemoteMessage( | ||||||
|  |         remoteMessage, | ||||||
|  |         MessageStatus.sent, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       _pendingMessages.remove(pendingMessageId); | ||||||
|  |       await _database.deleteMessage(pendingMessageId); | ||||||
|  |       await _database.saveMessage(_database.messageToCompanion(updatedMessage)); | ||||||
|  |  | ||||||
|  |       final newMessages = (state.value ?? []).map((m) { | ||||||
|  |         if (m.id == pendingMessageId) { | ||||||
|  |           return updatedMessage; | ||||||
|  |         } | ||||||
|  |         return m; | ||||||
|  |       }).toList(); | ||||||
|  |       state = AsyncValue.data(newMessages); | ||||||
|  |     } catch (e, stackTrace) { | ||||||
|  |       developer.log('Failed to retry message $pendingMessageId', name: 'MessagesNotifier', error: e, stackTrace: stackTrace); | ||||||
|  |       message.status = MessageStatus.failed; | ||||||
|  |       _pendingMessages[pendingMessageId] = message; | ||||||
|  |       await _database.updateMessageStatus( | ||||||
|  |         pendingMessageId, | ||||||
|  |         MessageStatus.failed, | ||||||
|  |       ); | ||||||
|  |       final newMessages = (state.value ?? []).map((m) { | ||||||
|  |         if (m.id == pendingMessageId) { | ||||||
|  |           return m..status = MessageStatus.failed; | ||||||
|  |         } | ||||||
|  |         return m; | ||||||
|  |       }).toList(); | ||||||
|  |       state = AsyncValue.data(newMessages); | ||||||
|  |       showErrorAlert(e); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> receiveMessage(SnChatMessage remoteMessage) async { |   Future<void> receiveMessage(SnChatMessage remoteMessage) async { | ||||||
|     try { |     if (remoteMessage.chatRoomId != _roomId) return; | ||||||
|       final repository = await ref.read( |     developer.log('Received new message ${remoteMessage.id}', name: 'MessagesNotifier'); | ||||||
|         messageRepositoryProvider(_roomId).future, |  | ||||||
|  |     final localMessage = LocalChatMessage.fromRemoteMessage( | ||||||
|  |       remoteMessage, | ||||||
|  |       MessageStatus.sent, | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|       // Skip if this message is not for this room |     if (remoteMessage.nonce != null) { | ||||||
|       if (remoteMessage.chatRoomId != _roomId) return; |       _pendingMessages.removeWhere( | ||||||
|  |         (_, pendingMsg) => pendingMsg.nonce == remoteMessage.nonce, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|       final localMessage = await repository.receiveMessage(remoteMessage); |     await _database.saveMessage(_database.messageToCompanion(localMessage)); | ||||||
|  |  | ||||||
|       // Add the new message to the state |  | ||||||
|     final currentMessages = state.value ?? []; |     final currentMessages = state.value ?? []; | ||||||
|  |  | ||||||
|       // Check if the message already exists (by id or nonce) |  | ||||||
|     final existingIndex = currentMessages.indexWhere( |     final existingIndex = currentMessages.indexWhere( | ||||||
|       (m) => |       (m) => | ||||||
|           m.id == localMessage.id || |           m.id == localMessage.id || | ||||||
| @@ -191,33 +514,24 @@ class MessagesNotifier extends _$MessagesNotifier { | |||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     if (existingIndex >= 0) { |     if (existingIndex >= 0) { | ||||||
|         // Replace existing message |  | ||||||
|       final newList = [...currentMessages]; |       final newList = [...currentMessages]; | ||||||
|       newList[existingIndex] = localMessage; |       newList[existingIndex] = localMessage; | ||||||
|       state = AsyncValue.data(newList); |       state = AsyncValue.data(newList); | ||||||
|     } else { |     } else { | ||||||
|         // Add new message at the beginning (newest first) |  | ||||||
|       state = AsyncValue.data([localMessage, ...currentMessages]); |       state = AsyncValue.data([localMessage, ...currentMessages]); | ||||||
|     } |     } | ||||||
|     } catch (err) { |  | ||||||
|       showErrorAlert(err); |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> receiveMessageUpdate(SnChatMessage remoteMessage) async { |   Future<void> receiveMessageUpdate(SnChatMessage remoteMessage) async { | ||||||
|     try { |  | ||||||
|       final repository = await ref.read( |  | ||||||
|         messageRepositoryProvider(_roomId).future, |  | ||||||
|       ); |  | ||||||
|  |  | ||||||
|       // Skip if this message is not for this room |  | ||||||
|     if (remoteMessage.chatRoomId != _roomId) return; |     if (remoteMessage.chatRoomId != _roomId) return; | ||||||
|  |     developer.log('Received message update ${remoteMessage.id}', name: 'MessagesNotifier'); | ||||||
|  |  | ||||||
|       final updatedMessage = await repository.receiveMessageUpdate( |     final updatedMessage = LocalChatMessage.fromRemoteMessage( | ||||||
|       remoteMessage, |       remoteMessage, | ||||||
|  |       MessageStatus.sent, | ||||||
|     ); |     ); | ||||||
|  |     await _database.updateMessage(_database.messageToCompanion(updatedMessage)); | ||||||
|  |  | ||||||
|       // Update the message in the list |  | ||||||
|     final currentMessages = state.value ?? []; |     final currentMessages = state.value ?? []; | ||||||
|     final index = currentMessages.indexWhere( |     final index = currentMessages.indexWhere( | ||||||
|       (m) => m.id == updatedMessage.id, |       (m) => m.id == updatedMessage.id, | ||||||
| @@ -228,20 +542,13 @@ class MessagesNotifier extends _$MessagesNotifier { | |||||||
|       newList[index] = updatedMessage; |       newList[index] = updatedMessage; | ||||||
|       state = AsyncValue.data(newList); |       state = AsyncValue.data(newList); | ||||||
|     } |     } | ||||||
|     } catch (err) { |  | ||||||
|       showErrorAlert(err); |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> receiveMessageDeletion(String messageId) async { |   Future<void> receiveMessageDeletion(String messageId) async { | ||||||
|     try { |     developer.log('Received message deletion $messageId', name: 'MessagesNotifier'); | ||||||
|       final repository = await ref.read( |     _pendingMessages.remove(messageId); | ||||||
|         messageRepositoryProvider(_roomId).future, |     await _database.deleteMessage(messageId); | ||||||
|       ); |  | ||||||
|  |  | ||||||
|       await repository.receiveMessageDeletion(messageId); |  | ||||||
|  |  | ||||||
|       // Remove the message from the list |  | ||||||
|     final currentMessages = state.value ?? []; |     final currentMessages = state.value ?? []; | ||||||
|     final filteredMessages = |     final filteredMessages = | ||||||
|         currentMessages.where((m) => m.id != messageId).toList(); |         currentMessages.where((m) => m.id != messageId).toList(); | ||||||
| @@ -249,41 +556,43 @@ class MessagesNotifier extends _$MessagesNotifier { | |||||||
|     if (filteredMessages.length != currentMessages.length) { |     if (filteredMessages.length != currentMessages.length) { | ||||||
|       state = AsyncValue.data(filteredMessages); |       state = AsyncValue.data(filteredMessages); | ||||||
|     } |     } | ||||||
|     } catch (err) { |  | ||||||
|       showErrorAlert(err); |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> deleteMessage(String messageId) async { |   Future<void> deleteMessage(String messageId) async { | ||||||
|  |     developer.log('Deleting message $messageId', name: 'MessagesNotifier'); | ||||||
|     try { |     try { | ||||||
|       final repository = await ref.read( |       await _apiClient.delete('/sphere/chat/$_roomId/messages/$messageId'); | ||||||
|         messageRepositoryProvider(_roomId).future, |       await receiveMessageDeletion(messageId); | ||||||
|       ); |     } catch (err, stackTrace) { | ||||||
|  |       developer.log('Error deleting message $messageId', name: 'MessagesNotifier', error: err, stackTrace: stackTrace); | ||||||
|       await repository.deleteMessage(messageId); |  | ||||||
|  |  | ||||||
|       // Remove the message from the list |  | ||||||
|       final currentMessages = state.value ?? []; |  | ||||||
|       final filteredMessages = |  | ||||||
|           currentMessages.where((m) => m.id != messageId).toList(); |  | ||||||
|  |  | ||||||
|       if (filteredMessages.length != currentMessages.length) { |  | ||||||
|         state = AsyncValue.data(filteredMessages); |  | ||||||
|       } |  | ||||||
|     } catch (err) { |  | ||||||
|       showErrorAlert(err); |       showErrorAlert(err); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<LocalChatMessage?> fetchMessageById(String messageId) async { |   Future<LocalChatMessage?> fetchMessageById(String messageId) async { | ||||||
|  |     developer.log('Fetching message by id $messageId', name: 'MessagesNotifier'); | ||||||
|     try { |     try { | ||||||
|       final repository = await ref.read( |       final localMessage = await (_database.select(_database.chatMessages) | ||||||
|         messageRepositoryProvider(_roomId).future, |             ..where((tbl) => tbl.id.equals(messageId))) | ||||||
|  |           .getSingleOrNull(); | ||||||
|  |       if (localMessage != null) { | ||||||
|  |         return _database.companionToMessage(localMessage); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       final response = await _apiClient.get( | ||||||
|  |         '/sphere/chat/$_roomId/messages/$messageId', | ||||||
|       ); |       ); | ||||||
|       return await repository.getMessageById(messageId); |       final remoteMessage = SnChatMessage.fromJson(response.data); | ||||||
|     } catch (err) { |       final message = LocalChatMessage.fromRemoteMessage( | ||||||
|       showErrorAlert(err); |         remoteMessage, | ||||||
|       return null; |         MessageStatus.sent, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       await _database.saveMessage(_database.messageToCompanion(message)); | ||||||
|  |       return message; | ||||||
|  |     } catch (e) { | ||||||
|  |       if (e is DioException) return null; | ||||||
|  |       rethrow; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
| @@ -296,6 +605,7 @@ class ChatRoomScreen extends HookConsumerWidget { | |||||||
|   Widget build(BuildContext context, WidgetRef ref) { |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|     final chatRoom = ref.watch(chatroomProvider(id)); |     final chatRoom = ref.watch(chatroomProvider(id)); | ||||||
|     final chatIdentity = ref.watch(chatroomIdentityProvider(id)); |     final chatIdentity = ref.watch(chatroomIdentityProvider(id)); | ||||||
|  |     final isSyncing = ref.watch(isSyncingProvider); | ||||||
|  |  | ||||||
|     if (chatIdentity.isLoading || chatRoom.isLoading) { |     if (chatIdentity.isLoading || chatRoom.isLoading) { | ||||||
|       return AppScaffold( |       return AppScaffold( | ||||||
| @@ -307,8 +617,7 @@ class ChatRoomScreen extends HookConsumerWidget { | |||||||
|       return AppScaffold( |       return AppScaffold( | ||||||
|         appBar: AppBar(leading: const PageBackButton()), |         appBar: AppBar(leading: const PageBackButton()), | ||||||
|         body: Center( |         body: Center( | ||||||
|           child: |           child: ConstrainedBox( | ||||||
|               ConstrainedBox( |  | ||||||
|             constraints: const BoxConstraints(maxWidth: 280), |             constraints: const BoxConstraints(maxWidth: 280), | ||||||
|             child: Column( |             child: Column( | ||||||
|               crossAxisAlignment: CrossAxisAlignment.center, |               crossAxisAlignment: CrossAxisAlignment.center, | ||||||
| @@ -417,10 +726,8 @@ class ChatRoomScreen extends HookConsumerWidget { | |||||||
|         if (typingStatuses.value.isNotEmpty) { |         if (typingStatuses.value.isNotEmpty) { | ||||||
|           // Remove typing statuses older than 5 seconds |           // Remove typing statuses older than 5 seconds | ||||||
|           final now = DateTime.now(); |           final now = DateTime.now(); | ||||||
|           typingStatuses.value = |           typingStatuses.value = typingStatuses.value.where((member) { | ||||||
|               typingStatuses.value.where((member) { |             final lastTyped = member.lastTyped ?? | ||||||
|                 final lastTyped = |  | ||||||
|                     member.lastTyped ?? |  | ||||||
|                 DateTime.now().subtract(const Duration(milliseconds: 1350)); |                 DateTime.now().subtract(const Duration(milliseconds: 1350)); | ||||||
|             return now.difference(lastTyped).inSeconds < 5; |             return now.difference(lastTyped).inSeconds < 5; | ||||||
|           }).toList(); |           }).toList(); | ||||||
| @@ -594,9 +901,7 @@ class ChatRoomScreen extends HookConsumerWidget { | |||||||
|         automaticallyImplyLeading: false, |         automaticallyImplyLeading: false, | ||||||
|         toolbarHeight: compactHeader ? null : 64, |         toolbarHeight: compactHeader ? null : 64, | ||||||
|         title: chatRoom.when( |         title: chatRoom.when( | ||||||
|           data: |           data: (room) => compactHeader | ||||||
|               (room) => |  | ||||||
|                   compactHeader |  | ||||||
|               ? Row( |               ? Row( | ||||||
|                   spacing: 8, |                   spacing: 8, | ||||||
|                   crossAxisAlignment: CrossAxisAlignment.center, |                   crossAxisAlignment: CrossAxisAlignment.center, | ||||||
| @@ -604,18 +909,11 @@ class ChatRoomScreen extends HookConsumerWidget { | |||||||
|                     SizedBox( |                     SizedBox( | ||||||
|                       height: 26, |                       height: 26, | ||||||
|                       width: 26, |                       width: 26, | ||||||
|                             child: |                       child: (room!.type == 1 && room.picture?.id == null) | ||||||
|                                 (room!.type == 1 && room.picture?.id == null) |  | ||||||
|                           ? SplitAvatarWidget( |                           ? SplitAvatarWidget( | ||||||
|                                       filesId: |                               filesId: room.members! | ||||||
|                                           room.members! |  | ||||||
|                                   .map( |                                   .map( | ||||||
|                                                 (e) => |                                     (e) => e.account.profile.picture?.id, | ||||||
|                                                     e |  | ||||||
|                                                         .account |  | ||||||
|                                                         .profile |  | ||||||
|                                                         .picture |  | ||||||
|                                                         ?.id, |  | ||||||
|                                   ) |                                   ) | ||||||
|                                   .toList(), |                                   .toList(), | ||||||
|                             ) |                             ) | ||||||
| @@ -633,9 +931,7 @@ class ChatRoomScreen extends HookConsumerWidget { | |||||||
|                     ), |                     ), | ||||||
|                     Text( |                     Text( | ||||||
|                       (room.type == 1 && room.name == null) |                       (room.type == 1 && room.name == null) | ||||||
|                                 ? room.members! |                           ? room.members!.map((e) => e.account.nick).join(', ') | ||||||
|                                     .map((e) => e.account.nick) |  | ||||||
|                                     .join(', ') |  | ||||||
|                           : room.name!, |                           : room.name!, | ||||||
|                     ).fontSize(19), |                     ).fontSize(19), | ||||||
|                   ], |                   ], | ||||||
| @@ -648,18 +944,11 @@ class ChatRoomScreen extends HookConsumerWidget { | |||||||
|                     SizedBox( |                     SizedBox( | ||||||
|                       height: 26, |                       height: 26, | ||||||
|                       width: 26, |                       width: 26, | ||||||
|                             child: |                       child: (room!.type == 1 && room.picture?.id == null) | ||||||
|                                 (room!.type == 1 && room.picture?.id == null) |  | ||||||
|                           ? SplitAvatarWidget( |                           ? SplitAvatarWidget( | ||||||
|                                       filesId: |                               filesId: room.members! | ||||||
|                                           room.members! |  | ||||||
|                                   .map( |                                   .map( | ||||||
|                                                 (e) => |                                     (e) => e.account.profile.picture?.id, | ||||||
|                                                     e |  | ||||||
|                                                         .account |  | ||||||
|                                                         .profile |  | ||||||
|                                                         .picture |  | ||||||
|                                                         ?.id, |  | ||||||
|                                   ) |                                   ) | ||||||
|                                   .toList(), |                                   .toList(), | ||||||
|                             ) |                             ) | ||||||
| @@ -677,16 +966,13 @@ class ChatRoomScreen extends HookConsumerWidget { | |||||||
|                     ), |                     ), | ||||||
|                     Text( |                     Text( | ||||||
|                       (room.type == 1 && room.name == null) |                       (room.type == 1 && room.name == null) | ||||||
|                                 ? room.members! |                           ? room.members!.map((e) => e.account.nick).join(', ') | ||||||
|                                     .map((e) => e.account.nick) |  | ||||||
|                                     .join(', ') |  | ||||||
|                           : room.name!, |                           : room.name!, | ||||||
|                     ).fontSize(15), |                     ).fontSize(15), | ||||||
|                   ], |                   ], | ||||||
|                 ), |                 ), | ||||||
|           loading: () => const Text('Loading...'), |           loading: () => const Text('Loading...'), | ||||||
|           error: |           error: (err, _) => ResponseErrorWidget( | ||||||
|               (err, _) => ResponseErrorWidget( |  | ||||||
|             error: err, |             error: err, | ||||||
|             onRetry: () => messagesNotifier.loadInitial(), |             onRetry: () => messagesNotifier.loadInitial(), | ||||||
|           ), |           ), | ||||||
| @@ -701,6 +987,12 @@ class ChatRoomScreen extends HookConsumerWidget { | |||||||
|           ), |           ), | ||||||
|           const Gap(8), |           const Gap(8), | ||||||
|         ], |         ], | ||||||
|  |         bottom: isSyncing | ||||||
|  |             ? const PreferredSize( | ||||||
|  |                 preferredSize: Size.fromHeight(4.0), | ||||||
|  |                 child: LinearProgressIndicator(), | ||||||
|  |               ) | ||||||
|  |             : null, | ||||||
|       ), |       ), | ||||||
|       body: Stack( |       body: Stack( | ||||||
|         children: [ |         children: [ | ||||||
| @@ -708,16 +1000,13 @@ class ChatRoomScreen extends HookConsumerWidget { | |||||||
|             children: [ |             children: [ | ||||||
|               Expanded( |               Expanded( | ||||||
|                 child: messages.when( |                 child: messages.when( | ||||||
|                   data: |                   data: (messageList) => messageList.isEmpty | ||||||
|                       (messageList) => |  | ||||||
|                           messageList.isEmpty |  | ||||||
|                       ? Center(child: Text('No messages yet'.tr())) |                       ? Center(child: Text('No messages yet'.tr())) | ||||||
|                       : SuperListView.builder( |                       : SuperListView.builder( | ||||||
|                           listController: listController, |                           listController: listController, | ||||||
|                           padding: EdgeInsets.symmetric(vertical: 16), |                           padding: EdgeInsets.symmetric(vertical: 16), | ||||||
|                           controller: scrollController, |                           controller: scrollController, | ||||||
|                                 reverse: |                           reverse: true, // Show newest messages at the bottom | ||||||
|                                     true, // Show newest messages at the bottom |  | ||||||
|                           itemCount: messageList.length, |                           itemCount: messageList.length, | ||||||
|                           findChildIndexCallback: (key) { |                           findChildIndexCallback: (key) { | ||||||
|                             final valueKey = key as ValueKey; |                             final valueKey = key as ValueKey; | ||||||
| @@ -728,14 +1017,11 @@ class ChatRoomScreen extends HookConsumerWidget { | |||||||
|                           }, |                           }, | ||||||
|                           itemBuilder: (context, index) { |                           itemBuilder: (context, index) { | ||||||
|                             final message = messageList[index]; |                             final message = messageList[index]; | ||||||
|                                   final nextMessage = |                             final nextMessage = index < messageList.length - 1 | ||||||
|                                       index < messageList.length - 1 |  | ||||||
|                                 ? messageList[index + 1] |                                 ? messageList[index + 1] | ||||||
|                                 : null; |                                 : null; | ||||||
|                                   final isLastInGroup = |                             final isLastInGroup = nextMessage == null || | ||||||
|                                       nextMessage == null || |                                 nextMessage.senderId != message.senderId || | ||||||
|                                       nextMessage.senderId != |  | ||||||
|                                           message.senderId || |  | ||||||
|                                 nextMessage.createdAt |                                 nextMessage.createdAt | ||||||
|                                         .difference(message.createdAt) |                                         .difference(message.createdAt) | ||||||
|                                         .inMinutes |                                         .inMinutes | ||||||
| @@ -744,11 +1030,10 @@ class ChatRoomScreen extends HookConsumerWidget { | |||||||
|  |  | ||||||
|                             return chatIdentity.when( |                             return chatIdentity.when( | ||||||
|                               skipError: true, |                               skipError: true, | ||||||
|                                     data: |                               data: (identity) => MessageItem( | ||||||
|                                         (identity) => MessageItem( |                                 key: ValueKey(message.id), | ||||||
|                                 message: message, |                                 message: message, | ||||||
|                                           isCurrentUser: |                                 isCurrentUser: identity?.id == message.senderId, | ||||||
|                                               identity?.id == message.senderId, |  | ||||||
|                                 onAction: (action) { |                                 onAction: (action) { | ||||||
|                                   switch (action) { |                                   switch (action) { | ||||||
|                                     case MessageItemAction.delete: |                                     case MessageItemAction.delete: | ||||||
| @@ -759,17 +1044,11 @@ class ChatRoomScreen extends HookConsumerWidget { | |||||||
|                                       messageEditingTo.value = |                                       messageEditingTo.value = | ||||||
|                                           message.toRemoteMessage(); |                                           message.toRemoteMessage(); | ||||||
|                                       messageController.text = |                                       messageController.text = | ||||||
|                                                     messageEditingTo |                                           messageEditingTo.value?.content ?? ''; | ||||||
|                                                         .value |                                       attachments.value = messageEditingTo | ||||||
|                                                         ?.content ?? |                                           .value!.attachments | ||||||
|                                                     ''; |  | ||||||
|                                                 attachments.value = |  | ||||||
|                                                     messageEditingTo |  | ||||||
|                                                         .value! |  | ||||||
|                                                         .attachments |  | ||||||
|                                           .map( |                                           .map( | ||||||
|                                                           (e) => |                                             (e) => UniversalFile.fromAttachment( | ||||||
|                                                               UniversalFile.fromAttachment( |  | ||||||
|                                               e, |                                               e, | ||||||
|                                             ), |                                             ), | ||||||
|                                           ) |                                           ) | ||||||
| @@ -783,8 +1062,7 @@ class ChatRoomScreen extends HookConsumerWidget { | |||||||
|                                   } |                                   } | ||||||
|                                 }, |                                 }, | ||||||
|                                 onJump: (messageId) { |                                 onJump: (messageId) { | ||||||
|                                             final messageIndex = messageList |                                   final messageIndex = messageList.indexWhere( | ||||||
|                                                 .indexWhere( |  | ||||||
|                                     (m) => m.id == messageId, |                                     (m) => m.id == messageId, | ||||||
|                                   ); |                                   ); | ||||||
|                                   listController.jumpToItem( |                                   listController.jumpToItem( | ||||||
| @@ -794,13 +1072,10 @@ class ChatRoomScreen extends HookConsumerWidget { | |||||||
|                                     alignment: 0.5, |                                     alignment: 0.5, | ||||||
|                                   ); |                                   ); | ||||||
|                                 }, |                                 }, | ||||||
|                                           progress: |                                 progress: attachmentProgress.value[message.id], | ||||||
|                                               attachmentProgress.value[message |  | ||||||
|                                                   .id], |  | ||||||
|                                 showAvatar: isLastInGroup, |                                 showAvatar: isLastInGroup, | ||||||
|                               ), |                               ), | ||||||
|                                     loading: |                               loading: () => MessageItem( | ||||||
|                                         () => MessageItem( |  | ||||||
|                                 message: message, |                                 message: message, | ||||||
|                                 isCurrentUser: false, |                                 isCurrentUser: false, | ||||||
|                                 onAction: null, |                                 onAction: null, | ||||||
| @@ -812,18 +1087,15 @@ class ChatRoomScreen extends HookConsumerWidget { | |||||||
|                             ); |                             ); | ||||||
|                           }, |                           }, | ||||||
|                         ), |                         ), | ||||||
|                   loading: |                   loading: () => const Center(child: CircularProgressIndicator()), | ||||||
|                       () => const Center(child: CircularProgressIndicator()), |                   error: (error, _) => ResponseErrorWidget( | ||||||
|                   error: |  | ||||||
|                       (error, _) => ResponseErrorWidget( |  | ||||||
|                     error: error, |                     error: error, | ||||||
|                     onRetry: () => messagesNotifier.loadInitial(), |                     onRetry: () => messagesNotifier.loadInitial(), | ||||||
|                   ), |                   ), | ||||||
|                 ), |                 ), | ||||||
|               ), |               ), | ||||||
|               chatRoom.when( |               chatRoom.when( | ||||||
|                 data: |                 data: (room) => Column( | ||||||
|                     (room) => Column( |  | ||||||
|                   mainAxisSize: MainAxisSize.min, |                   mainAxisSize: MainAxisSize.min, | ||||||
|                   children: [ |                   children: [ | ||||||
|                     AnimatedSwitcher( |                     AnimatedSwitcher( | ||||||
| @@ -854,8 +1126,7 @@ class ChatRoomScreen extends HookConsumerWidget { | |||||||
|                           ), |                           ), | ||||||
|                         ); |                         ); | ||||||
|                       }, |                       }, | ||||||
|                           child: |                       child: typingStatuses.value.isNotEmpty | ||||||
|                               typingStatuses.value.isNotEmpty |  | ||||||
|                           ? Container( |                           ? Container( | ||||||
|                               key: const ValueKey('typing-indicator'), |                               key: const ValueKey('typing-indicator'), | ||||||
|                               width: double.infinity, |                               width: double.infinity, | ||||||
| @@ -878,14 +1149,12 @@ class ChatRoomScreen extends HookConsumerWidget { | |||||||
|                                           typingStatuses.value |                                           typingStatuses.value | ||||||
|                                               .map( |                                               .map( | ||||||
|                                                 (x) => |                                                 (x) => | ||||||
|                                                           x.nick ?? |                                                     x.nick ?? x.account.nick, | ||||||
|                                                           x.account.nick, |  | ||||||
|                                               ) |                                               ) | ||||||
|                                               .join(', '), |                                               .join(', '), | ||||||
|                                         ], |                                         ], | ||||||
|                                       ), |                                       ), | ||||||
|                                             style: |                                       style: Theme.of( | ||||||
|                                                 Theme.of( |  | ||||||
|                                         context, |                                         context, | ||||||
|                                       ).textTheme.bodySmall, |                                       ).textTheme.bodySmall, | ||||||
|                                     ), |                                     ), | ||||||
| @@ -1154,14 +1423,11 @@ class _ChatInput extends HookConsumerWidget { | |||||||
|                             // Insert placeholder at current cursor position |                             // Insert placeholder at current cursor position | ||||||
|                             final text = messageController.text; |                             final text = messageController.text; | ||||||
|                             final selection = messageController.selection; |                             final selection = messageController.selection; | ||||||
|                             final start = |                             final start = selection.start >= 0 | ||||||
|                                 selection.start >= 0 |  | ||||||
|                                 ? selection.start |                                 ? selection.start | ||||||
|                                 : text.length; |                                 : text.length; | ||||||
|                             final end = |                             final end = | ||||||
|                                 selection.end >= 0 |                                 selection.end >= 0 ? selection.end : text.length; | ||||||
|                                     ? selection.end |  | ||||||
|                                     : text.length; |  | ||||||
|                             final newText = text.replaceRange( |                             final newText = text.replaceRange( | ||||||
|                               start, |                               start, | ||||||
|                               end, |                               end, | ||||||
| @@ -1179,8 +1445,7 @@ class _ChatInput extends HookConsumerWidget { | |||||||
|                     ), |                     ), | ||||||
|                     PopupMenuButton( |                     PopupMenuButton( | ||||||
|                       icon: const Icon(Symbols.photo_library), |                       icon: const Icon(Symbols.photo_library), | ||||||
|                       itemBuilder: |                       itemBuilder: (context) => [ | ||||||
|                           (context) => [ |  | ||||||
|                         PopupMenuItem( |                         PopupMenuItem( | ||||||
|                           onTap: () => onPickFile(true), |                           onTap: () => onPickFile(true), | ||||||
|                           child: Row( |                           child: Row( | ||||||
| @@ -1251,8 +1516,8 @@ class _ChatInput extends HookConsumerWidget { | |||||||
|                         ), |                         ), | ||||||
|                       ), |                       ), | ||||||
|                       maxLines: null, |                       maxLines: null, | ||||||
|                       onTapOutside: |                       onTapOutside: (_) => | ||||||
|                           (_) => FocusManager.instance.primaryFocus?.unfocus(), |                           FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                     ), |                     ), | ||||||
|                   ), |                   ), | ||||||
|                 ), |                 ), | ||||||
|   | |||||||
| @@ -6,7 +6,7 @@ part of 'room.dart'; | |||||||
| // RiverpodGenerator | // RiverpodGenerator | ||||||
| // ************************************************************************** | // ************************************************************************** | ||||||
|  |  | ||||||
| String _$messagesNotifierHash() => r'afc4d43f4948ec571118cef0321838a6cefc89c0'; | String _$messagesNotifierHash() => r'3b10c3101404f6528c7a83baa0d39cba1a30f579'; | ||||||
|  |  | ||||||
| /// Copied from Dart SDK | /// Copied from Dart SDK | ||||||
| class _SystemHash { | class _SystemHash { | ||||||
|   | |||||||
| @@ -11,6 +11,7 @@ import 'package:island/models/realm.dart'; | |||||||
| import 'package:island/models/webfeed.dart'; | import 'package:island/models/webfeed.dart'; | ||||||
| import 'package:island/pods/event_calendar.dart'; | import 'package:island/pods/event_calendar.dart'; | ||||||
| import 'package:island/pods/userinfo.dart'; | import 'package:island/pods/userinfo.dart'; | ||||||
|  | import 'package:island/screens/notification.dart'; | ||||||
| import 'package:island/services/responsive.dart'; | import 'package:island/services/responsive.dart'; | ||||||
| import 'package:island/widgets/account/fortune_graph.dart'; | import 'package:island/widgets/account/fortune_graph.dart'; | ||||||
| import 'package:island/widgets/app_scaffold.dart'; | import 'package:island/widgets/app_scaffold.dart'; | ||||||
| @@ -30,6 +31,33 @@ import 'package:styled_widget/styled_widget.dart'; | |||||||
|  |  | ||||||
| part 'explore.g.dart'; | part 'explore.g.dart'; | ||||||
|  |  | ||||||
|  | Widget notificationIndicatorWidget( | ||||||
|  |   BuildContext context, { | ||||||
|  |   required int count, | ||||||
|  |   EdgeInsets? margin, | ||||||
|  | }) => Card( | ||||||
|  |   margin: margin, | ||||||
|  |   child: ListTile( | ||||||
|  |     shape: const RoundedRectangleBorder( | ||||||
|  |       borderRadius: BorderRadius.all(Radius.circular(8)), | ||||||
|  |     ), | ||||||
|  |     leading: const Icon(Symbols.notifications), | ||||||
|  |     title: Row( | ||||||
|  |       children: [ | ||||||
|  |         Text('notifications').tr().fontSize(14), | ||||||
|  |         const Gap(8), | ||||||
|  |         Badge(label: Text(count.toString())), | ||||||
|  |       ], | ||||||
|  |     ), | ||||||
|  |     trailing: const Icon(Symbols.chevron_right), | ||||||
|  |     minTileHeight: 40, | ||||||
|  |     contentPadding: EdgeInsets.only(left: 16, right: 15), | ||||||
|  |     onTap: () { | ||||||
|  |       GoRouter.of(context).pushNamed('notifications'); | ||||||
|  |     }, | ||||||
|  |   ), | ||||||
|  | ); | ||||||
|  |  | ||||||
| class ExploreScreen extends HookConsumerWidget { | class ExploreScreen extends HookConsumerWidget { | ||||||
|   const ExploreScreen({super.key}); |   const ExploreScreen({super.key}); | ||||||
|  |  | ||||||
| @@ -77,6 +105,10 @@ class ExploreScreen extends HookConsumerWidget { | |||||||
|  |  | ||||||
|     final user = ref.watch(userInfoProvider); |     final user = ref.watch(userInfoProvider); | ||||||
|  |  | ||||||
|  |     final notificationCount = ref.watch( | ||||||
|  |       notificationUnreadCountNotifierProvider, | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       isNoBackground: false, |       isNoBackground: false, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
| @@ -185,7 +217,7 @@ class ExploreScreen extends HookConsumerWidget { | |||||||
|       floatingActionButtonLocation: TabbedFabLocation(context), |       floatingActionButtonLocation: TabbedFabLocation(context), | ||||||
|       body: Builder( |       body: Builder( | ||||||
|         builder: (context) { |         builder: (context) { | ||||||
|           final isWider = isWiderScreen(context); |           final isWide = isWideScreen(context); | ||||||
|  |  | ||||||
|           final bodyView = _buildActivityList( |           final bodyView = _buildActivityList( | ||||||
|             context, |             context, | ||||||
| @@ -193,13 +225,15 @@ class ExploreScreen extends HookConsumerWidget { | |||||||
|             currentFilter.value, |             currentFilter.value, | ||||||
|           ); |           ); | ||||||
|  |  | ||||||
|           if (isWider) { |           if (isWide) { | ||||||
|             return Row( |             return Row( | ||||||
|               children: [ |               children: [ | ||||||
|                 Flexible(flex: 3, child: bodyView.padding(left: 8)), |                 Flexible(flex: 3, child: bodyView.padding(left: 8)), | ||||||
|                 if (user.value != null) |                 if (user.value != null) | ||||||
|                   Flexible( |                   Flexible( | ||||||
|                     flex: 2, |                     flex: 2, | ||||||
|  |                     child: Align( | ||||||
|  |                       alignment: Alignment.topCenter, | ||||||
|                       child: SingleChildScrollView( |                       child: SingleChildScrollView( | ||||||
|                         child: Column( |                         child: Column( | ||||||
|                           children: [ |                           children: [ | ||||||
| @@ -215,13 +249,28 @@ class ExploreScreen extends HookConsumerWidget { | |||||||
|                                 ); |                                 ); | ||||||
|                               }, |                               }, | ||||||
|                             ), |                             ), | ||||||
|  |                             if (notificationCount.value != null && | ||||||
|  |                                 notificationCount.value! > 0) | ||||||
|  |                               notificationIndicatorWidget( | ||||||
|  |                                 context, | ||||||
|  |                                 count: notificationCount.value ?? 0, | ||||||
|  |                                 margin: EdgeInsets.only( | ||||||
|  |                                   left: 8, | ||||||
|  |                                   right: 12, | ||||||
|  |                                   top: 8, | ||||||
|  |                                 ), | ||||||
|  |                               ), | ||||||
|                             PostFeaturedList().padding( |                             PostFeaturedList().padding( | ||||||
|                               left: 8, |                               left: 8, | ||||||
|                               right: 12, |                               right: 12, | ||||||
|                               top: 8, |                               top: 8, | ||||||
|                             ), |                             ), | ||||||
|                             FortuneGraphWidget( |                             FortuneGraphWidget( | ||||||
|                             margin: EdgeInsets.only(left: 8, right: 12, top: 8), |                               margin: EdgeInsets.only( | ||||||
|  |                                 left: 8, | ||||||
|  |                                 right: 12, | ||||||
|  |                                 top: 8, | ||||||
|  |                               ), | ||||||
|                               events: events, |                               events: events, | ||||||
|                               constrainWidth: true, |                               constrainWidth: true, | ||||||
|                               onPointSelected: onDaySelected, |                               onPointSelected: onDaySelected, | ||||||
| @@ -229,6 +278,7 @@ class ExploreScreen extends HookConsumerWidget { | |||||||
|                           ], |                           ], | ||||||
|                         ), |                         ), | ||||||
|                       ), |                       ), | ||||||
|  |                     ), | ||||||
|                   ) |                   ) | ||||||
|                 else |                 else | ||||||
|                   Flexible( |                   Flexible( | ||||||
| @@ -268,7 +318,7 @@ class ExploreScreen extends HookConsumerWidget { | |||||||
|       activityListNotifierProvider(filter).notifier, |       activityListNotifierProvider(filter).notifier, | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     final isWider = isWiderScreen(context); |     final isWide = isWideScreen(context); | ||||||
|  |  | ||||||
|     return RefreshIndicator( |     return RefreshIndicator( | ||||||
|       onRefresh: () => Future.sync(activitiesNotifier.forceRefresh), |       onRefresh: () => Future.sync(activitiesNotifier.forceRefresh), | ||||||
| @@ -283,7 +333,7 @@ class ExploreScreen extends HookConsumerWidget { | |||||||
|                 widgetCount: widgetCount, |                 widgetCount: widgetCount, | ||||||
|                 endItemView: endItemView, |                 endItemView: endItemView, | ||||||
|                 activitiesNotifier: activitiesNotifier, |                 activitiesNotifier: activitiesNotifier, | ||||||
|                 contentOnly: isWider || filter != null, |                 contentOnly: isWide || filter != null, | ||||||
|               ), |               ), | ||||||
|             ), |             ), | ||||||
|       ), |       ), | ||||||
| @@ -380,6 +430,10 @@ class _ActivityListView extends HookConsumerWidget { | |||||||
|   Widget build(BuildContext context, WidgetRef ref) { |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|     final user = ref.watch(userInfoProvider); |     final user = ref.watch(userInfoProvider); | ||||||
|  |  | ||||||
|  |     final notificationCount = ref.watch( | ||||||
|  |       notificationUnreadCountNotifierProvider, | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     return CustomScrollView( |     return CustomScrollView( | ||||||
|       slivers: [ |       slivers: [ | ||||||
|         SliverGap(12), |         SliverGap(12), | ||||||
| @@ -393,6 +447,14 @@ class _ActivityListView extends HookConsumerWidget { | |||||||
|           SliverToBoxAdapter( |           SliverToBoxAdapter( | ||||||
|             child: PostFeaturedList().padding(horizontal: 8, bottom: 4, top: 4), |             child: PostFeaturedList().padding(horizontal: 8, bottom: 4, top: 4), | ||||||
|           ), |           ), | ||||||
|  |         if (!contentOnly) | ||||||
|  |           SliverToBoxAdapter( | ||||||
|  |             child: notificationIndicatorWidget( | ||||||
|  |               context, | ||||||
|  |               count: notificationCount.value ?? 0, | ||||||
|  |               margin: EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 4), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|         SliverList.builder( |         SliverList.builder( | ||||||
|           itemCount: widgetCount, |           itemCount: widgetCount, | ||||||
|           itemBuilder: (context, index) { |           itemBuilder: (context, index) { | ||||||
|   | |||||||
| @@ -3,14 +3,17 @@ import 'dart:math' as math; | |||||||
|  |  | ||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:gap/gap.dart'; | ||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
| import 'package:island/models/account.dart'; | import 'package:island/models/account.dart'; | ||||||
| import 'package:island/pods/network.dart'; | import 'package:island/pods/network.dart'; | ||||||
| import 'package:island/pods/websocket.dart'; | import 'package:island/pods/websocket.dart'; | ||||||
| import 'package:island/route.dart'; | import 'package:island/route.dart'; | ||||||
|  | import 'package:island/widgets/alert.dart'; | ||||||
| import 'package:island/widgets/app_scaffold.dart'; | import 'package:island/widgets/app_scaffold.dart'; | ||||||
| import 'package:island/widgets/content/markdown.dart'; | import 'package:island/widgets/content/markdown.dart'; | ||||||
|  | import 'package:material_symbols_icons/material_symbols_icons.dart'; | ||||||
| import 'package:relative_time/relative_time.dart'; | import 'package:relative_time/relative_time.dart'; | ||||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||||
| import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||||
| @@ -62,6 +65,10 @@ class NotificationUnreadCountNotifier | |||||||
|     final current = await future; |     final current = await future; | ||||||
|     state = AsyncData(math.max(current - count, 0)); |     state = AsyncData(math.max(current - count, 0)); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   void clear() async { | ||||||
|  |     state = AsyncData(0); | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| @riverpod | @riverpod | ||||||
| @@ -111,8 +118,27 @@ class NotificationScreen extends HookConsumerWidget { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context, WidgetRef ref) { |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     Future<void> markAllRead() async { | ||||||
|  |       showLoadingModal(context); | ||||||
|  |       final apiClient = ref.watch(apiClientProvider); | ||||||
|  |       await apiClient.post('/pusher/notifications/all/read'); | ||||||
|  |       if (!context.mounted) return; | ||||||
|  |       hideLoadingModal(context); | ||||||
|  |       ref.invalidate(notificationListNotifierProvider); | ||||||
|  |       ref.watch(notificationUnreadCountNotifierProvider.notifier).clear(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       appBar: AppBar(title: const Text('notifications').tr()), |       appBar: AppBar( | ||||||
|  |         title: const Text('notifications').tr(), | ||||||
|  |         actions: [ | ||||||
|  |           IconButton( | ||||||
|  |             onPressed: markAllRead, | ||||||
|  |             icon: const Icon(Symbols.mark_as_unread), | ||||||
|  |           ), | ||||||
|  |           const Gap(8), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|       body: PagingHelperView( |       body: PagingHelperView( | ||||||
|         provider: notificationListNotifierProvider, |         provider: notificationListNotifierProvider, | ||||||
|         futureRefreshable: notificationListNotifierProvider.future, |         futureRefreshable: notificationListNotifierProvider.future, | ||||||
|   | |||||||
| @@ -7,7 +7,7 @@ part of 'notification.dart'; | |||||||
| // ************************************************************************** | // ************************************************************************** | ||||||
|  |  | ||||||
| String _$notificationUnreadCountNotifierHash() => | String _$notificationUnreadCountNotifierHash() => | ||||||
|     r'd199abf0d16944587e747798399a267a790341f3'; |     r'0763b66bd64e5a9b7c317887e109ab367515dfa4'; | ||||||
|  |  | ||||||
| /// See also [NotificationUnreadCountNotifier]. | /// See also [NotificationUnreadCountNotifier]. | ||||||
| @ProviderFor(NotificationUnreadCountNotifier) | @ProviderFor(NotificationUnreadCountNotifier) | ||||||
|   | |||||||
| @@ -51,12 +51,12 @@ class PostSearchNotifier | |||||||
|       final offset = cursor == null ? 0 : int.parse(cursor); |       final offset = cursor == null ? 0 : int.parse(cursor); | ||||||
|  |  | ||||||
|       final response = await client.get( |       final response = await client.get( | ||||||
|         '/sphere/posts/search', |         '/sphere/posts', | ||||||
|         queryParameters: { |         queryParameters: { | ||||||
|           'query': _currentQuery, |           'query': _currentQuery, | ||||||
|           'offset': offset, |           'offset': offset, | ||||||
|           'take': _pageSize, |           'take': _pageSize, | ||||||
|           'useVector': false, |           'vector': false, | ||||||
|         }, |         }, | ||||||
|       ); |       ); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -26,7 +26,12 @@ StreamSubscription<WebSocketPacket> setupNotificationListener( | |||||||
|       final notification = SnNotification.fromJson(pkt.data!); |       final notification = SnNotification.fromJson(pkt.data!); | ||||||
|       showTopSnackBar( |       showTopSnackBar( | ||||||
|         globalOverlay.currentState!, |         globalOverlay.currentState!, | ||||||
|         NotificationCard(notification: notification), |         Center( | ||||||
|  |           child: ConstrainedBox( | ||||||
|  |             constraints: const BoxConstraints(maxWidth: 480), | ||||||
|  |             child: NotificationCard(notification: notification), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|         onTap: () { |         onTap: () { | ||||||
|           if (notification.meta['action_uri'] != null) { |           if (notification.meta['action_uri'] != null) { | ||||||
|             var uri = notification.meta['action_uri'] as String; |             var uri = notification.meta['action_uri'] as String; | ||||||
| @@ -53,9 +58,9 @@ StreamSubscription<WebSocketPacket> setupNotificationListener( | |||||||
|                       (Platform.isMacOS || |                       (Platform.isMacOS || | ||||||
|                           Platform.isWindows || |                           Platform.isWindows || | ||||||
|                           Platform.isLinux)) |                           Platform.isLinux)) | ||||||
|                   ? 24 |                   ? 28 | ||||||
|                   // ignore: use_build_context_synchronously |                   // ignore: use_build_context_synchronously | ||||||
|                   : MediaQuery.of(context).padding.top + 8, |                   : MediaQuery.of(context).padding.top + 16, | ||||||
|           bottom: 16, |           bottom: 16, | ||||||
|         ), |         ), | ||||||
|       ); |       ); | ||||||
|   | |||||||
| @@ -162,7 +162,7 @@ class AccountSessionSheet extends HookConsumerWidget { | |||||||
|       try { |       try { | ||||||
|         final apiClient = ref.watch(apiClientProvider); |         final apiClient = ref.watch(apiClientProvider); | ||||||
|         await apiClient.patch( |         await apiClient.patch( | ||||||
|           '/accounts/me/devices/$sessionId/label', |           '/id/accounts/me/devices/$sessionId/label', | ||||||
|           data: jsonEncode(label), |           data: jsonEncode(label), | ||||||
|         ); |         ); | ||||||
|         ref.invalidate(authDevicesProvider); |         ref.invalidate(authDevicesProvider); | ||||||
|   | |||||||
| @@ -11,7 +11,12 @@ export 'content/alert.native.dart' | |||||||
| void showSnackBar(String message, {SnackBarAction? action}) { | void showSnackBar(String message, {SnackBarAction? action}) { | ||||||
|   showTopSnackBar( |   showTopSnackBar( | ||||||
|     globalOverlay.currentState!, |     globalOverlay.currentState!, | ||||||
|     Card(child: Text(message).padding(horizontal: 20, vertical: 16)), |     ConstrainedBox( | ||||||
|  |       constraints: const BoxConstraints(maxWidth: 480), | ||||||
|  |       child: Center( | ||||||
|  |         child: Card(child: Text(message).padding(horizontal: 20, vertical: 16)), | ||||||
|  |       ), | ||||||
|  |     ), | ||||||
|     snackBarPosition: SnackBarPosition.bottom, |     snackBarPosition: SnackBarPosition.bottom, | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -57,11 +57,11 @@ class EmbedLinkWidget extends StatelessWidget { | |||||||
|                     Row( |                     Row( | ||||||
|                       children: [ |                       children: [ | ||||||
|                         // Favicon |                         // Favicon | ||||||
|                         if (link.faviconUrl.isNotEmpty) ...[ |                         if (link.faviconUrl?.isNotEmpty ?? false) ...[ | ||||||
|                           ClipRRect( |                           ClipRRect( | ||||||
|                             borderRadius: BorderRadius.circular(4), |                             borderRadius: BorderRadius.circular(4), | ||||||
|                             child: UniversalImage( |                             child: UniversalImage( | ||||||
|                               uri: link.faviconUrl, |                               uri: link.faviconUrl!, | ||||||
|                               width: 16, |                               width: 16, | ||||||
|                               height: 16, |                               height: 16, | ||||||
|                               fit: BoxFit.cover, |                               fit: BoxFit.cover, | ||||||
| @@ -80,8 +80,8 @@ class EmbedLinkWidget extends StatelessWidget { | |||||||
|                         // Site name |                         // Site name | ||||||
|                         Expanded( |                         Expanded( | ||||||
|                           child: Text( |                           child: Text( | ||||||
|                             link.siteName.isNotEmpty |                             (link.siteName?.isNotEmpty ?? false) | ||||||
|                                 ? link.siteName |                                 ? link.siteName! | ||||||
|                                 : Uri.parse(link.url).host, |                                 : Uri.parse(link.url).host, | ||||||
|                             style: theme.textTheme.bodySmall?.copyWith( |                             style: theme.textTheme.bodySmall?.copyWith( | ||||||
|                               color: colorScheme.onSurfaceVariant, |                               color: colorScheme.onSurfaceVariant, | ||||||
|   | |||||||
| @@ -183,9 +183,15 @@ class MarkdownTextContent extends HookConsumerWidget { | |||||||
|                     ); |                     ); | ||||||
|                 } |                 } | ||||||
|               } |               } | ||||||
|               final content = ConstrainedBox( |               final content = ClipRRect( | ||||||
|  |                 borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||||
|  |                 child: ConstrainedBox( | ||||||
|                   constraints: BoxConstraints(maxHeight: 360), |                   constraints: BoxConstraints(maxHeight: 360), | ||||||
|                 child: UniversalImage(uri: uri.toString(), fit: BoxFit.contain), |                   child: UniversalImage( | ||||||
|  |                     uri: uri.toString(), | ||||||
|  |                     fit: BoxFit.contain, | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|               ); |               ); | ||||||
|               return content; |               return content; | ||||||
|             }, |             }, | ||||||
|   | |||||||
| @@ -244,7 +244,6 @@ class ComposeSettingsSheet extends HookConsumerWidget { | |||||||
|             ), |             ), | ||||||
|  |  | ||||||
|             // Categories field |             // Categories field | ||||||
|             // FIXME: Sometimes the entire dropdown crashes: 'package:flutter/src/rendering/stack.dart': Failed assertion: line 799 pos 12: 'firstChild == null || child != null': is not true. |  | ||||||
|             DropdownButtonFormField2<SnPostCategory>( |             DropdownButtonFormField2<SnPostCategory>( | ||||||
|               isExpanded: true, |               isExpanded: true, | ||||||
|               decoration: InputDecoration( |               decoration: InputDecoration( | ||||||
| @@ -306,7 +305,7 @@ class ComposeSettingsSheet extends HookConsumerWidget { | |||||||
|               value: currentCategories.isEmpty ? null : currentCategories.last, |               value: currentCategories.isEmpty ? null : currentCategories.last, | ||||||
|               onChanged: (_) {}, |               onChanged: (_) {}, | ||||||
|               selectedItemBuilder: (context) { |               selectedItemBuilder: (context) { | ||||||
|                 return currentCategories.map((item) { |                 return (postCategories.value ?? []).map((item) { | ||||||
|                   return SingleChildScrollView( |                   return SingleChildScrollView( | ||||||
|                     scrollDirection: Axis.horizontal, |                     scrollDirection: Axis.horizontal, | ||||||
|                     child: Row( |                     child: Row( | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart'; | |||||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||||
| import 'package:island/widgets/post/post_item.dart'; | import 'package:island/widgets/post/post_item.dart'; | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:island/pods/config.dart'; // Import config.dart for shared preferences keys and provider | ||||||
|  |  | ||||||
| part 'post_featured.g.dart'; | part 'post_featured.g.dart'; | ||||||
|  |  | ||||||
| @@ -25,7 +26,13 @@ class PostFeaturedList extends HookConsumerWidget { | |||||||
|     final featuredPostsAsync = ref.watch(featuredPostsProvider); |     final featuredPostsAsync = ref.watch(featuredPostsProvider); | ||||||
|  |  | ||||||
|     final pageViewController = usePageController(); |     final pageViewController = usePageController(); | ||||||
|  |     final prefs = ref.watch(sharedPreferencesProvider); | ||||||
|     final pageViewCurrent = useState(0); |     final pageViewCurrent = useState(0); | ||||||
|  |     final previousFirstPostId = useState<String?>(null); | ||||||
|  |     final storedCollapsedId = useState<String?>( | ||||||
|  |       prefs.getString(kFeaturedPostsCollapsedId), | ||||||
|  |     ); | ||||||
|  |     final isCollapsed = useState(false); | ||||||
|  |  | ||||||
|     useEffect(() { |     useEffect(() { | ||||||
|       pageViewController.addListener(() { |       pageViewController.addListener(() { | ||||||
| @@ -34,6 +41,59 @@ class PostFeaturedList extends HookConsumerWidget { | |||||||
|       return null; |       return null; | ||||||
|     }, [pageViewController]); |     }, [pageViewController]); | ||||||
|  |  | ||||||
|  |     // Log isCollapsed state changes | ||||||
|  |     useEffect(() { | ||||||
|  |       debugPrint( | ||||||
|  |         'PostFeaturedList: isCollapsed changed to ${isCollapsed.value}', | ||||||
|  |       ); | ||||||
|  |       return null; | ||||||
|  |     }, [isCollapsed.value]); | ||||||
|  |  | ||||||
|  |     useEffect(() { | ||||||
|  |       if (featuredPostsAsync.hasValue && featuredPostsAsync.value!.isNotEmpty) { | ||||||
|  |         final currentFirstPostId = featuredPostsAsync.value!.first.id; | ||||||
|  |         debugPrint( | ||||||
|  |           'PostFeaturedList: Current first post ID: $currentFirstPostId', | ||||||
|  |         ); | ||||||
|  |         debugPrint( | ||||||
|  |           'PostFeaturedList: Previous first post ID: ${previousFirstPostId.value}', | ||||||
|  |         ); | ||||||
|  |         debugPrint( | ||||||
|  |           'PostFeaturedList: Stored collapsed ID: ${storedCollapsedId.value}', | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         if (previousFirstPostId.value == null) { | ||||||
|  |           // Initial load | ||||||
|  |           previousFirstPostId.value = currentFirstPostId; | ||||||
|  |           isCollapsed.value = (storedCollapsedId.value == currentFirstPostId); | ||||||
|  |           debugPrint( | ||||||
|  |             'PostFeaturedList: Initial load. isCollapsed set to ${isCollapsed.value}', | ||||||
|  |           ); | ||||||
|  |         } else if (previousFirstPostId.value != currentFirstPostId) { | ||||||
|  |           // First post changed, expand by default | ||||||
|  |           previousFirstPostId.value = currentFirstPostId; | ||||||
|  |           isCollapsed.value = false; | ||||||
|  |           prefs.remove( | ||||||
|  |             kFeaturedPostsCollapsedId, | ||||||
|  |           ); // Clear stored ID if post changes | ||||||
|  |           debugPrint( | ||||||
|  |             'PostFeaturedList: First post changed. isCollapsed set to false.', | ||||||
|  |           ); | ||||||
|  |         } else { | ||||||
|  |           // Same first post, maintain current collapse state | ||||||
|  |           // No change needed for isCollapsed.value unless manually toggled | ||||||
|  |           debugPrint( | ||||||
|  |             'PostFeaturedList: Same first post. Maintaining current collapse state.', | ||||||
|  |           ); | ||||||
|  |         } | ||||||
|  |       } else { | ||||||
|  |         debugPrint( | ||||||
|  |           'PostFeaturedList: featuredPostsAsync has no value or is empty.', | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |       return null; | ||||||
|  |     }, [featuredPostsAsync.value]); | ||||||
|  |  | ||||||
|     return ClipRRect( |     return ClipRRect( | ||||||
|       borderRadius: const BorderRadius.all(Radius.circular(8)), |       borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||||
|       child: Card( |       child: Card( | ||||||
| @@ -73,10 +133,48 @@ class PostFeaturedList extends HookConsumerWidget { | |||||||
|                   }, |                   }, | ||||||
|                   icon: const Icon(Symbols.arrow_right), |                   icon: const Icon(Symbols.arrow_right), | ||||||
|                 ), |                 ), | ||||||
|  |                 IconButton( | ||||||
|  |                   padding: EdgeInsets.zero, | ||||||
|  |                   visualDensity: VisualDensity.compact, | ||||||
|  |                   constraints: const BoxConstraints(), | ||||||
|  |                   onPressed: () { | ||||||
|  |                     isCollapsed.value = !isCollapsed.value; | ||||||
|  |                     debugPrint( | ||||||
|  |                       'PostFeaturedList: Manual toggle. isCollapsed set to ${isCollapsed.value}', | ||||||
|  |                     ); | ||||||
|  |                     if (isCollapsed.value && | ||||||
|  |                         featuredPostsAsync.hasValue && | ||||||
|  |                         featuredPostsAsync.value!.isNotEmpty) { | ||||||
|  |                       prefs.setString( | ||||||
|  |                         kFeaturedPostsCollapsedId, | ||||||
|  |                         featuredPostsAsync.value!.first.id, | ||||||
|  |                       ); | ||||||
|  |                       debugPrint( | ||||||
|  |                         'PostFeaturedList: Stored collapsed ID: ${featuredPostsAsync.value!.first.id}', | ||||||
|  |                       ); | ||||||
|  |                     } else { | ||||||
|  |                       prefs.remove(kFeaturedPostsCollapsedId); | ||||||
|  |                       debugPrint( | ||||||
|  |                         'PostFeaturedList: Removed stored collapsed ID.', | ||||||
|  |                       ); | ||||||
|  |                     } | ||||||
|  |                   }, | ||||||
|  |                   icon: Icon( | ||||||
|  |                     isCollapsed.value | ||||||
|  |                         ? Symbols.expand_more | ||||||
|  |                         : Symbols.expand_less, | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|               ], |               ], | ||||||
|             ).padding(horizontal: 16, vertical: 8), |             ).padding(horizontal: 16, vertical: 8), | ||||||
|             featuredPostsAsync.when( |             AnimatedSize( | ||||||
|               loading: () => const Center(child: CircularProgressIndicator()), |               duration: const Duration(milliseconds: 300), | ||||||
|  |               curve: Curves.easeInOut, | ||||||
|  |               child: Visibility( | ||||||
|  |                 visible: !isCollapsed.value, | ||||||
|  |                 child: featuredPostsAsync.when( | ||||||
|  |                   loading: | ||||||
|  |                       () => const Center(child: CircularProgressIndicator()), | ||||||
|                   error: (error, stack) => Center(child: Text('Error: $error')), |                   error: (error, stack) => Center(child: Text('Error: $error')), | ||||||
|                   data: (posts) { |                   data: (posts) { | ||||||
|                     return SizedBox( |                     return SizedBox( | ||||||
| @@ -97,6 +195,8 @@ class PostFeaturedList extends HookConsumerWidget { | |||||||
|                     ); |                     ); | ||||||
|                   }, |                   }, | ||||||
|                 ), |                 ), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|           ], |           ], | ||||||
|         ), |         ), | ||||||
|       ), |       ), | ||||||
|   | |||||||
| @@ -879,7 +879,8 @@ class _LinkPreview extends ConsumerWidget { | |||||||
|             crossAxisAlignment: CrossAxisAlignment.start, |             crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|             children: [ |             children: [ | ||||||
|               // Favicon and image |               // Favicon and image | ||||||
|               if (embed.imageUrl != null || embed.faviconUrl.isNotEmpty) |               if (embed.imageUrl != null || | ||||||
|  |                   (embed.faviconUrl?.isNotEmpty ?? false)) | ||||||
|                 Container( |                 Container( | ||||||
|                   width: 60, |                   width: 60, | ||||||
|                   height: 60, |                   height: 60, | ||||||
| @@ -899,11 +900,14 @@ class _LinkPreview extends ConsumerWidget { | |||||||
|                               errorBuilder: (context, error, stackTrace) { |                               errorBuilder: (context, error, stackTrace) { | ||||||
|                                 return _buildFaviconFallback( |                                 return _buildFaviconFallback( | ||||||
|                                   context, |                                   context, | ||||||
|                                   embed.faviconUrl, |                                   embed.faviconUrl ?? '', | ||||||
|                                 ); |                                 ); | ||||||
|                               }, |                               }, | ||||||
|                             ) |                             ) | ||||||
|                             : _buildFaviconFallback(context, embed.faviconUrl), |                             : _buildFaviconFallback( | ||||||
|  |                               context, | ||||||
|  |                               embed.faviconUrl ?? '', | ||||||
|  |                             ), | ||||||
|                   ), |                   ), | ||||||
|                 ), |                 ), | ||||||
|               // Content |               // Content | ||||||
| @@ -912,9 +916,9 @@ class _LinkPreview extends ConsumerWidget { | |||||||
|                   crossAxisAlignment: CrossAxisAlignment.start, |                   crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|                   children: [ |                   children: [ | ||||||
|                     // Site name |                     // Site name | ||||||
|                     if (embed.siteName.isNotEmpty) |                     if (embed.siteName?.isNotEmpty ?? false) | ||||||
|                       Text( |                       Text( | ||||||
|                         embed.siteName, |                         embed.siteName!, | ||||||
|                         style: Theme.of(context).textTheme.labelSmall?.copyWith( |                         style: Theme.of(context).textTheme.labelSmall?.copyWith( | ||||||
|                           color: Theme.of(context).colorScheme.primary, |                           color: Theme.of(context).colorScheme.primary, | ||||||
|                         ), |                         ), | ||||||
|   | |||||||
| @@ -41,7 +41,7 @@ endif() | |||||||
| # of modifying this function. | # of modifying this function. | ||||||
| function(APPLY_STANDARD_SETTINGS TARGET) | function(APPLY_STANDARD_SETTINGS TARGET) | ||||||
|   target_compile_features(${TARGET} PUBLIC cxx_std_14) |   target_compile_features(${TARGET} PUBLIC cxx_std_14) | ||||||
|   target_compile_options(${TARGET} PRIVATE -Wall -Werror) |   target_compile_options(${TARGET} PRIVATE -Wall -Wextra) | ||||||
|   target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>") |   target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>") | ||||||
|   target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>") |   target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>") | ||||||
| endfunction() | endfunction() | ||||||
|   | |||||||
| @@ -195,7 +195,7 @@ PODS: | |||||||
|   - PromisesObjC (2.4.0) |   - PromisesObjC (2.4.0) | ||||||
|   - PromisesSwift (2.4.0): |   - PromisesSwift (2.4.0): | ||||||
|     - PromisesObjC (= 2.4.0) |     - PromisesObjC (= 2.4.0) | ||||||
|   - record_macos (1.0.0): |   - record_macos (1.1.0): | ||||||
|     - FlutterMacOS |     - FlutterMacOS | ||||||
|   - SAMKeychain (1.5.3) |   - SAMKeychain (1.5.3) | ||||||
|   - share_plus (0.0.1): |   - share_plus (0.0.1): | ||||||
| @@ -422,7 +422,7 @@ SPEC CHECKSUMS: | |||||||
|   path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 |   path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 | ||||||
|   PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 |   PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 | ||||||
|   PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 |   PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 | ||||||
|   record_macos: 295d70bd5fb47145df78df7b80e6697cd18403c0 |   record_macos: 43194b6c06ca6f8fa132e2acea72b202b92a0f5b | ||||||
|   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c |   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c | ||||||
|   share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc |   share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc | ||||||
|   shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 |   shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 | ||||||
|   | |||||||
							
								
								
									
										36
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										36
									
								
								pubspec.lock
									
									
									
									
									
								
							| @@ -1281,10 +1281,10 @@ packages: | |||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
|       name: image_picker_platform_interface |       name: image_picker_platform_interface | ||||||
|       sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0" |       sha256: "9f143b0dba3e459553209e20cc425c9801af48e6dfa4f01a0fcf927be3f41665" | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.10.1" |     version: "2.11.0" | ||||||
|   image_picker_windows: |   image_picker_windows: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -1897,58 +1897,58 @@ packages: | |||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
|       name: record |       name: record | ||||||
|       sha256: daeb3f9b3fea9797094433fe6e49a879d8e4ca4207740bc6dc7e4a58764f0817 |       sha256: "3d08502b77edf2a864aa6e4cd7874b983d42a80f3689431da053cc5e85c1ad21" | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "6.0.0" |     version: "6.1.0" | ||||||
|   record_android: |   record_android: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: record_android |       name: record_android | ||||||
|       sha256: "97d7122455f30de89a01c6c244c839085be6b12abca251fc0e78f67fed73628b" |       sha256: "8b170e33d9866f9b51e01a767d7e1ecb97b9ecd629950bd87a47c79359ec57f8" | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.3.3" |     version: "1.4.0" | ||||||
|   record_ios: |   record_ios: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: record_ios |       name: record_ios | ||||||
|       sha256: "73706ebbece6150654c9d6f57897cf9b622c581148304132ba85dba15df0fdfb" |       sha256: ad97d0a75933c44bcf5aff648e86e32fc05eb61f8fbef190f14968c8eaf86692 | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.0.0" |     version: "1.1.0" | ||||||
|   record_linux: |   record_linux: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: record_linux |       name: record_linux | ||||||
|       sha256: "0626678a092c75ce6af1e32fe7fd1dea709b92d308bc8e3b6d6348e2430beb95" |       sha256: "785e8e8d6db109aa606d0669d95aaae416458aaa39782bb0abe0bee74eee17d7" | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.1.1" |     version: "1.2.0" | ||||||
|   record_macos: |   record_macos: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: record_macos |       name: record_macos | ||||||
|       sha256: "02240833fde16c33fcf2c589f3e08d4394b704761b4a3bb609d872ff3043fbbd" |       sha256: f1399bca76a1634da109e5b0cba764ed8332a2b4da49c704c66d2c553405ed81 | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.0.0" |     version: "1.1.0" | ||||||
|   record_platform_interface: |   record_platform_interface: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: record_platform_interface |       name: record_platform_interface | ||||||
|       sha256: c1ad38f51e4af88a085b3e792a22c685cb3e7c23fc37aa7ce44c4cf18f25fe89 |       sha256: b0065fdf1ec28f5a634d676724d388a77e43ce7646fb049949f58c69f3fcb4ed | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.3.0" |     version: "1.4.0" | ||||||
|   record_web: |   record_web: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: record_web |       name: record_web | ||||||
|       sha256: a12856d0b3dd03d336b4b10d7520a8b3e21649a06a8f95815318feaa8f07adbb |       sha256: "4f0adf20c9ccafcc02d71111fd91fba1ca7b17a7453902593e5a9b25b74a5c56" | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.1.9" |     version: "1.2.0" | ||||||
|   record_windows: |   record_windows: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -2568,10 +2568,10 @@ packages: | |||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: vector_graphics_compiler |       name: vector_graphics_compiler | ||||||
|       sha256: "557a315b7d2a6dbb0aaaff84d857967ce6bdc96a63dc6ee2a57ce5a6ee5d3331" |       sha256: ca81fdfaf62a5ab45d7296614aea108d2c7d0efca8393e96174bf4d51e6725b0 | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.1.17" |     version: "1.1.18" | ||||||
|   vector_math: |   vector_math: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|   | |||||||
| @@ -75,7 +75,7 @@ dependencies: | |||||||
|   image_picker: ^1.1.2 |   image_picker: ^1.1.2 | ||||||
|   file_picker: ^10.3.1 |   file_picker: ^10.3.1 | ||||||
|   riverpod_annotation: ^2.6.1 |   riverpod_annotation: ^2.6.1 | ||||||
|   image_picker_platform_interface: ^2.10.1 |   image_picker_platform_interface: ^2.11.0 | ||||||
|   image_picker_android: ^0.8.12+25 |   image_picker_android: ^0.8.12+25 | ||||||
|   super_context_menu: ^0.9.1 |   super_context_menu: ^0.9.1 | ||||||
|   modal_bottom_sheet: ^3.0.0 |   modal_bottom_sheet: ^3.0.0 | ||||||
| @@ -107,7 +107,7 @@ dependencies: | |||||||
|   livekit_client: ^2.5.0+hotfix.1 |   livekit_client: ^2.5.0+hotfix.1 | ||||||
|   pasteboard: ^0.4.0 |   pasteboard: ^0.4.0 | ||||||
|   flutter_colorpicker: ^1.1.0 |   flutter_colorpicker: ^1.1.0 | ||||||
|   record: ^6.0.0 |   record: ^6.1.0 | ||||||
|   qr_flutter: ^4.1.0 |   qr_flutter: ^4.1.0 | ||||||
|   flutter_otp_text_field: ^1.5.1+1 |   flutter_otp_text_field: ^1.5.1+1 | ||||||
|   palette_generator: ^0.3.3+7 |   palette_generator: ^0.3.3+7 | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user