Compare commits
	
		
			50 Commits
		
	
	
		
			a243cda1df
			...
			3.2.0+134
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 28b6eade48 | |||
| 1de7ef8c96 | |||
| 67eac5dcf5 | |||
| 7a44bfa075 | |||
| 1c2f25a152 | |||
| be26ea280e | |||
| b4996d069f | |||
| bf4892b34d | |||
| 5f84751fd5 | |||
| 457d1bac60 | |||
| 02ec11845b | |||
| 612f1bf004 | |||
| fd80b713ad | |||
| 508805368c | |||
| 98eb28a4ec | |||
| d1a2f59dd1 | |||
| bb9adb963a | |||
| 83e40cd860 | |||
| c06fb12f6a | |||
| 6600cf4df8 | |||
| 4293daaa2f | |||
| 866674ddde | |||
| 27d478ba4f | |||
| cccade763f | |||
| f760b85186 | |||
| e68c5f4f92 | |||
| b0f3b6b5c3 | |||
| cb2af379fa | |||
| 38f8103265 | |||
| 06bb18bdaa | |||
| 84c38500d0 | |||
| 9529bbf08b | |||
| 8baf77bcf7 | |||
| b2ac5fbef2 | |||
| c79b1d7aab | |||
|  | 4f55a8209c | ||
|  | ace302111a | ||
|  | 1391fa0dde | ||
|  | cbdc7acdcd | ||
|  | b80d91825a | ||
|  | 1a703b7eba | ||
|  | 3621ea7744 | ||
|  | b638343f02 | ||
|  | 269a64cabb | ||
| 406e5187a8 | |||
| 9bdd08d8dd | |||
| d737232dcf | |||
| c9d751479e | |||
| a2c2bfe585 | |||
| c7f9da0dee | 
| @@ -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", | ||||
| @@ -261,14 +264,14 @@ | ||||
|   "createStickerPack": "Create a Sticker Pack", | ||||
|   "editStickerPack": "Edit Sticker Pack", | ||||
|   "deleteStickerPack": "Delete Sticker Pack", | ||||
|   "deleteStickerPackHint": "Are you sure to delete this sticker pack? This action cannot be undone.", | ||||
|   "deleteStickerPackHint": "Are you sure you want to delete this sticker pack? This action cannot be undone.", | ||||
|   "stickerPackPrefix": "Prefix", | ||||
|   "stickerPackPrefixHint": "The prefix will be added before each stickers' slug in this pack.", | ||||
|   "stickers": "Stickers", | ||||
|   "createSticker": "Create a Sticker", | ||||
|   "editSticker": "Edit Sticker", | ||||
|   "deleteSticker": "Delete Sticker", | ||||
|   "deleteStickerHint": "Are you sure to delete this sticker? This action cannot be undone.", | ||||
|   "deleteStickerHint": "Are you sure you want to delete this sticker? This action cannot be undone.", | ||||
|   "stickerImage": "Image", | ||||
|   "stickerSlug": "Slug", | ||||
|   "stickerSlugHint": "The slug will be combined with the prefix to form the sticker's unique identifier.", | ||||
| @@ -333,13 +336,25 @@ | ||||
|   "levelingProgress": "Leveling Progress", | ||||
|   "levelingProgressExperience": "{} EXP", | ||||
|   "levelingProgressLevel": "Level {}", | ||||
|   "levelingStage1": "Novice", | ||||
|   "levelingStage2": "Apprentice", | ||||
|   "levelingStage3": "Journeyman", | ||||
|   "levelingStage4": "Adept", | ||||
|   "levelingStage5": "Expert", | ||||
|   "levelingStage6": "Master", | ||||
|   "levelingStage7": "Grandmaster", | ||||
|   "levelingStage8": "Legend", | ||||
|   "levelingStage9": "Myth", | ||||
|   "levelingStage10": "Immortal", | ||||
|   "levelingStage11": "Divine", | ||||
|   "levelingStage12": "Transcendent", | ||||
|   "fileUploadingProgress": "Uploading file #{}: {}%", | ||||
|   "removeChatMember": "Remove Chat Room Member", | ||||
|   "removeChatMemberHint": "Are you sure to remove this member from the room?", | ||||
|   "removeChatMemberHint": "Are you sure you want to remove this member from the room?", | ||||
|   "removeRealmMember": "Remove Realm Member", | ||||
|   "removeRealmMemberHint": "Are you sure to remove this member from the realm?", | ||||
|   "removeRealmMemberHint": "Are you sure you want to remove this member from the realm?", | ||||
|   "removePublisherMember": "Remove Publisher Member", | ||||
|   "removePublisherMemberHint": "Are you sure to remove this member from the publisher?", | ||||
|   "removePublisherMemberHint": "Are you sure you want to remove this member from the publisher?", | ||||
|   "memberRole": "Member Role", | ||||
|   "memberRoleHint": "Greater number has higher permission.", | ||||
|   "memberRoleEdit": "Edit role for @{}", | ||||
| @@ -348,9 +363,9 @@ | ||||
|   "brokenLink": "Unable open link {}... It might be broken or missing uri parts...", | ||||
|   "copyToClipboard": "Copy to clipboard", | ||||
|   "leaveChatRoom": "Leave Chat Room", | ||||
|   "leaveChatRoomHint": "Are you sure to leave this chat room?", | ||||
|   "leaveChatRoomHint": "Are you sure you want to leave this chat room?", | ||||
|   "leaveRealm": "Leave Realm", | ||||
|   "leaveRealmHint": "Are you sure to leave this realm?", | ||||
|   "leaveRealmHint": "Are you sure you want to leave this realm?", | ||||
|   "walletNotFound": "Wallet not found", | ||||
|   "walletCreateHint": "You don't have a wallet yet. Create one to start using the Solar Network eWallet.", | ||||
|   "walletCreate": "Create a Wallet", | ||||
| @@ -447,11 +462,13 @@ | ||||
|   "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..." | ||||
|   }, | ||||
|   "authDeviceEditLabel": "Edit Label", | ||||
|   "authDeviceEditLabel": "Edit Device Label", | ||||
|   "authDeviceLabelTitle": "Edit Device Label", | ||||
|   "authDeviceLabelHint": "Enter a name for this device", | ||||
|   "authDeviceSwipeEditHint": "Swipe left to edit label", | ||||
| @@ -468,6 +485,7 @@ | ||||
|   "settingsKeyboardShortcutSettings": "Settings", | ||||
|   "settingsKeyboardShortcutNewMessage": "New Message", | ||||
|   "settingsKeyboardShortcutCloseDialog": "Close Dialog", | ||||
|   "settingsMessageDisplayStyle": "Message Display Style", | ||||
|   "close": "Close", | ||||
|   "drafts": "Drafts", | ||||
|   "noDrafts": "No drafts yet", | ||||
| @@ -518,7 +536,7 @@ | ||||
|   "contactMethodPrimary": "Primary", | ||||
|   "contactMethodSetPrimary": "Set as Primary", | ||||
|   "contactMethodSetPrimaryHint": "Set this contact method as your primary contact method for account recovery and notifications", | ||||
|   "contactMethodDeleteHint": "Are you sure to delete this contact method? This action cannot be undone.", | ||||
|   "contactMethodDeleteHint": "Are you sure you want to delete this contact method? This action cannot be undone.", | ||||
|   "contactMethodMakePublic": "Make Public", | ||||
|   "contactMethodMakePrivate": "Make Private", | ||||
|   "contactMethodPublic": "Public", | ||||
| @@ -552,6 +570,7 @@ | ||||
|   "checkInResultT2": "Mid", | ||||
|   "checkInResultT3": "Good", | ||||
|   "checkInResultT4": "Best", | ||||
|   "checkInResultT5": "Birthday", | ||||
|   "accountProfileView": "View Profile", | ||||
|   "unspecified": "Unspecified", | ||||
|   "added": "Added", | ||||
| @@ -644,8 +663,6 @@ | ||||
|   "abuseReportSuccess": "Report submitted successfully. Thank you for helping keep our community safe.", | ||||
|   "abuseReportError": "Failed to submit report. Please try again.", | ||||
|   "abuseReportReasonRequired": "Please provide details about the issue", | ||||
|   "abuseReportSuccessTitle": "Report Submitted", | ||||
|   "abuseReportErrorTitle": "Error", | ||||
|   "abuseReportTypeSpam": "Spam or Misleading", | ||||
|   "abuseReportTypeHarassment": "Harassment or Abuse", | ||||
|   "abuseReportTypeInappropriate": "Inappropriate Content", | ||||
| @@ -827,11 +844,6 @@ | ||||
|   "postCategorySports": "Sports", | ||||
|   "postCategoryFinance": "Finance", | ||||
|   "postCategoryLife": "Life", | ||||
|   "postCategoryArt": "Art", | ||||
|   "postCategoryStudy": "Study", | ||||
|   "postCategoryGaming": "Gaming", | ||||
|   "postCategoryProgramming": "Programming", | ||||
|   "postCategoryMusic": "Music", | ||||
|   "links": "Links", | ||||
|   "addLink": "Add link", | ||||
|   "linkKey": "Link Name", | ||||
| @@ -890,6 +902,15 @@ | ||||
|   "attachmentOnDevice": "On-device", | ||||
|   "attachmentOnCloud": "On-cloud", | ||||
|   "attachments": "Attachments", | ||||
|   "uploadAttachment": "Upload Attachment", | ||||
|   "attachmentPreview": "Attachment Preview", | ||||
|   "selectPool": "Select Pool", | ||||
|   "choosePool": "Choose a pool", | ||||
|   "errorLoadingPools": "Error loading pools", | ||||
|   "quotaCostInfo": "This upload will cost {} quota points", | ||||
|   "uploadConstraints": "Upload Constraints", | ||||
|   "fileSizeExceeded": "File size exceeds the maximum limit of {}", | ||||
|   "fileTypeNotAccepted": "File type is not accepted by this pool", | ||||
|   "publisherCollabInvitation": "Collabration invitations", | ||||
|   "publisherCollabInvitationCount": { | ||||
|     "zero": "No invitation", | ||||
| @@ -1006,6 +1027,11 @@ | ||||
|   "expandPoll": "Expand Poll", | ||||
|   "collapsePoll": "Collapse Poll", | ||||
|   "embedView": "Embed View", | ||||
|   "auto": "Auto", | ||||
|   "manual": "Manual", | ||||
|   "iframeCode": "Iframe Code", | ||||
|   "iframeCodeHint": "<iframe src=\"...\" width=\"...\" height=\"...\">", | ||||
|   "parseIframe": "Parse Iframe", | ||||
|   "embedUri": "Embed URI", | ||||
|   "aspectRatio": "Aspect Ratio", | ||||
|   "renderer": "Renderer", | ||||
| @@ -1016,5 +1042,26 @@ | ||||
|   "currentEmbed": "Current Embed", | ||||
|   "noEmbed": "No embed yet", | ||||
|   "save": "Save", | ||||
|   "webView": "Web View" | ||||
|   "webView": "Web View", | ||||
|   "messageActions": "Message Actions", | ||||
|   "viewEmbedLoadHint": "Tap to load", | ||||
|   "files": "Files", | ||||
|   "confirmDeleteFile": "Are you sure you want to delete this file?", | ||||
|   "deleteFile": "Delete File", | ||||
|   "failedToDeleteFile": "Failed to delete file", | ||||
|   "drive": "Drive", | ||||
|   "allPools": "All Pools", | ||||
|   "includeRecycled": "Include Recycled", | ||||
|   "confirmDeleteRecycledFiles": "Are you sure you want to delete all recycled files?", | ||||
|   "deleteRecycledFiles": "Delete Recycled Files", | ||||
|   "recycledFilesDeleted": "Recycled files deleted successfully", | ||||
|   "failedToDeleteRecycledFiles": "Failed to delete recycled files", | ||||
|   "upload": "Upload", | ||||
|   "fileInfoTitle": "File Information", | ||||
|   "fileHashCopied": "File hash copied to clipboard", | ||||
|   "fileIdCopied": "File ID copied to clipboard", | ||||
|   "fileNameCopied": "File name copied to clipboard", | ||||
|   "fileMetadata": "File Metadata", | ||||
|   "userMetadata": "User Metadata", | ||||
|   "valueCopied": "Value copied to clipboard" | ||||
| } | ||||
|   | ||||
| @@ -122,6 +122,9 @@ | ||||
|   "addVideo": "添加视频", | ||||
|   "addPhoto": "添加照片", | ||||
|   "addFile": "添加文件", | ||||
|   "uploadFile": "上传文件", | ||||
|   "settingsDefaultPool": "选择文件池", | ||||
|   "settingsDefaultPoolHelper": "为文件上传选择一个默认池", | ||||
|   "createDirectMessage": "创建新私人消息", | ||||
|   "gotoDirectMessage": "前往私信", | ||||
|   "react": "反应", | ||||
| @@ -280,6 +283,18 @@ | ||||
|   "levelingProgress": "等级进度", | ||||
|   "levelingProgressExperience": "{} 经验值", | ||||
|   "levelingProgressLevel": "等级 {}", | ||||
|   "levelingStage1": "新手", | ||||
|   "levelingStage2": "学徒", | ||||
|   "levelingStage3": "熟练工", | ||||
|   "levelingStage4": "行家", | ||||
|   "levelingStage5": "专家", | ||||
|   "levelingStage6": "大师", | ||||
|   "levelingStage7": "宗师", | ||||
|   "levelingStage8": "传奇", | ||||
|   "levelingStage9": "神话", | ||||
|   "levelingStage10": "不朽", | ||||
|   "levelingStage11": "神圣", | ||||
|   "levelingStage12": "超凡", | ||||
|   "fileUploadingProgress": "正在上传文件 #{}: {}%", | ||||
|   "removeChatMember": "移除聊天室成员", | ||||
|   "removeChatMemberHint": "确定要将此成员从聊天室中移除吗?", | ||||
|   | ||||
| @@ -122,6 +122,10 @@ | ||||
|     "addVideo": "添加視頻", | ||||
|     "addPhoto": "添加照片", | ||||
|     "addFile": "添加文件", | ||||
|     "uploadFile": "上傳文件", | ||||
|     "settingsDefaultPool": "選擇文件池", | ||||
|     "settingsDefaultPoolHelper": "爲文件上傳選擇一個默認池", | ||||
|   | ||||
|     "createDirectMessage": "創建新私人消息", | ||||
|     "gotoDirectMessage": "前往私信", | ||||
|     "react": "反應", | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								assets/images/stickers/angry.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/images/stickers/angry.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 1.0 MiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/images/stickers/clap.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/images/stickers/clap.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 1.0 MiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/images/stickers/confuse.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/images/stickers/confuse.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 668 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/images/stickers/party.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/images/stickers/party.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 1.1 MiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/images/stickers/pray.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/images/stickers/pray.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 666 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/images/stickers/thumb_up.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/images/stickers/thumb_up.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 623 KiB | 
| @@ -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
											
										
									
								
							| @@ -50,18 +50,18 @@ PODS: | ||||
|   - Firebase/Messaging (12.2.0): | ||||
|     - Firebase/CoreOnly | ||||
|     - FirebaseMessaging (~> 12.2.0) | ||||
|   - firebase_analytics (12.0.1): | ||||
|   - firebase_analytics (12.0.2): | ||||
|     - firebase_core | ||||
|     - FirebaseAnalytics (= 12.2.0) | ||||
|     - Flutter | ||||
|   - firebase_core (4.1.0): | ||||
|   - firebase_core (4.1.1): | ||||
|     - Firebase/CoreOnly (= 12.2.0) | ||||
|     - Flutter | ||||
|   - firebase_crashlytics (5.0.1): | ||||
|   - firebase_crashlytics (5.0.2): | ||||
|     - Firebase/Crashlytics (= 12.2.0) | ||||
|     - firebase_core | ||||
|     - Flutter | ||||
|   - firebase_messaging (16.0.1): | ||||
|   - firebase_messaging (16.0.2): | ||||
|     - Firebase/Messaging (= 12.2.0) | ||||
|     - firebase_core | ||||
|     - Flutter | ||||
| @@ -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 | ||||
| @@ -476,10 +476,10 @@ SPEC CHECKSUMS: | ||||
|   file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be | ||||
|   file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6 | ||||
|   Firebase: 26f6f8d460603af3df970ad505b16b15f5e2e9a1 | ||||
|   firebase_analytics: 111ff65791a430356bd6c7e4d7339537fc6a15ae | ||||
|   firebase_core: 3ff52146406557dddd01d570e807e203ec7e1302 | ||||
|   firebase_crashlytics: 3637078b718a52dc9fb4d64e37c969e86b87ff6f | ||||
|   firebase_messaging: 3dcc998dd98e1e54af75d0cccae8606eba43553c | ||||
|   firebase_analytics: 8c78ce6224e0623152379d6cc7ef3d9098477b7e | ||||
|   firebase_core: dfc4bd142bee4bc53a5d482397ca322c2dd3165d | ||||
|   firebase_crashlytics: e55dcf895eed0dd87c447dd5aff8db7f1bb8bbdb | ||||
|   firebase_messaging: 38c66c1184695b0c87abe51d40fc590718abed1a | ||||
|   FirebaseAnalytics: e04e23bc070e3014aa5cf4980f9df7ce5cd79ec8 | ||||
|   FirebaseCore: 311c48a147ad4a0ab7febbaed89e8025c67510cd | ||||
|   FirebaseCoreExtension: 73af080c22a2f7b44cefa391dc08f7e4ee162cb5 | ||||
| @@ -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,29 @@ 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, ignore if they already exist | ||||
|         final columnsToAdd = [ | ||||
|           chatMessages.updatedAt, | ||||
|           chatMessages.deletedAt, | ||||
|           chatMessages.type, | ||||
|           chatMessages.meta, | ||||
|           chatMessages.membersMentioned, | ||||
|           chatMessages.editedAt, | ||||
|           chatMessages.attachments, | ||||
|           chatMessages.reactions, | ||||
|           chatMessages.repliedMessageId, | ||||
|           chatMessages.forwardedMessageId, | ||||
|         ]; | ||||
|  | ||||
|         for (final column in columnsToAdd) { | ||||
|           try { | ||||
|             await m.addColumn(chatMessages, column); | ||||
|           } catch (e) { | ||||
|             // Column already exists, skip | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|   ); | ||||
|  | ||||
| @@ -116,12 +139,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 +151,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 +183,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 +216,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, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -98,6 +98,7 @@ sealed class SnAccountStatus with _$SnAccountStatus { | ||||
|     required bool isNotDisturb, | ||||
|     required bool isCustomized, | ||||
|     @Default("") String label, | ||||
|     required Map<String, dynamic>? meta, | ||||
|     required DateTime? clearedAt, | ||||
|     required String accountId, | ||||
|     required DateTime createdAt, | ||||
|   | ||||
| @@ -1053,7 +1053,7 @@ $SnVerificationMarkCopyWith<$Res>? get verification { | ||||
| /// @nodoc | ||||
| mixin _$SnAccountStatus { | ||||
|  | ||||
|  String get id; int get attitude; bool get isOnline; bool get isInvisible; bool get isNotDisturb; bool get isCustomized; String get label; DateTime? get clearedAt; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; | ||||
|  String get id; int get attitude; bool get isOnline; bool get isInvisible; bool get isNotDisturb; bool get isCustomized; String get label; Map<String, dynamic>? get meta; DateTime? get clearedAt; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; | ||||
| /// Create a copy of SnAccountStatus | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -1066,16 +1066,16 @@ $SnAccountStatusCopyWith<SnAccountStatus> get copyWith => _$SnAccountStatusCopyW | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAccountStatus&&(identical(other.id, id) || other.id == id)&&(identical(other.attitude, attitude) || other.attitude == attitude)&&(identical(other.isOnline, isOnline) || other.isOnline == isOnline)&&(identical(other.isInvisible, isInvisible) || other.isInvisible == isInvisible)&&(identical(other.isNotDisturb, isNotDisturb) || other.isNotDisturb == isNotDisturb)&&(identical(other.isCustomized, isCustomized) || other.isCustomized == isCustomized)&&(identical(other.label, label) || other.label == label)&&(identical(other.clearedAt, clearedAt) || other.clearedAt == clearedAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAccountStatus&&(identical(other.id, id) || other.id == id)&&(identical(other.attitude, attitude) || other.attitude == attitude)&&(identical(other.isOnline, isOnline) || other.isOnline == isOnline)&&(identical(other.isInvisible, isInvisible) || other.isInvisible == isInvisible)&&(identical(other.isNotDisturb, isNotDisturb) || other.isNotDisturb == isNotDisturb)&&(identical(other.isCustomized, isCustomized) || other.isCustomized == isCustomized)&&(identical(other.label, label) || other.label == label)&&const DeepCollectionEquality().equals(other.meta, meta)&&(identical(other.clearedAt, clearedAt) || other.clearedAt == clearedAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(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,attitude,isOnline,isInvisible,isNotDisturb,isCustomized,label,clearedAt,accountId,createdAt,updatedAt,deletedAt); | ||||
| int get hashCode => Object.hash(runtimeType,id,attitude,isOnline,isInvisible,isNotDisturb,isCustomized,label,const DeepCollectionEquality().hash(meta),clearedAt,accountId,createdAt,updatedAt,deletedAt); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnAccountStatus(id: $id, attitude: $attitude, isOnline: $isOnline, isInvisible: $isInvisible, isNotDisturb: $isNotDisturb, isCustomized: $isCustomized, label: $label, clearedAt: $clearedAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
|   return 'SnAccountStatus(id: $id, attitude: $attitude, isOnline: $isOnline, isInvisible: $isInvisible, isNotDisturb: $isNotDisturb, isCustomized: $isCustomized, label: $label, meta: $meta, clearedAt: $clearedAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -1086,7 +1086,7 @@ abstract mixin class $SnAccountStatusCopyWith<$Res>  { | ||||
|   factory $SnAccountStatusCopyWith(SnAccountStatus value, $Res Function(SnAccountStatus) _then) = _$SnAccountStatusCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String id, int attitude, bool isOnline, bool isInvisible, bool isNotDisturb, bool isCustomized, String label, DateTime? clearedAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
|  String id, int attitude, bool isOnline, bool isInvisible, bool isNotDisturb, bool isCustomized, String label, Map<String, dynamic>? meta, DateTime? clearedAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -1103,7 +1103,7 @@ class _$SnAccountStatusCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnAccountStatus | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? attitude = null,Object? isOnline = null,Object? isInvisible = null,Object? isNotDisturb = null,Object? isCustomized = null,Object? label = null,Object? clearedAt = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? attitude = null,Object? isOnline = null,Object? isInvisible = null,Object? isNotDisturb = null,Object? isCustomized = null,Object? label = null,Object? meta = freezed,Object? clearedAt = 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,attitude: null == attitude ? _self.attitude : attitude // ignore: cast_nullable_to_non_nullable | ||||
| @@ -1112,7 +1112,8 @@ as bool,isInvisible: null == isInvisible ? _self.isInvisible : isInvisible // ig | ||||
| as bool,isNotDisturb: null == isNotDisturb ? _self.isNotDisturb : isNotDisturb // ignore: cast_nullable_to_non_nullable | ||||
| as bool,isCustomized: null == isCustomized ? _self.isCustomized : isCustomized // ignore: cast_nullable_to_non_nullable | ||||
| as bool,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable | ||||
| as String,clearedAt: freezed == clearedAt ? _self.clearedAt : clearedAt // ignore: cast_nullable_to_non_nullable | ||||
| as String,meta: freezed == meta ? _self.meta : meta // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>?,clearedAt: freezed == clearedAt ? _self.clearedAt : clearedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as String,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 | ||||
| @@ -1199,10 +1200,10 @@ return $default(_that);case _: | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id,  int attitude,  bool isOnline,  bool isInvisible,  bool isNotDisturb,  bool isCustomized,  String label,  DateTime? clearedAt,  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,  int attitude,  bool isOnline,  bool isInvisible,  bool isNotDisturb,  bool isCustomized,  String label,  Map<String, dynamic>? meta,  DateTime? clearedAt,  String accountId,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAccountStatus() when $default != null: | ||||
| return $default(_that.id,_that.attitude,_that.isOnline,_that.isInvisible,_that.isNotDisturb,_that.isCustomized,_that.label,_that.clearedAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
| return $default(_that.id,_that.attitude,_that.isOnline,_that.isInvisible,_that.isNotDisturb,_that.isCustomized,_that.label,_that.meta,_that.clearedAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| @@ -1220,10 +1221,10 @@ return $default(_that.id,_that.attitude,_that.isOnline,_that.isInvisible,_that.i | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  int attitude,  bool isOnline,  bool isInvisible,  bool isNotDisturb,  bool isCustomized,  String label,  DateTime? clearedAt,  String accountId,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  int attitude,  bool isOnline,  bool isInvisible,  bool isNotDisturb,  bool isCustomized,  String label,  Map<String, dynamic>? meta,  DateTime? clearedAt,  String accountId,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAccountStatus(): | ||||
| return $default(_that.id,_that.attitude,_that.isOnline,_that.isInvisible,_that.isNotDisturb,_that.isCustomized,_that.label,_that.clearedAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);} | ||||
| return $default(_that.id,_that.attitude,_that.isOnline,_that.isInvisible,_that.isNotDisturb,_that.isCustomized,_that.label,_that.meta,_that.clearedAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);} | ||||
| } | ||||
| /// A variant of `when` that fallback to returning `null` | ||||
| /// | ||||
| @@ -1237,10 +1238,10 @@ return $default(_that.id,_that.attitude,_that.isOnline,_that.isInvisible,_that.i | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  int attitude,  bool isOnline,  bool isInvisible,  bool isNotDisturb,  bool isCustomized,  String label,  DateTime? clearedAt,  String accountId,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  int attitude,  bool isOnline,  bool isInvisible,  bool isNotDisturb,  bool isCustomized,  String label,  Map<String, dynamic>? meta,  DateTime? clearedAt,  String accountId,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAccountStatus() when $default != null: | ||||
| return $default(_that.id,_that.attitude,_that.isOnline,_that.isInvisible,_that.isNotDisturb,_that.isCustomized,_that.label,_that.clearedAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
| return $default(_that.id,_that.attitude,_that.isOnline,_that.isInvisible,_that.isNotDisturb,_that.isCustomized,_that.label,_that.meta,_that.clearedAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| @@ -1252,7 +1253,7 @@ return $default(_that.id,_that.attitude,_that.isOnline,_that.isInvisible,_that.i | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SnAccountStatus implements SnAccountStatus { | ||||
|   const _SnAccountStatus({required this.id, required this.attitude, required this.isOnline, required this.isInvisible, required this.isNotDisturb, required this.isCustomized, this.label = "", required this.clearedAt, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}); | ||||
|   const _SnAccountStatus({required this.id, required this.attitude, required this.isOnline, required this.isInvisible, required this.isNotDisturb, required this.isCustomized, this.label = "", required final  Map<String, dynamic>? meta, required this.clearedAt, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}): _meta = meta; | ||||
|   factory _SnAccountStatus.fromJson(Map<String, dynamic> json) => _$SnAccountStatusFromJson(json); | ||||
|  | ||||
| @override final  String id; | ||||
| @@ -1262,6 +1263,15 @@ class _SnAccountStatus implements SnAccountStatus { | ||||
| @override final  bool isNotDisturb; | ||||
| @override final  bool isCustomized; | ||||
| @override@JsonKey() final  String label; | ||||
|  final  Map<String, dynamic>? _meta; | ||||
| @override Map<String, dynamic>? get meta { | ||||
|   final value = _meta; | ||||
|   if (value == null) return null; | ||||
|   if (_meta is EqualUnmodifiableMapView) return _meta; | ||||
|   // ignore: implicit_dynamic_type | ||||
|   return EqualUnmodifiableMapView(value); | ||||
| } | ||||
|  | ||||
| @override final  DateTime? clearedAt; | ||||
| @override final  String accountId; | ||||
| @override final  DateTime createdAt; | ||||
| @@ -1281,16 +1291,16 @@ Map<String, dynamic> toJson() { | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAccountStatus&&(identical(other.id, id) || other.id == id)&&(identical(other.attitude, attitude) || other.attitude == attitude)&&(identical(other.isOnline, isOnline) || other.isOnline == isOnline)&&(identical(other.isInvisible, isInvisible) || other.isInvisible == isInvisible)&&(identical(other.isNotDisturb, isNotDisturb) || other.isNotDisturb == isNotDisturb)&&(identical(other.isCustomized, isCustomized) || other.isCustomized == isCustomized)&&(identical(other.label, label) || other.label == label)&&(identical(other.clearedAt, clearedAt) || other.clearedAt == clearedAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAccountStatus&&(identical(other.id, id) || other.id == id)&&(identical(other.attitude, attitude) || other.attitude == attitude)&&(identical(other.isOnline, isOnline) || other.isOnline == isOnline)&&(identical(other.isInvisible, isInvisible) || other.isInvisible == isInvisible)&&(identical(other.isNotDisturb, isNotDisturb) || other.isNotDisturb == isNotDisturb)&&(identical(other.isCustomized, isCustomized) || other.isCustomized == isCustomized)&&(identical(other.label, label) || other.label == label)&&const DeepCollectionEquality().equals(other._meta, _meta)&&(identical(other.clearedAt, clearedAt) || other.clearedAt == clearedAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(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,attitude,isOnline,isInvisible,isNotDisturb,isCustomized,label,clearedAt,accountId,createdAt,updatedAt,deletedAt); | ||||
| int get hashCode => Object.hash(runtimeType,id,attitude,isOnline,isInvisible,isNotDisturb,isCustomized,label,const DeepCollectionEquality().hash(_meta),clearedAt,accountId,createdAt,updatedAt,deletedAt); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnAccountStatus(id: $id, attitude: $attitude, isOnline: $isOnline, isInvisible: $isInvisible, isNotDisturb: $isNotDisturb, isCustomized: $isCustomized, label: $label, clearedAt: $clearedAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
|   return 'SnAccountStatus(id: $id, attitude: $attitude, isOnline: $isOnline, isInvisible: $isInvisible, isNotDisturb: $isNotDisturb, isCustomized: $isCustomized, label: $label, meta: $meta, clearedAt: $clearedAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -1301,7 +1311,7 @@ abstract mixin class _$SnAccountStatusCopyWith<$Res> implements $SnAccountStatus | ||||
|   factory _$SnAccountStatusCopyWith(_SnAccountStatus value, $Res Function(_SnAccountStatus) _then) = __$SnAccountStatusCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String id, int attitude, bool isOnline, bool isInvisible, bool isNotDisturb, bool isCustomized, String label, DateTime? clearedAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
|  String id, int attitude, bool isOnline, bool isInvisible, bool isNotDisturb, bool isCustomized, String label, Map<String, dynamic>? meta, DateTime? clearedAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -1318,7 +1328,7 @@ class __$SnAccountStatusCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnAccountStatus | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? attitude = null,Object? isOnline = null,Object? isInvisible = null,Object? isNotDisturb = null,Object? isCustomized = null,Object? label = null,Object? clearedAt = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? attitude = null,Object? isOnline = null,Object? isInvisible = null,Object? isNotDisturb = null,Object? isCustomized = null,Object? label = null,Object? meta = freezed,Object? clearedAt = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
|   return _then(_SnAccountStatus( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,attitude: null == attitude ? _self.attitude : attitude // ignore: cast_nullable_to_non_nullable | ||||
| @@ -1327,7 +1337,8 @@ as bool,isInvisible: null == isInvisible ? _self.isInvisible : isInvisible // ig | ||||
| as bool,isNotDisturb: null == isNotDisturb ? _self.isNotDisturb : isNotDisturb // ignore: cast_nullable_to_non_nullable | ||||
| as bool,isCustomized: null == isCustomized ? _self.isCustomized : isCustomized // ignore: cast_nullable_to_non_nullable | ||||
| as bool,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable | ||||
| as String,clearedAt: freezed == clearedAt ? _self.clearedAt : clearedAt // ignore: cast_nullable_to_non_nullable | ||||
| as String,meta: freezed == meta ? _self._meta : meta // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>?,clearedAt: freezed == clearedAt ? _self.clearedAt : clearedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as String,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 | ||||
|   | ||||
| @@ -158,6 +158,7 @@ _SnAccountStatus _$SnAccountStatusFromJson(Map<String, dynamic> json) => | ||||
|       isNotDisturb: json['is_not_disturb'] as bool, | ||||
|       isCustomized: json['is_customized'] as bool, | ||||
|       label: json['label'] as String? ?? "", | ||||
|       meta: json['meta'] as Map<String, dynamic>?, | ||||
|       clearedAt: | ||||
|           json['cleared_at'] == null | ||||
|               ? null | ||||
| @@ -180,6 +181,7 @@ Map<String, dynamic> _$SnAccountStatusToJson(_SnAccountStatus instance) => | ||||
|       'is_not_disturb': instance.isNotDisturb, | ||||
|       'is_customized': instance.isCustomized, | ||||
|       'label': instance.label, | ||||
|       'meta': instance.meta, | ||||
|       'cleared_at': instance.clearedAt?.toIso8601String(), | ||||
|       'account_id': instance.accountId, | ||||
|       'created_at': instance.createdAt.toIso8601String(), | ||||
|   | ||||
| @@ -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(), | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:island/models/file_pool.dart'; | ||||
|  | ||||
| part 'file.freezed.dart'; | ||||
| part 'file.g.dart'; | ||||
| @@ -42,6 +43,7 @@ sealed class SnCloudFile with _$SnCloudFile { | ||||
|     required String? description, | ||||
|     required Map<String, dynamic>? fileMeta, | ||||
|     required Map<String, dynamic>? userMeta, | ||||
|     required SnFilePool? pool, | ||||
|     @Default([]) List<int> sensitiveMarks, | ||||
|     required String? mimeType, | ||||
|     required String? hash, | ||||
|   | ||||
| @@ -278,7 +278,7 @@ as bool, | ||||
| /// @nodoc | ||||
| mixin _$SnCloudFile { | ||||
|  | ||||
|  String get id; String get name; String? get description; Map<String, dynamic>? get fileMeta; Map<String, dynamic>? get userMeta; List<int> get sensitiveMarks; String? get mimeType; String? get hash; int get size; DateTime? get uploadedAt; String? get uploadedTo; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; | ||||
|  String get id; String get name; String? get description; Map<String, dynamic>? get fileMeta; Map<String, dynamic>? get userMeta; SnFilePool? get pool; List<int> get sensitiveMarks; String? get mimeType; String? get hash; int get size; DateTime? get uploadedAt; String? get uploadedTo; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; | ||||
| /// Create a copy of SnCloudFile | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -291,16 +291,16 @@ $SnCloudFileCopyWith<SnCloudFile> get copyWith => _$SnCloudFileCopyWithImpl<SnCl | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnCloudFile&&(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.fileMeta, fileMeta)&&const DeepCollectionEquality().equals(other.userMeta, userMeta)&&const DeepCollectionEquality().equals(other.sensitiveMarks, sensitiveMarks)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.uploadedTo, uploadedTo) || other.uploadedTo == uploadedTo)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnCloudFile&&(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.fileMeta, fileMeta)&&const DeepCollectionEquality().equals(other.userMeta, userMeta)&&(identical(other.pool, pool) || other.pool == pool)&&const DeepCollectionEquality().equals(other.sensitiveMarks, sensitiveMarks)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.uploadedTo, uploadedTo) || other.uploadedTo == uploadedTo)&&(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(fileMeta),const DeepCollectionEquality().hash(userMeta),const DeepCollectionEquality().hash(sensitiveMarks),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt); | ||||
| int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(fileMeta),const DeepCollectionEquality().hash(userMeta),pool,const DeepCollectionEquality().hash(sensitiveMarks),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, sensitiveMarks: $sensitiveMarks, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
|   return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, pool: $pool, sensitiveMarks: $sensitiveMarks, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -311,11 +311,11 @@ abstract mixin class $SnCloudFileCopyWith<$Res>  { | ||||
|   factory $SnCloudFileCopyWith(SnCloudFile value, $Res Function(SnCloudFile) _then) = _$SnCloudFileCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
|  String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, SnFilePool? pool, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
| $SnFilePoolCopyWith<$Res>? get pool; | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| @@ -328,14 +328,15 @@ class _$SnCloudFileCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnCloudFile | ||||
| /// 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? fileMeta = freezed,Object? userMeta = freezed,Object? sensitiveMarks = null,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? pool = freezed,Object? sensitiveMarks = null,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,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,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?,fileMeta: freezed == fileMeta ? _self.fileMeta : fileMeta // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>?,userMeta: freezed == userMeta ? _self.userMeta : userMeta // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>?,sensitiveMarks: null == sensitiveMarks ? _self.sensitiveMarks : sensitiveMarks // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>?,pool: freezed == pool ? _self.pool : pool // ignore: cast_nullable_to_non_nullable | ||||
| as SnFilePool?,sensitiveMarks: null == sensitiveMarks ? _self.sensitiveMarks : sensitiveMarks // ignore: cast_nullable_to_non_nullable | ||||
| as List<int>,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable | ||||
| as String?,hash: freezed == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable | ||||
| as String?,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable | ||||
| @@ -347,7 +348,19 @@ as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ign | ||||
| as DateTime?, | ||||
|   )); | ||||
| } | ||||
| /// Create a copy of SnCloudFile | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnFilePoolCopyWith<$Res>? get pool { | ||||
|     if (_self.pool == null) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return $SnFilePoolCopyWith<$Res>(_self.pool!, (value) { | ||||
|     return _then(_self.copyWith(pool: value)); | ||||
|   }); | ||||
| } | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -426,10 +439,10 @@ return $default(_that);case _: | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id,  String name,  String? description,  Map<String, dynamic>? fileMeta,  Map<String, dynamic>? userMeta,  List<int> sensitiveMarks,  String? mimeType,  String? hash,  int size,  DateTime? uploadedAt,  String? uploadedTo,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id,  String name,  String? description,  Map<String, dynamic>? fileMeta,  Map<String, dynamic>? userMeta,  SnFilePool? pool,  List<int> sensitiveMarks,  String? mimeType,  String? hash,  int size,  DateTime? uploadedAt,  String? uploadedTo,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnCloudFile() when $default != null: | ||||
| return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
| return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.pool,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| @@ -447,10 +460,10 @@ return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userM | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  String name,  String? description,  Map<String, dynamic>? fileMeta,  Map<String, dynamic>? userMeta,  List<int> sensitiveMarks,  String? mimeType,  String? hash,  int size,  DateTime? uploadedAt,  String? uploadedTo,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  String name,  String? description,  Map<String, dynamic>? fileMeta,  Map<String, dynamic>? userMeta,  SnFilePool? pool,  List<int> sensitiveMarks,  String? mimeType,  String? hash,  int size,  DateTime? uploadedAt,  String? uploadedTo,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnCloudFile(): | ||||
| return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);} | ||||
| return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.pool,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);} | ||||
| } | ||||
| /// A variant of `when` that fallback to returning `null` | ||||
| /// | ||||
| @@ -464,10 +477,10 @@ return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userM | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  String name,  String? description,  Map<String, dynamic>? fileMeta,  Map<String, dynamic>? userMeta,  List<int> sensitiveMarks,  String? mimeType,  String? hash,  int size,  DateTime? uploadedAt,  String? uploadedTo,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  String name,  String? description,  Map<String, dynamic>? fileMeta,  Map<String, dynamic>? userMeta,  SnFilePool? pool,  List<int> sensitiveMarks,  String? mimeType,  String? hash,  int size,  DateTime? uploadedAt,  String? uploadedTo,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnCloudFile() when $default != null: | ||||
| return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
| return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.pool,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| @@ -479,7 +492,7 @@ return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userM | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SnCloudFile implements SnCloudFile { | ||||
|   const _SnCloudFile({required this.id, required this.name, required this.description, required final  Map<String, dynamic>? fileMeta, required final  Map<String, dynamic>? userMeta, final  List<int> sensitiveMarks = const [], required this.mimeType, required this.hash, required this.size, required this.uploadedAt, required this.uploadedTo, required this.createdAt, required this.updatedAt, required this.deletedAt}): _fileMeta = fileMeta,_userMeta = userMeta,_sensitiveMarks = sensitiveMarks; | ||||
|   const _SnCloudFile({required this.id, required this.name, required this.description, required final  Map<String, dynamic>? fileMeta, required final  Map<String, dynamic>? userMeta, required this.pool, final  List<int> sensitiveMarks = const [], required this.mimeType, required this.hash, required this.size, required this.uploadedAt, required this.uploadedTo, required this.createdAt, required this.updatedAt, required this.deletedAt}): _fileMeta = fileMeta,_userMeta = userMeta,_sensitiveMarks = sensitiveMarks; | ||||
|   factory _SnCloudFile.fromJson(Map<String, dynamic> json) => _$SnCloudFileFromJson(json); | ||||
|  | ||||
| @override final  String id; | ||||
| @@ -503,6 +516,7 @@ class _SnCloudFile implements SnCloudFile { | ||||
|   return EqualUnmodifiableMapView(value); | ||||
| } | ||||
|  | ||||
| @override final  SnFilePool? pool; | ||||
|  final  List<int> _sensitiveMarks; | ||||
| @override@JsonKey() List<int> get sensitiveMarks { | ||||
|   if (_sensitiveMarks is EqualUnmodifiableListView) return _sensitiveMarks; | ||||
| @@ -532,16 +546,16 @@ Map<String, dynamic> toJson() { | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnCloudFile&&(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._fileMeta, _fileMeta)&&const DeepCollectionEquality().equals(other._userMeta, _userMeta)&&const DeepCollectionEquality().equals(other._sensitiveMarks, _sensitiveMarks)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.uploadedTo, uploadedTo) || other.uploadedTo == uploadedTo)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnCloudFile&&(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._fileMeta, _fileMeta)&&const DeepCollectionEquality().equals(other._userMeta, _userMeta)&&(identical(other.pool, pool) || other.pool == pool)&&const DeepCollectionEquality().equals(other._sensitiveMarks, _sensitiveMarks)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.uploadedTo, uploadedTo) || other.uploadedTo == uploadedTo)&&(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(_fileMeta),const DeepCollectionEquality().hash(_userMeta),const DeepCollectionEquality().hash(_sensitiveMarks),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt); | ||||
| int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(_fileMeta),const DeepCollectionEquality().hash(_userMeta),pool,const DeepCollectionEquality().hash(_sensitiveMarks),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, sensitiveMarks: $sensitiveMarks, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
|   return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, pool: $pool, sensitiveMarks: $sensitiveMarks, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -552,11 +566,11 @@ abstract mixin class _$SnCloudFileCopyWith<$Res> implements $SnCloudFileCopyWith | ||||
|   factory _$SnCloudFileCopyWith(_SnCloudFile value, $Res Function(_SnCloudFile) _then) = __$SnCloudFileCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
|  String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, SnFilePool? pool, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
| @override $SnFilePoolCopyWith<$Res>? get pool; | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| @@ -569,14 +583,15 @@ class __$SnCloudFileCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnCloudFile | ||||
| /// 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? fileMeta = freezed,Object? userMeta = freezed,Object? sensitiveMarks = null,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? pool = freezed,Object? sensitiveMarks = null,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
|   return _then(_SnCloudFile( | ||||
| 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?,fileMeta: freezed == fileMeta ? _self._fileMeta : fileMeta // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>?,userMeta: freezed == userMeta ? _self._userMeta : userMeta // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>?,sensitiveMarks: null == sensitiveMarks ? _self._sensitiveMarks : sensitiveMarks // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>?,pool: freezed == pool ? _self.pool : pool // ignore: cast_nullable_to_non_nullable | ||||
| as SnFilePool?,sensitiveMarks: null == sensitiveMarks ? _self._sensitiveMarks : sensitiveMarks // ignore: cast_nullable_to_non_nullable | ||||
| as List<int>,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable | ||||
| as String?,hash: freezed == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable | ||||
| as String?,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable | ||||
| @@ -589,7 +604,19 @@ as DateTime?, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| /// Create a copy of SnCloudFile | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnFilePoolCopyWith<$Res>? get pool { | ||||
|     if (_self.pool == null) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return $SnFilePoolCopyWith<$Res>(_self.pool!, (value) { | ||||
|     return _then(_self.copyWith(pool: value)); | ||||
|   }); | ||||
| } | ||||
| } | ||||
|  | ||||
| // dart format on | ||||
|   | ||||
| @@ -33,6 +33,10 @@ _SnCloudFile _$SnCloudFileFromJson(Map<String, dynamic> json) => _SnCloudFile( | ||||
|   description: json['description'] as String?, | ||||
|   fileMeta: json['file_meta'] as Map<String, dynamic>?, | ||||
|   userMeta: json['user_meta'] as Map<String, dynamic>?, | ||||
|   pool: | ||||
|       json['pool'] == null | ||||
|           ? null | ||||
|           : SnFilePool.fromJson(json['pool'] as Map<String, dynamic>), | ||||
|   sensitiveMarks: | ||||
|       (json['sensitive_marks'] as List<dynamic>?) | ||||
|           ?.map((e) => (e as num).toInt()) | ||||
| @@ -61,6 +65,7 @@ Map<String, dynamic> _$SnCloudFileToJson(_SnCloudFile instance) => | ||||
|       'description': instance.description, | ||||
|       'file_meta': instance.fileMeta, | ||||
|       'user_meta': instance.userMeta, | ||||
|       'pool': instance.pool?.toJson(), | ||||
|       'sensitive_marks': instance.sensitiveMarks, | ||||
|       'mime_type': instance.mimeType, | ||||
|       'hash': instance.hash, | ||||
|   | ||||
							
								
								
									
										25
									
								
								lib/models/file_pool.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								lib/models/file_pool.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| 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); | ||||
| } | ||||
							
								
								
									
										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(), | ||||
|     }; | ||||
| @@ -4,7 +4,9 @@ import 'dart:developer' as developer; | ||||
| import 'dart:io'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/widgets/account/status.dart'; | ||||
| import 'package:shelf/shelf.dart'; | ||||
| import 'package:shelf/shelf_io.dart' as shelf_io; | ||||
| import 'package:shelf_web_socket/shelf_web_socket.dart'; | ||||
| @@ -339,7 +341,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'); | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -388,12 +392,35 @@ final rpcServerStateProvider = | ||||
|         'message': (socket, dynamic data) async { | ||||
|           if (data['cmd'] == 'SET_ACTIVITY') { | ||||
|             notifier.addActivity( | ||||
|               'Activity: ${data['args']['activity']['details'] ?? 'Unknown'}', | ||||
|               'Activity: ${data['args']['activity']['details'] ?? ''}', | ||||
|             ); | ||||
|             final label = data['args']['activity']['details'] ?? 'Unknown'; | ||||
|             final label = data['args']['activity']['details'] ?? ''; | ||||
|             final appId = socket.clientId; | ||||
|             final meta = data['args']['activity']; | ||||
|             try { | ||||
|               await setRemoteActivityStatus(ref, label, appId); | ||||
|               await setRemoteActivityStatus( | ||||
|                 ref, | ||||
|                 label, | ||||
|                 appId, | ||||
|                 meta, | ||||
|               ); | ||||
|               final now = DateTime.now(); | ||||
|               final status = SnAccountStatus( | ||||
|                 id: 'local_$appId', | ||||
|                 attitude: 0, | ||||
|                 isOnline: true, | ||||
|                 isInvisible: false, | ||||
|                 isNotDisturb: false, | ||||
|                 isCustomized: true, | ||||
|                 label: label, | ||||
|                 meta: meta, | ||||
|                 clearedAt: null, | ||||
|                 accountId: 'me', | ||||
|                 createdAt: now, | ||||
|                 updatedAt: now, | ||||
|                 deletedAt: null, | ||||
|               ); | ||||
|               ref.read(currentAccountStatusProvider.notifier).setStatus(status); | ||||
|             } catch (e) { | ||||
|               developer.log( | ||||
|                 'Failed to set remote activity status: $e', | ||||
| @@ -413,6 +440,7 @@ final rpcServerStateProvider = | ||||
|           final appId = socket.clientId; | ||||
|           try { | ||||
|             await unsetRemoteActivityStatus(ref, appId); | ||||
|             ref.read(currentAccountStatusProvider.notifier).clearStatus(); | ||||
|           } catch (e) { | ||||
|             developer.log( | ||||
|               'Failed to unset remote activity status: $e', | ||||
| @@ -433,6 +461,7 @@ Future<void> setRemoteActivityStatus( | ||||
|   Ref ref, | ||||
|   String label, | ||||
|   String appId, | ||||
|   Map<String, dynamic> meta, | ||||
| ) async { | ||||
|   final apiClient = ref.read(apiClientProvider); | ||||
|   await apiClient.post( | ||||
| @@ -443,6 +472,7 @@ Future<void> setRemoteActivityStatus( | ||||
|       'is_automated': true, | ||||
|       'label': label, | ||||
|       'app_identifier': appId, | ||||
|       'meta': meta, | ||||
|     }, | ||||
|   ); | ||||
| } | ||||
|   | ||||
							
								
								
									
										34
									
								
								lib/pods/chat_rooms.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								lib/pods/chat_rooms.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); | ||||
|   } | ||||
| } | ||||
| @@ -25,6 +25,8 @@ 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 kAppMessageDisplayStyle = 'app_message_display_style'; | ||||
| const kFeaturedPostsCollapsedId = | ||||
|     'featured_posts_collapsed_id'; // Key for storing the ID of the collapsed featured post | ||||
|  | ||||
| @@ -65,6 +67,8 @@ 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, | ||||
|     required String messageDisplayStyle, | ||||
|   }) = _AppSettings; | ||||
| } | ||||
|  | ||||
| @@ -84,6 +88,8 @@ class AppSettingsNotifier extends _$AppSettingsNotifier { | ||||
|       customFonts: prefs.getString(kAppCustomFonts), | ||||
|       appColorScheme: prefs.getInt(kAppColorSchemeStoreKey), | ||||
|       windowSize: _getWindowSizeFromPrefs(prefs), | ||||
|       defaultPoolId: prefs.getString(kAppDefaultPoolId), | ||||
|       messageDisplayStyle: prefs.getString(kAppMessageDisplayStyle) ?? 'bubble', | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @@ -104,13 +110,23 @@ 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); | ||||
|     prefs.setBool(kAppAutoTranslate, value); | ||||
|     state = state.copyWith(autoTranslate: value); | ||||
|   } | ||||
|  | ||||
|   void setDataSavingMode(bool value){ | ||||
|   void setDataSavingMode(bool value) { | ||||
|     final prefs = ref.read(sharedPreferencesProvider); | ||||
|     prefs.setBool(kAppDataSavingMode, value); | ||||
|     state = state.copyWith(dataSavingMode: value); | ||||
| @@ -174,6 +190,12 @@ class AppSettingsNotifier extends _$AppSettingsNotifier { | ||||
|   Size? getWindowSize() { | ||||
|     return state.windowSize; | ||||
|   } | ||||
|  | ||||
|   void setMessageDisplayStyle(String value) { | ||||
|     final prefs = ref.read(sharedPreferencesProvider); | ||||
|     prefs.setString(kAppMessageDisplayStyle, value); | ||||
|     state = state.copyWith(messageDisplayStyle: value); | ||||
|   } | ||||
| } | ||||
|  | ||||
| final updateInfoProvider = | ||||
|   | ||||
| @@ -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; String get messageDisplayStyle; | ||||
| /// 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)&&(identical(other.messageDisplayStyle, messageDisplayStyle) || other.messageDisplayStyle == messageDisplayStyle)); | ||||
| } | ||||
|  | ||||
|  | ||||
| @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,messageDisplayStyle); | ||||
|  | ||||
| @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, messageDisplayStyle: $messageDisplayStyle)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -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, String messageDisplayStyle | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -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,Object? messageDisplayStyle = null,}) { | ||||
|   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,9 @@ 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?,messageDisplayStyle: null == messageDisplayStyle ? _self.messageDisplayStyle : messageDisplayStyle // ignore: cast_nullable_to_non_nullable | ||||
| as String, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| @@ -157,10 +160,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,  String messageDisplayStyle)?  $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,_that.messageDisplayStyle);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| @@ -178,10 +181,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,  String messageDisplayStyle)  $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,_that.messageDisplayStyle);} | ||||
| } | ||||
| /// A variant of `when` that fallback to returning `null` | ||||
| /// | ||||
| @@ -195,10 +198,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,  String messageDisplayStyle)?  $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,_that.messageDisplayStyle);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| @@ -210,7 +213,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, required this.messageDisplayStyle}); | ||||
|    | ||||
|  | ||||
| @override final  bool autoTranslate; | ||||
| @@ -224,6 +227,9 @@ 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; | ||||
| @override final  String messageDisplayStyle; | ||||
|  | ||||
| /// Create a copy of AppSettings | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @@ -235,16 +241,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)&&(identical(other.messageDisplayStyle, messageDisplayStyle) || other.messageDisplayStyle == messageDisplayStyle)); | ||||
| } | ||||
|  | ||||
|  | ||||
| @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,messageDisplayStyle); | ||||
|  | ||||
| @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, messageDisplayStyle: $messageDisplayStyle)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -255,7 +261,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, String messageDisplayStyle | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -272,7 +278,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,Object? messageDisplayStyle = null,}) { | ||||
|   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 +290,9 @@ 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?,messageDisplayStyle: null == messageDisplayStyle ? _self.messageDisplayStyle : messageDisplayStyle // ignore: cast_nullable_to_non_nullable | ||||
| as String, | ||||
|   )); | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -7,7 +7,7 @@ part of 'config.dart'; | ||||
| // ************************************************************************** | ||||
|  | ||||
| String _$appSettingsNotifierHash() => | ||||
|     r'cd18bff2614a94e3523634e6c577cefad0367eba'; | ||||
|     r'9f0979f18b107e61185391e7c39bd81ac4b8ca50'; | ||||
|  | ||||
| /// See also [AppSettingsNotifier]. | ||||
| @ProviderFor(AppSettingsNotifier) | ||||
|   | ||||
							
								
								
									
										24
									
								
								lib/pods/file_pool.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								lib/pods/file_pool.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| 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'); | ||||
|   return response.data | ||||
|       .map((e) => SnFilePool.fromJson(e)) | ||||
|       .cast<SnFilePool>() | ||||
|       .toList(); | ||||
| }); | ||||
|  | ||||
| String? resolveDefaultPoolId(WidgetRef ref, List<SnFilePool> pools) { | ||||
|   final settings = ref.watch(appSettingsNotifierProvider); | ||||
|  | ||||
|   final configuredId = settings.defaultPoolId; | ||||
|   if (configuredId != null && pools.any((p) => p.id == configuredId)) { | ||||
|     return configuredId; | ||||
|   } | ||||
|  | ||||
|   return pools.firstOrNull?.id; | ||||
| } | ||||
| @@ -10,17 +10,19 @@ Future<void> resetDatabase(WidgetRef ref) async { | ||||
|   if (kIsWeb) return; | ||||
|  | ||||
|   final db = ref.read(databaseProvider); | ||||
|   final basepath = await getApplicationSupportDirectory(); | ||||
|   final file = File(join(basepath.path, 'solar_network_data.sqlite')); | ||||
|  | ||||
|   // Close current database connection | ||||
|   db.close(); | ||||
|   await db.close(); | ||||
|  | ||||
|   // Delete database file | ||||
|   // Get the correct database file path | ||||
|   final dbFolder = await getApplicationDocumentsDirectory(); | ||||
|   final file = File(join(dbFolder.path, 'solar_network_data.sqlite')); | ||||
|  | ||||
|   // Delete database file if it exists | ||||
|   if (await file.exists()) { | ||||
|     await file.delete(); | ||||
|   } | ||||
|  | ||||
|   // Force refresh the database provider | ||||
|   // Force refresh the database provider to create a new instance | ||||
|   ref.invalidate(databaseProvider); | ||||
| } | ||||
|   | ||||
							
								
								
									
										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/chat_rooms.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 { | ||||
| @@ -44,9 +44,12 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> { | ||||
|                       : 'failedToLoadUserInfoNetwork') | ||||
|                   .tr() | ||||
|                   .trim(), | ||||
|               '${error.response?.statusCode ?? 'Network Error'}\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(), | ||||
|   | ||||
| @@ -6,7 +6,6 @@ import 'package:flutter/foundation.dart' show kIsWeb; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/screens/about.dart'; | ||||
| import 'package:island/screens/account/credits.dart'; | ||||
| import 'package:island/screens/developers/app_detail.dart'; | ||||
| import 'package:island/screens/developers/bot_detail.dart'; | ||||
| import 'package:island/screens/developers/edit_app.dart'; | ||||
| @@ -19,6 +18,7 @@ import 'package:island/screens/developers/edit_project.dart'; | ||||
| import 'package:island/screens/developers/new_project.dart'; | ||||
| import 'package:island/screens/developers/project_detail.dart'; | ||||
| import 'package:island/screens/discovery/articles.dart'; | ||||
| import 'package:island/screens/files/file_list.dart'; | ||||
| import 'package:island/screens/posts/post_categories_list.dart'; | ||||
| import 'package:island/screens/posts/post_category_detail.dart'; | ||||
| import 'package:island/screens/posts/post_search.dart'; | ||||
| @@ -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,10 +86,7 @@ Widget _tabPagesTransitionBuilder( | ||||
| } | ||||
|  | ||||
| bool get _supportsAnalytics => | ||||
|     kIsWeb || | ||||
|     Platform.isAndroid || | ||||
|     Platform.isIOS || | ||||
|     Platform.isMacOS; | ||||
|     kIsWeb || Platform.isAndroid || Platform.isIOS || Platform.isMacOS; | ||||
|  | ||||
| // Provider for the router | ||||
| final routerProvider = Provider<GoRouter>((ref) { | ||||
| @@ -659,9 +656,9 @@ final routerProvider = Provider<GoRouter>((ref) { | ||||
|                     builder: (context, state) => const WalletScreen(), | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     name: 'socialCredits', | ||||
|                     path: '/account/credits', | ||||
|                     builder: (context, state) => const SocialCreditsScreen(), | ||||
|                     name: 'files', | ||||
|                     path: '/account/files', | ||||
|                     builder: (context, state) => const FileListScreen(), | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     name: 'relationships', | ||||
|   | ||||
| @@ -141,20 +141,22 @@ class AccountScreen extends HookConsumerWidget { | ||||
|                 ], | ||||
|               ), | ||||
|             ).padding(horizontal: 8), | ||||
|             GestureDetector( | ||||
|               child: LevelingProgressCard( | ||||
|                 level: user.value!.profile.level, | ||||
|                 experience: user.value!.profile.experience, | ||||
|                 progress: user.value!.profile.levelingProgress, | ||||
|               ), | ||||
|             LevelingProgressCard( | ||||
|               isCompact: true, | ||||
|               level: user.value!.profile.level, | ||||
|               experience: user.value!.profile.experience, | ||||
|               progress: user.value!.profile.levelingProgress, | ||||
|               onTap: () { | ||||
|                 context.pushNamed('leveling'); | ||||
|               }, | ||||
|             ).padding(horizontal: 12), | ||||
|             const SizedBox.shrink(), | ||||
|             Row( | ||||
|               spacing: 8, | ||||
|               children: [ | ||||
|                 Expanded( | ||||
|                   child: Card( | ||||
|                     margin: EdgeInsets.zero, | ||||
|                     child: InkWell( | ||||
|                       borderRadius: BorderRadius.circular(8), | ||||
|                       child: Column( | ||||
| @@ -181,6 +183,7 @@ class AccountScreen extends HookConsumerWidget { | ||||
|                 ), | ||||
|                 Expanded( | ||||
|                   child: Card( | ||||
|                     margin: EdgeInsets.zero, | ||||
|                     child: InkWell( | ||||
|                       borderRadius: BorderRadius.circular(8), | ||||
|                       child: Column( | ||||
| @@ -206,7 +209,73 @@ class AccountScreen extends HookConsumerWidget { | ||||
|                   ).height(140), | ||||
|                 ), | ||||
|               ], | ||||
|             ).padding(horizontal: 8), | ||||
|             ).padding(horizontal: 12), | ||||
|             const SizedBox.shrink(), | ||||
|             Row( | ||||
|               spacing: 8, | ||||
|               children: [ | ||||
|                 Expanded( | ||||
|                   child: Card( | ||||
|                     margin: EdgeInsets.zero, | ||||
|                     child: InkWell( | ||||
|                       borderRadius: BorderRadius.circular(8), | ||||
|                       child: Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         children: [ | ||||
|                           Icon(Symbols.settings, size: 28).padding(bottom: 8), | ||||
|                           Text('appSettings').tr().fontSize(16).bold(), | ||||
|                         ], | ||||
|                       ).padding(horizontal: 16, vertical: 12), | ||||
|                       onTap: () { | ||||
|                         context.pushNamed('settings'); | ||||
|                       }, | ||||
|                     ), | ||||
|                   ).height(120), | ||||
|                 ), | ||||
|                 Expanded( | ||||
|                   child: Card( | ||||
|                     margin: EdgeInsets.zero, | ||||
|                     child: InkWell( | ||||
|                       borderRadius: BorderRadius.circular(8), | ||||
|                       child: Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         children: [ | ||||
|                           Icon( | ||||
|                             Symbols.person_edit, | ||||
|                             size: 28, | ||||
|                           ).padding(bottom: 8), | ||||
|                           Text('updateYourProfile').tr().fontSize(16).bold(), | ||||
|                         ], | ||||
|                       ).padding(horizontal: 16, vertical: 12), | ||||
|                       onTap: () { | ||||
|                         context.pushNamed('profileUpdate'); | ||||
|                       }, | ||||
|                     ), | ||||
|                   ).height(120), | ||||
|                 ), | ||||
|                 Expanded( | ||||
|                   child: Card( | ||||
|                     margin: EdgeInsets.zero, | ||||
|                     child: InkWell( | ||||
|                       borderRadius: BorderRadius.circular(8), | ||||
|                       child: Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         children: [ | ||||
|                           Icon( | ||||
|                             Symbols.manage_accounts, | ||||
|                             size: 28, | ||||
|                           ).padding(bottom: 8), | ||||
|                           Text('accountSettings').tr().fontSize(16).bold(), | ||||
|                         ], | ||||
|                       ).padding(horizontal: 16, vertical: 12), | ||||
|                       onTap: () { | ||||
|                         context.pushNamed('accountSettings'); | ||||
|                       }, | ||||
|                     ), | ||||
|                   ).height(120), | ||||
|                 ), | ||||
|               ], | ||||
|             ).padding(horizontal: 12), | ||||
|             ListTile( | ||||
|               minTileHeight: 48, | ||||
|               leading: const Icon(Symbols.notifications), | ||||
| @@ -235,6 +304,16 @@ class AccountScreen extends HookConsumerWidget { | ||||
|                 context.pushNamed('wallet'); | ||||
|               }, | ||||
|             ), | ||||
|             ListTile( | ||||
|               minTileHeight: 48, | ||||
|               leading: const Icon(Symbols.files), | ||||
|               trailing: const Icon(Symbols.chevron_right), | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|               title: Text('files').tr(), | ||||
|               onTap: () { | ||||
|                 context.pushNamed('files'); | ||||
|               }, | ||||
|             ), | ||||
|             ListTile( | ||||
|               minTileHeight: 48, | ||||
|               leading: const Icon(Symbols.people), | ||||
| @@ -265,16 +344,6 @@ class AccountScreen extends HookConsumerWidget { | ||||
|                 context.pushNamed('webFeedMarketplace'); | ||||
|               }, | ||||
|             ), | ||||
|             ListTile( | ||||
|               minTileHeight: 48, | ||||
|               leading: const Icon(Symbols.star), | ||||
|               trailing: const Icon(Symbols.chevron_right), | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|               title: Text('credits').tr(), | ||||
|               onTap: () { | ||||
|                 context.pushNamed('socialCredits'); | ||||
|               }, | ||||
|             ), | ||||
|             ListTile( | ||||
|               minTileHeight: 48, | ||||
|               title: Text('abuseReport').tr(), | ||||
| @@ -284,37 +353,6 @@ class AccountScreen extends HookConsumerWidget { | ||||
|               onTap: () => context.pushNamed('reportList'), | ||||
|             ), | ||||
|             const Divider(height: 1).padding(vertical: 8), | ||||
|             ListTile( | ||||
|               minTileHeight: 48, | ||||
|               leading: const Icon(Symbols.settings), | ||||
|               trailing: const Icon(Symbols.chevron_right), | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|               title: Text('appSettings').tr(), | ||||
|               onTap: () { | ||||
|                 context.pushNamed('settings'); | ||||
|               }, | ||||
|             ), | ||||
|             ListTile( | ||||
|               minTileHeight: 48, | ||||
|               leading: const Icon(Symbols.person_edit), | ||||
|               trailing: const Icon(Symbols.chevron_right), | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|               title: Text('updateYourProfile').tr(), | ||||
|               onTap: () { | ||||
|                 context.pushNamed('profileUpdate'); | ||||
|               }, | ||||
|             ), | ||||
|             ListTile( | ||||
|               minTileHeight: 48, | ||||
|               leading: const Icon(Symbols.manage_accounts), | ||||
|               trailing: const Icon(Symbols.chevron_right), | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|               title: Text('accountSettings').tr(), | ||||
|               onTap: () { | ||||
|                 context.pushNamed('accountSettings'); | ||||
|               }, | ||||
|             ), | ||||
|             const Divider(height: 1).padding(vertical: 8), | ||||
|             ListTile( | ||||
|               minTileHeight: 48, | ||||
|               leading: const Icon(Symbols.info), | ||||
| @@ -333,6 +371,8 @@ class AccountScreen extends HookConsumerWidget { | ||||
|               title: Text('debugOptions').tr(), | ||||
|               onTap: () { | ||||
|                 showModalBottomSheet( | ||||
|                   useRootNavigator: true, | ||||
|                   isScrollControlled: true, | ||||
|                   context: context, | ||||
|                   builder: (context) => DebugSheet(), | ||||
|                 ); | ||||
|   | ||||
| @@ -4,7 +4,6 @@ import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:material_symbols_icons/material_symbols_icons.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||
| @@ -59,94 +58,93 @@ class SocialCreditHistoryNotifier extends _$SocialCreditHistoryNotifier | ||||
|   } | ||||
| } | ||||
|  | ||||
| class SocialCreditsScreen extends HookConsumerWidget { | ||||
|   const SocialCreditsScreen({super.key}); | ||||
| class SocialCreditsTab extends HookConsumerWidget { | ||||
|   const SocialCreditsTab({super.key}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final socialCredits = ref.watch(socialCreditsProvider); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar(title: Text('socialCredits').tr()), | ||||
|       body: Column( | ||||
|         children: [ | ||||
|           Card( | ||||
|             margin: EdgeInsets.only(left: 16, right: 16, top: 8), | ||||
|             child: socialCredits | ||||
|                 .when( | ||||
|                   data: | ||||
|                       (credits) => Stack( | ||||
|                         children: [ | ||||
|                           Column( | ||||
|                             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                             children: [ | ||||
|                               Text( | ||||
|                                 credits < 100 | ||||
|                                     ? 'socialCreditsLevelPoor'.tr() | ||||
|                                     : credits < 150 | ||||
|                                     ? 'socialCreditsLevelNormal'.tr() | ||||
|                                     : credits < 200 | ||||
|                                     ? 'socialCreditsLevelGood'.tr() | ||||
|                                     : 'socialCreditsLevelExcellent'.tr(), | ||||
|                               ).tr().bold().fontSize(20), | ||||
|                               Text( | ||||
|                                 '${credits.toStringAsFixed(2)} pts', | ||||
|                               ).fontSize(14), | ||||
|                               const Gap(8), | ||||
|                               LinearProgressIndicator(value: credits / 200), | ||||
|                             ], | ||||
|     return Column( | ||||
|       children: [ | ||||
|         const Gap(8), | ||||
|         Card( | ||||
|           margin: const EdgeInsets.only(left: 16, right: 16, top: 8), | ||||
|           child: socialCredits | ||||
|               .when( | ||||
|                 data: | ||||
|                     (credits) => Stack( | ||||
|                       children: [ | ||||
|                         Column( | ||||
|                           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                           children: [ | ||||
|                             Text( | ||||
|                               credits < 100 | ||||
|                                   ? 'socialCreditsLevelPoor'.tr() | ||||
|                                   : credits < 150 | ||||
|                                   ? 'socialCreditsLevelNormal'.tr() | ||||
|                                   : credits < 200 | ||||
|                                   ? 'socialCreditsLevelGood'.tr() | ||||
|                                   : 'socialCreditsLevelExcellent'.tr(), | ||||
|                             ).tr().bold().fontSize(20), | ||||
|                             Text( | ||||
|                               '${credits.toStringAsFixed(2)} pts', | ||||
|                             ).fontSize(14), | ||||
|                             const Gap(8), | ||||
|                             LinearProgressIndicator(value: credits / 200), | ||||
|                           ], | ||||
|                         ), | ||||
|                         Positioned( | ||||
|                           right: 0, | ||||
|                           top: 0, | ||||
|                           child: IconButton( | ||||
|                             onPressed: () {}, | ||||
|                             icon: const Icon(Symbols.info), | ||||
|                             tooltip: 'socialCreditsDescription'.tr(), | ||||
|                           ), | ||||
|                           Positioned( | ||||
|                             right: 0, | ||||
|                             top: 0, | ||||
|                             child: IconButton( | ||||
|                               onPressed: () {}, | ||||
|                               icon: const Icon(Symbols.info), | ||||
|                               tooltip: 'socialCreditsDescription'.tr(), | ||||
|                             ), | ||||
|                           ), | ||||
|                         ], | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                 error: (_, _) => Text('Error loading credits'), | ||||
|                 loading: () => const LinearProgressIndicator(), | ||||
|               ) | ||||
|               .padding(horizontal: 20, vertical: 16), | ||||
|         ), | ||||
|         Expanded( | ||||
|           child: PagingHelperView( | ||||
|             provider: socialCreditHistoryNotifierProvider, | ||||
|             futureRefreshable: socialCreditHistoryNotifierProvider.future, | ||||
|             notifierRefreshable: socialCreditHistoryNotifierProvider.notifier, | ||||
|             contentBuilder: | ||||
|                 (data, widgetCount, endItemView) => ListView.builder( | ||||
|                   padding: EdgeInsets.zero, | ||||
|                   itemCount: widgetCount, | ||||
|                   itemBuilder: (context, index) { | ||||
|                     if (index == widgetCount - 1) { | ||||
|                       return endItemView; | ||||
|                     } | ||||
|                     final record = data.items[index]; | ||||
|                     return ListTile( | ||||
|                       contentPadding: const EdgeInsets.symmetric( | ||||
|                         horizontal: 24, | ||||
|                       ), | ||||
|                   error: (_, _) => Text('Error loading credits'), | ||||
|                   loading: () => const LinearProgressIndicator(), | ||||
|                 ) | ||||
|                 .padding(horizontal: 20, vertical: 16), | ||||
|           ), | ||||
|           Expanded( | ||||
|             child: PagingHelperView( | ||||
|               provider: socialCreditHistoryNotifierProvider, | ||||
|               futureRefreshable: socialCreditHistoryNotifierProvider.future, | ||||
|               notifierRefreshable: socialCreditHistoryNotifierProvider.notifier, | ||||
|               contentBuilder: | ||||
|                   (data, widgetCount, endItemView) => ListView.builder( | ||||
|                     padding: EdgeInsets.zero, | ||||
|                     itemCount: widgetCount, | ||||
|                     itemBuilder: (context, index) { | ||||
|                       if (index == widgetCount - 1) { | ||||
|                         return endItemView; | ||||
|                       } | ||||
|                       final record = data.items[index]; | ||||
|                       return ListTile( | ||||
|                         contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|                         title: Text(record.reason), | ||||
|                         subtitle: Text( | ||||
|                           DateFormat.yMMMd().format(record.createdAt), | ||||
|                       title: Text(record.reason), | ||||
|                       subtitle: Text( | ||||
|                         DateFormat.yMMMd().format(record.createdAt), | ||||
|                       ), | ||||
|                       trailing: Text( | ||||
|                         record.delta > 0 | ||||
|                             ? '+${record.delta}' | ||||
|                             : '${record.delta}', | ||||
|                         style: TextStyle( | ||||
|                           color: record.delta > 0 ? Colors.green : Colors.red, | ||||
|                         ), | ||||
|                         trailing: Text( | ||||
|                           record.delta > 0 | ||||
|                               ? '+${record.delta}' | ||||
|                               : '${record.delta}', | ||||
|                           style: TextStyle( | ||||
|                             color: record.delta > 0 ? Colors.green : Colors.red, | ||||
|                           ), | ||||
|                         ), | ||||
|                       ); | ||||
|                     }, | ||||
|                   ), | ||||
|             ), | ||||
|                       ), | ||||
|                     ); | ||||
|                   }, | ||||
|                 ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -4,12 +4,12 @@ import 'package:dio/dio.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:google_fonts/google_fonts.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/models/wallet.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/screens/account/credits.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
| import 'package:island/widgets/account/leveling_progress.dart'; | ||||
| @@ -89,7 +89,7 @@ class LevelingScreen extends HookConsumerWidget { | ||||
|     } | ||||
|  | ||||
|     return DefaultTabController( | ||||
|       length: 2, | ||||
|       length: 3, | ||||
|       child: AppScaffold( | ||||
|         appBar: AppBar( | ||||
|           title: Text('levelingProgress'.tr()), | ||||
| @@ -104,6 +104,15 @@ class LevelingScreen extends HookConsumerWidget { | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|               Tab( | ||||
|                 child: Text( | ||||
|                   'socialCredits'.tr(), | ||||
|                   textAlign: TextAlign.center, | ||||
|                   style: TextStyle( | ||||
|                     color: Theme.of(context).appBarTheme.foregroundColor!, | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|               Tab( | ||||
|                 child: Text( | ||||
|                   'stellarProgram'.tr(), | ||||
| @@ -119,6 +128,7 @@ class LevelingScreen extends HookConsumerWidget { | ||||
|         body: TabBarView( | ||||
|           children: [ | ||||
|             _buildLevelingTab(context, ref, user.value!), | ||||
|             const SocialCreditsTab(), | ||||
|             _buildStellarProgramTab(context, ref), | ||||
|           ], | ||||
|         ), | ||||
| @@ -138,7 +148,6 @@ class LevelingScreen extends HookConsumerWidget { | ||||
|     return Center( | ||||
|       child: Container( | ||||
|         padding: const EdgeInsets.symmetric(horizontal: 20), | ||||
|         constraints: const BoxConstraints(maxWidth: 480), | ||||
|         child: CustomScrollView( | ||||
|           slivers: [ | ||||
|             const SliverGap(20), | ||||
| @@ -164,10 +173,33 @@ class LevelingScreen extends HookConsumerWidget { | ||||
|             ), | ||||
|             const SliverGap(16), | ||||
|  | ||||
|             // Stairs visualization with fixed height and horizontal scroll | ||||
|             SliverToBoxAdapter(child: _buildLevelStairs(context, currentLevel)), | ||||
|             const SliverGap(24), | ||||
|  | ||||
|             SliverToBoxAdapter( | ||||
|               child: Card( | ||||
|                 margin: EdgeInsets.zero, | ||||
|                 child: Column( | ||||
|                   crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                   children: [ | ||||
|                     Text( | ||||
|                       '${'levelingProgressLevel'.tr(args: [currentLevel.toString()])} / 120', | ||||
|                       textAlign: TextAlign.start, | ||||
|                       style: Theme.of(context).textTheme.bodySmall, | ||||
|                     ), | ||||
|                     const Gap(8), | ||||
|                     LinearProgressIndicator( | ||||
|                       value: currentLevel / 120, | ||||
|                       minHeight: 10, | ||||
|                       stopIndicatorRadius: 0, | ||||
|                       trackGap: 0, | ||||
|                       color: Theme.of(context).colorScheme.primary, | ||||
|                       backgroundColor: | ||||
|                           Theme.of(context).colorScheme.surfaceContainerHigh, | ||||
|                       borderRadius: BorderRadius.circular(32), | ||||
|                     ), | ||||
|                   ], | ||||
|                 ).padding(horizontal: 16, top: 16, bottom: 12), | ||||
|               ), | ||||
|             ), | ||||
|             const SliverGap(16), | ||||
|             // Leveling History | ||||
|             SliverToBoxAdapter( | ||||
|               child: Text( | ||||
| @@ -239,137 +271,12 @@ class LevelingScreen extends HookConsumerWidget { | ||||
|  | ||||
|     return SingleChildScrollView( | ||||
|       padding: getTabbedPadding(context, horizontal: 20, vertical: 20), | ||||
|       child: Center( | ||||
|         child: ConstrainedBox( | ||||
|           constraints: const BoxConstraints(maxWidth: 480), | ||||
|           child: Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|             children: [ | ||||
|               _buildMembershipSection(context, ref, stellarSubscription), | ||||
|               const Gap(16), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildLevelStairs(BuildContext context, int currentLevel) { | ||||
|     const totalLevels = 14; | ||||
|     const stairHeight = 20.0; | ||||
|     const stairWidth = 50.0; | ||||
|     const containerHeight = 280.0; | ||||
|  | ||||
|     return Container( | ||||
|       height: containerHeight, | ||||
|       decoration: BoxDecoration( | ||||
|         borderRadius: BorderRadius.circular(12), | ||||
|         border: Border.all( | ||||
|           color: Theme.of(context).colorScheme.outline.withOpacity(0.2), | ||||
|         ), | ||||
|       ), | ||||
|       child: SingleChildScrollView( | ||||
|         scrollDirection: Axis.horizontal, | ||||
|         padding: const EdgeInsets.symmetric(horizontal: 16), | ||||
|         child: SizedBox( | ||||
|           width: (totalLevels * (stairWidth + 8)) + 40, | ||||
|           height: containerHeight, | ||||
|           child: CustomPaint( | ||||
|             painter: LevelStairsPainter( | ||||
|               currentLevel: currentLevel, | ||||
|               totalLevels: totalLevels, | ||||
|               primaryColor: Theme.of(context).colorScheme.primary, | ||||
|               surfaceColor: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||
|               onSurfaceColor: Theme.of(context).colorScheme.onSurface, | ||||
|               stairHeight: stairHeight, | ||||
|               stairWidth: stairWidth, | ||||
|             ), | ||||
|             child: Stack( | ||||
|               children: List.generate(totalLevels, (index) { | ||||
|                 final level = index + 1; | ||||
|                 final isCompleted = level <= currentLevel; | ||||
|                 final isCurrent = level == currentLevel; | ||||
|  | ||||
|                 // Calculate position from bottom | ||||
|                 final bottomPosition = 0.0; | ||||
|                 final leftPosition = 20.0 + (index * (stairWidth + 8)); | ||||
|  | ||||
|                 // Make higher levels progressively taller | ||||
|                 final progressiveHeight = | ||||
|                     40.0 + (index * 15.0); // Base height + progressive increase | ||||
|  | ||||
|                 return Positioned( | ||||
|                   left: leftPosition, | ||||
|                   bottom: bottomPosition, | ||||
|                   child: Container( | ||||
|                     width: stairWidth, | ||||
|                     height: progressiveHeight, | ||||
|                     decoration: BoxDecoration( | ||||
|                       color: | ||||
|                           isCompleted | ||||
|                               ? Theme.of(context).colorScheme.primary | ||||
|                               : Theme.of( | ||||
|                                 context, | ||||
|                               ).colorScheme.surfaceContainerHigh, | ||||
|                       borderRadius: const BorderRadius.only( | ||||
|                         topLeft: Radius.circular(6), | ||||
|                         topRight: Radius.circular(6), | ||||
|                       ), | ||||
|                       border: | ||||
|                           isCurrent | ||||
|                               ? Border.all( | ||||
|                                 color: Theme.of(context).colorScheme.primary, | ||||
|                                 width: 2, | ||||
|                               ) | ||||
|                               : null, | ||||
|                       boxShadow: | ||||
|                           isCurrent | ||||
|                               ? [ | ||||
|                                 BoxShadow( | ||||
|                                   color: Theme.of( | ||||
|                                     context, | ||||
|                                   ).colorScheme.primary.withOpacity(0.3), | ||||
|                                   blurRadius: 6, | ||||
|                                   spreadRadius: 1, | ||||
|                                 ), | ||||
|                               ] | ||||
|                               : null, | ||||
|                     ), | ||||
|                     child: Padding( | ||||
|                       padding: const EdgeInsets.only(top: 8), | ||||
|                       child: Column( | ||||
|                         children: [ | ||||
|                           Text( | ||||
|                             level.toString(), | ||||
|                             style: GoogleFonts.robotoMono( | ||||
|                               fontSize: 14, | ||||
|                               fontWeight: FontWeight.bold, | ||||
|                               color: | ||||
|                                   isCompleted | ||||
|                                       ? Theme.of(context).colorScheme.onPrimary | ||||
|                                       : Theme.of(context).colorScheme.onSurface, | ||||
|                             ), | ||||
|                           ), | ||||
|                           if (isCurrent) ...[ | ||||
|                             const Gap(4), | ||||
|                             Container( | ||||
|                               width: 4, | ||||
|                               height: 4, | ||||
|                               decoration: BoxDecoration( | ||||
|                                 color: Theme.of(context).colorScheme.onPrimary, | ||||
|                                 shape: BoxShape.circle, | ||||
|                               ), | ||||
|                             ), | ||||
|                           ], | ||||
|                         ], | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ); | ||||
|               }), | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           _buildMembershipSection(context, ref, stellarSubscription), | ||||
|           const Gap(16), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
							
								
								
									
										556
									
								
								lib/screens/files/file_list.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										556
									
								
								lib/screens/files/file_list.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,556 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:fl_chart/fl_chart.dart'; | ||||
| 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/models/file.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/file_pool.dart'; | ||||
| import 'package:island/utils/format.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/content/file_info_sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| part 'file_list.g.dart'; | ||||
|  | ||||
| @riverpod | ||||
| class CloudFileListNotifier extends _$CloudFileListNotifier | ||||
|     with CursorPagingNotifierMixin<SnCloudFile> { | ||||
|   String? _poolId; | ||||
|   bool _includeRecycled = false; | ||||
|  | ||||
|   void setFilters(String? poolId, bool includeRecycled) { | ||||
|     _poolId = poolId; | ||||
|     _includeRecycled = includeRecycled; | ||||
|     ref.invalidateSelf(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<CursorPagingData<SnCloudFile>> build() => fetch(cursor: null); | ||||
|  | ||||
|   @override | ||||
|   Future<CursorPagingData<SnCloudFile>> fetch({required String? cursor}) async { | ||||
|     final client = ref.read(apiClientProvider); | ||||
|     final offset = cursor == null ? 0 : int.parse(cursor); | ||||
|     final take = 20; | ||||
|  | ||||
|     final queryParameters = <String, dynamic>{'offset': offset, 'take': take}; | ||||
|  | ||||
|     // Add filter parameters | ||||
|     if (_poolId != null) { | ||||
|       queryParameters['pool'] = _poolId!; | ||||
|     } | ||||
|     if (_includeRecycled) { | ||||
|       queryParameters['recycled'] = 'true'; | ||||
|     } | ||||
|  | ||||
|     final response = await client.get( | ||||
|       '/drive/files/me', | ||||
|       queryParameters: queryParameters, | ||||
|     ); | ||||
|  | ||||
|     final List<SnCloudFile> items = | ||||
|         (response.data as List) | ||||
|             .map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>)) | ||||
|             .toList(); | ||||
|     final total = int.parse(response.headers.value('X-Total') ?? '0'); | ||||
|  | ||||
|     final hasMore = offset + items.length < total; | ||||
|     final nextCursor = hasMore ? (offset + items.length).toString() : null; | ||||
|  | ||||
|     return CursorPagingData( | ||||
|       items: items, | ||||
|       hasMore: hasMore, | ||||
|       nextCursor: nextCursor, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @riverpod | ||||
| Future<Map<String, dynamic>?> billingUsage(Ref ref) async { | ||||
|   final client = ref.read(apiClientProvider); | ||||
|   final response = await client.get('/drive/billing/usage'); | ||||
|   return response.data; | ||||
| } | ||||
|  | ||||
| @riverpod | ||||
| Future<Map<String, dynamic>?> billingQuota(Ref ref) async { | ||||
|   final client = ref.read(apiClientProvider); | ||||
|   final response = await client.get('/drive/billing/quota'); | ||||
|   return response.data; | ||||
| } | ||||
|  | ||||
| class FileListScreen extends HookConsumerWidget { | ||||
|   const FileListScreen({super.key}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     // Filter state | ||||
|     final selectedPool = useState<String?>(null); | ||||
|     final includeRecycled = useState(false); | ||||
|  | ||||
|     final usageAsync = ref.watch(billingUsageProvider); | ||||
|     final quotaAsync = ref.watch(billingQuotaProvider); | ||||
|  | ||||
|     // Update notifier filters when state changes | ||||
|     useEffect(() { | ||||
|       final notifier = ref.read(cloudFileListNotifierProvider.notifier); | ||||
|       notifier.setFilters(selectedPool.value, includeRecycled.value); | ||||
|       return null; | ||||
|     }, [selectedPool.value, includeRecycled.value]); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar(title: Text('Files'), leading: const PageBackButton()), | ||||
|       body: usageAsync.when( | ||||
|         data: | ||||
|             (usage) => quotaAsync.when( | ||||
|               data: | ||||
|                   (quota) => _buildQuotaUI( | ||||
|                     usage, | ||||
|                     quota, | ||||
|                     ref, | ||||
|                     selectedPool, | ||||
|                     includeRecycled, | ||||
|                   ), | ||||
|               loading: () => const Center(child: CircularProgressIndicator()), | ||||
|               error: (e, _) => Center(child: Text('Error loading quota')), | ||||
|             ), | ||||
|         loading: () => const Center(child: CircularProgressIndicator()), | ||||
|         error: (e, _) => Center(child: Text('Error loading usage')), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildQuotaUI( | ||||
|     Map<String, dynamic>? usage, | ||||
|     Map<String, dynamic>? quota, | ||||
|     WidgetRef ref, | ||||
|     ValueNotifier<String?> selectedPool, | ||||
|     ValueNotifier<bool> includeRecycled, | ||||
|   ) { | ||||
|     if (usage == null) return const SizedBox.shrink(); | ||||
|     return CustomScrollView( | ||||
|       slivers: [ | ||||
|         const SliverGap(8), | ||||
|         SliverToBoxAdapter( | ||||
|           child: Column( | ||||
|             children: [ | ||||
|               Row( | ||||
|                 children: [ | ||||
|                   Expanded( | ||||
|                     child: _buildStatCard( | ||||
|                       'All Uploads', | ||||
|                       '${((usage['total_usage_bytes'] as num) / (1024 * 1024 * 1024)).toStringAsFixed(3)} GiB', | ||||
|                     ), | ||||
|                   ), | ||||
|                   Expanded( | ||||
|                     child: _buildStatCard( | ||||
|                       'All Files', | ||||
|                       '${usage['total_file_count']}', | ||||
|                     ), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|               Row( | ||||
|                 children: [ | ||||
|                   Expanded( | ||||
|                     child: _buildStatCard( | ||||
|                       'Quota', | ||||
|                       '${usage['total_quota']} MiB', | ||||
|                     ), | ||||
|                   ), | ||||
|                   Expanded( | ||||
|                     child: _buildStatCard( | ||||
|                       'Used Quota', | ||||
|                       '${((usage['used_quota'] as num) / (usage['total_quota'] as num) * 100).toStringAsFixed(2)}%', | ||||
|                       progress: | ||||
|                           (usage['used_quota'] as num) / | ||||
|                           (usage['total_quota'] as num), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ], | ||||
|           ).padding(horizontal: 8), | ||||
|         ), | ||||
|         SliverToBoxAdapter( | ||||
|           child: Row( | ||||
|             children: [ | ||||
|               Expanded( | ||||
|                 child: Card( | ||||
|                   child: Padding( | ||||
|                     padding: const EdgeInsets.all(16), | ||||
|                     child: Column( | ||||
|                       children: [ | ||||
|                         const Text('Pool Usage'), | ||||
|                         SizedBox( | ||||
|                           height: 200, | ||||
|                           child: PieChart(_buildPoolChartData(usage)), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|               Expanded( | ||||
|                 child: Card( | ||||
|                   child: Padding( | ||||
|                     padding: const EdgeInsets.all(16), | ||||
|                     child: Column( | ||||
|                       children: [ | ||||
|                         const Text('Verbose Quota'), | ||||
|                         SizedBox( | ||||
|                           height: 200, | ||||
|                           child: PieChart(_buildQuotaChartData(quota)), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ).padding(horizontal: 8), | ||||
|         ), | ||||
|         const SliverGap(8), | ||||
|         SliverToBoxAdapter( | ||||
|           child: _buildFilters(ref, selectedPool, includeRecycled), | ||||
|         ), | ||||
|         const SliverGap(8), | ||||
|         PagingHelperSliverView( | ||||
|           provider: cloudFileListNotifierProvider, | ||||
|           futureRefreshable: cloudFileListNotifierProvider.future, | ||||
|           notifierRefreshable: cloudFileListNotifierProvider.notifier, | ||||
|           contentBuilder: | ||||
|               (data, widgetCount, endItemView) => SliverList.builder( | ||||
|                 itemCount: widgetCount, | ||||
|                 itemBuilder: (context, index) { | ||||
|                   if (index == widgetCount - 1) { | ||||
|                     return endItemView; | ||||
|                   } | ||||
|  | ||||
|                   final item = data.items[index]; | ||||
|                   final itemType = item.mimeType?.split('/').firstOrNull; | ||||
|                   return ListTile( | ||||
|                     leading: ClipRRect( | ||||
|                       borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|                       child: SizedBox( | ||||
|                         height: 48, | ||||
|                         width: 48, | ||||
|                         child: switch (itemType) { | ||||
|                           'image' => CloudImageWidget(file: item), | ||||
|                           'audio' => | ||||
|                             const Icon(Symbols.audio_file, fill: 1).center(), | ||||
|                           'video' => | ||||
|                             const Icon(Symbols.video_file, fill: 1).center(), | ||||
|                           _ => | ||||
|                             const Icon(Symbols.body_system, fill: 1).center(), | ||||
|                         }, | ||||
|                       ), | ||||
|                     ), | ||||
|                     title: | ||||
|                         item.name.isEmpty | ||||
|                             ? Text('untitled').tr().italic() | ||||
|                             : Text( | ||||
|                               item.name, | ||||
|                               maxLines: 1, | ||||
|                               overflow: TextOverflow.ellipsis, | ||||
|                             ), | ||||
|                     subtitle: Text(formatFileSize(item.size)), | ||||
|                     onTap: () { | ||||
|                       showModalBottomSheet( | ||||
|                         useRootNavigator: true, | ||||
|                         context: context, | ||||
|                         isScrollControlled: true, | ||||
|                         builder: (context) => FileInfoSheet(item: item), | ||||
|                       ); | ||||
|                     }, | ||||
|                     trailing: IconButton( | ||||
|                       icon: const Icon(Symbols.delete), | ||||
|                       onPressed: () async { | ||||
|                         final confirmed = await showConfirmAlert( | ||||
|                           'confirmDeleteFile'.tr(), | ||||
|                           'deleteFile'.tr(), | ||||
|                         ); | ||||
|                         if (!confirmed) return; | ||||
|  | ||||
|                         if (context.mounted) showLoadingModal(context); | ||||
|                         try { | ||||
|                           final client = ref.read(apiClientProvider); | ||||
|                           await client.delete('/drive/files/${item.id}'); | ||||
|                           ref.invalidate(cloudFileListNotifierProvider); | ||||
|                         } catch (e) { | ||||
|                           showSnackBar('failedToDeleteFile'.tr()); | ||||
|                         } finally { | ||||
|                           if (context.mounted) hideLoadingModal(context); | ||||
|                         } | ||||
|                       }, | ||||
|                     ), | ||||
|                   ); | ||||
|                 }, | ||||
|               ), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   PieChartData _buildPoolChartData(Map<String, dynamic> usage) { | ||||
|     final pools = usage['pool_usages'] as List<dynamic>; | ||||
|     final colors = [ | ||||
|       Colors.blue, | ||||
|       Colors.green, | ||||
|       Colors.orange, | ||||
|       Colors.red, | ||||
|       Colors.purple, | ||||
|     ]; | ||||
|     return PieChartData( | ||||
|       sections: | ||||
|           pools.asMap().entries.map((entry) { | ||||
|             final pool = entry.value as Map<String, dynamic>; | ||||
|             final title = pool['pool_name'] as String; | ||||
|             final truncatedTitle = | ||||
|                 title.length > 8 ? '${title.substring(0, 8)}...' : title; | ||||
|             return PieChartSectionData( | ||||
|               value: (pool['usage_bytes'] as num).toDouble(), | ||||
|               title: truncatedTitle, | ||||
|               color: colors[entry.key % colors.length], | ||||
|               radius: 60, | ||||
|               titleStyle: const TextStyle( | ||||
|                 fontSize: 12, | ||||
|                 color: Colors.white, | ||||
|                 fontWeight: FontWeight.bold, | ||||
|               ), | ||||
|             ); | ||||
|           }).toList(), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   PieChartData _buildQuotaChartData(Map<String, dynamic>? quota) { | ||||
|     if (quota == null) return PieChartData(sections: []); | ||||
|     return PieChartData( | ||||
|       sections: [ | ||||
|         PieChartSectionData( | ||||
|           value: (quota['based_quota'] as num).toDouble(), | ||||
|           title: 'Base', | ||||
|           color: Colors.green, | ||||
|           radius: 60, | ||||
|           titleStyle: const TextStyle( | ||||
|             fontSize: 12, | ||||
|             color: Colors.white, | ||||
|             fontWeight: FontWeight.bold, | ||||
|           ), | ||||
|         ), | ||||
|         PieChartSectionData( | ||||
|           value: (quota['extra_quota'] as num).toDouble(), | ||||
|           title: 'Extra', | ||||
|           color: Colors.orange, | ||||
|           radius: 60, | ||||
|           titleStyle: const TextStyle( | ||||
|             fontSize: 12, | ||||
|             color: Colors.white, | ||||
|             fontWeight: FontWeight.bold, | ||||
|           ), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildFilters( | ||||
|     WidgetRef ref, | ||||
|     ValueNotifier<String?> selectedPool, | ||||
|     ValueNotifier<bool> includeRecycled, | ||||
|   ) { | ||||
|     final poolsAsync = ref.watch(poolsProvider); | ||||
|  | ||||
|     return Card( | ||||
|       child: Padding( | ||||
|         padding: const EdgeInsets.all(16), | ||||
|         child: Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           children: [ | ||||
|             Text( | ||||
|               'filters'.tr(), | ||||
|               style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), | ||||
|             ), | ||||
|             const Gap(16), | ||||
|             LayoutBuilder( | ||||
|               builder: (context, constraints) { | ||||
|                 final isWide = constraints.maxWidth > 600; | ||||
|                 return isWide | ||||
|                     ? Row( | ||||
|                       children: [ | ||||
|                         Expanded( | ||||
|                           flex: 2, | ||||
|                           child: poolsAsync.when( | ||||
|                             data: | ||||
|                                 (pools) => DropdownButtonFormField<String?>( | ||||
|                                   value: selectedPool.value, | ||||
|                                   decoration: InputDecoration( | ||||
|                                     labelText: 'Pool', | ||||
|                                     border: const OutlineInputBorder(), | ||||
|                                   ), | ||||
|                                   items: [ | ||||
|                                     DropdownMenuItem<String?>( | ||||
|                                       value: null, | ||||
|                                       child: Text('allPools'.tr()), | ||||
|                                     ), | ||||
|                                     ...pools.map( | ||||
|                                       (pool) => DropdownMenuItem<String?>( | ||||
|                                         value: pool.id, | ||||
|                                         child: Text(pool.name), | ||||
|                                       ), | ||||
|                                     ), | ||||
|                                   ], | ||||
|                                   onChanged: | ||||
|                                       (value) => selectedPool.value = value, | ||||
|                                 ), | ||||
|                             loading: () => const CircularProgressIndicator(), | ||||
|                             error: (e, _) => const Text('Error loading pools'), | ||||
|                           ), | ||||
|                         ), | ||||
|                         const Gap(8), | ||||
|                         Expanded( | ||||
|                           child: Row( | ||||
|                             children: [ | ||||
|                               Text('includeRecycled'.tr()), | ||||
|                               const Gap(8), | ||||
|                               Switch( | ||||
|                                 value: includeRecycled.value, | ||||
|                                 onChanged: | ||||
|                                     (value) => includeRecycled.value = value, | ||||
|                                 padding: EdgeInsets.zero, | ||||
|                               ), | ||||
|                             ], | ||||
|                           ), | ||||
|                         ), | ||||
|                         const Gap(16), | ||||
|                         IconButton( | ||||
|                           icon: const Icon(Symbols.delete_sweep), | ||||
|                           tooltip: 'deleteRecycledFiles'.tr(), | ||||
|                           onPressed: | ||||
|                               includeRecycled.value | ||||
|                                   ? () => _deleteRecycledFiles(ref) | ||||
|                                   : null, | ||||
|                         ), | ||||
|                       ], | ||||
|                     ) | ||||
|                     : Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         poolsAsync.when( | ||||
|                           data: | ||||
|                               (pools) => DropdownButtonFormField<String?>( | ||||
|                                 value: selectedPool.value, | ||||
|                                 decoration: const InputDecoration( | ||||
|                                   labelText: 'Pool', | ||||
|                                   border: OutlineInputBorder(), | ||||
|                                 ), | ||||
|                                 items: [ | ||||
|                                   DropdownMenuItem<String?>( | ||||
|                                     value: null, | ||||
|                                     child: Text('allPools'.tr()), | ||||
|                                   ), | ||||
|                                   ...pools.map( | ||||
|                                     (pool) => DropdownMenuItem<String?>( | ||||
|                                       value: pool.id, | ||||
|                                       child: Text(pool.name), | ||||
|                                     ), | ||||
|                                   ), | ||||
|                                 ], | ||||
|                                 onChanged: | ||||
|                                     (value) => selectedPool.value = value, | ||||
|                               ), | ||||
|                           loading: () => const CircularProgressIndicator(), | ||||
|                           error: (e, _) => const Text('Error loading pools'), | ||||
|                         ), | ||||
|                         const Gap(16), | ||||
|                         Row( | ||||
|                           children: [ | ||||
|                             Text('includeRecycled'.tr()), | ||||
|                             const Gap(8), | ||||
|                             Switch( | ||||
|                               value: includeRecycled.value, | ||||
|                               onChanged: | ||||
|                                   (value) => includeRecycled.value = value, | ||||
|                             ), | ||||
|                             const Spacer(), | ||||
|                             IconButton( | ||||
|                               icon: const Icon(Symbols.delete_sweep), | ||||
|                               tooltip: 'deleteRecycledFiles'.tr(), | ||||
|                               onPressed: | ||||
|                                   includeRecycled.value | ||||
|                                       ? () => _deleteRecycledFiles(ref) | ||||
|                                       : null, | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                       ], | ||||
|                     ); | ||||
|               }, | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ).padding(horizontal: 8); | ||||
|   } | ||||
|  | ||||
|   Future<void> _deleteRecycledFiles(WidgetRef ref) async { | ||||
|     final confirmed = await showConfirmAlert( | ||||
|       'confirmDeleteRecycledFiles'.tr(), | ||||
|       'deleteRecycledFiles'.tr(), | ||||
|     ); | ||||
|     if (!confirmed) return; | ||||
|  | ||||
|     if (ref.context.mounted) showLoadingModal(ref.context); | ||||
|     try { | ||||
|       final client = ref.read(apiClientProvider); | ||||
|       await client.delete('/drive/files/recycled'); | ||||
|       ref.invalidate(cloudFileListNotifierProvider); | ||||
|       showSnackBar('recycledFilesDeleted'.tr()); | ||||
|     } catch (e) { | ||||
|       showSnackBar('failedToDeleteRecycledFiles'.tr()); | ||||
|     } finally { | ||||
|       if (ref.context.mounted) hideLoadingModal(ref.context); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Widget _buildStatCard(String label, String value, {double? progress}) { | ||||
|     return Card( | ||||
|       child: Padding( | ||||
|         padding: const EdgeInsets.all(16), | ||||
|         child: Column( | ||||
|           mainAxisAlignment: MainAxisAlignment.center, | ||||
|           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|           children: [ | ||||
|             Text(label, style: const TextStyle(fontSize: 14)), | ||||
|             Row( | ||||
|               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|               children: [ | ||||
|                 Text( | ||||
|                   value, | ||||
|                   style: const TextStyle( | ||||
|                     fontSize: 24, | ||||
|                     fontWeight: FontWeight.bold, | ||||
|                   ), | ||||
|                 ), | ||||
|                 if (progress != null) ...[ | ||||
|                   const SizedBox(height: 8), | ||||
|                   SizedBox( | ||||
|                     width: 28, | ||||
|                     height: 28, | ||||
|                     child: CircularProgressIndicator(value: progress), | ||||
|                   ), | ||||
|                 ], | ||||
|               ], | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										69
									
								
								lib/screens/files/file_list.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								lib/screens/files/file_list.g.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
|  | ||||
| part of 'file_list.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // RiverpodGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| String _$billingUsageHash() => r'58d8bc774868d60781574c85d6b25869a79c57aa'; | ||||
|  | ||||
| /// See also [billingUsage]. | ||||
| @ProviderFor(billingUsage) | ||||
| final billingUsageProvider = | ||||
|     AutoDisposeFutureProvider<Map<String, dynamic>?>.internal( | ||||
|       billingUsage, | ||||
|       name: r'billingUsageProvider', | ||||
|       debugGetCreateSourceHash: | ||||
|           const bool.fromEnvironment('dart.vm.product') | ||||
|               ? null | ||||
|               : _$billingUsageHash, | ||||
|       dependencies: null, | ||||
|       allTransitiveDependencies: null, | ||||
|     ); | ||||
|  | ||||
| @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||
| // ignore: unused_element | ||||
| typedef BillingUsageRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>; | ||||
| String _$billingQuotaHash() => r'4ec5d728e439015800abb2d0d673b5a7329cc654'; | ||||
|  | ||||
| /// See also [billingQuota]. | ||||
| @ProviderFor(billingQuota) | ||||
| final billingQuotaProvider = | ||||
|     AutoDisposeFutureProvider<Map<String, dynamic>?>.internal( | ||||
|       billingQuota, | ||||
|       name: r'billingQuotaProvider', | ||||
|       debugGetCreateSourceHash: | ||||
|           const bool.fromEnvironment('dart.vm.product') | ||||
|               ? null | ||||
|               : _$billingQuotaHash, | ||||
|       dependencies: null, | ||||
|       allTransitiveDependencies: null, | ||||
|     ); | ||||
|  | ||||
| @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||
| // ignore: unused_element | ||||
| typedef BillingQuotaRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>; | ||||
| String _$cloudFileListNotifierHash() => | ||||
|     r'22c45a8ea23147a3835ba870ad2f0bb833f853ea'; | ||||
|  | ||||
| /// See also [CloudFileListNotifier]. | ||||
| @ProviderFor(CloudFileListNotifier) | ||||
| final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider< | ||||
|   CloudFileListNotifier, | ||||
|   CursorPagingData<SnCloudFile> | ||||
| >.internal( | ||||
|   CloudFileListNotifier.new, | ||||
|   name: r'cloudFileListNotifierProvider', | ||||
|   debugGetCreateSourceHash: | ||||
|       const bool.fromEnvironment('dart.vm.product') | ||||
|           ? null | ||||
|           : _$cloudFileListNotifierHash, | ||||
|   dependencies: null, | ||||
|   allTransitiveDependencies: null, | ||||
| ); | ||||
|  | ||||
| typedef _$CloudFileListNotifier = | ||||
|     AutoDisposeAsyncNotifier<CursorPagingData<SnCloudFile>>; | ||||
| // ignore_for_file: type=lint | ||||
| // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package | ||||
| @@ -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) | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import 'package:island/screens/creators/publishers.dart'; | ||||
| import 'package:island/screens/posts/compose_article.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/attachment_uploader.dart'; | ||||
| import 'package:island/widgets/content/attachment_preview.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/post/compose_shared.dart'; | ||||
| @@ -225,8 +226,26 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|           return AttachmentPreview( | ||||
|             item: state.attachments.value[idx], | ||||
|             progress: progressMap[idx], | ||||
|             onRequestUpload: | ||||
|                 () => ComposeLogic.uploadAttachment(ref, state, idx), | ||||
|             onRequestUpload: () async { | ||||
|               final config = await showModalBottomSheet<AttachmentUploadConfig>( | ||||
|                 context: context, | ||||
|                 isScrollControlled: true, | ||||
|                 builder: | ||||
|                     (context) => AttachmentUploaderSheet( | ||||
|                       ref: ref, | ||||
|                       state: state, | ||||
|                       index: idx, | ||||
|                     ), | ||||
|               ); | ||||
|               if (config != null) { | ||||
|                 await ComposeLogic.uploadAttachment( | ||||
|                   ref, | ||||
|                   state, | ||||
|                   idx, | ||||
|                   poolId: config.poolId, | ||||
|                 ); | ||||
|               } | ||||
|             }, | ||||
|             onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx), | ||||
|             onUpdate: | ||||
|                 (value) => ComposeLogic.updateAttachment(state, value, idx), | ||||
| @@ -253,8 +272,27 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|                 return AttachmentPreview( | ||||
|                   item: state.attachments.value[idx], | ||||
|                   progress: progressMap[idx], | ||||
|                   onRequestUpload: | ||||
|                       () => ComposeLogic.uploadAttachment(ref, state, idx), | ||||
|                   onRequestUpload: () async { | ||||
|                     final config = | ||||
|                         await showModalBottomSheet<AttachmentUploadConfig>( | ||||
|                           context: context, | ||||
|                           isScrollControlled: true, | ||||
|                           builder: | ||||
|                               (context) => AttachmentUploaderSheet( | ||||
|                                 ref: ref, | ||||
|                                 state: state, | ||||
|                                 index: idx, | ||||
|                               ), | ||||
|                         ); | ||||
|                     if (config != null) { | ||||
|                       await ComposeLogic.uploadAttachment( | ||||
|                         ref, | ||||
|                         state, | ||||
|                         idx, | ||||
|                         poolId: config.poolId, | ||||
|                       ); | ||||
|                     } | ||||
|                   }, | ||||
|                   onDelete: | ||||
|                       () => ComposeLogic.deleteAttachment(ref, state, idx), | ||||
|                   onUpdate: | ||||
|   | ||||
| @@ -11,6 +11,7 @@ import 'package:island/models/post.dart'; | ||||
| import 'package:island/screens/creators/publishers.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/attachment_uploader.dart'; | ||||
| import 'package:island/screens/posts/post_detail.dart'; | ||||
| import 'package:island/widgets/content/attachment_preview.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| @@ -345,12 +346,30 @@ class ArticleComposeScreen extends HookConsumerWidget { | ||||
|                                       isCompact: true, | ||||
|                                       item: attachments[idx], | ||||
|                                       progress: progressMap[idx], | ||||
|                                       onRequestUpload: | ||||
|                                           () => ComposeLogic.uploadAttachment( | ||||
|                                       onRequestUpload: () async { | ||||
|                                         final config = | ||||
|                                             await showModalBottomSheet< | ||||
|                                               AttachmentUploadConfig | ||||
|                                             >( | ||||
|                                               context: context, | ||||
|                                               isScrollControlled: true, | ||||
|                                               builder: | ||||
|                                                   (context) => | ||||
|                                                       AttachmentUploaderSheet( | ||||
|                                                         ref: ref, | ||||
|                                                         state: state, | ||||
|                                                         index: idx, | ||||
|                                                       ), | ||||
|                                             ); | ||||
|                                         if (config != null) { | ||||
|                                           await ComposeLogic.uploadAttachment( | ||||
|                                             ref, | ||||
|                                             state, | ||||
|                                             idx, | ||||
|                                           ), | ||||
|                                             poolId: config.poolId, | ||||
|                                           ); | ||||
|                                         } | ||||
|                                       }, | ||||
|                                       onUpdate: | ||||
|                                           (value) => | ||||
|                                               ComposeLogic.updateAttachment( | ||||
|   | ||||
| @@ -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/pods/userinfo.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/file_pool.dart'; | ||||
|  | ||||
| class SettingsScreen extends HookConsumerWidget { | ||||
|   const SettingsScreen({super.key}); | ||||
| @@ -33,7 +35,8 @@ class SettingsScreen extends HookConsumerWidget { | ||||
|     final isDesktop = | ||||
|         !kIsWeb && (Platform.isWindows || Platform.isMacOS || Platform.isLinux); | ||||
|     final isWide = isWideScreen(context); | ||||
|  | ||||
|     final pools = ref.watch(poolsProvider); | ||||
|     final user = ref.watch(userInfoProvider); | ||||
|     final docBasepath = useState<String?>(null); | ||||
|  | ||||
|     useEffect(() { | ||||
| @@ -127,6 +130,48 @@ class SettingsScreen extends HookConsumerWidget { | ||||
|         ), | ||||
|       ), | ||||
|  | ||||
|       // Message display style settings | ||||
|       ListTile( | ||||
|         minLeadingWidth: 48, | ||||
|         title: Text('settingsMessageDisplayStyle').tr(), | ||||
|         contentPadding: const EdgeInsets.only(left: 24, right: 17), | ||||
|         leading: const Icon(Symbols.chat), | ||||
|         trailing: DropdownButtonHideUnderline( | ||||
|           child: DropdownButton2<String>( | ||||
|             isExpanded: true, | ||||
|             items: [ | ||||
|               DropdownMenuItem<String>( | ||||
|                 value: 'bubble', | ||||
|                 child: Text('Bubble').fontSize(14), | ||||
|               ), | ||||
|               DropdownMenuItem<String>( | ||||
|                 value: 'column', | ||||
|                 child: Text('Column').fontSize(14), | ||||
|               ), | ||||
|               DropdownMenuItem<String>( | ||||
|                 value: 'compact', | ||||
|                 child: Text('Compact').fontSize(14), | ||||
|               ), | ||||
|             ], | ||||
|             value: settings.messageDisplayStyle, | ||||
|             onChanged: (String? value) { | ||||
|               if (value != null) { | ||||
|                 ref | ||||
|                     .read(appSettingsNotifierProvider.notifier) | ||||
|                     .setMessageDisplayStyle(value); | ||||
|                 showSnackBar('settingsApplied'.tr()); | ||||
|               } | ||||
|             }, | ||||
|             buttonStyleData: const ButtonStyleData( | ||||
|               padding: EdgeInsets.symmetric(horizontal: 16, vertical: 5), | ||||
|               height: 40, | ||||
|               width: 140, | ||||
|             ), | ||||
|             menuItemStyleData: const MenuItemStyleData(height: 40), | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|  | ||||
|       // Color scheme settings | ||||
|       ListTile( | ||||
|         minLeadingWidth: 48, | ||||
| @@ -293,24 +338,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 +412,71 @@ class SettingsScreen extends HookConsumerWidget { | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|  | ||||
|       if (user.value != null) | ||||
|         pools.when( | ||||
|           data: (data) { | ||||
|             final validPools = data; | ||||
|             final currentPoolId = resolveDefaultPoolId(ref, data); | ||||
|  | ||||
|             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( | ||||
|                 '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: Tooltip( | ||||
|                             message: p.name, | ||||
|                             child: Text( | ||||
|                               p.name, | ||||
|                               maxLines: 1, | ||||
|                               overflow: TextOverflow.ellipsis, | ||||
|                             ).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: 120, | ||||
|                   ), | ||||
|                   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 = [ | ||||
|   | ||||
							
								
								
									
										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); | ||||
| }); | ||||
| @@ -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() { | ||||
|   | ||||
							
								
								
									
										122
									
								
								lib/utils/activity_utils.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								lib/utils/activity_utils.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,122 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
|  | ||||
| String? getActivityTitle(String? label, Map<String, dynamic>? meta) { | ||||
|   if (meta == null) return label; | ||||
|   if (meta['assets']?['large_text'] is String) { | ||||
|     return meta['assets']?['large_text']; | ||||
|   } | ||||
|   return label; | ||||
| } | ||||
|  | ||||
| String? getActivitySubtitle(Map<String, dynamic>? meta) { | ||||
|   if (meta == null) return null; | ||||
|   if (meta['assets']?['small_text'] is String) { | ||||
|     return meta['assets']?['small_text']; | ||||
|   } | ||||
|   return null; | ||||
| } | ||||
|  | ||||
| InlineSpan getActivityFullMessage(SnAccountStatus? status) { | ||||
|   if (status?.meta == null) return TextSpan(text: 'No activity details available'); | ||||
|   final meta = status!.meta!; | ||||
|   final List<InlineSpan> spans = []; | ||||
|   if (meta.containsKey('assets') && meta['assets'] is Map) { | ||||
|     final assets = meta['assets'] as Map<String, dynamic>; | ||||
|     if (assets.containsKey('large_text')) { | ||||
|       spans.add(TextSpan(text: assets['large_text'], style: TextStyle(fontWeight: FontWeight.bold))); | ||||
|     } | ||||
|     if (assets.containsKey('small_text')) { | ||||
|       if (spans.isNotEmpty) spans.add(TextSpan(text: '\n')); | ||||
|       spans.add(TextSpan(text: assets['small_text'])); | ||||
|     } | ||||
|   } | ||||
|   String normalText = ''; | ||||
|   if (meta.containsKey('details')) { | ||||
|     normalText += 'Details: ${meta['details']}\n'; | ||||
|   } | ||||
|   if (meta.containsKey('state')) { | ||||
|     normalText += 'State: ${meta['state']}\n'; | ||||
|   } | ||||
|   if (meta.containsKey('timestamps') && meta['timestamps'] is Map) { | ||||
|     final ts = meta['timestamps'] as Map<String, dynamic>; | ||||
|     if (ts.containsKey('start') && ts['start'] is int) { | ||||
|       final start = DateTime.fromMillisecondsSinceEpoch(ts['start'] * 1000); | ||||
|       normalText += 'Started: ${start.toLocal()}\n'; | ||||
|     } | ||||
|     if (ts.containsKey('end') && ts['end'] is int) { | ||||
|       final end = DateTime.fromMillisecondsSinceEpoch(ts['end'] * 1000); | ||||
|       normalText += 'Ends: ${end.toLocal()}\n'; | ||||
|     } | ||||
|   } | ||||
|   if (meta.containsKey('party') && meta['party'] is Map) { | ||||
|     final party = meta['party'] as Map<String, dynamic>; | ||||
|     if (party.containsKey('size') && party['size'] is List && party['size'].length >= 2) { | ||||
|       final size = party['size'] as List; | ||||
|       normalText += 'Party: ${size[0]}/${size[1]}\n'; | ||||
|     } | ||||
|   } | ||||
|   if (meta.containsKey('instance')) { | ||||
|     normalText += 'Instance: ${meta['instance']}\n'; | ||||
|   } | ||||
|   // Add other keys if present | ||||
|   meta.forEach((key, value) { | ||||
|     if (!['details', 'state', 'timestamps', 'assets', 'party', 'secrets', 'instance'].contains(key)) { | ||||
|       normalText += '$key: $value\n'; | ||||
|     } | ||||
|   }); | ||||
|   if (normalText.isNotEmpty) { | ||||
|     if (spans.isNotEmpty) spans.add(TextSpan(text: '\n')); | ||||
|     spans.add(TextSpan(text: normalText.trimRight())); | ||||
|   } | ||||
|   return TextSpan(children: spans); | ||||
| } | ||||
|  | ||||
| Widget buildActivityDetails(SnAccountStatus? status) { | ||||
|   if (status?.meta == null) return Text('No activity details available'); | ||||
|   final meta = status!.meta!; | ||||
|   final List<Widget> children = []; | ||||
|   if (meta.containsKey('assets') && meta['assets'] is Map) { | ||||
|     final assets = meta['assets'] as Map<String, dynamic>; | ||||
|     if (assets.containsKey('large_text')) { | ||||
|       children.add(Text(assets['large_text'])); | ||||
|     } | ||||
|     if (assets.containsKey('small_text')) { | ||||
|       children.add(Text(assets['small_text'])); | ||||
|     } | ||||
|   } | ||||
|   if (meta.containsKey('details')) { | ||||
|     children.add(Text('Details: ${meta['details']}')); | ||||
|   } | ||||
|   if (meta.containsKey('state')) { | ||||
|     children.add(Text('State: ${meta['state']}')); | ||||
|   } | ||||
|   if (meta.containsKey('timestamps') && meta['timestamps'] is Map) { | ||||
|     final ts = meta['timestamps'] as Map<String, dynamic>; | ||||
|     if (ts.containsKey('start') && ts['start'] is int) { | ||||
|       final start = DateTime.fromMillisecondsSinceEpoch(ts['start'] * 1000); | ||||
|       children.add(Text('Started: ${start.toLocal()}')); | ||||
|     } | ||||
|     if (ts.containsKey('end') && ts['end'] is int) { | ||||
|       final end = DateTime.fromMillisecondsSinceEpoch(ts['end'] * 1000); | ||||
|       children.add(Text('Ends: ${end.toLocal()}')); | ||||
|     } | ||||
|   } | ||||
|   if (meta.containsKey('party') && meta['party'] is Map) { | ||||
|     final party = meta['party'] as Map<String, dynamic>; | ||||
|     if (party.containsKey('size') && party['size'] is List && party['size'].length >= 2) { | ||||
|       final size = party['size'] as List; | ||||
|       children.add(Text('Party: ${size[0]}/${size[1]}')); | ||||
|     } | ||||
|   } | ||||
|   if (meta.containsKey('instance')) { | ||||
|     children.add(Text('Instance: ${meta['instance']}')); | ||||
|   } | ||||
|   // Add other keys if present | ||||
|   children.addAll(meta.entries.where((e) => !['details', 'state', 'timestamps', 'assets', 'party', 'secrets', 'instance'].contains(e.key)).map((e) => Text('${e.key}: ${e.value}'))); | ||||
|   return Column( | ||||
|     crossAxisAlignment: CrossAxisAlignment.start, | ||||
|     mainAxisSize: MainAxisSize.min, | ||||
|     children: children, | ||||
|   ); | ||||
| } | ||||
| @@ -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,117 @@ 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(), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -147,6 +147,7 @@ class AccountProfileCard extends HookConsumerWidget { | ||||
|                       if (data.badges.isNotEmpty) | ||||
|                         BadgeList(badges: data.badges).padding(top: 12), | ||||
|                       LevelingProgressCard( | ||||
|                         isCompact: true, | ||||
|                         level: data.profile.level, | ||||
|                         experience: data.profile.experience, | ||||
|                         progress: data.profile.levelingProgress, | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:island/models/activity.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
| import 'package:island/utils/activity_utils.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @@ -75,7 +76,10 @@ class EventDetailsWidget extends StatelessWidget { | ||||
|                       child: Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         children: [ | ||||
|                           Text(status.label), | ||||
|                           if ((getActivityTitle(status.label, status.meta) ?? status.label).isNotEmpty) | ||||
|                             Text(getActivityTitle(status.label, status.meta) ?? status.label), | ||||
|                           if (getActivitySubtitle(status.meta) != null) | ||||
|                             Text(getActivitySubtitle(status.meta)!).fontSize(11).opacity(0.8), | ||||
|                           Text( | ||||
|                             '${status.createdAt.formatSystem()} - ${status.clearedAt?.formatSystem() ?? 'present'.tr()}', | ||||
|                           ).fontSize(11).opacity(0.8), | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:google_fonts/google_fonts.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @@ -8,50 +7,152 @@ class LevelingProgressCard extends StatelessWidget { | ||||
|   final int level; | ||||
|   final int experience; | ||||
|   final double progress; | ||||
|   final VoidCallback? onTap; | ||||
|   final bool isCompact; | ||||
|  | ||||
|   const LevelingProgressCard({ | ||||
|     super.key, | ||||
|     required this.level, | ||||
|     required this.experience, | ||||
|     required this.progress, | ||||
|     this.onTap, | ||||
|     this.isCompact = false, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Card( | ||||
|     // Calculate level stage (1-12, each stage covers 10 levels) | ||||
|     int stage = ((level - 1) ~/ 10) + 1; | ||||
|     stage = stage.clamp(1, 12); // Ensure stage is within 1-12 | ||||
|  | ||||
|     // Define colors for each stage | ||||
|     const List<Color> stageColors = [ | ||||
|       Colors.green, | ||||
|       Colors.blue, | ||||
|       Colors.teal, | ||||
|       Colors.cyan, | ||||
|       Colors.indigo, | ||||
|       Colors.lime, | ||||
|       Colors.yellow, | ||||
|       Colors.amber, | ||||
|       Colors.orange, | ||||
|       Colors.deepOrange, | ||||
|       Colors.pink, | ||||
|       Colors.red, | ||||
|     ]; | ||||
|  | ||||
|     Color stageColor = stageColors[stage - 1]; | ||||
|  | ||||
|     // Compact mode adjustments | ||||
|     final double levelFontSize = isCompact ? 14 : 18; | ||||
|     final double stageFontSize = isCompact ? 13 : 14; | ||||
|     final double experienceFontSize = isCompact ? 12 : 14; | ||||
|     final double progressHeight = isCompact ? 6 : 10; | ||||
|     final double horizontalPadding = isCompact ? 16 : 20; | ||||
|     final double verticalPadding = isCompact ? 12 : 16; | ||||
|     final double gapSize = isCompact ? 4 : 8; | ||||
|     final double rowSpacing = 12; | ||||
|  | ||||
|     final cardContent = Card( | ||||
|       margin: EdgeInsets.zero, | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           Row( | ||||
|             spacing: 8, | ||||
|             crossAxisAlignment: CrossAxisAlignment.baseline, | ||||
|             mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|             textBaseline: TextBaseline.alphabetic, | ||||
|             children: [ | ||||
|               Text( | ||||
|                 'levelingProgressLevel'.tr(args: [level.toString()]), | ||||
|                 style: GoogleFonts.robotoMono(), | ||||
|               ).fontSize(13).bold(), | ||||
|               Text( | ||||
|                 'levelingProgressExperience'.tr(args: [experience.toString()]), | ||||
|                 style: GoogleFonts.robotoMono(), | ||||
|               ).fontSize(13), | ||||
|             ], | ||||
|           ), | ||||
|           const Gap(8), | ||||
|           Tooltip( | ||||
|             message: '${(progress).toStringAsFixed(1)}%', | ||||
|             child: LinearProgressIndicator( | ||||
|               minHeight: 4, | ||||
|               value: progress / 100, | ||||
|               color: Theme.of(context).colorScheme.primary, | ||||
|               backgroundColor: | ||||
|                   Theme.of(context).colorScheme.surfaceContainerHigh, | ||||
|       shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), | ||||
|       child: InkWell( | ||||
|         onTap: onTap, | ||||
|         borderRadius: BorderRadius.circular(12), | ||||
|         child: Container( | ||||
|           decoration: BoxDecoration( | ||||
|             borderRadius: BorderRadius.circular(12), | ||||
|             gradient: LinearGradient( | ||||
|               colors: [ | ||||
|                 stageColor.withOpacity(0.1), | ||||
|                 Theme.of(context).colorScheme.surface, | ||||
|               ], | ||||
|               begin: Alignment.topLeft, | ||||
|               end: Alignment.bottomRight, | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ).padding(horizontal: 16, vertical: 12), | ||||
|           child: Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|             children: [ | ||||
|               Row( | ||||
|                 spacing: rowSpacing, | ||||
|                 crossAxisAlignment: CrossAxisAlignment.baseline, | ||||
|                 mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                 textBaseline: TextBaseline.alphabetic, | ||||
|                 children: [ | ||||
|                   Expanded( | ||||
|                     child: Text( | ||||
|                       'levelingProgressLevel'.tr(args: [level.toString()]), | ||||
|                       style: TextStyle( | ||||
|                         color: stageColor, | ||||
|                         fontWeight: FontWeight.bold, | ||||
|                         fontSize: levelFontSize, | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                   Row( | ||||
|                     mainAxisSize: MainAxisSize.min, | ||||
|                     children: [ | ||||
|                       Text( | ||||
|                         'levelingStage$stage'.tr(), | ||||
|                         style: TextStyle( | ||||
|                           color: stageColor.withOpacity(0.7), | ||||
|                           fontWeight: FontWeight.w500, | ||||
|                           fontSize: stageFontSize, | ||||
|                         ), | ||||
|                       ), | ||||
|                       if (onTap != null) ...[ | ||||
|                         const Gap(4), | ||||
|                         Icon( | ||||
|                           Icons.arrow_forward_ios, | ||||
|                           size: isCompact ? 10 : 12, | ||||
|                           color: stageColor.withOpacity(0.7), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ], | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|               Gap(gapSize), | ||||
|               Row( | ||||
|                 spacing: rowSpacing, | ||||
|                 crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                 children: [ | ||||
|                   Expanded( | ||||
|                     child: Tooltip( | ||||
|                       message: '${progress.toStringAsFixed(1)}%', | ||||
|                       child: LinearProgressIndicator( | ||||
|                         minHeight: progressHeight, | ||||
|                         value: progress, | ||||
|                         borderRadius: BorderRadius.circular(32), | ||||
|                         backgroundColor: Theme.of( | ||||
|                           context, | ||||
|                         ).colorScheme.surfaceContainerLow.withOpacity(0.75), | ||||
|                         color: stageColor, | ||||
|                         stopIndicatorRadius: 0, | ||||
|                         trackGap: 0, | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                   Text( | ||||
|                     'levelingProgressExperience'.tr( | ||||
|                       args: [experience.toString()], | ||||
|                     ), | ||||
|                     style: TextStyle( | ||||
|                       color: Theme.of( | ||||
|                         context, | ||||
|                       ).colorScheme.onSurface.withOpacity(0.8), | ||||
|                       fontSize: experienceFontSize, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ], | ||||
|           ).padding(horizontal: horizontalPadding, vertical: verticalPadding), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|  | ||||
|     return cardContent; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -4,8 +4,10 @@ import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/screens/account/profile.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
| import 'package:island/utils/activity_utils.dart'; | ||||
| import 'package:island/widgets/account/status_creation.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| @@ -13,8 +15,31 @@ import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| part 'status.g.dart'; | ||||
|  | ||||
| class CurrentAccountStatusNotifier extends StateNotifier<SnAccountStatus?> { | ||||
|   CurrentAccountStatusNotifier() : super(null); | ||||
|  | ||||
|   void setStatus(SnAccountStatus status) { | ||||
|     state = status; | ||||
|   } | ||||
|  | ||||
|   void clearStatus() { | ||||
|     state = null; | ||||
|   } | ||||
| } | ||||
|  | ||||
| final currentAccountStatusProvider = StateNotifierProvider<CurrentAccountStatusNotifier, SnAccountStatus?>((ref) { | ||||
|   return CurrentAccountStatusNotifier(); | ||||
| }); | ||||
|  | ||||
| @riverpod | ||||
| Future<SnAccountStatus?> accountStatus(Ref ref, String uname) async { | ||||
|   final userInfo = ref.watch(userInfoProvider); | ||||
|   if (uname == 'me' || (userInfo.value != null && uname == userInfo.value!.name)) { | ||||
|     final local = ref.watch(currentAccountStatusProvider); | ||||
|     if (local != null) { | ||||
|       return local; | ||||
|     } | ||||
|   } | ||||
|   final apiClient = ref.watch(apiClientProvider); | ||||
|   try { | ||||
|     final resp = await apiClient.get('/id/accounts/$uname/statuses'); | ||||
| @@ -110,7 +135,11 @@ class AccountStatusWidget extends HookConsumerWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final status = ref.watch(accountStatusProvider(uname)); | ||||
|     final userInfo = ref.watch(userInfoProvider); | ||||
|     final localStatus = ref.watch(currentAccountStatusProvider); | ||||
|     final status = (uname == 'me' || (userInfo.value != null && uname == userInfo.value!.name && localStatus != null)) | ||||
|         ? AsyncValue.data(localStatus) | ||||
|         : ref.watch(accountStatusProvider(uname)); | ||||
|     final account = ref.watch(accountProvider(uname)); | ||||
|  | ||||
|     return Padding( | ||||
| @@ -133,10 +162,31 @@ class AccountStatusWidget extends HookConsumerWidget { | ||||
|             ).padding(right: 4), | ||||
|           if (status.value?.isCustomized ?? false) | ||||
|             Flexible( | ||||
|               child: Text( | ||||
|                 status.value?.label ?? 'unknown'.tr(), | ||||
|                 maxLines: 1, | ||||
|                 overflow: TextOverflow.ellipsis, | ||||
|               child: GestureDetector( | ||||
|                 onLongPress: () { | ||||
|                   showDialog( | ||||
|                     context: context, | ||||
|                     builder: (context) => AlertDialog( | ||||
|                       title: Text('Activity Details'), | ||||
|                       content: buildActivityDetails(status.value), | ||||
|                       actions: [ | ||||
|                         TextButton( | ||||
|                           onPressed: () => Navigator.of(context).pop(), | ||||
|                           child: Text('Close'), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ); | ||||
|                 }, | ||||
|                 child: Tooltip( | ||||
|                   richMessage: getActivityFullMessage(status.value), | ||||
|                   child: Text( | ||||
|                     getActivityTitle(status.value?.label, status.value?.meta) ?? | ||||
|                         'unknown'.tr(), | ||||
|                     maxLines: 1, | ||||
|                     overflow: TextOverflow.ellipsis, | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|             ) | ||||
|           else | ||||
| @@ -148,7 +198,13 @@ class AccountStatusWidget extends HookConsumerWidget { | ||||
|                     overflow: TextOverflow.ellipsis, | ||||
|                   ).tr(), | ||||
|             ), | ||||
|           if (!(status.value?.isOnline ?? false) && | ||||
|           if (getActivitySubtitle(status.value?.meta) != null) | ||||
|             Flexible( | ||||
|               child: Text( | ||||
|                 getActivitySubtitle(status.value?.meta)!, | ||||
|               ).opacity(0.75), | ||||
|             ) | ||||
|           else if (!(status.value?.isOnline ?? false) && | ||||
|               account.value?.profile.lastSeenAt != null) | ||||
|             Flexible( | ||||
|               child: Text( | ||||
|   | ||||
							
								
								
									
										363
									
								
								lib/widgets/attachment_uploader.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										363
									
								
								lib/widgets/attachment_uploader.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,363 @@ | ||||
| import 'dart:typed_data'; | ||||
|  | ||||
| import 'package:cross_file/cross_file.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:island/models/file_pool.dart'; | ||||
| import 'package:island/pods/file_pool.dart'; | ||||
| import 'package:island/widgets/content/attachment_preview.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:island/widgets/post/compose_shared.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| class AttachmentUploadConfig { | ||||
|   final String poolId; | ||||
|   final bool hasConstraints; | ||||
|  | ||||
|   const AttachmentUploadConfig({ | ||||
|     required this.poolId, | ||||
|     required this.hasConstraints, | ||||
|   }); | ||||
| } | ||||
|  | ||||
| class AttachmentUploaderSheet extends StatefulWidget { | ||||
|   final WidgetRef ref; | ||||
|   final ComposeState state; | ||||
|   final int index; | ||||
|  | ||||
|   const AttachmentUploaderSheet({ | ||||
|     super.key, | ||||
|     required this.ref, | ||||
|     required this.state, | ||||
|     required this.index, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<AttachmentUploaderSheet> createState() => | ||||
|       _AttachmentUploaderSheetState(); | ||||
| } | ||||
|  | ||||
| class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> { | ||||
|   String? selectedPoolId; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final attachment = widget.state.attachments.value[widget.index]; | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: 'uploadAttachment'.tr(), | ||||
|       child: FutureBuilder<List<SnFilePool>>( | ||||
|         future: widget.ref.read(poolsProvider.future), | ||||
|         builder: (context, snapshot) { | ||||
|           if (snapshot.connectionState == ConnectionState.waiting) { | ||||
|             return const Center(child: CircularProgressIndicator()); | ||||
|           } | ||||
|           if (snapshot.hasError) { | ||||
|             return Center(child: Text('errorLoadingPools'.tr())); | ||||
|           } | ||||
|           final pools = snapshot.data!; | ||||
|           selectedPoolId ??= resolveDefaultPoolId(widget.ref, pools); | ||||
|  | ||||
|           return Column( | ||||
|             children: [ | ||||
|               Expanded( | ||||
|                 child: SingleChildScrollView( | ||||
|                   padding: const EdgeInsets.all(16), | ||||
|                   child: Column( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                     children: [ | ||||
|                       DropdownButtonFormField<String>( | ||||
|                         value: selectedPoolId, | ||||
|                         items: | ||||
|                             pools.map((pool) { | ||||
|                               return DropdownMenuItem<String>( | ||||
|                                 value: pool.id, | ||||
|                                 child: Text(pool.name), | ||||
|                               ); | ||||
|                             }).toList(), | ||||
|                         onChanged: (value) { | ||||
|                           setState(() { | ||||
|                             selectedPoolId = value; | ||||
|                           }); | ||||
|                         }, | ||||
|                         decoration: InputDecoration( | ||||
|                           labelText: 'selectPool'.tr(), | ||||
|                           border: const OutlineInputBorder(), | ||||
|                           hintText: 'choosePool'.tr(), | ||||
|                         ), | ||||
|                       ), | ||||
|                       const Gap(16), | ||||
|                       FutureBuilder<int?>( | ||||
|                         future: _getFileSize(attachment), | ||||
|                         builder: (context, sizeSnapshot) { | ||||
|                           if (!sizeSnapshot.hasData) { | ||||
|                             return const SizedBox.shrink(); | ||||
|                           } | ||||
|                           final fileSize = sizeSnapshot.data!; | ||||
|                           final selectedPool = pools.firstWhere( | ||||
|                             (p) => p.id == selectedPoolId, | ||||
|                           ); | ||||
|  | ||||
|                           // Check file size limit | ||||
|                           final maxFileSize = | ||||
|                               selectedPool.policyConfig?['max_file_size'] | ||||
|                                   as int?; | ||||
|                           final fileSizeExceeded = | ||||
|                               maxFileSize != null && fileSize > maxFileSize; | ||||
|  | ||||
|                           // Check accepted types | ||||
|                           final acceptTypes = | ||||
|                               selectedPool.policyConfig?['accept_types'] | ||||
|                                   as List?; | ||||
|                           final mimeType = | ||||
|                               attachment.data.mimeType ?? | ||||
|                               ComposeLogic.getMimeTypeFromFileType( | ||||
|                                 attachment.type, | ||||
|                               ); | ||||
|                           final typeAccepted = | ||||
|                               acceptTypes == null || | ||||
|                               acceptTypes.isEmpty || | ||||
|                               acceptTypes.any( | ||||
|                                 (type) => mimeType.startsWith(type), | ||||
|                               ); | ||||
|  | ||||
|                           final hasIssues = fileSizeExceeded || !typeAccepted; | ||||
|  | ||||
|                           return Column( | ||||
|                             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                             children: [ | ||||
|                               if (hasIssues) ...[ | ||||
|                                 Container( | ||||
|                                   padding: const EdgeInsets.all(12), | ||||
|                                   decoration: BoxDecoration( | ||||
|                                     color: | ||||
|                                         Theme.of( | ||||
|                                           context, | ||||
|                                         ).colorScheme.errorContainer, | ||||
|                                     borderRadius: BorderRadius.circular(8), | ||||
|                                   ), | ||||
|                                   child: Column( | ||||
|                                     crossAxisAlignment: | ||||
|                                         CrossAxisAlignment.start, | ||||
|                                     children: [ | ||||
|                                       Row( | ||||
|                                         children: [ | ||||
|                                           Icon( | ||||
|                                             Symbols.warning, | ||||
|                                             size: 18, | ||||
|                                             color: | ||||
|                                                 Theme.of( | ||||
|                                                   context, | ||||
|                                                 ).colorScheme.error, | ||||
|                                           ), | ||||
|                                           const Gap(8), | ||||
|                                           Text( | ||||
|                                             'uploadConstraints'.tr(), | ||||
|                                             style: Theme.of( | ||||
|                                               context, | ||||
|                                             ).textTheme.bodyMedium?.copyWith( | ||||
|                                               color: | ||||
|                                                   Theme.of( | ||||
|                                                     context, | ||||
|                                                   ).colorScheme.error, | ||||
|                                               fontWeight: FontWeight.w600, | ||||
|                                             ), | ||||
|                                           ), | ||||
|                                         ], | ||||
|                                       ), | ||||
|                                       if (fileSizeExceeded) ...[ | ||||
|                                         const Gap(4), | ||||
|                                         Text( | ||||
|                                           'fileSizeExceeded'.tr( | ||||
|                                             args: [ | ||||
|                                               _formatFileSize(maxFileSize), | ||||
|                                             ], | ||||
|                                           ), | ||||
|                                           style: Theme.of( | ||||
|                                             context, | ||||
|                                           ).textTheme.bodySmall?.copyWith( | ||||
|                                             color: | ||||
|                                                 Theme.of( | ||||
|                                                   context, | ||||
|                                                 ).colorScheme.error, | ||||
|                                           ), | ||||
|                                         ), | ||||
|                                       ], | ||||
|                                       if (!typeAccepted) ...[ | ||||
|                                         const Gap(4), | ||||
|                                         Text( | ||||
|                                           'fileTypeNotAccepted'.tr(), | ||||
|                                           style: Theme.of( | ||||
|                                             context, | ||||
|                                           ).textTheme.bodySmall?.copyWith( | ||||
|                                             color: | ||||
|                                                 Theme.of( | ||||
|                                                   context, | ||||
|                                                 ).colorScheme.error, | ||||
|                                           ), | ||||
|                                         ), | ||||
|                                       ], | ||||
|                                     ], | ||||
|                                   ), | ||||
|                                 ), | ||||
|                                 const Gap(12), | ||||
|                               ], | ||||
|                               Row( | ||||
|                                 spacing: 6, | ||||
|                                 children: [ | ||||
|                                   const Icon( | ||||
|                                     Symbols.account_balance_wallet, | ||||
|                                     size: 18, | ||||
|                                   ), | ||||
|                                   Expanded( | ||||
|                                     child: Text( | ||||
|                                       'quotaCostInfo'.tr( | ||||
|                                         args: [ | ||||
|                                           _formatQuotaCost( | ||||
|                                             fileSize, | ||||
|                                             selectedPool, | ||||
|                                           ), | ||||
|                                         ], | ||||
|                                       ), | ||||
|                                       style: | ||||
|                                           Theme.of( | ||||
|                                             context, | ||||
|                                           ).textTheme.bodyMedium, | ||||
|                                     ).fontSize(13), | ||||
|                                   ), | ||||
|                                 ], | ||||
|                               ).padding(horizontal: 4), | ||||
|                             ], | ||||
|                           ); | ||||
|                         }, | ||||
|                       ), | ||||
|                       const Gap(4), | ||||
|                       Row( | ||||
|                         spacing: 6, | ||||
|                         children: [ | ||||
|                           const Icon(Symbols.info, size: 18), | ||||
|                           Text( | ||||
|                             'attachmentPreview'.tr(), | ||||
|                             style: Theme.of(context).textTheme.titleMedium, | ||||
|                           ).fontSize(13), | ||||
|                         ], | ||||
|                       ).padding(horizontal: 4), | ||||
|                       const Gap(8), | ||||
|                       AttachmentPreview(item: attachment, isCompact: true), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|               Padding( | ||||
|                 padding: const EdgeInsets.all(16), | ||||
|                 child: Row( | ||||
|                   mainAxisAlignment: MainAxisAlignment.end, | ||||
|                   children: [ | ||||
|                     TextButton.icon( | ||||
|                       onPressed: () => Navigator.pop(context), | ||||
|                       icon: const Icon(Symbols.close), | ||||
|                       label: Text('cancel').tr(), | ||||
|                     ), | ||||
|                     const Gap(8), | ||||
|                     TextButton.icon( | ||||
|                       onPressed: () => _confirmUpload(), | ||||
|                       icon: const Icon(Symbols.upload), | ||||
|                       label: Text('upload').tr(), | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ); | ||||
|         }, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Future<AttachmentUploadConfig?> _getUploadConfig() async { | ||||
|     final attachment = widget.state.attachments.value[widget.index]; | ||||
|     final fileSize = await _getFileSize(attachment); | ||||
|  | ||||
|     if (fileSize == null) return null; | ||||
|  | ||||
|     // Get the selected pool to check constraints | ||||
|     final pools = await widget.ref.read(poolsProvider.future); | ||||
|     final selectedPool = pools.firstWhere((p) => p.id == selectedPoolId); | ||||
|  | ||||
|     // Check constraints | ||||
|     final maxFileSize = selectedPool.policyConfig?['max_file_size'] as int?; | ||||
|     final fileSizeExceeded = maxFileSize != null && fileSize > maxFileSize; | ||||
|  | ||||
|     final acceptTypes = selectedPool.policyConfig?['accept_types'] as List?; | ||||
|     final mimeType = | ||||
|         attachment.data.mimeType ?? | ||||
|         ComposeLogic.getMimeTypeFromFileType(attachment.type); | ||||
|     final typeAccepted = | ||||
|         acceptTypes == null || | ||||
|         acceptTypes.isEmpty || | ||||
|         acceptTypes.any((type) => mimeType.startsWith(type)); | ||||
|  | ||||
|     final hasConstraints = fileSizeExceeded || !typeAccepted; | ||||
|  | ||||
|     return AttachmentUploadConfig( | ||||
|       poolId: selectedPoolId!, | ||||
|       hasConstraints: hasConstraints, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Future<void> _confirmUpload() async { | ||||
|     final config = await _getUploadConfig(); | ||||
|     if (config != null && mounted) { | ||||
|       Navigator.pop(context, config); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<int?> _getFileSize(UniversalFile attachment) async { | ||||
|     if (attachment.data is XFile) { | ||||
|       try { | ||||
|         return await (attachment.data as XFile).length(); | ||||
|       } catch (e) { | ||||
|         return null; | ||||
|       } | ||||
|     } else if (attachment.data is SnCloudFile) { | ||||
|       return (attachment.data as SnCloudFile).size; | ||||
|     } else if (attachment.data is List<int>) { | ||||
|       return (attachment.data as List<int>).length; | ||||
|     } else if (attachment.data is Uint8List) { | ||||
|       return (attachment.data as Uint8List).length; | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   String _formatNumber(int number) { | ||||
|     if (number >= 1000000) { | ||||
|       return '${(number / 1000000).toStringAsFixed(1)}M'; | ||||
|     } else if (number >= 1000) { | ||||
|       return '${(number / 1000).toStringAsFixed(1)}K'; | ||||
|     } else { | ||||
|       return number.toString(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   String _formatFileSize(int bytes) { | ||||
|     if (bytes >= 1073741824) { | ||||
|       return '${(bytes / 1073741824).toStringAsFixed(1)} GB'; | ||||
|     } else if (bytes >= 1048576) { | ||||
|       return '${(bytes / 1048576).toStringAsFixed(1)} MB'; | ||||
|     } else if (bytes >= 1024) { | ||||
|       return '${(bytes / 1024).toStringAsFixed(1)} KB'; | ||||
|     } else { | ||||
|       return '$bytes bytes'; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   String _formatQuotaCost(int fileSize, SnFilePool pool) { | ||||
|     final costMultiplier = pool.billingConfig?['cost_multiplier'] ?? 1.0; | ||||
|     final quotaCost = ((fileSize / 1024 / 1024) * costMultiplier).round(); | ||||
|     return _formatNumber(quotaCost); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										330
									
								
								lib/widgets/chat/chat_input.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										330
									
								
								lib/widgets/chat/chat_input.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,330 @@ | ||||
| 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; | ||||
|   final Map<String, Map<int, double>> attachmentProgress; | ||||
|  | ||||
|   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, | ||||
|     required this.attachmentProgress, | ||||
|   }); | ||||
|  | ||||
|   @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], | ||||
|                       progress: attachmentProgress['chat-upload']?[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), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										172
									
								
								lib/widgets/chat/message_content.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								lib/widgets/chat/message_content.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,172 @@ | ||||
| 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) { | ||||
|     if (item.type == 'messages.delete' || item.deletedAt != null) { | ||||
|       return Row( | ||||
|         mainAxisSize: MainAxisSize.min, | ||||
|         crossAxisAlignment: CrossAxisAlignment.center, | ||||
|         children: [ | ||||
|           Icon( | ||||
|             Symbols.delete, | ||||
|             size: 16, | ||||
|             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( | ||||
|               fontSize: 13, | ||||
|               color: Theme.of( | ||||
|                 context, | ||||
|               ).colorScheme.onSurfaceVariant.withOpacity(0.6), | ||||
|               fontStyle: FontStyle.italic, | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     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: 16, | ||||
|               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.bodyMedium!.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 '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, | ||||
|                 ), | ||||
|               ), | ||||
|         ); | ||||
|     } | ||||
|   } | ||||
| } | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										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, | ||||
|             showAvatar: false, | ||||
|           ), | ||||
|           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, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										158
									
								
								lib/widgets/chat/message_sender_info.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										158
									
								
								lib/widgets/chat/message_sender_info.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,158 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:gap/gap.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 showAvatar; | ||||
|   final bool isCompact; | ||||
|  | ||||
|   const MessageSenderInfo({ | ||||
|     super.key, | ||||
|     required this.sender, | ||||
|     required this.createdAt, | ||||
|     required this.textColor, | ||||
|     this.showAvatar = true, | ||||
|     this.isCompact = 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 (isCompact) { | ||||
|       return Row( | ||||
|         mainAxisSize: MainAxisSize.min, | ||||
|         crossAxisAlignment: CrossAxisAlignment.baseline, | ||||
|         textBaseline: TextBaseline.alphabetic, | ||||
|         children: [ | ||||
|           if (showAvatar) | ||||
|             AccountPfcGestureDetector( | ||||
|               uname: sender.account.name, | ||||
|               child: ProfilePictureWidget( | ||||
|                 fileId: sender.account.profile.picture?.id, | ||||
|                 radius: 14, | ||||
|               ), | ||||
|             ), | ||||
|           if (showAvatar) const Gap(4), | ||||
|           AccountName( | ||||
|             account: sender.account, | ||||
|             style: Theme.of(context).textTheme.bodySmall?.copyWith( | ||||
|               color: textColor, | ||||
|               fontWeight: FontWeight.w500, | ||||
|             ), | ||||
|           ), | ||||
|           const Gap(6), | ||||
|           Text( | ||||
|             timestamp, | ||||
|             style: TextStyle(fontSize: 10, color: textColor.withOpacity(0.7)), | ||||
|           ), | ||||
|         ], | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     if (showAvatar) { | ||||
|       return Row( | ||||
|         spacing: 8, | ||||
|         children: [ | ||||
|           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: [ | ||||
|         if (showAvatar) | ||||
|           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: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), | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -229,6 +229,7 @@ class CheckInWidget extends HookConsumerWidget { | ||||
|           Column( | ||||
|             mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|             crossAxisAlignment: CrossAxisAlignment.end, | ||||
|             spacing: 4, | ||||
|             children: [ | ||||
|               AnimatedSwitcher( | ||||
|                 duration: const Duration(milliseconds: 300), | ||||
|   | ||||
| @@ -470,7 +470,8 @@ class AttachmentPreview extends HookConsumerWidget { | ||||
|                 if (onRequestUpload != null) | ||||
|                   InkWell( | ||||
|                     borderRadius: BorderRadius.circular(8), | ||||
|                     onTap: () => onRequestUpload?.call(), | ||||
|                     onTap: | ||||
|                         item.isOnCloud ? null : () => onRequestUpload?.call(), | ||||
|                     child: ClipRRect( | ||||
|                       borderRadius: BorderRadius.circular(8), | ||||
|                       child: Container( | ||||
|   | ||||
| @@ -1,15 +1,12 @@ | ||||
| import 'dart:convert'; | ||||
| import 'dart:io'; | ||||
| import 'dart:math' as math; | ||||
| import 'dart:ui'; | ||||
|  | ||||
| import 'package:dismissible_page/dismissible_page.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter_blurhash/flutter_blurhash.dart'; | ||||
| import 'package:file_saver/file_saver.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:gal/gal.dart'; | ||||
| @@ -17,11 +14,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/utils/format.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/content/file_info_sheet.dart'; | ||||
| import 'package:island/widgets/content/sensitive.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:path/path.dart' show extension; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
| @@ -361,284 +357,11 @@ class CloudFileZoomIn extends HookConsumerWidget { | ||||
|     } | ||||
|  | ||||
|     void showInfoSheet() { | ||||
|       final theme = Theme.of(context); | ||||
|       final exifData = item.fileMeta?['exif'] as Map<String, dynamic>? ?? {}; | ||||
|  | ||||
|       showModalBottomSheet( | ||||
|         useRootNavigator: true, | ||||
|         context: context, | ||||
|         isScrollControlled: true, | ||||
|         builder: | ||||
|             (context) => SheetScaffold( | ||||
|               titleText: 'File Information', | ||||
|               child: SingleChildScrollView( | ||||
|                 child: Column( | ||||
|                   crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                   children: [ | ||||
|                     Row( | ||||
|                       children: [ | ||||
|                         Expanded( | ||||
|                           child: Column( | ||||
|                             crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                             mainAxisSize: MainAxisSize.min, | ||||
|                             children: [ | ||||
|                               Text('mimeType').tr(), | ||||
|                               Text( | ||||
|                                 item.mimeType ?? 'unknown'.tr(), | ||||
|                                 maxLines: 1, | ||||
|                                 overflow: TextOverflow.ellipsis, | ||||
|                                 style: theme.textTheme.titleMedium?.copyWith( | ||||
|                                   fontWeight: FontWeight.bold, | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ], | ||||
|                           ), | ||||
|                         ), | ||||
|                         SizedBox(height: 28, child: const VerticalDivider()), | ||||
|                         Expanded( | ||||
|                           child: Column( | ||||
|                             crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                             mainAxisSize: MainAxisSize.min, | ||||
|                             children: [ | ||||
|                               Text('fileSize').tr(), | ||||
|                               Text( | ||||
|                                 formatFileSize(item.size), | ||||
|                                 style: theme.textTheme.titleMedium?.copyWith( | ||||
|                                   fontWeight: FontWeight.bold, | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ], | ||||
|                           ), | ||||
|                         ), | ||||
|                         if (item.hash != null) | ||||
|                           SizedBox(height: 28, child: const VerticalDivider()), | ||||
|                         if (item.hash != null) | ||||
|                           Expanded( | ||||
|                             child: GestureDetector( | ||||
|                               child: Column( | ||||
|                                 crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                                 mainAxisSize: MainAxisSize.min, | ||||
|                                 children: [ | ||||
|                                   Text('fileHash').tr(), | ||||
|                                   Text( | ||||
|                                     '${item.hash!.substring(0, 6)}...', | ||||
|                                     style: theme.textTheme.titleMedium | ||||
|                                         ?.copyWith(fontWeight: FontWeight.bold), | ||||
|                                   ), | ||||
|                                 ], | ||||
|                               ), | ||||
|                               onLongPress: () { | ||||
|                                 Clipboard.setData( | ||||
|                                   ClipboardData(text: item.hash!), | ||||
|                                 ); | ||||
|                                 showSnackBar('File hash copied to clipboard'); | ||||
|                               }, | ||||
|                             ), | ||||
|                           ), | ||||
|                       ], | ||||
|                     ).padding(horizontal: 24, vertical: 16), | ||||
|                     const Divider(height: 1), | ||||
|                     ListTile( | ||||
|                       leading: const Icon(Symbols.tag), | ||||
|                       title: Text('ID').tr(), | ||||
|                       subtitle: Text( | ||||
|                         item.id, | ||||
|                         maxLines: 1, | ||||
|                         overflow: TextOverflow.ellipsis, | ||||
|                       ), | ||||
|                       contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|                       trailing: IconButton( | ||||
|                         icon: const Icon(Icons.copy), | ||||
|                         onPressed: () { | ||||
|                           Clipboard.setData(ClipboardData(text: item.id)); | ||||
|                           showSnackBar('File ID copied to clipboard'); | ||||
|                         }, | ||||
|                       ), | ||||
|                     ), | ||||
|                     ListTile( | ||||
|                       leading: const Icon(Symbols.file_present), | ||||
|                       title: Text('Name').tr(), | ||||
|                       subtitle: Text( | ||||
|                         item.name, | ||||
|                         maxLines: 1, | ||||
|                         overflow: TextOverflow.ellipsis, | ||||
|                       ), | ||||
|                       contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|                       trailing: IconButton( | ||||
|                         icon: const Icon(Icons.copy), | ||||
|                         onPressed: () { | ||||
|                           Clipboard.setData(ClipboardData(text: item.name)); | ||||
|                           showSnackBar('File name copied to clipboard'); | ||||
|                         }, | ||||
|                       ), | ||||
|                     ), | ||||
|                     if (exifData.isNotEmpty) ...[ | ||||
|                       const Divider(height: 1), | ||||
|                       Theme( | ||||
|                         data: theme.copyWith(dividerColor: Colors.transparent), | ||||
|                         child: ExpansionTile( | ||||
|                           tilePadding: const EdgeInsets.symmetric( | ||||
|                             horizontal: 24, | ||||
|                           ), | ||||
|                           title: Text( | ||||
|                             'exifData'.tr(), | ||||
|                             style: theme.textTheme.titleMedium?.copyWith( | ||||
|                               fontWeight: FontWeight.bold, | ||||
|                             ), | ||||
|                           ), | ||||
|                           children: [ | ||||
|                             Column( | ||||
|                               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                               children: [ | ||||
|                                 ...exifData.entries.map( | ||||
|                                   (entry) => ListTile( | ||||
|                                     dense: true, | ||||
|                                     contentPadding: EdgeInsets.symmetric( | ||||
|                                       horizontal: 24, | ||||
|                                     ), | ||||
|                                     title: | ||||
|                                         Text( | ||||
|                                           entry.key.contains('-') | ||||
|                                               ? entry.key.split('-').last | ||||
|                                               : entry.key, | ||||
|                                           style: theme.textTheme.bodyMedium | ||||
|                                               ?.copyWith( | ||||
|                                                 fontWeight: FontWeight.w500, | ||||
|                                               ), | ||||
|                                         ).bold(), | ||||
|                                     subtitle: Text( | ||||
|                                       '${entry.value}'.isNotEmpty | ||||
|                                           ? '${entry.value}' | ||||
|                                           : 'N/A', | ||||
|                                       style: theme.textTheme.bodyMedium, | ||||
|                                     ), | ||||
|                                     onTap: () { | ||||
|                                       Clipboard.setData( | ||||
|                                         ClipboardData(text: '${entry.value}'), | ||||
|                                       ); | ||||
|                                       showSnackBar('Value copied to clipboard'); | ||||
|                                     }, | ||||
|                                   ), | ||||
|                                 ), | ||||
|                               ], | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                     if (item.fileMeta != null && item.fileMeta!.isNotEmpty) ...[ | ||||
|                       const Divider(height: 1), | ||||
|                       Theme( | ||||
|                         data: theme.copyWith(dividerColor: Colors.transparent), | ||||
|                         child: ExpansionTile( | ||||
|                           tilePadding: const EdgeInsets.symmetric( | ||||
|                             horizontal: 24, | ||||
|                           ), | ||||
|                           title: Text( | ||||
|                             'File Metadata', | ||||
|                             style: theme.textTheme.titleMedium?.copyWith( | ||||
|                               fontWeight: FontWeight.bold, | ||||
|                             ), | ||||
|                           ), | ||||
|                           children: [ | ||||
|                             Column( | ||||
|                               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                               children: [ | ||||
|                                 ...item.fileMeta!.entries.map( | ||||
|                                   (entry) => ListTile( | ||||
|                                     dense: true, | ||||
|                                     contentPadding: EdgeInsets.symmetric( | ||||
|                                       horizontal: 24, | ||||
|                                     ), | ||||
|                                     title: | ||||
|                                         Text( | ||||
|                                           entry.key, | ||||
|                                           style: theme.textTheme.bodyMedium | ||||
|                                               ?.copyWith( | ||||
|                                                 fontWeight: FontWeight.w500, | ||||
|                                               ), | ||||
|                                         ).bold(), | ||||
|                                     subtitle: Text( | ||||
|                                       jsonEncode(entry.value), | ||||
|                                       style: theme.textTheme.bodyMedium, | ||||
|                                       maxLines: 3, | ||||
|                                       overflow: TextOverflow.ellipsis, | ||||
|                                     ), | ||||
|                                     onTap: () { | ||||
|                                       Clipboard.setData( | ||||
|                                         ClipboardData( | ||||
|                                           text: jsonEncode(entry.value), | ||||
|                                         ), | ||||
|                                       ); | ||||
|                                       showSnackBar('Value copied to clipboard'); | ||||
|                                     }, | ||||
|                                   ), | ||||
|                                 ), | ||||
|                               ], | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                     if (item.userMeta != null && item.userMeta!.isNotEmpty) ...[ | ||||
|                       const Divider(height: 1), | ||||
|                       Theme( | ||||
|                         data: theme.copyWith(dividerColor: Colors.transparent), | ||||
|                         child: ExpansionTile( | ||||
|                           tilePadding: const EdgeInsets.symmetric( | ||||
|                             horizontal: 24, | ||||
|                           ), | ||||
|                           title: Text( | ||||
|                             'User Metadata', | ||||
|                             style: theme.textTheme.titleMedium?.copyWith( | ||||
|                               fontWeight: FontWeight.bold, | ||||
|                             ), | ||||
|                           ), | ||||
|                           children: [ | ||||
|                             Column( | ||||
|                               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                               children: [ | ||||
|                                 ...item.userMeta!.entries.map( | ||||
|                                   (entry) => ListTile( | ||||
|                                     dense: true, | ||||
|                                     contentPadding: EdgeInsets.symmetric( | ||||
|                                       horizontal: 24, | ||||
|                                     ), | ||||
|                                     title: | ||||
|                                         Text( | ||||
|                                           entry.key, | ||||
|                                           style: theme.textTheme.bodyMedium | ||||
|                                               ?.copyWith( | ||||
|                                                 fontWeight: FontWeight.w500, | ||||
|                                               ), | ||||
|                                         ).bold(), | ||||
|                                     subtitle: Text( | ||||
|                                       jsonEncode(entry.value), | ||||
|                                       style: theme.textTheme.bodyMedium, | ||||
|                                       maxLines: 3, | ||||
|                                       overflow: TextOverflow.ellipsis, | ||||
|                                     ), | ||||
|                                     onTap: () { | ||||
|                                       Clipboard.setData( | ||||
|                                         ClipboardData( | ||||
|                                           text: jsonEncode(entry.value), | ||||
|                                         ), | ||||
|                                       ); | ||||
|                                       showSnackBar('Value copied to clipboard'); | ||||
|                                     }, | ||||
|                                   ), | ||||
|                                 ), | ||||
|                               ], | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                     const SizedBox(height: 16), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|         builder: (context) => FileInfoSheet(item: item), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
							
								
								
									
										298
									
								
								lib/widgets/content/file_info_sheet.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										298
									
								
								lib/widgets/content/file_info_sheet.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,298 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:island/utils/format.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| class FileInfoSheet extends StatelessWidget { | ||||
|   final SnCloudFile item; | ||||
|  | ||||
|   const FileInfoSheet({super.key, required this.item}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final theme = Theme.of(context); | ||||
|     final exifData = item.fileMeta?['exif'] as Map<String, dynamic>? ?? {}; | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: 'fileInfoTitle'.tr(), | ||||
|       child: SingleChildScrollView( | ||||
|         child: Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           children: [ | ||||
|             Row( | ||||
|               children: [ | ||||
|                 Expanded( | ||||
|                   child: Column( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                     mainAxisSize: MainAxisSize.min, | ||||
|                     children: [ | ||||
|                       Text('mimeType').tr(), | ||||
|                       Text( | ||||
|                         item.mimeType ?? 'unknown'.tr(), | ||||
|                         maxLines: 1, | ||||
|                         overflow: TextOverflow.ellipsis, | ||||
|                         style: theme.textTheme.titleMedium?.copyWith( | ||||
|                           fontWeight: FontWeight.bold, | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|                 SizedBox(height: 28, child: const VerticalDivider()), | ||||
|                 Expanded( | ||||
|                   child: Column( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                     mainAxisSize: MainAxisSize.min, | ||||
|                     children: [ | ||||
|                       Text('fileSize').tr(), | ||||
|                       Text( | ||||
|                         formatFileSize(item.size), | ||||
|                         style: theme.textTheme.titleMedium?.copyWith( | ||||
|                           fontWeight: FontWeight.bold, | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|                 if (item.hash != null) | ||||
|                   SizedBox(height: 28, child: const VerticalDivider()), | ||||
|                 if (item.hash != null) | ||||
|                   Expanded( | ||||
|                     child: GestureDetector( | ||||
|                       child: Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                         mainAxisSize: MainAxisSize.min, | ||||
|                         children: [ | ||||
|                           Text('fileHash').tr(), | ||||
|                           Text( | ||||
|                             '${item.hash!.substring(0, 6)}...', | ||||
|                             style: theme.textTheme.titleMedium?.copyWith( | ||||
|                               fontWeight: FontWeight.bold, | ||||
|                             ), | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|                       onLongPress: () { | ||||
|                         Clipboard.setData(ClipboardData(text: item.hash!)); | ||||
|                         showSnackBar('fileHashCopied'.tr()); | ||||
|                       }, | ||||
|                     ), | ||||
|                   ), | ||||
|               ], | ||||
|             ).padding(horizontal: 24, vertical: 16), | ||||
|             const Divider(height: 1), | ||||
|             ListTile( | ||||
|               leading: const Icon(Symbols.tag), | ||||
|               title: Text('ID').tr(), | ||||
|               subtitle: Text( | ||||
|                 item.id, | ||||
|                 maxLines: 1, | ||||
|                 overflow: TextOverflow.ellipsis, | ||||
|               ), | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|               trailing: IconButton( | ||||
|                 icon: const Icon(Icons.copy), | ||||
|                 onPressed: () { | ||||
|                   Clipboard.setData(ClipboardData(text: item.id)); | ||||
|                   showSnackBar('fileIdCopied'.tr()); | ||||
|                 }, | ||||
|               ), | ||||
|             ), | ||||
|             ListTile( | ||||
|               leading: const Icon(Symbols.file_present), | ||||
|               title: Text('Name').tr(), | ||||
|               subtitle: Text( | ||||
|                 item.name, | ||||
|                 maxLines: 1, | ||||
|                 overflow: TextOverflow.ellipsis, | ||||
|               ), | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|               trailing: IconButton( | ||||
|                 icon: const Icon(Icons.copy), | ||||
|                 onPressed: () { | ||||
|                   Clipboard.setData(ClipboardData(text: item.name)); | ||||
|                   showSnackBar('fileNameCopied'.tr()); | ||||
|                 }, | ||||
|               ), | ||||
|             ), | ||||
|             if (item.pool != null) | ||||
|               ListTile( | ||||
|                 leading: const Icon(Symbols.calendar_today), | ||||
|                 title: Text('File Pool').tr(), | ||||
|                 subtitle: Text( | ||||
|                   item.pool!.name, | ||||
|                   maxLines: 1, | ||||
|                   overflow: TextOverflow.ellipsis, | ||||
|                 ), | ||||
|                 contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|                 trailing: IconButton( | ||||
|                   icon: const Icon(Icons.copy), | ||||
|                   onPressed: () { | ||||
|                     Clipboard.setData(ClipboardData(text: item.pool!.id)); | ||||
|                     showSnackBar('fileNameCopied'.tr()); | ||||
|                   }, | ||||
|                 ), | ||||
|               ), | ||||
|             if (exifData.isNotEmpty) ...[ | ||||
|               const Divider(height: 1), | ||||
|               Theme( | ||||
|                 data: theme.copyWith(dividerColor: Colors.transparent), | ||||
|                 child: ExpansionTile( | ||||
|                   tilePadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|                   title: Text( | ||||
|                     'exifData'.tr(), | ||||
|                     style: theme.textTheme.titleMedium?.copyWith( | ||||
|                       fontWeight: FontWeight.bold, | ||||
|                     ), | ||||
|                   ), | ||||
|                   children: [ | ||||
|                     Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         ...exifData.entries.map( | ||||
|                           (entry) => ListTile( | ||||
|                             dense: true, | ||||
|                             contentPadding: EdgeInsets.symmetric( | ||||
|                               horizontal: 24, | ||||
|                             ), | ||||
|                             title: | ||||
|                                 Text( | ||||
|                                   entry.key.contains('-') | ||||
|                                       ? entry.key.split('-').last | ||||
|                                       : entry.key, | ||||
|                                   style: theme.textTheme.bodyMedium?.copyWith( | ||||
|                                     fontWeight: FontWeight.w500, | ||||
|                                   ), | ||||
|                                 ).bold(), | ||||
|                             subtitle: Text( | ||||
|                               '${entry.value}'.isNotEmpty | ||||
|                                   ? '${entry.value}' | ||||
|                                   : 'N/A', | ||||
|                               style: theme.textTheme.bodyMedium, | ||||
|                             ), | ||||
|                             onTap: () { | ||||
|                               Clipboard.setData( | ||||
|                                 ClipboardData(text: '${entry.value}'), | ||||
|                               ); | ||||
|                               showSnackBar('valueCopied'.tr()); | ||||
|                             }, | ||||
|                           ), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|             if (item.fileMeta != null && item.fileMeta!.isNotEmpty) ...[ | ||||
|               const Divider(height: 1), | ||||
|               Theme( | ||||
|                 data: theme.copyWith(dividerColor: Colors.transparent), | ||||
|                 child: ExpansionTile( | ||||
|                   tilePadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|                   title: Text( | ||||
|                     'fileMetadata'.tr(), | ||||
|                     style: theme.textTheme.titleMedium?.copyWith( | ||||
|                       fontWeight: FontWeight.bold, | ||||
|                     ), | ||||
|                   ), | ||||
|                   children: [ | ||||
|                     Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         ...item.fileMeta!.entries.map( | ||||
|                           (entry) => ListTile( | ||||
|                             dense: true, | ||||
|                             contentPadding: EdgeInsets.symmetric( | ||||
|                               horizontal: 24, | ||||
|                             ), | ||||
|                             title: | ||||
|                                 Text( | ||||
|                                   entry.key, | ||||
|                                   style: theme.textTheme.bodyMedium?.copyWith( | ||||
|                                     fontWeight: FontWeight.w500, | ||||
|                                   ), | ||||
|                                 ).bold(), | ||||
|                             subtitle: Text( | ||||
|                               jsonEncode(entry.value), | ||||
|                               style: theme.textTheme.bodyMedium, | ||||
|                               maxLines: 3, | ||||
|                               overflow: TextOverflow.ellipsis, | ||||
|                             ), | ||||
|                             onTap: () { | ||||
|                               Clipboard.setData( | ||||
|                                 ClipboardData(text: jsonEncode(entry.value)), | ||||
|                               ); | ||||
|                               showSnackBar('valueCopied'.tr()); | ||||
|                             }, | ||||
|                           ), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|             if (item.userMeta != null && item.userMeta!.isNotEmpty) ...[ | ||||
|               const Divider(height: 1), | ||||
|               Theme( | ||||
|                 data: theme.copyWith(dividerColor: Colors.transparent), | ||||
|                 child: ExpansionTile( | ||||
|                   tilePadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|                   title: Text( | ||||
|                     'userMetadata'.tr(), | ||||
|                     style: theme.textTheme.titleMedium?.copyWith( | ||||
|                       fontWeight: FontWeight.bold, | ||||
|                     ), | ||||
|                   ), | ||||
|                   children: [ | ||||
|                     Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         ...item.userMeta!.entries.map( | ||||
|                           (entry) => ListTile( | ||||
|                             dense: true, | ||||
|                             contentPadding: EdgeInsets.symmetric( | ||||
|                               horizontal: 24, | ||||
|                             ), | ||||
|                             title: | ||||
|                                 Text( | ||||
|                                   entry.key, | ||||
|                                   style: theme.textTheme.bodyMedium?.copyWith( | ||||
|                                     fontWeight: FontWeight.w500, | ||||
|                                   ), | ||||
|                                 ).bold(), | ||||
|                             subtitle: Text( | ||||
|                               jsonEncode(entry.value), | ||||
|                               style: theme.textTheme.bodyMedium, | ||||
|                               maxLines: 3, | ||||
|                               overflow: TextOverflow.ellipsis, | ||||
|                             ), | ||||
|                             onTap: () { | ||||
|                               Clipboard.setData( | ||||
|                                 ClipboardData(text: jsonEncode(entry.value)), | ||||
|                               ); | ||||
|                               showSnackBar('valueCopied'.tr()); | ||||
|                             }, | ||||
|                           ), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|             const SizedBox(height: 16), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -64,6 +64,7 @@ class DebugSheet extends HookConsumerWidget { | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: 'Debug', | ||||
|       heightFactor: 0.6, | ||||
|       child: Column( | ||||
|         children: [ | ||||
|           ListTile( | ||||
|   | ||||
| @@ -30,10 +30,13 @@ class ComposeEmbedSheet extends HookConsumerWidget { | ||||
|     final selectedRenderer = useState<PostEmbedViewRenderer>( | ||||
|       PostEmbedViewRenderer.webView, | ||||
|     ); | ||||
|     final tabController = useTabController(initialLength: 2); | ||||
|     final iframeController = useTextEditingController(); | ||||
|  | ||||
|     void clearForm() { | ||||
|       uriController.clear(); | ||||
|       aspectRatioController.clear(); | ||||
|       iframeController.clear(); | ||||
|       selectedRenderer.value = PostEmbedViewRenderer.webView; | ||||
|     } | ||||
|  | ||||
| @@ -77,6 +80,57 @@ class ComposeEmbedSheet extends HookConsumerWidget { | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     void parseIframe() { | ||||
|       final iframe = iframeController.text.trim(); | ||||
|       if (iframe.isEmpty) return; | ||||
|  | ||||
|       final srcMatch = RegExp(r'src="([^"]*)"').firstMatch(iframe); | ||||
|       final widthMatch = RegExp(r'width="([^"]*)"').firstMatch(iframe); | ||||
|       final heightMatch = RegExp(r'height="([^"]*)"').firstMatch(iframe); | ||||
|  | ||||
|       if (srcMatch != null) { | ||||
|         uriController.text = srcMatch.group(1)!; | ||||
|       } | ||||
|  | ||||
|       if (widthMatch != null && heightMatch != null) { | ||||
|         final w = double.tryParse(widthMatch.group(1)!); | ||||
|         final h = double.tryParse(heightMatch.group(1)!); | ||||
|         if (w != null && h != null && h != 0) { | ||||
|           aspectRatioController.text = (w / h).toStringAsFixed(3); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       tabController.animateTo(1); | ||||
|     } | ||||
|  | ||||
|     void deleteEmbed(BuildContext context) { | ||||
|       showDialog( | ||||
|         context: context, | ||||
|         builder: | ||||
|             (dialogContext) => AlertDialog( | ||||
|               title: Text('deleteEmbed').tr(), | ||||
|               content: Text('deleteEmbedConfirm').tr(), | ||||
|               actions: [ | ||||
|                 TextButton( | ||||
|                   onPressed: () => Navigator.of(dialogContext).pop(), | ||||
|                   child: Text('cancel').tr(), | ||||
|                 ), | ||||
|                 TextButton( | ||||
|                   onPressed: () { | ||||
|                     ComposeLogic.deleteEmbedView(state); | ||||
|                     clearForm(); | ||||
|                     Navigator.of(dialogContext).pop(); | ||||
|                   }, | ||||
|                   style: TextButton.styleFrom( | ||||
|                     foregroundColor: Theme.of(context).colorScheme.error, | ||||
|                   ), | ||||
|                   child: Text('delete').tr(), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: 'embedView'.tr(), | ||||
|       heightFactor: 0.7, | ||||
| @@ -85,7 +139,7 @@ class ComposeEmbedSheet extends HookConsumerWidget { | ||||
|           // Header with save button when editing | ||||
|           if (currentEmbedView != null) | ||||
|             Container( | ||||
|               padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), | ||||
|               padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), | ||||
|               color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||
|               child: Row( | ||||
|                 children: [ | ||||
| @@ -97,187 +151,207 @@ class ComposeEmbedSheet extends HookConsumerWidget { | ||||
|                   ), | ||||
|                   TextButton( | ||||
|                     onPressed: saveEmbedView, | ||||
|                     style: ButtonStyle(visualDensity: VisualDensity.compact), | ||||
|                     child: Text('save'.tr()), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|  | ||||
|           // Tab bar | ||||
|           TabBar( | ||||
|             controller: tabController, | ||||
|             tabs: [Tab(text: 'auto'.tr()), Tab(text: 'manual'.tr())], | ||||
|           ), | ||||
|  | ||||
|           // Content area | ||||
|           Expanded( | ||||
|             child: SingleChildScrollView( | ||||
|               padding: const EdgeInsets.all(16), | ||||
|               child: Column( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                 children: [ | ||||
|                   // Form fields | ||||
|                   TextField( | ||||
|                     controller: uriController, | ||||
|                     decoration: InputDecoration( | ||||
|                       labelText: 'embedUri'.tr(), | ||||
|                       hintText: 'https://example.com', | ||||
|                       border: OutlineInputBorder( | ||||
|                         borderRadius: BorderRadius.circular(8), | ||||
|             child: TabBarView( | ||||
|               controller: tabController, | ||||
|               children: [ | ||||
|                 // Auto tab | ||||
|                 SingleChildScrollView( | ||||
|                   padding: const EdgeInsets.all(16), | ||||
|                   child: Column( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                     children: [ | ||||
|                       TextField( | ||||
|                         controller: iframeController, | ||||
|                         decoration: InputDecoration( | ||||
|                           labelText: 'iframeCode'.tr(), | ||||
|                           hintText: 'iframeCodeHint'.tr(), | ||||
|                           border: OutlineInputBorder( | ||||
|                             borderRadius: BorderRadius.circular(8), | ||||
|                           ), | ||||
|                         ), | ||||
|                         maxLines: 5, | ||||
|                       ), | ||||
|                     ), | ||||
|                     keyboardType: TextInputType.url, | ||||
|                   ), | ||||
|                   const Gap(16), | ||||
|                   TextField( | ||||
|                     controller: aspectRatioController, | ||||
|                     decoration: InputDecoration( | ||||
|                       labelText: 'aspectRatio'.tr(), | ||||
|                       hintText: '16/9 = 1.777', | ||||
|                       border: OutlineInputBorder( | ||||
|                         borderRadius: BorderRadius.circular(8), | ||||
|                       const Gap(16), | ||||
|                       SizedBox( | ||||
|                         width: double.infinity, | ||||
|                         child: FilledButton.icon( | ||||
|                           onPressed: parseIframe, | ||||
|                           icon: const Icon(Symbols.auto_fix), | ||||
|                           label: Text('parseIframe'.tr()), | ||||
|                         ), | ||||
|                       ), | ||||
|                     ), | ||||
|                     keyboardType: TextInputType.numberWithOptions( | ||||
|                       decimal: true, | ||||
|                     ), | ||||
|                     inputFormatters: [ | ||||
|                       FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$')), | ||||
|                     ], | ||||
|                   ), | ||||
|                   const Gap(16), | ||||
|                   DropdownButtonFormField2<PostEmbedViewRenderer>( | ||||
|                     value: selectedRenderer.value, | ||||
|                     decoration: InputDecoration( | ||||
|                       labelText: 'renderer'.tr(), | ||||
|                       border: OutlineInputBorder( | ||||
|                         borderRadius: BorderRadius.circular(8), | ||||
|                       ), | ||||
|                     ), | ||||
|                     items: | ||||
|                         PostEmbedViewRenderer.values.map((renderer) { | ||||
|                           return DropdownMenuItem( | ||||
|                             value: renderer, | ||||
|                             child: Text(renderer.name).tr(), | ||||
|                           ); | ||||
|                         }).toList(), | ||||
|                     onChanged: (value) { | ||||
|                       if (value != null) { | ||||
|                         selectedRenderer.value = value; | ||||
|                       } | ||||
|                     }, | ||||
|                   ), | ||||
|  | ||||
|                   // Current embed view display (when exists) | ||||
|                   if (currentEmbedView != null) ...[ | ||||
|                     const Gap(32), | ||||
|                     Text( | ||||
|                       'currentEmbed'.tr(), | ||||
|                       style: theme.textTheme.titleMedium, | ||||
|                     ).padding(horizontal: 4), | ||||
|                     const Gap(8), | ||||
|                     Card( | ||||
|                       margin: EdgeInsets.zero, | ||||
|                       color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||
|                       child: Padding( | ||||
|                         padding: const EdgeInsets.only( | ||||
|                           left: 16, | ||||
|                           right: 16, | ||||
|                           bottom: 12, | ||||
|                           top: 4, | ||||
|                 ), | ||||
|                 // Manual tab | ||||
|                 SingleChildScrollView( | ||||
|                   padding: const EdgeInsets.all(16), | ||||
|                   child: Column( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                     children: [ | ||||
|                       // Form fields | ||||
|                       TextField( | ||||
|                         controller: uriController, | ||||
|                         decoration: InputDecoration( | ||||
|                           labelText: 'embedUri'.tr(), | ||||
|                           hintText: 'https://example.com', | ||||
|                           border: OutlineInputBorder( | ||||
|                             borderRadius: BorderRadius.circular(8), | ||||
|                           ), | ||||
|                         ), | ||||
|                         child: Column( | ||||
|                           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                           children: [ | ||||
|                             Row( | ||||
|                         keyboardType: TextInputType.url, | ||||
|                       ), | ||||
|                       const Gap(16), | ||||
|                       TextField( | ||||
|                         controller: aspectRatioController, | ||||
|                         decoration: InputDecoration( | ||||
|                           labelText: 'aspectRatio'.tr(), | ||||
|                           hintText: '16/9 = 1.777', | ||||
|                           border: OutlineInputBorder( | ||||
|                             borderRadius: BorderRadius.circular(8), | ||||
|                           ), | ||||
|                         ), | ||||
|                         keyboardType: TextInputType.numberWithOptions( | ||||
|                           decimal: true, | ||||
|                         ), | ||||
|                         inputFormatters: [ | ||||
|                           FilteringTextInputFormatter.allow( | ||||
|                             RegExp(r'^\d*\.?\d*$'), | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|                       const Gap(16), | ||||
|                       DropdownButtonFormField2<PostEmbedViewRenderer>( | ||||
|                         value: selectedRenderer.value, | ||||
|                         decoration: InputDecoration( | ||||
|                           labelText: 'renderer'.tr(), | ||||
|                           border: OutlineInputBorder( | ||||
|                             borderRadius: BorderRadius.circular(8), | ||||
|                           ), | ||||
|                         ), | ||||
|                         selectedItemBuilder: (context) { | ||||
|                           return PostEmbedViewRenderer.values.map((renderer) { | ||||
|                             return Text(renderer.name).tr(); | ||||
|                           }).toList(); | ||||
|                         }, | ||||
|                         menuItemStyleData: MenuItemStyleData( | ||||
|                           padding: EdgeInsets.zero, | ||||
|                         ), | ||||
|                         items: | ||||
|                             PostEmbedViewRenderer.values.map((renderer) { | ||||
|                               return DropdownMenuItem( | ||||
|                                 value: renderer, | ||||
|                                 child: Text( | ||||
|                                   renderer.name, | ||||
|                                 ).tr().padding(horizontal: 20), | ||||
|                               ); | ||||
|                             }).toList(), | ||||
|                         onChanged: (value) { | ||||
|                           if (value != null) { | ||||
|                             selectedRenderer.value = value; | ||||
|                           } | ||||
|                         }, | ||||
|                       ), | ||||
|  | ||||
|                       // Current embed view display (when exists) | ||||
|                       if (currentEmbedView != null) ...[ | ||||
|                         const Gap(32), | ||||
|                         Text( | ||||
|                           'currentEmbed'.tr(), | ||||
|                           style: theme.textTheme.titleMedium, | ||||
|                         ).padding(horizontal: 4), | ||||
|                         const Gap(8), | ||||
|                         Card( | ||||
|                           margin: EdgeInsets.zero, | ||||
|                           color: | ||||
|                               Theme.of( | ||||
|                                 context, | ||||
|                               ).colorScheme.surfaceContainerHigh, | ||||
|                           child: Padding( | ||||
|                             padding: const EdgeInsets.only( | ||||
|                               left: 16, | ||||
|                               right: 16, | ||||
|                               bottom: 12, | ||||
|                               top: 4, | ||||
|                             ), | ||||
|                             child: Column( | ||||
|                               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                               children: [ | ||||
|                                 Icon( | ||||
|                                   currentEmbedView.renderer == | ||||
|                                           PostEmbedViewRenderer.webView | ||||
|                                       ? Symbols.web | ||||
|                                       : Symbols.web, | ||||
|                                   color: colorScheme.primary, | ||||
|                                 Row( | ||||
|                                   children: [ | ||||
|                                     Icon( | ||||
|                                       currentEmbedView.renderer == | ||||
|                                               PostEmbedViewRenderer.webView | ||||
|                                           ? Symbols.web | ||||
|                                           : Symbols.web, | ||||
|                                       color: colorScheme.primary, | ||||
|                                     ), | ||||
|                                     const Gap(12), | ||||
|                                     Expanded( | ||||
|                                       child: Text( | ||||
|                                         currentEmbedView.uri, | ||||
|                                         style: theme.textTheme.bodyMedium, | ||||
|                                         maxLines: 1, | ||||
|                                         overflow: TextOverflow.ellipsis, | ||||
|                                       ), | ||||
|                                     ), | ||||
|                                     IconButton( | ||||
|                                       icon: const Icon(Symbols.delete), | ||||
|                                       onPressed: () => deleteEmbed(context), | ||||
|                                       tooltip: 'delete'.tr(), | ||||
|                                       color: colorScheme.error, | ||||
|                                     ), | ||||
|                                   ], | ||||
|                                 ), | ||||
|                                 const Gap(12), | ||||
|                                 Expanded( | ||||
|                                   child: Text( | ||||
|                                     currentEmbedView.uri, | ||||
|                                     style: theme.textTheme.bodyMedium, | ||||
|                                     maxLines: 1, | ||||
|                                     overflow: TextOverflow.ellipsis, | ||||
|                                 Text( | ||||
|                                   'aspectRatio'.tr(), | ||||
|                                   style: theme.textTheme.labelMedium?.copyWith( | ||||
|                                     color: colorScheme.onSurfaceVariant, | ||||
|                                   ), | ||||
|                                 ), | ||||
|                                 IconButton( | ||||
|                                   icon: const Icon(Symbols.delete), | ||||
|                                   onPressed: () { | ||||
|                                     showDialog( | ||||
|                                       context: context, | ||||
|                                       builder: | ||||
|                                           (dialogContext) => AlertDialog( | ||||
|                                             title: Text('deleteEmbed').tr(), | ||||
|                                             content: | ||||
|                                                 Text('deleteEmbedConfirm').tr(), | ||||
|                                             actions: [ | ||||
|                                               TextButton( | ||||
|                                                 onPressed: | ||||
|                                                     () => | ||||
|                                                         Navigator.of( | ||||
|                                                           dialogContext, | ||||
|                                                         ).pop(), | ||||
|                                                 child: Text('cancel'.tr()), | ||||
|                                               ), | ||||
|                                               TextButton( | ||||
|                                                 onPressed: () { | ||||
|                                                   ComposeLogic.deleteEmbedView( | ||||
|                                                     state, | ||||
|                                                   ); | ||||
|                                                   clearForm(); | ||||
|                                                   Navigator.of( | ||||
|                                                     dialogContext, | ||||
|                                                   ).pop(); | ||||
|                                                 }, | ||||
|                                                 style: TextButton.styleFrom( | ||||
|                                                   foregroundColor: | ||||
|                                                       colorScheme.error, | ||||
|                                                 ), | ||||
|                                                 child: Text('delete').tr(), | ||||
|                                               ), | ||||
|                                             ], | ||||
|                                           ), | ||||
|                                     ); | ||||
|                                   }, | ||||
|                                   tooltip: 'delete'.tr(), | ||||
|                                   color: colorScheme.error, | ||||
|                                 const Gap(4), | ||||
|                                 Text( | ||||
|                                   currentEmbedView.aspectRatio != null | ||||
|                                       ? currentEmbedView.aspectRatio! | ||||
|                                           .toStringAsFixed(2) | ||||
|                                       : 'notSet'.tr(), | ||||
|                                   style: theme.textTheme.bodyMedium, | ||||
|                                 ), | ||||
|                               ], | ||||
|                             ), | ||||
|                             const Gap(12), | ||||
|                             Text( | ||||
|                               'aspectRatio'.tr(), | ||||
|                               style: theme.textTheme.labelMedium?.copyWith( | ||||
|                                 color: colorScheme.onSurfaceVariant, | ||||
|                               ), | ||||
|                             ), | ||||
|                             const Gap(4), | ||||
|                             Text( | ||||
|                               currentEmbedView.aspectRatio != null | ||||
|                                   ? currentEmbedView.aspectRatio! | ||||
|                                       .toStringAsFixed(2) | ||||
|                                   : 'notSet'.tr(), | ||||
|                               style: theme.textTheme.bodyMedium, | ||||
|                             ), | ||||
|                           ], | ||||
|                           ), | ||||
|                         ), | ||||
|                       ), | ||||
|                     ), | ||||
|                   ] else ...[ | ||||
|                     // Save button for new embed | ||||
|                     const Gap(16), | ||||
|                     SizedBox( | ||||
|                       width: double.infinity, | ||||
|                       child: FilledButton.icon( | ||||
|                         onPressed: saveEmbedView, | ||||
|                         icon: const Icon(Symbols.add), | ||||
|                         label: Text('addEmbed'.tr()), | ||||
|                       ), | ||||
|                     ), | ||||
|                   ], | ||||
|                 ], | ||||
|               ), | ||||
|                       ] else ...[ | ||||
|                         const Gap(16), | ||||
|                         SizedBox( | ||||
|                           width: double.infinity, | ||||
|                           child: FilledButton.icon( | ||||
|                             onPressed: saveEmbedView, | ||||
|                             icon: const Icon(Symbols.add), | ||||
|                             label: Text('addEmbed'.tr()), | ||||
|                           ), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|   | ||||
| @@ -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/file_pool.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); | ||||
|     } | ||||
| @@ -635,7 +672,7 @@ class ComposeLogic { | ||||
|     try { | ||||
|       state.submitting.value = true; | ||||
|  | ||||
|       // Upload any local attachments first | ||||
|       // pload any local attachments first | ||||
|       await Future.wait( | ||||
|         state.attachments.value | ||||
|             .asMap() | ||||
| @@ -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); | ||||
|     } | ||||
| @@ -78,69 +82,83 @@ class ComposeToolbar extends HookConsumerWidget { | ||||
|           constraints: const BoxConstraints(maxWidth: 560), | ||||
|           child: Row( | ||||
|             children: [ | ||||
|               IconButton( | ||||
|                 onPressed: pickPhotoMedia, | ||||
|                 tooltip: 'addPhoto'.tr(), | ||||
|                 icon: const Icon(Symbols.add_a_photo), | ||||
|                 color: colorScheme.primary, | ||||
|               ), | ||||
|               IconButton( | ||||
|                 onPressed: pickVideoMedia, | ||||
|                 tooltip: 'addVideo'.tr(), | ||||
|                 icon: const Icon(Symbols.videocam), | ||||
|                 color: colorScheme.primary, | ||||
|               ), | ||||
|               IconButton( | ||||
|                 onPressed: addAudio, | ||||
|                 tooltip: 'addAudio'.tr(), | ||||
|                 icon: const Icon(Symbols.mic), | ||||
|                 color: colorScheme.primary, | ||||
|               ), | ||||
|               IconButton( | ||||
|                 onPressed: linkAttachment, | ||||
|                 icon: const Icon(Symbols.attach_file), | ||||
|                 tooltip: 'linkAttachment'.tr(), | ||||
|                 color: colorScheme.primary, | ||||
|               ), | ||||
|               // Poll button with visual state when a poll is linked | ||||
|               ListenableBuilder( | ||||
|                 listenable: state.pollId, | ||||
|                 builder: (context, _) { | ||||
|                   return IconButton( | ||||
|                     onPressed: pickPoll, | ||||
|                     icon: const Icon(Symbols.how_to_vote), | ||||
|                     tooltip: 'poll'.tr(), | ||||
|                     color: colorScheme.primary, | ||||
|                     style: ButtonStyle( | ||||
|                       backgroundColor: WidgetStatePropertyAll( | ||||
|                         state.pollId.value != null | ||||
|                             ? colorScheme.primary.withOpacity(0.15) | ||||
|                             : null, | ||||
|               Expanded( | ||||
|                 child: SingleChildScrollView( | ||||
|                   scrollDirection: Axis.horizontal, | ||||
|                   child: Row( | ||||
|                     children: [ | ||||
|                       IconButton( | ||||
|                         onPressed: pickPhotoMedia, | ||||
|                         tooltip: 'addPhoto'.tr(), | ||||
|                         icon: const Icon(Symbols.add_a_photo), | ||||
|                         color: colorScheme.primary, | ||||
|                       ), | ||||
|                     ), | ||||
|                   ); | ||||
|                 }, | ||||
|               ), | ||||
|               // Embed button with visual state when embed is present | ||||
|               ListenableBuilder( | ||||
|                 listenable: state.embedView, | ||||
|                 builder: (context, _) { | ||||
|                   return IconButton( | ||||
|                     onPressed: showEmbedSheet, | ||||
|                     icon: const Icon(Symbols.web), | ||||
|                     tooltip: 'embedView'.tr(), | ||||
|                     color: colorScheme.primary, | ||||
|                     style: ButtonStyle( | ||||
|                       backgroundColor: WidgetStatePropertyAll( | ||||
|                         state.embedView.value != null | ||||
|                             ? colorScheme.primary.withOpacity(0.15) | ||||
|                             : null, | ||||
|                       IconButton( | ||||
|                         onPressed: pickVideoMedia, | ||||
|                         tooltip: 'addVideo'.tr(), | ||||
|                         icon: const Icon(Symbols.videocam), | ||||
|                         color: colorScheme.primary, | ||||
|                       ), | ||||
|                     ), | ||||
|                   ); | ||||
|                 }, | ||||
|                       IconButton( | ||||
|                         onPressed: addAudio, | ||||
|                         tooltip: 'addAudio'.tr(), | ||||
|                         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), | ||||
|                         tooltip: 'linkAttachment'.tr(), | ||||
|                         color: colorScheme.primary, | ||||
|                       ), | ||||
|                       // Poll button with visual state when a poll is linked | ||||
|                       ListenableBuilder( | ||||
|                         listenable: state.pollId, | ||||
|                         builder: (context, _) { | ||||
|                           return IconButton( | ||||
|                             onPressed: pickPoll, | ||||
|                             icon: const Icon(Symbols.how_to_vote), | ||||
|                             tooltip: 'poll'.tr(), | ||||
|                             color: colorScheme.primary, | ||||
|                             style: ButtonStyle( | ||||
|                               backgroundColor: WidgetStatePropertyAll( | ||||
|                                 state.pollId.value != null | ||||
|                                     ? colorScheme.primary.withOpacity(0.15) | ||||
|                                     : null, | ||||
|                               ), | ||||
|                             ), | ||||
|                           ); | ||||
|                         }, | ||||
|                       ), | ||||
|                       // Embed button with visual state when embed is present | ||||
|                       ListenableBuilder( | ||||
|                         listenable: state.embedView, | ||||
|                         builder: (context, _) { | ||||
|                           return IconButton( | ||||
|                             onPressed: showEmbedSheet, | ||||
|                             icon: const Icon(Symbols.iframe), | ||||
|                             tooltip: 'embedView'.tr(), | ||||
|                             color: colorScheme.primary, | ||||
|                             style: ButtonStyle( | ||||
|                               backgroundColor: WidgetStatePropertyAll( | ||||
|                                 state.embedView.value != null | ||||
|                                     ? colorScheme.primary.withOpacity(0.15) | ||||
|                                     : null, | ||||
|                               ), | ||||
|                             ), | ||||
|                           ); | ||||
|                         }, | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|               const Spacer(), | ||||
|               if (originalPost == null && state.isEmpty) | ||||
|                 IconButton( | ||||
|                   icon: const Icon(Symbols.draft), | ||||
|   | ||||
| @@ -1,3 +1,6 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/gestures.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:flutter_inappwebview/flutter_inappwebview.dart'; | ||||
| @@ -58,8 +61,8 @@ class EmbedViewRenderer extends HookConsumerWidget { | ||||
|                 children: [ | ||||
|                   Icon( | ||||
|                     embedView.renderer == PostEmbedViewRenderer.webView | ||||
|                         ? Symbols.web | ||||
|                         : Symbols.web, | ||||
|                         ? Symbols.globe | ||||
|                         : Symbols.iframe, | ||||
|                     size: 16, | ||||
|                     color: colorScheme.primary, | ||||
|                   ), | ||||
| @@ -74,13 +77,13 @@ class EmbedViewRenderer extends HookConsumerWidget { | ||||
|                       overflow: TextOverflow.ellipsis, | ||||
|                     ), | ||||
|                   ), | ||||
|                   IconButton( | ||||
|                     icon: Icon( | ||||
|                   InkWell( | ||||
|                     child: Icon( | ||||
|                       Symbols.open_in_new, | ||||
|                       size: 16, | ||||
|                       color: colorScheme.onSurfaceVariant, | ||||
|                     ), | ||||
|                     onPressed: () async { | ||||
|                     onTap: () async { | ||||
|                       final uri = Uri.parse(embedView.uri); | ||||
|                       if (await canLaunchUrl(uri)) { | ||||
|                         await launchUrl( | ||||
| @@ -89,10 +92,6 @@ class EmbedViewRenderer extends HookConsumerWidget { | ||||
|                         ); | ||||
|                       } | ||||
|                     }, | ||||
|                     padding: EdgeInsets.zero, | ||||
|                     constraints: const BoxConstraints(), | ||||
|                     visualDensity: VisualDensity.compact, | ||||
|                     tooltip: 'Open in browser', | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
| @@ -106,6 +105,20 @@ class EmbedViewRenderer extends HookConsumerWidget { | ||||
|                       ? Stack( | ||||
|                         children: [ | ||||
|                           InAppWebView( | ||||
|                             gestureRecognizers: { | ||||
|                               Factory<VerticalDragGestureRecognizer>( | ||||
|                                 () => VerticalDragGestureRecognizer(), | ||||
|                               ), | ||||
|                               Factory<HorizontalDragGestureRecognizer>( | ||||
|                                 () => HorizontalDragGestureRecognizer(), | ||||
|                               ), | ||||
|                               Factory<ScaleGestureRecognizer>( | ||||
|                                 () => ScaleGestureRecognizer(), | ||||
|                               ), | ||||
|                               Factory<TapGestureRecognizer>( | ||||
|                                 () => TapGestureRecognizer(), | ||||
|                               ), | ||||
|                             }, | ||||
|                             initialUrlRequest: URLRequest( | ||||
|                               url: WebUri(embedView.uri), | ||||
|                             ), | ||||
| @@ -256,14 +269,14 @@ class EmbedViewRenderer extends HookConsumerWidget { | ||||
|                             children: [ | ||||
|                               Icon( | ||||
|                                 Symbols.play_arrow, | ||||
|                                 fill: 1, | ||||
|                                 size: 48, | ||||
|                                 color: colorScheme.onSurfaceVariant.withOpacity( | ||||
|                                   0.6, | ||||
|                                 ), | ||||
|                               ), | ||||
|                               const SizedBox(height: 8), | ||||
|                               Text( | ||||
|                                 'Tap to load content', | ||||
|                                 'viewEmbedLoadHint'.tr(), | ||||
|                                 style: theme.textTheme.bodyMedium?.copyWith( | ||||
|                                   color: colorScheme.onSurfaceVariant | ||||
|                                       .withOpacity(0.6), | ||||
|   | ||||
| @@ -31,6 +31,36 @@ import 'package:share_plus/share_plus.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:super_context_menu/super_context_menu.dart'; | ||||
|  | ||||
| const kAvailableStickers = { | ||||
|   'angry', | ||||
|   'clap', | ||||
|   'confuse', | ||||
|   'pray', | ||||
|   'thumb_up', | ||||
|   'party', | ||||
| }; | ||||
|  | ||||
| bool _getReactionImageAvailable(String symbol) { | ||||
|   return kAvailableStickers.contains(symbol); | ||||
| } | ||||
|  | ||||
| Widget _buildReactionIcon(String symbol, double size, {double iconSize = 24}) { | ||||
|   if (_getReactionImageAvailable(symbol)) { | ||||
|     return Image.asset( | ||||
|       'assets/images/stickers/$symbol.png', | ||||
|       width: size, | ||||
|       height: size, | ||||
|       fit: BoxFit.contain, | ||||
|       alignment: Alignment.bottomCenter, | ||||
|     ); | ||||
|   } else { | ||||
|     return Text( | ||||
|       kReactionTemplates[symbol]?.icon ?? '', | ||||
|       style: TextStyle(fontSize: iconSize), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class PostActionableItem extends HookConsumerWidget { | ||||
|   final SnPost item; | ||||
|   final EdgeInsets? padding; | ||||
| @@ -490,57 +520,66 @@ class PostItem extends HookConsumerWidget { | ||||
|           trailing: | ||||
|               isCompact | ||||
|                   ? null | ||||
|                   : IconButton( | ||||
|                     icon: | ||||
|                         mostReaction == null | ||||
|                             ? const Icon(Symbols.add_reaction) | ||||
|                             : Badge( | ||||
|                               label: Center( | ||||
|                                 child: Text( | ||||
|                                   'x${item.reactionsCount[mostReaction]}', | ||||
|                                   style: const TextStyle(fontSize: 11), | ||||
|                                   textAlign: TextAlign.center, | ||||
|                   : SizedBox( | ||||
|                     width: 36, | ||||
|                     height: 36, | ||||
|                     child: IconButton( | ||||
|                       icon: | ||||
|                           mostReaction == null | ||||
|                               ? const Icon(Symbols.add_reaction) | ||||
|                               : Badge( | ||||
|                                 label: Center( | ||||
|                                   child: Text( | ||||
|                                     'x${item.reactionsCount[mostReaction]}', | ||||
|                                     style: const TextStyle(fontSize: 11), | ||||
|                                     textAlign: TextAlign.center, | ||||
|                                   ), | ||||
|                                 ), | ||||
|                                 offset: const Offset(4, 20), | ||||
|                                 backgroundColor: Theme.of( | ||||
|                                   context, | ||||
|                                 ).colorScheme.primary.withOpacity(0.75), | ||||
|                                 textColor: | ||||
|                                     Theme.of(context).colorScheme.onPrimary, | ||||
|                                 child: _buildReactionIcon( | ||||
|                                   mostReaction, | ||||
|                                   32, | ||||
|                                 ).padding( | ||||
|                                   bottom: | ||||
|                                       _getReactionImageAvailable(mostReaction) | ||||
|                                           ? 2 | ||||
|                                           : 0, | ||||
|                                 ), | ||||
|                               ), | ||||
|                               offset: const Offset(4, 20), | ||||
|                               backgroundColor: Theme.of( | ||||
|                       style: ButtonStyle( | ||||
|                         backgroundColor: WidgetStatePropertyAll( | ||||
|                           (item.reactionsMade[mostReaction] ?? false) | ||||
|                               ? Theme.of( | ||||
|                                 context, | ||||
|                               ).colorScheme.primary.withOpacity(0.75), | ||||
|                               textColor: | ||||
|                                   Theme.of(context).colorScheme.onPrimary, | ||||
|                               child: Text( | ||||
|                                 kReactionTemplates[mostReaction]?.icon ?? '', | ||||
|                                 style: const TextStyle(fontSize: 20), | ||||
|                               ), | ||||
|                             ), | ||||
|                     style: ButtonStyle( | ||||
|                       backgroundColor: WidgetStatePropertyAll( | ||||
|                         (item.reactionsMade[mostReaction] ?? false) | ||||
|                             ? Theme.of( | ||||
|                               context, | ||||
|                             ).colorScheme.primary.withOpacity(0.5) | ||||
|                             : null, | ||||
|                               ).colorScheme.primary.withOpacity(0.5) | ||||
|                               : null, | ||||
|                         ), | ||||
|                       ), | ||||
|                       onPressed: () { | ||||
|                         showModalBottomSheet( | ||||
|                           context: context, | ||||
|                           useRootNavigator: true, | ||||
|                           builder: (BuildContext context) { | ||||
|                             return _PostReactionSheet( | ||||
|                               reactionsCount: item.reactionsCount, | ||||
|                               reactionsMade: item.reactionsMade, | ||||
|                               onReact: (symbol, attitude) { | ||||
|                                 reactPost(symbol, attitude); | ||||
|                               }, | ||||
|                             ); | ||||
|                           }, | ||||
|                         ); | ||||
|                       }, | ||||
|                       padding: EdgeInsets.zero, | ||||
|                       visualDensity: const VisualDensity( | ||||
|                         horizontal: -3, | ||||
|                         vertical: -3, | ||||
|                       ), | ||||
|                     ), | ||||
|                     onPressed: () { | ||||
|                       showModalBottomSheet( | ||||
|                         context: context, | ||||
|                         useRootNavigator: true, | ||||
|                         builder: (BuildContext context) { | ||||
|                           return _PostReactionSheet( | ||||
|                             reactionsCount: item.reactionsCount, | ||||
|                             reactionsMade: item.reactionsMade, | ||||
|                             onReact: (symbol, attitude) { | ||||
|                               reactPost(symbol, attitude); | ||||
|                             }, | ||||
|                           ); | ||||
|                         }, | ||||
|                       ); | ||||
|                     }, | ||||
|                     padding: EdgeInsets.zero, | ||||
|                     visualDensity: const VisualDensity( | ||||
|                       horizontal: -3, | ||||
|                       vertical: -3, | ||||
|                     ), | ||||
|                   ), | ||||
|         ), | ||||
| @@ -611,7 +650,7 @@ class PostReactionList extends HookConsumerWidget { | ||||
|     } | ||||
|  | ||||
|     return SizedBox( | ||||
|       height: 28, | ||||
|       height: 40, | ||||
|       child: ListView( | ||||
|         scrollDirection: Axis.horizontal, | ||||
|         padding: padding ?? EdgeInsets.zero, | ||||
| @@ -649,7 +688,7 @@ class PostReactionList extends HookConsumerWidget { | ||||
|             Padding( | ||||
|               padding: const EdgeInsets.only(right: 8), | ||||
|               child: ActionChip( | ||||
|                 avatar: Text(kReactionTemplates[symbol]?.icon ?? '?'), | ||||
|                 avatar: _buildReactionIcon(symbol, 24), | ||||
|                 label: Row( | ||||
|                   spacing: 4, | ||||
|                   children: [ | ||||
| @@ -786,37 +825,96 @@ class _PostReactionSheet extends StatelessWidget { | ||||
|             itemBuilder: (context, index) { | ||||
|               final symbol = allReactions[index]; | ||||
|               final count = reactionsCount[symbol] ?? 0; | ||||
|               return Card( | ||||
|                 margin: const EdgeInsets.symmetric(vertical: 4), | ||||
|                 color: | ||||
|                     (reactionsMade[symbol] ?? false) | ||||
|                         ? Theme.of(context).colorScheme.primaryContainer | ||||
|                         : Theme.of(context).colorScheme.surfaceContainerLowest, | ||||
|                 child: InkWell( | ||||
|                   borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|                   onTap: () { | ||||
|                     onReact(symbol, attitude); | ||||
|                     Navigator.pop(context); | ||||
|                   }, | ||||
|                   child: Column( | ||||
|                     mainAxisAlignment: MainAxisAlignment.center, | ||||
|                     children: [ | ||||
|                       Text( | ||||
|                         kReactionTemplates[symbol]?.icon ?? '', | ||||
|                         textAlign: TextAlign.center, | ||||
|                       ).fontSize(24), | ||||
|                       Text( | ||||
|                         ReactInfo.getTranslationKey(symbol), | ||||
|                         textAlign: TextAlign.center, | ||||
|                       ).tr(), | ||||
|                       if (count > 0) | ||||
|                         Text( | ||||
|                           'x$count', | ||||
|                           textAlign: TextAlign.center, | ||||
|                         ).bold().padding(bottom: 4) | ||||
|                       else | ||||
|                         const Gap(20), | ||||
|                     ], | ||||
|               final hasImage = _getReactionImageAvailable(symbol); | ||||
|               return Badge( | ||||
|                 label: Text('x$count'), | ||||
|                 isLabelVisible: count > 0, | ||||
|                 textColor: Theme.of(context).colorScheme.onPrimary, | ||||
|                 backgroundColor: Theme.of(context).colorScheme.primary, | ||||
|                 offset: Offset(0, 0), | ||||
|                 child: Card( | ||||
|                   margin: const EdgeInsets.symmetric(vertical: 4), | ||||
|                   color: Theme.of(context).colorScheme.surfaceContainerLowest, | ||||
|                   child: InkWell( | ||||
|                     borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|                     onTap: () { | ||||
|                       onReact(symbol, attitude); | ||||
|                       Navigator.pop(context); | ||||
|                     }, | ||||
|                     child: Container( | ||||
|                       decoration: | ||||
|                           hasImage | ||||
|                               ? BoxDecoration( | ||||
|                                 borderRadius: BorderRadius.circular(8), | ||||
|                                 image: DecorationImage( | ||||
|                                   image: AssetImage( | ||||
|                                     'assets/images/stickers/$symbol.png', | ||||
|                                   ), | ||||
|                                   fit: BoxFit.cover, | ||||
|                                   colorFilter: | ||||
|                                       (reactionsMade[symbol] ?? false) | ||||
|                                           ? ColorFilter.mode( | ||||
|                                             Theme.of(context) | ||||
|                                                 .colorScheme | ||||
|                                                 .primaryContainer | ||||
|                                                 .withOpacity(0.7), | ||||
|                                             BlendMode.srcATop, | ||||
|                                           ) | ||||
|                                           : null, | ||||
|                                 ), | ||||
|                               ) | ||||
|                               : null, | ||||
|                       child: Stack( | ||||
|                         fit: StackFit.expand, | ||||
|                         children: [ | ||||
|                           if (hasImage) | ||||
|                             Container( | ||||
|                               decoration: BoxDecoration( | ||||
|                                 borderRadius: BorderRadius.circular(8), | ||||
|                                 gradient: LinearGradient( | ||||
|                                   begin: Alignment.bottomCenter, | ||||
|                                   end: Alignment.topCenter, | ||||
|                                   colors: [ | ||||
|                                     Theme.of(context) | ||||
|                                         .colorScheme | ||||
|                                         .surfaceContainerHighest | ||||
|                                         .withOpacity(0.7), | ||||
|                                     Colors.transparent, | ||||
|                                   ], | ||||
|                                   stops: [0.0, 0.3], | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ), | ||||
|                           Column( | ||||
|                             mainAxisAlignment: | ||||
|                                 hasImage | ||||
|                                     ? MainAxisAlignment.end | ||||
|                                     : MainAxisAlignment.center, | ||||
|                             children: [ | ||||
|                               if (!hasImage) _buildReactionIcon(symbol, 36), | ||||
|                               Text( | ||||
|                                 ReactInfo.getTranslationKey(symbol), | ||||
|                                 textAlign: TextAlign.center, | ||||
|                                 style: TextStyle( | ||||
|                                   color: hasImage ? Colors.white : null, | ||||
|                                   shadows: | ||||
|                                       hasImage | ||||
|                                           ? [ | ||||
|                                             const Shadow( | ||||
|                                               blurRadius: 4, | ||||
|                                               offset: Offset(0.5, 0.5), | ||||
|                                               color: Colors.black, | ||||
|                                             ), | ||||
|                                           ] | ||||
|                                           : null, | ||||
|                                 ), | ||||
|                               ).tr(), | ||||
|                               if (hasImage) const Gap(4), | ||||
|                             ], | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ); | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -21,19 +21,19 @@ PODS: | ||||
|   - Firebase/Messaging (12.2.0): | ||||
|     - Firebase/CoreOnly | ||||
|     - FirebaseMessaging (~> 12.2.0) | ||||
|   - firebase_analytics (12.0.1): | ||||
|   - firebase_analytics (12.0.2): | ||||
|     - firebase_core | ||||
|     - FirebaseAnalytics (= 12.2.0) | ||||
|     - FlutterMacOS | ||||
|   - firebase_core (4.1.0): | ||||
|   - firebase_core (4.1.1): | ||||
|     - Firebase/CoreOnly (~> 12.2.0) | ||||
|     - FlutterMacOS | ||||
|   - firebase_crashlytics (5.0.1): | ||||
|   - firebase_crashlytics (5.0.2): | ||||
|     - Firebase/CoreOnly (~> 12.2.0) | ||||
|     - Firebase/Crashlytics (~> 12.2.0) | ||||
|     - firebase_core | ||||
|     - FlutterMacOS | ||||
|   - firebase_messaging (16.0.1): | ||||
|   - firebase_messaging (16.0.2): | ||||
|     - Firebase/CoreOnly (~> 12.2.0) | ||||
|     - Firebase/Messaging (~> 12.2.0) | ||||
|     - firebase_core | ||||
| @@ -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`) | ||||
| @@ -402,10 +402,10 @@ SPEC CHECKSUMS: | ||||
|   file_saver: e35bd97de451dde55ff8c38862ed7ad0f3418d0f | ||||
|   file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 | ||||
|   Firebase: 26f6f8d460603af3df970ad505b16b15f5e2e9a1 | ||||
|   firebase_analytics: efe6e51156f4565f3791d99072e8e3b0fcca0e91 | ||||
|   firebase_core: a8d3b82b0a87bd1d0ebc21e686b37e939c56e6e1 | ||||
|   firebase_crashlytics: fdbe67a1229a9e583ebf2b155541491aa83927bb | ||||
|   firebase_messaging: 6fb526705903e2e56e38a6ff56b43668b052b01b | ||||
|   firebase_analytics: 26346c2ccb9ba410c2f33d5d34c62e6369cbbf29 | ||||
|   firebase_core: 54fd706197e1779d510b297548eee74d3b39577c | ||||
|   firebase_crashlytics: 3694b4aca0849f6919244d7bbbb40615f989f46b | ||||
|   firebase_messaging: 658f1a6906d80faec2fb20e3aadb81af6b09e441 | ||||
|   FirebaseAnalytics: e04e23bc070e3014aa5cf4980f9df7ce5cd79ec8 | ||||
|   FirebaseCore: 311c48a147ad4a0ab7febbaed89e8025c67510cd | ||||
|   FirebaseCoreExtension: 73af080c22a2f7b44cefa391dc08f7e4ee162cb5 | ||||
| @@ -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> | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user