Compare commits
	
		
			46 Commits
		
	
	
		
			3.2.0+132
			...
			38f8103265
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 38f8103265 | |||
| 06bb18bdaa | |||
| 84c38500d0 | |||
| 9529bbf08b | |||
| 8baf77bcf7 | |||
| b2ac5fbef2 | |||
| c79b1d7aab | |||
|  | 4f55a8209c | ||
|  | ace302111a | ||
|  | 1391fa0dde | ||
|  | cbdc7acdcd | ||
|  | b80d91825a | ||
|  | 1a703b7eba | ||
|  | 3621ea7744 | ||
|  | b638343f02 | ||
|  | 269a64cabb | ||
| 406e5187a8 | |||
| 9bdd08d8dd | |||
| d737232dcf | |||
| c9d751479e | |||
| a2c2bfe585 | |||
| c7f9da0dee | |||
|  | a243cda1df | ||
|  | 7b238f32fd | ||
| 313af28d7f | |||
| c64e1e208c | |||
| c9b07a9a2a | |||
| 55c0e355f1 | |||
| be414891ec | |||
| 787876ab6a | |||
| 8578cde620 | |||
| 14d55d45a8 | |||
| 724391584e | |||
| 3a5e45808a | |||
| 488055955c | |||
|  | 313ebc64cc | ||
|  | 1ed8b1d0c1 | ||
| 4af816d931 | |||
| 1c058a4323 | |||
| 461ed1fcda | |||
| 5363afa558 | |||
| f0d2737da8 | |||
| 1f2f80aa3e | |||
| 240a872e65 | |||
| c1ec6f0849 | |||
| ab42686d4d | 
| @@ -30,6 +30,8 @@ | ||||
|   "fieldEmailAddressMustBeValid": "The email address must be valid.", | ||||
|   "logout": "Logout", | ||||
|   "updateYourProfile": "Profile Settings", | ||||
|   "settingsDefaultPool": "Default file pool", | ||||
|   "settingsDefaultPoolHelper": "Select the default storage pool for file uploads", | ||||
|   "accountBasicInfo": "Basic Info", | ||||
|   "accountProfile": "Your Profile", | ||||
|   "saveChanges": "Save Changes", | ||||
| @@ -168,6 +170,7 @@ | ||||
|   "addPhoto": "Add photo", | ||||
|   "addAudio": "Add audio", | ||||
|   "addFile": "Add file", | ||||
|   "uploadFile": "Upload File", | ||||
|   "recordAudio": "Record Audio", | ||||
|   "linkAttachment": "Link Attachment", | ||||
|   "fileIdCannotBeEmpty": "File ID cannot be empty", | ||||
| @@ -447,6 +450,8 @@ | ||||
|   "lastActiveAt": "Last active at {}", | ||||
|   "authDeviceLogout": "Logout", | ||||
|   "authDeviceLogoutHint": "Are you sure you want to logout this device? This will also disable the push notification to this device.", | ||||
|   "authDeviceChallenges": "Device Usage", | ||||
|   "authDeviceHint": "Swipe left to edit label, swipe right to logout device.", | ||||
|   "typingHint": { | ||||
|     "one": "{} is typing...", | ||||
|     "other": "{} are typing..." | ||||
|   | ||||
| @@ -122,6 +122,9 @@ | ||||
|   "addVideo": "添加视频", | ||||
|   "addPhoto": "添加照片", | ||||
|   "addFile": "添加文件", | ||||
|   "uploadFile": "上传文件", | ||||
|   "settingsDefaultPool": "选择文件池", | ||||
|   "settingsDefaultPoolHelper": "为文件上传选择一个默认池", | ||||
|   "createDirectMessage": "创建新私人消息", | ||||
|   "gotoDirectMessage": "前往私信", | ||||
|   "react": "反应", | ||||
|   | ||||
| @@ -122,6 +122,10 @@ | ||||
|     "addVideo": "添加視頻", | ||||
|     "addPhoto": "添加照片", | ||||
|     "addFile": "添加文件", | ||||
|     "uploadFile": "上傳文件", | ||||
|     "settingsDefaultPool": "選擇文件池", | ||||
|     "settingsDefaultPoolHelper": "爲文件上傳選擇一個默認池", | ||||
|   | ||||
|     "createDirectMessage": "創建新私人消息", | ||||
|     "gotoDirectMessage": "前往私信", | ||||
|     "react": "反應", | ||||
|   | ||||
| @@ -5,3 +5,7 @@ targets: | ||||
|         options: | ||||
|           explicit_to_json: true | ||||
|           field_rename: snake | ||||
|       drift_dev: | ||||
|         options: | ||||
|           databases: | ||||
|             app_database: lib/database/drift_db.dart | ||||
|   | ||||
							
								
								
									
										1
									
								
								drift_schemas/app_database/drift_schema_v6.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								drift_schemas/app_database/drift_schema_v6.json
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -149,9 +149,9 @@ PODS: | ||||
|   - flutter_udid (0.0.1): | ||||
|     - Flutter | ||||
|     - SAMKeychain | ||||
|   - flutter_webrtc (1.1.0): | ||||
|   - flutter_webrtc (1.2.0): | ||||
|     - Flutter | ||||
|     - WebRTC-SDK (= 137.7151.03) | ||||
|     - WebRTC-SDK (= 137.7151.04) | ||||
|   - gal (1.0.0): | ||||
|     - Flutter | ||||
|     - FlutterMacOS | ||||
| @@ -219,7 +219,7 @@ PODS: | ||||
|   - livekit_client (2.5.0): | ||||
|     - Flutter | ||||
|     - flutter_webrtc | ||||
|     - WebRTC-SDK (= 137.7151.03) | ||||
|     - WebRTC-SDK (= 137.7151.04) | ||||
|   - local_auth_darwin (0.0.1): | ||||
|     - Flutter | ||||
|     - FlutterMacOS | ||||
| @@ -299,7 +299,7 @@ PODS: | ||||
|     - Flutter | ||||
|   - wakelock_plus (0.0.1): | ||||
|     - Flutter | ||||
|   - WebRTC-SDK (137.7151.03) | ||||
|   - WebRTC-SDK (137.7151.04) | ||||
|  | ||||
| DEPENDENCIES: | ||||
|   - Alamofire | ||||
| @@ -499,7 +499,7 @@ SPEC CHECKSUMS: | ||||
|   flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 | ||||
|   flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 | ||||
|   flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9 | ||||
|   flutter_webrtc: b0b2e04411747142962164a1cfa43a1af9a0afac | ||||
|   flutter_webrtc: c3e21fc0dcd9d8eb246ae4d5256fcbeb2f5ecd22 | ||||
|   gal: baecd024ebfd13c441269ca7404792a7152fde89 | ||||
|   GoogleAdsOnDeviceConversion: 9090c435cde08903e8dd1ba2c77fbec9e46d9afe | ||||
|   GoogleAppMeasurement: 09f341dfa8527d1612a09cbfe809a242c0b737af | ||||
| @@ -508,8 +508,8 @@ SPEC CHECKSUMS: | ||||
|   image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a | ||||
|   irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 | ||||
|   Kingfisher: ff0d31a1f07bdff6a1ebb3ba08b8e6e567b6500c | ||||
|   livekit_client: f810c81bbbc229a84f60b09e66603ac4e93f7599 | ||||
|   local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19 | ||||
|   livekit_client: a6f5fa86ac28ccd7ded53626a5379961db311ab4 | ||||
|   local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb | ||||
|   media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 | ||||
|   media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 | ||||
|   nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 | ||||
| @@ -536,7 +536,7 @@ SPEC CHECKSUMS: | ||||
|   url_launcher_ios: 694010445543906933d732453a59da0a173ae33d | ||||
|   volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12 | ||||
|   wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 | ||||
|   WebRTC-SDK: 69d4e56b0b4b27d788e87bab9b9a1326ed05b1e3 | ||||
|   WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e | ||||
|  | ||||
| PODFILE CHECKSUM: c818292390b02fa379036ea099713a332bd7193f | ||||
|  | ||||
|   | ||||
| @@ -566,7 +566,7 @@ | ||||
| 			); | ||||
| 			runOnlyForDeploymentPostprocessing = 0; | ||||
| 			shellPath = /bin/sh; | ||||
| 			shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; | ||||
| 			shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin\n"; | ||||
| 		}; | ||||
| 		4815E0A19398E51078F4160D /* [CP] Check Pods Manifest.lock */ = { | ||||
| 			isa = PBXShellScriptBuildPhase; | ||||
| @@ -883,6 +883,7 @@ | ||||
| 				); | ||||
| 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian; | ||||
| 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||
| 				SWIFT_ENABLE_EXPLICIT_MODULES = "$(SWIFT_USE_INTEGRATED_DRIVER)"; | ||||
| 				SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; | ||||
| 				SWIFT_VERSION = 5.0; | ||||
| 				VERSIONING_SYSTEM = "apple-generic"; | ||||
| @@ -1096,6 +1097,7 @@ | ||||
| 				SKIP_INSTALL = YES; | ||||
| 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; | ||||
| 				SWIFT_EMIT_LOC_STRINGS = YES; | ||||
| 				SWIFT_ENABLE_EXPLICIT_MODULES = NO; | ||||
| 				SWIFT_OPTIMIZATION_LEVEL = "-Onone"; | ||||
| 				SWIFT_VERSION = 5.0; | ||||
| 				TARGETED_DEVICE_FAMILY = "1,2"; | ||||
| @@ -1137,6 +1139,7 @@ | ||||
| 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||
| 				SKIP_INSTALL = YES; | ||||
| 				SWIFT_EMIT_LOC_STRINGS = YES; | ||||
| 				SWIFT_ENABLE_EXPLICIT_MODULES = NO; | ||||
| 				SWIFT_VERSION = 5.0; | ||||
| 				TARGETED_DEVICE_FAMILY = "1,2"; | ||||
| 			}; | ||||
| @@ -1177,6 +1180,7 @@ | ||||
| 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||
| 				SKIP_INSTALL = YES; | ||||
| 				SWIFT_EMIT_LOC_STRINGS = YES; | ||||
| 				SWIFT_ENABLE_EXPLICIT_MODULES = NO; | ||||
| 				SWIFT_VERSION = 5.0; | ||||
| 				TARGETED_DEVICE_FAMILY = "1,2"; | ||||
| 			}; | ||||
| @@ -1434,6 +1438,7 @@ | ||||
| 				); | ||||
| 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian; | ||||
| 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||
| 				SWIFT_ENABLE_EXPLICIT_MODULES = "$(SWIFT_USE_INTEGRATED_DRIVER)"; | ||||
| 				SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; | ||||
| 				SWIFT_OPTIMIZATION_LEVEL = "-Onone"; | ||||
| 				SWIFT_VERSION = 5.0; | ||||
| @@ -1462,6 +1467,7 @@ | ||||
| 				); | ||||
| 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian; | ||||
| 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||
| 				SWIFT_ENABLE_EXPLICIT_MODULES = "$(SWIFT_USE_INTEGRATED_DRIVER)"; | ||||
| 				SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; | ||||
| 				SWIFT_VERSION = 5.0; | ||||
| 				VERSIONING_SYSTEM = "apple-generic"; | ||||
|   | ||||
| @@ -12,7 +12,7 @@ class AppDatabase extends _$AppDatabase { | ||||
|   AppDatabase(super.e); | ||||
|  | ||||
|   @override | ||||
|   int get schemaVersion => 6; | ||||
|   int get schemaVersion => 7; | ||||
|  | ||||
|   @override | ||||
|   MigrationStrategy get migration => MigrationStrategy( | ||||
| @@ -21,8 +21,8 @@ class AppDatabase extends _$AppDatabase { | ||||
|     }, | ||||
|     onUpgrade: (Migrator m, int from, int to) async { | ||||
|       if (from < 2) { | ||||
|         // Add isRead column with default value false | ||||
|         await m.addColumn(chatMessages, chatMessages.isRead); | ||||
|         // Add isDeleted column with default value false | ||||
|         await m.addColumn(chatMessages, chatMessages.isDeleted); | ||||
|       } | ||||
|       if (from < 4) { | ||||
|         // Drop old draft tables if they exist | ||||
| @@ -32,6 +32,19 @@ class AppDatabase extends _$AppDatabase { | ||||
|         // Migrate from old schema to new schema with separate searchable fields | ||||
|         await _migrateToVersion6(m); | ||||
|       } | ||||
|       if (from < 7) { | ||||
|         // Add new columns from SnChatMessage | ||||
|         await m.addColumn(chatMessages, chatMessages.updatedAt); | ||||
|         await m.addColumn(chatMessages, chatMessages.deletedAt); | ||||
|         await m.addColumn(chatMessages, chatMessages.type); | ||||
|         await m.addColumn(chatMessages, chatMessages.meta); | ||||
|         await m.addColumn(chatMessages, chatMessages.membersMentioned); | ||||
|         await m.addColumn(chatMessages, chatMessages.editedAt); | ||||
|         await m.addColumn(chatMessages, chatMessages.attachments); | ||||
|         await m.addColumn(chatMessages, chatMessages.reactions); | ||||
|         await m.addColumn(chatMessages, chatMessages.repliedMessageId); | ||||
|         await m.addColumn(chatMessages, chatMessages.forwardedMessageId); | ||||
|       } | ||||
|     }, | ||||
|   ); | ||||
|  | ||||
| @@ -116,12 +129,6 @@ class AppDatabase extends _$AppDatabase { | ||||
|     )).write(ChatMessagesCompanion(status: Value(status))); | ||||
|   } | ||||
|  | ||||
|   Future<int> markMessageAsRead(String id) { | ||||
|     return (update(chatMessages)..where( | ||||
|       (m) => m.id.equals(id), | ||||
|     )).write(ChatMessagesCompanion(isRead: const Value(true))); | ||||
|   } | ||||
|  | ||||
|   Future<int> deleteMessage(String id) { | ||||
|     return (delete(chatMessages)..where((m) => m.id.equals(id))).go(); | ||||
|   } | ||||
| @@ -134,15 +141,27 @@ class AppDatabase extends _$AppDatabase { | ||||
|  | ||||
|   Future<List<LocalChatMessage>> searchMessages( | ||||
|     String roomId, | ||||
|     String query, | ||||
|   ) async { | ||||
|     String query, { | ||||
|     bool? withAttachments, | ||||
|   }) async { | ||||
|     var selectStatement = select(chatMessages) | ||||
|       ..where((m) => m.roomId.equals(roomId)); | ||||
|  | ||||
|     if (query.isNotEmpty) { | ||||
|       final searchTerm = '%${query}%'; | ||||
|       selectStatement = | ||||
|           selectStatement | ||||
|             ..where((m) => m.content.like('%${query.toLowerCase()}%')); | ||||
|           selectStatement..where( | ||||
|             (m) => | ||||
|                 m.content.like(searchTerm) | | ||||
|                 m.meta.like(searchTerm) | | ||||
|                 m.attachments.like(searchTerm) | | ||||
|                 m.type.like(searchTerm), | ||||
|           ); | ||||
|     } | ||||
|  | ||||
|     if (withAttachments == true) { | ||||
|       selectStatement = | ||||
|           selectStatement..where((m) => m.attachments.equals('[]').not()); | ||||
|     } | ||||
|  | ||||
|     final messages = | ||||
| @@ -154,16 +173,26 @@ class AppDatabase extends _$AppDatabase { | ||||
|  | ||||
|   // Convert between Drift and model objects | ||||
|   ChatMessagesCompanion messageToCompanion(LocalChatMessage message) { | ||||
|     final remote = message.toRemoteMessage(); | ||||
|     return ChatMessagesCompanion( | ||||
|       id: Value(message.id), | ||||
|       roomId: Value(message.roomId), | ||||
|       senderId: Value(message.senderId), | ||||
|       content: Value(message.toRemoteMessage().content), | ||||
|       content: Value(remote.content), | ||||
|       nonce: Value(message.nonce), | ||||
|       data: Value(jsonEncode(message.data)), | ||||
|       createdAt: Value(message.createdAt), | ||||
|       status: Value(message.status), | ||||
|       isRead: Value(message.isRead), | ||||
|       updatedAt: Value(remote.updatedAt), | ||||
|       deletedAt: Value(remote.deletedAt), | ||||
|       type: Value(remote.type), | ||||
|       meta: Value(remote.meta), | ||||
|       membersMentioned: Value(remote.membersMentioned), | ||||
|       editedAt: Value(remote.editedAt), | ||||
|       attachments: Value(remote.attachments.map((e) => e.toJson()).toList()), | ||||
|       reactions: Value(remote.reactions.map((e) => e.toJson()).toList()), | ||||
|       repliedMessageId: Value(remote.repliedMessageId), | ||||
|       forwardedMessageId: Value(remote.forwardedMessageId), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @@ -177,7 +206,18 @@ class AppDatabase extends _$AppDatabase { | ||||
|       createdAt: dbMessage.createdAt, | ||||
|       status: dbMessage.status, | ||||
|       nonce: dbMessage.nonce, | ||||
|       isRead: dbMessage.isRead, | ||||
|       content: dbMessage.content, | ||||
|       isDeleted: dbMessage.isDeleted, | ||||
|       updatedAt: dbMessage.updatedAt, | ||||
|       deletedAt: dbMessage.deletedAt, | ||||
|       type: dbMessage.type, | ||||
|       meta: dbMessage.meta, | ||||
|       membersMentioned: dbMessage.membersMentioned, | ||||
|       editedAt: dbMessage.editedAt, | ||||
|       attachments: dbMessage.attachments, | ||||
|       reactions: dbMessage.reactions, | ||||
|       repliedMessageId: dbMessage.repliedMessageId, | ||||
|       forwardedMessageId: dbMessage.forwardedMessageId, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,7 +1,41 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:drift/drift.dart'; | ||||
| import 'package:island/models/chat.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
|  | ||||
| class MapConverter extends TypeConverter<Map<String, dynamic>, String> { | ||||
|   const MapConverter(); | ||||
|  | ||||
|   @override | ||||
|   Map<String, dynamic> fromSql(String fromDb) => json.decode(fromDb); | ||||
|  | ||||
|   @override | ||||
|   String toSql(Map<String, dynamic> value) => json.encode(value); | ||||
| } | ||||
|  | ||||
| class ListStringConverter extends TypeConverter<List<String>, String> { | ||||
|   const ListStringConverter(); | ||||
|  | ||||
|   @override | ||||
|   List<String> fromSql(String fromDb) => List<String>.from(json.decode(fromDb)); | ||||
|  | ||||
|   @override | ||||
|   String toSql(List<String> value) => json.encode(value); | ||||
| } | ||||
|  | ||||
| class ListMapConverter | ||||
|     extends TypeConverter<List<Map<String, dynamic>>, String> { | ||||
|   const ListMapConverter(); | ||||
|  | ||||
|   @override | ||||
|   List<Map<String, dynamic>> fromSql(String fromDb) => | ||||
|       List<Map<String, dynamic>>.from(json.decode(fromDb)); | ||||
|  | ||||
|   @override | ||||
|   String toSql(List<Map<String, dynamic>> value) => json.encode(value); | ||||
| } | ||||
|  | ||||
| class ChatMessages extends Table { | ||||
|   TextColumn get id => text()(); | ||||
|   TextColumn get roomId => text()(); | ||||
| @@ -11,7 +45,24 @@ class ChatMessages extends Table { | ||||
|   TextColumn get data => text()(); | ||||
|   DateTimeColumn get createdAt => dateTime()(); | ||||
|   IntColumn get status => intEnum<MessageStatus>()(); | ||||
|   BoolColumn get isRead => boolean().withDefault(const Constant(false))(); | ||||
|   BoolColumn get isDeleted => | ||||
|       boolean().nullable().withDefault(const Constant(false))(); | ||||
|   DateTimeColumn get updatedAt => dateTime().nullable()(); | ||||
|   DateTimeColumn get deletedAt => dateTime().nullable()(); | ||||
|   TextColumn get type => text().withDefault(const Constant('text'))(); | ||||
|   TextColumn get meta => | ||||
|       text().map(const MapConverter()).withDefault(const Constant('{}'))(); | ||||
|   TextColumn get membersMentioned => | ||||
|       text() | ||||
|           .map(const ListStringConverter()) | ||||
|           .withDefault(const Constant('[]'))(); | ||||
|   DateTimeColumn get editedAt => dateTime().nullable()(); | ||||
|   TextColumn get attachments => | ||||
|       text().map(const ListMapConverter()).withDefault(const Constant('[]'))(); | ||||
|   TextColumn get reactions => | ||||
|       text().map(const ListMapConverter()).withDefault(const Constant('[]'))(); | ||||
|   TextColumn get repliedMessageId => text().nullable()(); | ||||
|   TextColumn get forwardedMessageId => text().nullable()(); | ||||
|  | ||||
|   @override | ||||
|   Set<Column> get primaryKey => {id}; | ||||
| @@ -25,8 +76,19 @@ class LocalChatMessage { | ||||
|   final DateTime createdAt; | ||||
|   MessageStatus status; | ||||
|   final String? nonce; | ||||
|   final String? content; | ||||
|   final bool? isDeleted; | ||||
|   final DateTime? updatedAt; | ||||
|   final DateTime? deletedAt; | ||||
|   final String type; | ||||
|   final Map<String, dynamic> meta; | ||||
|   final List<String> membersMentioned; | ||||
|   final DateTime? editedAt; | ||||
|   final List<Map<String, dynamic>> attachments; | ||||
|   final List<Map<String, dynamic>> reactions; | ||||
|   final String? repliedMessageId; | ||||
|   final String? forwardedMessageId; | ||||
|   List<UniversalFile>? localAttachments; | ||||
|   bool isRead; | ||||
|  | ||||
|   LocalChatMessage({ | ||||
|     required this.id, | ||||
| @@ -36,8 +98,19 @@ class LocalChatMessage { | ||||
|     required this.createdAt, | ||||
|     required this.nonce, | ||||
|     required this.status, | ||||
|     this.content, | ||||
|     this.isDeleted, | ||||
|     this.updatedAt, | ||||
|     this.deletedAt, | ||||
|     required this.type, | ||||
|     required this.meta, | ||||
|     required this.membersMentioned, | ||||
|     this.editedAt, | ||||
|     required this.attachments, | ||||
|     required this.reactions, | ||||
|     this.repliedMessageId, | ||||
|     this.forwardedMessageId, | ||||
|     this.localAttachments, | ||||
|     this.isRead = false, | ||||
|   }); | ||||
|  | ||||
|   SnChatMessage toRemoteMessage() { | ||||
| @@ -48,7 +121,6 @@ class LocalChatMessage { | ||||
|     SnChatMessage message, | ||||
|     MessageStatus status, { | ||||
|     String? nonce, | ||||
|     bool isRead = false, | ||||
|   }) { | ||||
|     return LocalChatMessage( | ||||
|       id: message.id, | ||||
| @@ -58,7 +130,18 @@ class LocalChatMessage { | ||||
|       createdAt: message.createdAt, | ||||
|       status: status, | ||||
|       nonce: nonce ?? message.nonce, | ||||
|       isRead: isRead, | ||||
|       content: message.content, | ||||
|       isDeleted: false, | ||||
|       updatedAt: message.updatedAt, | ||||
|       deletedAt: null, | ||||
|       type: message.type, | ||||
|       meta: message.meta, | ||||
|       membersMentioned: message.membersMentioned, | ||||
|       editedAt: message.editedAt, | ||||
|       attachments: message.attachments.map((e) => e.toJson()).toList(), | ||||
|       reactions: message.reactions.map((e) => e.toJson()).toList(), | ||||
|       repliedMessageId: message.repliedMessageId, | ||||
|       forwardedMessageId: message.forwardedMessageId, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -181,7 +181,7 @@ class IslandApp extends HookConsumerWidget { | ||||
|     } | ||||
|  | ||||
|     useEffect(() { | ||||
|       if (!kIsWeb && Platform.isLinux) { | ||||
|       if (!kIsWeb && (Platform.isLinux || Platform.isWindows)) { | ||||
|         return null; | ||||
|       } | ||||
|  | ||||
|   | ||||
| @@ -14,11 +14,11 @@ sealed class AppToken with _$AppToken { | ||||
| @freezed | ||||
| sealed class GeoIpLocation with _$GeoIpLocation { | ||||
|   const factory GeoIpLocation({ | ||||
|     required double latitude, | ||||
|     required double longitude, | ||||
|     required String countryCode, | ||||
|     required String country, | ||||
|     required String city, | ||||
|     required double? latitude, | ||||
|     required double? longitude, | ||||
|     required String? countryCode, | ||||
|     required String? country, | ||||
|     required String? city, | ||||
|   }) = _GeoIpLocation; | ||||
|  | ||||
|   factory GeoIpLocation.fromJson(Map<String, dynamic> json) => | ||||
| @@ -29,7 +29,7 @@ sealed class GeoIpLocation with _$GeoIpLocation { | ||||
| sealed class SnAuthChallenge with _$SnAuthChallenge { | ||||
|   const factory SnAuthChallenge({ | ||||
|     required String id, | ||||
|     required DateTime expiredAt, | ||||
|     required DateTime? expiredAt, | ||||
|     required int stepRemain, | ||||
|     required int stepTotal, | ||||
|     required int failedAttempts, | ||||
| @@ -57,7 +57,7 @@ sealed class SnAuthSession with _$SnAuthSession { | ||||
|     required String id, | ||||
|     required String? label, | ||||
|     required DateTime lastGrantedAt, | ||||
|     required DateTime expiredAt, | ||||
|     required DateTime? expiredAt, | ||||
|     required String accountId, | ||||
|     required String challengeId, | ||||
|     required SnAuthChallenge challenge, | ||||
|   | ||||
| @@ -272,7 +272,7 @@ as String, | ||||
| /// @nodoc | ||||
| mixin _$GeoIpLocation { | ||||
|  | ||||
|  double get latitude; double get longitude; String get countryCode; String get country; String get city; | ||||
|  double? get latitude; double? get longitude; String? get countryCode; String? get country; String? get city; | ||||
| /// Create a copy of GeoIpLocation | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -305,7 +305,7 @@ abstract mixin class $GeoIpLocationCopyWith<$Res>  { | ||||
|   factory $GeoIpLocationCopyWith(GeoIpLocation value, $Res Function(GeoIpLocation) _then) = _$GeoIpLocationCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  double latitude, double longitude, String countryCode, String country, String city | ||||
|  double? latitude, double? longitude, String? countryCode, String? country, String? city | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -322,14 +322,14 @@ class _$GeoIpLocationCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of GeoIpLocation | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? latitude = null,Object? longitude = null,Object? countryCode = null,Object? country = null,Object? city = null,}) { | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? latitude = freezed,Object? longitude = freezed,Object? countryCode = freezed,Object? country = freezed,Object? city = freezed,}) { | ||||
|   return _then(_self.copyWith( | ||||
| latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable | ||||
| as double,longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable | ||||
| as double,countryCode: null == countryCode ? _self.countryCode : countryCode // ignore: cast_nullable_to_non_nullable | ||||
| as String,country: null == country ? _self.country : country // ignore: cast_nullable_to_non_nullable | ||||
| as String,city: null == city ? _self.city : city // ignore: cast_nullable_to_non_nullable | ||||
| as String, | ||||
| latitude: freezed == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable | ||||
| as double?,longitude: freezed == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable | ||||
| as double?,countryCode: freezed == countryCode ? _self.countryCode : countryCode // ignore: cast_nullable_to_non_nullable | ||||
| as String?,country: freezed == country ? _self.country : country // ignore: cast_nullable_to_non_nullable | ||||
| as String?,city: freezed == city ? _self.city : city // ignore: cast_nullable_to_non_nullable | ||||
| as String?, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| @@ -411,7 +411,7 @@ return $default(_that);case _: | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( double latitude,  double longitude,  String countryCode,  String country,  String city)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( double? latitude,  double? longitude,  String? countryCode,  String? country,  String? city)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _GeoIpLocation() when $default != null: | ||||
| return $default(_that.latitude,_that.longitude,_that.countryCode,_that.country,_that.city);case _: | ||||
| @@ -432,7 +432,7 @@ return $default(_that.latitude,_that.longitude,_that.countryCode,_that.country,_ | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( double latitude,  double longitude,  String countryCode,  String country,  String city)  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( double? latitude,  double? longitude,  String? countryCode,  String? country,  String? city)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _GeoIpLocation(): | ||||
| return $default(_that.latitude,_that.longitude,_that.countryCode,_that.country,_that.city);} | ||||
| @@ -449,7 +449,7 @@ return $default(_that.latitude,_that.longitude,_that.countryCode,_that.country,_ | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( double latitude,  double longitude,  String countryCode,  String country,  String city)?  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( double? latitude,  double? longitude,  String? countryCode,  String? country,  String? city)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _GeoIpLocation() when $default != null: | ||||
| return $default(_that.latitude,_that.longitude,_that.countryCode,_that.country,_that.city);case _: | ||||
| @@ -467,11 +467,11 @@ class _GeoIpLocation implements GeoIpLocation { | ||||
|   const _GeoIpLocation({required this.latitude, required this.longitude, required this.countryCode, required this.country, required this.city}); | ||||
|   factory _GeoIpLocation.fromJson(Map<String, dynamic> json) => _$GeoIpLocationFromJson(json); | ||||
|  | ||||
| @override final  double latitude; | ||||
| @override final  double longitude; | ||||
| @override final  String countryCode; | ||||
| @override final  String country; | ||||
| @override final  String city; | ||||
| @override final  double? latitude; | ||||
| @override final  double? longitude; | ||||
| @override final  String? countryCode; | ||||
| @override final  String? country; | ||||
| @override final  String? city; | ||||
|  | ||||
| /// Create a copy of GeoIpLocation | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @@ -506,7 +506,7 @@ abstract mixin class _$GeoIpLocationCopyWith<$Res> implements $GeoIpLocationCopy | ||||
|   factory _$GeoIpLocationCopyWith(_GeoIpLocation value, $Res Function(_GeoIpLocation) _then) = __$GeoIpLocationCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  double latitude, double longitude, String countryCode, String country, String city | ||||
|  double? latitude, double? longitude, String? countryCode, String? country, String? city | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -523,14 +523,14 @@ class __$GeoIpLocationCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of GeoIpLocation | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? latitude = null,Object? longitude = null,Object? countryCode = null,Object? country = null,Object? city = null,}) { | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? latitude = freezed,Object? longitude = freezed,Object? countryCode = freezed,Object? country = freezed,Object? city = freezed,}) { | ||||
|   return _then(_GeoIpLocation( | ||||
| latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable | ||||
| as double,longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable | ||||
| as double,countryCode: null == countryCode ? _self.countryCode : countryCode // ignore: cast_nullable_to_non_nullable | ||||
| as String,country: null == country ? _self.country : country // ignore: cast_nullable_to_non_nullable | ||||
| as String,city: null == city ? _self.city : city // ignore: cast_nullable_to_non_nullable | ||||
| as String, | ||||
| latitude: freezed == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable | ||||
| as double?,longitude: freezed == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable | ||||
| as double?,countryCode: freezed == countryCode ? _self.countryCode : countryCode // ignore: cast_nullable_to_non_nullable | ||||
| as String?,country: freezed == country ? _self.country : country // ignore: cast_nullable_to_non_nullable | ||||
| as String?,city: freezed == city ? _self.city : city // ignore: cast_nullable_to_non_nullable | ||||
| as String?, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| @@ -541,7 +541,7 @@ as String, | ||||
| /// @nodoc | ||||
| mixin _$SnAuthChallenge { | ||||
|  | ||||
|  String get id; DateTime get expiredAt; int get stepRemain; int get stepTotal; int get failedAttempts; int get type; List<String> get blacklistFactors; List<dynamic> get audiences; List<dynamic> get scopes; String get ipAddress; String get userAgent; String? get nonce; GeoIpLocation? get location; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; | ||||
|  String get id; DateTime? get expiredAt; int get stepRemain; int get stepTotal; int get failedAttempts; int get type; List<String> get blacklistFactors; List<dynamic> get audiences; List<dynamic> get scopes; String get ipAddress; String get userAgent; String? get nonce; GeoIpLocation? get location; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; | ||||
| /// Create a copy of SnAuthChallenge | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -574,7 +574,7 @@ abstract mixin class $SnAuthChallengeCopyWith<$Res>  { | ||||
|   factory $SnAuthChallengeCopyWith(SnAuthChallenge value, $Res Function(SnAuthChallenge) _then) = _$SnAuthChallengeCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String id, DateTime expiredAt, int stepRemain, int stepTotal, int failedAttempts, int type, List<String> blacklistFactors, List<dynamic> audiences, List<dynamic> scopes, String ipAddress, String userAgent, String? nonce, GeoIpLocation? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
|  String id, DateTime? expiredAt, int stepRemain, int stepTotal, int failedAttempts, int type, List<String> blacklistFactors, List<dynamic> audiences, List<dynamic> scopes, String ipAddress, String userAgent, String? nonce, GeoIpLocation? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -591,11 +591,11 @@ class _$SnAuthChallengeCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnAuthChallenge | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? expiredAt = null,Object? stepRemain = null,Object? stepTotal = null,Object? failedAttempts = null,Object? type = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? nonce = freezed,Object? location = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? expiredAt = freezed,Object? stepRemain = null,Object? stepTotal = null,Object? failedAttempts = null,Object? type = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? nonce = freezed,Object? location = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
|   return _then(_self.copyWith( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,stepRemain: null == stepRemain ? _self.stepRemain : stepRemain // ignore: cast_nullable_to_non_nullable | ||||
| as String,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,stepRemain: null == stepRemain ? _self.stepRemain : stepRemain // ignore: cast_nullable_to_non_nullable | ||||
| as int,stepTotal: null == stepTotal ? _self.stepTotal : stepTotal // ignore: cast_nullable_to_non_nullable | ||||
| as int,failedAttempts: null == failedAttempts ? _self.failedAttempts : failedAttempts // ignore: cast_nullable_to_non_nullable | ||||
| as int,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||
| @@ -704,7 +704,7 @@ return $default(_that);case _: | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id,  DateTime expiredAt,  int stepRemain,  int stepTotal,  int failedAttempts,  int type,  List<String> blacklistFactors,  List<dynamic> audiences,  List<dynamic> scopes,  String ipAddress,  String userAgent,  String? nonce,  GeoIpLocation? location,  String accountId,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id,  DateTime? expiredAt,  int stepRemain,  int stepTotal,  int failedAttempts,  int type,  List<String> blacklistFactors,  List<dynamic> audiences,  List<dynamic> scopes,  String ipAddress,  String userAgent,  String? nonce,  GeoIpLocation? location,  String accountId,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthChallenge() when $default != null: | ||||
| return $default(_that.id,_that.expiredAt,_that.stepRemain,_that.stepTotal,_that.failedAttempts,_that.type,_that.blacklistFactors,_that.audiences,_that.scopes,_that.ipAddress,_that.userAgent,_that.nonce,_that.location,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
| @@ -725,7 +725,7 @@ return $default(_that.id,_that.expiredAt,_that.stepRemain,_that.stepTotal,_that. | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  DateTime expiredAt,  int stepRemain,  int stepTotal,  int failedAttempts,  int type,  List<String> blacklistFactors,  List<dynamic> audiences,  List<dynamic> scopes,  String ipAddress,  String userAgent,  String? nonce,  GeoIpLocation? location,  String accountId,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  DateTime? expiredAt,  int stepRemain,  int stepTotal,  int failedAttempts,  int type,  List<String> blacklistFactors,  List<dynamic> audiences,  List<dynamic> scopes,  String ipAddress,  String userAgent,  String? nonce,  GeoIpLocation? location,  String accountId,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthChallenge(): | ||||
| return $default(_that.id,_that.expiredAt,_that.stepRemain,_that.stepTotal,_that.failedAttempts,_that.type,_that.blacklistFactors,_that.audiences,_that.scopes,_that.ipAddress,_that.userAgent,_that.nonce,_that.location,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);} | ||||
| @@ -742,7 +742,7 @@ return $default(_that.id,_that.expiredAt,_that.stepRemain,_that.stepTotal,_that. | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  DateTime expiredAt,  int stepRemain,  int stepTotal,  int failedAttempts,  int type,  List<String> blacklistFactors,  List<dynamic> audiences,  List<dynamic> scopes,  String ipAddress,  String userAgent,  String? nonce,  GeoIpLocation? location,  String accountId,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  DateTime? expiredAt,  int stepRemain,  int stepTotal,  int failedAttempts,  int type,  List<String> blacklistFactors,  List<dynamic> audiences,  List<dynamic> scopes,  String ipAddress,  String userAgent,  String? nonce,  GeoIpLocation? location,  String accountId,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthChallenge() when $default != null: | ||||
| return $default(_that.id,_that.expiredAt,_that.stepRemain,_that.stepTotal,_that.failedAttempts,_that.type,_that.blacklistFactors,_that.audiences,_that.scopes,_that.ipAddress,_that.userAgent,_that.nonce,_that.location,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
| @@ -761,7 +761,7 @@ class _SnAuthChallenge implements SnAuthChallenge { | ||||
|   factory _SnAuthChallenge.fromJson(Map<String, dynamic> json) => _$SnAuthChallengeFromJson(json); | ||||
|  | ||||
| @override final  String id; | ||||
| @override final  DateTime expiredAt; | ||||
| @override final  DateTime? expiredAt; | ||||
| @override final  int stepRemain; | ||||
| @override final  int stepTotal; | ||||
| @override final  int failedAttempts; | ||||
| @@ -829,7 +829,7 @@ abstract mixin class _$SnAuthChallengeCopyWith<$Res> implements $SnAuthChallenge | ||||
|   factory _$SnAuthChallengeCopyWith(_SnAuthChallenge value, $Res Function(_SnAuthChallenge) _then) = __$SnAuthChallengeCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String id, DateTime expiredAt, int stepRemain, int stepTotal, int failedAttempts, int type, List<String> blacklistFactors, List<dynamic> audiences, List<dynamic> scopes, String ipAddress, String userAgent, String? nonce, GeoIpLocation? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
|  String id, DateTime? expiredAt, int stepRemain, int stepTotal, int failedAttempts, int type, List<String> blacklistFactors, List<dynamic> audiences, List<dynamic> scopes, String ipAddress, String userAgent, String? nonce, GeoIpLocation? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -846,11 +846,11 @@ class __$SnAuthChallengeCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnAuthChallenge | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? expiredAt = null,Object? stepRemain = null,Object? stepTotal = null,Object? failedAttempts = null,Object? type = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? nonce = freezed,Object? location = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? expiredAt = freezed,Object? stepRemain = null,Object? stepTotal = null,Object? failedAttempts = null,Object? type = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? nonce = freezed,Object? location = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
|   return _then(_SnAuthChallenge( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,stepRemain: null == stepRemain ? _self.stepRemain : stepRemain // ignore: cast_nullable_to_non_nullable | ||||
| as String,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,stepRemain: null == stepRemain ? _self.stepRemain : stepRemain // ignore: cast_nullable_to_non_nullable | ||||
| as int,stepTotal: null == stepTotal ? _self.stepTotal : stepTotal // ignore: cast_nullable_to_non_nullable | ||||
| as int,failedAttempts: null == failedAttempts ? _self.failedAttempts : failedAttempts // ignore: cast_nullable_to_non_nullable | ||||
| as int,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||
| @@ -888,7 +888,7 @@ $GeoIpLocationCopyWith<$Res>? get location { | ||||
| /// @nodoc | ||||
| mixin _$SnAuthSession { | ||||
|  | ||||
|  String get id; String? get label; DateTime get lastGrantedAt; DateTime get expiredAt; String get accountId; String get challengeId; SnAuthChallenge get challenge; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; | ||||
|  String get id; String? get label; DateTime get lastGrantedAt; DateTime? get expiredAt; String get accountId; String get challengeId; SnAuthChallenge get challenge; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; | ||||
| /// Create a copy of SnAuthSession | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -921,7 +921,7 @@ abstract mixin class $SnAuthSessionCopyWith<$Res>  { | ||||
|   factory $SnAuthSessionCopyWith(SnAuthSession value, $Res Function(SnAuthSession) _then) = _$SnAuthSessionCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String id, String? label, DateTime lastGrantedAt, DateTime expiredAt, String accountId, String challengeId, SnAuthChallenge challenge, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
|  String id, String? label, DateTime lastGrantedAt, DateTime? expiredAt, String accountId, String challengeId, SnAuthChallenge challenge, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -938,13 +938,13 @@ class _$SnAuthSessionCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnAuthSession | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? label = freezed,Object? lastGrantedAt = null,Object? expiredAt = null,Object? accountId = null,Object? challengeId = null,Object? challenge = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? label = freezed,Object? lastGrantedAt = null,Object? expiredAt = freezed,Object? accountId = null,Object? challengeId = null,Object? challenge = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
|   return _then(_self.copyWith( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,label: freezed == label ? _self.label : label // ignore: cast_nullable_to_non_nullable | ||||
| as String?,lastGrantedAt: null == lastGrantedAt ? _self.lastGrantedAt : lastGrantedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as String,challengeId: null == challengeId ? _self.challengeId : challengeId // ignore: cast_nullable_to_non_nullable | ||||
| as String,challenge: null == challenge ? _self.challenge : challenge // ignore: cast_nullable_to_non_nullable | ||||
| as SnAuthChallenge,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| @@ -1041,7 +1041,7 @@ return $default(_that);case _: | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id,  String? label,  DateTime lastGrantedAt,  DateTime expiredAt,  String accountId,  String challengeId,  SnAuthChallenge challenge,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id,  String? label,  DateTime lastGrantedAt,  DateTime? expiredAt,  String accountId,  String challengeId,  SnAuthChallenge challenge,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthSession() when $default != null: | ||||
| return $default(_that.id,_that.label,_that.lastGrantedAt,_that.expiredAt,_that.accountId,_that.challengeId,_that.challenge,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
| @@ -1062,7 +1062,7 @@ return $default(_that.id,_that.label,_that.lastGrantedAt,_that.expiredAt,_that.a | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  String? label,  DateTime lastGrantedAt,  DateTime expiredAt,  String accountId,  String challengeId,  SnAuthChallenge challenge,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  String? label,  DateTime lastGrantedAt,  DateTime? expiredAt,  String accountId,  String challengeId,  SnAuthChallenge challenge,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthSession(): | ||||
| return $default(_that.id,_that.label,_that.lastGrantedAt,_that.expiredAt,_that.accountId,_that.challengeId,_that.challenge,_that.createdAt,_that.updatedAt,_that.deletedAt);} | ||||
| @@ -1079,7 +1079,7 @@ return $default(_that.id,_that.label,_that.lastGrantedAt,_that.expiredAt,_that.a | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  String? label,  DateTime lastGrantedAt,  DateTime expiredAt,  String accountId,  String challengeId,  SnAuthChallenge challenge,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  String? label,  DateTime lastGrantedAt,  DateTime? expiredAt,  String accountId,  String challengeId,  SnAuthChallenge challenge,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthSession() when $default != null: | ||||
| return $default(_that.id,_that.label,_that.lastGrantedAt,_that.expiredAt,_that.accountId,_that.challengeId,_that.challenge,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
| @@ -1100,7 +1100,7 @@ class _SnAuthSession implements SnAuthSession { | ||||
| @override final  String id; | ||||
| @override final  String? label; | ||||
| @override final  DateTime lastGrantedAt; | ||||
| @override final  DateTime expiredAt; | ||||
| @override final  DateTime? expiredAt; | ||||
| @override final  String accountId; | ||||
| @override final  String challengeId; | ||||
| @override final  SnAuthChallenge challenge; | ||||
| @@ -1141,7 +1141,7 @@ abstract mixin class _$SnAuthSessionCopyWith<$Res> implements $SnAuthSessionCopy | ||||
|   factory _$SnAuthSessionCopyWith(_SnAuthSession value, $Res Function(_SnAuthSession) _then) = __$SnAuthSessionCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String id, String? label, DateTime lastGrantedAt, DateTime expiredAt, String accountId, String challengeId, SnAuthChallenge challenge, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
|  String id, String? label, DateTime lastGrantedAt, DateTime? expiredAt, String accountId, String challengeId, SnAuthChallenge challenge, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -1158,13 +1158,13 @@ class __$SnAuthSessionCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnAuthSession | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? label = freezed,Object? lastGrantedAt = null,Object? expiredAt = null,Object? accountId = null,Object? challengeId = null,Object? challenge = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? label = freezed,Object? lastGrantedAt = null,Object? expiredAt = freezed,Object? accountId = null,Object? challengeId = null,Object? challenge = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
|   return _then(_SnAuthSession( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,label: freezed == label ? _self.label : label // ignore: cast_nullable_to_non_nullable | ||||
| as String?,lastGrantedAt: null == lastGrantedAt ? _self.lastGrantedAt : lastGrantedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as String,challengeId: null == challengeId ? _self.challengeId : challengeId // ignore: cast_nullable_to_non_nullable | ||||
| as String,challenge: null == challenge ? _self.challenge : challenge // ignore: cast_nullable_to_non_nullable | ||||
| as SnAuthChallenge,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
|   | ||||
| @@ -15,11 +15,11 @@ Map<String, dynamic> _$AppTokenToJson(_AppToken instance) => <String, dynamic>{ | ||||
|  | ||||
| _GeoIpLocation _$GeoIpLocationFromJson(Map<String, dynamic> json) => | ||||
|     _GeoIpLocation( | ||||
|       latitude: (json['latitude'] as num).toDouble(), | ||||
|       longitude: (json['longitude'] as num).toDouble(), | ||||
|       countryCode: json['country_code'] as String, | ||||
|       country: json['country'] as String, | ||||
|       city: json['city'] as String, | ||||
|       latitude: (json['latitude'] as num?)?.toDouble(), | ||||
|       longitude: (json['longitude'] as num?)?.toDouble(), | ||||
|       countryCode: json['country_code'] as String?, | ||||
|       country: json['country'] as String?, | ||||
|       city: json['city'] as String?, | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$GeoIpLocationToJson(_GeoIpLocation instance) => | ||||
| @@ -34,7 +34,10 @@ Map<String, dynamic> _$GeoIpLocationToJson(_GeoIpLocation instance) => | ||||
| _SnAuthChallenge _$SnAuthChallengeFromJson(Map<String, dynamic> json) => | ||||
|     _SnAuthChallenge( | ||||
|       id: json['id'] as String, | ||||
|       expiredAt: DateTime.parse(json['expired_at'] as String), | ||||
|       expiredAt: | ||||
|           json['expired_at'] == null | ||||
|               ? null | ||||
|               : DateTime.parse(json['expired_at'] as String), | ||||
|       stepRemain: (json['step_remain'] as num).toInt(), | ||||
|       stepTotal: (json['step_total'] as num).toInt(), | ||||
|       failedAttempts: (json['failed_attempts'] as num).toInt(), | ||||
| @@ -66,7 +69,7 @@ _SnAuthChallenge _$SnAuthChallengeFromJson(Map<String, dynamic> json) => | ||||
| Map<String, dynamic> _$SnAuthChallengeToJson(_SnAuthChallenge instance) => | ||||
|     <String, dynamic>{ | ||||
|       'id': instance.id, | ||||
|       'expired_at': instance.expiredAt.toIso8601String(), | ||||
|       'expired_at': instance.expiredAt?.toIso8601String(), | ||||
|       'step_remain': instance.stepRemain, | ||||
|       'step_total': instance.stepTotal, | ||||
|       'failed_attempts': instance.failedAttempts, | ||||
| @@ -89,7 +92,10 @@ _SnAuthSession _$SnAuthSessionFromJson(Map<String, dynamic> json) => | ||||
|       id: json['id'] as String, | ||||
|       label: json['label'] as String?, | ||||
|       lastGrantedAt: DateTime.parse(json['last_granted_at'] as String), | ||||
|       expiredAt: DateTime.parse(json['expired_at'] as String), | ||||
|       expiredAt: | ||||
|           json['expired_at'] == null | ||||
|               ? null | ||||
|               : DateTime.parse(json['expired_at'] as String), | ||||
|       accountId: json['account_id'] as String, | ||||
|       challengeId: json['challenge_id'] as String, | ||||
|       challenge: SnAuthChallenge.fromJson( | ||||
| @@ -108,7 +114,7 @@ Map<String, dynamic> _$SnAuthSessionToJson(_SnAuthSession instance) => | ||||
|       'id': instance.id, | ||||
|       'label': instance.label, | ||||
|       'last_granted_at': instance.lastGrantedAt.toIso8601String(), | ||||
|       'expired_at': instance.expiredAt.toIso8601String(), | ||||
|       'expired_at': instance.expiredAt?.toIso8601String(), | ||||
|       'account_id': instance.accountId, | ||||
|       'challenge_id': instance.challengeId, | ||||
|       'challenge': instance.challenge.toJson(), | ||||
|   | ||||
| @@ -40,7 +40,7 @@ sealed class SnChatMessage with _$SnChatMessage { | ||||
|     String? content, | ||||
|     String? nonce, | ||||
|     @Default({}) Map<String, dynamic> meta, | ||||
|     @Default([]) List<String> membersMetioned, | ||||
|     @Default([]) List<String> membersMentioned, | ||||
|     DateTime? editedAt, | ||||
|     @Default([]) List<SnCloudFile> attachments, | ||||
|     @Default([]) List<SnChatReaction> reactions, | ||||
| @@ -117,23 +117,10 @@ class MessageChangeAction { | ||||
|   static const String delete = "delete"; | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| sealed class MessageChange with _$MessageChange { | ||||
|   const factory MessageChange({ | ||||
|     required String messageId, | ||||
|     required String action, | ||||
|     SnChatMessage? message, | ||||
|     required DateTime timestamp, | ||||
|   }) = _MessageChange; | ||||
|  | ||||
|   factory MessageChange.fromJson(Map<String, dynamic> json) => | ||||
|       _$MessageChangeFromJson(json); | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| sealed class MessageSyncResponse with _$MessageSyncResponse { | ||||
|   const factory MessageSyncResponse({ | ||||
|     @Default([]) List<MessageChange> changes, | ||||
|     @Default([]) List<SnChatMessage> messages, | ||||
|     required DateTime currentTimestamp, | ||||
|   }) = _MessageSyncResponse; | ||||
|  | ||||
|   | ||||
| @@ -391,7 +391,7 @@ $SnRealmCopyWith<$Res>? get realm { | ||||
| /// @nodoc | ||||
| mixin _$SnChatMessage { | ||||
|  | ||||
|  DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; String get id; String get type; String? get content; String? get nonce; Map<String, dynamic> get meta; List<String> get membersMetioned; DateTime? get editedAt; List<SnCloudFile> get attachments; List<SnChatReaction> get reactions; String? get repliedMessageId; String? get forwardedMessageId; String get senderId; SnChatMember get sender; String get chatRoomId; | ||||
|  DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; String get id; String get type; String? get content; String? get nonce; Map<String, dynamic> get meta; List<String> get membersMentioned; DateTime? get editedAt; List<SnCloudFile> get attachments; List<SnChatReaction> get reactions; String? get repliedMessageId; String? get forwardedMessageId; String get senderId; SnChatMember get sender; String get chatRoomId; | ||||
| /// Create a copy of SnChatMessage | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -404,16 +404,16 @@ $SnChatMessageCopyWith<SnChatMessage> get copyWith => _$SnChatMessageCopyWithImp | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnChatMessage&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.content, content) || other.content == content)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&const DeepCollectionEquality().equals(other.meta, meta)&&const DeepCollectionEquality().equals(other.membersMetioned, membersMetioned)&&(identical(other.editedAt, editedAt) || other.editedAt == editedAt)&&const DeepCollectionEquality().equals(other.attachments, attachments)&&const DeepCollectionEquality().equals(other.reactions, reactions)&&(identical(other.repliedMessageId, repliedMessageId) || other.repliedMessageId == repliedMessageId)&&(identical(other.forwardedMessageId, forwardedMessageId) || other.forwardedMessageId == forwardedMessageId)&&(identical(other.senderId, senderId) || other.senderId == senderId)&&(identical(other.sender, sender) || other.sender == sender)&&(identical(other.chatRoomId, chatRoomId) || other.chatRoomId == chatRoomId)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnChatMessage&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.content, content) || other.content == content)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&const DeepCollectionEquality().equals(other.meta, meta)&&const DeepCollectionEquality().equals(other.membersMentioned, membersMentioned)&&(identical(other.editedAt, editedAt) || other.editedAt == editedAt)&&const DeepCollectionEquality().equals(other.attachments, attachments)&&const DeepCollectionEquality().equals(other.reactions, reactions)&&(identical(other.repliedMessageId, repliedMessageId) || other.repliedMessageId == repliedMessageId)&&(identical(other.forwardedMessageId, forwardedMessageId) || other.forwardedMessageId == forwardedMessageId)&&(identical(other.senderId, senderId) || other.senderId == senderId)&&(identical(other.sender, sender) || other.sender == sender)&&(identical(other.chatRoomId, chatRoomId) || other.chatRoomId == chatRoomId)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,type,content,nonce,const DeepCollectionEquality().hash(meta),const DeepCollectionEquality().hash(membersMetioned),editedAt,const DeepCollectionEquality().hash(attachments),const DeepCollectionEquality().hash(reactions),repliedMessageId,forwardedMessageId,senderId,sender,chatRoomId); | ||||
| int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,type,content,nonce,const DeepCollectionEquality().hash(meta),const DeepCollectionEquality().hash(membersMentioned),editedAt,const DeepCollectionEquality().hash(attachments),const DeepCollectionEquality().hash(reactions),repliedMessageId,forwardedMessageId,senderId,sender,chatRoomId); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnChatMessage(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, type: $type, content: $content, nonce: $nonce, meta: $meta, membersMetioned: $membersMetioned, editedAt: $editedAt, attachments: $attachments, reactions: $reactions, repliedMessageId: $repliedMessageId, forwardedMessageId: $forwardedMessageId, senderId: $senderId, sender: $sender, chatRoomId: $chatRoomId)'; | ||||
|   return 'SnChatMessage(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, type: $type, content: $content, nonce: $nonce, meta: $meta, membersMentioned: $membersMentioned, editedAt: $editedAt, attachments: $attachments, reactions: $reactions, repliedMessageId: $repliedMessageId, forwardedMessageId: $forwardedMessageId, senderId: $senderId, sender: $sender, chatRoomId: $chatRoomId)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -424,7 +424,7 @@ abstract mixin class $SnChatMessageCopyWith<$Res>  { | ||||
|   factory $SnChatMessageCopyWith(SnChatMessage value, $Res Function(SnChatMessage) _then) = _$SnChatMessageCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String type, String? content, String? nonce, Map<String, dynamic> meta, List<String> membersMetioned, DateTime? editedAt, List<SnCloudFile> attachments, List<SnChatReaction> reactions, String? repliedMessageId, String? forwardedMessageId, String senderId, SnChatMember sender, String chatRoomId | ||||
|  DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String type, String? content, String? nonce, Map<String, dynamic> meta, List<String> membersMentioned, DateTime? editedAt, List<SnCloudFile> attachments, List<SnChatReaction> reactions, String? repliedMessageId, String? forwardedMessageId, String senderId, SnChatMember sender, String chatRoomId | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -441,7 +441,7 @@ class _$SnChatMessageCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnChatMessage | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? type = null,Object? content = freezed,Object? nonce = freezed,Object? meta = null,Object? membersMetioned = null,Object? editedAt = freezed,Object? attachments = null,Object? reactions = null,Object? repliedMessageId = freezed,Object? forwardedMessageId = freezed,Object? senderId = null,Object? sender = null,Object? chatRoomId = null,}) { | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? type = null,Object? content = freezed,Object? nonce = freezed,Object? meta = null,Object? membersMentioned = null,Object? editedAt = freezed,Object? attachments = null,Object? reactions = null,Object? repliedMessageId = freezed,Object? forwardedMessageId = freezed,Object? senderId = null,Object? sender = null,Object? chatRoomId = null,}) { | ||||
|   return _then(_self.copyWith( | ||||
| createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
| @@ -451,7 +451,7 @@ as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non | ||||
| as String,content: freezed == content ? _self.content : content // ignore: cast_nullable_to_non_nullable | ||||
| as String?,nonce: freezed == nonce ? _self.nonce : nonce // ignore: cast_nullable_to_non_nullable | ||||
| as String?,meta: null == meta ? _self.meta : meta // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>,membersMetioned: null == membersMetioned ? _self.membersMetioned : membersMetioned // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>,membersMentioned: null == membersMentioned ? _self.membersMentioned : membersMentioned // ignore: cast_nullable_to_non_nullable | ||||
| as List<String>,editedAt: freezed == editedAt ? _self.editedAt : editedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,attachments: null == attachments ? _self.attachments : attachments // ignore: cast_nullable_to_non_nullable | ||||
| as List<SnCloudFile>,reactions: null == reactions ? _self.reactions : reactions // ignore: cast_nullable_to_non_nullable | ||||
| @@ -551,10 +551,10 @@ return $default(_that);case _: | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt,  String id,  String type,  String? content,  String? nonce,  Map<String, dynamic> meta,  List<String> membersMetioned,  DateTime? editedAt,  List<SnCloudFile> attachments,  List<SnChatReaction> reactions,  String? repliedMessageId,  String? forwardedMessageId,  String senderId,  SnChatMember sender,  String chatRoomId)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt,  String id,  String type,  String? content,  String? nonce,  Map<String, dynamic> meta,  List<String> membersMentioned,  DateTime? editedAt,  List<SnCloudFile> attachments,  List<SnChatReaction> reactions,  String? repliedMessageId,  String? forwardedMessageId,  String senderId,  SnChatMember sender,  String chatRoomId)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnChatMessage() when $default != null: | ||||
| return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.id,_that.type,_that.content,_that.nonce,_that.meta,_that.membersMetioned,_that.editedAt,_that.attachments,_that.reactions,_that.repliedMessageId,_that.forwardedMessageId,_that.senderId,_that.sender,_that.chatRoomId);case _: | ||||
| return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.id,_that.type,_that.content,_that.nonce,_that.meta,_that.membersMentioned,_that.editedAt,_that.attachments,_that.reactions,_that.repliedMessageId,_that.forwardedMessageId,_that.senderId,_that.sender,_that.chatRoomId);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| @@ -572,10 +572,10 @@ return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.id,_that.t | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt,  String id,  String type,  String? content,  String? nonce,  Map<String, dynamic> meta,  List<String> membersMetioned,  DateTime? editedAt,  List<SnCloudFile> attachments,  List<SnChatReaction> reactions,  String? repliedMessageId,  String? forwardedMessageId,  String senderId,  SnChatMember sender,  String chatRoomId)  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt,  String id,  String type,  String? content,  String? nonce,  Map<String, dynamic> meta,  List<String> membersMentioned,  DateTime? editedAt,  List<SnCloudFile> attachments,  List<SnChatReaction> reactions,  String? repliedMessageId,  String? forwardedMessageId,  String senderId,  SnChatMember sender,  String chatRoomId)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnChatMessage(): | ||||
| return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.id,_that.type,_that.content,_that.nonce,_that.meta,_that.membersMetioned,_that.editedAt,_that.attachments,_that.reactions,_that.repliedMessageId,_that.forwardedMessageId,_that.senderId,_that.sender,_that.chatRoomId);} | ||||
| return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.id,_that.type,_that.content,_that.nonce,_that.meta,_that.membersMentioned,_that.editedAt,_that.attachments,_that.reactions,_that.repliedMessageId,_that.forwardedMessageId,_that.senderId,_that.sender,_that.chatRoomId);} | ||||
| } | ||||
| /// A variant of `when` that fallback to returning `null` | ||||
| /// | ||||
| @@ -589,10 +589,10 @@ return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.id,_that.t | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt,  String id,  String type,  String? content,  String? nonce,  Map<String, dynamic> meta,  List<String> membersMetioned,  DateTime? editedAt,  List<SnCloudFile> attachments,  List<SnChatReaction> reactions,  String? repliedMessageId,  String? forwardedMessageId,  String senderId,  SnChatMember sender,  String chatRoomId)?  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt,  String id,  String type,  String? content,  String? nonce,  Map<String, dynamic> meta,  List<String> membersMentioned,  DateTime? editedAt,  List<SnCloudFile> attachments,  List<SnChatReaction> reactions,  String? repliedMessageId,  String? forwardedMessageId,  String senderId,  SnChatMember sender,  String chatRoomId)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnChatMessage() when $default != null: | ||||
| return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.id,_that.type,_that.content,_that.nonce,_that.meta,_that.membersMetioned,_that.editedAt,_that.attachments,_that.reactions,_that.repliedMessageId,_that.forwardedMessageId,_that.senderId,_that.sender,_that.chatRoomId);case _: | ||||
| return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.id,_that.type,_that.content,_that.nonce,_that.meta,_that.membersMentioned,_that.editedAt,_that.attachments,_that.reactions,_that.repliedMessageId,_that.forwardedMessageId,_that.senderId,_that.sender,_that.chatRoomId);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| @@ -604,7 +604,7 @@ return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.id,_that.t | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SnChatMessage implements SnChatMessage { | ||||
|   const _SnChatMessage({required this.createdAt, required this.updatedAt, this.deletedAt, required this.id, this.type = 'text', this.content, this.nonce, final  Map<String, dynamic> meta = const {}, final  List<String> membersMetioned = const [], this.editedAt, final  List<SnCloudFile> attachments = const [], final  List<SnChatReaction> reactions = const [], this.repliedMessageId, this.forwardedMessageId, required this.senderId, required this.sender, required this.chatRoomId}): _meta = meta,_membersMetioned = membersMetioned,_attachments = attachments,_reactions = reactions; | ||||
|   const _SnChatMessage({required this.createdAt, required this.updatedAt, this.deletedAt, required this.id, this.type = 'text', this.content, this.nonce, final  Map<String, dynamic> meta = const {}, final  List<String> membersMentioned = const [], this.editedAt, final  List<SnCloudFile> attachments = const [], final  List<SnChatReaction> reactions = const [], this.repliedMessageId, this.forwardedMessageId, required this.senderId, required this.sender, required this.chatRoomId}): _meta = meta,_membersMentioned = membersMentioned,_attachments = attachments,_reactions = reactions; | ||||
|   factory _SnChatMessage.fromJson(Map<String, dynamic> json) => _$SnChatMessageFromJson(json); | ||||
|  | ||||
| @override final  DateTime createdAt; | ||||
| @@ -621,11 +621,11 @@ class _SnChatMessage implements SnChatMessage { | ||||
|   return EqualUnmodifiableMapView(_meta); | ||||
| } | ||||
|  | ||||
|  final  List<String> _membersMetioned; | ||||
| @override@JsonKey() List<String> get membersMetioned { | ||||
|   if (_membersMetioned is EqualUnmodifiableListView) return _membersMetioned; | ||||
|  final  List<String> _membersMentioned; | ||||
| @override@JsonKey() List<String> get membersMentioned { | ||||
|   if (_membersMentioned is EqualUnmodifiableListView) return _membersMentioned; | ||||
|   // ignore: implicit_dynamic_type | ||||
|   return EqualUnmodifiableListView(_membersMetioned); | ||||
|   return EqualUnmodifiableListView(_membersMentioned); | ||||
| } | ||||
|  | ||||
| @override final  DateTime? editedAt; | ||||
| @@ -662,16 +662,16 @@ Map<String, dynamic> toJson() { | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnChatMessage&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.content, content) || other.content == content)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&const DeepCollectionEquality().equals(other._meta, _meta)&&const DeepCollectionEquality().equals(other._membersMetioned, _membersMetioned)&&(identical(other.editedAt, editedAt) || other.editedAt == editedAt)&&const DeepCollectionEquality().equals(other._attachments, _attachments)&&const DeepCollectionEquality().equals(other._reactions, _reactions)&&(identical(other.repliedMessageId, repliedMessageId) || other.repliedMessageId == repliedMessageId)&&(identical(other.forwardedMessageId, forwardedMessageId) || other.forwardedMessageId == forwardedMessageId)&&(identical(other.senderId, senderId) || other.senderId == senderId)&&(identical(other.sender, sender) || other.sender == sender)&&(identical(other.chatRoomId, chatRoomId) || other.chatRoomId == chatRoomId)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnChatMessage&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.content, content) || other.content == content)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&const DeepCollectionEquality().equals(other._meta, _meta)&&const DeepCollectionEquality().equals(other._membersMentioned, _membersMentioned)&&(identical(other.editedAt, editedAt) || other.editedAt == editedAt)&&const DeepCollectionEquality().equals(other._attachments, _attachments)&&const DeepCollectionEquality().equals(other._reactions, _reactions)&&(identical(other.repliedMessageId, repliedMessageId) || other.repliedMessageId == repliedMessageId)&&(identical(other.forwardedMessageId, forwardedMessageId) || other.forwardedMessageId == forwardedMessageId)&&(identical(other.senderId, senderId) || other.senderId == senderId)&&(identical(other.sender, sender) || other.sender == sender)&&(identical(other.chatRoomId, chatRoomId) || other.chatRoomId == chatRoomId)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,type,content,nonce,const DeepCollectionEquality().hash(_meta),const DeepCollectionEquality().hash(_membersMetioned),editedAt,const DeepCollectionEquality().hash(_attachments),const DeepCollectionEquality().hash(_reactions),repliedMessageId,forwardedMessageId,senderId,sender,chatRoomId); | ||||
| int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,type,content,nonce,const DeepCollectionEquality().hash(_meta),const DeepCollectionEquality().hash(_membersMentioned),editedAt,const DeepCollectionEquality().hash(_attachments),const DeepCollectionEquality().hash(_reactions),repliedMessageId,forwardedMessageId,senderId,sender,chatRoomId); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnChatMessage(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, type: $type, content: $content, nonce: $nonce, meta: $meta, membersMetioned: $membersMetioned, editedAt: $editedAt, attachments: $attachments, reactions: $reactions, repliedMessageId: $repliedMessageId, forwardedMessageId: $forwardedMessageId, senderId: $senderId, sender: $sender, chatRoomId: $chatRoomId)'; | ||||
|   return 'SnChatMessage(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, type: $type, content: $content, nonce: $nonce, meta: $meta, membersMentioned: $membersMentioned, editedAt: $editedAt, attachments: $attachments, reactions: $reactions, repliedMessageId: $repliedMessageId, forwardedMessageId: $forwardedMessageId, senderId: $senderId, sender: $sender, chatRoomId: $chatRoomId)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -682,7 +682,7 @@ abstract mixin class _$SnChatMessageCopyWith<$Res> implements $SnChatMessageCopy | ||||
|   factory _$SnChatMessageCopyWith(_SnChatMessage value, $Res Function(_SnChatMessage) _then) = __$SnChatMessageCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String type, String? content, String? nonce, Map<String, dynamic> meta, List<String> membersMetioned, DateTime? editedAt, List<SnCloudFile> attachments, List<SnChatReaction> reactions, String? repliedMessageId, String? forwardedMessageId, String senderId, SnChatMember sender, String chatRoomId | ||||
|  DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String type, String? content, String? nonce, Map<String, dynamic> meta, List<String> membersMentioned, DateTime? editedAt, List<SnCloudFile> attachments, List<SnChatReaction> reactions, String? repliedMessageId, String? forwardedMessageId, String senderId, SnChatMember sender, String chatRoomId | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -699,7 +699,7 @@ class __$SnChatMessageCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnChatMessage | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? type = null,Object? content = freezed,Object? nonce = freezed,Object? meta = null,Object? membersMetioned = null,Object? editedAt = freezed,Object? attachments = null,Object? reactions = null,Object? repliedMessageId = freezed,Object? forwardedMessageId = freezed,Object? senderId = null,Object? sender = null,Object? chatRoomId = null,}) { | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? type = null,Object? content = freezed,Object? nonce = freezed,Object? meta = null,Object? membersMentioned = null,Object? editedAt = freezed,Object? attachments = null,Object? reactions = null,Object? repliedMessageId = freezed,Object? forwardedMessageId = freezed,Object? senderId = null,Object? sender = null,Object? chatRoomId = null,}) { | ||||
|   return _then(_SnChatMessage( | ||||
| createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
| @@ -709,7 +709,7 @@ as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non | ||||
| as String,content: freezed == content ? _self.content : content // ignore: cast_nullable_to_non_nullable | ||||
| as String?,nonce: freezed == nonce ? _self.nonce : nonce // ignore: cast_nullable_to_non_nullable | ||||
| as String?,meta: null == meta ? _self._meta : meta // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>,membersMetioned: null == membersMetioned ? _self._membersMetioned : membersMetioned // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>,membersMentioned: null == membersMentioned ? _self._membersMentioned : membersMentioned // ignore: cast_nullable_to_non_nullable | ||||
| as List<String>,editedAt: freezed == editedAt ? _self.editedAt : editedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,attachments: null == attachments ? _self._attachments : attachments // ignore: cast_nullable_to_non_nullable | ||||
| as List<SnCloudFile>,reactions: null == reactions ? _self._reactions : reactions // ignore: cast_nullable_to_non_nullable | ||||
| @@ -1691,300 +1691,10 @@ $SnChatMessageCopyWith<$Res>? get lastMessage { | ||||
| } | ||||
|  | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$MessageChange { | ||||
|  | ||||
|  String get messageId; String get action; SnChatMessage? get message; DateTime get timestamp; | ||||
| /// Create a copy of MessageChange | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| $MessageChangeCopyWith<MessageChange> get copyWith => _$MessageChangeCopyWithImpl<MessageChange>(this as MessageChange, _$identity); | ||||
|  | ||||
|   /// Serializes this MessageChange to a JSON map. | ||||
|   Map<String, dynamic> toJson(); | ||||
|  | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is MessageChange&&(identical(other.messageId, messageId) || other.messageId == messageId)&&(identical(other.action, action) || other.action == action)&&(identical(other.message, message) || other.message == message)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,messageId,action,message,timestamp); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'MessageChange(messageId: $messageId, action: $action, message: $message, timestamp: $timestamp)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class $MessageChangeCopyWith<$Res>  { | ||||
|   factory $MessageChangeCopyWith(MessageChange value, $Res Function(MessageChange) _then) = _$MessageChangeCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String messageId, String action, SnChatMessage? message, DateTime timestamp | ||||
| }); | ||||
|  | ||||
|  | ||||
| $SnChatMessageCopyWith<$Res>? get message; | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class _$MessageChangeCopyWithImpl<$Res> | ||||
|     implements $MessageChangeCopyWith<$Res> { | ||||
|   _$MessageChangeCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final MessageChange _self; | ||||
|   final $Res Function(MessageChange) _then; | ||||
|  | ||||
| /// Create a copy of MessageChange | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? messageId = null,Object? action = null,Object? message = freezed,Object? timestamp = null,}) { | ||||
|   return _then(_self.copyWith( | ||||
| messageId: null == messageId ? _self.messageId : messageId // ignore: cast_nullable_to_non_nullable | ||||
| as String,action: null == action ? _self.action : action // ignore: cast_nullable_to_non_nullable | ||||
| as String,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable | ||||
| as SnChatMessage?,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime, | ||||
|   )); | ||||
| } | ||||
| /// Create a copy of MessageChange | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnChatMessageCopyWith<$Res>? get message { | ||||
|     if (_self.message == null) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return $SnChatMessageCopyWith<$Res>(_self.message!, (value) { | ||||
|     return _then(_self.copyWith(message: value)); | ||||
|   }); | ||||
| } | ||||
| } | ||||
|  | ||||
|  | ||||
| /// Adds pattern-matching-related methods to [MessageChange]. | ||||
| extension MessageChangePatterns on MessageChange { | ||||
| /// A variant of `map` that fallback to returning `orElse`. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return orElse(); | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _MessageChange value)?  $default,{required TResult orElse(),}){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _MessageChange() when $default != null: | ||||
| return $default(_that);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| } | ||||
| /// A `switch`-like method, using callbacks. | ||||
| /// | ||||
| /// Callbacks receives the raw object, upcasted. | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case final Subclass2 value: | ||||
| ///     return ...; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _MessageChange value)  $default,){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _MessageChange(): | ||||
| return $default(_that);} | ||||
| } | ||||
| /// A variant of `map` that fallback to returning `null`. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return null; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _MessageChange value)?  $default,){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _MessageChange() when $default != null: | ||||
| return $default(_that);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| } | ||||
| /// A variant of `when` that fallback to an `orElse` callback. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return orElse(); | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String messageId,  String action,  SnChatMessage? message,  DateTime timestamp)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _MessageChange() when $default != null: | ||||
| return $default(_that.messageId,_that.action,_that.message,_that.timestamp);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| } | ||||
| /// A `switch`-like method, using callbacks. | ||||
| /// | ||||
| /// As opposed to `map`, this offers destructuring. | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case Subclass2(:final field2): | ||||
| ///     return ...; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String messageId,  String action,  SnChatMessage? message,  DateTime timestamp)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _MessageChange(): | ||||
| return $default(_that.messageId,_that.action,_that.message,_that.timestamp);} | ||||
| } | ||||
| /// A variant of `when` that fallback to returning `null` | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return null; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String messageId,  String action,  SnChatMessage? message,  DateTime timestamp)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _MessageChange() when $default != null: | ||||
| return $default(_that.messageId,_that.action,_that.message,_that.timestamp);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| } | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _MessageChange implements MessageChange { | ||||
|   const _MessageChange({required this.messageId, required this.action, this.message, required this.timestamp}); | ||||
|   factory _MessageChange.fromJson(Map<String, dynamic> json) => _$MessageChangeFromJson(json); | ||||
|  | ||||
| @override final  String messageId; | ||||
| @override final  String action; | ||||
| @override final  SnChatMessage? message; | ||||
| @override final  DateTime timestamp; | ||||
|  | ||||
| /// Create a copy of MessageChange | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| _$MessageChangeCopyWith<_MessageChange> get copyWith => __$MessageChangeCopyWithImpl<_MessageChange>(this, _$identity); | ||||
|  | ||||
| @override | ||||
| Map<String, dynamic> toJson() { | ||||
|   return _$MessageChangeToJson(this, ); | ||||
| } | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _MessageChange&&(identical(other.messageId, messageId) || other.messageId == messageId)&&(identical(other.action, action) || other.action == action)&&(identical(other.message, message) || other.message == message)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,messageId,action,message,timestamp); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'MessageChange(messageId: $messageId, action: $action, message: $message, timestamp: $timestamp)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class _$MessageChangeCopyWith<$Res> implements $MessageChangeCopyWith<$Res> { | ||||
|   factory _$MessageChangeCopyWith(_MessageChange value, $Res Function(_MessageChange) _then) = __$MessageChangeCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String messageId, String action, SnChatMessage? message, DateTime timestamp | ||||
| }); | ||||
|  | ||||
|  | ||||
| @override $SnChatMessageCopyWith<$Res>? get message; | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class __$MessageChangeCopyWithImpl<$Res> | ||||
|     implements _$MessageChangeCopyWith<$Res> { | ||||
|   __$MessageChangeCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final _MessageChange _self; | ||||
|   final $Res Function(_MessageChange) _then; | ||||
|  | ||||
| /// Create a copy of MessageChange | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? messageId = null,Object? action = null,Object? message = freezed,Object? timestamp = null,}) { | ||||
|   return _then(_MessageChange( | ||||
| messageId: null == messageId ? _self.messageId : messageId // ignore: cast_nullable_to_non_nullable | ||||
| as String,action: null == action ? _self.action : action // ignore: cast_nullable_to_non_nullable | ||||
| as String,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable | ||||
| as SnChatMessage?,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| /// Create a copy of MessageChange | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnChatMessageCopyWith<$Res>? get message { | ||||
|     if (_self.message == null) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return $SnChatMessageCopyWith<$Res>(_self.message!, (value) { | ||||
|     return _then(_self.copyWith(message: value)); | ||||
|   }); | ||||
| } | ||||
| } | ||||
|  | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$MessageSyncResponse { | ||||
|  | ||||
|  List<MessageChange> get changes; DateTime get currentTimestamp; | ||||
|  List<SnChatMessage> get messages; DateTime get currentTimestamp; | ||||
| /// Create a copy of MessageSyncResponse | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -1997,16 +1707,16 @@ $MessageSyncResponseCopyWith<MessageSyncResponse> get copyWith => _$MessageSyncR | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is MessageSyncResponse&&const DeepCollectionEquality().equals(other.changes, changes)&&(identical(other.currentTimestamp, currentTimestamp) || other.currentTimestamp == currentTimestamp)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is MessageSyncResponse&&const DeepCollectionEquality().equals(other.messages, messages)&&(identical(other.currentTimestamp, currentTimestamp) || other.currentTimestamp == currentTimestamp)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(changes),currentTimestamp); | ||||
| int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(messages),currentTimestamp); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'MessageSyncResponse(changes: $changes, currentTimestamp: $currentTimestamp)'; | ||||
|   return 'MessageSyncResponse(messages: $messages, currentTimestamp: $currentTimestamp)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -2017,7 +1727,7 @@ abstract mixin class $MessageSyncResponseCopyWith<$Res>  { | ||||
|   factory $MessageSyncResponseCopyWith(MessageSyncResponse value, $Res Function(MessageSyncResponse) _then) = _$MessageSyncResponseCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  List<MessageChange> changes, DateTime currentTimestamp | ||||
|  List<SnChatMessage> messages, DateTime currentTimestamp | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -2034,10 +1744,10 @@ class _$MessageSyncResponseCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of MessageSyncResponse | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? changes = null,Object? currentTimestamp = null,}) { | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? messages = null,Object? currentTimestamp = null,}) { | ||||
|   return _then(_self.copyWith( | ||||
| changes: null == changes ? _self.changes : changes // ignore: cast_nullable_to_non_nullable | ||||
| as List<MessageChange>,currentTimestamp: null == currentTimestamp ? _self.currentTimestamp : currentTimestamp // ignore: cast_nullable_to_non_nullable | ||||
| messages: null == messages ? _self.messages : messages // ignore: cast_nullable_to_non_nullable | ||||
| as List<SnChatMessage>,currentTimestamp: null == currentTimestamp ? _self.currentTimestamp : currentTimestamp // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime, | ||||
|   )); | ||||
| } | ||||
| @@ -2120,10 +1830,10 @@ return $default(_that);case _: | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( List<MessageChange> changes,  DateTime currentTimestamp)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( List<SnChatMessage> messages,  DateTime currentTimestamp)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _MessageSyncResponse() when $default != null: | ||||
| return $default(_that.changes,_that.currentTimestamp);case _: | ||||
| return $default(_that.messages,_that.currentTimestamp);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| @@ -2141,10 +1851,10 @@ return $default(_that.changes,_that.currentTimestamp);case _: | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( List<MessageChange> changes,  DateTime currentTimestamp)  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( List<SnChatMessage> messages,  DateTime currentTimestamp)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _MessageSyncResponse(): | ||||
| return $default(_that.changes,_that.currentTimestamp);} | ||||
| return $default(_that.messages,_that.currentTimestamp);} | ||||
| } | ||||
| /// A variant of `when` that fallback to returning `null` | ||||
| /// | ||||
| @@ -2158,10 +1868,10 @@ return $default(_that.changes,_that.currentTimestamp);} | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( List<MessageChange> changes,  DateTime currentTimestamp)?  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( List<SnChatMessage> messages,  DateTime currentTimestamp)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _MessageSyncResponse() when $default != null: | ||||
| return $default(_that.changes,_that.currentTimestamp);case _: | ||||
| return $default(_that.messages,_that.currentTimestamp);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| @@ -2173,14 +1883,14 @@ return $default(_that.changes,_that.currentTimestamp);case _: | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _MessageSyncResponse implements MessageSyncResponse { | ||||
|   const _MessageSyncResponse({final  List<MessageChange> changes = const [], required this.currentTimestamp}): _changes = changes; | ||||
|   const _MessageSyncResponse({final  List<SnChatMessage> messages = const [], required this.currentTimestamp}): _messages = messages; | ||||
|   factory _MessageSyncResponse.fromJson(Map<String, dynamic> json) => _$MessageSyncResponseFromJson(json); | ||||
|  | ||||
|  final  List<MessageChange> _changes; | ||||
| @override@JsonKey() List<MessageChange> get changes { | ||||
|   if (_changes is EqualUnmodifiableListView) return _changes; | ||||
|  final  List<SnChatMessage> _messages; | ||||
| @override@JsonKey() List<SnChatMessage> get messages { | ||||
|   if (_messages is EqualUnmodifiableListView) return _messages; | ||||
|   // ignore: implicit_dynamic_type | ||||
|   return EqualUnmodifiableListView(_changes); | ||||
|   return EqualUnmodifiableListView(_messages); | ||||
| } | ||||
|  | ||||
| @override final  DateTime currentTimestamp; | ||||
| @@ -2198,16 +1908,16 @@ Map<String, dynamic> toJson() { | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _MessageSyncResponse&&const DeepCollectionEquality().equals(other._changes, _changes)&&(identical(other.currentTimestamp, currentTimestamp) || other.currentTimestamp == currentTimestamp)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _MessageSyncResponse&&const DeepCollectionEquality().equals(other._messages, _messages)&&(identical(other.currentTimestamp, currentTimestamp) || other.currentTimestamp == currentTimestamp)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_changes),currentTimestamp); | ||||
| int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_messages),currentTimestamp); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'MessageSyncResponse(changes: $changes, currentTimestamp: $currentTimestamp)'; | ||||
|   return 'MessageSyncResponse(messages: $messages, currentTimestamp: $currentTimestamp)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -2218,7 +1928,7 @@ abstract mixin class _$MessageSyncResponseCopyWith<$Res> implements $MessageSync | ||||
|   factory _$MessageSyncResponseCopyWith(_MessageSyncResponse value, $Res Function(_MessageSyncResponse) _then) = __$MessageSyncResponseCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  List<MessageChange> changes, DateTime currentTimestamp | ||||
|  List<SnChatMessage> messages, DateTime currentTimestamp | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -2235,10 +1945,10 @@ class __$MessageSyncResponseCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of MessageSyncResponse | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? changes = null,Object? currentTimestamp = null,}) { | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? messages = null,Object? currentTimestamp = null,}) { | ||||
|   return _then(_MessageSyncResponse( | ||||
| changes: null == changes ? _self._changes : changes // ignore: cast_nullable_to_non_nullable | ||||
| as List<MessageChange>,currentTimestamp: null == currentTimestamp ? _self.currentTimestamp : currentTimestamp // ignore: cast_nullable_to_non_nullable | ||||
| messages: null == messages ? _self._messages : messages // ignore: cast_nullable_to_non_nullable | ||||
| as List<SnChatMessage>,currentTimestamp: null == currentTimestamp ? _self.currentTimestamp : currentTimestamp // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime, | ||||
|   )); | ||||
| } | ||||
|   | ||||
| @@ -69,8 +69,8 @@ _SnChatMessage _$SnChatMessageFromJson(Map<String, dynamic> json) => | ||||
|       content: json['content'] as String?, | ||||
|       nonce: json['nonce'] as String?, | ||||
|       meta: json['meta'] as Map<String, dynamic>? ?? const {}, | ||||
|       membersMetioned: | ||||
|           (json['members_metioned'] as List<dynamic>?) | ||||
|       membersMentioned: | ||||
|           (json['members_mentioned'] as List<dynamic>?) | ||||
|               ?.map((e) => e as String) | ||||
|               .toList() ?? | ||||
|           const [], | ||||
| @@ -105,7 +105,7 @@ Map<String, dynamic> _$SnChatMessageToJson(_SnChatMessage instance) => | ||||
|       'content': instance.content, | ||||
|       'nonce': instance.nonce, | ||||
|       'meta': instance.meta, | ||||
|       'members_metioned': instance.membersMetioned, | ||||
|       'members_mentioned': instance.membersMentioned, | ||||
|       'edited_at': instance.editedAt?.toIso8601String(), | ||||
|       'attachments': instance.attachments.map((e) => e.toJson()).toList(), | ||||
|       'reactions': instance.reactions.map((e) => e.toJson()).toList(), | ||||
| @@ -227,30 +227,11 @@ Map<String, dynamic> _$SnChatSummaryToJson(_SnChatSummary instance) => | ||||
|       'last_message': instance.lastMessage?.toJson(), | ||||
|     }; | ||||
|  | ||||
| _MessageChange _$MessageChangeFromJson(Map<String, dynamic> json) => | ||||
|     _MessageChange( | ||||
|       messageId: json['message_id'] as String, | ||||
|       action: json['action'] as String, | ||||
|       message: | ||||
|           json['message'] == null | ||||
|               ? null | ||||
|               : SnChatMessage.fromJson(json['message'] as Map<String, dynamic>), | ||||
|       timestamp: DateTime.parse(json['timestamp'] as String), | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$MessageChangeToJson(_MessageChange instance) => | ||||
|     <String, dynamic>{ | ||||
|       'message_id': instance.messageId, | ||||
|       'action': instance.action, | ||||
|       'message': instance.message?.toJson(), | ||||
|       'timestamp': instance.timestamp.toIso8601String(), | ||||
|     }; | ||||
|  | ||||
| _MessageSyncResponse _$MessageSyncResponseFromJson(Map<String, dynamic> json) => | ||||
|     _MessageSyncResponse( | ||||
|       changes: | ||||
|           (json['changes'] as List<dynamic>?) | ||||
|               ?.map((e) => MessageChange.fromJson(e as Map<String, dynamic>)) | ||||
|       messages: | ||||
|           (json['messages'] as List<dynamic>?) | ||||
|               ?.map((e) => SnChatMessage.fromJson(e as Map<String, dynamic>)) | ||||
|               .toList() ?? | ||||
|           const [], | ||||
|       currentTimestamp: DateTime.parse(json['current_timestamp'] as String), | ||||
| @@ -259,7 +240,7 @@ _MessageSyncResponse _$MessageSyncResponseFromJson(Map<String, dynamic> json) => | ||||
| Map<String, dynamic> _$MessageSyncResponseToJson( | ||||
|   _MessageSyncResponse instance, | ||||
| ) => <String, dynamic>{ | ||||
|   'changes': instance.changes.map((e) => e.toJson()).toList(), | ||||
|   'messages': instance.messages.map((e) => e.toJson()).toList(), | ||||
|   'current_timestamp': instance.currentTimestamp.toIso8601String(), | ||||
| }; | ||||
|  | ||||
|   | ||||
							
								
								
									
										53
									
								
								lib/models/file_pool.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								lib/models/file_pool.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
|  | ||||
| part 'file_pool.freezed.dart'; | ||||
| part 'file_pool.g.dart'; | ||||
|  | ||||
| @freezed | ||||
| sealed class SnFilePool with _$SnFilePool { | ||||
|   const factory SnFilePool({ | ||||
|     required String id, | ||||
|     required String name, | ||||
|     String? description, | ||||
|     Map<String, dynamic>? storageConfig, | ||||
|     Map<String, dynamic>? billingConfig, | ||||
|     Map<String, dynamic>? policyConfig, | ||||
|     bool? isHidden, | ||||
|     String? accountId, | ||||
|     String? resourceIdentifier, | ||||
|     DateTime? createdAt, | ||||
|     DateTime? updatedAt, | ||||
|     DateTime? deletedAt, | ||||
|   }) = _SnFilePool; | ||||
|  | ||||
|   factory SnFilePool.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnFilePoolFromJson(json); | ||||
| } | ||||
|  | ||||
| extension SnFilePoolList on List<SnFilePool> { | ||||
|   static List<SnFilePool> listFromResponse(dynamic data) { | ||||
|     if (data is List) { | ||||
|       return data | ||||
|           .whereType<Map<String, dynamic>>() | ||||
|           .map(SnFilePool.fromJson) | ||||
|           .toList(); | ||||
|     } | ||||
|     throw ArgumentError('Unexpected response format: $data'); | ||||
|   } | ||||
|  | ||||
|   List<SnFilePool> filterValid() { | ||||
|     return where((p) { | ||||
|       final accept = p.policyConfig?['accept_types']; | ||||
|  | ||||
|       if (accept is List) { | ||||
|         final acceptsOnlyMedia = accept.every((t) => | ||||
|             t is String && | ||||
|             (t.startsWith('image/') || | ||||
|                 t.startsWith('video/') || | ||||
|                 t.startsWith('audio/'))); | ||||
|         if (acceptsOnlyMedia) return false; | ||||
|       } | ||||
|       return true; | ||||
|     }).toList(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										328
									
								
								lib/models/file_pool.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										328
									
								
								lib/models/file_pool.freezed.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,328 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| // coverage:ignore-file | ||||
| // ignore_for_file: type=lint | ||||
| // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark | ||||
|  | ||||
| part of 'file_pool.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // FreezedGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| // dart format off | ||||
| T _$identity<T>(T value) => value; | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$SnFilePool { | ||||
|  | ||||
|  String get id; String get name; String? get description; Map<String, dynamic>? get storageConfig; Map<String, dynamic>? get billingConfig; Map<String, dynamic>? get policyConfig; bool? get isHidden; String? get accountId; String? get resourceIdentifier; DateTime? get createdAt; DateTime? get updatedAt; DateTime? get deletedAt; | ||||
| /// Create a copy of SnFilePool | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnFilePoolCopyWith<SnFilePool> get copyWith => _$SnFilePoolCopyWithImpl<SnFilePool>(this as SnFilePool, _$identity); | ||||
|  | ||||
|   /// Serializes this SnFilePool to a JSON map. | ||||
|   Map<String, dynamic> toJson(); | ||||
|  | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnFilePool&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other.storageConfig, storageConfig)&&const DeepCollectionEquality().equals(other.billingConfig, billingConfig)&&const DeepCollectionEquality().equals(other.policyConfig, policyConfig)&&(identical(other.isHidden, isHidden) || other.isHidden == isHidden)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(storageConfig),const DeepCollectionEquality().hash(billingConfig),const DeepCollectionEquality().hash(policyConfig),isHidden,accountId,resourceIdentifier,createdAt,updatedAt,deletedAt); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnFilePool(id: $id, name: $name, description: $description, storageConfig: $storageConfig, billingConfig: $billingConfig, policyConfig: $policyConfig, isHidden: $isHidden, accountId: $accountId, resourceIdentifier: $resourceIdentifier, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class $SnFilePoolCopyWith<$Res>  { | ||||
|   factory $SnFilePoolCopyWith(SnFilePool value, $Res Function(SnFilePool) _then) = _$SnFilePoolCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String id, String name, String? description, Map<String, dynamic>? storageConfig, Map<String, dynamic>? billingConfig, Map<String, dynamic>? policyConfig, bool? isHidden, String? accountId, String? resourceIdentifier, DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class _$SnFilePoolCopyWithImpl<$Res> | ||||
|     implements $SnFilePoolCopyWith<$Res> { | ||||
|   _$SnFilePoolCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final SnFilePool _self; | ||||
|   final $Res Function(SnFilePool) _then; | ||||
|  | ||||
| /// Create a copy of SnFilePool | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? storageConfig = freezed,Object? billingConfig = freezed,Object? policyConfig = freezed,Object? isHidden = freezed,Object? accountId = freezed,Object? resourceIdentifier = freezed,Object? createdAt = freezed,Object? updatedAt = freezed,Object? deletedAt = freezed,}) { | ||||
|   return _then(_self.copyWith( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable | ||||
| as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable | ||||
| as String?,storageConfig: freezed == storageConfig ? _self.storageConfig : storageConfig // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>?,billingConfig: freezed == billingConfig ? _self.billingConfig : billingConfig // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>?,policyConfig: freezed == policyConfig ? _self.policyConfig : policyConfig // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>?,isHidden: freezed == isHidden ? _self.isHidden : isHidden // ignore: cast_nullable_to_non_nullable | ||||
| as bool?,accountId: freezed == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as String?,resourceIdentifier: freezed == resourceIdentifier ? _self.resourceIdentifier : resourceIdentifier // ignore: cast_nullable_to_non_nullable | ||||
| as String?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
| /// Adds pattern-matching-related methods to [SnFilePool]. | ||||
| extension SnFilePoolPatterns on SnFilePool { | ||||
| /// A variant of `map` that fallback to returning `orElse`. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return orElse(); | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SnFilePool value)?  $default,{required TResult orElse(),}){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _SnFilePool() when $default != null: | ||||
| return $default(_that);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| } | ||||
| /// A `switch`-like method, using callbacks. | ||||
| /// | ||||
| /// Callbacks receives the raw object, upcasted. | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case final Subclass2 value: | ||||
| ///     return ...; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SnFilePool value)  $default,){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _SnFilePool(): | ||||
| return $default(_that);} | ||||
| } | ||||
| /// A variant of `map` that fallback to returning `null`. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return null; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SnFilePool value)?  $default,){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _SnFilePool() when $default != null: | ||||
| return $default(_that);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| } | ||||
| /// A variant of `when` that fallback to an `orElse` callback. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return orElse(); | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id,  String name,  String? description,  Map<String, dynamic>? storageConfig,  Map<String, dynamic>? billingConfig,  Map<String, dynamic>? policyConfig,  bool? isHidden,  String? accountId,  String? resourceIdentifier,  DateTime? createdAt,  DateTime? updatedAt,  DateTime? deletedAt)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnFilePool() when $default != null: | ||||
| return $default(_that.id,_that.name,_that.description,_that.storageConfig,_that.billingConfig,_that.policyConfig,_that.isHidden,_that.accountId,_that.resourceIdentifier,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| } | ||||
| /// A `switch`-like method, using callbacks. | ||||
| /// | ||||
| /// As opposed to `map`, this offers destructuring. | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case Subclass2(:final field2): | ||||
| ///     return ...; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  String name,  String? description,  Map<String, dynamic>? storageConfig,  Map<String, dynamic>? billingConfig,  Map<String, dynamic>? policyConfig,  bool? isHidden,  String? accountId,  String? resourceIdentifier,  DateTime? createdAt,  DateTime? updatedAt,  DateTime? deletedAt)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnFilePool(): | ||||
| return $default(_that.id,_that.name,_that.description,_that.storageConfig,_that.billingConfig,_that.policyConfig,_that.isHidden,_that.accountId,_that.resourceIdentifier,_that.createdAt,_that.updatedAt,_that.deletedAt);} | ||||
| } | ||||
| /// A variant of `when` that fallback to returning `null` | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return null; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  String name,  String? description,  Map<String, dynamic>? storageConfig,  Map<String, dynamic>? billingConfig,  Map<String, dynamic>? policyConfig,  bool? isHidden,  String? accountId,  String? resourceIdentifier,  DateTime? createdAt,  DateTime? updatedAt,  DateTime? deletedAt)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnFilePool() when $default != null: | ||||
| return $default(_that.id,_that.name,_that.description,_that.storageConfig,_that.billingConfig,_that.policyConfig,_that.isHidden,_that.accountId,_that.resourceIdentifier,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| } | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SnFilePool implements SnFilePool { | ||||
|   const _SnFilePool({required this.id, required this.name, this.description, final  Map<String, dynamic>? storageConfig, final  Map<String, dynamic>? billingConfig, final  Map<String, dynamic>? policyConfig, this.isHidden, this.accountId, this.resourceIdentifier, this.createdAt, this.updatedAt, this.deletedAt}): _storageConfig = storageConfig,_billingConfig = billingConfig,_policyConfig = policyConfig; | ||||
|   factory _SnFilePool.fromJson(Map<String, dynamic> json) => _$SnFilePoolFromJson(json); | ||||
|  | ||||
| @override final  String id; | ||||
| @override final  String name; | ||||
| @override final  String? description; | ||||
|  final  Map<String, dynamic>? _storageConfig; | ||||
| @override Map<String, dynamic>? get storageConfig { | ||||
|   final value = _storageConfig; | ||||
|   if (value == null) return null; | ||||
|   if (_storageConfig is EqualUnmodifiableMapView) return _storageConfig; | ||||
|   // ignore: implicit_dynamic_type | ||||
|   return EqualUnmodifiableMapView(value); | ||||
| } | ||||
|  | ||||
|  final  Map<String, dynamic>? _billingConfig; | ||||
| @override Map<String, dynamic>? get billingConfig { | ||||
|   final value = _billingConfig; | ||||
|   if (value == null) return null; | ||||
|   if (_billingConfig is EqualUnmodifiableMapView) return _billingConfig; | ||||
|   // ignore: implicit_dynamic_type | ||||
|   return EqualUnmodifiableMapView(value); | ||||
| } | ||||
|  | ||||
|  final  Map<String, dynamic>? _policyConfig; | ||||
| @override Map<String, dynamic>? get policyConfig { | ||||
|   final value = _policyConfig; | ||||
|   if (value == null) return null; | ||||
|   if (_policyConfig is EqualUnmodifiableMapView) return _policyConfig; | ||||
|   // ignore: implicit_dynamic_type | ||||
|   return EqualUnmodifiableMapView(value); | ||||
| } | ||||
|  | ||||
| @override final  bool? isHidden; | ||||
| @override final  String? accountId; | ||||
| @override final  String? resourceIdentifier; | ||||
| @override final  DateTime? createdAt; | ||||
| @override final  DateTime? updatedAt; | ||||
| @override final  DateTime? deletedAt; | ||||
|  | ||||
| /// Create a copy of SnFilePool | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| _$SnFilePoolCopyWith<_SnFilePool> get copyWith => __$SnFilePoolCopyWithImpl<_SnFilePool>(this, _$identity); | ||||
|  | ||||
| @override | ||||
| Map<String, dynamic> toJson() { | ||||
|   return _$SnFilePoolToJson(this, ); | ||||
| } | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnFilePool&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other._storageConfig, _storageConfig)&&const DeepCollectionEquality().equals(other._billingConfig, _billingConfig)&&const DeepCollectionEquality().equals(other._policyConfig, _policyConfig)&&(identical(other.isHidden, isHidden) || other.isHidden == isHidden)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(_storageConfig),const DeepCollectionEquality().hash(_billingConfig),const DeepCollectionEquality().hash(_policyConfig),isHidden,accountId,resourceIdentifier,createdAt,updatedAt,deletedAt); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnFilePool(id: $id, name: $name, description: $description, storageConfig: $storageConfig, billingConfig: $billingConfig, policyConfig: $policyConfig, isHidden: $isHidden, accountId: $accountId, resourceIdentifier: $resourceIdentifier, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class _$SnFilePoolCopyWith<$Res> implements $SnFilePoolCopyWith<$Res> { | ||||
|   factory _$SnFilePoolCopyWith(_SnFilePool value, $Res Function(_SnFilePool) _then) = __$SnFilePoolCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String id, String name, String? description, Map<String, dynamic>? storageConfig, Map<String, dynamic>? billingConfig, Map<String, dynamic>? policyConfig, bool? isHidden, String? accountId, String? resourceIdentifier, DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class __$SnFilePoolCopyWithImpl<$Res> | ||||
|     implements _$SnFilePoolCopyWith<$Res> { | ||||
|   __$SnFilePoolCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final _SnFilePool _self; | ||||
|   final $Res Function(_SnFilePool) _then; | ||||
|  | ||||
| /// Create a copy of SnFilePool | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? storageConfig = freezed,Object? billingConfig = freezed,Object? policyConfig = freezed,Object? isHidden = freezed,Object? accountId = freezed,Object? resourceIdentifier = freezed,Object? createdAt = freezed,Object? updatedAt = freezed,Object? deletedAt = freezed,}) { | ||||
|   return _then(_SnFilePool( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable | ||||
| as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable | ||||
| as String?,storageConfig: freezed == storageConfig ? _self._storageConfig : storageConfig // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>?,billingConfig: freezed == billingConfig ? _self._billingConfig : billingConfig // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>?,policyConfig: freezed == policyConfig ? _self._policyConfig : policyConfig // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>?,isHidden: freezed == isHidden ? _self.isHidden : isHidden // ignore: cast_nullable_to_non_nullable | ||||
| as bool?,accountId: freezed == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as String?,resourceIdentifier: freezed == resourceIdentifier ? _self.resourceIdentifier : resourceIdentifier // ignore: cast_nullable_to_non_nullable | ||||
| as String?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?, | ||||
|   )); | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| // dart format on | ||||
							
								
								
									
										47
									
								
								lib/models/file_pool.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								lib/models/file_pool.g.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
|  | ||||
| part of 'file_pool.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // JsonSerializableGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| _SnFilePool _$SnFilePoolFromJson(Map<String, dynamic> json) => _SnFilePool( | ||||
|   id: json['id'] as String, | ||||
|   name: json['name'] as String, | ||||
|   description: json['description'] as String?, | ||||
|   storageConfig: json['storage_config'] as Map<String, dynamic>?, | ||||
|   billingConfig: json['billing_config'] as Map<String, dynamic>?, | ||||
|   policyConfig: json['policy_config'] as Map<String, dynamic>?, | ||||
|   isHidden: json['is_hidden'] as bool?, | ||||
|   accountId: json['account_id'] as String?, | ||||
|   resourceIdentifier: json['resource_identifier'] as String?, | ||||
|   createdAt: | ||||
|       json['created_at'] == null | ||||
|           ? null | ||||
|           : DateTime.parse(json['created_at'] as String), | ||||
|   updatedAt: | ||||
|       json['updated_at'] == null | ||||
|           ? null | ||||
|           : DateTime.parse(json['updated_at'] as String), | ||||
|   deletedAt: | ||||
|       json['deleted_at'] == null | ||||
|           ? null | ||||
|           : DateTime.parse(json['deleted_at'] as String), | ||||
| ); | ||||
|  | ||||
| Map<String, dynamic> _$SnFilePoolToJson(_SnFilePool instance) => | ||||
|     <String, dynamic>{ | ||||
|       'id': instance.id, | ||||
|       'name': instance.name, | ||||
|       'description': instance.description, | ||||
|       'storage_config': instance.storageConfig, | ||||
|       'billing_config': instance.billingConfig, | ||||
|       'policy_config': instance.policyConfig, | ||||
|       'is_hidden': instance.isHidden, | ||||
|       'account_id': instance.accountId, | ||||
|       'resource_identifier': instance.resourceIdentifier, | ||||
|       'created_at': instance.createdAt?.toIso8601String(), | ||||
|       'updated_at': instance.updatedAt?.toIso8601String(), | ||||
|       'deleted_at': instance.deletedAt?.toIso8601String(), | ||||
|     }; | ||||
| @@ -9,7 +9,9 @@ import 'package:shelf/shelf.dart'; | ||||
| import 'package:shelf/shelf_io.dart' as shelf_io; | ||||
| import 'package:shelf_web_socket/shelf_web_socket.dart'; | ||||
| import 'package:web_socket_channel/web_socket_channel.dart'; | ||||
| import 'package:path/path.dart' as path; | ||||
| 
 | ||||
| // Conditional imports for IPC server - use web stubs on web platform | ||||
| import 'ipc_server.dart' if (dart.library.html) 'ipc_server.web.dart'; | ||||
| 
 | ||||
| const String kRpcLogPrefix = 'arRPC.websocket'; | ||||
| const String kRpcIpcLogPrefix = 'arRPC.ipc'; | ||||
| @@ -43,14 +45,14 @@ class IpcErrorCodes { | ||||
|   static const int invalidEncoding = 4005; | ||||
| } | ||||
| 
 | ||||
| // Reference https://github.com/OpenAsar/arrpc/blob/main/src/transports/ipc.js | ||||
| class ActivityRpcServer { | ||||
|   static const List<int> portRange = [6463, 6472]; // Ports 6463–6472 | ||||
|   Map<String, Function> | ||||
|   handlers; // {connection: (socket), message: (socket, data), close: (socket)} | ||||
|   HttpServer? _httpServer; | ||||
|   ServerSocket? _ipcServer; | ||||
|   IpcServer? _ipcServer; | ||||
|   final List<WebSocketChannel> _wsSockets = []; | ||||
|   final List<_IpcSocketWrapper> _ipcSockets = []; | ||||
| 
 | ||||
|   ActivityRpcServer(this.handlers); | ||||
| 
 | ||||
| @@ -58,109 +60,20 @@ class ActivityRpcServer { | ||||
|     handlers = newHandlers; | ||||
|   } | ||||
| 
 | ||||
|   // Encode IPC packet | ||||
|   static Uint8List encodeIpcPacket(int type, Map<String, dynamic> data) { | ||||
|     final jsonData = jsonEncode(data); | ||||
|     final dataBytes = utf8.encode(jsonData); | ||||
|     final dataSize = dataBytes.length; | ||||
| 
 | ||||
|     final buffer = ByteData(8 + dataSize); | ||||
|     buffer.setInt32(0, type, Endian.little); | ||||
|     buffer.setInt32(4, dataSize, Endian.little); | ||||
|     buffer.buffer.asUint8List().setRange(8, 8 + dataSize, dataBytes); | ||||
| 
 | ||||
|     return buffer.buffer.asUint8List(); | ||||
|   } | ||||
| 
 | ||||
|   Future<String> _getMacOsSystemTmpDir() async { | ||||
|     final result = await Process.run('getconf', ['DARWIN_USER_TEMP_DIR']); | ||||
|     return (result.stdout as String).trim(); | ||||
|   } | ||||
| 
 | ||||
|   // Find available IPC socket path | ||||
|   Future<String> _findAvailableIpcPath() async { | ||||
|     // Build list of directories to try, with macOS-specific handling | ||||
|     final baseDirs = <String>[]; | ||||
| 
 | ||||
|     if (Platform.isMacOS) { | ||||
|       try { | ||||
|         final macTempDir = await _getMacOsSystemTmpDir(); | ||||
|         if (macTempDir.isNotEmpty) { | ||||
|           baseDirs.add(macTempDir); | ||||
|         } | ||||
|       } catch (e) { | ||||
|         developer.log( | ||||
|           'Failed to get macOS system temp dir: $e', | ||||
|           name: kRpcIpcLogPrefix, | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // Add other standard directories | ||||
|     final otherDirs = [ | ||||
|       Platform.environment['XDG_RUNTIME_DIR'], // User runtime directory | ||||
|       Platform.environment['TMPDIR'], // App container temp (fallback) | ||||
|       Platform.environment['TMP'], | ||||
|       Platform.environment['TEMP'], | ||||
|       '/tmp', // System temp directory - most compatible | ||||
|     ]; | ||||
| 
 | ||||
|     baseDirs.addAll( | ||||
|       otherDirs.where((dir) => dir != null && dir.isNotEmpty).cast<String>(), | ||||
|     ); | ||||
| 
 | ||||
|     for (final baseDir in baseDirs) { | ||||
|       for (int i = 0; i < 10; i++) { | ||||
|         final socketPath = path.join(baseDir, '$kIpcBasePath-$i'); | ||||
|         try { | ||||
|           final socket = await ServerSocket.bind( | ||||
|             InternetAddress(socketPath, type: InternetAddressType.unix), | ||||
|             0, | ||||
|           ); | ||||
|           socket.close(); | ||||
|           // Clean up the test socket | ||||
|           try { | ||||
|             await File(socketPath).delete(); | ||||
|           } catch (_) {} | ||||
|           developer.log( | ||||
|             'IPC socket will be created at: $socketPath', | ||||
|             name: kRpcIpcLogPrefix, | ||||
|           ); | ||||
|           return socketPath; | ||||
|         } catch (e) { | ||||
|           // Path not available, try next | ||||
|           if (i == 0) { | ||||
|             // Log only for the first attempt per directory | ||||
|             developer.log( | ||||
|               'IPC path $socketPath not available: $e', | ||||
|               name: kRpcIpcLogPrefix, | ||||
|             ); | ||||
|           } | ||||
|           continue; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     throw Exception( | ||||
|       'No available IPC socket paths found in any temp directory', | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   // Start the WebSocket server | ||||
|   // Start the server | ||||
|   Future<void> start() async { | ||||
|     int port = portRange[0]; | ||||
|     bool wsSuccess = false; | ||||
| 
 | ||||
|     // Start WebSocket server | ||||
|     while (port <= portRange[1]) { | ||||
|       developer.log('trying port $port', name: kRpcLogPrefix); | ||||
|       developer.log('Trying port $port', name: kRpcLogPrefix); | ||||
|       try { | ||||
|         // Start HTTP server | ||||
|         _httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, port); | ||||
|         developer.log('listening on $port', name: kRpcLogPrefix); | ||||
|         developer.log('Listening on $port', name: kRpcLogPrefix); | ||||
| 
 | ||||
|         // Handle WebSocket upgrades | ||||
|         shelf_io.serveRequests(_httpServer!, (Request request) async { | ||||
|           developer.log('new request', name: kRpcLogPrefix); | ||||
|           developer.log('New request', name: kRpcLogPrefix); | ||||
|           if (request.headers['upgrade']?.toLowerCase() == 'websocket') { | ||||
|             final handler = webSocketHandler((WebSocketChannel channel, _) { | ||||
|               _wsSockets.add(channel); | ||||
| @@ -169,7 +82,7 @@ class ActivityRpcServer { | ||||
|             return handler(request); | ||||
|           } | ||||
|           developer.log( | ||||
|             'new request disposed due to not websocket', | ||||
|             'New request disposed due to not websocket', | ||||
|             name: kRpcLogPrefix, | ||||
|           ); | ||||
|           return Response.notFound('Not a WebSocket request'); | ||||
| @@ -178,12 +91,12 @@ class ActivityRpcServer { | ||||
|         break; | ||||
|       } catch (e) { | ||||
|         if (e is SocketException && e.osError?.errorCode == 98) { | ||||
|           // EADDRINUSE | ||||
|           developer.log('$port in use!', name: kRpcLogPrefix); | ||||
|         } else { | ||||
|           developer.log('http error: $e', name: kRpcLogPrefix); | ||||
|           developer.log('HTTP error: $e', name: kRpcLogPrefix); | ||||
|         } | ||||
|         port++; | ||||
|         await Future.delayed(Duration(milliseconds: 100)); // Add delay | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
| @@ -193,27 +106,24 @@ class ActivityRpcServer { | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     // Start IPC server (skip on macOS due to sandboxing) | ||||
|     final shouldStartIpc = !Platform.isMacOS; | ||||
|     // Start IPC server | ||||
|     final shouldStartIpc = !Platform.isMacOS && !kIsWeb; | ||||
|     if (shouldStartIpc) { | ||||
|       try { | ||||
|         final ipcPath = await _findAvailableIpcPath(); | ||||
|         _ipcServer = await ServerSocket.bind( | ||||
|           InternetAddress(ipcPath, type: InternetAddressType.unix), | ||||
|           0, | ||||
|         ); | ||||
|         developer.log('IPC listening at $ipcPath', name: kRpcIpcLogPrefix); | ||||
|         _ipcServer = MultiPlatformIpcServer(); | ||||
| 
 | ||||
|         _ipcServer!.listen((Socket socket) { | ||||
|           _onIpcConnection(socket); | ||||
|         }); | ||||
|         // Set up IPC handlers | ||||
|         _ipcServer!.handlePacket = (socket, packet, _) { | ||||
|           _handleIpcPacket(socket, packet); | ||||
|         }; | ||||
| 
 | ||||
|         await _ipcServer!.start(); | ||||
|       } catch (e) { | ||||
|         developer.log('IPC server error: $e', name: kRpcIpcLogPrefix); | ||||
|         // Continue without IPC if it fails | ||||
|       } | ||||
|     } else { | ||||
|       developer.log( | ||||
|         'IPC server disabled on macOS in production mode due to sandboxing', | ||||
|         'IPC server disabled on macOS or web in production mode', | ||||
|         name: kRpcIpcLogPrefix, | ||||
|       ); | ||||
|     } | ||||
| @@ -223,24 +133,23 @@ class ActivityRpcServer { | ||||
|   Future<void> stop() async { | ||||
|     // Stop WebSocket server | ||||
|     for (var socket in _wsSockets) { | ||||
|       await socket.sink.close(); | ||||
|       try { | ||||
|         await socket.sink.close(); | ||||
|       } catch (e) { | ||||
|         developer.log('Error closing WebSocket: $e', name: kRpcLogPrefix); | ||||
|       } | ||||
|     } | ||||
|     _wsSockets.clear(); | ||||
|     await _httpServer?.close(); | ||||
|     await _httpServer?.close(force: true); | ||||
| 
 | ||||
|     // Stop IPC server | ||||
|     for (var socket in _ipcSockets) { | ||||
|       socket.close(); | ||||
|     } | ||||
|     _ipcSockets.clear(); | ||||
|     await _ipcServer?.close(); | ||||
|     await _ipcServer?.stop(); | ||||
| 
 | ||||
|     developer.log('servers stopped', name: kRpcLogPrefix); | ||||
|     developer.log('Servers stopped', name: kRpcLogPrefix); | ||||
|   } | ||||
| 
 | ||||
|   // Handle new WebSocket connection | ||||
|   void _onWsConnection(WebSocketChannel socket, Request request) { | ||||
|     // Parse query parameters | ||||
|     final uri = request.url; | ||||
|     final params = uri.queryParameters; | ||||
|     final ver = int.tryParse(params['v'] ?? '1') ?? 1; | ||||
| @@ -249,43 +158,38 @@ class ActivityRpcServer { | ||||
|     final origin = request.headers['origin'] ?? ''; | ||||
| 
 | ||||
|     developer.log( | ||||
|       'new WS connection! origin: $origin, params: $params', | ||||
|       'New WS connection! origin: $origin, params: $params', | ||||
|       name: kRpcLogPrefix, | ||||
|     ); | ||||
| 
 | ||||
|     // Validate origin | ||||
|     if (origin.isNotEmpty && | ||||
|         ![ | ||||
|           'https://discord.com', | ||||
|           'https://ptb.discord.com', | ||||
|           'https://canary.discord.com', | ||||
|         ].contains(origin)) { | ||||
|       developer.log('disallowed origin: $origin', name: kRpcLogPrefix); | ||||
|       developer.log('Disallowed origin: $origin', name: kRpcLogPrefix); | ||||
|       socket.sink.close(); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     // Validate encoding | ||||
|     if (encoding != 'json') { | ||||
|       developer.log( | ||||
|         'unsupported encoding requested: $encoding', | ||||
|         'Unsupported encoding requested: $encoding', | ||||
|         name: kRpcLogPrefix, | ||||
|       ); | ||||
|       socket.sink.close(); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     // Validate version | ||||
|     if (ver != 1) { | ||||
|       developer.log('unsupported version requested: $ver', name: kRpcLogPrefix); | ||||
|       developer.log('Unsupported version requested: $ver', name: kRpcLogPrefix); | ||||
|       socket.sink.close(); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     // Store client info on socket | ||||
|     final socketWithMeta = _WsSocketWrapper(socket, clientId, encoding); | ||||
| 
 | ||||
|     // Set up event listeners | ||||
|     socket.stream.listen( | ||||
|       (data) => _onWsMessage(socketWithMeta, data), | ||||
|       onError: (e) { | ||||
| @@ -298,36 +202,27 @@ class ActivityRpcServer { | ||||
|       }, | ||||
|     ); | ||||
| 
 | ||||
|     // Notify handler of new connection | ||||
|     handlers['connection']?.call(socketWithMeta); | ||||
|   } | ||||
| 
 | ||||
|   // Handle new IPC connection | ||||
|   void _onIpcConnection(Socket socket) { | ||||
|     developer.log('new IPC connection!', name: kRpcIpcLogPrefix); | ||||
| 
 | ||||
|     final socketWrapper = _IpcSocketWrapper(socket); | ||||
|     _ipcSockets.add(socketWrapper); | ||||
| 
 | ||||
|     // Set up event listeners | ||||
|     socket.listen( | ||||
|       (data) => _onIpcData(socketWrapper, data), | ||||
|       onError: (e) { | ||||
|         developer.log('IPC socket error: $e', name: kRpcIpcLogPrefix); | ||||
|         socket.close(); | ||||
|       }, | ||||
|       onDone: () { | ||||
|         developer.log('IPC socket closed', name: kRpcIpcLogPrefix); | ||||
|         handlers['close']?.call(socketWrapper); | ||||
|         _ipcSockets.remove(socketWrapper); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   // Handle incoming WebSocket message | ||||
|   void _onWsMessage(_WsSocketWrapper socket, dynamic data) { | ||||
|   Future<void> _onWsMessage(_WsSocketWrapper socket, dynamic data) async { | ||||
|     if (data is! String) { | ||||
|       developer.log( | ||||
|         'Invalid WebSocket message: not a string', | ||||
|         name: kRpcLogPrefix, | ||||
|       ); | ||||
|       return; | ||||
|     } | ||||
|     try { | ||||
|       final jsonData = jsonDecode(data as String); | ||||
|       final jsonData = await compute(jsonDecode, data); | ||||
|       if (jsonData is! Map<String, dynamic>) { | ||||
|         developer.log( | ||||
|           'Invalid WebSocket message: not a JSON object', | ||||
|           name: kRpcLogPrefix, | ||||
|         ); | ||||
|         return; | ||||
|       } | ||||
|       developer.log('WS message: $jsonData', name: kRpcLogPrefix); | ||||
|       handlers['message']?.call(socket, jsonData); | ||||
|     } catch (e) { | ||||
| @@ -335,22 +230,8 @@ class ActivityRpcServer { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   // Handle incoming IPC data | ||||
|   void _onIpcData(_IpcSocketWrapper socket, List<int> data) { | ||||
|     try { | ||||
|       socket.addData(data); | ||||
|       final packets = socket.readPackets(); | ||||
|       for (final packet in packets) { | ||||
|         _handleIpcPacket(socket, packet); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       developer.log('IPC data error: $e', name: kRpcIpcLogPrefix); | ||||
|       socket.closeWithCode(IpcCloseCodes.closeUnsupported, e.toString()); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   // Handle IPC packet | ||||
|   void _handleIpcPacket(_IpcSocketWrapper socket, _IpcPacket packet) { | ||||
|   void _handleIpcPacket(IpcSocketWrapper socket, IpcPacket packet) { | ||||
|     switch (packet.type) { | ||||
|       case IpcTypes.ping: | ||||
|         developer.log('IPC ping received', name: kRpcIpcLogPrefix); | ||||
| @@ -359,7 +240,6 @@ class ActivityRpcServer { | ||||
| 
 | ||||
|       case IpcTypes.pong: | ||||
|         developer.log('IPC pong received', name: kRpcIpcLogPrefix); | ||||
|         // Handle pong if needed | ||||
|         break; | ||||
| 
 | ||||
|       case IpcTypes.handshake: | ||||
| @@ -388,13 +268,12 @@ class ActivityRpcServer { | ||||
|   } | ||||
| 
 | ||||
|   // Handle IPC handshake | ||||
|   void _onIpcHandshake(_IpcSocketWrapper socket, Map<String, dynamic> params) { | ||||
|   void _onIpcHandshake(IpcSocketWrapper socket, Map<String, dynamic> params) { | ||||
|     developer.log('IPC handshake: $params', name: kRpcIpcLogPrefix); | ||||
| 
 | ||||
|     final ver = int.tryParse(params['v']?.toString() ?? '1') ?? 1; | ||||
|     final clientId = params['client_id']?.toString() ?? ''; | ||||
| 
 | ||||
|     // Validate version | ||||
|     if (ver != 1) { | ||||
|       developer.log( | ||||
|         'IPC unsupported version requested: $ver', | ||||
| @@ -404,7 +283,6 @@ class ActivityRpcServer { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     // Validate client ID | ||||
|     if (clientId.isEmpty) { | ||||
|       developer.log('IPC client ID required', name: kRpcIpcLogPrefix); | ||||
|       socket.closeWithCode(IpcErrorCodes.invalidClientId); | ||||
| @@ -413,7 +291,6 @@ class ActivityRpcServer { | ||||
| 
 | ||||
|     socket.clientId = clientId; | ||||
| 
 | ||||
|     // Notify handler of new connection | ||||
|     handlers['connection']?.call(socket); | ||||
|   } | ||||
| } | ||||
| @@ -432,74 +309,6 @@ class _WsSocketWrapper { | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // IPC wrapper | ||||
| class _IpcSocketWrapper { | ||||
|   final Socket socket; | ||||
|   String clientId = ''; | ||||
|   bool handshook = false; | ||||
|   final List<int> _buffer = []; | ||||
| 
 | ||||
|   _IpcSocketWrapper(this.socket); | ||||
| 
 | ||||
|   void addData(List<int> data) { | ||||
|     _buffer.addAll(data); | ||||
|   } | ||||
| 
 | ||||
|   void send(Map<String, dynamic> msg) { | ||||
|     developer.log('IPC sending: $msg', name: kRpcIpcLogPrefix); | ||||
|     final packet = ActivityRpcServer.encodeIpcPacket(IpcTypes.frame, msg); | ||||
|     socket.add(packet); | ||||
|   } | ||||
| 
 | ||||
|   void sendPong(dynamic data) { | ||||
|     final packet = ActivityRpcServer.encodeIpcPacket(IpcTypes.pong, data ?? {}); | ||||
|     socket.add(packet); | ||||
|   } | ||||
| 
 | ||||
|   void close() { | ||||
|     socket.close(); | ||||
|   } | ||||
| 
 | ||||
|   void closeWithCode(int code, [String message = '']) { | ||||
|     final closeData = {'code': code, 'message': message}; | ||||
|     final packet = ActivityRpcServer.encodeIpcPacket(IpcTypes.close, closeData); | ||||
|     socket.add(packet); | ||||
|     socket.close(); | ||||
|   } | ||||
| 
 | ||||
|   List<_IpcPacket> readPackets() { | ||||
|     final packets = <_IpcPacket>[]; | ||||
| 
 | ||||
|     while (_buffer.length >= 8) { | ||||
|       final buffer = Uint8List.fromList(_buffer); | ||||
|       final byteData = ByteData.view(buffer.buffer); | ||||
| 
 | ||||
|       final type = byteData.getInt32(0, Endian.little); | ||||
|       final dataSize = byteData.getInt32(4, Endian.little); | ||||
| 
 | ||||
|       if (_buffer.length < 8 + dataSize) break; | ||||
| 
 | ||||
|       final dataBytes = _buffer.sublist(8, 8 + dataSize); | ||||
|       final jsonStr = utf8.decode(dataBytes); | ||||
|       final jsonData = jsonDecode(jsonStr); | ||||
| 
 | ||||
|       packets.add(_IpcPacket(type, jsonData)); | ||||
| 
 | ||||
|       _buffer.removeRange(0, 8 + dataSize); | ||||
|     } | ||||
| 
 | ||||
|     return packets; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // IPC Packet structure | ||||
| class _IpcPacket { | ||||
|   final int type; | ||||
|   final Map<String, dynamic> data; | ||||
| 
 | ||||
|   _IpcPacket(this.type, this.data); | ||||
| } | ||||
| 
 | ||||
| // State management for server status and activities | ||||
| class ServerState { | ||||
|   final String status; | ||||
| @@ -522,7 +331,6 @@ class ServerStateNotifier extends StateNotifier<ServerState> { | ||||
|     : super(ServerState(status: 'Server not started')); | ||||
| 
 | ||||
|   Future<void> start() async { | ||||
|     // Only start server on desktop platforms | ||||
|     if (!Platform.isAndroid && !Platform.isIOS && !kIsWeb) { | ||||
|       try { | ||||
|         await server.start(); | ||||
| @@ -531,7 +339,9 @@ class ServerStateNotifier extends StateNotifier<ServerState> { | ||||
|         state = state.copyWith(status: 'Server failed: $e'); | ||||
|       } | ||||
|     } else { | ||||
|       state = state.copyWith(status: 'Server disabled on mobile/web'); | ||||
|       Future(() { | ||||
|         state = state.copyWith(status: 'Server disabled on mobile/web'); | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
| @@ -554,9 +364,8 @@ final rpcServerStateProvider = | ||||
|           final clientId = | ||||
|               socket is _WsSocketWrapper | ||||
|                   ? socket.clientId | ||||
|                   : (socket as _IpcSocketWrapper).clientId; | ||||
|                   : (socket as IpcSocketWrapper).clientId; | ||||
|           notifier.updateStatus('Client connected (ID: $clientId)'); | ||||
|           // Send READY event | ||||
|           socket.send({ | ||||
|             'cmd': 'DISPATCH', | ||||
|             'data': { | ||||
| @@ -575,7 +384,7 @@ final rpcServerStateProvider = | ||||
|               }, | ||||
|             }, | ||||
|             'evt': 'READY', | ||||
|             'nonce': '12345', // Should be dynamic | ||||
|             'nonce': '12345', | ||||
|           }); | ||||
|         }, | ||||
|         'message': (socket, dynamic data) async { | ||||
| @@ -583,7 +392,6 @@ final rpcServerStateProvider = | ||||
|             notifier.addActivity( | ||||
|               'Activity: ${data['args']['activity']['details'] ?? 'Unknown'}', | ||||
|             ); | ||||
|             // Call setRemoteActivityStatus | ||||
|             final label = data['args']['activity']['details'] ?? 'Unknown'; | ||||
|             final appId = socket.clientId; | ||||
|             try { | ||||
| @@ -594,7 +402,6 @@ final rpcServerStateProvider = | ||||
|                 name: kRpcLogPrefix, | ||||
|               ); | ||||
|             } | ||||
|             // Echo back success | ||||
|             socket.send({ | ||||
|               'cmd': 'SET_ACTIVITY', | ||||
|               'data': data['args']['activity'], | ||||
							
								
								
									
										297
									
								
								lib/pods/activity/ipc_server.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										297
									
								
								lib/pods/activity/ipc_server.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,297 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:convert'; | ||||
| import 'dart:developer' as developer; | ||||
| import 'dart:io'; | ||||
| import 'dart:typed_data'; | ||||
| import 'package:dart_ipc/dart_ipc.dart'; | ||||
| import 'package:path/path.dart' as path; | ||||
|  | ||||
| const String kRpcIpcLogPrefix = 'arRPC.ipc'; | ||||
|  | ||||
| // IPC Packet Types | ||||
| class IpcTypes { | ||||
|   static const int handshake = 0; | ||||
|   static const int frame = 1; | ||||
|   static const int close = 2; | ||||
|   static const int ping = 3; | ||||
|   static const int pong = 4; | ||||
| } | ||||
|  | ||||
| // IPC Close Codes | ||||
| class IpcCloseCodes { | ||||
|   static const int closeNormal = 1000; | ||||
|   static const int closeUnsupported = 1003; | ||||
|   static const int closeAbnormal = 1006; | ||||
| } | ||||
|  | ||||
| // IPC Error Codes | ||||
| class IpcErrorCodes { | ||||
|   static const int invalidClientId = 4000; | ||||
|   static const int invalidOrigin = 4001; | ||||
|   static const int rateLimited = 4002; | ||||
|   static const int tokenRevoked = 4003; | ||||
|   static const int invalidVersion = 4004; | ||||
|   static const int invalidEncoding = 4005; | ||||
| } | ||||
|  | ||||
| // IPC Packet structure | ||||
| class IpcPacket { | ||||
|   final int type; | ||||
|   final Map<String, dynamic> data; | ||||
|  | ||||
|   IpcPacket(this.type, this.data); | ||||
| } | ||||
|  | ||||
| // Abstract base class for IPC server | ||||
| abstract class IpcServer { | ||||
|   final List<IpcSocketWrapper> _sockets = []; | ||||
|  | ||||
|   // Encode IPC packet | ||||
|   static Uint8List encodeIpcPacket(int type, Map<String, dynamic> data) { | ||||
|     final jsonData = jsonEncode(data); | ||||
|     final dataBytes = utf8.encode(jsonData); | ||||
|     final dataSize = dataBytes.length; | ||||
|  | ||||
|     final buffer = ByteData(8 + dataSize); | ||||
|     buffer.setInt32(0, type, Endian.little); | ||||
|     buffer.setInt32(4, dataSize, Endian.little); | ||||
|     buffer.buffer.asUint8List().setRange(8, 8 + dataSize, dataBytes); | ||||
|  | ||||
|     return buffer.buffer.asUint8List(); | ||||
|   } | ||||
|  | ||||
|   Future<void> start(); | ||||
|   Future<void> stop(); | ||||
|  | ||||
|   void addSocket(IpcSocketWrapper socket) { | ||||
|     _sockets.add(socket); | ||||
|   } | ||||
|  | ||||
|   void removeSocket(IpcSocketWrapper socket) { | ||||
|     _sockets.remove(socket); | ||||
|   } | ||||
|  | ||||
|   List<IpcSocketWrapper> get sockets => _sockets; | ||||
|  | ||||
|   void Function( | ||||
|     IpcSocketWrapper socket, | ||||
|     IpcPacket packet, | ||||
|     Map<String, Function> handlers, | ||||
|   )? | ||||
|   handlePacket; | ||||
| } | ||||
|  | ||||
| // Abstract base class for IPC socket wrapper | ||||
| abstract class IpcSocketWrapper { | ||||
|   String clientId = ''; | ||||
|   bool handshook = false; | ||||
|   final List<int> _buffer = []; | ||||
|  | ||||
|   void addData(List<int> data) { | ||||
|     _buffer.addAll(data); | ||||
|   } | ||||
|  | ||||
|   void send(Map<String, dynamic> msg); | ||||
|   void sendPong(dynamic data); | ||||
|   void close(); | ||||
|   void closeWithCode(int code, [String message = '']); | ||||
|  | ||||
|   List<IpcPacket> readPackets() { | ||||
|     final packets = <IpcPacket>[]; | ||||
|  | ||||
|     while (_buffer.length >= 8) { | ||||
|       final buffer = Uint8List.fromList(_buffer); | ||||
|       final byteData = ByteData.view(buffer.buffer); | ||||
|  | ||||
|       final type = byteData.getInt32(0, Endian.little); | ||||
|       final dataSize = byteData.getInt32(4, Endian.little); | ||||
|  | ||||
|       if (_buffer.length < 8 + dataSize) break; | ||||
|  | ||||
|       final dataBytes = _buffer.sublist(8, 8 + dataSize); | ||||
|       final jsonStr = utf8.decode(dataBytes); | ||||
|       final jsonData = jsonDecode(jsonStr); | ||||
|  | ||||
|       packets.add(IpcPacket(type, jsonData)); | ||||
|  | ||||
|       _buffer.removeRange(0, 8 + dataSize); | ||||
|     } | ||||
|  | ||||
|     return packets; | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Multiplatform IPC Server implementation using dart_ipc | ||||
| class MultiPlatformIpcServer extends IpcServer { | ||||
|   StreamSubscription? _serverSubscription; | ||||
|  | ||||
|   @override | ||||
|   Future<void> start() async { | ||||
|     try { | ||||
|       final ipcPath = Platform.isWindows | ||||
|           ? r'\\.\pipe\discord-ipc-0' | ||||
|           : await _findAvailableUnixIpcPath(); | ||||
|  | ||||
|       final serverSocket = await bind(ipcPath); | ||||
|       developer.log( | ||||
|         'IPC listening at $ipcPath', | ||||
|         name: kRpcIpcLogPrefix, | ||||
|       ); | ||||
|  | ||||
|       _serverSubscription = serverSocket.listen((socket) { | ||||
|         final socketWrapper = MultiPlatformIpcSocketWrapper(socket); | ||||
|         addSocket(socketWrapper); | ||||
|         developer.log( | ||||
|           'New IPC connection!', | ||||
|           name: kRpcIpcLogPrefix, | ||||
|         ); | ||||
|         _handleIpcData(socketWrapper); | ||||
|       }); | ||||
|     } catch (e) { | ||||
|       throw Exception('Failed to start IPC server: $e'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<void> stop() async { | ||||
|     for (var socket in sockets) { | ||||
|       try { | ||||
|         socket.close(); | ||||
|       } catch (e) { | ||||
|         developer.log('Error closing IPC socket: $e', name: kRpcIpcLogPrefix); | ||||
|       } | ||||
|     } | ||||
|     sockets.clear(); | ||||
|     _serverSubscription?.cancel(); | ||||
|   } | ||||
|  | ||||
|   // Handle incoming IPC data | ||||
|   void _handleIpcData(MultiPlatformIpcSocketWrapper socket) { | ||||
|     final startTime = DateTime.now(); | ||||
|     socket.socket.listen((data) { | ||||
|       final readStart = DateTime.now(); | ||||
|       socket.addData(data); | ||||
|       final readDuration = DateTime.now().difference(readStart).inMicroseconds; | ||||
|       developer.log( | ||||
|         'Read data took $readDuration microseconds', | ||||
|         name: kRpcIpcLogPrefix, | ||||
|       ); | ||||
|  | ||||
|       final packets = socket.readPackets(); | ||||
|       for (final packet in packets) { | ||||
|         handlePacket?.call(socket, packet, {}); | ||||
|       } | ||||
|     }, onDone: () { | ||||
|       developer.log('IPC connection closed', name: kRpcIpcLogPrefix); | ||||
|       socket.close(); | ||||
|     }, onError: (e) { | ||||
|       developer.log('IPC data error: $e', name: kRpcIpcLogPrefix); | ||||
|       socket.closeWithCode(IpcCloseCodes.closeUnsupported, e.toString()); | ||||
|     }); | ||||
|     final totalDuration = DateTime.now().difference(startTime).inMicroseconds; | ||||
|     developer.log( | ||||
|       '_handleIpcData took $totalDuration microseconds', | ||||
|       name: kRpcIpcLogPrefix, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Future<String> _getMacOsSystemTmpDir() async { | ||||
|     final result = await Process.run('getconf', ['DARWIN_USER_TEMP_DIR']); | ||||
|     return (result.stdout as String).trim(); | ||||
|   } | ||||
|  | ||||
|   // Find available IPC socket path for Unix-like systems | ||||
|   Future<String> _findAvailableUnixIpcPath() async { | ||||
|     // Build list of directories to try, with macOS-specific handling | ||||
|     final baseDirs = <String>[]; | ||||
|  | ||||
|     if (Platform.isMacOS) { | ||||
|       try { | ||||
|         final macTempDir = await _getMacOsSystemTmpDir(); | ||||
|         if (macTempDir.isNotEmpty) { | ||||
|           baseDirs.add(macTempDir); | ||||
|         } | ||||
|       } catch (e) { | ||||
|         developer.log( | ||||
|           'Failed to get macOS system temp dir: $e', | ||||
|           name: kRpcIpcLogPrefix, | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // Add other standard directories | ||||
|     final otherDirs = [ | ||||
|       Platform.environment['XDG_RUNTIME_DIR'], | ||||
|       Platform.environment['TMPDIR'], | ||||
|       Platform.environment['TMP'], | ||||
|       Platform.environment['TEMP'], | ||||
|       '/tmp', | ||||
|     ]; | ||||
|  | ||||
|     baseDirs.addAll( | ||||
|       otherDirs.where((dir) => dir != null && dir.isNotEmpty).cast<String>(), | ||||
|     ); | ||||
|  | ||||
|     for (final baseDir in baseDirs) { | ||||
|       for (int i = 0; i < 10; i++) { | ||||
|         final socketPath = path.join(baseDir, 'discord-ipc-$i'); | ||||
|         try { | ||||
|           final socket = await bind(socketPath); | ||||
|           socket.close(); | ||||
|           try { | ||||
|             await File(socketPath).delete(); | ||||
|           } catch (_) {} | ||||
|           developer.log( | ||||
|             'IPC socket will be created at: $socketPath', | ||||
|             name: kRpcIpcLogPrefix, | ||||
|           ); | ||||
|           return socketPath; | ||||
|         } catch (e) { | ||||
|           if (i == 0) { | ||||
|             developer.log( | ||||
|               'IPC path $socketPath not available: $e', | ||||
|               name: kRpcIpcLogPrefix, | ||||
|             ); | ||||
|           } | ||||
|           continue; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     throw Exception( | ||||
|       'No available IPC socket paths found in any temp directory', | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Multiplatform IPC Socket Wrapper | ||||
| class MultiPlatformIpcSocketWrapper extends IpcSocketWrapper { | ||||
|   final dynamic socket; | ||||
|  | ||||
|   MultiPlatformIpcSocketWrapper(this.socket); | ||||
|  | ||||
|   @override | ||||
|   void send(Map<String, dynamic> msg) { | ||||
|     developer.log('IPC sending: $msg', name: kRpcIpcLogPrefix); | ||||
|     final packet = IpcServer.encodeIpcPacket(IpcTypes.frame, msg); | ||||
|     socket.add(packet); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void sendPong(dynamic data) { | ||||
|     final packet = IpcServer.encodeIpcPacket(IpcTypes.pong, data ?? {}); | ||||
|     socket.add(packet); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void close() { | ||||
|     socket.close(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void closeWithCode(int code, [String message = '']) { | ||||
|     final closeData = {'code': code, 'message': message}; | ||||
|     final packet = IpcServer.encodeIpcPacket(IpcTypes.close, closeData); | ||||
|     socket.add(packet); | ||||
|     socket.close(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										61
									
								
								lib/pods/activity/ipc_server.web.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								lib/pods/activity/ipc_server.web.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | ||||
| // Stub implementation for web platform | ||||
| // This file provides empty implementations to avoid import errors on web | ||||
|  | ||||
| // IPC Packet Types | ||||
| class IpcTypes { | ||||
|   static const int handshake = 0; | ||||
|   static const int frame = 1; | ||||
|   static const int close = 2; | ||||
|   static const int ping = 3; | ||||
|   static const int pong = 4; | ||||
| } | ||||
|  | ||||
| // IPC Close Codes | ||||
| class IpcCloseCodes { | ||||
|   static const int closeNormal = 1000; | ||||
|   static const int closeUnsupported = 1003; | ||||
|   static const int closeAbnormal = 1006; | ||||
| } | ||||
|  | ||||
| // IPC Error Codes | ||||
| class IpcErrorCodes { | ||||
|   static const int invalidClientId = 4000; | ||||
|   static const int invalidOrigin = 4001; | ||||
|   static const int rateLimited = 4002; | ||||
|   static const int tokenRevoked = 4003; | ||||
|   static const int invalidVersion = 4004; | ||||
|   static const int invalidEncoding = 4005; | ||||
| } | ||||
|  | ||||
| // IPC Packet structure | ||||
| class IpcPacket { | ||||
|   final int type; | ||||
|   final Map<String, dynamic> data; | ||||
|  | ||||
|   IpcPacket(this.type, this.data); | ||||
| } | ||||
|  | ||||
| class IpcServer { | ||||
|   Future<void> start() async {} | ||||
|   Future<void> stop() async {} | ||||
|   void Function(dynamic, dynamic, dynamic)? handlePacket; | ||||
|   void addSocket(dynamic socket) {} | ||||
|   void removeSocket(dynamic socket) {} | ||||
|   List<dynamic> get sockets => []; | ||||
| } | ||||
|  | ||||
| class IpcSocketWrapper { | ||||
|   String clientId = ''; | ||||
|   bool handshook = false; | ||||
|  | ||||
|   void addData(List<int> data) {} | ||||
|   void send(Map<String, dynamic> msg) {} | ||||
|   void sendPong(dynamic data) {} | ||||
|   void close() {} | ||||
|   void closeWithCode(int code, [String message = '']) {} | ||||
|   List<dynamic> readPackets() => []; | ||||
| } | ||||
|  | ||||
| class MultiPlatformIpcServer extends IpcServer {} | ||||
|  | ||||
| class MultiPlatformIpcSocketWrapper extends IpcSocketWrapper {} | ||||
| @@ -25,6 +25,7 @@ const kAppSoundEffects = 'app_sound_effects'; | ||||
| const kAppAprilFoolFeatures = 'app_april_fool_features'; | ||||
| const kAppWindowSize = 'app_window_size'; | ||||
| const kAppEnterToSend = 'app_enter_to_send'; | ||||
| const kAppDefaultPoolId = 'app_default_pool_id'; | ||||
| const kFeaturedPostsCollapsedId = | ||||
|     'featured_posts_collapsed_id'; // Key for storing the ID of the collapsed featured post | ||||
|  | ||||
| @@ -65,6 +66,7 @@ sealed class AppSettings with _$AppSettings { | ||||
|     required String? customFonts, | ||||
|     required int? appColorScheme, // The color stored via the int type | ||||
|     required Size? windowSize, // The window size for desktop platforms | ||||
|     required String? defaultPoolId, | ||||
|   }) = _AppSettings; | ||||
| } | ||||
|  | ||||
| @@ -84,6 +86,7 @@ class AppSettingsNotifier extends _$AppSettingsNotifier { | ||||
|       customFonts: prefs.getString(kAppCustomFonts), | ||||
|       appColorScheme: prefs.getInt(kAppColorSchemeStoreKey), | ||||
|       windowSize: _getWindowSizeFromPrefs(prefs), | ||||
|       defaultPoolId: prefs.getString(kAppDefaultPoolId), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @@ -103,6 +106,15 @@ class AppSettingsNotifier extends _$AppSettingsNotifier { | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
|   void setDefaultPoolId(String? value) { | ||||
|     final prefs = ref.read(sharedPreferencesProvider); | ||||
|     if (value != null) { | ||||
|       prefs.setString(kAppDefaultPoolId, value); | ||||
|     } else { | ||||
|       prefs.remove(kAppDefaultPoolId); | ||||
|     } | ||||
|     state = state.copyWith(defaultPoolId: value); | ||||
|   } | ||||
|  | ||||
|   void setAutoTranslate(bool value) { | ||||
|     final prefs = ref.read(sharedPreferencesProvider); | ||||
|   | ||||
| @@ -15,7 +15,8 @@ T _$identity<T>(T value) => value; | ||||
| mixin _$AppSettings { | ||||
|  | ||||
|  bool get autoTranslate; bool get dataSavingMode; bool get soundEffects; bool get aprilFoolFeatures; bool get enterToSend; bool get appBarTransparent; bool get showBackgroundImage; String? get customFonts; int? get appColorScheme;// The color stored via the int type | ||||
|  Size? get windowSize; | ||||
|  Size? get windowSize;// The window size for desktop platforms | ||||
|  String? get defaultPoolId; | ||||
| /// Create a copy of AppSettings | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -26,16 +27,16 @@ $AppSettingsCopyWith<AppSettings> get copyWith => _$AppSettingsCopyWithImpl<AppS | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is AppSettings&&(identical(other.autoTranslate, autoTranslate) || other.autoTranslate == autoTranslate)&&(identical(other.dataSavingMode, dataSavingMode) || other.dataSavingMode == dataSavingMode)&&(identical(other.soundEffects, soundEffects) || other.soundEffects == soundEffects)&&(identical(other.aprilFoolFeatures, aprilFoolFeatures) || other.aprilFoolFeatures == aprilFoolFeatures)&&(identical(other.enterToSend, enterToSend) || other.enterToSend == enterToSend)&&(identical(other.appBarTransparent, appBarTransparent) || other.appBarTransparent == appBarTransparent)&&(identical(other.showBackgroundImage, showBackgroundImage) || other.showBackgroundImage == showBackgroundImage)&&(identical(other.customFonts, customFonts) || other.customFonts == customFonts)&&(identical(other.appColorScheme, appColorScheme) || other.appColorScheme == appColorScheme)&&(identical(other.windowSize, windowSize) || other.windowSize == windowSize)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is AppSettings&&(identical(other.autoTranslate, autoTranslate) || other.autoTranslate == autoTranslate)&&(identical(other.dataSavingMode, dataSavingMode) || other.dataSavingMode == dataSavingMode)&&(identical(other.soundEffects, soundEffects) || other.soundEffects == soundEffects)&&(identical(other.aprilFoolFeatures, aprilFoolFeatures) || other.aprilFoolFeatures == aprilFoolFeatures)&&(identical(other.enterToSend, enterToSend) || other.enterToSend == enterToSend)&&(identical(other.appBarTransparent, appBarTransparent) || other.appBarTransparent == appBarTransparent)&&(identical(other.showBackgroundImage, showBackgroundImage) || other.showBackgroundImage == showBackgroundImage)&&(identical(other.customFonts, customFonts) || other.customFonts == customFonts)&&(identical(other.appColorScheme, appColorScheme) || other.appColorScheme == appColorScheme)&&(identical(other.windowSize, windowSize) || other.windowSize == windowSize)&&(identical(other.defaultPoolId, defaultPoolId) || other.defaultPoolId == defaultPoolId)); | ||||
| } | ||||
|  | ||||
|  | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,autoTranslate,dataSavingMode,soundEffects,aprilFoolFeatures,enterToSend,appBarTransparent,showBackgroundImage,customFonts,appColorScheme,windowSize); | ||||
| int get hashCode => Object.hash(runtimeType,autoTranslate,dataSavingMode,soundEffects,aprilFoolFeatures,enterToSend,appBarTransparent,showBackgroundImage,customFonts,appColorScheme,windowSize,defaultPoolId); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'AppSettings(autoTranslate: $autoTranslate, dataSavingMode: $dataSavingMode, soundEffects: $soundEffects, aprilFoolFeatures: $aprilFoolFeatures, enterToSend: $enterToSend, appBarTransparent: $appBarTransparent, showBackgroundImage: $showBackgroundImage, customFonts: $customFonts, appColorScheme: $appColorScheme, windowSize: $windowSize)'; | ||||
|   return 'AppSettings(autoTranslate: $autoTranslate, dataSavingMode: $dataSavingMode, soundEffects: $soundEffects, aprilFoolFeatures: $aprilFoolFeatures, enterToSend: $enterToSend, appBarTransparent: $appBarTransparent, showBackgroundImage: $showBackgroundImage, customFonts: $customFonts, appColorScheme: $appColorScheme, windowSize: $windowSize, defaultPoolId: $defaultPoolId)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -46,7 +47,7 @@ abstract mixin class $AppSettingsCopyWith<$Res>  { | ||||
|   factory $AppSettingsCopyWith(AppSettings value, $Res Function(AppSettings) _then) = _$AppSettingsCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, Size? windowSize | ||||
|  bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, Size? windowSize, String? defaultPoolId | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -63,7 +64,7 @@ class _$AppSettingsCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of AppSettings | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? autoTranslate = null,Object? dataSavingMode = null,Object? soundEffects = null,Object? aprilFoolFeatures = null,Object? enterToSend = null,Object? appBarTransparent = null,Object? showBackgroundImage = null,Object? customFonts = freezed,Object? appColorScheme = freezed,Object? windowSize = freezed,}) { | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? autoTranslate = null,Object? dataSavingMode = null,Object? soundEffects = null,Object? aprilFoolFeatures = null,Object? enterToSend = null,Object? appBarTransparent = null,Object? showBackgroundImage = null,Object? customFonts = freezed,Object? appColorScheme = freezed,Object? windowSize = freezed,Object? defaultPoolId = freezed,}) { | ||||
|   return _then(_self.copyWith( | ||||
| autoTranslate: null == autoTranslate ? _self.autoTranslate : autoTranslate // ignore: cast_nullable_to_non_nullable | ||||
| as bool,dataSavingMode: null == dataSavingMode ? _self.dataSavingMode : dataSavingMode // ignore: cast_nullable_to_non_nullable | ||||
| @@ -75,7 +76,8 @@ as bool,showBackgroundImage: null == showBackgroundImage ? _self.showBackgroundI | ||||
| as bool,customFonts: freezed == customFonts ? _self.customFonts : customFonts // ignore: cast_nullable_to_non_nullable | ||||
| as String?,appColorScheme: freezed == appColorScheme ? _self.appColorScheme : appColorScheme // ignore: cast_nullable_to_non_nullable | ||||
| as int?,windowSize: freezed == windowSize ? _self.windowSize : windowSize // ignore: cast_nullable_to_non_nullable | ||||
| as Size?, | ||||
| as Size?,defaultPoolId: freezed == defaultPoolId ? _self.defaultPoolId : defaultPoolId // ignore: cast_nullable_to_non_nullable | ||||
| as String?, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| @@ -157,10 +159,10 @@ return $default(_that);case _: | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool autoTranslate,  bool dataSavingMode,  bool soundEffects,  bool aprilFoolFeatures,  bool enterToSend,  bool appBarTransparent,  bool showBackgroundImage,  String? customFonts,  int? appColorScheme,  Size? windowSize)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool autoTranslate,  bool dataSavingMode,  bool soundEffects,  bool aprilFoolFeatures,  bool enterToSend,  bool appBarTransparent,  bool showBackgroundImage,  String? customFonts,  int? appColorScheme,  Size? windowSize,  String? defaultPoolId)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _AppSettings() when $default != null: | ||||
| return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.windowSize);case _: | ||||
| return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.windowSize,_that.defaultPoolId);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| @@ -178,10 +180,10 @@ return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_tha | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool autoTranslate,  bool dataSavingMode,  bool soundEffects,  bool aprilFoolFeatures,  bool enterToSend,  bool appBarTransparent,  bool showBackgroundImage,  String? customFonts,  int? appColorScheme,  Size? windowSize)  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool autoTranslate,  bool dataSavingMode,  bool soundEffects,  bool aprilFoolFeatures,  bool enterToSend,  bool appBarTransparent,  bool showBackgroundImage,  String? customFonts,  int? appColorScheme,  Size? windowSize,  String? defaultPoolId)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _AppSettings(): | ||||
| return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.windowSize);} | ||||
| return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.windowSize,_that.defaultPoolId);} | ||||
| } | ||||
| /// A variant of `when` that fallback to returning `null` | ||||
| /// | ||||
| @@ -195,10 +197,10 @@ return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_tha | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool autoTranslate,  bool dataSavingMode,  bool soundEffects,  bool aprilFoolFeatures,  bool enterToSend,  bool appBarTransparent,  bool showBackgroundImage,  String? customFonts,  int? appColorScheme,  Size? windowSize)?  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool autoTranslate,  bool dataSavingMode,  bool soundEffects,  bool aprilFoolFeatures,  bool enterToSend,  bool appBarTransparent,  bool showBackgroundImage,  String? customFonts,  int? appColorScheme,  Size? windowSize,  String? defaultPoolId)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _AppSettings() when $default != null: | ||||
| return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.windowSize);case _: | ||||
| return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.windowSize,_that.defaultPoolId);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| @@ -210,7 +212,7 @@ return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_tha | ||||
|  | ||||
|  | ||||
| class _AppSettings implements AppSettings { | ||||
|   const _AppSettings({required this.autoTranslate, required this.dataSavingMode, required this.soundEffects, required this.aprilFoolFeatures, required this.enterToSend, required this.appBarTransparent, required this.showBackgroundImage, required this.customFonts, required this.appColorScheme, required this.windowSize}); | ||||
|   const _AppSettings({required this.autoTranslate, required this.dataSavingMode, required this.soundEffects, required this.aprilFoolFeatures, required this.enterToSend, required this.appBarTransparent, required this.showBackgroundImage, required this.customFonts, required this.appColorScheme, required this.windowSize, required this.defaultPoolId}); | ||||
|    | ||||
|  | ||||
| @override final  bool autoTranslate; | ||||
| @@ -224,6 +226,8 @@ class _AppSettings implements AppSettings { | ||||
| @override final  int? appColorScheme; | ||||
| // The color stored via the int type | ||||
| @override final  Size? windowSize; | ||||
| // The window size for desktop platforms | ||||
| @override final  String? defaultPoolId; | ||||
|  | ||||
| /// Create a copy of AppSettings | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @@ -235,16 +239,16 @@ _$AppSettingsCopyWith<_AppSettings> get copyWith => __$AppSettingsCopyWithImpl<_ | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppSettings&&(identical(other.autoTranslate, autoTranslate) || other.autoTranslate == autoTranslate)&&(identical(other.dataSavingMode, dataSavingMode) || other.dataSavingMode == dataSavingMode)&&(identical(other.soundEffects, soundEffects) || other.soundEffects == soundEffects)&&(identical(other.aprilFoolFeatures, aprilFoolFeatures) || other.aprilFoolFeatures == aprilFoolFeatures)&&(identical(other.enterToSend, enterToSend) || other.enterToSend == enterToSend)&&(identical(other.appBarTransparent, appBarTransparent) || other.appBarTransparent == appBarTransparent)&&(identical(other.showBackgroundImage, showBackgroundImage) || other.showBackgroundImage == showBackgroundImage)&&(identical(other.customFonts, customFonts) || other.customFonts == customFonts)&&(identical(other.appColorScheme, appColorScheme) || other.appColorScheme == appColorScheme)&&(identical(other.windowSize, windowSize) || other.windowSize == windowSize)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppSettings&&(identical(other.autoTranslate, autoTranslate) || other.autoTranslate == autoTranslate)&&(identical(other.dataSavingMode, dataSavingMode) || other.dataSavingMode == dataSavingMode)&&(identical(other.soundEffects, soundEffects) || other.soundEffects == soundEffects)&&(identical(other.aprilFoolFeatures, aprilFoolFeatures) || other.aprilFoolFeatures == aprilFoolFeatures)&&(identical(other.enterToSend, enterToSend) || other.enterToSend == enterToSend)&&(identical(other.appBarTransparent, appBarTransparent) || other.appBarTransparent == appBarTransparent)&&(identical(other.showBackgroundImage, showBackgroundImage) || other.showBackgroundImage == showBackgroundImage)&&(identical(other.customFonts, customFonts) || other.customFonts == customFonts)&&(identical(other.appColorScheme, appColorScheme) || other.appColorScheme == appColorScheme)&&(identical(other.windowSize, windowSize) || other.windowSize == windowSize)&&(identical(other.defaultPoolId, defaultPoolId) || other.defaultPoolId == defaultPoolId)); | ||||
| } | ||||
|  | ||||
|  | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,autoTranslate,dataSavingMode,soundEffects,aprilFoolFeatures,enterToSend,appBarTransparent,showBackgroundImage,customFonts,appColorScheme,windowSize); | ||||
| int get hashCode => Object.hash(runtimeType,autoTranslate,dataSavingMode,soundEffects,aprilFoolFeatures,enterToSend,appBarTransparent,showBackgroundImage,customFonts,appColorScheme,windowSize,defaultPoolId); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'AppSettings(autoTranslate: $autoTranslate, dataSavingMode: $dataSavingMode, soundEffects: $soundEffects, aprilFoolFeatures: $aprilFoolFeatures, enterToSend: $enterToSend, appBarTransparent: $appBarTransparent, showBackgroundImage: $showBackgroundImage, customFonts: $customFonts, appColorScheme: $appColorScheme, windowSize: $windowSize)'; | ||||
|   return 'AppSettings(autoTranslate: $autoTranslate, dataSavingMode: $dataSavingMode, soundEffects: $soundEffects, aprilFoolFeatures: $aprilFoolFeatures, enterToSend: $enterToSend, appBarTransparent: $appBarTransparent, showBackgroundImage: $showBackgroundImage, customFonts: $customFonts, appColorScheme: $appColorScheme, windowSize: $windowSize, defaultPoolId: $defaultPoolId)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -255,7 +259,7 @@ abstract mixin class _$AppSettingsCopyWith<$Res> implements $AppSettingsCopyWith | ||||
|   factory _$AppSettingsCopyWith(_AppSettings value, $Res Function(_AppSettings) _then) = __$AppSettingsCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, Size? windowSize | ||||
|  bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, Size? windowSize, String? defaultPoolId | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -272,7 +276,7 @@ class __$AppSettingsCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of AppSettings | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? autoTranslate = null,Object? dataSavingMode = null,Object? soundEffects = null,Object? aprilFoolFeatures = null,Object? enterToSend = null,Object? appBarTransparent = null,Object? showBackgroundImage = null,Object? customFonts = freezed,Object? appColorScheme = freezed,Object? windowSize = freezed,}) { | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? autoTranslate = null,Object? dataSavingMode = null,Object? soundEffects = null,Object? aprilFoolFeatures = null,Object? enterToSend = null,Object? appBarTransparent = null,Object? showBackgroundImage = null,Object? customFonts = freezed,Object? appColorScheme = freezed,Object? windowSize = freezed,Object? defaultPoolId = freezed,}) { | ||||
|   return _then(_AppSettings( | ||||
| autoTranslate: null == autoTranslate ? _self.autoTranslate : autoTranslate // ignore: cast_nullable_to_non_nullable | ||||
| as bool,dataSavingMode: null == dataSavingMode ? _self.dataSavingMode : dataSavingMode // ignore: cast_nullable_to_non_nullable | ||||
| @@ -284,7 +288,8 @@ as bool,showBackgroundImage: null == showBackgroundImage ? _self.showBackgroundI | ||||
| as bool,customFonts: freezed == customFonts ? _self.customFonts : customFonts // ignore: cast_nullable_to_non_nullable | ||||
| as String?,appColorScheme: freezed == appColorScheme ? _self.appColorScheme : appColorScheme // ignore: cast_nullable_to_non_nullable | ||||
| as int?,windowSize: freezed == windowSize ? _self.windowSize : windowSize // ignore: cast_nullable_to_non_nullable | ||||
| as Size?, | ||||
| as Size?,defaultPoolId: freezed == defaultPoolId ? _self.defaultPoolId : defaultPoolId // ignore: cast_nullable_to_non_nullable | ||||
| as String?, | ||||
|   )); | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -7,7 +7,7 @@ part of 'config.dart'; | ||||
| // ************************************************************************** | ||||
|  | ||||
| String _$appSettingsNotifierHash() => | ||||
|     r'cd18bff2614a94e3523634e6c577cefad0367eba'; | ||||
|     r'a623ad859b71f42d0527b7f8b75bd37a6fd5d5c7'; | ||||
|  | ||||
| /// See also [AppSettingsNotifier]. | ||||
| @ProviderFor(AppSettingsNotifier) | ||||
|   | ||||
							
								
								
									
										852
									
								
								lib/pods/messages_notifier.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										852
									
								
								lib/pods/messages_notifier.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,852 @@ | ||||
| import "dart:async"; | ||||
| import "dart:developer" as developer; | ||||
| import "package:dio/dio.dart"; | ||||
| import "package:drift/drift.dart" show Variable; | ||||
| import "package:easy_localization/easy_localization.dart"; | ||||
| import "package:flutter/material.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/pods/config.dart"; | ||||
| import "package:island/pods/database.dart"; | ||||
| import "package:island/pods/network.dart"; | ||||
| import "package:island/services/file.dart"; | ||||
| import "package:island/widgets/alert.dart"; | ||||
| import "package:riverpod_annotation/riverpod_annotation.dart"; | ||||
| import "package:uuid/uuid.dart"; | ||||
| import "package:island/screens/chat/chat.dart"; | ||||
| import "package:island/pods/room_providers.dart"; | ||||
|  | ||||
| part 'messages_notifier.g.dart'; | ||||
|  | ||||
| @riverpod | ||||
| 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; | ||||
|   String? _searchQuery; | ||||
|   bool? _withLinks; | ||||
|   bool? _withAttachments; | ||||
|  | ||||
|   late final String _roomId; | ||||
|   static const int _pageSize = 20; | ||||
|   bool _hasMore = true; | ||||
|   bool _isSyncing = false; | ||||
|   bool _isJumping = false; | ||||
|  | ||||
|   @override | ||||
|   FutureOr<List<LocalChatMessage>> build(String roomId) async { | ||||
|     _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) { | ||||
|       throw Exception('Room not found'); | ||||
|     } | ||||
|     _room = room; | ||||
|  | ||||
|     // Allow building even if identity is null for public rooms | ||||
|     if (identity != null) { | ||||
|       _identity = identity; | ||||
|     } | ||||
|  | ||||
|     developer.log( | ||||
|       'MessagesNotifier built for room $roomId', | ||||
|       name: 'MessagesNotifier', | ||||
|     ); | ||||
|  | ||||
|     // Only setup sync and lifecycle listeners if user is a member | ||||
|     if (identity != null) { | ||||
|       ref.listen(appLifecycleStateProvider, (_, next) { | ||||
|         if (next.hasValue && next.value == AppLifecycleState.resumed) { | ||||
|           developer.log( | ||||
|             'App resumed, syncing messages', | ||||
|             name: 'MessagesNotifier', | ||||
|           ); | ||||
|           syncMessages(); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     loadInitial(); | ||||
|     return []; | ||||
|   } | ||||
|  | ||||
|   List<LocalChatMessage> _sortMessages(List<LocalChatMessage> messages) { | ||||
|     messages.sort((a, b) => b.createdAt.compareTo(a.createdAt)); | ||||
|     return messages; | ||||
|   } | ||||
|  | ||||
|   Future<List<LocalChatMessage>> _getCachedMessages({ | ||||
|     int offset = 0, | ||||
|     int take = 20, | ||||
|   }) async { | ||||
|     developer.log( | ||||
|       'Getting cached messages from offset $offset, take $take', | ||||
|       name: 'MessagesNotifier', | ||||
|     ); | ||||
|     final List<LocalChatMessage> dbMessages; | ||||
|     if (_searchQuery != null && _searchQuery!.isNotEmpty) { | ||||
|       dbMessages = await _database.searchMessages( | ||||
|         _roomId, | ||||
|         _searchQuery ?? '', | ||||
|         withAttachments: _withAttachments, | ||||
|       ); | ||||
|     } else { | ||||
|       final chatMessagesFromDb = await _database.getMessagesForRoom( | ||||
|         _roomId, | ||||
|         offset: offset, | ||||
|         limit: take, | ||||
|       ); | ||||
|       dbMessages = | ||||
|           chatMessagesFromDb.map(_database.companionToMessage).toList(); | ||||
|     } | ||||
|  | ||||
|     List<LocalChatMessage> filteredMessages = dbMessages; | ||||
|  | ||||
|     if (_withLinks == true) { | ||||
|       filteredMessages = | ||||
|           filteredMessages.where((msg) => _hasLink(msg)).toList(); | ||||
|     } | ||||
|  | ||||
|     final dbLocalMessages = filteredMessages; | ||||
|  | ||||
|     // Always ensure unique messages to prevent duplicate keys | ||||
|     final uniqueMessages = <LocalChatMessage>[]; | ||||
|     final seenIds = <String>{}; | ||||
|     for (final message in dbLocalMessages) { | ||||
|       if (seenIds.add(message.id)) { | ||||
|         uniqueMessages.add(message); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (offset == 0) { | ||||
|       final pendingForRoom = | ||||
|           _pendingMessages.values | ||||
|               .where((msg) => msg.roomId == _roomId) | ||||
|               .toList(); | ||||
|  | ||||
|       final allMessages = [...pendingForRoom, ...uniqueMessages]; | ||||
|       _sortMessages(allMessages); // Use the helper function | ||||
|  | ||||
|       final finalUniqueMessages = <LocalChatMessage>[]; | ||||
|       final finalSeenIds = <String>{}; | ||||
|       for (final message in allMessages) { | ||||
|         if (finalSeenIds.add(message.id)) { | ||||
|           finalUniqueMessages.add(message); | ||||
|         } | ||||
|       } | ||||
|       return finalUniqueMessages; | ||||
|     } | ||||
|  | ||||
|     return uniqueMessages; | ||||
|   } | ||||
|  | ||||
|   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}, | ||||
|       ); | ||||
|       _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; | ||||
|     _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<void> syncMessages() async { | ||||
|     if (_isSyncing) { | ||||
|       developer.log( | ||||
|         'Sync already in progress, skipping.', | ||||
|         name: 'MessagesNotifier', | ||||
|       ); | ||||
|       return; | ||||
|     } | ||||
|     _isSyncing = true; | ||||
|  | ||||
|     developer.log('Starting message sync', name: 'MessagesNotifier'); | ||||
|     Future.microtask(() => 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.messages.length} changes', | ||||
|         name: 'MessagesNotifier', | ||||
|       ); | ||||
|       for (final message in response.messages) { | ||||
|         switch (message.type) { | ||||
|           case "messages.update": | ||||
|           case "messages.update.links": | ||||
|             await receiveMessageUpdate(message); | ||||
|             break; | ||||
|           case "messages.delete": | ||||
|             await receiveMessageDeletion(message.id.toString()); | ||||
|             break; | ||||
|         } | ||||
|         // Still need receive the message to show the history actions | ||||
|         await receiveMessage(message); | ||||
|       } | ||||
|     } catch (err, stackTrace) { | ||||
|       developer.log( | ||||
|         'Error syncing messages', | ||||
|         name: 'MessagesNotifier', | ||||
|         error: err, | ||||
|         stackTrace: stackTrace, | ||||
|       ); | ||||
|       showErrorAlert(err); | ||||
|     } finally { | ||||
|       developer.log('Finished message sync', name: 'MessagesNotifier'); | ||||
|       Future.microtask( | ||||
|         () => ref.read(isSyncingProvider.notifier).state = false, | ||||
|       ); | ||||
|       _isSyncing = false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<List<LocalChatMessage>> listMessages({ | ||||
|     int offset = 0, | ||||
|     int take = 20, | ||||
|     bool synced = false, | ||||
|   }) async { | ||||
|     try { | ||||
|       if (offset == 0 && | ||||
|           !synced && | ||||
|           (_searchQuery == null || _searchQuery!.isEmpty)) { | ||||
|         _fetchAndCacheMessages(offset: 0, take: take).catchError((_) { | ||||
|           return <LocalChatMessage>[]; | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       final localMessages = await _getCachedMessages( | ||||
|         offset: offset, | ||||
|         take: take, | ||||
|       ); | ||||
|  | ||||
|       if (localMessages.isNotEmpty) { | ||||
|         return localMessages; | ||||
|       } | ||||
|  | ||||
|       if (_searchQuery == null || _searchQuery!.isEmpty) { | ||||
|         return await _fetchAndCacheMessages(offset: offset, take: take); | ||||
|       } else { | ||||
|         return []; // If searching, and no local messages, don't fetch from network | ||||
|       } | ||||
|     } catch (e) { | ||||
|       final localMessages = await _getCachedMessages( | ||||
|         offset: offset, | ||||
|         take: take, | ||||
|       ); | ||||
|  | ||||
|       if (localMessages.isNotEmpty) { | ||||
|         return localMessages; | ||||
|       } | ||||
|       rethrow; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> loadInitial() async { | ||||
|     developer.log('Loading initial messages', name: 'MessagesNotifier'); | ||||
|     if (_searchQuery == null || _searchQuery!.isEmpty) { | ||||
|       syncMessages(); | ||||
|     } | ||||
|  | ||||
|     final messages = await _getCachedMessages(offset: 0, take: _pageSize); | ||||
|  | ||||
|     _hasMore = messages.length == _pageSize; | ||||
|  | ||||
|     state = AsyncValue.data(messages); | ||||
|   } | ||||
|  | ||||
|   Future<void> loadMore() async { | ||||
|     if (!_hasMore || state is AsyncLoading) return; | ||||
|     developer.log('Loading more messages', name: 'MessagesNotifier'); | ||||
|  | ||||
|     try { | ||||
|       final currentMessages = state.value ?? []; | ||||
|       final offset = currentMessages.length; | ||||
|  | ||||
|       final newMessages = await listMessages(offset: offset, take: _pageSize); | ||||
|  | ||||
|       if (newMessages.isEmpty || newMessages.length < _pageSize) { | ||||
|         _hasMore = false; | ||||
|       } | ||||
|  | ||||
|       state = AsyncValue.data( | ||||
|         _sortMessages([...currentMessages, ...newMessages]), | ||||
|       ); | ||||
|     } catch (err, stackTrace) { | ||||
|       developer.log( | ||||
|         'Error loading more messages', | ||||
|         name: 'MessagesNotifier', | ||||
|         error: err, | ||||
|         stackTrace: stackTrace, | ||||
|       ); | ||||
|       showErrorAlert(err); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> sendMessage( | ||||
|     String content, | ||||
|     List<UniversalFile> attachments, { | ||||
|     SnChatMessage? editingTo, | ||||
|     SnChatMessage? forwardingTo, | ||||
|     SnChatMessage? replyingTo, | ||||
|     Function(String, Map<int, double>)? onProgress, | ||||
|   }) async { | ||||
|     final nonce = const Uuid().v4(); | ||||
|     developer.log( | ||||
|       'Sending message with nonce $nonce', | ||||
|       name: 'MessagesNotifier', | ||||
|     ); | ||||
|     final baseUrl = ref.read(serverUrlProvider); | ||||
|     final token = await getToken(ref.watch(tokenProvider)); | ||||
|     if (token == null) throw ArgumentError('Access token is null'); | ||||
|  | ||||
|     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, | ||||
|     ); | ||||
|  | ||||
|     _pendingMessages[localMessage.id] = localMessage; | ||||
|     _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 putFileToCloud( | ||||
|               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 currentMessages = state.value ?? []; | ||||
|       if (editingTo != null) { | ||||
|         final newMessages = | ||||
|             currentMessages | ||||
|                 .where((m) => m.id != localMessage.id) // remove pending message | ||||
|                 .map( | ||||
|                   (m) => m.id == editingTo.id ? updatedMessage : m, | ||||
|                 ) // update original message | ||||
|                 .toList(); | ||||
|         state = AsyncValue.data(newMessages); | ||||
|       } else { | ||||
|         final newMessages = | ||||
|             currentMessages.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 { | ||||
|     developer.log( | ||||
|       'Retrying message $pendingMessageId', | ||||
|       name: 'MessagesNotifier', | ||||
|     ); | ||||
|     final message = await fetchMessageById(pendingMessageId); | ||||
|     if (message == null) { | ||||
|       throw Exception('Message not found'); | ||||
|     } | ||||
|  | ||||
|     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(_sortMessages(newMessages)); | ||||
|       showErrorAlert(e); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> receiveMessage(SnChatMessage remoteMessage) async { | ||||
|     if (remoteMessage.chatRoomId != _roomId) return; | ||||
|     developer.log( | ||||
|       'Received new message ${remoteMessage.id}', | ||||
|       name: 'MessagesNotifier', | ||||
|     ); | ||||
|  | ||||
|     final localMessage = LocalChatMessage.fromRemoteMessage( | ||||
|       remoteMessage, | ||||
|       MessageStatus.sent, | ||||
|     ); | ||||
|  | ||||
|     if (remoteMessage.nonce != null) { | ||||
|       _pendingMessages.removeWhere( | ||||
|         (_, pendingMsg) => pendingMsg.nonce == remoteMessage.nonce, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     await _database.saveMessage(_database.messageToCompanion(localMessage)); | ||||
|  | ||||
|     final currentMessages = state.value ?? []; | ||||
|     final existingIndex = currentMessages.indexWhere( | ||||
|       (m) => | ||||
|           m.id == localMessage.id || | ||||
|           (localMessage.nonce != null && m.nonce == localMessage.nonce), | ||||
|     ); | ||||
|  | ||||
|     if (existingIndex >= 0) { | ||||
|       final newList = [...currentMessages]; | ||||
|       newList[existingIndex] = localMessage; | ||||
|       state = AsyncValue.data(_sortMessages(newList)); | ||||
|     } else { | ||||
|       state = AsyncValue.data( | ||||
|         _sortMessages([localMessage, ...currentMessages]), | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> receiveMessageUpdate(SnChatMessage remoteMessage) async { | ||||
|     if (remoteMessage.chatRoomId != _roomId) return; | ||||
|     developer.log( | ||||
|       'Received message update ${remoteMessage.id}', | ||||
|       name: 'MessagesNotifier', | ||||
|     ); | ||||
|  | ||||
|     final updatedMessage = LocalChatMessage.fromRemoteMessage( | ||||
|       remoteMessage, | ||||
|       MessageStatus.sent, | ||||
|     ); | ||||
|     await _database.updateMessage(_database.messageToCompanion(updatedMessage)); | ||||
|  | ||||
|     final currentMessages = state.value ?? []; | ||||
|     final index = currentMessages.indexWhere((m) => m.id == updatedMessage.id); | ||||
|  | ||||
|     if (index >= 0) { | ||||
|       final newList = [...currentMessages]; | ||||
|       newList[index] = updatedMessage; | ||||
|       state = AsyncValue.data(_sortMessages(newList)); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> receiveMessageDeletion(String messageId) async { | ||||
|     developer.log( | ||||
|       'Received message deletion $messageId', | ||||
|       name: 'MessagesNotifier', | ||||
|     ); | ||||
|     _pendingMessages.remove(messageId); | ||||
|  | ||||
|     final currentMessages = state.value ?? []; | ||||
|     final messageIndex = currentMessages.indexWhere((m) => m.id == messageId); | ||||
|  | ||||
|     LocalChatMessage? messageToUpdate; | ||||
|     if (messageIndex != -1) { | ||||
|       messageToUpdate = currentMessages[messageIndex]; | ||||
|     } else { | ||||
|       messageToUpdate = await fetchMessageById(messageId); | ||||
|     } | ||||
|  | ||||
|     if (messageToUpdate == null) return; | ||||
|  | ||||
|     final remote = messageToUpdate.toRemoteMessage(); | ||||
|     final updatedRemote = remote.copyWith( | ||||
|       content: 'This message was deleted', | ||||
|       deletedAt: DateTime.now(), | ||||
|       attachments: [], | ||||
|     ); | ||||
|  | ||||
|     final deletedMessage = LocalChatMessage.fromRemoteMessage( | ||||
|       updatedRemote, | ||||
|       messageToUpdate.status, | ||||
|     ); | ||||
|  | ||||
|     await _database.saveMessage(_database.messageToCompanion(deletedMessage)); | ||||
|  | ||||
|     if (messageIndex != -1) { | ||||
|       final newList = [...currentMessages]; | ||||
|       newList[messageIndex] = deletedMessage; | ||||
|       state = AsyncValue.data(newList); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> deleteMessage(String messageId) async { | ||||
|     developer.log('Deleting message $messageId', name: 'MessagesNotifier'); | ||||
|     try { | ||||
|       await _apiClient.delete('/sphere/chat/$_roomId/messages/$messageId'); | ||||
|       await receiveMessageDeletion(messageId); | ||||
|     } catch (err, stackTrace) { | ||||
|       developer.log( | ||||
|         'Error deleting message $messageId', | ||||
|         name: 'MessagesNotifier', | ||||
|         error: err, | ||||
|         stackTrace: stackTrace, | ||||
|       ); | ||||
|       showErrorAlert(err); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void searchMessages(String query, {bool? withLinks, bool? withAttachments}) { | ||||
|     _searchQuery = query.trim(); | ||||
|     _withLinks = withLinks; | ||||
|     _withAttachments = withAttachments; | ||||
|     loadInitial(); | ||||
|   } | ||||
|  | ||||
|   void clearSearch() { | ||||
|     _searchQuery = null; | ||||
|     _withLinks = null; | ||||
|     _withAttachments = null; | ||||
|     loadInitial(); | ||||
|   } | ||||
|  | ||||
|   Future<LocalChatMessage?> fetchMessageById(String messageId) async { | ||||
|     developer.log( | ||||
|       'Fetching message by id $messageId', | ||||
|       name: 'MessagesNotifier', | ||||
|     ); | ||||
|     try { | ||||
|       final localMessage = | ||||
|           await (_database.select(_database.chatMessages) | ||||
|             ..where((tbl) => tbl.id.equals(messageId))).getSingleOrNull(); | ||||
|       if (localMessage != null) { | ||||
|         return _database.companionToMessage(localMessage); | ||||
|       } | ||||
|  | ||||
|       final response = await _apiClient.get( | ||||
|         '/sphere/chat/$_roomId/messages/$messageId', | ||||
|       ); | ||||
|       final remoteMessage = SnChatMessage.fromJson(response.data); | ||||
|       final message = LocalChatMessage.fromRemoteMessage( | ||||
|         remoteMessage, | ||||
|         MessageStatus.sent, | ||||
|       ); | ||||
|  | ||||
|       await _database.saveMessage(_database.messageToCompanion(message)); | ||||
|       return message; | ||||
|     } catch (e) { | ||||
|       if (e is DioException) return null; | ||||
|       rethrow; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<int> jumpToMessage(String messageId) async { | ||||
|     developer.log( | ||||
|       'Starting jump to message $messageId', | ||||
|       name: 'MessagesNotifier', | ||||
|     ); | ||||
|     if (_isJumping) { | ||||
|       developer.log( | ||||
|         'Jump already in progress, skipping', | ||||
|         name: 'MessagesNotifier', | ||||
|       ); | ||||
|       return -1; | ||||
|     } | ||||
|     _isJumping = true; | ||||
|  | ||||
|     try { | ||||
|       developer.log('Fetching message $messageId', name: 'MessagesNotifier'); | ||||
|       final message = await fetchMessageById(messageId); | ||||
|       if (message == null) { | ||||
|         developer.log('Message $messageId not found', name: 'MessagesNotifier'); | ||||
|         showSnackBar('messageNotFound'.tr()); | ||||
|         return -1; | ||||
|       } | ||||
|  | ||||
|       // Check if message is already in current state to avoid duplicate loading | ||||
|       final currentMessages = state.value ?? []; | ||||
|       final existingIndex = currentMessages.indexWhere( | ||||
|         (m) => m.id == messageId, | ||||
|       ); | ||||
|       if (existingIndex >= 0) { | ||||
|         developer.log( | ||||
|           'Message $messageId already in current state at index $existingIndex, jumping directly', | ||||
|           name: 'MessagesNotifier', | ||||
|         ); | ||||
|         return existingIndex; | ||||
|       } | ||||
|  | ||||
|       developer.log( | ||||
|         'Message $messageId not in current state, loading messages around it', | ||||
|         name: 'MessagesNotifier', | ||||
|       ); | ||||
|  | ||||
|       // Count messages newer than this one | ||||
|       final query = _database.customSelect( | ||||
|         'SELECT COUNT(*) as count FROM chat_messages WHERE room_id = ? AND created_at > ?', | ||||
|         variables: [ | ||||
|           Variable.withString(_roomId), | ||||
|           Variable.withDateTime(message.createdAt), | ||||
|         ], | ||||
|         readsFrom: {_database.chatMessages}, | ||||
|       ); | ||||
|       final result = await query.getSingle(); | ||||
|       final newerCount = result.read<int>('count'); | ||||
|  | ||||
|       // Load messages around this position | ||||
|       final offset = | ||||
|           (newerCount - _pageSize ~/ 2).clamp(0, double.infinity).toInt(); | ||||
|       developer.log( | ||||
|         'Loading messages with offset $offset, take $_pageSize', | ||||
|         name: 'MessagesNotifier', | ||||
|       ); | ||||
|       final loadedMessages = await _getCachedMessages( | ||||
|         offset: offset, | ||||
|         take: _pageSize, | ||||
|       ); | ||||
|  | ||||
|       // Check if loaded messages are already in current state | ||||
|       final currentIds = currentMessages.map((m) => m.id).toSet(); | ||||
|       final newMessages = | ||||
|           loadedMessages.where((m) => !currentIds.contains(m.id)).toList(); | ||||
|       developer.log( | ||||
|         'Loaded ${loadedMessages.length} messages, ${newMessages.length} are new', | ||||
|         name: 'MessagesNotifier', | ||||
|       ); | ||||
|  | ||||
|       if (newMessages.isNotEmpty) { | ||||
|         // Merge with current messages | ||||
|         final allMessages = [...currentMessages, ...newMessages]; | ||||
|         final uniqueMessages = <LocalChatMessage>[]; | ||||
|         final seenIds = <String>{}; | ||||
|         for (final message in allMessages) { | ||||
|           if (seenIds.add(message.id)) { | ||||
|             uniqueMessages.add(message); | ||||
|           } | ||||
|         } | ||||
|         _sortMessages(uniqueMessages); | ||||
|         state = AsyncValue.data(uniqueMessages); | ||||
|         developer.log( | ||||
|           'Updated state with ${uniqueMessages.length} total messages', | ||||
|           name: 'MessagesNotifier', | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       final finalIndex = (state.value ?? []).indexWhere( | ||||
|         (m) => m.id == messageId, | ||||
|       ); | ||||
|       developer.log( | ||||
|         'Final index for message $messageId is $finalIndex', | ||||
|         name: 'MessagesNotifier', | ||||
|       ); | ||||
|       return finalIndex; | ||||
|     } finally { | ||||
|       _isJumping = false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   bool _hasLink(LocalChatMessage message) { | ||||
|     final content = message.toRemoteMessage().content; | ||||
|     if (content == null) return false; | ||||
|     final urlRegex = RegExp(r'https?://[^\s/$.?#].[^\s]*'); | ||||
|     return urlRegex.hasMatch(content); | ||||
|   } | ||||
| } | ||||
| @@ -1,12 +1,12 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| 
 | ||||
| part of 'room.dart'; | ||||
| part of 'messages_notifier.dart'; | ||||
| 
 | ||||
| // ************************************************************************** | ||||
| // RiverpodGenerator | ||||
| // ************************************************************************** | ||||
| 
 | ||||
| String _$messagesNotifierHash() => r'fc3b66dfb8dd3fc55d142dae5c5e7bdc67eca5d4'; | ||||
| String _$messagesNotifierHash() => r'37bab723531a5c5248471ef810b74cf57b3dc237'; | ||||
| 
 | ||||
| /// Copied from Dart SDK | ||||
| class _SystemHash { | ||||
							
								
								
									
										28
									
								
								lib/pods/pool_provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								lib/pods/pool_provider.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/file_pool.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
|  | ||||
| final poolsProvider = FutureProvider<List<SnFilePool>>((ref) async { | ||||
|   final dio = ref.watch(apiClientProvider); | ||||
|   final response = await dio.get('/drive/pools'); | ||||
|   final pools = SnFilePoolList.listFromResponse(response.data); | ||||
|   return pools.filterValid(); | ||||
| }); | ||||
|  | ||||
| String resolveDefaultPoolId(WidgetRef ref, List<SnFilePool> pools) { | ||||
|   final settings = ref.watch(appSettingsNotifierProvider); | ||||
|   final validPools = pools.filterValid(); | ||||
|  | ||||
|   final configuredId = settings.defaultPoolId; | ||||
|   if (configuredId != null && validPools.any((p) => p.id == configuredId)) { | ||||
|     return configuredId; | ||||
|   } | ||||
|  | ||||
|   if (validPools.isNotEmpty) { | ||||
|     return validPools.first.id; | ||||
|   } | ||||
|  | ||||
|   // DEFAULT: Solar Network Driver | ||||
|   return '500e5ed8-bd44-4359-bc0a-ec85e2adf447'; } | ||||
|  | ||||
							
								
								
									
										34
									
								
								lib/pods/room_providers.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								lib/pods/room_providers.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| import "dart:async"; | ||||
| import "package:flutter/material.dart"; | ||||
| import "package:hooks_riverpod/hooks_riverpod.dart"; | ||||
|  | ||||
| final isSyncingProvider = StateProvider.autoDispose<bool>((ref) => false); | ||||
|  | ||||
| final flashingMessagesProvider = StateProvider<Set<String>>((ref) => {}); | ||||
|  | ||||
| final appLifecycleStateProvider = StreamProvider<AppLifecycleState>((ref) { | ||||
|   final controller = StreamController<AppLifecycleState>(); | ||||
|  | ||||
|   final observer = _AppLifecycleObserver((state) { | ||||
|     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); | ||||
|   } | ||||
| } | ||||
| @@ -30,7 +30,7 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> { | ||||
|       final user = SnAccount.fromJson(response.data); | ||||
|       state = AsyncValue.data(user); | ||||
|  | ||||
|       if (kIsWeb || !Platform.isLinux) { | ||||
|       if (kIsWeb || !(Platform.isLinux || Platform.isWindows)) { | ||||
|         FirebaseAnalytics.instance.setUserId(id: user.id); | ||||
|       } | ||||
|     } catch (error, stackTrace) { | ||||
| @@ -44,9 +44,12 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> { | ||||
|                       : 'failedToLoadUserInfoNetwork') | ||||
|                   .tr() | ||||
|                   .trim(), | ||||
|               '${error.response!.statusCode}\n${error.response?.headers}', | ||||
|               jsonEncode(error.response?.data), | ||||
|             ].join('\n\n'), | ||||
|               '', | ||||
|               '${error.response?.statusCode ?? 'Network Error'}', | ||||
|               if (error.response?.headers != null) error.response?.headers, | ||||
|               if (error.response?.data != null) | ||||
|                 jsonEncode(error.response?.data), | ||||
|             ].join('\n'), | ||||
|             iconStyle: IconStyle.error, | ||||
|             neutralButtonTitle: 'retry'.tr(), | ||||
|             negativeButtonTitle: 'okay'.tr(), | ||||
| @@ -87,7 +90,7 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> { | ||||
|     final prefs = _ref.read(sharedPreferencesProvider); | ||||
|     await prefs.remove(kTokenPairStoreKey); | ||||
|     _ref.invalidate(tokenProvider); | ||||
|     if (kIsWeb || !Platform.isLinux) { | ||||
|     if (kIsWeb || !(Platform.isLinux || Platform.isWindows)) { | ||||
|       FirebaseAnalytics.instance.setUserId(id: null); | ||||
|     } | ||||
|   } | ||||
|   | ||||
| @@ -38,7 +38,7 @@ import 'package:island/screens/chat/chat.dart'; | ||||
| import 'package:island/screens/chat/room.dart'; | ||||
| import 'package:island/screens/chat/room_detail.dart'; | ||||
| import 'package:island/screens/chat/call.dart'; | ||||
| import 'package:island/screens/chat/search_messages_screen.dart'; | ||||
| import 'package:island/screens/chat/search_messages.dart'; | ||||
| import 'package:island/screens/creators/hub.dart'; | ||||
| import 'package:island/screens/creators/posts/post_manage_list.dart'; | ||||
| import 'package:island/screens/creators/stickers/stickers.dart'; | ||||
| @@ -86,11 +86,7 @@ Widget _tabPagesTransitionBuilder( | ||||
| } | ||||
|  | ||||
| bool get _supportsAnalytics => | ||||
|     kIsWeb || | ||||
|     Platform.isAndroid || | ||||
|     Platform.isIOS || | ||||
|     Platform.isMacOS || | ||||
|     Platform.isWindows; | ||||
|     kIsWeb || Platform.isAndroid || Platform.isIOS || Platform.isMacOS; | ||||
|  | ||||
| // Provider for the router | ||||
| final routerProvider = Provider<GoRouter>((ref) { | ||||
|   | ||||
| @@ -6,7 +6,7 @@ import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/services/udid.native.dart'; | ||||
| import 'package:island/services/udid.dart' as udid; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| @@ -68,7 +68,7 @@ class _AboutScreenState extends ConsumerState<AboutScreen> { | ||||
|     try { | ||||
|       final deviceInfoPlugin = DeviceInfoPlugin(); | ||||
|       _deviceInfo = await deviceInfoPlugin.deviceInfo; | ||||
|       _deviceUdid = await getUdid(); | ||||
|       _deviceUdid = await udid.getUdid(); | ||||
|       if (mounted) { | ||||
|         setState(() {}); | ||||
|       } | ||||
| @@ -174,12 +174,20 @@ class _AboutScreenState extends ConsumerState<AboutScreen> { | ||||
|                             context, | ||||
|                             title: 'Device Information', | ||||
|                             children: [ | ||||
|                               _buildInfoItem( | ||||
|                                 context, | ||||
|                                 icon: Symbols.label, | ||||
|                                 label: 'aboutDeviceName'.tr(), | ||||
|                                 value: | ||||
|                                     _deviceInfo?.data['name'] ?? 'unknown'.tr(), | ||||
|                               FutureBuilder<String>( | ||||
|                                 future: udid.getDeviceName(), | ||||
|                                 builder: (context, snapshot) { | ||||
|                                   final value = | ||||
|                                       snapshot.hasData | ||||
|                                           ? snapshot.data! | ||||
|                                           : 'unknown'.tr(); | ||||
|                                   return _buildInfoItem( | ||||
|                                     context, | ||||
|                                     icon: Symbols.label, | ||||
|                                     label: 'aboutDeviceName'.tr(), | ||||
|                                     value: value, | ||||
|                                   ); | ||||
|                                 }, | ||||
|                               ), | ||||
|                               _buildInfoItem( | ||||
|                                 context, | ||||
|   | ||||
| @@ -66,7 +66,7 @@ class UpdateProfileScreen extends HookConsumerWidget { | ||||
|         final token = await getToken(ref.watch(tokenProvider)); | ||||
|         if (token == null) throw ArgumentError('Token is null'); | ||||
|         final cloudFile = | ||||
|             await putMediaToCloud( | ||||
|             await putFileToCloud( | ||||
|               fileData: UniversalFile( | ||||
|                 data: result, | ||||
|                 type: UniversalFileType.image, | ||||
|   | ||||
| @@ -32,7 +32,7 @@ import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/content/markdown.dart'; | ||||
| import 'package:island/widgets/safety/abuse_report_helper.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:palette_generator/palette_generator.dart'; | ||||
| import 'package:island/services/color_extraction.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:share_plus/share_plus.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| @@ -581,14 +581,14 @@ Future<Color?> accountAppbarForcegroundColor(Ref ref, String uname) async { | ||||
|   try { | ||||
|     final account = await ref.watch(accountProvider(uname).future); | ||||
|     if (account.profile.background == null) return null; | ||||
|     final palette = await PaletteGenerator.fromImageProvider( | ||||
|     final colors = await ColorExtractionService.getColorsFromImage( | ||||
|       CloudImageWidget.provider( | ||||
|         fileId: account.profile.background!.id, | ||||
|         serverUrl: ref.watch(serverUrlProvider), | ||||
|       ), | ||||
|     ); | ||||
|     final dominantColor = palette.dominantColor?.color; | ||||
|     if (dominantColor == null) return null; | ||||
|     if (colors.isEmpty) return null; | ||||
|     final dominantColor = colors.first; | ||||
|     return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white; | ||||
|   } catch (_) { | ||||
|     return null; | ||||
|   | ||||
| @@ -268,7 +268,7 @@ class _AccountBadgesProviderElement | ||||
| } | ||||
|  | ||||
| String _$accountAppbarForcegroundColorHash() => | ||||
|     r'8ee0cae10817b77fb09548a482f5247662b4374c'; | ||||
|     r'127fcc7fd6ec6a41ac4a6975276b5271aa4fa7d0'; | ||||
|  | ||||
| /// See also [accountAppbarForcegroundColor]. | ||||
| @ProviderFor(accountAppbarForcegroundColor) | ||||
|   | ||||
| @@ -1,17 +1,10 @@ | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
|  | ||||
| part 'captcha.config.g.dart'; | ||||
|  | ||||
| @riverpod | ||||
| Future<String> captchaUrl(Ref ref) async { | ||||
|   final apiClient = ref.watch(apiClientProvider); | ||||
|   final resp = await apiClient.get('/.well-known/services'); | ||||
|   final serviceMapping = await resp.data; | ||||
|   var baseUrl = serviceMapping['DysonNetwork.Pass'] as String; | ||||
|   // The backend using self-signed certicates on development | ||||
|   // Which mobile simulator might not accept, use this to avoid errors | ||||
|   if (baseUrl.contains('https://localhost')) baseUrl = 'http://localhost:5216'; | ||||
|   return '$baseUrl/captcha'; | ||||
|   const baseUrl = "https://solian.app"; | ||||
|   return '$baseUrl/auth/captcha'; | ||||
| } | ||||
|   | ||||
| @@ -6,7 +6,7 @@ part of 'captcha.config.dart'; | ||||
| // RiverpodGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| String _$captchaUrlHash() => r'bbed0d18272dd205069642b3c6583ea2eef735d1'; | ||||
| String _$captchaUrlHash() => r'd46bc43032cef504547cd528a40c23cf76f27cc8'; | ||||
|  | ||||
| /// See also [captchaUrl]. | ||||
| @ProviderFor(captchaUrl) | ||||
|   | ||||
| @@ -1,9 +1,6 @@ | ||||
| import 'dart:convert'; | ||||
| import 'dart:io'; | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| import 'package:animations/animations.dart'; | ||||
| import 'package:device_info_plus/device_info_plus.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| @@ -42,22 +39,6 @@ final Map<int, (String, String, IconData)> kFactorTypes = { | ||||
|   4: ('authFactorPin', 'authFactorPinDescription', Symbols.nest_secure_alarm), | ||||
| }; | ||||
|  | ||||
| Future<String?> getDeviceName() async { | ||||
|   if (kIsWeb) return null; | ||||
|   String? name; | ||||
|   if (Platform.isIOS) { | ||||
|     final deviceInfo = await DeviceInfoPlugin().iosInfo; | ||||
|     name = deviceInfo.name; | ||||
|   } else if (Platform.isAndroid) { | ||||
|     final deviceInfo = await DeviceInfoPlugin().androidInfo; | ||||
|     name = deviceInfo.name; | ||||
|   } else if (Platform.isWindows) { | ||||
|     final deviceInfo = await DeviceInfoPlugin().windowsInfo; | ||||
|     name = deviceInfo.computerName; | ||||
|   } | ||||
|   return name; | ||||
| } | ||||
|  | ||||
| class LoginScreen extends HookConsumerWidget { | ||||
|   const LoginScreen({super.key}); | ||||
|  | ||||
|   | ||||
| @@ -543,7 +543,7 @@ class EditChatScreen extends HookConsumerWidget { | ||||
|         final token = await getToken(ref.watch(tokenProvider)); | ||||
|         if (token == null) throw ArgumentError('Token is null'); | ||||
|         final cloudFile = | ||||
|             await putMediaToCloud( | ||||
|             await putFileToCloud( | ||||
|               fileData: UniversalFile( | ||||
|                 data: result, | ||||
|                 type: UniversalFileType.image, | ||||
|   | ||||
							
								
								
									
										221
									
								
								lib/screens/chat/public_room_preview.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										221
									
								
								lib/screens/chat/public_room_preview.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,221 @@ | ||||
| import "package:flutter/material.dart"; | ||||
| import "package:flutter_hooks/flutter_hooks.dart"; | ||||
| import "package:gap/gap.dart"; | ||||
| import "package:hooks_riverpod/hooks_riverpod.dart"; | ||||
| import "package:island/database/message.dart"; | ||||
| import "package:island/screens/chat/chat.dart"; | ||||
| import "package:island/widgets/content/cloud_files.dart"; | ||||
| import "package:super_sliver_list/super_sliver_list.dart"; | ||||
| import "package:easy_localization/easy_localization.dart"; | ||||
| import "package:go_router/go_router.dart"; | ||||
| import "package:material_symbols_icons/symbols.dart"; | ||||
| import "package:styled_widget/styled_widget.dart"; | ||||
| import "package:island/models/chat.dart"; | ||||
| import "package:island/widgets/alert.dart"; | ||||
| import "package:island/widgets/app_scaffold.dart"; | ||||
| import "package:island/widgets/chat/message_item.dart"; | ||||
| import "package:island/widgets/response.dart"; | ||||
| import "package:island/pods/network.dart"; | ||||
| import "package:island/services/responsive.dart"; | ||||
| import "package:island/pods/messages_notifier.dart"; | ||||
|  | ||||
| class PublicRoomPreview extends HookConsumerWidget { | ||||
|   final String id; | ||||
|   final SnChatRoom room; | ||||
|  | ||||
|   const PublicRoomPreview({super.key, required this.id, required this.room}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final messages = ref.watch(messagesNotifierProvider(id)); | ||||
|     final messagesNotifier = ref.read(messagesNotifierProvider(id).notifier); | ||||
|     final scrollController = useScrollController(); | ||||
|  | ||||
|     final listController = useMemoized(() => ListController(), []); | ||||
|  | ||||
|     var isLoading = false; | ||||
|  | ||||
|     // Add scroll listener for pagination | ||||
|     useEffect(() { | ||||
|       void onScroll() { | ||||
|         if (scrollController.position.pixels >= | ||||
|             scrollController.position.maxScrollExtent - 200) { | ||||
|           if (isLoading) return; | ||||
|           isLoading = true; | ||||
|           messagesNotifier.loadMore().then((_) => isLoading = false); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       scrollController.addListener(onScroll); | ||||
|       return () => scrollController.removeListener(onScroll); | ||||
|     }, [scrollController]); | ||||
|  | ||||
|     Widget chatMessageListWidget(List<LocalChatMessage> messageList) => | ||||
|         SuperListView.builder( | ||||
|           listController: listController, | ||||
|           padding: EdgeInsets.symmetric(vertical: 16), | ||||
|           controller: scrollController, | ||||
|           reverse: true, // Show newest messages at the bottom | ||||
|           itemCount: messageList.length, | ||||
|           findChildIndexCallback: (key) { | ||||
|             final valueKey = key as ValueKey; | ||||
|             final messageId = valueKey.value as String; | ||||
|             return messageList.indexWhere((m) => m.id == messageId); | ||||
|           }, | ||||
|           extentEstimation: (_, _) => 40, | ||||
|           itemBuilder: (context, index) { | ||||
|             final message = messageList[index]; | ||||
|             final nextMessage = | ||||
|                 index < messageList.length - 1 ? messageList[index + 1] : null; | ||||
|             final isLastInGroup = | ||||
|                 nextMessage == null || | ||||
|                 nextMessage.senderId != message.senderId || | ||||
|                 nextMessage.createdAt | ||||
|                         .difference(message.createdAt) | ||||
|                         .inMinutes | ||||
|                         .abs() > | ||||
|                     3; | ||||
|  | ||||
|             return MessageItem( | ||||
|               message: message, | ||||
|               isCurrentUser: false, // User is not a member, so not current user | ||||
|               onAction: null, // No actions allowed in preview mode | ||||
|               onJump: (_) {}, // No jump functionality in preview | ||||
|               progress: null, | ||||
|               showAvatar: isLastInGroup, | ||||
|             ); | ||||
|           }, | ||||
|         ); | ||||
|  | ||||
|     final compactHeader = isWideScreen(context); | ||||
|  | ||||
|     Widget comfortHeaderWidget() => Column( | ||||
|       spacing: 4, | ||||
|       mainAxisAlignment: MainAxisAlignment.center, | ||||
|       crossAxisAlignment: CrossAxisAlignment.center, | ||||
|       children: [ | ||||
|         SizedBox( | ||||
|           height: 26, | ||||
|           width: 26, | ||||
|           child: | ||||
|               (room.type == 1 && room.picture?.id == null) | ||||
|                   ? SplitAvatarWidget( | ||||
|                     filesId: | ||||
|                         room.members! | ||||
|                             .map((e) => e.account.profile.picture?.id) | ||||
|                             .toList(), | ||||
|                   ) | ||||
|                   : room.picture?.id != null | ||||
|                   ? ProfilePictureWidget( | ||||
|                     fileId: room.picture?.id, | ||||
|                     fallbackIcon: Symbols.chat, | ||||
|                   ) | ||||
|                   : CircleAvatar( | ||||
|                     child: Text( | ||||
|                       room.name![0].toUpperCase(), | ||||
|                       style: const TextStyle(fontSize: 12), | ||||
|                     ), | ||||
|                   ), | ||||
|         ), | ||||
|         Text( | ||||
|           (room.type == 1 && room.name == null) | ||||
|               ? room.members!.map((e) => e.account.nick).join(', ') | ||||
|               : room.name!, | ||||
|         ).fontSize(15), | ||||
|       ], | ||||
|     ); | ||||
|  | ||||
|     Widget compactHeaderWidget() => Row( | ||||
|       spacing: 8, | ||||
|       crossAxisAlignment: CrossAxisAlignment.center, | ||||
|       children: [ | ||||
|         SizedBox( | ||||
|           height: 26, | ||||
|           width: 26, | ||||
|           child: | ||||
|               (room.type == 1 && room.picture?.id == null) | ||||
|                   ? SplitAvatarWidget( | ||||
|                     filesId: | ||||
|                         room.members! | ||||
|                             .map((e) => e.account.profile.picture?.id) | ||||
|                             .toList(), | ||||
|                   ) | ||||
|                   : room.picture?.id != null | ||||
|                   ? ProfilePictureWidget( | ||||
|                     fileId: room.picture?.id, | ||||
|                     fallbackIcon: Symbols.chat, | ||||
|                   ) | ||||
|                   : CircleAvatar( | ||||
|                     child: Text( | ||||
|                       room.name![0].toUpperCase(), | ||||
|                       style: const TextStyle(fontSize: 12), | ||||
|                     ), | ||||
|                   ), | ||||
|         ), | ||||
|         Text( | ||||
|           (room.type == 1 && room.name == null) | ||||
|               ? room.members!.map((e) => e.account.nick).join(', ') | ||||
|               : room.name!, | ||||
|         ).fontSize(19), | ||||
|       ], | ||||
|     ); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar( | ||||
|         leading: !compactHeader ? const Center(child: PageBackButton()) : null, | ||||
|         automaticallyImplyLeading: false, | ||||
|         toolbarHeight: compactHeader ? null : 64, | ||||
|         title: compactHeader ? compactHeaderWidget() : comfortHeaderWidget(), | ||||
|         actions: [ | ||||
|           IconButton( | ||||
|             icon: const Icon(Icons.more_vert), | ||||
|             onPressed: () { | ||||
|               context.pushNamed('chatDetail', pathParameters: {'id': id}); | ||||
|             }, | ||||
|           ), | ||||
|           const Gap(8), | ||||
|         ], | ||||
|       ), | ||||
|       body: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           Expanded( | ||||
|             child: messages.when( | ||||
|               data: | ||||
|                   (messageList) => | ||||
|                       messageList.isEmpty | ||||
|                           ? Center(child: Text('No messages yet'.tr())) | ||||
|                           : chatMessageListWidget(messageList), | ||||
|               loading: () => const Center(child: CircularProgressIndicator()), | ||||
|               error: | ||||
|                   (error, _) => ResponseErrorWidget( | ||||
|                     error: error, | ||||
|                     onRetry: () => messagesNotifier.loadInitial(), | ||||
|                   ), | ||||
|             ), | ||||
|           ), | ||||
|           // Join button at the bottom for public rooms | ||||
|           Container( | ||||
|             padding: const EdgeInsets.all(16), | ||||
|             child: FilledButton.tonalIcon( | ||||
|               onPressed: () async { | ||||
|                 try { | ||||
|                   showLoadingModal(context); | ||||
|                   final apiClient = ref.read(apiClientProvider); | ||||
|                   await apiClient.post('/sphere/chat/${room.id}/members/me'); | ||||
|                   ref.invalidate(chatroomIdentityProvider(id)); | ||||
|                 } catch (err) { | ||||
|                   showErrorAlert(err); | ||||
|                 } finally { | ||||
|                   if (context.mounted) hideLoadingModal(context); | ||||
|                 } | ||||
|               }, | ||||
|               label: Text('chatJoin').tr(), | ||||
|               icon: const Icon(Icons.add), | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -21,6 +21,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:island/pods/database.dart'; | ||||
| import 'package:island/screens/chat/search_messages.dart'; | ||||
|  | ||||
| part 'room_detail.freezed.dart'; | ||||
| part 'room_detail.g.dart'; | ||||
| @@ -401,11 +402,17 @@ class ChatDetailScreen extends HookConsumerWidget { | ||||
|                                           ), | ||||
|                                         ), | ||||
|                                   ), | ||||
|                                   onTap: () { | ||||
|                                     context.pushNamed( | ||||
|                                   onTap: () async { | ||||
|                                     final result = await context.pushNamed( | ||||
|                                       'searchMessages', | ||||
|                                       pathParameters: {'id': id}, | ||||
|                                     ); | ||||
|                                     if (result is SearchMessagesResult) { | ||||
|                                       // Navigate back to room screen with message to jump to | ||||
|                                       if (context.mounted) { | ||||
|                                         context.pop(result.messageId); | ||||
|                                       } | ||||
|                                     } | ||||
|                                   }, | ||||
|                                 ), | ||||
|                               ], | ||||
|   | ||||
| @@ -1,13 +1,20 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/screens/chat/room.dart'; | ||||
| import 'package:island/pods/messages_notifier.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/chat/message_item.dart'; | ||||
| import 'package:island/widgets/chat/message_list_tile.dart'; | ||||
| import 'package:material_symbols_icons/material_symbols_icons.dart'; | ||||
| import 'package:super_sliver_list/super_sliver_list.dart'; | ||||
| 
 | ||||
| // Class to represent the result when popping from search messages | ||||
| class SearchMessagesResult { | ||||
|   final String messageId; | ||||
|   const SearchMessagesResult(this.messageId); | ||||
| } | ||||
| 
 | ||||
| class SearchMessagesScreen extends HookConsumerWidget { | ||||
|   final String roomId; | ||||
| 
 | ||||
| @@ -112,24 +119,24 @@ class SearchMessagesScreen extends HookConsumerWidget { | ||||
|                           ? Center(child: Text('noMessagesFound'.tr())) | ||||
|                           : SuperListView.builder( | ||||
|                             padding: const EdgeInsets.symmetric(vertical: 16), | ||||
|                             reverse: true, // Show newest messages at the bottom | ||||
|                             reverse: false, // Show newest messages at the top | ||||
|                             itemCount: messageList.length, | ||||
|                             itemBuilder: (context, index) { | ||||
|                               final message = messageList[index]; | ||||
|                               // Simplified MessageItem for search results, no grouping logic | ||||
|                               return MessageItem( | ||||
|                               return MessageListTile( | ||||
|                                 message: message, | ||||
|                                 isCurrentUser: | ||||
|                                     false, // Or determine based on actual user | ||||
|                                 onAction: null, | ||||
|                                 onJump: (_) {}, | ||||
|                                 progress: null, | ||||
|                                 showAvatar: true, | ||||
|                                 onJump: (messageId) { | ||||
|                                   // Return the search result and pop back to room detail | ||||
|                                   context.pop(SearchMessagesResult(messageId)); | ||||
|                                 }, | ||||
|                               ); | ||||
|                             }, | ||||
|                           ), | ||||
|               loading: () => const Center(child: CircularProgressIndicator()), | ||||
|               error: (error, _) => Center(child: Text('errorGeneric'.tr(args: [error.toString()]))), | ||||
|               error: | ||||
|                   (error, _) => Center( | ||||
|                     child: Text('errorGeneric'.tr(args: [error.toString()])), | ||||
|                   ), | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
| @@ -78,6 +78,7 @@ class EditPublisherScreen extends HookConsumerWidget { | ||||
|       result = await cropImage( | ||||
|         context, | ||||
|         image: result, | ||||
|         replacePath: true, | ||||
|         allowedAspectRatios: [ | ||||
|           if (position == 'background') | ||||
|             CropAspectRatio(height: 7, width: 16) | ||||
| @@ -98,7 +99,7 @@ class EditPublisherScreen extends HookConsumerWidget { | ||||
|         final token = await getToken(ref.watch(tokenProvider)); | ||||
|         if (token == null) throw ArgumentError('Token is null'); | ||||
|         final cloudFile = | ||||
|             await putMediaToCloud( | ||||
|             await putFileToCloud( | ||||
|               fileData: UniversalFile( | ||||
|                 data: result, | ||||
|                 type: UniversalFileType.image, | ||||
|   | ||||
| @@ -141,7 +141,7 @@ class EditAppScreen extends HookConsumerWidget { | ||||
|         final token = await getToken(ref.watch(tokenProvider)); | ||||
|         if (token == null) throw ArgumentError('Token is null'); | ||||
|         final cloudFile = | ||||
|             await putMediaToCloud( | ||||
|             await putFileToCloud( | ||||
|               fileData: UniversalFile( | ||||
|                 data: result, | ||||
|                 type: UniversalFileType.image, | ||||
|   | ||||
| @@ -127,7 +127,7 @@ class EditBotScreen extends HookConsumerWidget { | ||||
|         final token = await getToken(ref.watch(tokenProvider)); | ||||
|         if (token == null) throw ArgumentError('Token is null'); | ||||
|         final cloudFile = | ||||
|             await putMediaToCloud( | ||||
|             await putFileToCloud( | ||||
|               fileData: UniversalFile( | ||||
|                 data: result, | ||||
|                 type: UniversalFileType.image, | ||||
|   | ||||
| @@ -39,7 +39,7 @@ class NotificationUnreadCountNotifier | ||||
|  | ||||
|     try { | ||||
|       final client = ref.read(apiClientProvider); | ||||
|       final response = await client.get('/pusher/notifications/count'); | ||||
|       final response = await client.get('/ring/notifications/count'); | ||||
|       return (response.data as num).toInt(); | ||||
|     } catch (_) { | ||||
|       return 0; | ||||
| @@ -89,7 +89,7 @@ class NotificationListNotifier extends _$NotificationListNotifier | ||||
|     final queryParams = {'offset': offset, 'take': _pageSize}; | ||||
|  | ||||
|     final response = await client.get( | ||||
|       '/pusher/notifications', | ||||
|       '/ring/notifications', | ||||
|       queryParameters: queryParams, | ||||
|     ); | ||||
|     final total = int.parse(response.headers.value('X-Total') ?? '0'); | ||||
| @@ -121,7 +121,7 @@ class NotificationScreen extends HookConsumerWidget { | ||||
|     Future<void> markAllRead() async { | ||||
|       showLoadingModal(context); | ||||
|       final apiClient = ref.watch(apiClientProvider); | ||||
|       await apiClient.post('/pusher/notifications/all/read'); | ||||
|       await apiClient.post('/ring/notifications/all/read'); | ||||
|       if (!context.mounted) return; | ||||
|       hideLoadingModal(context); | ||||
|       ref.invalidate(notificationListNotifierProvider); | ||||
|   | ||||
| @@ -7,7 +7,7 @@ part of 'notification.dart'; | ||||
| // ************************************************************************** | ||||
|  | ||||
| String _$notificationUnreadCountNotifierHash() => | ||||
|     r'0763b66bd64e5a9b7c317887e109ab367515dfa4'; | ||||
|     r'08c773809958d96a7ce82acf04af1f9e0b23e119'; | ||||
|  | ||||
| /// See also [NotificationUnreadCountNotifier]. | ||||
| @ProviderFor(NotificationUnreadCountNotifier) | ||||
| @@ -28,7 +28,7 @@ final notificationUnreadCountNotifierProvider = | ||||
|  | ||||
| typedef _$NotificationUnreadCountNotifier = AutoDisposeAsyncNotifier<int>; | ||||
| String _$notificationListNotifierHash() => | ||||
|     r'5099466db475bbcf1ab6b514eb072f1dc4c6f930'; | ||||
|     r'260046e11f45b0d67ab25bcbdc8604890d71ccc7'; | ||||
|  | ||||
| /// See also [NotificationListNotifier]. | ||||
| @ProviderFor(NotificationListNotifier) | ||||
|   | ||||
| @@ -2,7 +2,6 @@ import 'dart:async'; | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
|   | ||||
| @@ -21,7 +21,7 @@ import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/content/markdown.dart'; | ||||
| import 'package:island/widgets/post/post_list.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:palette_generator/palette_generator.dart'; | ||||
| import 'package:island/services/color_extraction.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @@ -278,14 +278,14 @@ Future<Color?> publisherAppbarForcegroundColor(Ref ref, String pubName) async { | ||||
|   try { | ||||
|     final publisher = await ref.watch(publisherProvider(pubName).future); | ||||
|     if (publisher.background == null) return null; | ||||
|     final palette = await PaletteGenerator.fromImageProvider( | ||||
|     final colors = await ColorExtractionService.getColorsFromImage( | ||||
|       CloudImageWidget.provider( | ||||
|         fileId: publisher.background!.id, | ||||
|         serverUrl: ref.watch(serverUrlProvider), | ||||
|       ), | ||||
|     ); | ||||
|     final dominantColor = palette.dominantColor?.color; | ||||
|     if (dominantColor == null) return null; | ||||
|     if (colors.isEmpty) return null; | ||||
|     final dominantColor = colors.first; | ||||
|     return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white; | ||||
|   } catch (_) { | ||||
|     return null; | ||||
|   | ||||
| @@ -400,7 +400,7 @@ class _PublisherSubscriptionStatusProviderElement | ||||
| } | ||||
|  | ||||
| String _$publisherAppbarForcegroundColorHash() => | ||||
|     r'd781a806a242aea5c1609ec98c97c52fdd9f7db1'; | ||||
|     r'cd9a9816177a6eecc2bc354acebbbd48892ffdd7'; | ||||
|  | ||||
| /// See also [publisherAppbarForcegroundColor]. | ||||
| @ProviderFor(publisherAppbarForcegroundColor) | ||||
|   | ||||
| @@ -8,7 +8,7 @@ import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/widgets/account/account_pfc.dart'; | ||||
| import 'package:island/widgets/account/status.dart'; | ||||
| import 'package:island/widgets/post/post_list.dart'; | ||||
| import 'package:palette_generator/palette_generator.dart'; | ||||
| import 'package:island/services/color_extraction.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| @@ -32,14 +32,14 @@ part 'realm_detail.g.dart'; | ||||
| Future<Color?> realmAppbarForegroundColor(Ref ref, String realmSlug) async { | ||||
|   final realm = await ref.watch(realmProvider(realmSlug).future); | ||||
|   if (realm?.background == null) return null; | ||||
|   final palette = await PaletteGenerator.fromImageProvider( | ||||
|   final colors = await ColorExtractionService.getColorsFromImage( | ||||
|     CloudImageWidget.provider( | ||||
|       fileId: realm!.background!.id, | ||||
|       serverUrl: ref.watch(serverUrlProvider), | ||||
|     ), | ||||
|   ); | ||||
|   final dominantColor = palette.dominantColor?.color; | ||||
|   if (dominantColor == null) return null; | ||||
|   if (colors.isEmpty) return null; | ||||
|   final dominantColor = colors.first; | ||||
|   return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -7,7 +7,7 @@ part of 'realm_detail.dart'; | ||||
| // ************************************************************************** | ||||
|  | ||||
| String _$realmAppbarForegroundColorHash() => | ||||
|     r'14b5563d861996ea182d0d2db7aa5c2bb3bbaf48'; | ||||
|     r'8131c047a984318a4cc3fbb5daa5ef0ce44dfae5'; | ||||
|  | ||||
| /// Copied from Dart SDK | ||||
| class _SystemHash { | ||||
|   | ||||
| @@ -211,7 +211,7 @@ class EditRealmScreen extends HookConsumerWidget { | ||||
|         final token = await getToken(ref.watch(tokenProvider)); | ||||
|         if (token == null) throw ArgumentError('Access token is null'); | ||||
|         final cloudFile = | ||||
|             await putMediaToCloud( | ||||
|             await putFileToCloud( | ||||
|               fileData: UniversalFile( | ||||
|                 data: result, | ||||
|                 type: UniversalFileType.image, | ||||
|   | ||||
| @@ -12,14 +12,16 @@ import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:image_picker/image_picker.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/services/color_extraction.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:palette_generator/palette_generator.dart'; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/pool_provider.dart'; | ||||
| import 'package:island/models/file_pool.dart'; | ||||
|  | ||||
| class SettingsScreen extends HookConsumerWidget { | ||||
|   const SettingsScreen({super.key}); | ||||
| @@ -33,7 +35,7 @@ class SettingsScreen extends HookConsumerWidget { | ||||
|     final isDesktop = | ||||
|         !kIsWeb && (Platform.isWindows || Platform.isMacOS || Platform.isLinux); | ||||
|     final isWide = isWideScreen(context); | ||||
|  | ||||
|     final poolsAsync = ref.watch(poolsProvider); | ||||
|     final docBasepath = useState<String?>(null); | ||||
|  | ||||
|     useEffect(() { | ||||
| @@ -293,24 +295,26 @@ class SettingsScreen extends HookConsumerWidget { | ||||
|               trailing: const Icon(Symbols.chevron_right), | ||||
|               onTap: () async { | ||||
|                 showLoadingModal(context); | ||||
|                 final palette = await PaletteGenerator.fromImageProvider( | ||||
|                 final colors = await ColorExtractionService.getColorsFromImage( | ||||
|                   FileImage( | ||||
|                     File('${docBasepath.value}/$kAppBackgroundImagePath'), | ||||
|                   ), | ||||
|                 ); | ||||
|                 if (palette.darkVibrantColor == null || | ||||
|                     palette.lightVibrantColor == null) { | ||||
|                 if (colors.isEmpty) { | ||||
|                   if (context.mounted) hideLoadingModal(context); | ||||
|                   showErrorAlert( | ||||
|                     'Unable to calculate the domiant color of the background image.', | ||||
|                     'Unable to calculate the dominant color of the background image.', | ||||
|                   ); | ||||
|                   return; | ||||
|                 } | ||||
|                 if (!context.mounted) return; | ||||
|                 final colorScheme = ColorScheme.fromSeed( | ||||
|                   seedColor: colors.first, | ||||
|                 ); | ||||
|                 final color = | ||||
|                     MediaQuery.of(context).platformBrightness == Brightness.dark | ||||
|                         ? palette.darkVibrantColor!.color | ||||
|                         : palette.lightVibrantColor!.color; | ||||
|                         ? colorScheme.primary | ||||
|                         : colorScheme.primary; | ||||
|                 ref | ||||
|                     .read(appSettingsNotifierProvider.notifier) | ||||
|                     .setAppColorScheme(color.value); | ||||
| @@ -365,6 +369,66 @@ class SettingsScreen extends HookConsumerWidget { | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|  | ||||
|       poolsAsync.when( | ||||
|         data: (pools) { | ||||
|           final validPools = pools.filterValid(); | ||||
|           final currentPoolId = resolveDefaultPoolId(ref, pools); | ||||
|  | ||||
|           return ListTile( | ||||
|             isThreeLine: true, | ||||
|             minLeadingWidth: 48, | ||||
|             title: Text('settingsDefaultPool').tr(), | ||||
|             contentPadding: const EdgeInsets.only(left: 24, right: 17), | ||||
|             leading: const Icon(Symbols.cloud), | ||||
|             subtitle: Text( | ||||
|               validPools | ||||
|                       .firstWhereOrNull((p) => p.id == currentPoolId) | ||||
|                       ?.description ?? | ||||
|                   'settingsDefaultPoolHelper'.tr(), | ||||
|               style: Theme.of(context).textTheme.bodySmall, | ||||
|             ), | ||||
|             trailing: DropdownButtonHideUnderline( | ||||
|               child: DropdownButton2<String>( | ||||
|                 isExpanded: true, | ||||
|                 items: | ||||
|                     validPools.map((p) { | ||||
|                       return DropdownMenuItem<String>( | ||||
|                         value: p.id, | ||||
|                         child: Text(p.name).fontSize(14), | ||||
|                       ); | ||||
|                     }).toList(), | ||||
|                 value: currentPoolId, | ||||
|                 onChanged: (value) { | ||||
|                   ref | ||||
|                       .read(appSettingsNotifierProvider.notifier) | ||||
|                       .setDefaultPoolId(value); | ||||
|                   showSnackBar('settingsApplied'.tr()); | ||||
|                 }, | ||||
|                 buttonStyleData: const ButtonStyleData( | ||||
|                   padding: EdgeInsets.symmetric(horizontal: 16, vertical: 5), | ||||
|                   height: 40, | ||||
|                   width: 220, | ||||
|                 ), | ||||
|                 menuItemStyleData: const MenuItemStyleData(height: 40), | ||||
|               ), | ||||
|             ), | ||||
|           ); | ||||
|         }, | ||||
|         loading: | ||||
|             () => const ListTile( | ||||
|               minLeadingWidth: 48, | ||||
|               title: Text('Loading pools...'), | ||||
|               leading: CircularProgressIndicator(), | ||||
|             ), | ||||
|         error: | ||||
|             (err, st) => ListTile( | ||||
|               minLeadingWidth: 48, | ||||
|               title: Text('settingsDefaultPool').tr(), | ||||
|               subtitle: Text('Error: $err'), | ||||
|               leading: const Icon(Icons.error, color: Colors.red), | ||||
|             ), | ||||
|       ), | ||||
|     ]; | ||||
|  | ||||
|     final behaviorSettings = [ | ||||
|   | ||||
| @@ -48,11 +48,12 @@ class TrayService { | ||||
|   void handleAction(MenuItem item) { | ||||
|     switch (item.key) { | ||||
|       case 'show_window': | ||||
|         if (appWindow.isVisible) { | ||||
|           appWindow.restore(); | ||||
|         } else { | ||||
|           appWindow.show(); | ||||
|         } | ||||
|         () async { | ||||
|         appWindow.show(); | ||||
|         appWindow.restore(); | ||||
|         await Future.delayed(const Duration(milliseconds: 32)); | ||||
|         appWindow.show(); | ||||
|         }(); | ||||
|         break; | ||||
|       case 'exit_app': | ||||
|         appWindow.close(); | ||||
|   | ||||
							
								
								
									
										49
									
								
								lib/services/color_extraction.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								lib/services/color_extraction.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| import 'package:flutter/widgets.dart'; | ||||
| import 'package:image/image.dart' as img; | ||||
| import 'package:material_color_utilities/material_color_utilities.dart' as mcu; | ||||
|  | ||||
| class ColorExtractionService { | ||||
|   /// Extracts dominant colors from an image provider. | ||||
|   /// Returns a list of colors suitable for UI theming. | ||||
|   static Future<List<Color>> getColorsFromImage(ImageProvider provider) async { | ||||
|     try { | ||||
|       if (provider is FileImage) { | ||||
|         final bytes = await provider.file.readAsBytes(); | ||||
|         final image = img.decodeImage(bytes); | ||||
|         if (image == null) return []; | ||||
|         final Map<int, int> colorToCount = {}; | ||||
|         for (int y = 0; y < image.height; y++) { | ||||
|           for (int x = 0; x < image.width; x++) { | ||||
|             final pixel = image.getPixel(x, y) as int; | ||||
|             final r = (pixel >> 24) & 0xff; | ||||
|             final g = (pixel >> 16) & 0xff; | ||||
|             final b = (pixel >> 8) & 0xff; | ||||
|             final a = pixel & 0xff; | ||||
|             if (a == 0) continue; | ||||
|             final argb = (a << 24) | (r << 16) | (g << 8) | b; | ||||
|             colorToCount[argb] = (colorToCount[argb] ?? 0) + 1; | ||||
|           } | ||||
|         } | ||||
|         final List<int> filteredResults = mcu.Score.score( | ||||
|           colorToCount, | ||||
|           desired: 1, | ||||
|           filter: true, | ||||
|         ); | ||||
|         final List<int> scoredResults = mcu.Score.score( | ||||
|           colorToCount, | ||||
|           desired: 4, | ||||
|           filter: false, | ||||
|         ); | ||||
|         return <dynamic>{ | ||||
|           ...filteredResults, | ||||
|           ...scoredResults, | ||||
|         }.toList().map((argb) => Color(argb)).toList(); | ||||
|       } else { | ||||
|         return []; | ||||
|       } | ||||
|     } catch (e) { | ||||
|       debugPrint('Error getting colors from image: $e'); | ||||
|       return []; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -1,21 +1,23 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:convert'; | ||||
| import 'dart:io'; | ||||
| import 'dart:ui'; | ||||
|  | ||||
| import 'package:croppy/croppy.dart'; | ||||
| import 'package:cross_file/cross_file.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/widgets.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:island/services/file_uploader.dart'; | ||||
| import 'package:native_exif/native_exif.dart'; | ||||
| import 'package:tus_client_dart/tus_client_dart.dart'; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
|  | ||||
| enum FileUploadMode { generic, mediaSafe } | ||||
|  | ||||
| Future<XFile?> cropImage( | ||||
|   BuildContext context, { | ||||
|   required XFile image, | ||||
|   List<CropAspectRatio?>? allowedAspectRatios, | ||||
|   bool replacePath = false, | ||||
|   bool replacePath = true, | ||||
| }) async { | ||||
|   final result = await showMaterialImageCropper( | ||||
|     context, | ||||
| @@ -40,64 +42,63 @@ Future<XFile?> cropImage( | ||||
|   ); | ||||
| } | ||||
|  | ||||
| Completer<SnCloudFile?> putMediaToCloud({ | ||||
| Completer<SnCloudFile?> putFileToCloud({ | ||||
|   required UniversalFile fileData, | ||||
|   required String atk, | ||||
|   required String baseUrl, | ||||
|   String? poolId, | ||||
|   String? filename, | ||||
|   String? mimetype, | ||||
|   FileUploadMode? mode, | ||||
|   Function(double progress, Duration estimate)? onProgress, | ||||
| }) { | ||||
|   final completer = Completer<SnCloudFile?>(); | ||||
|  | ||||
|   // Process the image to remove GPS EXIF data if needed | ||||
|   if (fileData.isOnDevice && fileData.type == UniversalFileType.image) { | ||||
|   final effectiveMode = | ||||
|       mode ?? | ||||
|       (fileData.type == UniversalFileType.file | ||||
|           ? FileUploadMode.generic | ||||
|           : FileUploadMode.mediaSafe); | ||||
|  | ||||
|   if (effectiveMode == FileUploadMode.mediaSafe && | ||||
|       fileData.isOnDevice && | ||||
|       fileData.type == UniversalFileType.image) { | ||||
|     final data = fileData.data; | ||||
|     if (data is XFile && !kIsWeb && (Platform.isIOS || Platform.isAndroid)) { | ||||
|       // Use native_exif to selectively remove GPS data | ||||
|       Exif.fromPath(data.path) | ||||
|           .then((exif) { | ||||
|             // Remove GPS-related attributes | ||||
|             final gpsAttributes = [ | ||||
|               'GPSLatitude', | ||||
|               'GPSLatitudeRef', | ||||
|               'GPSLongitude', | ||||
|               'GPSLongitudeRef', | ||||
|               'GPSAltitude', | ||||
|               'GPSAltitudeRef', | ||||
|               'GPSTimeStamp', | ||||
|               'GPSProcessingMethod', | ||||
|               'GPSDateStamp', | ||||
|             ]; | ||||
|  | ||||
|             // Create a map of attributes to clear | ||||
|             final clearAttributes = <String, String>{}; | ||||
|             for (final attr in gpsAttributes) { | ||||
|               clearAttributes[attr] = ''; | ||||
|             } | ||||
|  | ||||
|             // Write empty values to remove GPS data | ||||
|             return exif.writeAttributes(clearAttributes); | ||||
|           .then((exif) async { | ||||
|             final gpsAttributes = { | ||||
|               'GPSLatitude': '', | ||||
|               'GPSLatitudeRef': '', | ||||
|               'GPSLongitude': '', | ||||
|               'GPSLongitudeRef': '', | ||||
|               'GPSAltitude': '', | ||||
|               'GPSAltitudeRef': '', | ||||
|               'GPSTimeStamp': '', | ||||
|               'GPSProcessingMethod': '', | ||||
|               'GPSDateStamp': '', | ||||
|             }; | ||||
|             await exif.writeAttributes(gpsAttributes); | ||||
|           }) | ||||
|           .then((_) { | ||||
|             // Continue with upload after GPS data is removed | ||||
|             _processUpload( | ||||
|           .then( | ||||
|             (_) => _processUpload( | ||||
|               fileData, | ||||
|               atk, | ||||
|               baseUrl, | ||||
|               poolId, | ||||
|               filename, | ||||
|               mimetype, | ||||
|               onProgress, | ||||
|               completer, | ||||
|             ); | ||||
|           }) | ||||
|             ), | ||||
|           ) | ||||
|           .catchError((e) { | ||||
|             // If there's an error, continue with the original file | ||||
|             debugPrint('Error removing GPS EXIF data: $e'); | ||||
|             _processUpload( | ||||
|             return _processUpload( | ||||
|               fileData, | ||||
|               atk, | ||||
|               baseUrl, | ||||
|               poolId, | ||||
|               filename, | ||||
|               mimetype, | ||||
|               onProgress, | ||||
| @@ -109,11 +110,11 @@ Completer<SnCloudFile?> putMediaToCloud({ | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // If not an image or on web, continue with normal upload | ||||
|   _processUpload( | ||||
|     fileData, | ||||
|     atk, | ||||
|     baseUrl, | ||||
|     poolId, | ||||
|     filename, | ||||
|     mimetype, | ||||
|     onProgress, | ||||
| @@ -127,6 +128,7 @@ Completer<SnCloudFile?> _processUpload( | ||||
|   UniversalFile fileData, | ||||
|   String atk, | ||||
|   String baseUrl, | ||||
|   String? poolId, | ||||
|   String? filename, | ||||
|   String? mimetype, | ||||
|   Function(double progress, Duration estimate)? onProgress, | ||||
| @@ -168,26 +170,81 @@ Completer<SnCloudFile?> _processUpload( | ||||
|     return completer; | ||||
|   } | ||||
|  | ||||
|   final Map<String, String> metadata = { | ||||
|     'filename': actualFilename, | ||||
|     'content-type': actualMimetype, | ||||
|   }; | ||||
|   // Create Dio instance | ||||
|   final dio = Dio( | ||||
|     BaseOptions( | ||||
|       baseUrl: baseUrl, | ||||
|       headers: { | ||||
|         'Authorization': 'AtField $atk', | ||||
|         'Accept': 'application/json', | ||||
|         'Content-Type': 'application/json', | ||||
|       }, | ||||
|     ), | ||||
|   ); | ||||
|  | ||||
|   final client = TusClient(file); | ||||
|   client | ||||
|       .upload( | ||||
|         uri: Uri.parse('$baseUrl/drive/tus'), | ||||
|         headers: {'Authorization': 'AtField $atk'}, | ||||
|         metadata: metadata, | ||||
|         onComplete: (lastResponse) { | ||||
|           final resp = jsonDecode(lastResponse!.headers['x-fileinfo']!); | ||||
|           completer.complete(SnCloudFile.fromJson(resp)); | ||||
|         }, | ||||
|         onProgress: (double progress, Duration estimate) { | ||||
|           onProgress?.call(progress, estimate); | ||||
|         }, | ||||
|       ) | ||||
|       .catchError(completer.completeError); | ||||
|   final uploader = FileUploader(dio); | ||||
|  | ||||
|   // Get File object | ||||
|   File fileObj; | ||||
|   if (file.path.isNotEmpty) { | ||||
|     fileObj = File(file.path); | ||||
|     // Call progress start | ||||
|     onProgress?.call(0.0, Duration.zero); | ||||
|     uploader | ||||
|         .uploadFile( | ||||
|           file: fileObj, | ||||
|           fileName: actualFilename, | ||||
|           contentType: actualMimetype, | ||||
|           poolId: poolId, | ||||
|         ) | ||||
|         .then((result) { | ||||
|           // Call progress end | ||||
|           onProgress?.call(1.0, Duration.zero); | ||||
|           completer.complete(result); | ||||
|         }) | ||||
|         .catchError((e) { | ||||
|           completer.completeError(e); | ||||
|           throw e; | ||||
|         }); | ||||
|   } else { | ||||
|     // Write to temp file | ||||
|     getTemporaryDirectory() | ||||
|         .then((tempDir) { | ||||
|           final tempFile = File('${tempDir.path}/temp_upload_$actualFilename'); | ||||
|           file | ||||
|               .readAsBytes() | ||||
|               .then((bytes) => tempFile.writeAsBytes(bytes)) | ||||
|               .then((_) { | ||||
|                 fileObj = tempFile; | ||||
|                 // Call progress start | ||||
|                 onProgress?.call(0.0, Duration.zero); | ||||
|                 uploader | ||||
|                     .uploadFile( | ||||
|                       file: fileObj, | ||||
|                       fileName: actualFilename, | ||||
|                       contentType: actualMimetype, | ||||
|                       poolId: poolId, | ||||
|                     ) | ||||
|                     .then((result) { | ||||
|                       // Call progress end | ||||
|                       onProgress?.call(1.0, Duration.zero); | ||||
|                       completer.complete(result); | ||||
|                     }) | ||||
|                     .catchError((e) { | ||||
|                       completer.completeError(e); | ||||
|                       throw e; | ||||
|                     }); | ||||
|               }) | ||||
|               .catchError((e) { | ||||
|                 completer.completeError(e); | ||||
|                 throw e; | ||||
|               }); | ||||
|         }) | ||||
|         .catchError((e) { | ||||
|           completer.completeError(e); | ||||
|           throw e; | ||||
|         }); | ||||
|   } | ||||
|  | ||||
|   return completer; | ||||
| } | ||||
|   | ||||
							
								
								
									
										155
									
								
								lib/services/file_uploader.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										155
									
								
								lib/services/file_uploader.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,155 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:io'; | ||||
| import 'dart:typed_data'; | ||||
|  | ||||
| import 'package:crypto/crypto.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
|  | ||||
| class FileUploader { | ||||
|   final Dio _dio; | ||||
|  | ||||
|   FileUploader(this._dio); | ||||
|  | ||||
|   /// Calculates the MD5 hash of a file. | ||||
|   Future<String> _calculateFileHash(File file) async { | ||||
|     final bytes = await file.readAsBytes(); | ||||
|     final digest = md5.convert(bytes); | ||||
|     return digest.toString(); | ||||
|   } | ||||
|  | ||||
|   /// Creates an upload task for the given file. | ||||
|   Future<Map<String, dynamic>> createUploadTask({ | ||||
|     required File file, | ||||
|     required String fileName, | ||||
|     required String contentType, | ||||
|     String? poolId, | ||||
|     String? bundleId, | ||||
|     String? encryptPassword, | ||||
|     String? expiredAt, | ||||
|     int? chunkSize, | ||||
|   }) async { | ||||
|     final hash = await _calculateFileHash(file); | ||||
|     final fileSize = await file.length(); | ||||
|  | ||||
|     final response = await _dio.post( | ||||
|       '/drive/files/upload/create', | ||||
|       data: { | ||||
|         'hash': hash, | ||||
|         'file_name': fileName, | ||||
|         'file_size': fileSize, | ||||
|         'content_type': contentType, | ||||
|         'pool_id': poolId, | ||||
|         'bundle_id': bundleId, | ||||
|         'encrypt_password': encryptPassword, | ||||
|         'expired_at': expiredAt, | ||||
|         'chunk_size': chunkSize, | ||||
|       }, | ||||
|     ); | ||||
|  | ||||
|     return response.data; | ||||
|   } | ||||
|  | ||||
|   /// Uploads a single chunk of the file. | ||||
|   Future<void> uploadChunk({ | ||||
|     required String taskId, | ||||
|     required int chunkIndex, | ||||
|     required Uint8List chunkData, | ||||
|   }) async { | ||||
|     final formData = FormData.fromMap({ | ||||
|       'chunk': MultipartFile.fromBytes( | ||||
|         chunkData, | ||||
|         filename: 'chunk_$chunkIndex', | ||||
|       ), | ||||
|     }); | ||||
|  | ||||
|     await _dio.post( | ||||
|       '/drive/files/upload/chunk/$taskId/$chunkIndex', | ||||
|       data: formData, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   /// Completes the upload and returns the CloudFile object. | ||||
|   Future<SnCloudFile> completeUpload(String taskId) async { | ||||
|     final response = await _dio.post('/drive/files/upload/complete/$taskId'); | ||||
|  | ||||
|     return SnCloudFile.fromJson(response.data); | ||||
|   } | ||||
|  | ||||
|   /// Uploads a file in chunks using the multi-part API. | ||||
|   Future<SnCloudFile> uploadFile({ | ||||
|     required File file, | ||||
|     required String fileName, | ||||
|     required String contentType, | ||||
|     String? poolId, | ||||
|     String? bundleId, | ||||
|     String? encryptPassword, | ||||
|     String? expiredAt, | ||||
|     int? customChunkSize, | ||||
|   }) async { | ||||
|     // Step 1: Create upload task | ||||
|     final createResponse = await createUploadTask( | ||||
|       file: file, | ||||
|       fileName: fileName, | ||||
|       contentType: contentType, | ||||
|       poolId: poolId, | ||||
|       bundleId: bundleId, | ||||
|       encryptPassword: encryptPassword, | ||||
|       expiredAt: expiredAt, | ||||
|       chunkSize: customChunkSize, | ||||
|     ); | ||||
|  | ||||
|     if (createResponse['file_exists'] == true) { | ||||
|       // File already exists, return the existing file | ||||
|       return SnCloudFile.fromJson(createResponse['file']); | ||||
|     } | ||||
|  | ||||
|     final taskId = createResponse['task_id'] as String; | ||||
|     final chunkSize = createResponse['chunk_size'] as int; | ||||
|     final chunksCount = createResponse['chunks_count'] as int; | ||||
|  | ||||
|     // Step 2: Upload chunks | ||||
|     final stream = file.openRead(); | ||||
|     final chunks = <Uint8List>[]; | ||||
|     int bytesRead = 0; | ||||
|     final buffer = BytesBuilder(); | ||||
|  | ||||
|     await for (final chunk in stream) { | ||||
|       buffer.add(chunk); | ||||
|       bytesRead += chunk.length; | ||||
|  | ||||
|       if (bytesRead >= chunkSize) { | ||||
|         chunks.add(buffer.takeBytes()); | ||||
|         bytesRead = 0; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // Add remaining bytes as last chunk | ||||
|     if (buffer.length > 0) { | ||||
|       chunks.add(buffer.takeBytes()); | ||||
|     } | ||||
|  | ||||
|     // Ensure we have the correct number of chunks | ||||
|     if (chunks.length != chunksCount) { | ||||
|       throw Exception( | ||||
|         'Chunk count mismatch: expected $chunksCount, got ${chunks.length}', | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     // Upload each chunk | ||||
|     for (int i = 0; i < chunks.length; i++) { | ||||
|       await uploadChunk(taskId: taskId, chunkIndex: i, chunkData: chunks[i]); | ||||
|     } | ||||
|  | ||||
|     // Step 3: Complete upload | ||||
|     return await completeUpload(taskId); | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Riverpod provider for the FileUploader service | ||||
| final fileUploaderProvider = Provider<FileUploader>((ref) { | ||||
|   final dio = ref.watch(apiClientProvider); | ||||
|   return FileUploader(dio); | ||||
| }); | ||||
| @@ -1,230 +1,47 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:developer'; | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:firebase_messaging/firebase_messaging.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_local_notifications/flutter_local_notifications.dart'; | ||||
| import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:island/main.dart'; | ||||
| import 'package:island/route.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/websocket.dart'; | ||||
| import 'package:island/widgets/app_notification.dart'; | ||||
| import 'package:top_snackbar_flutter/top_snack_bar.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = | ||||
|     FlutterLocalNotificationsPlugin(); | ||||
|  | ||||
| AppLifecycleState _appLifecycleState = AppLifecycleState.resumed; | ||||
|  | ||||
| void _onAppLifecycleChanged(AppLifecycleState state) { | ||||
|   _appLifecycleState = state; | ||||
| } | ||||
| // Conditional imports based on platform | ||||
| import 'notify.windows.dart' as windows_notify; | ||||
| import 'notify.universal.dart' as universal_notify; | ||||
|  | ||||
| // Platform-specific delegation | ||||
| Future<void> initializeLocalNotifications() async { | ||||
|   const AndroidInitializationSettings initializationSettingsAndroid = | ||||
|       AndroidInitializationSettings('@mipmap/ic_launcher'); | ||||
|  | ||||
|   const DarwinInitializationSettings initializationSettingsIOS = | ||||
|       DarwinInitializationSettings(); | ||||
|  | ||||
|   const DarwinInitializationSettings initializationSettingsMacOS = | ||||
|       DarwinInitializationSettings(); | ||||
|  | ||||
|   const LinuxInitializationSettings initializationSettingsLinux = | ||||
|       LinuxInitializationSettings(defaultActionName: 'Open notification'); | ||||
|  | ||||
|   const WindowsInitializationSettings initializationSettingsWindows = | ||||
|       WindowsInitializationSettings( | ||||
|         appName: 'Island', | ||||
|         appUserModelId: 'dev.solsynth.solian', | ||||
|         guid: 'dev.solsynth.solian', | ||||
|       ); | ||||
|  | ||||
|   const InitializationSettings initializationSettings = InitializationSettings( | ||||
|     android: initializationSettingsAndroid, | ||||
|     iOS: initializationSettingsIOS, | ||||
|     macOS: initializationSettingsMacOS, | ||||
|     linux: initializationSettingsLinux, | ||||
|     windows: initializationSettingsWindows, | ||||
|   ); | ||||
|  | ||||
|   await flutterLocalNotificationsPlugin.initialize( | ||||
|     initializationSettings, | ||||
|     onDidReceiveNotificationResponse: (NotificationResponse response) async { | ||||
|       final payload = response.payload; | ||||
|       if (payload != null) { | ||||
|         if (payload.startsWith('/')) { | ||||
|           // In-app routes | ||||
|           rootNavigatorKey.currentContext?.push(payload); | ||||
|         } else { | ||||
|           // External URLs | ||||
|           launchUrlString(payload); | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|   ); | ||||
|  | ||||
|   WidgetsBinding.instance.addObserver( | ||||
|     LifecycleEventHandler(onAppLifecycleChanged: _onAppLifecycleChanged), | ||||
|   ); | ||||
| } | ||||
|  | ||||
| class LifecycleEventHandler extends WidgetsBindingObserver { | ||||
|   final void Function(AppLifecycleState) onAppLifecycleChanged; | ||||
|  | ||||
|   LifecycleEventHandler({required this.onAppLifecycleChanged}); | ||||
|  | ||||
|   @override | ||||
|   void didChangeAppLifecycleState(AppLifecycleState state) { | ||||
|     onAppLifecycleChanged(state); | ||||
|   if (Platform.isWindows) { | ||||
|     return windows_notify.initializeLocalNotifications(); | ||||
|   } else { | ||||
|     return universal_notify.initializeLocalNotifications(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| StreamSubscription<WebSocketPacket> setupNotificationListener( | ||||
| StreamSubscription setupNotificationListener( | ||||
|   BuildContext context, | ||||
|   WidgetRef ref, | ||||
| ) { | ||||
|   final ws = ref.watch(websocketProvider); | ||||
|   return ws.dataStream.listen((pkt) async { | ||||
|     if (pkt.type == "notifications.new") { | ||||
|       final notification = SnNotification.fromJson(pkt.data!); | ||||
|       if (_appLifecycleState == AppLifecycleState.resumed) { | ||||
|         // App is focused, show in-app notification | ||||
|         log( | ||||
|           '[Notification] Showing in-app notification: ${notification.title}', | ||||
|         ); | ||||
|         showTopSnackBar( | ||||
|           globalOverlay.currentState!, | ||||
|           Center( | ||||
|             child: ConstrainedBox( | ||||
|               constraints: const BoxConstraints(maxWidth: 480), | ||||
|               child: NotificationCard(notification: notification), | ||||
|             ), | ||||
|           ), | ||||
|           onTap: () { | ||||
|             if (notification.meta['action_uri'] != null) { | ||||
|               var uri = notification.meta['action_uri'] as String; | ||||
|               if (uri.startsWith('/')) { | ||||
|                 // In-app routes | ||||
|                 rootNavigatorKey.currentContext?.push( | ||||
|                   notification.meta['action_uri'], | ||||
|                 ); | ||||
|               } else { | ||||
|                 // External URLs | ||||
|                 launchUrlString(uri); | ||||
|               } | ||||
|             } | ||||
|           }, | ||||
|           onDismissed: () {}, | ||||
|           dismissType: DismissType.onSwipe, | ||||
|           displayDuration: const Duration(seconds: 5), | ||||
|           snackBarPosition: SnackBarPosition.top, | ||||
|           padding: EdgeInsets.only( | ||||
|             left: 16, | ||||
|             right: 16, | ||||
|             top: | ||||
|                 (!kIsWeb && | ||||
|                         (Platform.isMacOS || | ||||
|                             Platform.isWindows || | ||||
|                             Platform.isLinux)) | ||||
|                     ? 28 | ||||
|                     // ignore: use_build_context_synchronously | ||||
|                     : MediaQuery.of(context).padding.top + 16, | ||||
|             bottom: 16, | ||||
|           ), | ||||
|         ); | ||||
|       } else { | ||||
|         // App is in background, show system notification (only on supported platforms) | ||||
|         if (!kIsWeb && !Platform.isIOS) { | ||||
|           log( | ||||
|             '[Notification] Showing system notification: ${notification.title}', | ||||
|           ); | ||||
|           const AndroidNotificationDetails androidNotificationDetails = | ||||
|               AndroidNotificationDetails( | ||||
|                 'channel_id', | ||||
|                 'channel_name', | ||||
|                 channelDescription: 'channel_description', | ||||
|                 importance: Importance.max, | ||||
|                 priority: Priority.high, | ||||
|                 ticker: 'ticker', | ||||
|               ); | ||||
|           const NotificationDetails notificationDetails = NotificationDetails( | ||||
|             android: androidNotificationDetails, | ||||
|           ); | ||||
|           await flutterLocalNotificationsPlugin.show( | ||||
|             0, | ||||
|             notification.title, | ||||
|             notification.content, | ||||
|             notificationDetails, | ||||
|             payload: notification.meta['action_uri'] as String?, | ||||
|           ); | ||||
|         } else { | ||||
|           log( | ||||
|             '[Notification] Skipping system notification for unsupported platform: ${notification.title}', | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }); | ||||
|   if (Platform.isWindows) { | ||||
|     return windows_notify.setupNotificationListener(context, ref); | ||||
|   } else { | ||||
|     return universal_notify.setupNotificationListener(context, ref); | ||||
|   } | ||||
| } | ||||
|  | ||||
| Future<void> subscribePushNotification( | ||||
|   Dio apiClient, { | ||||
|   bool detailedErrors = false, | ||||
| }) async { | ||||
|   if (!kIsWeb && Platform.isLinux) { | ||||
|     return; | ||||
|   } | ||||
|   await FirebaseMessaging.instance.requestPermission( | ||||
|     alert: true, | ||||
|     badge: true, | ||||
|     sound: true, | ||||
|   ); | ||||
|  | ||||
|   String? deviceToken; | ||||
|   if (kIsWeb) { | ||||
|     deviceToken = await FirebaseMessaging.instance.getToken( | ||||
|       vapidKey: | ||||
|           "BFN2mkqyeI6oi4d2PAV4pfNyG3Jy0FBEblmmPrjmP0r5lHOPrxrcqLIWhM21R_cicF-j4Xhtr1kyDyDgJYRPLgU", | ||||
|     ); | ||||
|   } else if (Platform.isAndroid) { | ||||
|     deviceToken = await FirebaseMessaging.instance.getToken(); | ||||
|   } else if (Platform.isIOS) { | ||||
|     deviceToken = await FirebaseMessaging.instance.getAPNSToken(); | ||||
|   } | ||||
|  | ||||
|   FirebaseMessaging.instance.onTokenRefresh | ||||
|       .listen((fcmToken) { | ||||
|         _putTokenToRemote(apiClient, fcmToken, 1); | ||||
|       }) | ||||
|       .onError((err) { | ||||
|         log("Failed to get firebase cloud messaging push token: $err"); | ||||
|       }); | ||||
|  | ||||
|   if (deviceToken != null) { | ||||
|     _putTokenToRemote( | ||||
|   if (Platform.isWindows) { | ||||
|     return windows_notify.subscribePushNotification( | ||||
|       apiClient, | ||||
|       deviceToken, | ||||
|       !kIsWeb && (Platform.isIOS || Platform.isMacOS) ? 0 : 1, | ||||
|       detailedErrors: detailedErrors, | ||||
|     ); | ||||
|   } else { | ||||
|     return universal_notify.subscribePushNotification( | ||||
|       apiClient, | ||||
|       detailedErrors: detailedErrors, | ||||
|     ); | ||||
|   } else if (detailedErrors) { | ||||
|     throw Exception("Failed to get device token for push notifications."); | ||||
|   } | ||||
| } | ||||
|  | ||||
| Future<void> _putTokenToRemote( | ||||
|   Dio apiClient, | ||||
|   String token, | ||||
|   int provider, | ||||
| ) async { | ||||
|   await apiClient.put( | ||||
|     "/pusher/notifications/subscription", | ||||
|     data: {"provider": provider, "device_token": token}, | ||||
|   ); | ||||
| } | ||||
|   | ||||
							
								
								
									
										232
									
								
								lib/services/notify.universal.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										232
									
								
								lib/services/notify.universal.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,232 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:developer'; | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:firebase_messaging/firebase_messaging.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_local_notifications/flutter_local_notifications.dart'; | ||||
| import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:island/main.dart'; | ||||
| import 'package:island/route.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/websocket.dart'; | ||||
| import 'package:island/widgets/app_notification.dart'; | ||||
| import 'package:top_snackbar_flutter/top_snack_bar.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = | ||||
|     FlutterLocalNotificationsPlugin(); | ||||
|  | ||||
| AppLifecycleState _appLifecycleState = AppLifecycleState.resumed; | ||||
|  | ||||
| void _onAppLifecycleChanged(AppLifecycleState state) { | ||||
|   _appLifecycleState = state; | ||||
| } | ||||
|  | ||||
| Future<void> initializeLocalNotifications() async { | ||||
|   const AndroidInitializationSettings initializationSettingsAndroid = | ||||
|       AndroidInitializationSettings('@mipmap/ic_launcher'); | ||||
|  | ||||
|   const DarwinInitializationSettings initializationSettingsIOS = | ||||
|       DarwinInitializationSettings(); | ||||
|  | ||||
|   const DarwinInitializationSettings initializationSettingsMacOS = | ||||
|       DarwinInitializationSettings(); | ||||
|  | ||||
|   const LinuxInitializationSettings initializationSettingsLinux = | ||||
|       LinuxInitializationSettings(defaultActionName: 'Open notification'); | ||||
|  | ||||
|   const WindowsInitializationSettings initializationSettingsWindows = | ||||
|       WindowsInitializationSettings( | ||||
|         appName: 'Island', | ||||
|         appUserModelId: 'dev.solsynth.solian', | ||||
|         guid: 'dev.solsynth.solian', | ||||
|       ); | ||||
|  | ||||
|   const InitializationSettings initializationSettings = InitializationSettings( | ||||
|     android: initializationSettingsAndroid, | ||||
|     iOS: initializationSettingsIOS, | ||||
|     macOS: initializationSettingsMacOS, | ||||
|     linux: initializationSettingsLinux, | ||||
|     windows: initializationSettingsWindows, | ||||
|   ); | ||||
|  | ||||
|   await flutterLocalNotificationsPlugin.initialize( | ||||
|     initializationSettings, | ||||
|     onDidReceiveNotificationResponse: (NotificationResponse response) async { | ||||
|       final payload = response.payload; | ||||
|       if (payload != null) { | ||||
|         if (payload.startsWith('/')) { | ||||
|           // In-app routes | ||||
|           rootNavigatorKey.currentContext?.push(payload); | ||||
|         } else { | ||||
|           // External URLs | ||||
|           launchUrlString(payload); | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|   ); | ||||
|  | ||||
|   WidgetsBinding.instance.addObserver( | ||||
|     LifecycleEventHandler(onAppLifecycleChanged: _onAppLifecycleChanged), | ||||
|   ); | ||||
| } | ||||
|  | ||||
| class LifecycleEventHandler extends WidgetsBindingObserver { | ||||
|   final void Function(AppLifecycleState) onAppLifecycleChanged; | ||||
|  | ||||
|   LifecycleEventHandler({required this.onAppLifecycleChanged}); | ||||
|  | ||||
|   @override | ||||
|   void didChangeAppLifecycleState(AppLifecycleState state) { | ||||
|     onAppLifecycleChanged(state); | ||||
|   } | ||||
| } | ||||
|  | ||||
| StreamSubscription<WebSocketPacket> setupNotificationListener( | ||||
|   BuildContext context, | ||||
|   WidgetRef ref, | ||||
| ) { | ||||
|   final ws = ref.watch(websocketProvider); | ||||
|   return ws.dataStream.listen((pkt) async { | ||||
|     if (pkt.type == "notifications.new") { | ||||
|       final notification = SnNotification.fromJson(pkt.data!); | ||||
|       if (_appLifecycleState == AppLifecycleState.resumed) { | ||||
|         // App is focused, show in-app notification | ||||
|         log( | ||||
|           '[Notification] Showing in-app notification: ${notification.title}', | ||||
|         ); | ||||
|         showTopSnackBar( | ||||
|           globalOverlay.currentState!, | ||||
|           Center( | ||||
|             child: ConstrainedBox( | ||||
|               constraints: const BoxConstraints(maxWidth: 480), | ||||
|               child: NotificationCard(notification: notification), | ||||
|             ), | ||||
|           ), | ||||
|           onTap: () { | ||||
|             if (notification.meta['action_uri'] != null) { | ||||
|               var uri = notification.meta['action_uri'] as String; | ||||
|               if (uri.startsWith('/')) { | ||||
|                 // In-app routes | ||||
|                 rootNavigatorKey.currentContext?.push( | ||||
|                   notification.meta['action_uri'], | ||||
|                 ); | ||||
|               } else { | ||||
|                 // External URLs | ||||
|                 launchUrlString(uri); | ||||
|               } | ||||
|             } | ||||
|           }, | ||||
|           onDismissed: () {}, | ||||
|           dismissType: DismissType.onSwipe, | ||||
|           displayDuration: const Duration(seconds: 5), | ||||
|           snackBarPosition: SnackBarPosition.top, | ||||
|           padding: EdgeInsets.only( | ||||
|             left: 16, | ||||
|             right: 16, | ||||
|             top: | ||||
|                 (!kIsWeb && | ||||
|                         (Platform.isMacOS || | ||||
|                             Platform.isWindows || | ||||
|                             Platform.isLinux)) | ||||
|                     ? 28 | ||||
|                     // ignore: use_build_context_synchronously | ||||
|                     : MediaQuery.of(context).padding.top + 16, | ||||
|             bottom: 16, | ||||
|           ), | ||||
|         ); | ||||
|       } else { | ||||
|         // App is in background, show system notification (only on supported platforms) | ||||
|         if (!kIsWeb && !Platform.isIOS) { | ||||
|           log( | ||||
|             '[Notification] Showing system notification: ${notification.title}', | ||||
|           ); | ||||
|  | ||||
|           // Use flutter_local_notifications for universal platforms | ||||
|           const AndroidNotificationDetails androidNotificationDetails = | ||||
|               AndroidNotificationDetails( | ||||
|                 'channel_id', | ||||
|                 'channel_name', | ||||
|                 channelDescription: 'channel_description', | ||||
|                 importance: Importance.max, | ||||
|                 priority: Priority.high, | ||||
|                 ticker: 'ticker', | ||||
|               ); | ||||
|           const NotificationDetails notificationDetails = NotificationDetails( | ||||
|             android: androidNotificationDetails, | ||||
|           ); | ||||
|           await flutterLocalNotificationsPlugin.show( | ||||
|             0, | ||||
|             notification.title, | ||||
|             notification.content, | ||||
|             notificationDetails, | ||||
|             payload: notification.meta['action_uri'] as String?, | ||||
|           ); | ||||
|         } else { | ||||
|           log( | ||||
|             '[Notification] Skipping system notification for unsupported platform: ${notification.title}', | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }); | ||||
| } | ||||
|  | ||||
| Future<void> subscribePushNotification( | ||||
|   Dio apiClient, { | ||||
|   bool detailedErrors = false, | ||||
| }) async { | ||||
|   if (!kIsWeb && Platform.isLinux) { | ||||
|     return; | ||||
|   } | ||||
|   await FirebaseMessaging.instance.requestPermission( | ||||
|     alert: true, | ||||
|     badge: true, | ||||
|     sound: true, | ||||
|   ); | ||||
|  | ||||
|   String? deviceToken; | ||||
|   if (kIsWeb) { | ||||
|     deviceToken = await FirebaseMessaging.instance.getToken( | ||||
|       vapidKey: | ||||
|           "BFN2mkqyeI6oi4d2PAV4pfNyG3Jy0FBEblmmPrjmP0r5lHOPrxrcqLIWhM21R_cicF-j4Xhtr1kyDyDgJYRPLgU", | ||||
|     ); | ||||
|   } else if (Platform.isAndroid) { | ||||
|     deviceToken = await FirebaseMessaging.instance.getToken(); | ||||
|   } else if (Platform.isIOS) { | ||||
|     deviceToken = await FirebaseMessaging.instance.getAPNSToken(); | ||||
|   } | ||||
|  | ||||
|   FirebaseMessaging.instance.onTokenRefresh | ||||
|       .listen((fcmToken) { | ||||
|         _putTokenToRemote(apiClient, fcmToken, 1); | ||||
|       }) | ||||
|       .onError((err) { | ||||
|         log("Failed to get firebase cloud messaging push token: $err"); | ||||
|       }); | ||||
|  | ||||
|   if (deviceToken != null) { | ||||
|     _putTokenToRemote( | ||||
|       apiClient, | ||||
|       deviceToken, | ||||
|       !kIsWeb && (Platform.isIOS || Platform.isMacOS) ? 0 : 1, | ||||
|     ); | ||||
|   } else if (detailedErrors) { | ||||
|     throw Exception("Failed to get device token for push notifications."); | ||||
|   } | ||||
| } | ||||
|  | ||||
| Future<void> _putTokenToRemote( | ||||
|   Dio apiClient, | ||||
|   String token, | ||||
|   int provider, | ||||
| ) async { | ||||
|   await apiClient.put( | ||||
|     "/ring/notifications/subscription", | ||||
|     data: {"provider": provider, "device_token": token}, | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										176
									
								
								lib/services/notify.windows.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								lib/services/notify.windows.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,176 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:developer'; | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:firebase_messaging/firebase_messaging.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:island/main.dart'; | ||||
| import 'package:island/route.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/websocket.dart'; | ||||
| import 'package:island/widgets/app_notification.dart'; | ||||
| import 'package:top_snackbar_flutter/top_snack_bar.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
| import 'package:windows_notification/windows_notification.dart' | ||||
|     as windows_notification; | ||||
| import 'package:windows_notification/notification_message.dart'; | ||||
|  | ||||
| import 'package:dio/dio.dart'; | ||||
|  | ||||
| // Windows notification instance | ||||
| windows_notification.WindowsNotification? windowsNotification; | ||||
|  | ||||
| AppLifecycleState _appLifecycleState = AppLifecycleState.resumed; | ||||
|  | ||||
| void _onAppLifecycleChanged(AppLifecycleState state) { | ||||
|   _appLifecycleState = state; | ||||
| } | ||||
|  | ||||
| Future<void> initializeLocalNotifications() async { | ||||
|   // Initialize Windows notification for Windows platform | ||||
|   windowsNotification = windows_notification.WindowsNotification( | ||||
|     applicationId: 'dev.solsynth.solian', | ||||
|   ); | ||||
|  | ||||
|   WidgetsBinding.instance.addObserver( | ||||
|     LifecycleEventHandler(onAppLifecycleChanged: _onAppLifecycleChanged), | ||||
|   ); | ||||
| } | ||||
|  | ||||
| class LifecycleEventHandler extends WidgetsBindingObserver { | ||||
|   final void Function(AppLifecycleState) onAppLifecycleChanged; | ||||
|  | ||||
|   LifecycleEventHandler({required this.onAppLifecycleChanged}); | ||||
|  | ||||
|   @override | ||||
|   void didChangeAppLifecycleState(AppLifecycleState state) { | ||||
|     onAppLifecycleChanged(state); | ||||
|   } | ||||
| } | ||||
|  | ||||
| StreamSubscription<WebSocketPacket> setupNotificationListener( | ||||
|   BuildContext context, | ||||
|   WidgetRef ref, | ||||
| ) { | ||||
|   final ws = ref.watch(websocketProvider); | ||||
|   return ws.dataStream.listen((pkt) async { | ||||
|     if (pkt.type == "notifications.new") { | ||||
|       final notification = SnNotification.fromJson(pkt.data!); | ||||
|       if (_appLifecycleState == AppLifecycleState.resumed) { | ||||
|         // App is focused, show in-app notification | ||||
|         log( | ||||
|           '[Notification] Showing in-app notification: ${notification.title}', | ||||
|         ); | ||||
|         showTopSnackBar( | ||||
|           globalOverlay.currentState!, | ||||
|           Center( | ||||
|             child: ConstrainedBox( | ||||
|               constraints: const BoxConstraints(maxWidth: 480), | ||||
|               child: NotificationCard(notification: notification), | ||||
|             ), | ||||
|           ), | ||||
|           onTap: () { | ||||
|             if (notification.meta['action_uri'] != null) { | ||||
|               var uri = notification.meta['action_uri'] as String; | ||||
|               if (uri.startsWith('/')) { | ||||
|                 // In-app routes | ||||
|                 rootNavigatorKey.currentContext?.push( | ||||
|                   notification.meta['action_uri'], | ||||
|                 ); | ||||
|               } else { | ||||
|                 // External URLs | ||||
|                 launchUrlString(uri); | ||||
|               } | ||||
|             } | ||||
|           }, | ||||
|           onDismissed: () {}, | ||||
|           dismissType: DismissType.onSwipe, | ||||
|           displayDuration: const Duration(seconds: 5), | ||||
|           snackBarPosition: SnackBarPosition.top, | ||||
|           padding: EdgeInsets.only( | ||||
|             left: 16, | ||||
|             right: 16, | ||||
|             top: 28, // Windows specific padding | ||||
|             bottom: 16, | ||||
|           ), | ||||
|         ); | ||||
|       } else { | ||||
|         // App is in background, show Windows system notification | ||||
|         log( | ||||
|           '[Notification] Showing Windows system notification: ${notification.title}', | ||||
|         ); | ||||
|  | ||||
|         if (windowsNotification != null) { | ||||
|           // Use Windows notification for Windows platform | ||||
|           final notificationMessage = NotificationMessage.fromPluginTemplate( | ||||
|             DateTime.now().millisecondsSinceEpoch.toString(), // unique id | ||||
|             notification.title, | ||||
|             notification.content, | ||||
|             launch: notification.meta['action_uri'] as String?, | ||||
|           ); | ||||
|           await windowsNotification!.showNotificationPluginTemplate( | ||||
|             notificationMessage, | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }); | ||||
| } | ||||
|  | ||||
| Future<void> subscribePushNotification( | ||||
|   Dio apiClient, { | ||||
|   bool detailedErrors = false, | ||||
| }) async { | ||||
|   if (!kIsWeb && Platform.isLinux) { | ||||
|     return; | ||||
|   } | ||||
|   await FirebaseMessaging.instance.requestPermission( | ||||
|     alert: true, | ||||
|     badge: true, | ||||
|     sound: true, | ||||
|   ); | ||||
|  | ||||
|   String? deviceToken; | ||||
|   if (kIsWeb) { | ||||
|     deviceToken = await FirebaseMessaging.instance.getToken( | ||||
|       vapidKey: | ||||
|           "BFN2mkqyeI6oi4d2PAV4pfNyG3Jy0FBEblmmPrjmP0r5lHOPrxrcqLIWhM21R_cicF-j4Xhtr1kyDyDgJYRPLgU", | ||||
|     ); | ||||
|   } else if (Platform.isAndroid) { | ||||
|     deviceToken = await FirebaseMessaging.instance.getToken(); | ||||
|   } else if (Platform.isIOS) { | ||||
|     deviceToken = await FirebaseMessaging.instance.getAPNSToken(); | ||||
|   } | ||||
|  | ||||
|   FirebaseMessaging.instance.onTokenRefresh | ||||
|       .listen((fcmToken) { | ||||
|         _putTokenToRemote(apiClient, fcmToken, 1); | ||||
|       }) | ||||
|       .onError((err) { | ||||
|         log("Failed to get firebase cloud messaging push token: $err"); | ||||
|       }); | ||||
|  | ||||
|   if (deviceToken != null) { | ||||
|     _putTokenToRemote( | ||||
|       apiClient, | ||||
|       deviceToken, | ||||
|       !kIsWeb && (Platform.isIOS || Platform.isMacOS) ? 0 : 1, | ||||
|     ); | ||||
|   } else if (detailedErrors) { | ||||
|     throw Exception("Failed to get device token for push notifications."); | ||||
|   } | ||||
| } | ||||
|  | ||||
| Future<void> _putTokenToRemote( | ||||
|   Dio apiClient, | ||||
|   String token, | ||||
|   int provider, | ||||
| ) async { | ||||
|   await apiClient.put( | ||||
|     "/ring/notifications/subscription", | ||||
|     data: {"provider": provider, "device_token": token}, | ||||
|   ); | ||||
| } | ||||
| @@ -14,7 +14,7 @@ Future<void> initializeTzdb() async { | ||||
| } | ||||
|  | ||||
| Future<String> getMachineTz() async { | ||||
|   return await FlutterTimezone.getLocalTimezone(); | ||||
|   return (await FlutterTimezone.getLocalTimezone()).identifier; | ||||
| } | ||||
|  | ||||
| List<String> getAvailableTz() { | ||||
|   | ||||
| @@ -13,7 +13,7 @@ Future<void> initializeTzdb() async { | ||||
| } | ||||
|  | ||||
| Future<String> getMachineTz() async { | ||||
|   return await FlutterTimezone.getLocalTimezone(); | ||||
|   return (await FlutterTimezone.getLocalTimezone()).identifier; | ||||
| } | ||||
|  | ||||
| List<String> getAvailableTz() { | ||||
|   | ||||
| @@ -1 +1,3 @@ | ||||
| export 'udid.native.dart' if (dart.library.html) 'udid.web.dart'; | ||||
| export 'udid.native.dart' | ||||
|     if (dart.library.html) 'udid.web.dart' | ||||
|     if (dart.library.io) 'udid.native.dart'; | ||||
|   | ||||
| @@ -1,3 +1,6 @@ | ||||
| import 'dart:io'; | ||||
| import 'package:device_info_plus/device_info_plus.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter_udid/flutter_udid.dart'; | ||||
|  | ||||
| String? _cachedUdid; | ||||
| @@ -9,3 +12,18 @@ Future<String> getUdid() async { | ||||
|   _cachedUdid = await FlutterUdid.consistentUdid; | ||||
|   return _cachedUdid!; | ||||
| } | ||||
|  | ||||
| Future<String> getDeviceName() async { | ||||
|   DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); | ||||
|   if (Platform.isAndroid) { | ||||
|     AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; | ||||
|     return androidInfo.device; | ||||
|   } else if (Platform.isIOS) { | ||||
|     IosDeviceInfo iosInfo = await deviceInfo.iosInfo; | ||||
|     return iosInfo.name; | ||||
|   } else if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) { | ||||
|     return Platform.localHostname; | ||||
|   } else { | ||||
|     return 'unknown'.tr(); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -9,3 +9,18 @@ Future<String> getUdid() async { | ||||
|   final hash = sha256.convert(bytes); | ||||
|   return hash.toString(); | ||||
| } | ||||
|  | ||||
| Future<String> getDeviceName() async { | ||||
|   final userAgent = window.navigator.userAgent; | ||||
|   if (userAgent.contains('Chrome') && !userAgent.contains('Edg')) { | ||||
|     return 'Chrome'; | ||||
|   } else if (userAgent.contains('Firefox')) { | ||||
|     return 'Firefox'; | ||||
|   } else if (userAgent.contains('Safari') && !userAgent.contains('Chrome')) { | ||||
|     return 'Safari'; | ||||
|   } else if (userAgent.contains('Edg')) { | ||||
|     return 'Edge'; | ||||
|   } else { | ||||
|     return 'Browser'; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -6,10 +6,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
| import 'package:island/services/udid.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:island/widgets/response.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:island/widgets/extended_refresh_indicator.dart'; | ||||
| @@ -43,32 +45,11 @@ class _DeviceListTile extends StatelessWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return ListTile( | ||||
|       isThreeLine: true, | ||||
|       contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), | ||||
|       leading: Icon(switch (device.platform) { | ||||
|         0 => Icons.device_unknown, // Unidentified | ||||
|         1 => Icons.web, // Web | ||||
|         2 => Icons.phone_iphone, // iOS | ||||
|         3 => Icons.phone_android, // Android | ||||
|         4 => Icons.laptop_mac, // macOS | ||||
|         5 => Icons.window, // Windows | ||||
|         6 => Icons.computer, // Linux | ||||
|         _ => Icons.device_unknown, // fallback | ||||
|       }).padding(top: 4), | ||||
|       subtitle: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|     return ExpansionTile( | ||||
|       title: Row( | ||||
|         spacing: 8, | ||||
|         children: [ | ||||
|           Text( | ||||
|             'lastActiveAt'.tr( | ||||
|               args: [ | ||||
|                 DateFormat().format( | ||||
|                   device.challenges.first.createdAt.toLocal(), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|           Text(device.challenges.first.ipAddress), | ||||
|           Flexible(child: Text(device.deviceLabel ?? device.deviceName)), | ||||
|           if (device.isCurrent) | ||||
|             Row( | ||||
|               children: [ | ||||
| @@ -82,10 +63,29 @@ class _DeviceListTile extends StatelessWidget { | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|             ).padding(top: 4), | ||||
|             ), | ||||
|         ], | ||||
|       ), | ||||
|       title: Text(device.deviceLabel ?? device.deviceName), | ||||
|       subtitle: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           Text( | ||||
|             'lastActiveAt'.tr( | ||||
|               args: [device.challenges.first.createdAt.formatSystem()], | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|       leading: Icon(switch (device.platform) { | ||||
|         0 => Icons.device_unknown, // Unidentified | ||||
|         1 => Icons.web, // Web | ||||
|         2 => Icons.phone_iphone, // iOS | ||||
|         3 => Icons.phone_android, // Android | ||||
|         4 => Icons.laptop_mac, // macOS | ||||
|         5 => Icons.window, // Windows | ||||
|         6 => Icons.computer, // Linux | ||||
|         _ => Icons.device_unknown, // fallback | ||||
|       }).padding(top: 4), | ||||
|       trailing: | ||||
|           isWideScreen(context) | ||||
|               ? Row( | ||||
| @@ -105,6 +105,36 @@ class _DeviceListTile extends StatelessWidget { | ||||
|                 ], | ||||
|               ) | ||||
|               : null, | ||||
|       expandedCrossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|       children: [ | ||||
|         Container( | ||||
|           decoration: BoxDecoration( | ||||
|             color: Theme.of(context).colorScheme.surfaceVariant, | ||||
|           ), | ||||
|           padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), | ||||
|           child: Text('authDeviceChallenges'.tr()), | ||||
|         ), | ||||
|         for (final challenge in device.challenges) | ||||
|           ListTile( | ||||
|             minTileHeight: 48, | ||||
|             title: Text(DateFormat().format(challenge.createdAt.toLocal())), | ||||
|             subtitle: Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|               children: [ | ||||
|                 Text(challenge.ipAddress), | ||||
|                 if (challenge.location != null) | ||||
|                   Row( | ||||
|                     spacing: 4, | ||||
|                     children: | ||||
|                         [challenge.location?.city, challenge.location?.country] | ||||
|                             .where((e) => e?.isNotEmpty ?? false) | ||||
|                             .map((e) => Text(e!)) | ||||
|                             .toList(), | ||||
|                   ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -176,72 +206,116 @@ class AccountSessionSheet extends HookConsumerWidget { | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: 'authSessions'.tr(), | ||||
|       child: authDevices.when( | ||||
|         data: | ||||
|             (data) => ExtendedRefreshIndicator( | ||||
|               onRefresh: | ||||
|                   () => Future.sync(() => ref.invalidate(authDevicesProvider)), | ||||
|               child: ListView.builder( | ||||
|                 padding: EdgeInsets.zero, | ||||
|                 itemCount: data.length, | ||||
|                 itemBuilder: (context, index) { | ||||
|                   final device = data[index]; | ||||
|                   if (wideScreen) { | ||||
|                     return _DeviceListTile( | ||||
|                       device: device, | ||||
|                       updateDeviceLabel: updateDeviceLabel, | ||||
|                       logoutDevice: logoutDevice, | ||||
|                     ); | ||||
|                   } else { | ||||
|                     return Dismissible( | ||||
|                       key: Key('device-${device.id}'), | ||||
|                       direction: | ||||
|                           device.isCurrent | ||||
|                               ? DismissDirection.startToEnd | ||||
|                               : DismissDirection.horizontal, | ||||
|                       background: Container( | ||||
|                         color: Colors.blue, | ||||
|                         alignment: Alignment.centerLeft, | ||||
|                         padding: EdgeInsets.symmetric(horizontal: 20), | ||||
|                         child: Icon(Icons.edit, color: Colors.white), | ||||
|       child: Column( | ||||
|         children: [ | ||||
|           if (!wideScreen) | ||||
|             Container( | ||||
|               width: double.infinity, | ||||
|               padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), | ||||
|               color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||
|               child: Row( | ||||
|                 mainAxisAlignment: MainAxisAlignment.center, | ||||
|                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                 spacing: 8, | ||||
|                 children: [ | ||||
|                   const Icon(Symbols.info, size: 16).padding(top: 2), | ||||
|                   Flexible( | ||||
|                     child: Text( | ||||
|                       'authDeviceHint'.tr(), | ||||
|                       style: TextStyle( | ||||
|                         color: Theme.of(context).colorScheme.onSurfaceVariant, | ||||
|                       ), | ||||
|                       secondaryBackground: Container( | ||||
|                         color: Colors.red, | ||||
|                         alignment: Alignment.centerRight, | ||||
|                         padding: EdgeInsets.symmetric(horizontal: 20), | ||||
|                         child: Icon(Icons.logout, color: Colors.white), | ||||
|                       ), | ||||
|                       confirmDismiss: (direction) async { | ||||
|                         if (direction == DismissDirection.startToEnd) { | ||||
|                           updateDeviceLabel(device.deviceId); | ||||
|                           return false; | ||||
|                         } else { | ||||
|                           final confirm = await showConfirmAlert( | ||||
|                             'authDeviceLogoutHint'.tr(), | ||||
|                             'authDeviceLogout'.tr(), | ||||
|                           ); | ||||
|                           if (confirm && context.mounted) { | ||||
|                             logoutDevice(device.deviceId); | ||||
|                           } | ||||
|                           return false; // Don't dismiss | ||||
|                         } | ||||
|                       }, | ||||
|                       child: _DeviceListTile( | ||||
|                         device: device, | ||||
|                         updateDeviceLabel: updateDeviceLabel, | ||||
|                         logoutDevice: logoutDevice, | ||||
|                       ), | ||||
|                     ); | ||||
|                   } | ||||
|                 }, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|         error: | ||||
|             (err, _) => ResponseErrorWidget( | ||||
|               error: err, | ||||
|               onRetry: () => ref.invalidate(authDevicesProvider), | ||||
|           Expanded( | ||||
|             child: authDevices.when( | ||||
|               data: | ||||
|                   (data) => ExtendedRefreshIndicator( | ||||
|                     onRefresh: | ||||
|                         () => Future.sync( | ||||
|                           () => ref.invalidate(authDevicesProvider), | ||||
|                         ), | ||||
|                     child: ListView.builder( | ||||
|                       padding: EdgeInsets.zero, | ||||
|                       itemCount: data.length, | ||||
|                       itemBuilder: (context, index) { | ||||
|                         final device = data[index]; | ||||
|                         if (wideScreen) { | ||||
|                           return _DeviceListTile( | ||||
|                             device: device, | ||||
|                             updateDeviceLabel: updateDeviceLabel, | ||||
|                             logoutDevice: logoutDevice, | ||||
|                           ); | ||||
|                         } else { | ||||
|                           return Dismissible( | ||||
|                             key: Key('device-${device.id}'), | ||||
|                             direction: | ||||
|                                 device.isCurrent | ||||
|                                     ? DismissDirection.startToEnd | ||||
|                                     : DismissDirection.horizontal, | ||||
|                             background: Container( | ||||
|                               color: Colors.blue, | ||||
|                               alignment: Alignment.centerLeft, | ||||
|                               padding: EdgeInsets.symmetric(horizontal: 20), | ||||
|                               child: Icon(Icons.edit, color: Colors.white), | ||||
|                             ), | ||||
|                             secondaryBackground: Container( | ||||
|                               color: Colors.red, | ||||
|                               alignment: Alignment.centerRight, | ||||
|                               padding: EdgeInsets.symmetric(horizontal: 20), | ||||
|                               child: Icon(Icons.logout, color: Colors.white), | ||||
|                             ), | ||||
|                             confirmDismiss: (direction) async { | ||||
|                               if (direction == DismissDirection.startToEnd) { | ||||
|                                 updateDeviceLabel(device.deviceId); | ||||
|                                 return false; | ||||
|                               } else { | ||||
|                                 final confirm = await showConfirmAlert( | ||||
|                                   'authDeviceLogoutHint'.tr(), | ||||
|                                   'authDeviceLogout'.tr(), | ||||
|                                 ); | ||||
|                                 if (confirm && context.mounted) { | ||||
|                                   try { | ||||
|                                     showLoadingModal(context); | ||||
|                                     final apiClient = ref.watch( | ||||
|                                       apiClientProvider, | ||||
|                                     ); | ||||
|                                     await apiClient.delete( | ||||
|                                       '/id/accounts/me/devices/${device.deviceId}', | ||||
|                                     ); | ||||
|                                     ref.invalidate(authDevicesProvider); | ||||
|                                   } catch (err) { | ||||
|                                     showErrorAlert(err); | ||||
|                                   } finally { | ||||
|                                     if (context.mounted) | ||||
|                                       hideLoadingModal(context); | ||||
|                                   } | ||||
|                                 } | ||||
|                                 return confirm; | ||||
|                               } | ||||
|                             }, | ||||
|                             child: _DeviceListTile( | ||||
|                               device: device, | ||||
|                               updateDeviceLabel: updateDeviceLabel, | ||||
|                               logoutDevice: logoutDevice, | ||||
|                             ), | ||||
|                           ); | ||||
|                         } | ||||
|                       }, | ||||
|                     ), | ||||
|                   ), | ||||
|               error: | ||||
|                   (err, _) => ResponseErrorWidget( | ||||
|                     error: err, | ||||
|                     onRetry: () => ref.invalidate(authDevicesProvider), | ||||
|                   ), | ||||
|               loading: () => ResponseLoadingWidget(), | ||||
|             ), | ||||
|         loading: () => ResponseLoadingWidget(), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -3,7 +3,7 @@ import 'package:bitsdojo_window/bitsdojo_window.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/pods/activity_rpc.dart'; | ||||
| import 'package:island/pods/activity/activity_rpc.dart'; | ||||
| import 'package:island/pods/websocket.dart'; | ||||
| import 'package:island/screens/tray_manager.dart'; | ||||
| import 'package:island/services/notify.dart'; | ||||
|   | ||||
							
								
								
									
										327
									
								
								lib/widgets/chat/chat_input.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										327
									
								
								lib/widgets/chat/chat_input.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,327 @@ | ||||
| import "dart:async"; | ||||
| import "dart:io"; | ||||
| import "package:easy_localization/easy_localization.dart"; | ||||
| import "package:flutter/foundation.dart"; | ||||
| import "package:flutter/material.dart"; | ||||
| import "package:flutter/services.dart"; | ||||
| import "package:flutter_hooks/flutter_hooks.dart"; | ||||
| import "package:gap/gap.dart"; | ||||
| import "package:hooks_riverpod/hooks_riverpod.dart"; | ||||
| import "package:image_picker/image_picker.dart"; | ||||
| import "package:island/models/chat.dart"; | ||||
| import "package:island/models/file.dart"; | ||||
| import "package:island/pods/config.dart"; | ||||
| import "package:island/widgets/content/attachment_preview.dart"; | ||||
| import "package:material_symbols_icons/material_symbols_icons.dart"; | ||||
| import "package:pasteboard/pasteboard.dart"; | ||||
| import "package:styled_widget/styled_widget.dart"; | ||||
| import "package:material_symbols_icons/symbols.dart"; | ||||
| import "package:island/widgets/stickers/picker.dart"; | ||||
|  | ||||
| class ChatInput extends HookConsumerWidget { | ||||
|   final TextEditingController messageController; | ||||
|   final SnChatRoom chatRoom; | ||||
|   final VoidCallback onSend; | ||||
|   final VoidCallback onClear; | ||||
|   final Function(bool isPhoto) onPickFile; | ||||
|   final SnChatMessage? messageReplyingTo; | ||||
|   final SnChatMessage? messageForwardingTo; | ||||
|   final SnChatMessage? messageEditingTo; | ||||
|   final List<UniversalFile> attachments; | ||||
|   final Function(int) onUploadAttachment; | ||||
|   final Function(int) onDeleteAttachment; | ||||
|   final Function(int, int) onMoveAttachment; | ||||
|   final Function(List<UniversalFile>) onAttachmentsChanged; | ||||
|  | ||||
|   const ChatInput({ | ||||
|     super.key, | ||||
|     required this.messageController, | ||||
|     required this.chatRoom, | ||||
|     required this.onSend, | ||||
|     required this.onClear, | ||||
|     required this.onPickFile, | ||||
|     required this.messageReplyingTo, | ||||
|     required this.messageForwardingTo, | ||||
|     required this.messageEditingTo, | ||||
|     required this.attachments, | ||||
|     required this.onUploadAttachment, | ||||
|     required this.onDeleteAttachment, | ||||
|     required this.onMoveAttachment, | ||||
|     required this.onAttachmentsChanged, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final inputFocusNode = useFocusNode(); | ||||
|  | ||||
|     final enterToSend = ref.watch(appSettingsNotifierProvider).enterToSend; | ||||
|  | ||||
|     final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS); | ||||
|  | ||||
|     void send() { | ||||
|       onSend.call(); | ||||
|       WidgetsBinding.instance.addPostFrameCallback((_) { | ||||
|         inputFocusNode.requestFocus(); | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     Future<void> handlePaste() async { | ||||
|       final clipboard = await Pasteboard.image; | ||||
|       if (clipboard == null) return; | ||||
|  | ||||
|       onAttachmentsChanged([ | ||||
|         ...attachments, | ||||
|         UniversalFile( | ||||
|           data: XFile.fromData(clipboard, mimeType: "image/jpeg"), | ||||
|           type: UniversalFileType.image, | ||||
|         ), | ||||
|       ]); | ||||
|     } | ||||
|  | ||||
|     void handleKeyPress( | ||||
|       BuildContext context, | ||||
|       WidgetRef ref, | ||||
|       RawKeyEvent event, | ||||
|     ) { | ||||
|       if (event is! RawKeyDownEvent) return; | ||||
|  | ||||
|       final isPaste = event.logicalKey == LogicalKeyboardKey.keyV; | ||||
|       final isModifierPressed = event.isMetaPressed || event.isControlPressed; | ||||
|  | ||||
|       if (isPaste && isModifierPressed) { | ||||
|         handlePaste(); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       final enterToSend = ref.read(appSettingsNotifierProvider).enterToSend; | ||||
|       final isEnter = event.logicalKey == LogicalKeyboardKey.enter; | ||||
|  | ||||
|       if (isEnter) { | ||||
|         if (enterToSend && !isModifierPressed) { | ||||
|           send(); | ||||
|         } else if (!enterToSend && isModifierPressed) { | ||||
|           send(); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return Material( | ||||
|       elevation: 8, | ||||
|       color: Theme.of(context).colorScheme.surface, | ||||
|       child: Column( | ||||
|         children: [ | ||||
|           if (attachments.isNotEmpty) | ||||
|             SizedBox( | ||||
|               height: 280, | ||||
|               child: ListView.separated( | ||||
|                 padding: EdgeInsets.symmetric(horizontal: 12), | ||||
|                 scrollDirection: Axis.horizontal, | ||||
|                 itemCount: attachments.length, | ||||
|                 itemBuilder: (context, idx) { | ||||
|                   return SizedBox( | ||||
|                     height: 280, | ||||
|                     width: 280, | ||||
|                     child: AttachmentPreview( | ||||
|                       item: attachments[idx], | ||||
|                       onRequestUpload: () => onUploadAttachment(idx), | ||||
|                       onDelete: () => onDeleteAttachment(idx), | ||||
|                       onUpdate: (value) { | ||||
|                         attachments[idx] = value; | ||||
|                         onAttachmentsChanged(attachments); | ||||
|                       }, | ||||
|                       onMove: (delta) => onMoveAttachment(idx, delta), | ||||
|                     ), | ||||
|                   ); | ||||
|                 }, | ||||
|                 separatorBuilder: (_, _) => const Gap(8), | ||||
|               ), | ||||
|             ).padding(top: 12), | ||||
|           if (messageReplyingTo != null || | ||||
|               messageForwardingTo != null || | ||||
|               messageEditingTo != null) | ||||
|             Container( | ||||
|               padding: const EdgeInsets.symmetric(horizontal: 16), | ||||
|               decoration: BoxDecoration( | ||||
|                 color: Theme.of(context).colorScheme.surfaceContainer, | ||||
|                 borderRadius: BorderRadius.circular(8), | ||||
|               ), | ||||
|               margin: const EdgeInsets.only(left: 8, right: 8, top: 8), | ||||
|               child: Row( | ||||
|                 children: [ | ||||
|                   Icon( | ||||
|                     messageReplyingTo != null | ||||
|                         ? Symbols.reply | ||||
|                         : messageForwardingTo != null | ||||
|                         ? Symbols.forward | ||||
|                         : Symbols.edit, | ||||
|                     size: 20, | ||||
|                     color: Theme.of(context).colorScheme.primary, | ||||
|                   ), | ||||
|                   const Gap(8), | ||||
|                   Expanded( | ||||
|                     child: Text( | ||||
|                       messageReplyingTo != null | ||||
|                           ? 'Replying to ${messageReplyingTo?.sender.account.nick}' | ||||
|                           : messageForwardingTo != null | ||||
|                           ? 'Forwarding message' | ||||
|                           : 'Editing message', | ||||
|                       style: Theme.of(context).textTheme.bodySmall, | ||||
|                       maxLines: 1, | ||||
|                       overflow: TextOverflow.ellipsis, | ||||
|                     ), | ||||
|                   ), | ||||
|                   IconButton( | ||||
|                     icon: const Icon(Icons.close, size: 20), | ||||
|                     onPressed: onClear, | ||||
|                     padding: EdgeInsets.zero, | ||||
|                     style: ButtonStyle( | ||||
|                       minimumSize: WidgetStatePropertyAll(Size(28, 28)), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), | ||||
|             child: Row( | ||||
|               children: [ | ||||
|                 Row( | ||||
|                   mainAxisSize: MainAxisSize.min, | ||||
|                   children: [ | ||||
|                     IconButton( | ||||
|                       tooltip: 'stickers'.tr(), | ||||
|                       icon: const Icon(Symbols.add_reaction), | ||||
|                       onPressed: () { | ||||
|                         final size = MediaQuery.of(context).size; | ||||
|                         showStickerPickerPopover( | ||||
|                           context, | ||||
|                           Offset( | ||||
|                             20, | ||||
|                             size.height - | ||||
|                                 480 - | ||||
|                                 MediaQuery.of(context).padding.bottom, | ||||
|                           ), | ||||
|                           onPick: (placeholder) { | ||||
|                             // Insert placeholder at current cursor position | ||||
|                             final text = messageController.text; | ||||
|                             final selection = messageController.selection; | ||||
|                             final start = | ||||
|                                 selection.start >= 0 | ||||
|                                     ? selection.start | ||||
|                                     : text.length; | ||||
|                             final end = | ||||
|                                 selection.end >= 0 | ||||
|                                     ? selection.end | ||||
|                                     : text.length; | ||||
|                             final newText = text.replaceRange( | ||||
|                               start, | ||||
|                               end, | ||||
|                               placeholder, | ||||
|                             ); | ||||
|                             messageController.value = TextEditingValue( | ||||
|                               text: newText, | ||||
|                               selection: TextSelection.collapsed( | ||||
|                                 offset: start + placeholder.length, | ||||
|                               ), | ||||
|                             ); | ||||
|                           }, | ||||
|                         ); | ||||
|                       }, | ||||
|                     ), | ||||
|                     PopupMenuButton( | ||||
|                       icon: const Icon(Symbols.photo_library), | ||||
|                       itemBuilder: | ||||
|                           (context) => [ | ||||
|                             PopupMenuItem( | ||||
|                               onTap: () => onPickFile(true), | ||||
|                               child: Row( | ||||
|                                 spacing: 12, | ||||
|                                 children: [ | ||||
|                                   const Icon(Symbols.photo), | ||||
|                                   Text('addPhoto').tr(), | ||||
|                                 ], | ||||
|                               ), | ||||
|                             ), | ||||
|                             PopupMenuItem( | ||||
|                               onTap: () => onPickFile(false), | ||||
|                               child: Row( | ||||
|                                 spacing: 12, | ||||
|                                 children: [ | ||||
|                                   const Icon(Symbols.video_call), | ||||
|                                   Text('addVideo').tr(), | ||||
|                                 ], | ||||
|                               ), | ||||
|                             ), | ||||
|                           ], | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|                 Expanded( | ||||
|                   child: RawKeyboardListener( | ||||
|                     focusNode: FocusNode(), | ||||
|                     onKey: (event) => handleKeyPress(context, ref, event), | ||||
|                     child: TextField( | ||||
|                       focusNode: inputFocusNode, | ||||
|                       controller: messageController, | ||||
|                       onSubmitted: | ||||
|                           (enterToSend && isMobile) | ||||
|                               ? (_) { | ||||
|                                 send(); | ||||
|                               } | ||||
|                               : null, | ||||
|                       keyboardType: | ||||
|                           (enterToSend && isMobile) | ||||
|                               ? TextInputType.text | ||||
|                               : TextInputType.multiline, | ||||
|                       textInputAction: TextInputAction.send, | ||||
|                       inputFormatters: [ | ||||
|                         if (enterToSend && !isMobile) | ||||
|                           TextInputFormatter.withFunction((oldValue, newValue) { | ||||
|                             if (newValue.text.endsWith('\n')) { | ||||
|                               return oldValue; | ||||
|                             } | ||||
|                             return newValue; | ||||
|                           }), | ||||
|                       ], | ||||
|                       decoration: InputDecoration( | ||||
|                         hintText: | ||||
|                             (chatRoom.type == 1 && chatRoom.name == null) | ||||
|                                 ? 'chatDirectMessageHint'.tr( | ||||
|                                   args: [ | ||||
|                                     chatRoom.members! | ||||
|                                         .map((e) => e.account.nick) | ||||
|                                         .join(', '), | ||||
|                                   ], | ||||
|                                 ) | ||||
|                                 : 'chatMessageHint'.tr(args: [chatRoom.name!]), | ||||
|                         border: InputBorder.none, | ||||
|                         isDense: true, | ||||
|                         contentPadding: const EdgeInsets.symmetric( | ||||
|                           horizontal: 12, | ||||
|                           vertical: 4, | ||||
|                         ), | ||||
|                         counterText: | ||||
|                             messageController.text.length > 1024 | ||||
|                                 ? '${messageController.text.length}/4096' | ||||
|                                 : null, | ||||
|                       ), | ||||
|                       maxLines: 3, | ||||
|                       minLines: 1, | ||||
|                       onTapOutside: | ||||
|                           (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|                 IconButton( | ||||
|                   icon: const Icon(Icons.send), | ||||
|                   color: Theme.of(context).colorScheme.primary, | ||||
|                   onPressed: send, | ||||
|                 ), | ||||
|               ], | ||||
|             ).padding(bottom: MediaQuery.of(context).padding.bottom), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										169
									
								
								lib/widgets/chat/message_content.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										169
									
								
								lib/widgets/chat/message_content.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,169 @@ | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:island/models/chat.dart'; | ||||
| import 'package:island/pods/call.dart'; | ||||
| import 'package:island/widgets/content/markdown.dart'; | ||||
| import 'package:material_symbols_icons/material_symbols_icons.dart'; | ||||
| import 'package:pretty_diff_text/pretty_diff_text.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| class MessageContent extends StatelessWidget { | ||||
|   final SnChatMessage item; | ||||
|   final String? translatedText; | ||||
|  | ||||
|   const MessageContent({super.key, required this.item, this.translatedText}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     switch (item.type) { | ||||
|       case 'call.start': | ||||
|       case 'call.ended': | ||||
|         return _MessageContentCall( | ||||
|           isEnded: item.type == 'call.ended', | ||||
|           duration: item.meta['duration']?.toDouble(), | ||||
|         ); | ||||
|       case 'messages.update': | ||||
|       case 'messages.update.links': | ||||
|         return Row( | ||||
|           mainAxisSize: MainAxisSize.min, | ||||
|           crossAxisAlignment: CrossAxisAlignment.center, | ||||
|           children: [ | ||||
|             Icon( | ||||
|               Symbols.edit, | ||||
|               size: 14, | ||||
|               color: Theme.of( | ||||
|                 context, | ||||
|               ).colorScheme.onSurfaceVariant.withOpacity(0.6), | ||||
|             ), | ||||
|             const Gap(4), | ||||
|             if (item.meta['previous_content'] is String) | ||||
|               PrettyDiffText( | ||||
|                 oldText: item.meta['previous_content'], | ||||
|                 newText: item.content ?? 'Edited a message', | ||||
|                 defaultTextStyle: Theme.of( | ||||
|                   context, | ||||
|                 ).textTheme.bodySmall!.copyWith( | ||||
|                   color: Theme.of(context).colorScheme.onSurfaceVariant, | ||||
|                 ), | ||||
|                 addedTextStyle: TextStyle( | ||||
|                   backgroundColor: Theme.of( | ||||
|                     context, | ||||
|                   ).colorScheme.primaryFixedDim.withOpacity(0.4), | ||||
|                 ), | ||||
|                 deletedTextStyle: TextStyle( | ||||
|                   decoration: TextDecoration.lineThrough, | ||||
|                   color: Theme.of( | ||||
|                     context, | ||||
|                   ).colorScheme.onSurfaceVariant.withOpacity(0.7), | ||||
|                 ), | ||||
|               ) | ||||
|             else | ||||
|               Text( | ||||
|                 item.content ?? 'Edited a message', | ||||
|                 style: Theme.of(context).textTheme.bodySmall?.copyWith( | ||||
|                   color: Theme.of( | ||||
|                     context, | ||||
|                   ).colorScheme.onSurfaceVariant.withOpacity(0.6), | ||||
|                 ), | ||||
|               ), | ||||
|           ], | ||||
|         ); | ||||
|       case 'messages.delete': | ||||
|         return Row( | ||||
|           mainAxisSize: MainAxisSize.min, | ||||
|           crossAxisAlignment: CrossAxisAlignment.center, | ||||
|           children: [ | ||||
|             Icon( | ||||
|               Symbols.delete, | ||||
|               size: 14, | ||||
|               color: Theme.of( | ||||
|                 context, | ||||
|               ).colorScheme.onSurfaceVariant.withOpacity(0.6), | ||||
|             ), | ||||
|             const Gap(4), | ||||
|             Text( | ||||
|               item.content ?? 'Deleted a message', | ||||
|               style: Theme.of(context).textTheme.bodySmall?.copyWith( | ||||
|                 color: Theme.of( | ||||
|                   context, | ||||
|                 ).colorScheme.onSurfaceVariant.withOpacity(0.6), | ||||
|                 fontStyle: FontStyle.italic, | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ); | ||||
|       case 'text': | ||||
|       default: | ||||
|         return Column( | ||||
|           mainAxisSize: MainAxisSize.min, | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           children: [ | ||||
|             MarkdownTextContent( | ||||
|               content: item.content ?? '*${item.type} has no content*', | ||||
|               isSelectable: true, | ||||
|               linesMargin: EdgeInsets.zero, | ||||
|             ), | ||||
|             if (translatedText?.isNotEmpty ?? false) | ||||
|               ...([ | ||||
|                 ConstrainedBox( | ||||
|                   constraints: BoxConstraints( | ||||
|                     maxWidth: math.min( | ||||
|                       280, | ||||
|                       MediaQuery.of(context).size.width * 0.4, | ||||
|                     ), | ||||
|                   ), | ||||
|                   child: Row( | ||||
|                     mainAxisSize: MainAxisSize.min, | ||||
|                     children: [ | ||||
|                       Text('translated').tr().fontSize(11).opacity(0.75), | ||||
|                       const Gap(8), | ||||
|                       Flexible(child: Divider()), | ||||
|                     ], | ||||
|                   ).padding(vertical: 4), | ||||
|                 ), | ||||
|                 MarkdownTextContent( | ||||
|                   content: translatedText!, | ||||
|                   isSelectable: true, | ||||
|                   linesMargin: EdgeInsets.zero, | ||||
|                 ), | ||||
|               ]), | ||||
|           ], | ||||
|         ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   static bool hasContent(SnChatMessage item) { | ||||
|     return item.type != 'text' || (item.content?.isNotEmpty ?? false); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _MessageContentCall extends StatelessWidget { | ||||
|   final bool isEnded; | ||||
|   final double? duration; | ||||
|  | ||||
|   const _MessageContentCall({required this.isEnded, this.duration}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Row( | ||||
|       mainAxisSize: MainAxisSize.min, | ||||
|       children: [ | ||||
|         Icon( | ||||
|           isEnded ? Symbols.call_end : Symbols.phone_in_talk, | ||||
|           size: 16, | ||||
|           color: Theme.of(context).colorScheme.primary, | ||||
|         ), | ||||
|         Gap(4), | ||||
|         Text( | ||||
|           isEnded | ||||
|               ? 'Call ended after ${formatDuration(Duration(seconds: duration!.toInt()))}' | ||||
|               : 'Call started', | ||||
|           style: TextStyle(color: Theme.of(context).colorScheme.primary), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										69
									
								
								lib/widgets/chat/message_indicators.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								lib/widgets/chat/message_indicators.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/database/message.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| class MessageIndicators extends StatelessWidget { | ||||
|   final DateTime? editedAt; | ||||
|   final MessageStatus? status; | ||||
|   final bool isCurrentUser; | ||||
|   final Color textColor; | ||||
|  | ||||
|   const MessageIndicators({ | ||||
|     super.key, | ||||
|     this.editedAt, | ||||
|     this.status, | ||||
|     required this.isCurrentUser, | ||||
|     required this.textColor, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Row( | ||||
|       spacing: 4, | ||||
|       mainAxisSize: MainAxisSize.min, | ||||
|       children: [ | ||||
|         if (editedAt != null) | ||||
|           Text( | ||||
|             'edited'.tr().toLowerCase(), | ||||
|             style: TextStyle(fontSize: 11, color: textColor.withOpacity(0.7)), | ||||
|           ), | ||||
|         if (isCurrentUser && status != null) | ||||
|           _buildStatusIcon( | ||||
|             context, | ||||
|             status!, | ||||
|             textColor.withOpacity(0.7), | ||||
|           ).padding(bottom: 3), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildStatusIcon( | ||||
|     BuildContext context, | ||||
|     MessageStatus status, | ||||
|     Color textColor, | ||||
|   ) { | ||||
|     switch (status) { | ||||
|       case MessageStatus.pending: | ||||
|         return Icon(Icons.access_time, size: 12, color: textColor); | ||||
|       case MessageStatus.sent: | ||||
|         return Icon(Icons.check, size: 12, color: textColor); | ||||
|       case MessageStatus.failed: | ||||
|         return Consumer( | ||||
|           builder: | ||||
|               (context, ref, _) => GestureDetector( | ||||
|                 onTap: () { | ||||
|                   // This would need to be passed in or accessed differently | ||||
|                   // For now, just show the error icon | ||||
|                 }, | ||||
|                 child: const Icon( | ||||
|                   Icons.error_outline, | ||||
|                   size: 12, | ||||
|                   color: Colors.red, | ||||
|                 ), | ||||
|               ), | ||||
|         ); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -1,3 +1,4 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:io'; | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| @@ -9,20 +10,18 @@ import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/database/message.dart'; | ||||
| import 'package:island/models/chat.dart'; | ||||
| import 'package:island/models/embed.dart'; | ||||
| import 'package:island/pods/call.dart'; | ||||
| import 'package:island/pods/messages_notifier.dart'; | ||||
| import 'package:island/pods/translate.dart'; | ||||
| import 'package:island/screens/chat/room.dart'; | ||||
| import 'package:island/utils/mapping.dart'; | ||||
| import 'package:island/widgets/account/account_name.dart'; | ||||
| import 'package:island/widgets/account/account_pfc.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/chat/message_content.dart'; | ||||
| import 'package:island/widgets/chat/message_indicators.dart'; | ||||
| import 'package:island/widgets/chat/message_sender_info.dart'; | ||||
| import 'package:island/widgets/content/alert.native.dart'; | ||||
| import 'package:island/widgets/content/cloud_file_collection.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/content/embed/link.dart'; | ||||
| import 'package:island/widgets/content/markdown.dart'; | ||||
| import 'package:material_symbols_icons/material_symbols_icons.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:super_context_menu/super_context_menu.dart'; | ||||
| @@ -66,6 +65,46 @@ class MessageItem extends HookConsumerWidget { | ||||
|     final hasBackground = | ||||
|         ref.watch(backgroundImageFileProvider).valueOrNull != null; | ||||
|  | ||||
|     final flashing = ref.watch( | ||||
|       flashingMessagesProvider.select((set) => set.contains(message.id)), | ||||
|     ); | ||||
|  | ||||
|     final isFlashing = useState(false); | ||||
|     final flashTimer = useState<Timer?>(null); | ||||
|  | ||||
|     useEffect(() { | ||||
|       if (flashing) { | ||||
|         if (flashTimer.value != null) return null; | ||||
|         isFlashing.value = true; | ||||
|         flashTimer.value = Timer.periodic(const Duration(milliseconds: 200), ( | ||||
|           timer, | ||||
|         ) { | ||||
|           isFlashing.value = !isFlashing.value; | ||||
|           if (timer.tick >= 4) { | ||||
|             // 4 ticks: true, false, true, false | ||||
|             timer.cancel(); | ||||
|             flashTimer.value = null; | ||||
|             isFlashing.value = false; | ||||
|             ref | ||||
|                 .read(flashingMessagesProvider.notifier) | ||||
|                 .update((set) => set.difference({message.id})); | ||||
|           } | ||||
|         }); | ||||
|       } else { | ||||
|         flashTimer.value?.cancel(); | ||||
|         flashTimer.value = null; | ||||
|         isFlashing.value = false; | ||||
|       } | ||||
|       return () { | ||||
|         flashTimer.value?.cancel(); | ||||
|       }; | ||||
|     }, [flashing]); | ||||
|  | ||||
|     final flashColor = | ||||
|         isFlashing.value | ||||
|             ? Theme.of(context).colorScheme.primary.withOpacity(0.8) | ||||
|             : containerColor; | ||||
|  | ||||
|     final remoteMessage = message.toRemoteMessage(); | ||||
|     final sender = remoteMessage.sender; | ||||
|  | ||||
| @@ -186,62 +225,10 @@ class MessageItem extends HookConsumerWidget { | ||||
|             children: [ | ||||
|               if (showAvatar) ...[ | ||||
|                 const Gap(8), | ||||
|                 Row( | ||||
|                   spacing: 8, | ||||
|                   mainAxisSize: MainAxisSize.min, | ||||
|                   children: [ | ||||
|                     AccountPfcGestureDetector( | ||||
|                       uname: sender.account.name, | ||||
|                       child: ProfilePictureWidget( | ||||
|                         fileId: sender.account.profile.picture?.id, | ||||
|                         radius: 16, | ||||
|                       ), | ||||
|                     ), | ||||
|                     Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       spacing: 2, | ||||
|                       children: [ | ||||
|                         Text( | ||||
|                           DateTime.now().difference(message.createdAt).inDays > | ||||
|                                   365 | ||||
|                               ? DateFormat( | ||||
|                                 'yyyy/MM/dd HH:mm', | ||||
|                               ).format(message.createdAt.toLocal()) | ||||
|                               : DateTime.now() | ||||
|                                       .difference(message.createdAt) | ||||
|                                       .inDays > | ||||
|                                   0 | ||||
|                               ? DateFormat( | ||||
|                                 'MM/dd HH:mm', | ||||
|                               ).format(message.createdAt.toLocal()) | ||||
|                               : DateFormat( | ||||
|                                 'HH:mm', | ||||
|                               ).format(message.createdAt.toLocal()), | ||||
|                           style: TextStyle(fontSize: 10, color: textColor), | ||||
|                         ), | ||||
|                         Row( | ||||
|                           mainAxisSize: MainAxisSize.min, | ||||
|                           spacing: 5, | ||||
|                           children: [ | ||||
|                             AccountName( | ||||
|                               account: sender.account, | ||||
|                               style: Theme.of(context).textTheme.bodySmall, | ||||
|                             ), | ||||
|                             Badge( | ||||
|                               label: | ||||
|                                   Text( | ||||
|                                     sender.role >= 100 | ||||
|                                         ? 'permissionOwner' | ||||
|                                         : sender.role >= 50 | ||||
|                                         ? 'permissionModerator' | ||||
|                                         : 'permissionMember', | ||||
|                                   ).tr(), | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ], | ||||
|                 MessageSenderInfo( | ||||
|                   sender: sender, | ||||
|                   createdAt: message.createdAt, | ||||
|                   textColor: textColor, | ||||
|                 ), | ||||
|                 const Gap(4), | ||||
|               ], | ||||
| @@ -252,9 +239,10 @@ class MessageItem extends HookConsumerWidget { | ||||
|                 crossAxisAlignment: CrossAxisAlignment.end, | ||||
|                 children: [ | ||||
|                   Flexible( | ||||
|                     child: Container( | ||||
|                     child: AnimatedContainer( | ||||
|                       duration: const Duration(milliseconds: 200), | ||||
|                       decoration: BoxDecoration( | ||||
|                         color: containerColor, | ||||
|                         color: flashColor, | ||||
|                         borderRadius: BorderRadius.circular(16), | ||||
|                       ), | ||||
|                       padding: const EdgeInsets.symmetric( | ||||
| @@ -276,8 +264,8 @@ class MessageItem extends HookConsumerWidget { | ||||
|                               textColor: textColor, | ||||
|                               isReply: false, | ||||
|                             ).padding(vertical: 4), | ||||
|                           if (_MessageItemContent.hasContent(remoteMessage)) | ||||
|                             _MessageItemContent( | ||||
|                           if (MessageContent.hasContent(remoteMessage)) | ||||
|                             MessageContent( | ||||
|                               item: remoteMessage, | ||||
|                               translatedText: translatedText.value, | ||||
|                             ), | ||||
| @@ -363,12 +351,11 @@ class MessageItem extends HookConsumerWidget { | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                   _buildMessageIndicators( | ||||
|                     context, | ||||
|                     textColor, | ||||
|                     remoteMessage, | ||||
|                     message, | ||||
|                     isCurrentUser, | ||||
|                   MessageIndicators( | ||||
|                     editedAt: remoteMessage.editedAt, | ||||
|                     status: message.status, | ||||
|                     isCurrentUser: isCurrentUser, | ||||
|                     textColor: textColor, | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
| @@ -378,61 +365,6 @@ class MessageItem extends HookConsumerWidget { | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildMessageIndicators( | ||||
|     BuildContext context, | ||||
|     Color textColor, | ||||
|     SnChatMessage remoteMessage, | ||||
|     LocalChatMessage message, | ||||
|     bool isCurrentUser, | ||||
|   ) { | ||||
|     return Row( | ||||
|       spacing: 4, | ||||
|       mainAxisSize: MainAxisSize.min, | ||||
|       children: [ | ||||
|         if (remoteMessage.editedAt != null) | ||||
|           Text( | ||||
|             'edited'.tr().toLowerCase(), | ||||
|             style: TextStyle(fontSize: 11, color: textColor.withOpacity(0.7)), | ||||
|           ), | ||||
|         if (isCurrentUser) | ||||
|           _buildStatusIcon( | ||||
|             context, | ||||
|             message.status, | ||||
|             textColor.withOpacity(0.7), | ||||
|           ).padding(bottom: 3), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildStatusIcon( | ||||
|     BuildContext context, | ||||
|     MessageStatus status, | ||||
|     Color textColor, | ||||
|   ) { | ||||
|     switch (status) { | ||||
|       case MessageStatus.pending: | ||||
|         return Icon(Icons.access_time, size: 12, color: textColor); | ||||
|       case MessageStatus.sent: | ||||
|         return Icon(Icons.check, size: 12, color: textColor); | ||||
|       case MessageStatus.failed: | ||||
|         return Consumer( | ||||
|           builder: | ||||
|               (context, ref, _) => GestureDetector( | ||||
|                 onTap: () { | ||||
|                   ref | ||||
|                       .read(messagesNotifierProvider(message.roomId).notifier) | ||||
|                       .retryMessage(message.id); | ||||
|                 }, | ||||
|                 child: const Icon( | ||||
|                   Icons.error_outline, | ||||
|                   size: 12, | ||||
|                   color: Colors.red, | ||||
|                 ), | ||||
|               ), | ||||
|         ); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| class MessageQuoteWidget extends HookConsumerWidget { | ||||
| @@ -509,8 +441,8 @@ class MessageQuoteWidget extends HookConsumerWidget { | ||||
|                           ).textColor(textColor).bold(), | ||||
|                         ], | ||||
|                       ).padding(right: 8), | ||||
|                     if (_MessageItemContent.hasContent(remoteMessage)) | ||||
|                       _MessageItemContent(item: remoteMessage), | ||||
|                     if (MessageContent.hasContent(remoteMessage)) | ||||
|                       MessageContent(item: remoteMessage), | ||||
|                     if (remoteMessage.attachments.isNotEmpty) | ||||
|                       Row( | ||||
|                         mainAxisSize: MainAxisSize.min, | ||||
| @@ -537,109 +469,3 @@ class MessageQuoteWidget extends HookConsumerWidget { | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _MessageItemContent extends StatelessWidget { | ||||
|   final SnChatMessage item; | ||||
|   final String? translatedText; | ||||
|   const _MessageItemContent({required this.item, this.translatedText}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     switch (item.type) { | ||||
|       case 'deleted': | ||||
|         return Row( | ||||
|           mainAxisSize: MainAxisSize.min, | ||||
|           crossAxisAlignment: CrossAxisAlignment.center, | ||||
|           children: [ | ||||
|             Icon( | ||||
|               Symbols.delete, | ||||
|               size: 14, | ||||
|               color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.6), | ||||
|             ), | ||||
|             const Gap(4), | ||||
|             Text( | ||||
|               item.content!, | ||||
|               style: Theme.of(context).textTheme.bodySmall?.copyWith( | ||||
|                     color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.6), | ||||
|                     fontStyle: FontStyle.italic, | ||||
|                   ), | ||||
|             ), | ||||
|           ], | ||||
|         ); | ||||
|       case 'call.start': | ||||
|       case 'call.ended': | ||||
|         return _MessageContentCall( | ||||
|           isEnded: item.type == 'call.ended', | ||||
|           duration: item.meta['duration']?.toDouble(), | ||||
|         ); | ||||
|       case 'text': | ||||
|       default: | ||||
|         return Column( | ||||
|           mainAxisSize: MainAxisSize.min, | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           children: [ | ||||
|             MarkdownTextContent( | ||||
|               content: item.content!, | ||||
|               isSelectable: true, | ||||
|               linesMargin: EdgeInsets.zero, | ||||
|             ), | ||||
|             if (translatedText?.isNotEmpty ?? false) | ||||
|               ...([ | ||||
|                 ConstrainedBox( | ||||
|                   constraints: BoxConstraints( | ||||
|                     maxWidth: math.min( | ||||
|                       280, | ||||
|                       MediaQuery.of(context).size.width * 0.4, | ||||
|                     ), | ||||
|                   ), | ||||
|                   child: Row( | ||||
|                     mainAxisSize: MainAxisSize.min, | ||||
|                     children: [ | ||||
|                       Text('translated').tr().fontSize(11).opacity(0.75), | ||||
|                       const Gap(8), | ||||
|                       Flexible(child: Divider()), | ||||
|                     ], | ||||
|                   ).padding(vertical: 4), | ||||
|                 ), | ||||
|                 MarkdownTextContent( | ||||
|                   content: translatedText!, | ||||
|                   isSelectable: true, | ||||
|                   linesMargin: EdgeInsets.zero, | ||||
|                 ), | ||||
|               ]), | ||||
|           ], | ||||
|         ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   static bool hasContent(SnChatMessage item) { | ||||
|     return item.type != 'text' || (item.content?.isNotEmpty ?? false); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _MessageContentCall extends StatelessWidget { | ||||
|   final bool isEnded; | ||||
|   final double? duration; | ||||
|   const _MessageContentCall({required this.isEnded, this.duration}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Row( | ||||
|       mainAxisSize: MainAxisSize.min, | ||||
|       children: [ | ||||
|         Icon( | ||||
|           isEnded ? Symbols.call_end : Symbols.phone_in_talk, | ||||
|           size: 16, | ||||
|           color: Theme.of(context).colorScheme.primary, | ||||
|         ), | ||||
|         Gap(4), | ||||
|         Text( | ||||
|           isEnded | ||||
|               ? 'Call ended after ${formatDuration(Duration(seconds: duration!.toInt()))}' | ||||
|               : 'Call started', | ||||
|           style: TextStyle(color: Theme.of(context).colorScheme.primary), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										87
									
								
								lib/widgets/chat/message_list_tile.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								lib/widgets/chat/message_list_tile.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,87 @@ | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:island/database/message.dart'; | ||||
| import 'package:island/models/embed.dart'; | ||||
| import 'package:island/utils/mapping.dart'; | ||||
| import 'package:island/widgets/chat/message_content.dart'; | ||||
| import 'package:island/widgets/chat/message_sender_info.dart'; | ||||
| import 'package:island/widgets/content/cloud_file_collection.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/content/embed/link.dart'; | ||||
|  | ||||
| class MessageListTile extends StatelessWidget { | ||||
|   final LocalChatMessage message; | ||||
|   final Function(String messageId) onJump; | ||||
|  | ||||
|   const MessageListTile({ | ||||
|     super.key, | ||||
|     required this.message, | ||||
|     required this.onJump, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final remoteMessage = message.toRemoteMessage(); | ||||
|     final sender = remoteMessage.sender; | ||||
|  | ||||
|     return ListTile( | ||||
|       leading: CircleAvatar( | ||||
|         radius: 20, | ||||
|         backgroundColor: Colors.transparent, | ||||
|         child: ProfilePictureWidget( | ||||
|           fileId: sender.account.profile.picture?.id, | ||||
|           radius: 20, | ||||
|         ), | ||||
|       ), | ||||
|       title: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           MessageSenderInfo( | ||||
|             sender: sender, | ||||
|             createdAt: message.createdAt, | ||||
|             textColor: Theme.of(context).colorScheme.onSurfaceVariant, | ||||
|             compact: true, | ||||
|           ), | ||||
|           const SizedBox(height: 4), | ||||
|           MessageContent(item: remoteMessage), | ||||
|         ], | ||||
|       ), | ||||
|       subtitle: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           if (remoteMessage.attachments.isNotEmpty) | ||||
|             LayoutBuilder( | ||||
|               builder: (context, constraints) { | ||||
|                 return CloudFileList( | ||||
|                   files: remoteMessage.attachments, | ||||
|                   maxWidth: constraints.maxWidth, | ||||
|                   padding: EdgeInsets.symmetric(vertical: 4), | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|           if (remoteMessage.meta['embeds'] != null) | ||||
|             ...((remoteMessage.meta['embeds'] as List<dynamic>) | ||||
|                 .map((embed) => convertMapKeysToSnakeCase(embed)) | ||||
|                 .where((embed) => embed['type'] == 'link') | ||||
|                 .map((embed) => SnScrappedLink.fromJson(embed)) | ||||
|                 .map( | ||||
|                   (link) => LayoutBuilder( | ||||
|                     builder: (context, constraints) { | ||||
|                       return EmbedLinkWidget( | ||||
|                         link: link, | ||||
|                         maxWidth: math.min(constraints.maxWidth, 480), | ||||
|                         margin: const EdgeInsets.symmetric(vertical: 4), | ||||
|                       ); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ) | ||||
|                 .toList()), | ||||
|         ], | ||||
|       ), | ||||
|       onTap: () => onJump(message.id), | ||||
|       contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), | ||||
|       dense: true, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										124
									
								
								lib/widgets/chat/message_sender_info.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								lib/widgets/chat/message_sender_info.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,124 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:island/models/chat.dart'; | ||||
| import 'package:island/widgets/account/account_name.dart'; | ||||
| import 'package:island/widgets/account/account_pfc.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
|  | ||||
| class MessageSenderInfo extends StatelessWidget { | ||||
|   final SnChatMember sender; | ||||
|   final DateTime createdAt; | ||||
|   final Color textColor; | ||||
|   final bool compact; | ||||
|  | ||||
|   const MessageSenderInfo({ | ||||
|     super.key, | ||||
|     required this.sender, | ||||
|     required this.createdAt, | ||||
|     required this.textColor, | ||||
|     this.compact = false, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final timestamp = | ||||
|         DateTime.now().difference(createdAt).inDays > 365 | ||||
|             ? DateFormat('yyyy/MM/dd HH:mm').format(createdAt.toLocal()) | ||||
|             : DateTime.now().difference(createdAt).inDays > 0 | ||||
|             ? DateFormat('MM/dd HH:mm').format(createdAt.toLocal()) | ||||
|             : DateFormat('HH:mm').format(createdAt.toLocal()); | ||||
|  | ||||
|     if (compact) { | ||||
|       return Row( | ||||
|         spacing: 8, | ||||
|         children: [ | ||||
|           if (!compact) | ||||
|             AccountPfcGestureDetector( | ||||
|               uname: sender.account.name, | ||||
|               child: ProfilePictureWidget( | ||||
|                 fileId: sender.account.profile.picture?.id, | ||||
|                 radius: 14, | ||||
|               ), | ||||
|             ), | ||||
|           Expanded( | ||||
|             child: Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 Row( | ||||
|                   children: [ | ||||
|                     AccountName( | ||||
|                       account: sender.account, | ||||
|                       style: Theme.of(context).textTheme.bodySmall?.copyWith( | ||||
|                         color: textColor, | ||||
|                         fontWeight: FontWeight.w500, | ||||
|                       ), | ||||
|                     ), | ||||
|                     const SizedBox(width: 4), | ||||
|                     Badge( | ||||
|                       label: | ||||
|                           Text( | ||||
|                             sender.role >= 100 | ||||
|                                 ? 'permissionOwner' | ||||
|                                 : sender.role >= 50 | ||||
|                                 ? 'permissionModerator' | ||||
|                                 : 'permissionMember', | ||||
|                           ).tr(), | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|                 Text( | ||||
|                   timestamp, | ||||
|                   style: TextStyle( | ||||
|                     fontSize: 10, | ||||
|                     color: textColor.withOpacity(0.7), | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return Row( | ||||
|       spacing: 8, | ||||
|       mainAxisSize: MainAxisSize.min, | ||||
|       children: [ | ||||
|         AccountPfcGestureDetector( | ||||
|           uname: sender.account.name, | ||||
|           child: ProfilePictureWidget( | ||||
|             fileId: sender.account.profile.picture?.id, | ||||
|             radius: 16, | ||||
|           ), | ||||
|         ), | ||||
|         Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           spacing: 2, | ||||
|           children: [ | ||||
|             Text(timestamp, style: TextStyle(fontSize: 10, color: textColor)), | ||||
|             Row( | ||||
|               mainAxisSize: MainAxisSize.min, | ||||
|               spacing: 5, | ||||
|               children: [ | ||||
|                 AccountName( | ||||
|                   account: sender.account, | ||||
|                   style: Theme.of(context).textTheme.bodySmall, | ||||
|                 ), | ||||
|                 Badge( | ||||
|                   label: | ||||
|                       Text( | ||||
|                         sender.role >= 100 | ||||
|                             ? 'permissionOwner' | ||||
|                             : sender.role >= 50 | ||||
|                             ? 'permissionModerator' | ||||
|                             : 'permissionMember', | ||||
|                       ).tr(), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										223
									
								
								lib/widgets/chat/public_room_preview.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										223
									
								
								lib/widgets/chat/public_room_preview.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,223 @@ | ||||
| import "package:easy_localization/easy_localization.dart"; | ||||
| import "package:flutter/material.dart"; | ||||
| import "package:go_router/go_router.dart"; | ||||
| import "package:flutter_hooks/flutter_hooks.dart"; | ||||
| import "package:gap/gap.dart"; | ||||
| import "package:hooks_riverpod/hooks_riverpod.dart"; | ||||
| import "package:island/database/message.dart"; | ||||
| import "package:island/models/chat.dart"; | ||||
| import "package:island/pods/messages_notifier.dart"; | ||||
| import "package:island/pods/network.dart"; | ||||
| import "package:island/services/responsive.dart"; | ||||
| import "package:island/widgets/alert.dart"; | ||||
| import "package:island/widgets/app_scaffold.dart"; | ||||
| import "package:island/widgets/chat/message_item.dart"; | ||||
| import "package:island/widgets/content/cloud_files.dart"; | ||||
| import "package:island/widgets/response.dart"; | ||||
| import "package:material_symbols_icons/material_symbols_icons.dart"; | ||||
| import "package:styled_widget/styled_widget.dart"; | ||||
| import "package:super_sliver_list/super_sliver_list.dart"; | ||||
| import "package:material_symbols_icons/symbols.dart"; | ||||
| import "package:riverpod_annotation/riverpod_annotation.dart"; | ||||
| import "package:island/screens/chat/chat.dart"; | ||||
|  | ||||
| class PublicRoomPreview extends HookConsumerWidget { | ||||
|   final String id; | ||||
|   final SnChatRoom room; | ||||
|  | ||||
|   const PublicRoomPreview({super.key, required this.id, required this.room}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final messages = ref.watch(messagesNotifierProvider(id)); | ||||
|     final messagesNotifier = ref.read(messagesNotifierProvider(id).notifier); | ||||
|     final scrollController = useScrollController(); | ||||
|  | ||||
|     final listController = useMemoized(() => ListController(), []); | ||||
|  | ||||
|     var isLoading = false; | ||||
|  | ||||
|     // Add scroll listener for pagination | ||||
|     useEffect(() { | ||||
|       void onScroll() { | ||||
|         if (scrollController.position.pixels >= | ||||
|             scrollController.position.maxScrollExtent - 200) { | ||||
|           if (isLoading) return; | ||||
|           isLoading = true; | ||||
|           messagesNotifier.loadMore().then((_) => isLoading = false); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       scrollController.addListener(onScroll); | ||||
|       return () => scrollController.removeListener(onScroll); | ||||
|     }, [scrollController]); | ||||
|  | ||||
|     Widget chatMessageListWidget(List<LocalChatMessage> messageList) => | ||||
|         SuperListView.builder( | ||||
|           listController: listController, | ||||
|           padding: EdgeInsets.symmetric(vertical: 16), | ||||
|           controller: scrollController, | ||||
|           reverse: true, // Show newest messages at the bottom | ||||
|           itemCount: messageList.length, | ||||
|           findChildIndexCallback: (key) { | ||||
|             final valueKey = key as ValueKey; | ||||
|             final messageId = valueKey.value as String; | ||||
|             return messageList.indexWhere((m) => m.id == messageId); | ||||
|           }, | ||||
|           extentEstimation: (_, _) => 40, | ||||
|           itemBuilder: (context, index) { | ||||
|             final message = messageList[index]; | ||||
|             final nextMessage = | ||||
|                 index < messageList.length - 1 ? messageList[index + 1] : null; | ||||
|             final isLastInGroup = | ||||
|                 nextMessage == null || | ||||
|                 nextMessage.senderId != message.senderId || | ||||
|                 nextMessage.createdAt | ||||
|                         .difference(message.createdAt) | ||||
|                         .inMinutes | ||||
|                         .abs() > | ||||
|                     3; | ||||
|  | ||||
|             return MessageItem( | ||||
|               message: message, | ||||
|               isCurrentUser: false, // User is not a member, so not current user | ||||
|               onAction: null, // No actions allowed in preview mode | ||||
|               onJump: (_) {}, // No jump functionality in preview | ||||
|               progress: null, | ||||
|               showAvatar: isLastInGroup, | ||||
|             ); | ||||
|           }, | ||||
|         ); | ||||
|  | ||||
|     final compactHeader = isWideScreen(context); | ||||
|  | ||||
|     Widget comfortHeaderWidget() => Column( | ||||
|       spacing: 4, | ||||
|       mainAxisAlignment: MainAxisAlignment.center, | ||||
|       crossAxisAlignment: CrossAxisAlignment.center, | ||||
|       children: [ | ||||
|         SizedBox( | ||||
|           height: 26, | ||||
|           width: 26, | ||||
|           child: | ||||
|               (room.type == 1 && room.picture?.id == null) | ||||
|                   ? SplitAvatarWidget( | ||||
|                     filesId: | ||||
|                         room.members! | ||||
|                             .map((e) => e.account.profile.picture?.id) | ||||
|                             .toList(), | ||||
|                   ) | ||||
|                   : room.picture?.id != null | ||||
|                   ? ProfilePictureWidget( | ||||
|                     fileId: room.picture?.id, | ||||
|                     fallbackIcon: Symbols.chat, | ||||
|                   ) | ||||
|                   : CircleAvatar( | ||||
|                     child: Text( | ||||
|                       room.name![0].toUpperCase(), | ||||
|                       style: const TextStyle(fontSize: 12), | ||||
|                     ), | ||||
|                   ), | ||||
|         ), | ||||
|         Text( | ||||
|           (room.type == 1 && room.name == null) | ||||
|               ? room.members!.map((e) => e.account.nick).join(', ') | ||||
|               : room.name!, | ||||
|         ).fontSize(15), | ||||
|       ], | ||||
|     ); | ||||
|  | ||||
|     Widget compactHeaderWidget() => Row( | ||||
|       spacing: 8, | ||||
|       crossAxisAlignment: CrossAxisAlignment.center, | ||||
|       children: [ | ||||
|         SizedBox( | ||||
|           height: 26, | ||||
|           width: 26, | ||||
|           child: | ||||
|               (room.type == 1 && room.picture?.id == null) | ||||
|                   ? SplitAvatarWidget( | ||||
|                     filesId: | ||||
|                         room.members! | ||||
|                             .map((e) => e.account.profile.picture?.id) | ||||
|                             .toList(), | ||||
|                   ) | ||||
|                   : room.picture?.id != null | ||||
|                   ? ProfilePictureWidget( | ||||
|                     fileId: room.picture?.id, | ||||
|                     fallbackIcon: Symbols.chat, | ||||
|                   ) | ||||
|                   : CircleAvatar( | ||||
|                     child: Text( | ||||
|                       room.name![0].toUpperCase(), | ||||
|                       style: const TextStyle(fontSize: 12), | ||||
|                     ), | ||||
|                   ), | ||||
|         ), | ||||
|         Text( | ||||
|           (room.type == 1 && room.name == null) | ||||
|               ? room.members!.map((e) => e.account.nick).join(', ') | ||||
|               : room.name!, | ||||
|         ).fontSize(19), | ||||
|       ], | ||||
|     ); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar( | ||||
|         leading: !compactHeader ? const Center(child: PageBackButton()) : null, | ||||
|         automaticallyImplyLeading: false, | ||||
|         toolbarHeight: compactHeader ? null : 64, | ||||
|         title: compactHeader ? compactHeaderWidget() : comfortHeaderWidget(), | ||||
|         actions: [ | ||||
|           IconButton( | ||||
|             icon: const Icon(Icons.more_vert), | ||||
|             onPressed: () { | ||||
|               context.pushNamed('chatDetail', pathParameters: {'id': id}); | ||||
|             }, | ||||
|           ), | ||||
|           const Gap(8), | ||||
|         ], | ||||
|       ), | ||||
|       body: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           Expanded( | ||||
|             child: messages.when( | ||||
|               data: | ||||
|                   (messageList) => | ||||
|                       messageList.isEmpty | ||||
|                           ? Center(child: Text('No messages yet'.tr())) | ||||
|                           : chatMessageListWidget(messageList), | ||||
|               loading: () => const Center(child: CircularProgressIndicator()), | ||||
|               error: | ||||
|                   (error, _) => ResponseErrorWidget( | ||||
|                     error: error, | ||||
|                     onRetry: () => messagesNotifier.loadInitial(), | ||||
|                   ), | ||||
|             ), | ||||
|           ), | ||||
|           // Join button at the bottom for public rooms | ||||
|           Container( | ||||
|             padding: const EdgeInsets.all(16), | ||||
|             child: FilledButton.tonalIcon( | ||||
|               onPressed: () async { | ||||
|                 try { | ||||
|                   showLoadingModal(context); | ||||
|                   final apiClient = ref.read(apiClientProvider); | ||||
|                   await apiClient.post('/sphere/chat/${room.id}/members/me'); | ||||
|                   ref.invalidate(chatroomIdentityProvider(id)); | ||||
|                 } catch (err) { | ||||
|                   showErrorAlert(err); | ||||
|                 } finally { | ||||
|                   if (context.mounted) hideLoadingModal(context); | ||||
|                 } | ||||
|               }, | ||||
|               label: Text('chatJoin').tr(), | ||||
|               icon: const Icon(Icons.add), | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -228,7 +228,7 @@ class CheckInWidget extends HookConsumerWidget { | ||||
|           ), | ||||
|           Column( | ||||
|             mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|             spacing: 3, | ||||
|             crossAxisAlignment: CrossAxisAlignment.end, | ||||
|             children: [ | ||||
|               AnimatedSwitcher( | ||||
|                 duration: const Duration(milliseconds: 300), | ||||
| @@ -244,7 +244,7 @@ class CheckInWidget extends HookConsumerWidget { | ||||
|                   loading: () => Text('checkInNone').tr().fontSize(15).bold(), | ||||
|                   error: (err, stack) => Text('error').tr().fontSize(15).bold(), | ||||
|                 ), | ||||
|               ), | ||||
|               ).padding(right: 4), | ||||
|               IconButton.outlined( | ||||
|                 iconSize: 16, | ||||
|                 visualDensity: const VisualDensity( | ||||
|   | ||||
| @@ -9,6 +9,11 @@ String _parseRemoteError(DioException err) { | ||||
|   String? message; | ||||
|   if (err.response?.data is String) { | ||||
|     message = err.response?.data; | ||||
|   } else if (err.response?.data?['message'] != null) { | ||||
|     message = <String?>[ | ||||
|       err.response?.data?['message']?.toString(), | ||||
|       err.response?.data?['detail']?.toString(), | ||||
|     ].where((e) => e != null).cast<String>().map((e) => e.trim()).join('\n'); | ||||
|   } else if (err.response?.data?['errors'] != null) { | ||||
|     final errors = err.response?.data['errors'] as Map<String, dynamic>; | ||||
|     message = errors.values | ||||
|   | ||||
| @@ -8,17 +8,26 @@ import 'package:easy_localization/easy_localization.dart'; | ||||
|  | ||||
| String _parseRemoteError(DioException err) { | ||||
|   log('${err.requestOptions.method} ${err.requestOptions.uri} ${err.message}'); | ||||
|   if (err.response?.data is String) return err.response?.data; | ||||
|   if (err.response?.data?['errors'] != null) { | ||||
|   String? message; | ||||
|   if (err.response?.data is String) { | ||||
|     message = err.response?.data; | ||||
|   } else if (err.response?.data?['message'] != null) { | ||||
|     message = <String?>[ | ||||
|       err.response?.data?['message']?.toString(), | ||||
|       err.response?.data?['detail']?.toString(), | ||||
|     ].where((e) => e != null).cast<String>().map((e) => e.trim()).join('\n'); | ||||
|   } else if (err.response?.data?['errors'] != null) { | ||||
|     final errors = err.response?.data['errors'] as Map<String, dynamic>; | ||||
|     return errors.values | ||||
|     message = errors.values | ||||
|         .map( | ||||
|           (ele) => | ||||
|               (ele as List<dynamic>).map((ele) => ele.toString()).join('\n'), | ||||
|         ) | ||||
|         .join('\n'); | ||||
|   } | ||||
|   return err.message ?? err.toString(); | ||||
|   if (message == null || message.isEmpty) message = err.response?.statusMessage; | ||||
|   message ??= err.message; | ||||
|   return message ?? err.toString(); | ||||
| } | ||||
|  | ||||
| void showErrorAlert(dynamic err) async { | ||||
|   | ||||
| @@ -55,7 +55,7 @@ class CloudFilePicker extends HookConsumerWidget { | ||||
|           uploadPosition.value = idx; | ||||
|           final file = files.value[idx]; | ||||
|           final cloudFile = | ||||
|               await putMediaToCloud( | ||||
|               await putFileToCloud( | ||||
|                 fileData: file, | ||||
|                 atk: token, | ||||
|                 baseUrl: baseUrl, | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:mime/mime.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:file_picker/file_picker.dart'; | ||||
| @@ -19,6 +20,7 @@ import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/post/compose_link_attachments.dart'; | ||||
| import 'package:island/widgets/post/compose_poll.dart'; | ||||
| import 'package:island/widgets/post/compose_recorder.dart'; | ||||
| import 'package:island/pods/pool_provider.dart'; | ||||
| import 'package:pasteboard/pasteboard.dart'; | ||||
| import 'package:textfield_tags/textfield_tags.dart'; | ||||
| import 'dart:async'; | ||||
| @@ -183,7 +185,7 @@ class ComposeLogic { | ||||
|         if (attachment.data is! SnCloudFile) { | ||||
|           try { | ||||
|             final cloudFile = | ||||
|                 await putMediaToCloud( | ||||
|                 await putFileToCloud( | ||||
|                   fileData: attachment, | ||||
|                   atk: token, | ||||
|                   baseUrl: baseUrl, | ||||
| @@ -386,6 +388,30 @@ class ComposeLogic { | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   static Future<void> pickGeneralFile(WidgetRef ref, ComposeState state) async { | ||||
|     final result = await FilePicker.platform.pickFiles( | ||||
|       type: FileType.any, | ||||
|       allowMultiple: true, | ||||
|     ); | ||||
|     if (result == null || result.count == 0) return; | ||||
|  | ||||
|     final newFiles = <UniversalFile>[]; | ||||
|  | ||||
|     for (final f in result.files) { | ||||
|       if (f.path == null) continue; | ||||
|  | ||||
|       final mimeType = | ||||
|           lookupMimeType(f.path!, headerBytes: f.bytes) ?? | ||||
|           'application/octet-stream'; | ||||
|       final xfile = XFile(f.path!, name: f.name, mimeType: mimeType); | ||||
|  | ||||
|       final uf = UniversalFile(data: xfile, type: UniversalFileType.file); | ||||
|       newFiles.add(uf); | ||||
|     } | ||||
|  | ||||
|     state.attachments.value = [...state.attachments.value, ...newFiles]; | ||||
|   } | ||||
|  | ||||
|   static Future<void> pickPhotoMedia(WidgetRef ref, ComposeState state) async { | ||||
|     final result = await FilePicker.platform.pickFiles( | ||||
|       type: FileType.image, | ||||
| @@ -479,8 +505,9 @@ class ComposeLogic { | ||||
|   static Future<void> uploadAttachment( | ||||
|     WidgetRef ref, | ||||
|     ComposeState state, | ||||
|     int index, | ||||
|   ) async { | ||||
|     int index, { | ||||
|     String? poolId, // For Unit Test | ||||
|   }) async { | ||||
|     final attachment = state.attachments.value[index]; | ||||
|     if (attachment.isOnCloud) return; | ||||
|  | ||||
| @@ -489,22 +516,34 @@ class ComposeLogic { | ||||
|     if (token == null) throw ArgumentError('Token is null'); | ||||
|  | ||||
|     try { | ||||
|       // Update progress state | ||||
|       state.attachmentProgress.value = { | ||||
|         ...state.attachmentProgress.value, | ||||
|         index: 0, | ||||
|       }; | ||||
|  | ||||
|       // Upload file to cloud | ||||
|       final cloudFile = | ||||
|           await putMediaToCloud( | ||||
|       SnCloudFile? cloudFile; | ||||
|  | ||||
|       final pools = await ref.read(poolsProvider.future); | ||||
|       final selectedPoolId = resolveDefaultPoolId(ref, pools); | ||||
|  | ||||
|       cloudFile = | ||||
|           await putFileToCloud( | ||||
|             fileData: attachment, | ||||
|             atk: token, | ||||
|             baseUrl: baseUrl, | ||||
|             filename: attachment.data.name ?? 'Post media', | ||||
|             poolId: selectedPoolId, | ||||
|             filename: | ||||
|                 attachment.data.name ?? | ||||
|                 (attachment.type == UniversalFileType.file | ||||
|                     ? 'General file' | ||||
|                     : 'Post media'), | ||||
|             mimetype: | ||||
|                 attachment.data.mimeType ?? | ||||
|                 getMimeTypeFromFileType(attachment.type), | ||||
|             mode: | ||||
|                 attachment.type == UniversalFileType.file | ||||
|                     ? FileUploadMode.generic | ||||
|                     : FileUploadMode.mediaSafe, | ||||
|             onProgress: (progress, _) { | ||||
|               state.attachmentProgress.value = { | ||||
|                 ...state.attachmentProgress.value, | ||||
| @@ -517,14 +556,12 @@ class ComposeLogic { | ||||
|         throw ArgumentError('Failed to upload the file...'); | ||||
|       } | ||||
|  | ||||
|       // Update attachments list with cloud file | ||||
|       final clone = List.of(state.attachments.value); | ||||
|       clone[index] = UniversalFile(data: cloudFile, type: attachment.type); | ||||
|       state.attachments.value = clone; | ||||
|     } catch (err) { | ||||
|       showErrorAlert(err); | ||||
|       showErrorAlert(err.toString()); | ||||
|     } finally { | ||||
|       // Clean up progress state | ||||
|       state.attachmentProgress.value = {...state.attachmentProgress.value} | ||||
|         ..remove(index); | ||||
|     } | ||||
| @@ -643,7 +680,6 @@ class ComposeLogic { | ||||
|             .where((entry) => entry.value.isOnDevice) | ||||
|             .map((entry) => uploadAttachment(ref, state, entry.key)), | ||||
|       ); | ||||
|  | ||||
|       // Prepare API request | ||||
|       final client = ref.watch(apiClientProvider); | ||||
|       final isNewPost = originalPost == null; | ||||
|   | ||||
| @@ -25,6 +25,10 @@ class ComposeToolbar extends HookConsumerWidget { | ||||
|       ComposeLogic.pickVideoMedia(ref, state); | ||||
|     } | ||||
|  | ||||
|     void pickGeneralFile() { | ||||
|       ComposeLogic.pickGeneralFile(ref, state); | ||||
|     } | ||||
|  | ||||
|     void addAudio() { | ||||
|       ComposeLogic.recordAudioMedia(ref, state, context); | ||||
|     } | ||||
| @@ -96,6 +100,12 @@ class ComposeToolbar extends HookConsumerWidget { | ||||
|                 icon: const Icon(Symbols.mic), | ||||
|                 color: colorScheme.primary, | ||||
|               ), | ||||
|               IconButton( | ||||
|                 onPressed: pickGeneralFile, | ||||
|                 tooltip: 'uploadFile'.tr(), | ||||
|                 icon: const Icon(Symbols.file_upload), | ||||
|                 color: colorScheme.primary, | ||||
|               ), | ||||
|               IconButton( | ||||
|                 onPressed: linkAttachment, | ||||
|                 icon: const Icon(Symbols.attach_file), | ||||
|   | ||||
| @@ -7,7 +7,7 @@ part of 'post_award_history_sheet.dart'; | ||||
| // ************************************************************************** | ||||
|  | ||||
| String _$postAwardListNotifierHash() => | ||||
|     r'492ae59a5dbbfb5c98f863f036023193b6e08668'; | ||||
|     r'834d08f90ef352a2dfb0192455c75b1620e859c2'; | ||||
|  | ||||
| /// Copied from Dart SDK | ||||
| class _SystemHash { | ||||
|   | ||||
| @@ -247,7 +247,7 @@ class _ShareSheetState extends ConsumerState<ShareSheet> { | ||||
|             for (var idx = 0; idx < universalFiles.length; idx++) { | ||||
|               final file = universalFiles[idx]; | ||||
|               final cloudFile = | ||||
|                   await putMediaToCloud( | ||||
|                   await putFileToCloud( | ||||
|                     fileData: file, | ||||
|                     atk: token, | ||||
|                     baseUrl: serverUrl, | ||||
|   | ||||
| @@ -111,9 +111,9 @@ PODS: | ||||
|   - flutter_udid (0.0.1): | ||||
|     - FlutterMacOS | ||||
|     - SAMKeychain | ||||
|   - flutter_webrtc (1.1.0): | ||||
|   - flutter_webrtc (1.2.0): | ||||
|     - FlutterMacOS | ||||
|     - WebRTC-SDK (= 137.7151.03) | ||||
|     - WebRTC-SDK (= 137.7151.04) | ||||
|   - FlutterMacOS (1.0.0) | ||||
|   - gal (1.0.0): | ||||
|     - Flutter | ||||
| @@ -175,7 +175,7 @@ PODS: | ||||
|   - livekit_client (2.5.0): | ||||
|     - flutter_webrtc | ||||
|     - FlutterMacOS | ||||
|     - WebRTC-SDK (= 137.7151.03) | ||||
|     - WebRTC-SDK (= 137.7151.04) | ||||
|   - local_auth_darwin (0.0.1): | ||||
|     - Flutter | ||||
|     - FlutterMacOS | ||||
| @@ -247,7 +247,7 @@ PODS: | ||||
|     - FlutterMacOS | ||||
|   - wakelock_plus (0.0.1): | ||||
|     - FlutterMacOS | ||||
|   - WebRTC-SDK (137.7151.03) | ||||
|   - WebRTC-SDK (137.7151.04) | ||||
|  | ||||
| DEPENDENCIES: | ||||
|   - bitsdojo_window_macos (from `Flutter/ephemeral/.symlinks/plugins/bitsdojo_window_macos/macos`) | ||||
| @@ -419,17 +419,17 @@ SPEC CHECKSUMS: | ||||
|   flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0 | ||||
|   flutter_platform_alert: 8fa7a7c21f95b26d08b4a3891936ca27e375f284 | ||||
|   flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54 | ||||
|   flutter_timezone: d59eea86178cbd7943cd2431cc2eaa9850f935d8 | ||||
|   flutter_timezone: d272288c69082ad571630e0d17140b3d6b93dc0c | ||||
|   flutter_udid: d26e455e8c06174e6aff476e147defc6cae38495 | ||||
|   flutter_webrtc: 1ce7fe9a42f085286378355a575e682edd7f114d | ||||
|   flutter_webrtc: 718eae22a371cd94e5d56aa4f301443ebc5bb737 | ||||
|   FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 | ||||
|   gal: baecd024ebfd13c441269ca7404792a7152fde89 | ||||
|   GoogleAppMeasurement: 09f341dfa8527d1612a09cbfe809a242c0b737af | ||||
|   GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 | ||||
|   GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 | ||||
|   irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba | ||||
|   livekit_client: 5a5c0f1081978542bbf9a986c7ac9bffcdb73906 | ||||
|   local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19 | ||||
|   livekit_client: 95b4a47f51f98a8be3a181c3fa251be7823dddd4 | ||||
|   local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb | ||||
|   media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65 | ||||
|   media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758 | ||||
|   nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 | ||||
| @@ -451,8 +451,8 @@ SPEC CHECKSUMS: | ||||
|   tray_manager: a104b5c81b578d83f3c3d0f40a997c8b10810166 | ||||
|   url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 | ||||
|   volume_controller: 5c068e6d085c80dadd33fc2c918d2114b775b3dd | ||||
|   wakelock_plus: 21ddc249ac4b8d018838dbdabd65c5976c308497 | ||||
|   WebRTC-SDK: 69d4e56b0b4b27d788e87bab9b9a1326ed05b1e3 | ||||
|   wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b | ||||
|   WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e | ||||
|  | ||||
| PODFILE CHECKSUM: 346bfb2deb41d4a6ebd6f6799f92188bde2d246f | ||||
|  | ||||
|   | ||||
| @@ -6,8 +6,6 @@ | ||||
| 	<array> | ||||
| 		<string>Default</string> | ||||
| 	</array> | ||||
| 	<key>com.apple.developer.device-information.user-assigned-device-name</key> | ||||
| 	<true/> | ||||
| 	<key>com.apple.security.app-sandbox</key> | ||||
| 	<true/> | ||||
| 	<key>com.apple.security.cs.allow-jit</key> | ||||
|   | ||||
| @@ -6,8 +6,6 @@ | ||||
| 	<array> | ||||
| 		<string>Default</string> | ||||
| 	</array> | ||||
| 	<key>com.apple.developer.device-information.user-assigned-device-name</key> | ||||
| 	<true/> | ||||
| 	<key>com.apple.security.app-sandbox</key> | ||||
| 	<true/> | ||||
| 	<key>com.apple.security.device.audio-input</key> | ||||
|   | ||||
							
								
								
									
										140
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										140
									
								
								pubspec.lock
									
									
									
									
									
								
							| @@ -285,10 +285,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: code_builder | ||||
|       sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" | ||||
|       sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "4.10.1" | ||||
|     version: "4.11.0" | ||||
|   collection: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -333,10 +333,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: croppy | ||||
|       sha256: "2a69059d9ec007b79d6a494854094b2e3c0a4f7ed609cf55a4805c9de9ec171d" | ||||
|       sha256: ca70a77cd5a981172d69382a7b43629d15d6c868475b2bbb45efce32cfc58b86 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.3.6" | ||||
|     version: "1.4.0" | ||||
|   cross_file: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -401,6 +401,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.0.0+7.7.0" | ||||
|   dart_ipc: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: dart_ipc | ||||
|       sha256: "6cad558cda5304017c1f581df4c96fd4f8e4ee212aae7bfa4357716236faa9ba" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.0.1" | ||||
|   dart_style: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -413,10 +421,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: dart_webrtc | ||||
|       sha256: "3bfa069a8b14a53ba506f6dd529e9b88c878ba0cc238f311051a39bf1e53d075" | ||||
|       sha256: "51bcda4ba5d7dd9e65a309244ce3ac0b58025e6e1f6d7442cee4cd02134ef65f" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.5.3+hotfix.5" | ||||
|     version: "1.6.0" | ||||
|   dbus: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -441,6 +449,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "7.0.3" | ||||
|   diff_match_patch: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: diff_match_patch | ||||
|       sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.4.1" | ||||
|   dio: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -546,7 +562,7 @@ packages: | ||||
|     source: hosted | ||||
|     version: "1.3.3" | ||||
|   ffi: | ||||
|     dependency: transitive | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: ffi | ||||
|       sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" | ||||
| @@ -565,10 +581,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: file_picker | ||||
|       sha256: e7e16c9d15c36330b94ca0e2ad8cb61f93cd5282d0158c09805aed13b5452f22 | ||||
|       sha256: f2d9f173c2c14635cc0e9b14c143c49ef30b4934e8d1d274d6206fcb0086a06f | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "10.3.2" | ||||
|     version: "10.3.3" | ||||
|   file_saver: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -709,10 +725,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: fl_chart | ||||
|       sha256: d3f82f4a38e33ba23d05a08ff304d7d8b22d2a59a5503f20bd802966e915db89 | ||||
|       sha256: "7ca9a40f4eb85949190e54087be8b4d6ac09dc4c54238d782a34cf1f7c011de9" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.1.0" | ||||
|     version: "1.1.1" | ||||
|   flutter: | ||||
|     dependency: "direct main" | ||||
|     description: flutter | ||||
| @@ -906,10 +922,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: flutter_local_notifications | ||||
|       sha256: a9966c850de5e445331b854fa42df96a8020066d67f125a5964cbc6556643f68 | ||||
|       sha256: "7ed76be64e8a7d01dfdf250b8434618e2a028c9dfa2a3c41dc9b531d4b3fc8a5" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "19.4.1" | ||||
|     version: "19.4.2" | ||||
|   flutter_local_notifications_linux: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -930,10 +946,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: flutter_local_notifications_windows | ||||
|       sha256: ed46d7ae4ec9d19e4c8fa2badac5fe27ba87a3fe387343ce726f927af074ec98 | ||||
|       sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.0.2" | ||||
|     version: "1.0.3" | ||||
|   flutter_localizations: | ||||
|     dependency: transitive | ||||
|     description: flutter | ||||
| @@ -1076,10 +1092,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: flutter_timezone | ||||
|       sha256: "13b2109ad75651faced4831bf262e32559e44aa549426eab8a597610d385d934" | ||||
|       sha256: ccad42fbb5d01d51d3eb281cc4428fca556cc4063c52bd9fa40f80cd93b8e649 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "4.1.1" | ||||
|     version: "5.0.0" | ||||
|   flutter_typeahead: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -1105,10 +1121,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: flutter_webrtc | ||||
|       sha256: "945d0a38b90fbca8257eadb167d8fb9fa7075d9a1939fd2953c10054454d1de2" | ||||
|       sha256: "16ca9e30d428bae3dd32933e875c9f67c5843d1fa726c37cf1fc479eb9294549" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.1.0" | ||||
|     version: "1.2.0" | ||||
|   font_awesome_flutter: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1177,10 +1193,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: go_router | ||||
|       sha256: eb059dfe59f08546e9787f895bd01652076f996bcbf485a8609ef990419ad227 | ||||
|       sha256: b1488741c9ce37b72e026377c69a59c47378493156fc38efb5a54f6def3f92a3 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "16.2.1" | ||||
|     version: "16.2.2" | ||||
|   google_fonts: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -1262,7 +1278,7 @@ packages: | ||||
|     source: hosted | ||||
|     version: "4.1.2" | ||||
|   image: | ||||
|     dependency: transitive | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: image | ||||
|       sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" | ||||
| @@ -1281,10 +1297,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: image_picker_android | ||||
|       sha256: "28f3987ca0ec702d346eae1d90eda59603a2101b52f1e234ded62cff1d5cfa6e" | ||||
|       sha256: "8dfe08ea7fcf7467dbaf6889e72eebd5e0d6711caae201fdac780eb45232cd02" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.8.13+1" | ||||
|     version: "0.8.13+3" | ||||
|   image_picker_for_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1393,10 +1409,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: leak_tracker | ||||
|       sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" | ||||
|       sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "11.0.1" | ||||
|     version: "11.0.2" | ||||
|   leak_tracker_flutter_testing: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1433,10 +1449,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: livekit_client | ||||
|       sha256: "011affc0fca22b2f9b0e8827219dad9948f84f2bf057980693de13039de904c7" | ||||
|       sha256: "4c1663c1e6ac20a743d9a46c7bc71f17e1949db99d245750c68661d554e30cd2" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.5.0+hotfix.3" | ||||
|     version: "2.5.1" | ||||
|   local_auth: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -1449,18 +1465,18 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: local_auth_android | ||||
|       sha256: "48924f4a8b3cc45994ad5993e2e232d3b00788a305c1bf1c7db32cef281ce9a3" | ||||
|       sha256: "1ee0e63fb8b5c6fa286796b5fb1570d256857c2f4a262127e728b36b80a570cf" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.0.52" | ||||
|     version: "1.0.53" | ||||
|   local_auth_darwin: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: local_auth_darwin | ||||
|       sha256: "0e9706a8543a4a2eee60346294d6a633dd7c3ee60fae6b752570457c4ff32055" | ||||
|       sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.6.0" | ||||
|     version: "1.6.1" | ||||
|   local_auth_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1537,10 +1553,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: material_symbols_icons | ||||
|       sha256: "2cfd19bf1c3016b0de7298eb3d3444fcb6ef093d934deb870ceb946af89cfa58" | ||||
|       sha256: "9e2042673fda5dda0b77e262220b3c34cac5806a3833da85522e41bb27fbf6c0" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "4.2872.0" | ||||
|     version: "4.2873.0" | ||||
|   media_kit: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -1697,10 +1713,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: package_info_plus | ||||
|       sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" | ||||
|       sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "8.3.1" | ||||
|     version: "9.0.0" | ||||
|   package_info_plus_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1709,14 +1725,6 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.2.1" | ||||
|   palette_generator: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: palette_generator | ||||
|       sha256: "4420f7ccc3f0a4a906144e73f8b6267cd940b64f57a7262e95cb8cec3a8ae0ed" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.3.3+7" | ||||
|   pasteboard: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -1873,10 +1881,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: pool | ||||
|       sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" | ||||
|       sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.5.1" | ||||
|     version: "1.5.2" | ||||
|   posix: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1885,6 +1893,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "6.0.3" | ||||
|   pretty_diff_text: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: pretty_diff_text | ||||
|       sha256: "2d4decf2bb6dac14c4e7a2cbd2da9ae7e004ccadc1155c723a0ca20a5daa7c57" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.1.0" | ||||
|   process_run: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -1969,10 +1985,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: record_android | ||||
|       sha256: "8361a791c9a3fa5c065f0b8b5adb10f12531f8538c86b19474cf7b56ea80d426" | ||||
|       sha256: "854627cd78d8d66190377f98477eee06ca96ab7c9f2e662700daf33dbf7e6673" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.4.1" | ||||
|     version: "1.4.2" | ||||
|   record_ios: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -2137,10 +2153,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: share_plus | ||||
|       sha256: d7dc0630a923883c6328ca31b89aa682bacbf2f8304162d29f7c6aaff03a27a1 | ||||
|       sha256: "3424e9d5c22fd7f7590254ba09465febd6f8827c8b19a44350de4ac31d92d3a6" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "11.1.0" | ||||
|     version: "12.0.0" | ||||
|   share_plus_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -2576,10 +2592,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: url_launcher_android | ||||
|       sha256: "69ee86740f2847b9a4ba6cffa74ed12ce500bbe2b07f3dc1e643439da60637b7" | ||||
|       sha256: "199bc33e746088546a39cc5f36bac5a278c5e53b40cb3196f99e7345fdcfae6b" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "6.3.18" | ||||
|     version: "6.3.22" | ||||
|   url_launcher_ios: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -2704,18 +2720,18 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: wakelock_plus | ||||
|       sha256: a474e314c3e8fb5adef1f9ae2d247e57467ad557fa7483a2b895bc1b421c5678 | ||||
|       sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.3.2" | ||||
|     version: "1.4.0" | ||||
|   wakelock_plus_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: wakelock_plus_platform_interface | ||||
|       sha256: e10444072e50dbc4999d7316fd303f7ea53d31c824aa5eb05d7ccbdd98985207 | ||||
|       sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.2.3" | ||||
|     version: "1.3.0" | ||||
|   watcher: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -2765,7 +2781,7 @@ packages: | ||||
|     source: hosted | ||||
|     version: "1.3.0" | ||||
|   win32: | ||||
|     dependency: transitive | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: win32 | ||||
|       sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" | ||||
| @@ -2780,6 +2796,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.1.0" | ||||
|   windows_notification: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: windows_notification | ||||
|       sha256: be3e650874615f315402c9b9f3656e29af156709c4b5cc272cb4ca0ab7ba94a8 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.3.0" | ||||
|   xdg_directories: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -2806,4 +2830,4 @@ packages: | ||||
|     version: "3.1.3" | ||||
| sdks: | ||||
|   dart: ">=3.9.0 <4.0.0" | ||||
|   flutter: ">=3.32.0" | ||||
|   flutter: ">=3.35.0" | ||||
|   | ||||
							
								
								
									
										34
									
								
								pubspec.yaml
									
									
									
									
									
								
							
							
						
						
									
										34
									
								
								pubspec.yaml
									
									
									
									
									
								
							| @@ -39,7 +39,7 @@ dependencies: | ||||
|   flutter_hooks: ^0.21.3+1 | ||||
|   hooks_riverpod: ^2.6.1 | ||||
|   bitsdojo_window: ^0.1.6 | ||||
|   go_router: ^16.2.1 | ||||
|   go_router: ^16.2.2 | ||||
|   styled_widget: ^0.4.1 | ||||
|   shared_preferences: ^2.5.3 | ||||
|   flutter_riverpod: ^2.6.1 | ||||
| @@ -67,29 +67,29 @@ dependencies: | ||||
|   easy_localization: ^3.0.8 | ||||
|   flutter_inappwebview: ^6.1.5 | ||||
|   animations: ^2.0.11 | ||||
|   package_info_plus: ^8.3.1 | ||||
|   package_info_plus: ^9.0.0 | ||||
|   device_info_plus: ^11.5.0 | ||||
|   tus_client_dart: | ||||
|     git: https://github.com/LittleSheep2Code/tus_client.git | ||||
|   cross_file: ^0.3.4+2 | ||||
|   image_picker: ^1.2.0 | ||||
|   file_picker: ^10.3.2 | ||||
|   file_picker: ^10.3.3 | ||||
|   riverpod_annotation: ^2.6.1 | ||||
|   image_picker_platform_interface: ^2.11.0 | ||||
|   image_picker_android: ^0.8.13+1 | ||||
|   image_picker_android: ^0.8.13+3 | ||||
|   super_context_menu: ^0.9.1 | ||||
|   modal_bottom_sheet: ^3.0.0 | ||||
|   firebase_messaging: ^16.0.1 | ||||
|   flutter_udid: ^4.0.0 | ||||
|   firebase_core: ^4.1.0 | ||||
|   web_socket_channel: ^3.0.3 | ||||
|   material_symbols_icons: ^4.2872.0 | ||||
|   material_symbols_icons: ^4.2873.0 | ||||
|   drift: ^2.28.1 | ||||
|   drift_flutter: ^0.2.6 | ||||
|   path: ^1.9.1 | ||||
|   collection: ^1.19.1 | ||||
|   markdown_editor_plus: ^0.2.15 | ||||
|   croppy: ^1.3.6 | ||||
|   croppy: ^1.4.0 | ||||
|   table_calendar: ^3.2.0 | ||||
|   relative_time: ^5.0.0 | ||||
|   dropdown_button2: ^2.3.9 | ||||
| @@ -103,24 +103,25 @@ dependencies: | ||||
|   gal: ^2.3.2 | ||||
|   dismissible_page: ^1.0.2 | ||||
|   super_sliver_list: ^0.4.1 | ||||
|   livekit_client: ^2.5.0+hotfix.3 | ||||
|   livekit_client: ^2.5.1 | ||||
|   pasteboard: ^0.4.0 | ||||
|   flutter_colorpicker: ^1.1.0 | ||||
|   image: ^4.5.4 | ||||
|   record: ^6.1.1 | ||||
|   qr_flutter: ^4.1.0 | ||||
|   flutter_otp_text_field: ^1.5.1+1 | ||||
|   palette_generator: ^0.3.3+7 | ||||
|  | ||||
|   flutter_popup_card: ^0.0.6 | ||||
|   timezone: ^0.10.1 | ||||
|   flutter_timezone: ^4.1.1 | ||||
|   fl_chart: ^1.1.0 | ||||
|   flutter_timezone: ^5.0.0 | ||||
|   fl_chart: ^1.1.1 | ||||
|   sign_in_with_apple: ^7.0.1 | ||||
|   flutter_svg: ^2.2.1 | ||||
|   native_exif: ^0.6.2 | ||||
|   local_auth: ^2.3.0 | ||||
|   flutter_secure_storage: ^9.2.4 | ||||
|   flutter_math_fork: ^0.7.4 | ||||
|   share_plus: ^11.1.0 | ||||
|   share_plus: ^12.0.0 | ||||
|   receive_sharing_intent: ^1.8.1 | ||||
|   top_snackbar_flutter: ^3.3.0 | ||||
|   textfield_tags: | ||||
| @@ -141,12 +142,17 @@ dependencies: | ||||
|   flutter_card_swiper: ^7.0.2 | ||||
|   file_saver: ^0.3.1 | ||||
|   tray_manager: ^0.5.1 | ||||
|   flutter_webrtc: ^1.1.0 | ||||
|   flutter_local_notifications: ^19.4.1 | ||||
|   wakelock_plus: ^1.3.2 | ||||
|   flutter_webrtc: ^1.2.0 | ||||
|   flutter_local_notifications: ^19.4.2 | ||||
|   wakelock_plus: ^1.4.0 | ||||
|   slide_countdown: ^2.0.2 | ||||
|   shelf: ^1.4.2 | ||||
|   shelf_web_socket: ^3.0.0 | ||||
|   windows_notification: ^1.3.0 | ||||
|   win32: ^5.14.0 | ||||
|   ffi: ^2.1.4 | ||||
|   dart_ipc: ^1.0.1 | ||||
|   pretty_diff_text: ^2.1.0 | ||||
|  | ||||
| dev_dependencies: | ||||
|   flutter_test: | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| ; ================================================== | ||||
| #define AppVersion "3.2.0" | ||||
| #define BuildNumber "124" | ||||
| #define BuildNumber "132" | ||||
| ; ================================================== | ||||
|  | ||||
| #define FullVersion AppVersion + "." + BuildNumber | ||||
|   | ||||
| @@ -8,6 +8,7 @@ | ||||
|  | ||||
| #include <bitsdojo_window_windows/bitsdojo_window_plugin.h> | ||||
| #include <connectivity_plus/connectivity_plus_windows_plugin.h> | ||||
| #include <dart_ipc/dart_ipc_plugin_c_api.h> | ||||
| #include <file_saver/file_saver_plugin.h> | ||||
| #include <file_selector_windows/file_selector_windows.h> | ||||
| #include <firebase_core/firebase_core_plugin_c_api.h> | ||||
| @@ -31,12 +32,15 @@ | ||||
| #include <tray_manager/tray_manager_plugin.h> | ||||
| #include <url_launcher_windows/url_launcher_windows.h> | ||||
| #include <volume_controller/volume_controller_plugin_c_api.h> | ||||
| #include <windows_notification/windows_notification_plugin_c_api.h> | ||||
|  | ||||
| void RegisterPlugins(flutter::PluginRegistry* registry) { | ||||
|   BitsdojoWindowPluginRegisterWithRegistrar( | ||||
|       registry->GetRegistrarForPlugin("BitsdojoWindowPlugin")); | ||||
|   ConnectivityPlusWindowsPluginRegisterWithRegistrar( | ||||
|       registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); | ||||
|   DartIpcPluginCApiRegisterWithRegistrar( | ||||
|       registry->GetRegistrarForPlugin("DartIpcPluginCApi")); | ||||
|   FileSaverPluginRegisterWithRegistrar( | ||||
|       registry->GetRegistrarForPlugin("FileSaverPlugin")); | ||||
|   FileSelectorWindowsRegisterWithRegistrar( | ||||
| @@ -83,4 +87,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { | ||||
|       registry->GetRegistrarForPlugin("UrlLauncherWindows")); | ||||
|   VolumeControllerPluginCApiRegisterWithRegistrar( | ||||
|       registry->GetRegistrarForPlugin("VolumeControllerPluginCApi")); | ||||
|   WindowsNotificationPluginCApiRegisterWithRegistrar( | ||||
|       registry->GetRegistrarForPlugin("WindowsNotificationPluginCApi")); | ||||
| } | ||||
|   | ||||
| @@ -5,6 +5,7 @@ | ||||
| list(APPEND FLUTTER_PLUGIN_LIST | ||||
|   bitsdojo_window_windows | ||||
|   connectivity_plus | ||||
|   dart_ipc | ||||
|   file_saver | ||||
|   file_selector_windows | ||||
|   firebase_core | ||||
| @@ -28,6 +29,7 @@ list(APPEND FLUTTER_PLUGIN_LIST | ||||
|   tray_manager | ||||
|   url_launcher_windows | ||||
|   volume_controller | ||||
|   windows_notification | ||||
| ) | ||||
|  | ||||
| list(APPEND FLUTTER_FFI_PLUGIN_LIST | ||||
|   | ||||
		Reference in New Issue
	
	Block a user