Compare commits
	
		
			50 Commits
		
	
	
		
			3.1.0+115
			...
			a6d869ebf6
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| a6d869ebf6 | |||
| f3a8699389 | |||
| d345c00e84 | |||
| a706f127b6 | |||
| 680ece0b6a | |||
| b976c6ed37 | |||
| 6ae6b132de | |||
| 95aec7c95b | |||
| edd760fbcb | |||
| ba269dbbb8 | |||
| 1aa45dd9f1 | |||
| 92685d7410 | |||
| c8e351514d | |||
| f3900825e3 | |||
| 2cc6652f75 | |||
| 4d4409de2e | |||
| e1286c797f | |||
| bec037622f | |||
| a0d8c1a9b3 | |||
| 26135d2116 | |||
| 71b67fd22d | |||
| 855072dfea | |||
| b39e2e2d64 | |||
| 84b1d6a346 | |||
| 28335dd548 | |||
| 7253e2d3ef | |||
| 4d489425fa | |||
| 890a8a44cf | |||
| 8e3583f57a | |||
| d0ff14659f | |||
| 1f7caaeaac | |||
| 9f9f42071a | |||
| 6bd6e994cb | |||
| 02e68d76ee | |||
| d04b06089c | |||
| 9be6fea2e0 | |||
| 6b1214a06f | |||
| 4597373ac9 | |||
| 047c8d93aa | |||
| 715f95ca22 | |||
| ba709012d7 | |||
| fd186f8391 | |||
| 262d36cd2d | |||
| f320855348 | |||
| ed90152462 | |||
| 6e5c5f1690 | |||
| 7c92dee097 | |||
| e4bb031138 | |||
| 97226ae96b | |||
| d8cd33e79a | 
| @@ -4,6 +4,7 @@ | |||||||
|     <uses-permission android:name="android.permission.INTERNET" /> |     <uses-permission android:name="android.permission.INTERNET" /> | ||||||
|     <uses-permission android:name="android.permission.CAMERA" /> |     <uses-permission android:name="android.permission.CAMERA" /> | ||||||
|     <uses-permission android:name="android.permission.RECORD_AUDIO" /> |     <uses-permission android:name="android.permission.RECORD_AUDIO" /> | ||||||
|  |     <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> | ||||||
|     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> |     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> | ||||||
|     <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" /> |     <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" /> | ||||||
|     <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> |     <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> | ||||||
| @@ -89,6 +90,13 @@ | |||||||
|             </intent-filter> |             </intent-filter> | ||||||
|         </activity> |         </activity> | ||||||
|  |  | ||||||
|  |         <!-- Livekit Screenshare --> | ||||||
|  |         <service | ||||||
|  |             android:name="de.julianassmann.flutter_background.IsolateHolderService" | ||||||
|  |             android:enabled="true" | ||||||
|  |             android:exported="false" | ||||||
|  |             android:foregroundServiceType="mediaProjection" /> | ||||||
|  |  | ||||||
|         <!-- Sign in with Apple --> |         <!-- Sign in with Apple --> | ||||||
|         <activity |         <activity | ||||||
|             android:name="com.aboutyou.dart_packages.sign_in_with_apple.SignInWithAppleCallback" |             android:name="com.aboutyou.dart_packages.sign_in_with_apple.SignInWithAppleCallback" | ||||||
|   | |||||||
| @@ -146,7 +146,13 @@ | |||||||
|   "edited": "Edited", |   "edited": "Edited", | ||||||
|   "addVideo": "Add video", |   "addVideo": "Add video", | ||||||
|   "addPhoto": "Add photo", |   "addPhoto": "Add photo", | ||||||
|  |   "addAudio": "Add audio", | ||||||
|   "addFile": "Add file", |   "addFile": "Add file", | ||||||
|  |   "recordAudio": "Record Audio", | ||||||
|  |   "linkAttachment": "Link Attachment", | ||||||
|  |   "fileIdCannotBeEmpty": "File ID cannot be empty", | ||||||
|  |   "fileIdLinkHint": "Haven't upload to the Solar Network? Tap here to open Solar Network Drive to customize your uploads.", | ||||||
|  |   "failedToFetchFile": "Failed to fetch file: {}", | ||||||
|   "createDirectMessage": "Send new DM", |   "createDirectMessage": "Send new DM", | ||||||
|   "gotoDirectMessage": "Go to DM", |   "gotoDirectMessage": "Go to DM", | ||||||
|   "react": "React", |   "react": "React", | ||||||
| @@ -352,6 +358,8 @@ | |||||||
|   "postTitle": "Title", |   "postTitle": "Title", | ||||||
|   "postDescription": "Description", |   "postDescription": "Description", | ||||||
|   "call": "Call", |   "call": "Call", | ||||||
|  |   "callLeave": "Leave", | ||||||
|  |   "callEnd": "End this call", | ||||||
|   "done": "Done", |   "done": "Done", | ||||||
|   "loginResetPasswordSent": "Password reset link sent, please check your email inbox.", |   "loginResetPasswordSent": "Password reset link sent, please check your email inbox.", | ||||||
|   "accountDeletion": "Delete Account", |   "accountDeletion": "Delete Account", | ||||||
| @@ -622,8 +630,8 @@ | |||||||
|   "chatJoin": "Join the Chat", |   "chatJoin": "Join the Chat", | ||||||
|   "realmJoin": "Join the Realm", |   "realmJoin": "Join the Realm", | ||||||
|   "realmJoinSuccess": "Successfully joined the realm.", |   "realmJoinSuccess": "Successfully joined the realm.", | ||||||
|   "discoverRealms": "Discover Realms", |   "discoverRealms": "Discover realms", | ||||||
|   "discoverPublishers": "Discover Publishers", |   "discoverPublishers": "Discover publishers", | ||||||
|   "search": "Search", |   "search": "Search", | ||||||
|   "publisherMembers": "Collaborators", |   "publisherMembers": "Collaborators", | ||||||
|   "developerHub": "Developer Hub", |   "developerHub": "Developer Hub", | ||||||
| @@ -702,5 +710,56 @@ | |||||||
|   "aboutDeviceName": "Device Name", |   "aboutDeviceName": "Device Name", | ||||||
|   "aboutDeviceIdentifier": "Device Identifier", |   "aboutDeviceIdentifier": "Device Identifier", | ||||||
|   "donate": "Donate", |   "donate": "Donate", | ||||||
|   "donateDescription": "Support us to continue developing the Solar Network and keep the server up and running." |   "donateDescription": "Support us to continue developing the Solar Network and keep the server up and running.", | ||||||
|  |   "fileId": "File ID", | ||||||
|  |   "fileIdHint": "The file ID is the ID you get after upload the file via the Solar Network Drive.", | ||||||
|  |   "translate": "Translate", | ||||||
|  |   "translating": "Translating", | ||||||
|  |   "translated": "Translated", | ||||||
|  |   "reactionThumbUp": "Thumbs Up", | ||||||
|  |   "reactionThumbDown": "Thumbs Down", | ||||||
|  |   "reactionJustOkay": "Just Okay", | ||||||
|  |   "reactionCry": "Cry", | ||||||
|  |   "reactionConfuse": "Confused", | ||||||
|  |   "reactionClap": "Clap", | ||||||
|  |   "reactionLaugh": "Laugh", | ||||||
|  |   "reactionAngry": "Angry", | ||||||
|  |   "reactionParty": "Party", | ||||||
|  |   "reactionPray": "Pray", | ||||||
|  |   "reactionHeart": "Heart", | ||||||
|  |   "selectMicrophone": "Select Microphone", | ||||||
|  |   "selectCamera": "Select Camera", | ||||||
|  |   "switchedTo": "Switched to {}", | ||||||
|  |   "connecting": "Connecting", | ||||||
|  |   "reconnecting": "Reconnecting", | ||||||
|  |   "disconnected": "Disconnected", | ||||||
|  |   "connected": "Connected", | ||||||
|  |   "repliesLoadMore": "Load more replies", | ||||||
|  |   "attachmentsRecentUploads": "Recent Uploads", | ||||||
|  |   "attachmentsManualInput": "Manual Input", | ||||||
|  |   "crop": "Crop", | ||||||
|  |   "rename": "Rename", | ||||||
|  |   "markAsSensitive": "Mark as Sensitive", | ||||||
|  |   "fileName": "File name", | ||||||
|  |   "sensitiveCategories.language": "Language", | ||||||
|  |   "sensitiveCategories.sexualContent": "Sexual Content", | ||||||
|  |   "sensitiveCategories.violence": "Violence", | ||||||
|  |   "sensitiveCategories.profanity": "Profanity", | ||||||
|  |   "sensitiveCategories.hateSpeech": "Hate Speech", | ||||||
|  |   "sensitiveCategories.racism": "Racism", | ||||||
|  |   "sensitiveCategories.adultContent": "Adult Content", | ||||||
|  |   "sensitiveCategories.drugAbuse": "Drug Abuse", | ||||||
|  |   "sensitiveCategories.alcoholAbuse": "Alcohol Abuse", | ||||||
|  |   "sensitiveCategories.gambling": "Gambling", | ||||||
|  |   "sensitiveCategories.selfHarm": "Self-harm", | ||||||
|  |   "sensitiveCategories.childAbuse": "Child Abuse", | ||||||
|  |   "sensitiveCategories.other": "Other", | ||||||
|  |   "poll": "Poll", | ||||||
|  |   "pollsRecent": "Recent Polls", | ||||||
|  |   "pollCreateNew": "Create New", | ||||||
|  |   "pollCreateNewHint": "Create a new poll for your post. Pick a publisher and continue.", | ||||||
|  |   "publisher": "Publisher", | ||||||
|  |   "publisherHint": "Enter the publisher name", | ||||||
|  |   "publisherCannotBeEmpty": "Publisher cannot be empty", | ||||||
|  |   "operationFailed": "Operation failed: {}" | ||||||
| } | } | ||||||
| @@ -123,6 +123,10 @@ | |||||||
|   "addVideo": "添加视频", |   "addVideo": "添加视频", | ||||||
|   "addPhoto": "添加照片", |   "addPhoto": "添加照片", | ||||||
|   "addFile": "添加文件", |   "addFile": "添加文件", | ||||||
|  |   "addAttachmentById": "通过 ID 添加附件", | ||||||
|  |   "enterFileId": "输入文件 ID", | ||||||
|  |   "fileIdCannotBeEmpty": "文件 ID 不能为空", | ||||||
|  |   "failedToFetchFile": "获取文件失败: {}", | ||||||
|   "createDirectMessage": "创建新私人消息", |   "createDirectMessage": "创建新私人消息", | ||||||
|   "gotoDirectMessage": "前往私信", |   "gotoDirectMessage": "前往私信", | ||||||
|   "react": "反应", |   "react": "反应", | ||||||
|   | |||||||
| @@ -123,6 +123,10 @@ | |||||||
|     "addVideo": "新增影片", |     "addVideo": "新增影片", | ||||||
|     "addPhoto": "新增照片", |     "addPhoto": "新增照片", | ||||||
|     "addFile": "新增檔案", |     "addFile": "新增檔案", | ||||||
|  |     "addAttachmentById": "透過 ID 新增附件", | ||||||
|  |     "enterFileId": "輸入檔案 ID", | ||||||
|  |     "fileIdCannotBeEmpty": "檔案 ID 不能為空", | ||||||
|  |     "failedToFetchFile": "無法取得檔案: {}", | ||||||
|     "createDirectMessage": "建立新私人訊息", |     "createDirectMessage": "建立新私人訊息", | ||||||
|     "gotoDirectMessage": "Go to DM", |     "gotoDirectMessage": "Go to DM", | ||||||
|     "react": "反應", |     "react": "反應", | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| # Uncomment this line to define a global platform for your project | # Uncomment this line to define a global platform for your project | ||||||
| platform :ios, '13.0' | platform :ios, '15.0' | ||||||
|  |  | ||||||
| # CocoaPods analytics sends network stats synchronously affecting flutter build latency. | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. | ||||||
| ENV['COCOAPODS_DISABLE_STATS'] = 'true' | ENV['COCOAPODS_DISABLE_STATS'] = 'true' | ||||||
|   | |||||||
| @@ -40,33 +40,33 @@ PODS: | |||||||
|   - file_picker (0.0.1): |   - file_picker (0.0.1): | ||||||
|     - DKImagePickerController/PhotoGallery |     - DKImagePickerController/PhotoGallery | ||||||
|     - Flutter |     - Flutter | ||||||
|   - Firebase/CoreOnly (11.15.0): |   - Firebase/CoreOnly (12.0.0): | ||||||
|     - FirebaseCore (~> 11.15.0) |     - FirebaseCore (~> 12.0.0) | ||||||
|   - Firebase/Messaging (11.15.0): |   - Firebase/Messaging (12.0.0): | ||||||
|     - Firebase/CoreOnly |     - Firebase/CoreOnly | ||||||
|     - FirebaseMessaging (~> 11.15.0) |     - FirebaseMessaging (~> 12.0.0) | ||||||
|   - firebase_core (3.15.2): |   - firebase_core (4.0.0): | ||||||
|     - Firebase/CoreOnly (= 11.15.0) |     - Firebase/CoreOnly (= 12.0.0) | ||||||
|     - Flutter |     - Flutter | ||||||
|   - firebase_messaging (15.2.10): |   - firebase_messaging (16.0.0): | ||||||
|     - Firebase/Messaging (= 11.15.0) |     - Firebase/Messaging (= 12.0.0) | ||||||
|     - firebase_core |     - firebase_core | ||||||
|     - Flutter |     - Flutter | ||||||
|   - FirebaseCore (11.15.0): |   - FirebaseCore (12.0.0): | ||||||
|     - FirebaseCoreInternal (~> 11.15.0) |     - FirebaseCoreInternal (~> 12.0.0) | ||||||
|     - GoogleUtilities/Environment (~> 8.1) |     - GoogleUtilities/Environment (~> 8.1) | ||||||
|     - GoogleUtilities/Logger (~> 8.1) |     - GoogleUtilities/Logger (~> 8.1) | ||||||
|   - FirebaseCoreInternal (11.15.0): |   - FirebaseCoreInternal (12.0.0): | ||||||
|     - "GoogleUtilities/NSData+zlib (~> 8.1)" |     - "GoogleUtilities/NSData+zlib (~> 8.1)" | ||||||
|   - FirebaseInstallations (11.15.0): |   - FirebaseInstallations (12.0.0): | ||||||
|     - FirebaseCore (~> 11.15.0) |     - FirebaseCore (~> 12.0.0) | ||||||
|     - GoogleUtilities/Environment (~> 8.1) |     - GoogleUtilities/Environment (~> 8.1) | ||||||
|     - GoogleUtilities/UserDefaults (~> 8.1) |     - GoogleUtilities/UserDefaults (~> 8.1) | ||||||
|     - PromisesObjC (~> 2.4) |     - PromisesObjC (~> 2.4) | ||||||
|   - FirebaseMessaging (11.15.0): |   - FirebaseMessaging (12.0.0): | ||||||
|     - FirebaseCore (~> 11.15.0) |     - FirebaseCore (~> 12.0.0) | ||||||
|     - FirebaseInstallations (~> 11.0) |     - FirebaseInstallations (~> 12.0.0) | ||||||
|     - GoogleDataTransport (~> 10.0) |     - GoogleDataTransport (~> 10.1) | ||||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.1) |     - GoogleUtilities/AppDelegateSwizzler (~> 8.1) | ||||||
|     - GoogleUtilities/Environment (~> 8.1) |     - GoogleUtilities/Environment (~> 8.1) | ||||||
|     - GoogleUtilities/Reachability (~> 8.1) |     - GoogleUtilities/Reachability (~> 8.1) | ||||||
| @@ -93,9 +93,9 @@ PODS: | |||||||
|   - flutter_udid (0.0.1): |   - flutter_udid (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|     - SAMKeychain |     - SAMKeychain | ||||||
|   - flutter_webrtc (0.14.0): |   - flutter_webrtc (1.0.0): | ||||||
|     - Flutter |     - Flutter | ||||||
|     - WebRTC-SDK (= 125.6422.07) |     - WebRTC-SDK (= 137.7151.02) | ||||||
|   - gal (1.0.0): |   - gal (1.0.0): | ||||||
|     - Flutter |     - Flutter | ||||||
|     - FlutterMacOS |     - FlutterMacOS | ||||||
| @@ -131,10 +131,10 @@ PODS: | |||||||
|   - irondash_engine_context (0.0.1): |   - irondash_engine_context (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - Kingfisher (8.5.0) |   - Kingfisher (8.5.0) | ||||||
|   - livekit_client (2.4.9): |   - livekit_client (2.5.0): | ||||||
|     - Flutter |     - Flutter | ||||||
|     - flutter_webrtc |     - flutter_webrtc | ||||||
|     - WebRTC-SDK (= 125.6422.07) |     - WebRTC-SDK (= 137.7151.02) | ||||||
|   - local_auth_darwin (0.0.1): |   - local_auth_darwin (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|     - FlutterMacOS |     - FlutterMacOS | ||||||
| @@ -191,6 +191,8 @@ PODS: | |||||||
|     - sqlite3/common |     - sqlite3/common | ||||||
|   - sqlite3/rtree (3.50.3): |   - sqlite3/rtree (3.50.3): | ||||||
|     - sqlite3/common |     - sqlite3/common | ||||||
|  |   - sqlite3/session (3.50.3): | ||||||
|  |     - sqlite3/common | ||||||
|   - sqlite3_flutter_libs (0.0.1): |   - sqlite3_flutter_libs (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|     - FlutterMacOS |     - FlutterMacOS | ||||||
| @@ -200,6 +202,7 @@ PODS: | |||||||
|     - sqlite3/math |     - sqlite3/math | ||||||
|     - sqlite3/perf-threadsafe |     - sqlite3/perf-threadsafe | ||||||
|     - sqlite3/rtree |     - sqlite3/rtree | ||||||
|  |     - sqlite3/session | ||||||
|   - super_native_extensions (0.0.1): |   - super_native_extensions (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - SwiftyGif (5.4.5) |   - SwiftyGif (5.4.5) | ||||||
| @@ -209,7 +212,7 @@ PODS: | |||||||
|     - Flutter |     - Flutter | ||||||
|   - wakelock_plus (0.0.1): |   - wakelock_plus (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - WebRTC-SDK (125.6422.07) |   - WebRTC-SDK (137.7151.02) | ||||||
|  |  | ||||||
| DEPENDENCIES: | DEPENDENCIES: | ||||||
|   - Alamofire |   - Alamofire | ||||||
| @@ -361,13 +364,13 @@ SPEC CHECKSUMS: | |||||||
|   DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c |   DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c | ||||||
|   DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 |   DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 | ||||||
|   file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be |   file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be | ||||||
|   Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e |   Firebase: 800d487043c0557d9faed71477a38d9aafb08a41 | ||||||
|   firebase_core: 995454a784ff288be5689b796deb9e9fa3601818 |   firebase_core: 633e1851ffe1b9ab875f6467a4f574c79cef02e4 | ||||||
|   firebase_messaging: f4a41dd102ac18b840eba3f39d67e77922d3f707 |   firebase_messaging: d17feef781edc84ebefe62624fb384358ad96361 | ||||||
|   FirebaseCore: efb3893e5b94f32b86e331e3bd6dadf18b66568e |   FirebaseCore: 055f4ab117d5964158c833f3d5e7ec6d91648d4a | ||||||
|   FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4 |   FirebaseCoreInternal: dedc28e569a4be85f38f3d6af1070a2e12018d55 | ||||||
|   FirebaseInstallations: 317270fec08a5d418fdbc8429282238cab3ac843 |   FirebaseInstallations: d4c7c958f99c8860d7fcece786314ae790e2f988 | ||||||
|   FirebaseMessaging: 3b26e2cee503815e01c3701236b020aa9b576f09 |   FirebaseMessaging: af49f8d7c0a3d2a017d9302c80946f45a7777dde | ||||||
|   Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 |   Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 | ||||||
|   flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 |   flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 | ||||||
|   flutter_keyboard_visibility: 4625131e43015dbbe759d9b20daaf77e0e3f6619 |   flutter_keyboard_visibility: 4625131e43015dbbe759d9b20daaf77e0e3f6619 | ||||||
| @@ -376,14 +379,14 @@ SPEC CHECKSUMS: | |||||||
|   flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 |   flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 | ||||||
|   flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 |   flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 | ||||||
|   flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9 |   flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9 | ||||||
|   flutter_webrtc: fd0d3bdef8766a0736dbbe2e5b7e85f1f3c52117 |   flutter_webrtc: 6f7da106613d52ade777d5b4875a43f48c28b457 | ||||||
|   gal: baecd024ebfd13c441269ca7404792a7152fde89 |   gal: baecd024ebfd13c441269ca7404792a7152fde89 | ||||||
|   GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 |   GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 | ||||||
|   GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 |   GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 | ||||||
|   image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a |   image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a | ||||||
|   irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 |   irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 | ||||||
|   Kingfisher: ff0d31a1f07bdff6a1ebb3ba08b8e6e567b6500c |   Kingfisher: ff0d31a1f07bdff6a1ebb3ba08b8e6e567b6500c | ||||||
|   livekit_client: 3f79d79233a5bd13d5b541732624ef959d7c538e |   livekit_client: e3b79b99405428aac439b6b76a254cd9a11dbbfb | ||||||
|   local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19 |   local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19 | ||||||
|   media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 |   media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 | ||||||
|   media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 |   media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 | ||||||
| @@ -404,14 +407,14 @@ SPEC CHECKSUMS: | |||||||
|   sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418 |   sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418 | ||||||
|   sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 |   sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 | ||||||
|   sqlite3: 83105acd294c9137c026e2da1931c30b4588ab81 |   sqlite3: 83105acd294c9137c026e2da1931c30b4588ab81 | ||||||
|   sqlite3_flutter_libs: ce0522d143cee6ef5e16587acfce8f476316e005 |   sqlite3_flutter_libs: 616267f2fca40e9c6af8c5d82324e05667040b6e | ||||||
|   super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4 |   super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4 | ||||||
|   SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 |   SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 | ||||||
|   url_launcher_ios: 694010445543906933d732453a59da0a173ae33d |   url_launcher_ios: 694010445543906933d732453a59da0a173ae33d | ||||||
|   volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12 |   volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12 | ||||||
|   wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 |   wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 | ||||||
|   WebRTC-SDK: dff00a3892bc570b6014e046297782084071657e |   WebRTC-SDK: d20de357dcbf7c9696b124b39f3ff62125107e4b | ||||||
|  |  | ||||||
| PODFILE CHECKSUM: f6df17c2a0cbd7af89692fd3877231eaea40230f | PODFILE CHECKSUM: c818292390b02fa379036ea099713a332bd7193f | ||||||
|  |  | ||||||
| COCOAPODS: 1.16.2 | COCOAPODS: 1.16.2 | ||||||
|   | |||||||
| @@ -10,6 +10,8 @@ | |||||||
| 		1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; | 		1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; | ||||||
| 		331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; | 		331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; | ||||||
| 		3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; | 		3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; | ||||||
|  | 		73ACDFAD2E3D0E6100B63535 /* ReplayKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */; }; | ||||||
|  | 		73ACDFC32E3D0E6100B63535 /* SolianBroadcastExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; | ||||||
| 		73C305D82E0BE878009035B9 /* SolianShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; | 		73C305D82E0BE878009035B9 /* SolianShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; | ||||||
| 		73CDD6812DEC00480059D95D /* SolianNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73CDD67A2DEC00480059D95D /* SolianNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; | 		73CDD6812DEC00480059D95D /* SolianNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73CDD67A2DEC00480059D95D /* SolianNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; | ||||||
| 		73D4264B2DEB815D006C0AAE /* NotifyDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D4264A2DEB815D006C0AAE /* NotifyDelegate.swift */; }; | 		73D4264B2DEB815D006C0AAE /* NotifyDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D4264A2DEB815D006C0AAE /* NotifyDelegate.swift */; }; | ||||||
| @@ -32,6 +34,13 @@ | |||||||
| 			remoteGlobalIDString = 97C146ED1CF9000F007C117D; | 			remoteGlobalIDString = 97C146ED1CF9000F007C117D; | ||||||
| 			remoteInfo = Runner; | 			remoteInfo = Runner; | ||||||
| 		}; | 		}; | ||||||
|  | 		73ACDFC12E3D0E6100B63535 /* PBXContainerItemProxy */ = { | ||||||
|  | 			isa = PBXContainerItemProxy; | ||||||
|  | 			containerPortal = 97C146E61CF9000F007C117D /* Project object */; | ||||||
|  | 			proxyType = 1; | ||||||
|  | 			remoteGlobalIDString = 73ACDFAA2E3D0E6100B63535; | ||||||
|  | 			remoteInfo = SolianBroadcastExtension; | ||||||
|  | 		}; | ||||||
| 		73C305D62E0BE878009035B9 /* PBXContainerItemProxy */ = { | 		73C305D62E0BE878009035B9 /* PBXContainerItemProxy */ = { | ||||||
| 			isa = PBXContainerItemProxy; | 			isa = PBXContainerItemProxy; | ||||||
| 			containerPortal = 97C146E61CF9000F007C117D /* Project object */; | 			containerPortal = 97C146E61CF9000F007C117D /* Project object */; | ||||||
| @@ -55,6 +64,7 @@ | |||||||
| 			dstPath = ""; | 			dstPath = ""; | ||||||
| 			dstSubfolderSpec = 13; | 			dstSubfolderSpec = 13; | ||||||
| 			files = ( | 			files = ( | ||||||
|  | 				73ACDFC32E3D0E6100B63535 /* SolianBroadcastExtension.appex in Embed Foundation Extensions */, | ||||||
| 				73C305D82E0BE878009035B9 /* SolianShareExtension.appex in Embed Foundation Extensions */, | 				73C305D82E0BE878009035B9 /* SolianShareExtension.appex in Embed Foundation Extensions */, | ||||||
| 				73CDD6812DEC00480059D95D /* SolianNotificationService.appex in Embed Foundation Extensions */, | 				73CDD6812DEC00480059D95D /* SolianNotificationService.appex in Embed Foundation Extensions */, | ||||||
| 			); | 			); | ||||||
| @@ -91,6 +101,9 @@ | |||||||
| 		3A1C47BD29CC6AC2587D4DBE /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; | 		3A1C47BD29CC6AC2587D4DBE /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; | ||||||
| 		3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; | 		3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; | ||||||
| 		737E920B2DB6A9FF00BE9CDB /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; }; | 		737E920B2DB6A9FF00BE9CDB /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; }; | ||||||
|  | 		73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolianBroadcastExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; | ||||||
|  | 		73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ReplayKit.framework; path = System/Library/Frameworks/ReplayKit.framework; sourceTree = SDKROOT; }; | ||||||
|  | 		73ACDFB82E3D0E6100B63535 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; | ||||||
| 		73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolianShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; | 		73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolianShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; | ||||||
| 		73CDD67A2DEC00480059D95D /* SolianNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolianNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; | 		73CDD67A2DEC00480059D95D /* SolianNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolianNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; | ||||||
| 		73D4264A2DEB815D006C0AAE /* NotifyDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotifyDelegate.swift; sourceTree = "<group>"; }; | 		73D4264A2DEB815D006C0AAE /* NotifyDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotifyDelegate.swift; sourceTree = "<group>"; }; | ||||||
| @@ -117,6 +130,13 @@ | |||||||
| /* End PBXFileReference section */ | /* End PBXFileReference section */ | ||||||
|  |  | ||||||
| /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ | ||||||
|  | 		73ACDFCA2E3D0E6100B63535 /* Exceptions for "SolianBroadcastExtension" folder in "SolianBroadcastExtension" target */ = { | ||||||
|  | 			isa = PBXFileSystemSynchronizedBuildFileExceptionSet; | ||||||
|  | 			membershipExceptions = ( | ||||||
|  | 				Info.plist, | ||||||
|  | 			); | ||||||
|  | 			target = 73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */; | ||||||
|  | 		}; | ||||||
| 		73C305DC2E0BE878009035B9 /* Exceptions for "SolianShareExtension" folder in "SolianShareExtension" target */ = { | 		73C305DC2E0BE878009035B9 /* Exceptions for "SolianShareExtension" folder in "SolianShareExtension" target */ = { | ||||||
| 			isa = PBXFileSystemSynchronizedBuildFileExceptionSet; | 			isa = PBXFileSystemSynchronizedBuildFileExceptionSet; | ||||||
| 			membershipExceptions = ( | 			membershipExceptions = ( | ||||||
| @@ -150,6 +170,14 @@ | |||||||
| 			path = Services; | 			path = Services; | ||||||
| 			sourceTree = "<group>"; | 			sourceTree = "<group>"; | ||||||
| 		}; | 		}; | ||||||
|  | 		73ACDFAE2E3D0E6100B63535 /* SolianBroadcastExtension */ = { | ||||||
|  | 			isa = PBXFileSystemSynchronizedRootGroup; | ||||||
|  | 			exceptions = ( | ||||||
|  | 				73ACDFCA2E3D0E6100B63535 /* Exceptions for "SolianBroadcastExtension" folder in "SolianBroadcastExtension" target */, | ||||||
|  | 			); | ||||||
|  | 			path = SolianBroadcastExtension; | ||||||
|  | 			sourceTree = "<group>"; | ||||||
|  | 		}; | ||||||
| 		73C305CF2E0BE878009035B9 /* SolianShareExtension */ = { | 		73C305CF2E0BE878009035B9 /* SolianShareExtension */ = { | ||||||
| 			isa = PBXFileSystemSynchronizedRootGroup; | 			isa = PBXFileSystemSynchronizedRootGroup; | ||||||
| 			exceptions = ( | 			exceptions = ( | ||||||
| @@ -177,6 +205,14 @@ | |||||||
| 			); | 			); | ||||||
| 			runOnlyForDeploymentPostprocessing = 0; | 			runOnlyForDeploymentPostprocessing = 0; | ||||||
| 		}; | 		}; | ||||||
|  | 		73ACDFA82E3D0E6100B63535 /* Frameworks */ = { | ||||||
|  | 			isa = PBXFrameworksBuildPhase; | ||||||
|  | 			buildActionMask = 2147483647; | ||||||
|  | 			files = ( | ||||||
|  | 				73ACDFAD2E3D0E6100B63535 /* ReplayKit.framework in Frameworks */, | ||||||
|  | 			); | ||||||
|  | 			runOnlyForDeploymentPostprocessing = 0; | ||||||
|  | 		}; | ||||||
| 		73C305CB2E0BE878009035B9 /* Frameworks */ = { | 		73C305CB2E0BE878009035B9 /* Frameworks */ = { | ||||||
| 			isa = PBXFrameworksBuildPhase; | 			isa = PBXFrameworksBuildPhase; | ||||||
| 			buildActionMask = 2147483647; | 			buildActionMask = 2147483647; | ||||||
| @@ -220,6 +256,8 @@ | |||||||
| 				AA0CA8A3E15DEE023BB27438 /* Pods_NotificationService.framework */, | 				AA0CA8A3E15DEE023BB27438 /* Pods_NotificationService.framework */, | ||||||
| 				39FE4CC6223F0D3C0E1FFD04 /* Pods_SolianNotificationService.framework */, | 				39FE4CC6223F0D3C0E1FFD04 /* Pods_SolianNotificationService.framework */, | ||||||
| 				7B40764A2C4CC0E7DC70A0D3 /* Pods_SolianShareExtension.framework */, | 				7B40764A2C4CC0E7DC70A0D3 /* Pods_SolianShareExtension.framework */, | ||||||
|  | 				73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */, | ||||||
|  | 				73ACDFB82E3D0E6100B63535 /* UIKit.framework */, | ||||||
| 			); | 			); | ||||||
| 			name = Frameworks; | 			name = Frameworks; | ||||||
| 			sourceTree = "<group>"; | 			sourceTree = "<group>"; | ||||||
| @@ -264,6 +302,7 @@ | |||||||
| 				97C146F01CF9000F007C117D /* Runner */, | 				97C146F01CF9000F007C117D /* Runner */, | ||||||
| 				73CDD67B2DEC00480059D95D /* SolianNotificationService */, | 				73CDD67B2DEC00480059D95D /* SolianNotificationService */, | ||||||
| 				73C305CF2E0BE878009035B9 /* SolianShareExtension */, | 				73C305CF2E0BE878009035B9 /* SolianShareExtension */, | ||||||
|  | 				73ACDFAE2E3D0E6100B63535 /* SolianBroadcastExtension */, | ||||||
| 				97C146EF1CF9000F007C117D /* Products */, | 				97C146EF1CF9000F007C117D /* Products */, | ||||||
| 				331C8082294A63A400263BE5 /* RunnerTests */, | 				331C8082294A63A400263BE5 /* RunnerTests */, | ||||||
| 				91E124CE95BCB4DCD890160D /* Pods */, | 				91E124CE95BCB4DCD890160D /* Pods */, | ||||||
| @@ -279,6 +318,7 @@ | |||||||
| 				331C8081294A63A400263BE5 /* RunnerTests.xctest */, | 				331C8081294A63A400263BE5 /* RunnerTests.xctest */, | ||||||
| 				73CDD67A2DEC00480059D95D /* SolianNotificationService.appex */, | 				73CDD67A2DEC00480059D95D /* SolianNotificationService.appex */, | ||||||
| 				73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */, | 				73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */, | ||||||
|  | 				73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */, | ||||||
| 			); | 			); | ||||||
| 			name = Products; | 			name = Products; | ||||||
| 			sourceTree = "<group>"; | 			sourceTree = "<group>"; | ||||||
| @@ -323,6 +363,26 @@ | |||||||
| 			productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; | 			productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; | ||||||
| 			productType = "com.apple.product-type.bundle.unit-test"; | 			productType = "com.apple.product-type.bundle.unit-test"; | ||||||
| 		}; | 		}; | ||||||
|  | 		73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */ = { | ||||||
|  | 			isa = PBXNativeTarget; | ||||||
|  | 			buildConfigurationList = 73ACDFCB2E3D0E6100B63535 /* Build configuration list for PBXNativeTarget "SolianBroadcastExtension" */; | ||||||
|  | 			buildPhases = ( | ||||||
|  | 				73ACDFA72E3D0E6100B63535 /* Sources */, | ||||||
|  | 				73ACDFA82E3D0E6100B63535 /* Frameworks */, | ||||||
|  | 				73ACDFA92E3D0E6100B63535 /* Resources */, | ||||||
|  | 			); | ||||||
|  | 			buildRules = ( | ||||||
|  | 			); | ||||||
|  | 			dependencies = ( | ||||||
|  | 			); | ||||||
|  | 			fileSystemSynchronizedGroups = ( | ||||||
|  | 				73ACDFAE2E3D0E6100B63535 /* SolianBroadcastExtension */, | ||||||
|  | 			); | ||||||
|  | 			name = SolianBroadcastExtension; | ||||||
|  | 			productName = SolianBroadcastExtension; | ||||||
|  | 			productReference = 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */; | ||||||
|  | 			productType = "com.apple.product-type.app-extension"; | ||||||
|  | 		}; | ||||||
| 		73C305CD2E0BE878009035B9 /* SolianShareExtension */ = { | 		73C305CD2E0BE878009035B9 /* SolianShareExtension */ = { | ||||||
| 			isa = PBXNativeTarget; | 			isa = PBXNativeTarget; | ||||||
| 			buildConfigurationList = 73C305DD2E0BE878009035B9 /* Build configuration list for PBXNativeTarget "SolianShareExtension" */; | 			buildConfigurationList = 73C305DD2E0BE878009035B9 /* Build configuration list for PBXNativeTarget "SolianShareExtension" */; | ||||||
| @@ -385,6 +445,7 @@ | |||||||
| 			dependencies = ( | 			dependencies = ( | ||||||
| 				73CDD6802DEC00480059D95D /* PBXTargetDependency */, | 				73CDD6802DEC00480059D95D /* PBXTargetDependency */, | ||||||
| 				73C305D72E0BE878009035B9 /* PBXTargetDependency */, | 				73C305D72E0BE878009035B9 /* PBXTargetDependency */, | ||||||
|  | 				73ACDFC22E3D0E6100B63535 /* PBXTargetDependency */, | ||||||
| 			); | 			); | ||||||
| 			fileSystemSynchronizedGroups = ( | 			fileSystemSynchronizedGroups = ( | ||||||
| 				73268D272DEB012A0076E970 /* Services */, | 				73268D272DEB012A0076E970 /* Services */, | ||||||
| @@ -409,6 +470,9 @@ | |||||||
| 						CreatedOnToolsVersion = 14.0; | 						CreatedOnToolsVersion = 14.0; | ||||||
| 						TestTargetID = 97C146ED1CF9000F007C117D; | 						TestTargetID = 97C146ED1CF9000F007C117D; | ||||||
| 					}; | 					}; | ||||||
|  | 					73ACDFAA2E3D0E6100B63535 = { | ||||||
|  | 						CreatedOnToolsVersion = 16.4; | ||||||
|  | 					}; | ||||||
| 					73C305CD2E0BE878009035B9 = { | 					73C305CD2E0BE878009035B9 = { | ||||||
| 						CreatedOnToolsVersion = 16.4; | 						CreatedOnToolsVersion = 16.4; | ||||||
| 					}; | 					}; | ||||||
| @@ -438,6 +502,7 @@ | |||||||
| 				331C8080294A63A400263BE5 /* RunnerTests */, | 				331C8080294A63A400263BE5 /* RunnerTests */, | ||||||
| 				73CDD6792DEC00480059D95D /* SolianNotificationService */, | 				73CDD6792DEC00480059D95D /* SolianNotificationService */, | ||||||
| 				73C305CD2E0BE878009035B9 /* SolianShareExtension */, | 				73C305CD2E0BE878009035B9 /* SolianShareExtension */, | ||||||
|  | 				73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */, | ||||||
| 			); | 			); | ||||||
| 		}; | 		}; | ||||||
| /* End PBXProject section */ | /* End PBXProject section */ | ||||||
| @@ -450,6 +515,13 @@ | |||||||
| 			); | 			); | ||||||
| 			runOnlyForDeploymentPostprocessing = 0; | 			runOnlyForDeploymentPostprocessing = 0; | ||||||
| 		}; | 		}; | ||||||
|  | 		73ACDFA92E3D0E6100B63535 /* Resources */ = { | ||||||
|  | 			isa = PBXResourcesBuildPhase; | ||||||
|  | 			buildActionMask = 2147483647; | ||||||
|  | 			files = ( | ||||||
|  | 			); | ||||||
|  | 			runOnlyForDeploymentPostprocessing = 0; | ||||||
|  | 		}; | ||||||
| 		73C305CC2E0BE878009035B9 /* Resources */ = { | 		73C305CC2E0BE878009035B9 /* Resources */ = { | ||||||
| 			isa = PBXResourcesBuildPhase; | 			isa = PBXResourcesBuildPhase; | ||||||
| 			buildActionMask = 2147483647; | 			buildActionMask = 2147483647; | ||||||
| @@ -643,6 +715,13 @@ | |||||||
| 			); | 			); | ||||||
| 			runOnlyForDeploymentPostprocessing = 0; | 			runOnlyForDeploymentPostprocessing = 0; | ||||||
| 		}; | 		}; | ||||||
|  | 		73ACDFA72E3D0E6100B63535 /* Sources */ = { | ||||||
|  | 			isa = PBXSourcesBuildPhase; | ||||||
|  | 			buildActionMask = 2147483647; | ||||||
|  | 			files = ( | ||||||
|  | 			); | ||||||
|  | 			runOnlyForDeploymentPostprocessing = 0; | ||||||
|  | 		}; | ||||||
| 		73C305CA2E0BE878009035B9 /* Sources */ = { | 		73C305CA2E0BE878009035B9 /* Sources */ = { | ||||||
| 			isa = PBXSourcesBuildPhase; | 			isa = PBXSourcesBuildPhase; | ||||||
| 			buildActionMask = 2147483647; | 			buildActionMask = 2147483647; | ||||||
| @@ -675,6 +754,11 @@ | |||||||
| 			target = 97C146ED1CF9000F007C117D /* Runner */; | 			target = 97C146ED1CF9000F007C117D /* Runner */; | ||||||
| 			targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; | 			targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; | ||||||
| 		}; | 		}; | ||||||
|  | 		73ACDFC22E3D0E6100B63535 /* PBXTargetDependency */ = { | ||||||
|  | 			isa = PBXTargetDependency; | ||||||
|  | 			target = 73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */; | ||||||
|  | 			targetProxy = 73ACDFC12E3D0E6100B63535 /* PBXContainerItemProxy */; | ||||||
|  | 		}; | ||||||
| 		73C305D72E0BE878009035B9 /* PBXTargetDependency */ = { | 		73C305D72E0BE878009035B9 /* PBXTargetDependency */ = { | ||||||
| 			isa = PBXTargetDependency; | 			isa = PBXTargetDependency; | ||||||
| 			target = 73C305CD2E0BE878009035B9 /* SolianShareExtension */; | 			target = 73C305CD2E0BE878009035B9 /* SolianShareExtension */; | ||||||
| @@ -773,7 +857,7 @@ | |||||||
| 				INFOPLIST_FILE = Runner/Info.plist; | 				INFOPLIST_FILE = Runner/Info.plist; | ||||||
| 				INFOPLIST_KEY_CFBundleDisplayName = Solian; | 				INFOPLIST_KEY_CFBundleDisplayName = Solian; | ||||||
| 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; | 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; | ||||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 13.0; | 				IPHONEOS_DEPLOYMENT_TARGET = 15.0; | ||||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | 				LD_RUNPATH_SEARCH_PATHS = ( | ||||||
| 					"$(inherited)", | 					"$(inherited)", | ||||||
| 					"@executable_path/Frameworks", | 					"@executable_path/Frameworks", | ||||||
| @@ -836,6 +920,123 @@ | |||||||
| 			}; | 			}; | ||||||
| 			name = Profile; | 			name = Profile; | ||||||
| 		}; | 		}; | ||||||
|  | 		73ACDFC42E3D0E6100B63535 /* Debug */ = { | ||||||
|  | 			isa = XCBuildConfiguration; | ||||||
|  | 			buildSettings = { | ||||||
|  | 				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; | ||||||
|  | 				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; | ||||||
|  | 				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; | ||||||
|  | 				CLANG_ENABLE_OBJC_WEAK = YES; | ||||||
|  | 				CLANG_WARN_DOCUMENTATION_COMMENTS = YES; | ||||||
|  | 				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; | ||||||
|  | 				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; | ||||||
|  | 				CODE_SIGN_ENTITLEMENTS = SolianBroadcastExtension/SolianBroadcastExtension.entitlements; | ||||||
|  | 				CODE_SIGN_STYLE = Automatic; | ||||||
|  | 				CURRENT_PROJECT_VERSION = 1; | ||||||
|  | 				DEVELOPMENT_TEAM = W7HPZ53V6B; | ||||||
|  | 				ENABLE_USER_SCRIPT_SANDBOXING = YES; | ||||||
|  | 				GCC_C_LANGUAGE_STANDARD = gnu17; | ||||||
|  | 				GENERATE_INFOPLIST_FILE = YES; | ||||||
|  | 				INFOPLIST_FILE = SolianBroadcastExtension/Info.plist; | ||||||
|  | 				INFOPLIST_KEY_CFBundleDisplayName = SolianBroadcastExtension; | ||||||
|  | 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | ||||||
|  | 				IPHONEOS_DEPLOYMENT_TARGET = 15.0; | ||||||
|  | 				LD_RUNPATH_SEARCH_PATHS = ( | ||||||
|  | 					"$(inherited)", | ||||||
|  | 					"@executable_path/Frameworks", | ||||||
|  | 					"@executable_path/../../Frameworks", | ||||||
|  | 				); | ||||||
|  | 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES; | ||||||
|  | 				MARKETING_VERSION = 1.0; | ||||||
|  | 				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; | ||||||
|  | 				MTL_FAST_MATH = YES; | ||||||
|  | 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianBroadcastExtension; | ||||||
|  | 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||||
|  | 				SKIP_INSTALL = YES; | ||||||
|  | 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; | ||||||
|  | 				SWIFT_EMIT_LOC_STRINGS = YES; | ||||||
|  | 				SWIFT_OPTIMIZATION_LEVEL = "-Onone"; | ||||||
|  | 				SWIFT_VERSION = 5.0; | ||||||
|  | 				TARGETED_DEVICE_FAMILY = "1,2"; | ||||||
|  | 			}; | ||||||
|  | 			name = Debug; | ||||||
|  | 		}; | ||||||
|  | 		73ACDFC52E3D0E6100B63535 /* Release */ = { | ||||||
|  | 			isa = XCBuildConfiguration; | ||||||
|  | 			buildSettings = { | ||||||
|  | 				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; | ||||||
|  | 				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; | ||||||
|  | 				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; | ||||||
|  | 				CLANG_ENABLE_OBJC_WEAK = YES; | ||||||
|  | 				CLANG_WARN_DOCUMENTATION_COMMENTS = YES; | ||||||
|  | 				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; | ||||||
|  | 				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; | ||||||
|  | 				CODE_SIGN_ENTITLEMENTS = SolianBroadcastExtension/SolianBroadcastExtension.entitlements; | ||||||
|  | 				CODE_SIGN_STYLE = Automatic; | ||||||
|  | 				CURRENT_PROJECT_VERSION = 1; | ||||||
|  | 				DEVELOPMENT_TEAM = W7HPZ53V6B; | ||||||
|  | 				ENABLE_USER_SCRIPT_SANDBOXING = YES; | ||||||
|  | 				GCC_C_LANGUAGE_STANDARD = gnu17; | ||||||
|  | 				GENERATE_INFOPLIST_FILE = YES; | ||||||
|  | 				INFOPLIST_FILE = SolianBroadcastExtension/Info.plist; | ||||||
|  | 				INFOPLIST_KEY_CFBundleDisplayName = SolianBroadcastExtension; | ||||||
|  | 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | ||||||
|  | 				IPHONEOS_DEPLOYMENT_TARGET = 15.0; | ||||||
|  | 				LD_RUNPATH_SEARCH_PATHS = ( | ||||||
|  | 					"$(inherited)", | ||||||
|  | 					"@executable_path/Frameworks", | ||||||
|  | 					"@executable_path/../../Frameworks", | ||||||
|  | 				); | ||||||
|  | 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES; | ||||||
|  | 				MARKETING_VERSION = 1.0; | ||||||
|  | 				MTL_FAST_MATH = YES; | ||||||
|  | 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianBroadcastExtension; | ||||||
|  | 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||||
|  | 				SKIP_INSTALL = YES; | ||||||
|  | 				SWIFT_EMIT_LOC_STRINGS = YES; | ||||||
|  | 				SWIFT_VERSION = 5.0; | ||||||
|  | 				TARGETED_DEVICE_FAMILY = "1,2"; | ||||||
|  | 			}; | ||||||
|  | 			name = Release; | ||||||
|  | 		}; | ||||||
|  | 		73ACDFC62E3D0E6100B63535 /* Profile */ = { | ||||||
|  | 			isa = XCBuildConfiguration; | ||||||
|  | 			buildSettings = { | ||||||
|  | 				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; | ||||||
|  | 				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; | ||||||
|  | 				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; | ||||||
|  | 				CLANG_ENABLE_OBJC_WEAK = YES; | ||||||
|  | 				CLANG_WARN_DOCUMENTATION_COMMENTS = YES; | ||||||
|  | 				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; | ||||||
|  | 				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; | ||||||
|  | 				CODE_SIGN_ENTITLEMENTS = SolianBroadcastExtension/SolianBroadcastExtension.entitlements; | ||||||
|  | 				CODE_SIGN_STYLE = Automatic; | ||||||
|  | 				CURRENT_PROJECT_VERSION = 1; | ||||||
|  | 				DEVELOPMENT_TEAM = W7HPZ53V6B; | ||||||
|  | 				ENABLE_USER_SCRIPT_SANDBOXING = YES; | ||||||
|  | 				GCC_C_LANGUAGE_STANDARD = gnu17; | ||||||
|  | 				GENERATE_INFOPLIST_FILE = YES; | ||||||
|  | 				INFOPLIST_FILE = SolianBroadcastExtension/Info.plist; | ||||||
|  | 				INFOPLIST_KEY_CFBundleDisplayName = SolianBroadcastExtension; | ||||||
|  | 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | ||||||
|  | 				IPHONEOS_DEPLOYMENT_TARGET = 15.0; | ||||||
|  | 				LD_RUNPATH_SEARCH_PATHS = ( | ||||||
|  | 					"$(inherited)", | ||||||
|  | 					"@executable_path/Frameworks", | ||||||
|  | 					"@executable_path/../../Frameworks", | ||||||
|  | 				); | ||||||
|  | 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES; | ||||||
|  | 				MARKETING_VERSION = 1.0; | ||||||
|  | 				MTL_FAST_MATH = YES; | ||||||
|  | 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianBroadcastExtension; | ||||||
|  | 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||||
|  | 				SKIP_INSTALL = YES; | ||||||
|  | 				SWIFT_EMIT_LOC_STRINGS = YES; | ||||||
|  | 				SWIFT_VERSION = 5.0; | ||||||
|  | 				TARGETED_DEVICE_FAMILY = "1,2"; | ||||||
|  | 			}; | ||||||
|  | 			name = Profile; | ||||||
|  | 		}; | ||||||
| 		73C305D92E0BE878009035B9 /* Debug */ = { | 		73C305D92E0BE878009035B9 /* Debug */ = { | ||||||
| 			isa = XCBuildConfiguration; | 			isa = XCBuildConfiguration; | ||||||
| 			baseConfigurationReference = 17FAB080A9C53193ABD9C15B /* Pods-SolianShareExtension.debug.xcconfig */; | 			baseConfigurationReference = 17FAB080A9C53193ABD9C15B /* Pods-SolianShareExtension.debug.xcconfig */; | ||||||
| @@ -1204,7 +1405,7 @@ | |||||||
| 				INFOPLIST_FILE = Runner/Info.plist; | 				INFOPLIST_FILE = Runner/Info.plist; | ||||||
| 				INFOPLIST_KEY_CFBundleDisplayName = Solian; | 				INFOPLIST_KEY_CFBundleDisplayName = Solian; | ||||||
| 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; | 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; | ||||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 13.0; | 				IPHONEOS_DEPLOYMENT_TARGET = 15.0; | ||||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | 				LD_RUNPATH_SEARCH_PATHS = ( | ||||||
| 					"$(inherited)", | 					"$(inherited)", | ||||||
| 					"@executable_path/Frameworks", | 					"@executable_path/Frameworks", | ||||||
| @@ -1232,7 +1433,7 @@ | |||||||
| 				INFOPLIST_FILE = Runner/Info.plist; | 				INFOPLIST_FILE = Runner/Info.plist; | ||||||
| 				INFOPLIST_KEY_CFBundleDisplayName = Solian; | 				INFOPLIST_KEY_CFBundleDisplayName = Solian; | ||||||
| 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; | 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; | ||||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 13.0; | 				IPHONEOS_DEPLOYMENT_TARGET = 15.0; | ||||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | 				LD_RUNPATH_SEARCH_PATHS = ( | ||||||
| 					"$(inherited)", | 					"$(inherited)", | ||||||
| 					"@executable_path/Frameworks", | 					"@executable_path/Frameworks", | ||||||
| @@ -1258,6 +1459,16 @@ | |||||||
| 			defaultConfigurationIsVisible = 0; | 			defaultConfigurationIsVisible = 0; | ||||||
| 			defaultConfigurationName = Release; | 			defaultConfigurationName = Release; | ||||||
| 		}; | 		}; | ||||||
|  | 		73ACDFCB2E3D0E6100B63535 /* Build configuration list for PBXNativeTarget "SolianBroadcastExtension" */ = { | ||||||
|  | 			isa = XCConfigurationList; | ||||||
|  | 			buildConfigurations = ( | ||||||
|  | 				73ACDFC42E3D0E6100B63535 /* Debug */, | ||||||
|  | 				73ACDFC52E3D0E6100B63535 /* Release */, | ||||||
|  | 				73ACDFC62E3D0E6100B63535 /* Profile */, | ||||||
|  | 			); | ||||||
|  | 			defaultConfigurationIsVisible = 0; | ||||||
|  | 			defaultConfigurationName = Release; | ||||||
|  | 		}; | ||||||
| 		73C305DD2E0BE878009035B9 /* Build configuration list for PBXNativeTarget "SolianShareExtension" */ = { | 		73C305DD2E0BE878009035B9 /* Build configuration list for PBXNativeTarget "SolianShareExtension" */ = { | ||||||
| 			isa = XCConfigurationList; | 			isa = XCConfigurationList; | ||||||
| 			buildConfigurations = ( | 			buildConfigurations = ( | ||||||
|   | |||||||
							
								
								
									
										37
									
								
								ios/SolianBroadcastExtension/Atomic.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								ios/SolianBroadcastExtension/Atomic.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | |||||||
|  | // | ||||||
|  | //  Atomic.swift | ||||||
|  | //  Broadcast Extension | ||||||
|  | // | ||||||
|  | //  Created by Maksym Shcheglov. | ||||||
|  | //  https://www.onswiftwings.com/posts/atomic-property-wrapper/ | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import Foundation | ||||||
|  |  | ||||||
|  | @propertyWrapper | ||||||
|  | struct Atomic<Value> { | ||||||
|  |  | ||||||
|  |     private var value: Value | ||||||
|  |     private let lock = NSLock() | ||||||
|  |  | ||||||
|  |     init(wrappedValue value: Value) { | ||||||
|  |         self.value = value | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     var wrappedValue: Value { | ||||||
|  |         get { load() } | ||||||
|  |         set { store(newValue: newValue) } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func load() -> Value { | ||||||
|  |         lock.lock() | ||||||
|  |         defer { lock.unlock() } | ||||||
|  |         return value | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     mutating func store(newValue: Value) { | ||||||
|  |         lock.lock() | ||||||
|  |         defer { lock.unlock() } | ||||||
|  |         value = newValue | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										29
									
								
								ios/SolianBroadcastExtension/DarwinNotification.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								ios/SolianBroadcastExtension/DarwinNotification.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | |||||||
|  | // | ||||||
|  | //  DarwinNotificationCenter.swift | ||||||
|  | //  Broadcast Extension | ||||||
|  | // | ||||||
|  | //  Created by Alex-Dan Bumbu on 23/03/2021. | ||||||
|  | //  Copyright © 2021 8x8, Inc. All rights reserved. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import Foundation | ||||||
|  |  | ||||||
|  | enum DarwinNotification: String { | ||||||
|  |     case broadcastStarted = "iOS_BroadcastStarted" | ||||||
|  |     case broadcastStopped = "iOS_BroadcastStopped" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class DarwinNotificationCenter { | ||||||
|  |      | ||||||
|  |     static let shared = DarwinNotificationCenter() | ||||||
|  |      | ||||||
|  |     private let notificationCenter: CFNotificationCenter | ||||||
|  |      | ||||||
|  |     init() { | ||||||
|  |         notificationCenter = CFNotificationCenterGetDarwinNotifyCenter() | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     func postNotification(_ name: DarwinNotification) { | ||||||
|  |         CFNotificationCenterPostNotification(notificationCenter, CFNotificationName(rawValue: name.rawValue as CFString), nil, nil, true) | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										15
									
								
								ios/SolianBroadcastExtension/Info.plist
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								ios/SolianBroadcastExtension/Info.plist
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | |||||||
|  | <?xml version="1.0" encoding="UTF-8"?> | ||||||
|  | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||||||
|  | <plist version="1.0"> | ||||||
|  | <dict> | ||||||
|  | 	<key>NSExtension</key> | ||||||
|  | 	<dict> | ||||||
|  | 		<key>NSExtensionPointIdentifier</key> | ||||||
|  | 		<string>com.apple.broadcast-services-upload</string> | ||||||
|  | 		<key>NSExtensionPrincipalClass</key> | ||||||
|  | 		<string>$(PRODUCT_MODULE_NAME).SampleHandler</string> | ||||||
|  | 		<key>RPBroadcastProcessMode</key> | ||||||
|  | 		<string>RPBroadcastProcessModeSampleBuffer</string> | ||||||
|  | 	</dict> | ||||||
|  | </dict> | ||||||
|  | </plist> | ||||||
							
								
								
									
										103
									
								
								ios/SolianBroadcastExtension/SampleHandler.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								ios/SolianBroadcastExtension/SampleHandler.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,103 @@ | |||||||
|  | // | ||||||
|  | //  SampleHandler.swift | ||||||
|  | //  Broadcast Extension | ||||||
|  | // | ||||||
|  | //  Created by Alex-Dan Bumbu on 04.06.2021. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import ReplayKit | ||||||
|  | import OSLog | ||||||
|  |  | ||||||
|  | let broadcastLogger = OSLog(subsystem: "dev.solsynth.solian", category: "Broadcast") | ||||||
|  | private enum Constants { | ||||||
|  |     // the App Group ID value that the app and the broadcast extension targets are setup with. It differs for each app. | ||||||
|  |     static let appGroupIdentifier = "group.solsynth.solian" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class SampleHandler: RPBroadcastSampleHandler { | ||||||
|  |  | ||||||
|  |     private var clientConnection: SocketConnection? | ||||||
|  |     private var uploader: SampleUploader? | ||||||
|  |  | ||||||
|  |     private var frameCount: Int = 0 | ||||||
|  |  | ||||||
|  |     var socketFilePath: String { | ||||||
|  |       let sharedContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.appGroupIdentifier) | ||||||
|  |         return sharedContainer?.appendingPathComponent("rtc_SSFD").path ?? "" | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     override init() { | ||||||
|  |       super.init() | ||||||
|  |         if let connection = SocketConnection(filePath: socketFilePath) { | ||||||
|  |           clientConnection = connection | ||||||
|  |           setupConnection() | ||||||
|  |  | ||||||
|  |           uploader = SampleUploader(connection: connection) | ||||||
|  |         } | ||||||
|  |         os_log(.debug, log: broadcastLogger, "%{public}s", socketFilePath) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     override func broadcastStarted(withSetupInfo setupInfo: [String: NSObject]?) { | ||||||
|  |         // User has requested to start the broadcast. Setup info from the UI extension can be supplied but optional. | ||||||
|  |         frameCount = 0 | ||||||
|  |  | ||||||
|  |         DarwinNotificationCenter.shared.postNotification(.broadcastStarted) | ||||||
|  |         openConnection() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     override func broadcastPaused() { | ||||||
|  |         // User has requested to pause the broadcast. Samples will stop being delivered. | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     override func broadcastResumed() { | ||||||
|  |         // User has requested to resume the broadcast. Samples delivery will resume. | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     override func broadcastFinished() { | ||||||
|  |         // User has requested to finish the broadcast. | ||||||
|  |         DarwinNotificationCenter.shared.postNotification(.broadcastStopped) | ||||||
|  |         clientConnection?.close() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) { | ||||||
|  |         switch sampleBufferType { | ||||||
|  |         case RPSampleBufferType.video: | ||||||
|  |             uploader?.send(sample: sampleBuffer) | ||||||
|  |         default: | ||||||
|  |             break | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | private extension SampleHandler { | ||||||
|  |  | ||||||
|  |     func setupConnection() { | ||||||
|  |         clientConnection?.didClose = { [weak self] error in | ||||||
|  |             os_log(.debug, log: broadcastLogger, "client connection did close \(String(describing: error))") | ||||||
|  |  | ||||||
|  |             if let error = error { | ||||||
|  |                 self?.finishBroadcastWithError(error) | ||||||
|  |             } else { | ||||||
|  |                 // the displayed failure message is more user friendly when using NSError instead of Error | ||||||
|  |                 let JMScreenSharingStopped = 10001 | ||||||
|  |                 let customError = NSError(domain: RPRecordingErrorDomain, code: JMScreenSharingStopped, userInfo: [NSLocalizedDescriptionKey: "Screen sharing stopped"]) | ||||||
|  |                 self?.finishBroadcastWithError(customError) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func openConnection() { | ||||||
|  |         let queue = DispatchQueue(label: "broadcast.connectTimer") | ||||||
|  |         let timer = DispatchSource.makeTimerSource(queue: queue) | ||||||
|  |         timer.schedule(deadline: .now(), repeating: .milliseconds(100), leeway: .milliseconds(500)) | ||||||
|  |         timer.setEventHandler { [weak self] in | ||||||
|  |             guard self?.clientConnection?.open() == true else { | ||||||
|  |                 return | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             timer.cancel() | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         timer.resume() | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										147
									
								
								ios/SolianBroadcastExtension/SampleUploader.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								ios/SolianBroadcastExtension/SampleUploader.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,147 @@ | |||||||
|  | // | ||||||
|  | //  SampleUploader.swift | ||||||
|  | //  Broadcast Extension | ||||||
|  | // | ||||||
|  | //  Created by Alex-Dan Bumbu on 22/03/2021. | ||||||
|  | //  Copyright © 2021 8x8, Inc. All rights reserved. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import Foundation | ||||||
|  | import ReplayKit | ||||||
|  | import OSLog | ||||||
|  |  | ||||||
|  | private enum Constants { | ||||||
|  |     static let bufferMaxLength = 10240 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class SampleUploader { | ||||||
|  |      | ||||||
|  |     private static var imageContext = CIContext(options: nil) | ||||||
|  |      | ||||||
|  |     @Atomic private var isReady = false | ||||||
|  |     private var connection: SocketConnection | ||||||
|  |    | ||||||
|  |     private var dataToSend: Data? | ||||||
|  |     private var byteIndex = 0 | ||||||
|  |    | ||||||
|  |     private let serialQueue: DispatchQueue | ||||||
|  |      | ||||||
|  |     init(connection: SocketConnection) { | ||||||
|  |         self.connection = connection | ||||||
|  |         self.serialQueue = DispatchQueue(label: "org.jitsi.meet.broadcast.sampleUploader") | ||||||
|  |        | ||||||
|  |         setupConnection() | ||||||
|  |     } | ||||||
|  |    | ||||||
|  |     @discardableResult func send(sample buffer: CMSampleBuffer) -> Bool { | ||||||
|  |         guard isReady else { | ||||||
|  |             return false | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         isReady = false | ||||||
|  |  | ||||||
|  |         dataToSend = prepare(sample: buffer) | ||||||
|  |         byteIndex = 0 | ||||||
|  |  | ||||||
|  |         serialQueue.async { [weak self] in | ||||||
|  |             self?.sendDataChunk() | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         return true | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | private extension SampleUploader { | ||||||
|  |      | ||||||
|  |     func setupConnection() { | ||||||
|  |         connection.didOpen = { [weak self] in | ||||||
|  |             self?.isReady = true | ||||||
|  |         } | ||||||
|  |         connection.streamHasSpaceAvailable = { [weak self] in | ||||||
|  |             self?.serialQueue.async { | ||||||
|  |                 if let success = self?.sendDataChunk() { | ||||||
|  |                     self?.isReady = !success | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     @discardableResult func sendDataChunk() -> Bool { | ||||||
|  |         guard let dataToSend = dataToSend else { | ||||||
|  |             return false | ||||||
|  |         } | ||||||
|  |        | ||||||
|  |         var bytesLeft = dataToSend.count - byteIndex | ||||||
|  |         var length = bytesLeft > Constants.bufferMaxLength ? Constants.bufferMaxLength : bytesLeft | ||||||
|  |  | ||||||
|  |         length = dataToSend[byteIndex..<(byteIndex + length)].withUnsafeBytes { | ||||||
|  |             guard let ptr = $0.bindMemory(to: UInt8.self).baseAddress else { | ||||||
|  |                 return 0 | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return connection.writeToStream(buffer: ptr, maxLength: length) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if length > 0 { | ||||||
|  |             byteIndex += length | ||||||
|  |             bytesLeft -= length | ||||||
|  |  | ||||||
|  |             if bytesLeft == 0 { | ||||||
|  |                 self.dataToSend = nil | ||||||
|  |                 byteIndex = 0 | ||||||
|  |             } | ||||||
|  |         } else { | ||||||
|  |             os_log(.debug, log: broadcastLogger, "writeBufferToStream failure") | ||||||
|  |         } | ||||||
|  |        | ||||||
|  |         return true | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     func prepare(sample buffer: CMSampleBuffer) -> Data? { | ||||||
|  |         guard let imageBuffer = CMSampleBufferGetImageBuffer(buffer) else { | ||||||
|  |             os_log(.debug, log: broadcastLogger, "image buffer not available") | ||||||
|  |             return nil | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         CVPixelBufferLockBaseAddress(imageBuffer, .readOnly) | ||||||
|  |          | ||||||
|  |         let scaleFactor = 1.0 | ||||||
|  |         let width = CVPixelBufferGetWidth(imageBuffer)/Int(scaleFactor) | ||||||
|  |         let height = CVPixelBufferGetHeight(imageBuffer)/Int(scaleFactor) | ||||||
|  |         let orientation = CMGetAttachment(buffer, key: RPVideoSampleOrientationKey as CFString, attachmentModeOut: nil)?.uintValue ?? 0 | ||||||
|  |                                      | ||||||
|  |         let scaleTransform = CGAffineTransform(scaleX: CGFloat(1.0/scaleFactor), y: CGFloat(1.0/scaleFactor)) | ||||||
|  |         let bufferData = self.jpegData(from: imageBuffer, scale: scaleTransform) | ||||||
|  |          | ||||||
|  |         CVPixelBufferUnlockBaseAddress(imageBuffer, .readOnly) | ||||||
|  |          | ||||||
|  |         guard let messageData = bufferData else { | ||||||
|  |             os_log(.debug, log: broadcastLogger, "corrupted image buffer") | ||||||
|  |             return nil | ||||||
|  |         } | ||||||
|  |                | ||||||
|  |         let httpResponse = CFHTTPMessageCreateResponse(nil, 200, nil, kCFHTTPVersion1_1).takeRetainedValue() | ||||||
|  |         CFHTTPMessageSetHeaderFieldValue(httpResponse, "Content-Length" as CFString, String(messageData.count) as CFString) | ||||||
|  |         CFHTTPMessageSetHeaderFieldValue(httpResponse, "Buffer-Width" as CFString, String(width) as CFString) | ||||||
|  |         CFHTTPMessageSetHeaderFieldValue(httpResponse, "Buffer-Height" as CFString, String(height) as CFString) | ||||||
|  |         CFHTTPMessageSetHeaderFieldValue(httpResponse, "Buffer-Orientation" as CFString, String(orientation) as CFString) | ||||||
|  |          | ||||||
|  |         CFHTTPMessageSetBody(httpResponse, messageData as CFData) | ||||||
|  |          | ||||||
|  |         let serializedMessage = CFHTTPMessageCopySerializedMessage(httpResponse)?.takeRetainedValue() as Data? | ||||||
|  |        | ||||||
|  |         return serializedMessage | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     func jpegData(from buffer: CVPixelBuffer, scale scaleTransform: CGAffineTransform) -> Data? { | ||||||
|  |         let image = CIImage(cvPixelBuffer: buffer).transformed(by: scaleTransform) | ||||||
|  |          | ||||||
|  |         guard let colorSpace = image.colorSpace else { | ||||||
|  |             return nil | ||||||
|  |         } | ||||||
|  |        | ||||||
|  |         let options: [CIImageRepresentationOption: Float] = [kCGImageDestinationLossyCompressionQuality as CIImageRepresentationOption: 1.0] | ||||||
|  |  | ||||||
|  |         return SampleUploader.imageContext.jpegRepresentation(of: image, colorSpace: colorSpace, options: options) | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										199
									
								
								ios/SolianBroadcastExtension/SocketConnection.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										199
									
								
								ios/SolianBroadcastExtension/SocketConnection.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,199 @@ | |||||||
|  | // | ||||||
|  | //  SocketConnection.swift | ||||||
|  | //  Broadcast Extension | ||||||
|  | // | ||||||
|  | //  Created by Alex-Dan Bumbu on 22/03/2021. | ||||||
|  | //  Copyright © 2021 Atlassian Inc. All rights reserved. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import Foundation | ||||||
|  | import OSLog | ||||||
|  |  | ||||||
|  | class SocketConnection: NSObject { | ||||||
|  |     var didOpen: (() -> Void)? | ||||||
|  |     var didClose: ((Error?) -> Void)? | ||||||
|  |     var streamHasSpaceAvailable: (() -> Void)? | ||||||
|  |  | ||||||
|  |     private let filePath: String | ||||||
|  |     private var socketHandle: Int32 = -1 | ||||||
|  |     private var address: sockaddr_un? | ||||||
|  |  | ||||||
|  |     private var inputStream: InputStream? | ||||||
|  |     private var outputStream: OutputStream? | ||||||
|  |      | ||||||
|  |     private var networkQueue: DispatchQueue? | ||||||
|  |     private var shouldKeepRunning = false | ||||||
|  |  | ||||||
|  |     init?(filePath path: String) { | ||||||
|  |         filePath = path | ||||||
|  |         socketHandle = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) | ||||||
|  |  | ||||||
|  |         guard socketHandle != -1 else { | ||||||
|  |             os_log(.debug, log: broadcastLogger, "failure: create socket") | ||||||
|  |             return nil | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func open() -> Bool { | ||||||
|  |         os_log(.debug, log: broadcastLogger, "open socket connection") | ||||||
|  |  | ||||||
|  |         guard FileManager.default.fileExists(atPath: filePath) else { | ||||||
|  |             os_log(.debug, log: broadcastLogger, "failure: socket file missing") | ||||||
|  |             return false | ||||||
|  |         } | ||||||
|  |        | ||||||
|  |         guard setupAddress() == true else { | ||||||
|  |             return false | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         guard connectSocket() == true else { | ||||||
|  |             return false | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         setupStreams() | ||||||
|  |          | ||||||
|  |         inputStream?.open() | ||||||
|  |         outputStream?.open() | ||||||
|  |  | ||||||
|  |         return true | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func close() { | ||||||
|  |         unscheduleStreams() | ||||||
|  |  | ||||||
|  |         inputStream?.delegate = nil | ||||||
|  |         outputStream?.delegate = nil | ||||||
|  |  | ||||||
|  |         inputStream?.close() | ||||||
|  |         outputStream?.close() | ||||||
|  |          | ||||||
|  |         inputStream = nil | ||||||
|  |         outputStream = nil | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func writeToStream(buffer: UnsafePointer<UInt8>, maxLength length: Int) -> Int { | ||||||
|  |         outputStream?.write(buffer, maxLength: length) ?? 0 | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | extension SocketConnection: StreamDelegate { | ||||||
|  |  | ||||||
|  |     func stream(_ aStream: Stream, handle eventCode: Stream.Event) { | ||||||
|  |         switch eventCode { | ||||||
|  |         case .openCompleted: | ||||||
|  |             os_log(.debug, log: broadcastLogger, "client stream open completed") | ||||||
|  |             if aStream == outputStream { | ||||||
|  |                 didOpen?() | ||||||
|  |             } | ||||||
|  |         case .hasBytesAvailable: | ||||||
|  |             if aStream == inputStream { | ||||||
|  |                 var buffer: UInt8 = 0 | ||||||
|  |                 let numberOfBytesRead = inputStream?.read(&buffer, maxLength: 1) | ||||||
|  |                 if numberOfBytesRead == 0 && aStream.streamStatus == .atEnd { | ||||||
|  |                     os_log(.debug, log: broadcastLogger, "server socket closed") | ||||||
|  |                     close() | ||||||
|  |                     notifyDidClose(error: nil) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         case .hasSpaceAvailable: | ||||||
|  |             if aStream == outputStream { | ||||||
|  |                 streamHasSpaceAvailable?() | ||||||
|  |             } | ||||||
|  |         case .errorOccurred: | ||||||
|  |             os_log(.debug, log: broadcastLogger, "client stream error occured: \(String(describing: aStream.streamError))") | ||||||
|  |             close() | ||||||
|  |             notifyDidClose(error: aStream.streamError) | ||||||
|  |  | ||||||
|  |         default: | ||||||
|  |             break | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | private extension SocketConnection { | ||||||
|  |    | ||||||
|  |     func setupAddress() -> Bool { | ||||||
|  |         var addr = sockaddr_un() | ||||||
|  |         guard filePath.count < MemoryLayout.size(ofValue: addr.sun_path) else { | ||||||
|  |             os_log(.debug, log: broadcastLogger, "failure: fd path is too long") | ||||||
|  |             return false | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         _ = withUnsafeMutablePointer(to: &addr.sun_path.0) { ptr in | ||||||
|  |             filePath.withCString { | ||||||
|  |                 strncpy(ptr, $0, filePath.count) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         address = addr | ||||||
|  |         return true | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func connectSocket() -> Bool { | ||||||
|  |         guard var addr = address else { | ||||||
|  |             return false | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         let status = withUnsafePointer(to: &addr) { ptr in | ||||||
|  |             ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { | ||||||
|  |                 Darwin.connect(socketHandle, $0, socklen_t(MemoryLayout<sockaddr_un>.size)) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         guard status == noErr else { | ||||||
|  |             os_log(.debug, log: broadcastLogger, "failure: \(status)") | ||||||
|  |             return false | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         return true | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func setupStreams() { | ||||||
|  |         var readStream: Unmanaged<CFReadStream>? | ||||||
|  |         var writeStream: Unmanaged<CFWriteStream>? | ||||||
|  |  | ||||||
|  |         CFStreamCreatePairWithSocket(kCFAllocatorDefault, socketHandle, &readStream, &writeStream) | ||||||
|  |  | ||||||
|  |         inputStream = readStream?.takeRetainedValue() | ||||||
|  |         inputStream?.delegate = self | ||||||
|  |         inputStream?.setProperty(kCFBooleanTrue, forKey: Stream.PropertyKey(kCFStreamPropertyShouldCloseNativeSocket as String)) | ||||||
|  |  | ||||||
|  |         outputStream = writeStream?.takeRetainedValue() | ||||||
|  |         outputStream?.delegate = self | ||||||
|  |         outputStream?.setProperty(kCFBooleanTrue, forKey: Stream.PropertyKey(kCFStreamPropertyShouldCloseNativeSocket as String)) | ||||||
|  |  | ||||||
|  |         scheduleStreams() | ||||||
|  |     } | ||||||
|  |    | ||||||
|  |     func scheduleStreams() { | ||||||
|  |         shouldKeepRunning = true | ||||||
|  |          | ||||||
|  |         networkQueue = DispatchQueue.global(qos: .userInitiated) | ||||||
|  |         networkQueue?.async { [weak self] in | ||||||
|  |             self?.inputStream?.schedule(in: .current, forMode: .common) | ||||||
|  |             self?.outputStream?.schedule(in: .current, forMode: .common) | ||||||
|  |             RunLoop.current.run() | ||||||
|  |              | ||||||
|  |             var isRunning = false | ||||||
|  |                          | ||||||
|  |             repeat { | ||||||
|  |                 isRunning = self?.shouldKeepRunning ?? false && RunLoop.current.run(mode: .default, before: .distantFuture) | ||||||
|  |             } while (isRunning) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     func unscheduleStreams() { | ||||||
|  |         networkQueue?.sync { [weak self] in | ||||||
|  |             self?.inputStream?.remove(from: .current, forMode: .common) | ||||||
|  |             self?.outputStream?.remove(from: .current, forMode: .common) | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         shouldKeepRunning = false | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     func notifyDidClose(error: Error?) { | ||||||
|  |         if didClose != nil { | ||||||
|  |             didClose?(error) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,10 @@ | |||||||
|  | <?xml version="1.0" encoding="UTF-8"?> | ||||||
|  | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||||||
|  | <plist version="1.0"> | ||||||
|  | <dict> | ||||||
|  | 	<key>com.apple.security.application-groups</key> | ||||||
|  | 	<array> | ||||||
|  | 		<string>group.solsynth.solian</string> | ||||||
|  | 	</array> | ||||||
|  | </dict> | ||||||
|  | </plist> | ||||||
| @@ -49,8 +49,7 @@ class AppDatabase extends _$AppDatabase { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<int> updateMessage(ChatMessagesCompanion message) { |   Future<int> updateMessage(ChatMessagesCompanion message) { | ||||||
|     return (update(chatMessages) |     return into(chatMessages).insert(message, mode: InsertMode.insertOrReplace); | ||||||
|       ..where((m) => m.id.equals(message.id.value))).write(message); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<int> updateMessageStatus(String id, MessageStatus status) { |   Future<int> updateMessageStatus(String id, MessageStatus status) { | ||||||
|   | |||||||
| @@ -20,7 +20,6 @@ import 'package:bitsdojo_window/bitsdojo_window.dart'; | |||||||
| import 'package:island/pods/userinfo.dart'; | import 'package:island/pods/userinfo.dart'; | ||||||
| import 'package:island/pods/websocket.dart'; | import 'package:island/pods/websocket.dart'; | ||||||
| import 'package:island/route.dart'; | import 'package:island/route.dart'; | ||||||
|  |  | ||||||
| import 'package:island/services/notify.dart'; | import 'package:island/services/notify.dart'; | ||||||
| import 'package:island/services/timezone.dart'; | import 'package:island/services/timezone.dart'; | ||||||
| import 'package:island/widgets/alert.dart'; | import 'package:island/widgets/alert.dart'; | ||||||
| @@ -30,6 +29,7 @@ import 'package:shared_preferences/shared_preferences.dart'; | |||||||
| import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; | import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; | ||||||
| import 'package:flutter_native_splash/flutter_native_splash.dart'; | import 'package:flutter_native_splash/flutter_native_splash.dart'; | ||||||
| import 'package:url_launcher/url_launcher_string.dart'; | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
|  | import 'package:flutter_langdetect/flutter_langdetect.dart' as langdetect; | ||||||
|  |  | ||||||
| @pragma('vm:entry-point') | @pragma('vm:entry-point') | ||||||
| Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async { | Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async { | ||||||
| @@ -51,6 +51,7 @@ void main() async { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   try { |   try { | ||||||
|  |     await langdetect.initLangDetect(); | ||||||
|     await EasyLocalization.ensureInitialized(); |     await EasyLocalization.ensureInitialized(); | ||||||
|     await Firebase.initializeApp( |     await Firebase.initializeApp( | ||||||
|       options: DefaultFirebaseOptions.currentPlatform, |       options: DefaultFirebaseOptions.currentPlatform, | ||||||
|   | |||||||
| @@ -162,8 +162,6 @@ sealed class CallParticipant with _$CallParticipant { | |||||||
|     required String identity, |     required String identity, | ||||||
|     required String name, |     required String name, | ||||||
|     required DateTime joinedAt, |     required DateTime joinedAt, | ||||||
|     required String? accountId, |  | ||||||
|     required SnChatMember? profile, |  | ||||||
|   }) = _CallParticipant; |   }) = _CallParticipant; | ||||||
|  |  | ||||||
|   factory CallParticipant.fromJson(Map<String, dynamic> json) => |   factory CallParticipant.fromJson(Map<String, dynamic> json) => | ||||||
|   | |||||||
| @@ -2498,7 +2498,7 @@ as List<CallParticipant>, | |||||||
| /// @nodoc | /// @nodoc | ||||||
| mixin _$CallParticipant { | mixin _$CallParticipant { | ||||||
|  |  | ||||||
|  String get identity; String get name; DateTime get joinedAt; String? get accountId; SnChatMember? get profile; |  String get identity; String get name; DateTime get joinedAt; | ||||||
| /// Create a copy of CallParticipant | /// Create a copy of CallParticipant | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @JsonKey(includeFromJson: false, includeToJson: false) | @JsonKey(includeFromJson: false, includeToJson: false) | ||||||
| @@ -2511,16 +2511,16 @@ $CallParticipantCopyWith<CallParticipant> get copyWith => _$CallParticipantCopyW | |||||||
|  |  | ||||||
| @override | @override | ||||||
| bool operator ==(Object other) { | bool operator ==(Object other) { | ||||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is CallParticipant&&(identical(other.identity, identity) || other.identity == identity)&&(identical(other.name, name) || other.name == name)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.profile, profile) || other.profile == profile)); |   return identical(this, other) || (other.runtimeType == runtimeType&&other is CallParticipant&&(identical(other.identity, identity) || other.identity == identity)&&(identical(other.name, name) || other.name == name)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)); | ||||||
| } | } | ||||||
|  |  | ||||||
| @JsonKey(includeFromJson: false, includeToJson: false) | @JsonKey(includeFromJson: false, includeToJson: false) | ||||||
| @override | @override | ||||||
| int get hashCode => Object.hash(runtimeType,identity,name,joinedAt,accountId,profile); | int get hashCode => Object.hash(runtimeType,identity,name,joinedAt); | ||||||
|  |  | ||||||
| @override | @override | ||||||
| String toString() { | String toString() { | ||||||
|   return 'CallParticipant(identity: $identity, name: $name, joinedAt: $joinedAt, accountId: $accountId, profile: $profile)'; |   return 'CallParticipant(identity: $identity, name: $name, joinedAt: $joinedAt)'; | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -2531,11 +2531,11 @@ abstract mixin class $CallParticipantCopyWith<$Res>  { | |||||||
|   factory $CallParticipantCopyWith(CallParticipant value, $Res Function(CallParticipant) _then) = _$CallParticipantCopyWithImpl; |   factory $CallParticipantCopyWith(CallParticipant value, $Res Function(CallParticipant) _then) = _$CallParticipantCopyWithImpl; | ||||||
| @useResult | @useResult | ||||||
| $Res call({ | $Res call({ | ||||||
|  String identity, String name, DateTime joinedAt, String? accountId, SnChatMember? profile |  String identity, String name, DateTime joinedAt | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  |  | ||||||
| $SnChatMemberCopyWith<$Res>? get profile; |  | ||||||
|  |  | ||||||
| } | } | ||||||
| /// @nodoc | /// @nodoc | ||||||
| @@ -2548,29 +2548,15 @@ class _$CallParticipantCopyWithImpl<$Res> | |||||||
|  |  | ||||||
| /// Create a copy of CallParticipant | /// Create a copy of CallParticipant | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @pragma('vm:prefer-inline') @override $Res call({Object? identity = null,Object? name = null,Object? joinedAt = null,Object? accountId = freezed,Object? profile = freezed,}) { | @pragma('vm:prefer-inline') @override $Res call({Object? identity = null,Object? name = null,Object? joinedAt = null,}) { | ||||||
|   return _then(_self.copyWith( |   return _then(_self.copyWith( | ||||||
| identity: null == identity ? _self.identity : identity // ignore: cast_nullable_to_non_nullable | identity: null == identity ? _self.identity : identity // ignore: cast_nullable_to_non_nullable | ||||||
| as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable | as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable | ||||||
| as String,joinedAt: null == joinedAt ? _self.joinedAt : joinedAt // ignore: cast_nullable_to_non_nullable | as String,joinedAt: null == joinedAt ? _self.joinedAt : joinedAt // ignore: cast_nullable_to_non_nullable | ||||||
| as DateTime,accountId: freezed == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | as DateTime, | ||||||
| as String?,profile: freezed == profile ? _self.profile : profile // ignore: cast_nullable_to_non_nullable |  | ||||||
| as SnChatMember?, |  | ||||||
|   )); |   )); | ||||||
| } | } | ||||||
| /// Create a copy of CallParticipant |  | ||||||
| /// with the given fields replaced by the non-null parameter values. |  | ||||||
| @override |  | ||||||
| @pragma('vm:prefer-inline') |  | ||||||
| $SnChatMemberCopyWith<$Res>? get profile { |  | ||||||
|     if (_self.profile == null) { |  | ||||||
|     return null; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   return $SnChatMemberCopyWith<$Res>(_self.profile!, (value) { |  | ||||||
|     return _then(_self.copyWith(profile: value)); |  | ||||||
|   }); |  | ||||||
| } |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -2649,10 +2635,10 @@ return $default(_that);case _: | |||||||
| /// } | /// } | ||||||
| /// ``` | /// ``` | ||||||
|  |  | ||||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String identity,  String name,  DateTime joinedAt,  String? accountId,  SnChatMember? profile)?  $default,{required TResult orElse(),}) {final _that = this; | @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String identity,  String name,  DateTime joinedAt)?  $default,{required TResult orElse(),}) {final _that = this; | ||||||
| switch (_that) { | switch (_that) { | ||||||
| case _CallParticipant() when $default != null: | case _CallParticipant() when $default != null: | ||||||
| return $default(_that.identity,_that.name,_that.joinedAt,_that.accountId,_that.profile);case _: | return $default(_that.identity,_that.name,_that.joinedAt);case _: | ||||||
|   return orElse(); |   return orElse(); | ||||||
|  |  | ||||||
| } | } | ||||||
| @@ -2670,10 +2656,10 @@ return $default(_that.identity,_that.name,_that.joinedAt,_that.accountId,_that.p | |||||||
| /// } | /// } | ||||||
| /// ``` | /// ``` | ||||||
|  |  | ||||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String identity,  String name,  DateTime joinedAt,  String? accountId,  SnChatMember? profile)  $default,) {final _that = this; | @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String identity,  String name,  DateTime joinedAt)  $default,) {final _that = this; | ||||||
| switch (_that) { | switch (_that) { | ||||||
| case _CallParticipant(): | case _CallParticipant(): | ||||||
| return $default(_that.identity,_that.name,_that.joinedAt,_that.accountId,_that.profile);} | return $default(_that.identity,_that.name,_that.joinedAt);} | ||||||
| } | } | ||||||
| /// A variant of `when` that fallback to returning `null` | /// A variant of `when` that fallback to returning `null` | ||||||
| /// | /// | ||||||
| @@ -2687,10 +2673,10 @@ return $default(_that.identity,_that.name,_that.joinedAt,_that.accountId,_that.p | |||||||
| /// } | /// } | ||||||
| /// ``` | /// ``` | ||||||
|  |  | ||||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String identity,  String name,  DateTime joinedAt,  String? accountId,  SnChatMember? profile)?  $default,) {final _that = this; | @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String identity,  String name,  DateTime joinedAt)?  $default,) {final _that = this; | ||||||
| switch (_that) { | switch (_that) { | ||||||
| case _CallParticipant() when $default != null: | case _CallParticipant() when $default != null: | ||||||
| return $default(_that.identity,_that.name,_that.joinedAt,_that.accountId,_that.profile);case _: | return $default(_that.identity,_that.name,_that.joinedAt);case _: | ||||||
|   return null; |   return null; | ||||||
|  |  | ||||||
| } | } | ||||||
| @@ -2702,14 +2688,12 @@ return $default(_that.identity,_that.name,_that.joinedAt,_that.accountId,_that.p | |||||||
| @JsonSerializable() | @JsonSerializable() | ||||||
|  |  | ||||||
| class _CallParticipant implements CallParticipant { | class _CallParticipant implements CallParticipant { | ||||||
|   const _CallParticipant({required this.identity, required this.name, required this.joinedAt, required this.accountId, required this.profile}); |   const _CallParticipant({required this.identity, required this.name, required this.joinedAt}); | ||||||
|   factory _CallParticipant.fromJson(Map<String, dynamic> json) => _$CallParticipantFromJson(json); |   factory _CallParticipant.fromJson(Map<String, dynamic> json) => _$CallParticipantFromJson(json); | ||||||
|  |  | ||||||
| @override final  String identity; | @override final  String identity; | ||||||
| @override final  String name; | @override final  String name; | ||||||
| @override final  DateTime joinedAt; | @override final  DateTime joinedAt; | ||||||
| @override final  String? accountId; |  | ||||||
| @override final  SnChatMember? profile; |  | ||||||
|  |  | ||||||
| /// Create a copy of CallParticipant | /// Create a copy of CallParticipant | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @@ -2724,16 +2708,16 @@ Map<String, dynamic> toJson() { | |||||||
|  |  | ||||||
| @override | @override | ||||||
| bool operator ==(Object other) { | bool operator ==(Object other) { | ||||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallParticipant&&(identical(other.identity, identity) || other.identity == identity)&&(identical(other.name, name) || other.name == name)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.profile, profile) || other.profile == profile)); |   return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallParticipant&&(identical(other.identity, identity) || other.identity == identity)&&(identical(other.name, name) || other.name == name)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)); | ||||||
| } | } | ||||||
|  |  | ||||||
| @JsonKey(includeFromJson: false, includeToJson: false) | @JsonKey(includeFromJson: false, includeToJson: false) | ||||||
| @override | @override | ||||||
| int get hashCode => Object.hash(runtimeType,identity,name,joinedAt,accountId,profile); | int get hashCode => Object.hash(runtimeType,identity,name,joinedAt); | ||||||
|  |  | ||||||
| @override | @override | ||||||
| String toString() { | String toString() { | ||||||
|   return 'CallParticipant(identity: $identity, name: $name, joinedAt: $joinedAt, accountId: $accountId, profile: $profile)'; |   return 'CallParticipant(identity: $identity, name: $name, joinedAt: $joinedAt)'; | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -2744,11 +2728,11 @@ abstract mixin class _$CallParticipantCopyWith<$Res> implements $CallParticipant | |||||||
|   factory _$CallParticipantCopyWith(_CallParticipant value, $Res Function(_CallParticipant) _then) = __$CallParticipantCopyWithImpl; |   factory _$CallParticipantCopyWith(_CallParticipant value, $Res Function(_CallParticipant) _then) = __$CallParticipantCopyWithImpl; | ||||||
| @override @useResult | @override @useResult | ||||||
| $Res call({ | $Res call({ | ||||||
|  String identity, String name, DateTime joinedAt, String? accountId, SnChatMember? profile |  String identity, String name, DateTime joinedAt | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  |  | ||||||
| @override $SnChatMemberCopyWith<$Res>? get profile; |  | ||||||
|  |  | ||||||
| } | } | ||||||
| /// @nodoc | /// @nodoc | ||||||
| @@ -2761,30 +2745,16 @@ class __$CallParticipantCopyWithImpl<$Res> | |||||||
|  |  | ||||||
| /// Create a copy of CallParticipant | /// Create a copy of CallParticipant | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @override @pragma('vm:prefer-inline') $Res call({Object? identity = null,Object? name = null,Object? joinedAt = null,Object? accountId = freezed,Object? profile = freezed,}) { | @override @pragma('vm:prefer-inline') $Res call({Object? identity = null,Object? name = null,Object? joinedAt = null,}) { | ||||||
|   return _then(_CallParticipant( |   return _then(_CallParticipant( | ||||||
| identity: null == identity ? _self.identity : identity // ignore: cast_nullable_to_non_nullable | identity: null == identity ? _self.identity : identity // ignore: cast_nullable_to_non_nullable | ||||||
| as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable | as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable | ||||||
| as String,joinedAt: null == joinedAt ? _self.joinedAt : joinedAt // ignore: cast_nullable_to_non_nullable | as String,joinedAt: null == joinedAt ? _self.joinedAt : joinedAt // ignore: cast_nullable_to_non_nullable | ||||||
| as DateTime,accountId: freezed == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | as DateTime, | ||||||
| as String?,profile: freezed == profile ? _self.profile : profile // ignore: cast_nullable_to_non_nullable |  | ||||||
| as SnChatMember?, |  | ||||||
|   )); |   )); | ||||||
| } | } | ||||||
|  |  | ||||||
| /// Create a copy of CallParticipant |  | ||||||
| /// with the given fields replaced by the non-null parameter values. |  | ||||||
| @override |  | ||||||
| @pragma('vm:prefer-inline') |  | ||||||
| $SnChatMemberCopyWith<$Res>? get profile { |  | ||||||
|     if (_self.profile == null) { |  | ||||||
|     return null; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   return $SnChatMemberCopyWith<$Res>(_self.profile!, (value) { |  | ||||||
|     return _then(_self.copyWith(profile: value)); |  | ||||||
|   }); |  | ||||||
| } |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -285,11 +285,6 @@ _CallParticipant _$CallParticipantFromJson(Map<String, dynamic> json) => | |||||||
|       identity: json['identity'] as String, |       identity: json['identity'] as String, | ||||||
|       name: json['name'] as String, |       name: json['name'] as String, | ||||||
|       joinedAt: DateTime.parse(json['joined_at'] as String), |       joinedAt: DateTime.parse(json['joined_at'] as String), | ||||||
|       accountId: json['account_id'] as String?, |  | ||||||
|       profile: |  | ||||||
|           json['profile'] == null |  | ||||||
|               ? null |  | ||||||
|               : SnChatMember.fromJson(json['profile'] as Map<String, dynamic>), |  | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
| Map<String, dynamic> _$CallParticipantToJson(_CallParticipant instance) => | Map<String, dynamic> _$CallParticipantToJson(_CallParticipant instance) => | ||||||
| @@ -297,8 +292,6 @@ Map<String, dynamic> _$CallParticipantToJson(_CallParticipant instance) => | |||||||
|       'identity': instance.identity, |       'identity': instance.identity, | ||||||
|       'name': instance.name, |       'name': instance.name, | ||||||
|       'joined_at': instance.joinedAt.toIso8601String(), |       'joined_at': instance.joinedAt.toIso8601String(), | ||||||
|       'account_id': instance.accountId, |  | ||||||
|       'profile': instance.profile?.toJson(), |  | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
| _SnRealtimeCall _$SnRealtimeCallFromJson(Map<String, dynamic> json) => | _SnRealtimeCall _$SnRealtimeCallFromJson(Map<String, dynamic> json) => | ||||||
|   | |||||||
| @@ -11,8 +11,8 @@ sealed class SnEmbedLink with _$SnEmbedLink { | |||||||
|     @JsonKey(name: 'Title') required String title, |     @JsonKey(name: 'Title') required String title, | ||||||
|     @JsonKey(name: 'Description') required String? description, |     @JsonKey(name: 'Description') required String? description, | ||||||
|     @JsonKey(name: 'ImageUrl') required String? imageUrl, |     @JsonKey(name: 'ImageUrl') required String? imageUrl, | ||||||
|     @JsonKey(name: 'FaviconUrl') required String faviconUrl, |     @JsonKey(name: 'FaviconUrl') @Default("") String faviconUrl, | ||||||
|     @JsonKey(name: 'SiteName') required String siteName, |     @JsonKey(name: 'SiteName') @Default("") String siteName, | ||||||
|     @JsonKey(name: 'ContentType') required String? contentType, |     @JsonKey(name: 'ContentType') required String? contentType, | ||||||
|     @JsonKey(name: 'Author') required String? author, |     @JsonKey(name: 'Author') required String? author, | ||||||
|     @JsonKey(name: 'PublishedDate') required DateTime? publishedDate, |     @JsonKey(name: 'PublishedDate') required DateTime? publishedDate, | ||||||
|   | |||||||
| @@ -212,7 +212,7 @@ return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUr | |||||||
| @JsonSerializable() | @JsonSerializable() | ||||||
|  |  | ||||||
| class _SnEmbedLink implements SnEmbedLink { | class _SnEmbedLink implements SnEmbedLink { | ||||||
|   const _SnEmbedLink({@JsonKey(name: 'Type') required this.type, @JsonKey(name: 'Url') required this.url, @JsonKey(name: 'Title') required this.title, @JsonKey(name: 'Description') required this.description, @JsonKey(name: 'ImageUrl') required this.imageUrl, @JsonKey(name: 'FaviconUrl') required this.faviconUrl, @JsonKey(name: 'SiteName') required this.siteName, @JsonKey(name: 'ContentType') required this.contentType, @JsonKey(name: 'Author') required this.author, @JsonKey(name: 'PublishedDate') required this.publishedDate}); |   const _SnEmbedLink({@JsonKey(name: 'Type') required this.type, @JsonKey(name: 'Url') required this.url, @JsonKey(name: 'Title') required this.title, @JsonKey(name: 'Description') required this.description, @JsonKey(name: 'ImageUrl') required this.imageUrl, @JsonKey(name: 'FaviconUrl') this.faviconUrl = "", @JsonKey(name: 'SiteName') this.siteName = "", @JsonKey(name: 'ContentType') required this.contentType, @JsonKey(name: 'Author') required this.author, @JsonKey(name: 'PublishedDate') required this.publishedDate}); | ||||||
|   factory _SnEmbedLink.fromJson(Map<String, dynamic> json) => _$SnEmbedLinkFromJson(json); |   factory _SnEmbedLink.fromJson(Map<String, dynamic> json) => _$SnEmbedLinkFromJson(json); | ||||||
|  |  | ||||||
| @override@JsonKey(name: 'Type') final  String type; | @override@JsonKey(name: 'Type') final  String type; | ||||||
|   | |||||||
| @@ -12,8 +12,8 @@ _SnEmbedLink _$SnEmbedLinkFromJson(Map<String, dynamic> json) => _SnEmbedLink( | |||||||
|   title: json['Title'] as String, |   title: json['Title'] as String, | ||||||
|   description: json['Description'] as String?, |   description: json['Description'] as String?, | ||||||
|   imageUrl: json['ImageUrl'] as String?, |   imageUrl: json['ImageUrl'] as String?, | ||||||
|   faviconUrl: json['FaviconUrl'] as String, |   faviconUrl: json['FaviconUrl'] as String? ?? "", | ||||||
|   siteName: json['SiteName'] as String, |   siteName: json['SiteName'] as String? ?? "", | ||||||
|   contentType: json['ContentType'] as String?, |   contentType: json['ContentType'] as String?, | ||||||
|   author: json['Author'] as String?, |   author: json['Author'] as String?, | ||||||
|   publishedDate: |   publishedDate: | ||||||
|   | |||||||
| @@ -12,6 +12,7 @@ sealed class UniversalFile with _$UniversalFile { | |||||||
|   const factory UniversalFile({ |   const factory UniversalFile({ | ||||||
|     required dynamic data, |     required dynamic data, | ||||||
|     required UniversalFileType type, |     required UniversalFileType type, | ||||||
|  |     @Default(false) bool isLink, | ||||||
|   }) = _UniversalFile; |   }) = _UniversalFile; | ||||||
|  |  | ||||||
|   factory UniversalFile.fromJson(Map<String, dynamic> json) => |   factory UniversalFile.fromJson(Map<String, dynamic> json) => | ||||||
| @@ -41,6 +42,7 @@ sealed class SnCloudFile with _$SnCloudFile { | |||||||
|     required String? description, |     required String? description, | ||||||
|     required Map<String, dynamic>? fileMeta, |     required Map<String, dynamic>? fileMeta, | ||||||
|     required Map<String, dynamic>? userMeta, |     required Map<String, dynamic>? userMeta, | ||||||
|  |     @Default([]) List<int> sensitiveMarks, | ||||||
|     required String? mimeType, |     required String? mimeType, | ||||||
|     required String? hash, |     required String? hash, | ||||||
|     required int size, |     required int size, | ||||||
|   | |||||||
| @@ -15,7 +15,7 @@ T _$identity<T>(T value) => value; | |||||||
| /// @nodoc | /// @nodoc | ||||||
| mixin _$UniversalFile { | mixin _$UniversalFile { | ||||||
|  |  | ||||||
|  dynamic get data; UniversalFileType get type; |  dynamic get data; UniversalFileType get type; bool get isLink; | ||||||
| /// Create a copy of UniversalFile | /// Create a copy of UniversalFile | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @JsonKey(includeFromJson: false, includeToJson: false) | @JsonKey(includeFromJson: false, includeToJson: false) | ||||||
| @@ -28,16 +28,16 @@ $UniversalFileCopyWith<UniversalFile> get copyWith => _$UniversalFileCopyWithImp | |||||||
|  |  | ||||||
| @override | @override | ||||||
| bool operator ==(Object other) { | bool operator ==(Object other) { | ||||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)); |   return identical(this, other) || (other.runtimeType == runtimeType&&other is UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)&&(identical(other.isLink, isLink) || other.isLink == isLink)); | ||||||
| } | } | ||||||
|  |  | ||||||
| @JsonKey(includeFromJson: false, includeToJson: false) | @JsonKey(includeFromJson: false, includeToJson: false) | ||||||
| @override | @override | ||||||
| int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type); | int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type,isLink); | ||||||
|  |  | ||||||
| @override | @override | ||||||
| String toString() { | String toString() { | ||||||
|   return 'UniversalFile(data: $data, type: $type)'; |   return 'UniversalFile(data: $data, type: $type, isLink: $isLink)'; | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -48,7 +48,7 @@ abstract mixin class $UniversalFileCopyWith<$Res>  { | |||||||
|   factory $UniversalFileCopyWith(UniversalFile value, $Res Function(UniversalFile) _then) = _$UniversalFileCopyWithImpl; |   factory $UniversalFileCopyWith(UniversalFile value, $Res Function(UniversalFile) _then) = _$UniversalFileCopyWithImpl; | ||||||
| @useResult | @useResult | ||||||
| $Res call({ | $Res call({ | ||||||
|  dynamic data, UniversalFileType type |  dynamic data, UniversalFileType type, bool isLink | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -65,11 +65,12 @@ class _$UniversalFileCopyWithImpl<$Res> | |||||||
|  |  | ||||||
| /// Create a copy of UniversalFile | /// Create a copy of UniversalFile | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @pragma('vm:prefer-inline') @override $Res call({Object? data = freezed,Object? type = null,}) { | @pragma('vm:prefer-inline') @override $Res call({Object? data = freezed,Object? type = null,Object? isLink = null,}) { | ||||||
|   return _then(_self.copyWith( |   return _then(_self.copyWith( | ||||||
| data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable | data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable | ||||||
| as dynamic,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | as dynamic,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||||
| as UniversalFileType, | as UniversalFileType,isLink: null == isLink ? _self.isLink : isLink // ignore: cast_nullable_to_non_nullable | ||||||
|  | as bool, | ||||||
|   )); |   )); | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -151,10 +152,10 @@ return $default(_that);case _: | |||||||
| /// } | /// } | ||||||
| /// ``` | /// ``` | ||||||
|  |  | ||||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( dynamic data,  UniversalFileType type)?  $default,{required TResult orElse(),}) {final _that = this; | @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( dynamic data,  UniversalFileType type,  bool isLink)?  $default,{required TResult orElse(),}) {final _that = this; | ||||||
| switch (_that) { | switch (_that) { | ||||||
| case _UniversalFile() when $default != null: | case _UniversalFile() when $default != null: | ||||||
| return $default(_that.data,_that.type);case _: | return $default(_that.data,_that.type,_that.isLink);case _: | ||||||
|   return orElse(); |   return orElse(); | ||||||
|  |  | ||||||
| } | } | ||||||
| @@ -172,10 +173,10 @@ return $default(_that.data,_that.type);case _: | |||||||
| /// } | /// } | ||||||
| /// ``` | /// ``` | ||||||
|  |  | ||||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( dynamic data,  UniversalFileType type)  $default,) {final _that = this; | @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( dynamic data,  UniversalFileType type,  bool isLink)  $default,) {final _that = this; | ||||||
| switch (_that) { | switch (_that) { | ||||||
| case _UniversalFile(): | case _UniversalFile(): | ||||||
| return $default(_that.data,_that.type);} | return $default(_that.data,_that.type,_that.isLink);} | ||||||
| } | } | ||||||
| /// A variant of `when` that fallback to returning `null` | /// A variant of `when` that fallback to returning `null` | ||||||
| /// | /// | ||||||
| @@ -189,10 +190,10 @@ return $default(_that.data,_that.type);} | |||||||
| /// } | /// } | ||||||
| /// ``` | /// ``` | ||||||
|  |  | ||||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( dynamic data,  UniversalFileType type)?  $default,) {final _that = this; | @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( dynamic data,  UniversalFileType type,  bool isLink)?  $default,) {final _that = this; | ||||||
| switch (_that) { | switch (_that) { | ||||||
| case _UniversalFile() when $default != null: | case _UniversalFile() when $default != null: | ||||||
| return $default(_that.data,_that.type);case _: | return $default(_that.data,_that.type,_that.isLink);case _: | ||||||
|   return null; |   return null; | ||||||
|  |  | ||||||
| } | } | ||||||
| @@ -204,11 +205,12 @@ return $default(_that.data,_that.type);case _: | |||||||
| @JsonSerializable() | @JsonSerializable() | ||||||
|  |  | ||||||
| class _UniversalFile extends UniversalFile { | class _UniversalFile extends UniversalFile { | ||||||
|   const _UniversalFile({required this.data, required this.type}): super._(); |   const _UniversalFile({required this.data, required this.type, this.isLink = false}): super._(); | ||||||
|   factory _UniversalFile.fromJson(Map<String, dynamic> json) => _$UniversalFileFromJson(json); |   factory _UniversalFile.fromJson(Map<String, dynamic> json) => _$UniversalFileFromJson(json); | ||||||
|  |  | ||||||
| @override final  dynamic data; | @override final  dynamic data; | ||||||
| @override final  UniversalFileType type; | @override final  UniversalFileType type; | ||||||
|  | @override@JsonKey() final  bool isLink; | ||||||
|  |  | ||||||
| /// Create a copy of UniversalFile | /// Create a copy of UniversalFile | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @@ -223,16 +225,16 @@ Map<String, dynamic> toJson() { | |||||||
|  |  | ||||||
| @override | @override | ||||||
| bool operator ==(Object other) { | bool operator ==(Object other) { | ||||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)); |   return identical(this, other) || (other.runtimeType == runtimeType&&other is _UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)&&(identical(other.isLink, isLink) || other.isLink == isLink)); | ||||||
| } | } | ||||||
|  |  | ||||||
| @JsonKey(includeFromJson: false, includeToJson: false) | @JsonKey(includeFromJson: false, includeToJson: false) | ||||||
| @override | @override | ||||||
| int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type); | int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type,isLink); | ||||||
|  |  | ||||||
| @override | @override | ||||||
| String toString() { | String toString() { | ||||||
|   return 'UniversalFile(data: $data, type: $type)'; |   return 'UniversalFile(data: $data, type: $type, isLink: $isLink)'; | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -243,7 +245,7 @@ abstract mixin class _$UniversalFileCopyWith<$Res> implements $UniversalFileCopy | |||||||
|   factory _$UniversalFileCopyWith(_UniversalFile value, $Res Function(_UniversalFile) _then) = __$UniversalFileCopyWithImpl; |   factory _$UniversalFileCopyWith(_UniversalFile value, $Res Function(_UniversalFile) _then) = __$UniversalFileCopyWithImpl; | ||||||
| @override @useResult | @override @useResult | ||||||
| $Res call({ | $Res call({ | ||||||
|  dynamic data, UniversalFileType type |  dynamic data, UniversalFileType type, bool isLink | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -260,11 +262,12 @@ class __$UniversalFileCopyWithImpl<$Res> | |||||||
|  |  | ||||||
| /// Create a copy of UniversalFile | /// Create a copy of UniversalFile | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @override @pragma('vm:prefer-inline') $Res call({Object? data = freezed,Object? type = null,}) { | @override @pragma('vm:prefer-inline') $Res call({Object? data = freezed,Object? type = null,Object? isLink = null,}) { | ||||||
|   return _then(_UniversalFile( |   return _then(_UniversalFile( | ||||||
| data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable | data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable | ||||||
| as dynamic,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | as dynamic,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||||
| as UniversalFileType, | as UniversalFileType,isLink: null == isLink ? _self.isLink : isLink // ignore: cast_nullable_to_non_nullable | ||||||
|  | as bool, | ||||||
|   )); |   )); | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -275,7 +278,7 @@ as UniversalFileType, | |||||||
| /// @nodoc | /// @nodoc | ||||||
| mixin _$SnCloudFile { | mixin _$SnCloudFile { | ||||||
|  |  | ||||||
|  String get id; String get name; String? get description; Map<String, dynamic>? get fileMeta; Map<String, dynamic>? get userMeta; 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; 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 | /// Create a copy of SnCloudFile | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @JsonKey(includeFromJson: false, includeToJson: false) | @JsonKey(includeFromJson: false, includeToJson: false) | ||||||
| @@ -288,16 +291,16 @@ $SnCloudFileCopyWith<SnCloudFile> get copyWith => _$SnCloudFileCopyWithImpl<SnCl | |||||||
|  |  | ||||||
| @override | @override | ||||||
| bool operator ==(Object other) { | 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)&&(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)&&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) | @JsonKey(includeFromJson: false, includeToJson: false) | ||||||
| @override | @override | ||||||
| int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(fileMeta),const DeepCollectionEquality().hash(userMeta),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),const DeepCollectionEquality().hash(sensitiveMarks),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt); | ||||||
|  |  | ||||||
| @override | @override | ||||||
| String toString() { | String toString() { | ||||||
|   return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, 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, sensitiveMarks: $sensitiveMarks, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -308,7 +311,7 @@ abstract mixin class $SnCloudFileCopyWith<$Res>  { | |||||||
|   factory $SnCloudFileCopyWith(SnCloudFile value, $Res Function(SnCloudFile) _then) = _$SnCloudFileCopyWithImpl; |   factory $SnCloudFileCopyWith(SnCloudFile value, $Res Function(SnCloudFile) _then) = _$SnCloudFileCopyWithImpl; | ||||||
| @useResult | @useResult | ||||||
| $Res call({ | $Res call({ | ||||||
|  String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, 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, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -325,14 +328,15 @@ class _$SnCloudFileCopyWithImpl<$Res> | |||||||
|  |  | ||||||
| /// Create a copy of SnCloudFile | /// Create a copy of SnCloudFile | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,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? 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( |   return _then(_self.copyWith( | ||||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | 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,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,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 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>?,userMeta: freezed == userMeta ? _self.userMeta : userMeta // ignore: cast_nullable_to_non_nullable | ||||||
| as Map<String, dynamic>?,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable | as Map<String, dynamic>?,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?,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 | as String?,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable | ||||||
| as int,uploadedAt: freezed == uploadedAt ? _self.uploadedAt : uploadedAt // ignore: cast_nullable_to_non_nullable | as int,uploadedAt: freezed == uploadedAt ? _self.uploadedAt : uploadedAt // ignore: cast_nullable_to_non_nullable | ||||||
| @@ -422,10 +426,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,  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,  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) { | switch (_that) { | ||||||
| case _SnCloudFile() when $default != null: | case _SnCloudFile() when $default != null: | ||||||
| return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_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.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||||
|   return orElse(); |   return orElse(); | ||||||
|  |  | ||||||
| } | } | ||||||
| @@ -443,10 +447,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,  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,  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) { | switch (_that) { | ||||||
| case _SnCloudFile(): | case _SnCloudFile(): | ||||||
| return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_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.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` | /// A variant of `when` that fallback to returning `null` | ||||||
| /// | /// | ||||||
| @@ -460,10 +464,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,  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,  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) { | switch (_that) { | ||||||
| case _SnCloudFile() when $default != null: | case _SnCloudFile() when $default != null: | ||||||
| return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_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.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||||
|   return null; |   return null; | ||||||
|  |  | ||||||
| } | } | ||||||
| @@ -475,7 +479,7 @@ return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userM | |||||||
| @JsonSerializable() | @JsonSerializable() | ||||||
|  |  | ||||||
| class _SnCloudFile implements SnCloudFile { | 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, 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; |   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; | ||||||
|   factory _SnCloudFile.fromJson(Map<String, dynamic> json) => _$SnCloudFileFromJson(json); |   factory _SnCloudFile.fromJson(Map<String, dynamic> json) => _$SnCloudFileFromJson(json); | ||||||
|  |  | ||||||
| @override final  String id; | @override final  String id; | ||||||
| @@ -499,6 +503,13 @@ class _SnCloudFile implements SnCloudFile { | |||||||
|   return EqualUnmodifiableMapView(value); |   return EqualUnmodifiableMapView(value); | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  final  List<int> _sensitiveMarks; | ||||||
|  | @override@JsonKey() List<int> get sensitiveMarks { | ||||||
|  |   if (_sensitiveMarks is EqualUnmodifiableListView) return _sensitiveMarks; | ||||||
|  |   // ignore: implicit_dynamic_type | ||||||
|  |   return EqualUnmodifiableListView(_sensitiveMarks); | ||||||
|  | } | ||||||
|  |  | ||||||
| @override final  String? mimeType; | @override final  String? mimeType; | ||||||
| @override final  String? hash; | @override final  String? hash; | ||||||
| @override final  int size; | @override final  int size; | ||||||
| @@ -521,16 +532,16 @@ Map<String, dynamic> toJson() { | |||||||
|  |  | ||||||
| @override | @override | ||||||
| bool operator ==(Object other) { | 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)&&(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)&&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) | @JsonKey(includeFromJson: false, includeToJson: false) | ||||||
| @override | @override | ||||||
| int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(_fileMeta),const DeepCollectionEquality().hash(_userMeta),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),const DeepCollectionEquality().hash(_sensitiveMarks),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt); | ||||||
|  |  | ||||||
| @override | @override | ||||||
| String toString() { | String toString() { | ||||||
|   return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, 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, sensitiveMarks: $sensitiveMarks, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -541,7 +552,7 @@ abstract mixin class _$SnCloudFileCopyWith<$Res> implements $SnCloudFileCopyWith | |||||||
|   factory _$SnCloudFileCopyWith(_SnCloudFile value, $Res Function(_SnCloudFile) _then) = __$SnCloudFileCopyWithImpl; |   factory _$SnCloudFileCopyWith(_SnCloudFile value, $Res Function(_SnCloudFile) _then) = __$SnCloudFileCopyWithImpl; | ||||||
| @override @useResult | @override @useResult | ||||||
| $Res call({ | $Res call({ | ||||||
|  String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, 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, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -558,14 +569,15 @@ class __$SnCloudFileCopyWithImpl<$Res> | |||||||
|  |  | ||||||
| /// Create a copy of SnCloudFile | /// Create a copy of SnCloudFile | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,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? 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( |   return _then(_SnCloudFile( | ||||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | 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,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,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 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>?,userMeta: freezed == userMeta ? _self._userMeta : userMeta // ignore: cast_nullable_to_non_nullable | ||||||
| as Map<String, dynamic>?,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable | as Map<String, dynamic>?,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?,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 | as String?,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable | ||||||
| as int,uploadedAt: freezed == uploadedAt ? _self.uploadedAt : uploadedAt // ignore: cast_nullable_to_non_nullable | as int,uploadedAt: freezed == uploadedAt ? _self.uploadedAt : uploadedAt // ignore: cast_nullable_to_non_nullable | ||||||
|   | |||||||
| @@ -10,12 +10,14 @@ _UniversalFile _$UniversalFileFromJson(Map<String, dynamic> json) => | |||||||
|     _UniversalFile( |     _UniversalFile( | ||||||
|       data: json['data'], |       data: json['data'], | ||||||
|       type: $enumDecode(_$UniversalFileTypeEnumMap, json['type']), |       type: $enumDecode(_$UniversalFileTypeEnumMap, json['type']), | ||||||
|  |       isLink: json['is_link'] as bool? ?? false, | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
| Map<String, dynamic> _$UniversalFileToJson(_UniversalFile instance) => | Map<String, dynamic> _$UniversalFileToJson(_UniversalFile instance) => | ||||||
|     <String, dynamic>{ |     <String, dynamic>{ | ||||||
|       'data': instance.data, |       'data': instance.data, | ||||||
|       'type': _$UniversalFileTypeEnumMap[instance.type]!, |       'type': _$UniversalFileTypeEnumMap[instance.type]!, | ||||||
|  |       'is_link': instance.isLink, | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
| const _$UniversalFileTypeEnumMap = { | const _$UniversalFileTypeEnumMap = { | ||||||
| @@ -31,6 +33,11 @@ _SnCloudFile _$SnCloudFileFromJson(Map<String, dynamic> json) => _SnCloudFile( | |||||||
|   description: json['description'] as String?, |   description: json['description'] as String?, | ||||||
|   fileMeta: json['file_meta'] as Map<String, dynamic>?, |   fileMeta: json['file_meta'] as Map<String, dynamic>?, | ||||||
|   userMeta: json['user_meta'] as Map<String, dynamic>?, |   userMeta: json['user_meta'] as Map<String, dynamic>?, | ||||||
|  |   sensitiveMarks: | ||||||
|  |       (json['sensitive_marks'] as List<dynamic>?) | ||||||
|  |           ?.map((e) => (e as num).toInt()) | ||||||
|  |           .toList() ?? | ||||||
|  |       const [], | ||||||
|   mimeType: json['mime_type'] as String?, |   mimeType: json['mime_type'] as String?, | ||||||
|   hash: json['hash'] as String?, |   hash: json['hash'] as String?, | ||||||
|   size: (json['size'] as num).toInt(), |   size: (json['size'] as num).toInt(), | ||||||
| @@ -54,6 +61,7 @@ Map<String, dynamic> _$SnCloudFileToJson(_SnCloudFile instance) => | |||||||
|       'description': instance.description, |       'description': instance.description, | ||||||
|       'file_meta': instance.fileMeta, |       'file_meta': instance.fileMeta, | ||||||
|       'user_meta': instance.userMeta, |       'user_meta': instance.userMeta, | ||||||
|  |       'sensitive_marks': instance.sensitiveMarks, | ||||||
|       'mime_type': instance.mimeType, |       'mime_type': instance.mimeType, | ||||||
|       'hash': instance.hash, |       'hash': instance.hash, | ||||||
|       'size': instance.size, |       'size': instance.size, | ||||||
|   | |||||||
							
								
								
									
										92
									
								
								lib/models/poll.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								lib/models/poll.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | |||||||
|  | import 'package:freezed_annotation/freezed_annotation.dart'; | ||||||
|  | import 'package:island/models/publisher.dart'; | ||||||
|  |  | ||||||
|  | part 'poll.freezed.dart'; | ||||||
|  | part 'poll.g.dart'; | ||||||
|  |  | ||||||
|  | @freezed | ||||||
|  | sealed class SnPollWithStats with _$SnPollWithStats { | ||||||
|  |   const factory SnPollWithStats({ | ||||||
|  |     required Map<String, dynamic>? userAnswer, | ||||||
|  |     required Map<String, dynamic> stats, | ||||||
|  |     required String id, | ||||||
|  |     required List<SnPollQuestion> questions, | ||||||
|  |     String? title, | ||||||
|  |     String? description, | ||||||
|  |     DateTime? endedAt, | ||||||
|  |     required String publisherId, | ||||||
|  |     required DateTime createdAt, | ||||||
|  |     required DateTime updatedAt, | ||||||
|  |     DateTime? deletedAt, | ||||||
|  |   }) = _SnPollWithStats; | ||||||
|  |  | ||||||
|  |   factory SnPollWithStats.fromJson(Map<String, dynamic> json) => | ||||||
|  |       _$SnPollWithStatsFromJson(json); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @freezed | ||||||
|  | sealed class SnPoll with _$SnPoll { | ||||||
|  |   const factory SnPoll({ | ||||||
|  |     required String id, | ||||||
|  |     required List<SnPollQuestion> questions, | ||||||
|  |  | ||||||
|  |     String? title, | ||||||
|  |     String? description, | ||||||
|  |  | ||||||
|  |     DateTime? endedAt, | ||||||
|  |  | ||||||
|  |     required String publisherId, | ||||||
|  |     SnPublisher? publisher, | ||||||
|  |  | ||||||
|  |     // ModelBase fields | ||||||
|  |     required DateTime createdAt, | ||||||
|  |     required DateTime updatedAt, | ||||||
|  |     DateTime? deletedAt, | ||||||
|  |   }) = _SnPoll; | ||||||
|  |  | ||||||
|  |   factory SnPoll.fromJson(Map<String, dynamic> json) => _$SnPollFromJson(json); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @freezed | ||||||
|  | sealed class SnPollQuestion with _$SnPollQuestion { | ||||||
|  |   const factory SnPollQuestion({ | ||||||
|  |     required String id, | ||||||
|  |  | ||||||
|  |     required SnPollQuestionType type, | ||||||
|  |     List<SnPollOption>? options, | ||||||
|  |  | ||||||
|  |     required String title, | ||||||
|  |     String? description, | ||||||
|  |     required int order, | ||||||
|  |     required bool isRequired, | ||||||
|  |   }) = _SnPollQuestion; | ||||||
|  |  | ||||||
|  |   factory SnPollQuestion.fromJson(Map<String, dynamic> json) => | ||||||
|  |       _$SnPollQuestionFromJson(json); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @freezed | ||||||
|  | sealed class SnPollOption with _$SnPollOption { | ||||||
|  |   const factory SnPollOption({ | ||||||
|  |     required String id, | ||||||
|  |     required String label, | ||||||
|  |     String? description, | ||||||
|  |     required int order, | ||||||
|  |   }) = _SnPollOption; | ||||||
|  |  | ||||||
|  |   factory SnPollOption.fromJson(Map<String, dynamic> json) => | ||||||
|  |       _$SnPollOptionFromJson(json); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | enum SnPollQuestionType { | ||||||
|  |   @JsonValue(0) | ||||||
|  |   singleChoice, | ||||||
|  |   @JsonValue(1) | ||||||
|  |   multipleChoice, | ||||||
|  |   @JsonValue(2) | ||||||
|  |   yesNo, | ||||||
|  |   @JsonValue(3) | ||||||
|  |   rating, | ||||||
|  |   @JsonValue(4) | ||||||
|  |   freeText, | ||||||
|  | } | ||||||
							
								
								
									
										1186
									
								
								lib/models/poll.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1186
									
								
								lib/models/poll.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										133
									
								
								lib/models/poll.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								lib/models/poll.g.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,133 @@ | |||||||
|  | // GENERATED CODE - DO NOT MODIFY BY HAND | ||||||
|  |  | ||||||
|  | part of 'poll.dart'; | ||||||
|  |  | ||||||
|  | // ************************************************************************** | ||||||
|  | // JsonSerializableGenerator | ||||||
|  | // ************************************************************************** | ||||||
|  |  | ||||||
|  | _SnPollWithStats _$SnPollWithStatsFromJson(Map<String, dynamic> json) => | ||||||
|  |     _SnPollWithStats( | ||||||
|  |       userAnswer: json['user_answer'] as Map<String, dynamic>?, | ||||||
|  |       stats: json['stats'] as Map<String, dynamic>, | ||||||
|  |       id: json['id'] as String, | ||||||
|  |       questions: | ||||||
|  |           (json['questions'] as List<dynamic>) | ||||||
|  |               .map((e) => SnPollQuestion.fromJson(e as Map<String, dynamic>)) | ||||||
|  |               .toList(), | ||||||
|  |       title: json['title'] as String?, | ||||||
|  |       description: json['description'] as String?, | ||||||
|  |       endedAt: | ||||||
|  |           json['ended_at'] == null | ||||||
|  |               ? null | ||||||
|  |               : DateTime.parse(json['ended_at'] as String), | ||||||
|  |       publisherId: json['publisher_id'] as String, | ||||||
|  |       createdAt: DateTime.parse(json['created_at'] as String), | ||||||
|  |       updatedAt: DateTime.parse(json['updated_at'] as String), | ||||||
|  |       deletedAt: | ||||||
|  |           json['deleted_at'] == null | ||||||
|  |               ? null | ||||||
|  |               : DateTime.parse(json['deleted_at'] as String), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  | Map<String, dynamic> _$SnPollWithStatsToJson(_SnPollWithStats instance) => | ||||||
|  |     <String, dynamic>{ | ||||||
|  |       'user_answer': instance.userAnswer, | ||||||
|  |       'stats': instance.stats, | ||||||
|  |       'id': instance.id, | ||||||
|  |       'questions': instance.questions.map((e) => e.toJson()).toList(), | ||||||
|  |       'title': instance.title, | ||||||
|  |       'description': instance.description, | ||||||
|  |       'ended_at': instance.endedAt?.toIso8601String(), | ||||||
|  |       'publisher_id': instance.publisherId, | ||||||
|  |       'created_at': instance.createdAt.toIso8601String(), | ||||||
|  |       'updated_at': instance.updatedAt.toIso8601String(), | ||||||
|  |       'deleted_at': instance.deletedAt?.toIso8601String(), | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  | _SnPoll _$SnPollFromJson(Map<String, dynamic> json) => _SnPoll( | ||||||
|  |   id: json['id'] as String, | ||||||
|  |   questions: | ||||||
|  |       (json['questions'] as List<dynamic>) | ||||||
|  |           .map((e) => SnPollQuestion.fromJson(e as Map<String, dynamic>)) | ||||||
|  |           .toList(), | ||||||
|  |   title: json['title'] as String?, | ||||||
|  |   description: json['description'] as String?, | ||||||
|  |   endedAt: | ||||||
|  |       json['ended_at'] == null | ||||||
|  |           ? null | ||||||
|  |           : DateTime.parse(json['ended_at'] as String), | ||||||
|  |   publisherId: json['publisher_id'] as String, | ||||||
|  |   publisher: | ||||||
|  |       json['publisher'] == null | ||||||
|  |           ? null | ||||||
|  |           : SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>), | ||||||
|  |   createdAt: DateTime.parse(json['created_at'] as String), | ||||||
|  |   updatedAt: DateTime.parse(json['updated_at'] as String), | ||||||
|  |   deletedAt: | ||||||
|  |       json['deleted_at'] == null | ||||||
|  |           ? null | ||||||
|  |           : DateTime.parse(json['deleted_at'] as String), | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | Map<String, dynamic> _$SnPollToJson(_SnPoll instance) => <String, dynamic>{ | ||||||
|  |   'id': instance.id, | ||||||
|  |   'questions': instance.questions.map((e) => e.toJson()).toList(), | ||||||
|  |   'title': instance.title, | ||||||
|  |   'description': instance.description, | ||||||
|  |   'ended_at': instance.endedAt?.toIso8601String(), | ||||||
|  |   'publisher_id': instance.publisherId, | ||||||
|  |   'publisher': instance.publisher?.toJson(), | ||||||
|  |   'created_at': instance.createdAt.toIso8601String(), | ||||||
|  |   'updated_at': instance.updatedAt.toIso8601String(), | ||||||
|  |   'deleted_at': instance.deletedAt?.toIso8601String(), | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | _SnPollQuestion _$SnPollQuestionFromJson(Map<String, dynamic> json) => | ||||||
|  |     _SnPollQuestion( | ||||||
|  |       id: json['id'] as String, | ||||||
|  |       type: $enumDecode(_$SnPollQuestionTypeEnumMap, json['type']), | ||||||
|  |       options: | ||||||
|  |           (json['options'] as List<dynamic>?) | ||||||
|  |               ?.map((e) => SnPollOption.fromJson(e as Map<String, dynamic>)) | ||||||
|  |               .toList(), | ||||||
|  |       title: json['title'] as String, | ||||||
|  |       description: json['description'] as String?, | ||||||
|  |       order: (json['order'] as num).toInt(), | ||||||
|  |       isRequired: json['is_required'] as bool, | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  | Map<String, dynamic> _$SnPollQuestionToJson(_SnPollQuestion instance) => | ||||||
|  |     <String, dynamic>{ | ||||||
|  |       'id': instance.id, | ||||||
|  |       'type': _$SnPollQuestionTypeEnumMap[instance.type]!, | ||||||
|  |       'options': instance.options?.map((e) => e.toJson()).toList(), | ||||||
|  |       'title': instance.title, | ||||||
|  |       'description': instance.description, | ||||||
|  |       'order': instance.order, | ||||||
|  |       'is_required': instance.isRequired, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  | const _$SnPollQuestionTypeEnumMap = { | ||||||
|  |   SnPollQuestionType.singleChoice: 0, | ||||||
|  |   SnPollQuestionType.multipleChoice: 1, | ||||||
|  |   SnPollQuestionType.yesNo: 2, | ||||||
|  |   SnPollQuestionType.rating: 3, | ||||||
|  |   SnPollQuestionType.freeText: 4, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | _SnPollOption _$SnPollOptionFromJson(Map<String, dynamic> json) => | ||||||
|  |     _SnPollOption( | ||||||
|  |       id: json['id'] as String, | ||||||
|  |       label: json['label'] as String, | ||||||
|  |       description: json['description'] as String?, | ||||||
|  |       order: (json['order'] as num).toInt(), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  | Map<String, dynamic> _$SnPollOptionToJson(_SnPollOption instance) => | ||||||
|  |     <String, dynamic>{ | ||||||
|  |       'id': instance.id, | ||||||
|  |       'label': instance.label, | ||||||
|  |       'description': instance.description, | ||||||
|  |       'order': instance.order, | ||||||
|  |     }; | ||||||
| @@ -34,6 +34,7 @@ sealed class SnPost with _$SnPost { | |||||||
|     @Default([]) List<SnCloudFile> attachments, |     @Default([]) List<SnCloudFile> attachments, | ||||||
|     required SnPublisher publisher, |     required SnPublisher publisher, | ||||||
|     @Default({}) Map<String, int> reactionsCount, |     @Default({}) Map<String, int> reactionsCount, | ||||||
|  |     @Default({}) Map<String, bool> reactionsMade, | ||||||
|     @Default([]) List<dynamic> reactions, |     @Default([]) List<dynamic> reactions, | ||||||
|     @Default([]) List<PostTag> tags, |     @Default([]) List<PostTag> tags, | ||||||
|     @Default([]) List<PostCategory> categories, |     @Default([]) List<PostCategory> categories, | ||||||
| @@ -77,6 +78,13 @@ sealed class SnSubscriptionStatus with _$SnSubscriptionStatus { | |||||||
| sealed class ReactInfo with _$ReactInfo { | sealed class ReactInfo with _$ReactInfo { | ||||||
|   const factory ReactInfo({required String icon, required int attitude}) = |   const factory ReactInfo({required String icon, required int attitude}) = | ||||||
|       _ReactInfo; |       _ReactInfo; | ||||||
|  |  | ||||||
|  |   static String getTranslationKey(String templateKey) { | ||||||
|  |     final parts = templateKey.split('_'); | ||||||
|  |     final camelCase = | ||||||
|  |         parts.map((p) => p[0].toUpperCase() + p.substring(1)).join(); | ||||||
|  |     return 'reaction$camelCase'; | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| const Map<String, ReactInfo> kReactionTemplates = { | const Map<String, ReactInfo> kReactionTemplates = { | ||||||
|   | |||||||
| @@ -15,7 +15,7 @@ T _$identity<T>(T value) => value; | |||||||
| /// @nodoc | /// @nodoc | ||||||
| mixin _$SnPost { | mixin _$SnPost { | ||||||
|  |  | ||||||
|  String get id; String? get title; String? get description; String? get language; DateTime? get editedAt; DateTime? get publishedAt; int get visibility; String? get content; int get type; Map<String, dynamic>? get meta; int get viewsUnique; int get viewsTotal; int get upvotes; int get downvotes; int get repliesCount; String? get threadedPostId; SnPost? get threadedPost; String? get repliedPostId; SnPost? get repliedPost; String? get forwardedPostId; SnPost? get forwardedPost; List<SnCloudFile> get attachments; SnPublisher get publisher; Map<String, int> get reactionsCount; List<dynamic> get reactions; List<PostTag> get tags; List<PostCategory> get categories; List<dynamic> get collections; DateTime? get createdAt; DateTime? get updatedAt; DateTime? get deletedAt; bool get isTruncated; |  String get id; String? get title; String? get description; String? get language; DateTime? get editedAt; DateTime? get publishedAt; int get visibility; String? get content; int get type; Map<String, dynamic>? get meta; int get viewsUnique; int get viewsTotal; int get upvotes; int get downvotes; int get repliesCount; String? get threadedPostId; SnPost? get threadedPost; String? get repliedPostId; SnPost? get repliedPost; String? get forwardedPostId; SnPost? get forwardedPost; List<SnCloudFile> get attachments; SnPublisher get publisher; Map<String, int> get reactionsCount; Map<String, bool> get reactionsMade; List<dynamic> get reactions; List<PostTag> get tags; List<PostCategory> get categories; List<dynamic> get collections; DateTime? get createdAt; DateTime? get updatedAt; DateTime? get deletedAt; bool get isTruncated; | ||||||
| /// Create a copy of SnPost | /// Create a copy of SnPost | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @JsonKey(includeFromJson: false, includeToJson: false) | @JsonKey(includeFromJson: false, includeToJson: false) | ||||||
| @@ -28,16 +28,16 @@ $SnPostCopyWith<SnPost> get copyWith => _$SnPostCopyWithImpl<SnPost>(this as SnP | |||||||
|  |  | ||||||
| @override | @override | ||||||
| bool operator ==(Object other) { | bool operator ==(Object other) { | ||||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPost&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.language, language) || other.language == language)&&(identical(other.editedAt, editedAt) || other.editedAt == editedAt)&&(identical(other.publishedAt, publishedAt) || other.publishedAt == publishedAt)&&(identical(other.visibility, visibility) || other.visibility == visibility)&&(identical(other.content, content) || other.content == content)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other.meta, meta)&&(identical(other.viewsUnique, viewsUnique) || other.viewsUnique == viewsUnique)&&(identical(other.viewsTotal, viewsTotal) || other.viewsTotal == viewsTotal)&&(identical(other.upvotes, upvotes) || other.upvotes == upvotes)&&(identical(other.downvotes, downvotes) || other.downvotes == downvotes)&&(identical(other.repliesCount, repliesCount) || other.repliesCount == repliesCount)&&(identical(other.threadedPostId, threadedPostId) || other.threadedPostId == threadedPostId)&&(identical(other.threadedPost, threadedPost) || other.threadedPost == threadedPost)&&(identical(other.repliedPostId, repliedPostId) || other.repliedPostId == repliedPostId)&&(identical(other.repliedPost, repliedPost) || other.repliedPost == repliedPost)&&(identical(other.forwardedPostId, forwardedPostId) || other.forwardedPostId == forwardedPostId)&&(identical(other.forwardedPost, forwardedPost) || other.forwardedPost == forwardedPost)&&const DeepCollectionEquality().equals(other.attachments, attachments)&&(identical(other.publisher, publisher) || other.publisher == publisher)&&const DeepCollectionEquality().equals(other.reactionsCount, reactionsCount)&&const DeepCollectionEquality().equals(other.reactions, reactions)&&const DeepCollectionEquality().equals(other.tags, tags)&&const DeepCollectionEquality().equals(other.categories, categories)&&const DeepCollectionEquality().equals(other.collections, collections)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.isTruncated, isTruncated) || other.isTruncated == isTruncated)); |   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPost&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.language, language) || other.language == language)&&(identical(other.editedAt, editedAt) || other.editedAt == editedAt)&&(identical(other.publishedAt, publishedAt) || other.publishedAt == publishedAt)&&(identical(other.visibility, visibility) || other.visibility == visibility)&&(identical(other.content, content) || other.content == content)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other.meta, meta)&&(identical(other.viewsUnique, viewsUnique) || other.viewsUnique == viewsUnique)&&(identical(other.viewsTotal, viewsTotal) || other.viewsTotal == viewsTotal)&&(identical(other.upvotes, upvotes) || other.upvotes == upvotes)&&(identical(other.downvotes, downvotes) || other.downvotes == downvotes)&&(identical(other.repliesCount, repliesCount) || other.repliesCount == repliesCount)&&(identical(other.threadedPostId, threadedPostId) || other.threadedPostId == threadedPostId)&&(identical(other.threadedPost, threadedPost) || other.threadedPost == threadedPost)&&(identical(other.repliedPostId, repliedPostId) || other.repliedPostId == repliedPostId)&&(identical(other.repliedPost, repliedPost) || other.repliedPost == repliedPost)&&(identical(other.forwardedPostId, forwardedPostId) || other.forwardedPostId == forwardedPostId)&&(identical(other.forwardedPost, forwardedPost) || other.forwardedPost == forwardedPost)&&const DeepCollectionEquality().equals(other.attachments, attachments)&&(identical(other.publisher, publisher) || other.publisher == publisher)&&const DeepCollectionEquality().equals(other.reactionsCount, reactionsCount)&&const DeepCollectionEquality().equals(other.reactionsMade, reactionsMade)&&const DeepCollectionEquality().equals(other.reactions, reactions)&&const DeepCollectionEquality().equals(other.tags, tags)&&const DeepCollectionEquality().equals(other.categories, categories)&&const DeepCollectionEquality().equals(other.collections, collections)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.isTruncated, isTruncated) || other.isTruncated == isTruncated)); | ||||||
| } | } | ||||||
|  |  | ||||||
| @JsonKey(includeFromJson: false, includeToJson: false) | @JsonKey(includeFromJson: false, includeToJson: false) | ||||||
| @override | @override | ||||||
| int get hashCode => Object.hashAll([runtimeType,id,title,description,language,editedAt,publishedAt,visibility,content,type,const DeepCollectionEquality().hash(meta),viewsUnique,viewsTotal,upvotes,downvotes,repliesCount,threadedPostId,threadedPost,repliedPostId,repliedPost,forwardedPostId,forwardedPost,const DeepCollectionEquality().hash(attachments),publisher,const DeepCollectionEquality().hash(reactionsCount),const DeepCollectionEquality().hash(reactions),const DeepCollectionEquality().hash(tags),const DeepCollectionEquality().hash(categories),const DeepCollectionEquality().hash(collections),createdAt,updatedAt,deletedAt,isTruncated]); | int get hashCode => Object.hashAll([runtimeType,id,title,description,language,editedAt,publishedAt,visibility,content,type,const DeepCollectionEquality().hash(meta),viewsUnique,viewsTotal,upvotes,downvotes,repliesCount,threadedPostId,threadedPost,repliedPostId,repliedPost,forwardedPostId,forwardedPost,const DeepCollectionEquality().hash(attachments),publisher,const DeepCollectionEquality().hash(reactionsCount),const DeepCollectionEquality().hash(reactionsMade),const DeepCollectionEquality().hash(reactions),const DeepCollectionEquality().hash(tags),const DeepCollectionEquality().hash(categories),const DeepCollectionEquality().hash(collections),createdAt,updatedAt,deletedAt,isTruncated]); | ||||||
|  |  | ||||||
| @override | @override | ||||||
| String toString() { | String toString() { | ||||||
|   return 'SnPost(id: $id, title: $title, description: $description, language: $language, editedAt: $editedAt, publishedAt: $publishedAt, visibility: $visibility, content: $content, type: $type, meta: $meta, viewsUnique: $viewsUnique, viewsTotal: $viewsTotal, upvotes: $upvotes, downvotes: $downvotes, repliesCount: $repliesCount, threadedPostId: $threadedPostId, threadedPost: $threadedPost, repliedPostId: $repliedPostId, repliedPost: $repliedPost, forwardedPostId: $forwardedPostId, forwardedPost: $forwardedPost, attachments: $attachments, publisher: $publisher, reactionsCount: $reactionsCount, reactions: $reactions, tags: $tags, categories: $categories, collections: $collections, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, isTruncated: $isTruncated)'; |   return 'SnPost(id: $id, title: $title, description: $description, language: $language, editedAt: $editedAt, publishedAt: $publishedAt, visibility: $visibility, content: $content, type: $type, meta: $meta, viewsUnique: $viewsUnique, viewsTotal: $viewsTotal, upvotes: $upvotes, downvotes: $downvotes, repliesCount: $repliesCount, threadedPostId: $threadedPostId, threadedPost: $threadedPost, repliedPostId: $repliedPostId, repliedPost: $repliedPost, forwardedPostId: $forwardedPostId, forwardedPost: $forwardedPost, attachments: $attachments, publisher: $publisher, reactionsCount: $reactionsCount, reactionsMade: $reactionsMade, reactions: $reactions, tags: $tags, categories: $categories, collections: $collections, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, isTruncated: $isTruncated)'; | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -48,7 +48,7 @@ abstract mixin class $SnPostCopyWith<$Res>  { | |||||||
|   factory $SnPostCopyWith(SnPost value, $Res Function(SnPost) _then) = _$SnPostCopyWithImpl; |   factory $SnPostCopyWith(SnPost value, $Res Function(SnPost) _then) = _$SnPostCopyWithImpl; | ||||||
| @useResult | @useResult | ||||||
| $Res call({ | $Res call({ | ||||||
|  String id, String? title, String? description, String? language, DateTime? editedAt, DateTime? publishedAt, int visibility, String? content, int type, Map<String, dynamic>? meta, int viewsUnique, int viewsTotal, int upvotes, int downvotes, int repliesCount, String? threadedPostId, SnPost? threadedPost, String? repliedPostId, SnPost? repliedPost, String? forwardedPostId, SnPost? forwardedPost, List<SnCloudFile> attachments, SnPublisher publisher, Map<String, int> reactionsCount, List<dynamic> reactions, List<PostTag> tags, List<PostCategory> categories, List<dynamic> collections, DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, bool isTruncated |  String id, String? title, String? description, String? language, DateTime? editedAt, DateTime? publishedAt, int visibility, String? content, int type, Map<String, dynamic>? meta, int viewsUnique, int viewsTotal, int upvotes, int downvotes, int repliesCount, String? threadedPostId, SnPost? threadedPost, String? repliedPostId, SnPost? repliedPost, String? forwardedPostId, SnPost? forwardedPost, List<SnCloudFile> attachments, SnPublisher publisher, Map<String, int> reactionsCount, Map<String, bool> reactionsMade, List<dynamic> reactions, List<PostTag> tags, List<PostCategory> categories, List<dynamic> collections, DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, bool isTruncated | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -65,7 +65,7 @@ class _$SnPostCopyWithImpl<$Res> | |||||||
|  |  | ||||||
| /// Create a copy of SnPost | /// Create a copy of SnPost | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = freezed,Object? description = freezed,Object? language = freezed,Object? editedAt = freezed,Object? publishedAt = freezed,Object? visibility = null,Object? content = freezed,Object? type = null,Object? meta = freezed,Object? viewsUnique = null,Object? viewsTotal = null,Object? upvotes = null,Object? downvotes = null,Object? repliesCount = null,Object? threadedPostId = freezed,Object? threadedPost = freezed,Object? repliedPostId = freezed,Object? repliedPost = freezed,Object? forwardedPostId = freezed,Object? forwardedPost = freezed,Object? attachments = null,Object? publisher = null,Object? reactionsCount = null,Object? reactions = null,Object? tags = null,Object? categories = null,Object? collections = null,Object? createdAt = freezed,Object? updatedAt = freezed,Object? deletedAt = freezed,Object? isTruncated = null,}) { | @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = freezed,Object? description = freezed,Object? language = freezed,Object? editedAt = freezed,Object? publishedAt = freezed,Object? visibility = null,Object? content = freezed,Object? type = null,Object? meta = freezed,Object? viewsUnique = null,Object? viewsTotal = null,Object? upvotes = null,Object? downvotes = null,Object? repliesCount = null,Object? threadedPostId = freezed,Object? threadedPost = freezed,Object? repliedPostId = freezed,Object? repliedPost = freezed,Object? forwardedPostId = freezed,Object? forwardedPost = freezed,Object? attachments = null,Object? publisher = null,Object? reactionsCount = null,Object? reactionsMade = null,Object? reactions = null,Object? tags = null,Object? categories = null,Object? collections = null,Object? createdAt = freezed,Object? updatedAt = freezed,Object? deletedAt = freezed,Object? isTruncated = null,}) { | ||||||
|   return _then(_self.copyWith( |   return _then(_self.copyWith( | ||||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||||
| as String,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable | as String,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable | ||||||
| @@ -91,7 +91,8 @@ as String?,forwardedPost: freezed == forwardedPost ? _self.forwardedPost : forwa | |||||||
| as SnPost?,attachments: null == attachments ? _self.attachments : attachments // ignore: cast_nullable_to_non_nullable | as SnPost?,attachments: null == attachments ? _self.attachments : attachments // ignore: cast_nullable_to_non_nullable | ||||||
| as List<SnCloudFile>,publisher: null == publisher ? _self.publisher : publisher // ignore: cast_nullable_to_non_nullable | as List<SnCloudFile>,publisher: null == publisher ? _self.publisher : publisher // ignore: cast_nullable_to_non_nullable | ||||||
| as SnPublisher,reactionsCount: null == reactionsCount ? _self.reactionsCount : reactionsCount // ignore: cast_nullable_to_non_nullable | as SnPublisher,reactionsCount: null == reactionsCount ? _self.reactionsCount : reactionsCount // ignore: cast_nullable_to_non_nullable | ||||||
| as Map<String, int>,reactions: null == reactions ? _self.reactions : reactions // ignore: cast_nullable_to_non_nullable | as Map<String, int>,reactionsMade: null == reactionsMade ? _self.reactionsMade : reactionsMade // ignore: cast_nullable_to_non_nullable | ||||||
|  | as Map<String, bool>,reactions: null == reactions ? _self.reactions : reactions // ignore: cast_nullable_to_non_nullable | ||||||
| as List<dynamic>,tags: null == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable | as List<dynamic>,tags: null == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable | ||||||
| as List<PostTag>,categories: null == categories ? _self.categories : categories // ignore: cast_nullable_to_non_nullable | as List<PostTag>,categories: null == categories ? _self.categories : categories // ignore: cast_nullable_to_non_nullable | ||||||
| as List<PostCategory>,collections: null == collections ? _self.collections : collections // ignore: cast_nullable_to_non_nullable | as List<PostCategory>,collections: null == collections ? _self.collections : collections // ignore: cast_nullable_to_non_nullable | ||||||
| @@ -226,10 +227,10 @@ return $default(_that);case _: | |||||||
| /// } | /// } | ||||||
| /// ``` | /// ``` | ||||||
|  |  | ||||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id,  String? title,  String? description,  String? language,  DateTime? editedAt,  DateTime? publishedAt,  int visibility,  String? content,  int type,  Map<String, dynamic>? meta,  int viewsUnique,  int viewsTotal,  int upvotes,  int downvotes,  int repliesCount,  String? threadedPostId,  SnPost? threadedPost,  String? repliedPostId,  SnPost? repliedPost,  String? forwardedPostId,  SnPost? forwardedPost,  List<SnCloudFile> attachments,  SnPublisher publisher,  Map<String, int> reactionsCount,  List<dynamic> reactions,  List<PostTag> tags,  List<PostCategory> categories,  List<dynamic> collections,  DateTime? createdAt,  DateTime? updatedAt,  DateTime? deletedAt,  bool isTruncated)?  $default,{required TResult orElse(),}) {final _that = this; | @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id,  String? title,  String? description,  String? language,  DateTime? editedAt,  DateTime? publishedAt,  int visibility,  String? content,  int type,  Map<String, dynamic>? meta,  int viewsUnique,  int viewsTotal,  int upvotes,  int downvotes,  int repliesCount,  String? threadedPostId,  SnPost? threadedPost,  String? repliedPostId,  SnPost? repliedPost,  String? forwardedPostId,  SnPost? forwardedPost,  List<SnCloudFile> attachments,  SnPublisher publisher,  Map<String, int> reactionsCount,  Map<String, bool> reactionsMade,  List<dynamic> reactions,  List<PostTag> tags,  List<PostCategory> categories,  List<dynamic> collections,  DateTime? createdAt,  DateTime? updatedAt,  DateTime? deletedAt,  bool isTruncated)?  $default,{required TResult orElse(),}) {final _that = this; | ||||||
| switch (_that) { | switch (_that) { | ||||||
| case _SnPost() when $default != null: | case _SnPost() when $default != null: | ||||||
| return $default(_that.id,_that.title,_that.description,_that.language,_that.editedAt,_that.publishedAt,_that.visibility,_that.content,_that.type,_that.meta,_that.viewsUnique,_that.viewsTotal,_that.upvotes,_that.downvotes,_that.repliesCount,_that.threadedPostId,_that.threadedPost,_that.repliedPostId,_that.repliedPost,_that.forwardedPostId,_that.forwardedPost,_that.attachments,_that.publisher,_that.reactionsCount,_that.reactions,_that.tags,_that.categories,_that.collections,_that.createdAt,_that.updatedAt,_that.deletedAt,_that.isTruncated);case _: | return $default(_that.id,_that.title,_that.description,_that.language,_that.editedAt,_that.publishedAt,_that.visibility,_that.content,_that.type,_that.meta,_that.viewsUnique,_that.viewsTotal,_that.upvotes,_that.downvotes,_that.repliesCount,_that.threadedPostId,_that.threadedPost,_that.repliedPostId,_that.repliedPost,_that.forwardedPostId,_that.forwardedPost,_that.attachments,_that.publisher,_that.reactionsCount,_that.reactionsMade,_that.reactions,_that.tags,_that.categories,_that.collections,_that.createdAt,_that.updatedAt,_that.deletedAt,_that.isTruncated);case _: | ||||||
|   return orElse(); |   return orElse(); | ||||||
|  |  | ||||||
| } | } | ||||||
| @@ -247,10 +248,10 @@ return $default(_that.id,_that.title,_that.description,_that.language,_that.edit | |||||||
| /// } | /// } | ||||||
| /// ``` | /// ``` | ||||||
|  |  | ||||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  String? title,  String? description,  String? language,  DateTime? editedAt,  DateTime? publishedAt,  int visibility,  String? content,  int type,  Map<String, dynamic>? meta,  int viewsUnique,  int viewsTotal,  int upvotes,  int downvotes,  int repliesCount,  String? threadedPostId,  SnPost? threadedPost,  String? repliedPostId,  SnPost? repliedPost,  String? forwardedPostId,  SnPost? forwardedPost,  List<SnCloudFile> attachments,  SnPublisher publisher,  Map<String, int> reactionsCount,  List<dynamic> reactions,  List<PostTag> tags,  List<PostCategory> categories,  List<dynamic> collections,  DateTime? createdAt,  DateTime? updatedAt,  DateTime? deletedAt,  bool isTruncated)  $default,) {final _that = this; | @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  String? title,  String? description,  String? language,  DateTime? editedAt,  DateTime? publishedAt,  int visibility,  String? content,  int type,  Map<String, dynamic>? meta,  int viewsUnique,  int viewsTotal,  int upvotes,  int downvotes,  int repliesCount,  String? threadedPostId,  SnPost? threadedPost,  String? repliedPostId,  SnPost? repliedPost,  String? forwardedPostId,  SnPost? forwardedPost,  List<SnCloudFile> attachments,  SnPublisher publisher,  Map<String, int> reactionsCount,  Map<String, bool> reactionsMade,  List<dynamic> reactions,  List<PostTag> tags,  List<PostCategory> categories,  List<dynamic> collections,  DateTime? createdAt,  DateTime? updatedAt,  DateTime? deletedAt,  bool isTruncated)  $default,) {final _that = this; | ||||||
| switch (_that) { | switch (_that) { | ||||||
| case _SnPost(): | case _SnPost(): | ||||||
| return $default(_that.id,_that.title,_that.description,_that.language,_that.editedAt,_that.publishedAt,_that.visibility,_that.content,_that.type,_that.meta,_that.viewsUnique,_that.viewsTotal,_that.upvotes,_that.downvotes,_that.repliesCount,_that.threadedPostId,_that.threadedPost,_that.repliedPostId,_that.repliedPost,_that.forwardedPostId,_that.forwardedPost,_that.attachments,_that.publisher,_that.reactionsCount,_that.reactions,_that.tags,_that.categories,_that.collections,_that.createdAt,_that.updatedAt,_that.deletedAt,_that.isTruncated);} | return $default(_that.id,_that.title,_that.description,_that.language,_that.editedAt,_that.publishedAt,_that.visibility,_that.content,_that.type,_that.meta,_that.viewsUnique,_that.viewsTotal,_that.upvotes,_that.downvotes,_that.repliesCount,_that.threadedPostId,_that.threadedPost,_that.repliedPostId,_that.repliedPost,_that.forwardedPostId,_that.forwardedPost,_that.attachments,_that.publisher,_that.reactionsCount,_that.reactionsMade,_that.reactions,_that.tags,_that.categories,_that.collections,_that.createdAt,_that.updatedAt,_that.deletedAt,_that.isTruncated);} | ||||||
| } | } | ||||||
| /// A variant of `when` that fallback to returning `null` | /// A variant of `when` that fallback to returning `null` | ||||||
| /// | /// | ||||||
| @@ -264,10 +265,10 @@ return $default(_that.id,_that.title,_that.description,_that.language,_that.edit | |||||||
| /// } | /// } | ||||||
| /// ``` | /// ``` | ||||||
|  |  | ||||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  String? title,  String? description,  String? language,  DateTime? editedAt,  DateTime? publishedAt,  int visibility,  String? content,  int type,  Map<String, dynamic>? meta,  int viewsUnique,  int viewsTotal,  int upvotes,  int downvotes,  int repliesCount,  String? threadedPostId,  SnPost? threadedPost,  String? repliedPostId,  SnPost? repliedPost,  String? forwardedPostId,  SnPost? forwardedPost,  List<SnCloudFile> attachments,  SnPublisher publisher,  Map<String, int> reactionsCount,  List<dynamic> reactions,  List<PostTag> tags,  List<PostCategory> categories,  List<dynamic> collections,  DateTime? createdAt,  DateTime? updatedAt,  DateTime? deletedAt,  bool isTruncated)?  $default,) {final _that = this; | @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  String? title,  String? description,  String? language,  DateTime? editedAt,  DateTime? publishedAt,  int visibility,  String? content,  int type,  Map<String, dynamic>? meta,  int viewsUnique,  int viewsTotal,  int upvotes,  int downvotes,  int repliesCount,  String? threadedPostId,  SnPost? threadedPost,  String? repliedPostId,  SnPost? repliedPost,  String? forwardedPostId,  SnPost? forwardedPost,  List<SnCloudFile> attachments,  SnPublisher publisher,  Map<String, int> reactionsCount,  Map<String, bool> reactionsMade,  List<dynamic> reactions,  List<PostTag> tags,  List<PostCategory> categories,  List<dynamic> collections,  DateTime? createdAt,  DateTime? updatedAt,  DateTime? deletedAt,  bool isTruncated)?  $default,) {final _that = this; | ||||||
| switch (_that) { | switch (_that) { | ||||||
| case _SnPost() when $default != null: | case _SnPost() when $default != null: | ||||||
| return $default(_that.id,_that.title,_that.description,_that.language,_that.editedAt,_that.publishedAt,_that.visibility,_that.content,_that.type,_that.meta,_that.viewsUnique,_that.viewsTotal,_that.upvotes,_that.downvotes,_that.repliesCount,_that.threadedPostId,_that.threadedPost,_that.repliedPostId,_that.repliedPost,_that.forwardedPostId,_that.forwardedPost,_that.attachments,_that.publisher,_that.reactionsCount,_that.reactions,_that.tags,_that.categories,_that.collections,_that.createdAt,_that.updatedAt,_that.deletedAt,_that.isTruncated);case _: | return $default(_that.id,_that.title,_that.description,_that.language,_that.editedAt,_that.publishedAt,_that.visibility,_that.content,_that.type,_that.meta,_that.viewsUnique,_that.viewsTotal,_that.upvotes,_that.downvotes,_that.repliesCount,_that.threadedPostId,_that.threadedPost,_that.repliedPostId,_that.repliedPost,_that.forwardedPostId,_that.forwardedPost,_that.attachments,_that.publisher,_that.reactionsCount,_that.reactionsMade,_that.reactions,_that.tags,_that.categories,_that.collections,_that.createdAt,_that.updatedAt,_that.deletedAt,_that.isTruncated);case _: | ||||||
|   return null; |   return null; | ||||||
|  |  | ||||||
| } | } | ||||||
| @@ -279,7 +280,7 @@ return $default(_that.id,_that.title,_that.description,_that.language,_that.edit | |||||||
| @JsonSerializable() | @JsonSerializable() | ||||||
|  |  | ||||||
| class _SnPost implements SnPost { | class _SnPost implements SnPost { | ||||||
|   const _SnPost({required this.id, this.title, this.description, this.language, this.editedAt, this.publishedAt = null, this.visibility = 0, this.content, this.type = 0, final  Map<String, dynamic>? meta, this.viewsUnique = 0, this.viewsTotal = 0, this.upvotes = 0, this.downvotes = 0, this.repliesCount = 0, this.threadedPostId, this.threadedPost, this.repliedPostId, this.repliedPost, this.forwardedPostId, this.forwardedPost, final  List<SnCloudFile> attachments = const [], required this.publisher, final  Map<String, int> reactionsCount = const {}, final  List<dynamic> reactions = const [], final  List<PostTag> tags = const [], final  List<PostCategory> categories = const [], final  List<dynamic> collections = const [], this.createdAt = null, this.updatedAt = null, this.deletedAt, this.isTruncated = false}): _meta = meta,_attachments = attachments,_reactionsCount = reactionsCount,_reactions = reactions,_tags = tags,_categories = categories,_collections = collections; |   const _SnPost({required this.id, this.title, this.description, this.language, this.editedAt, this.publishedAt = null, this.visibility = 0, this.content, this.type = 0, final  Map<String, dynamic>? meta, this.viewsUnique = 0, this.viewsTotal = 0, this.upvotes = 0, this.downvotes = 0, this.repliesCount = 0, this.threadedPostId, this.threadedPost, this.repliedPostId, this.repliedPost, this.forwardedPostId, this.forwardedPost, final  List<SnCloudFile> attachments = const [], required this.publisher, final  Map<String, int> reactionsCount = const {}, final  Map<String, bool> reactionsMade = const {}, final  List<dynamic> reactions = const [], final  List<PostTag> tags = const [], final  List<PostCategory> categories = const [], final  List<dynamic> collections = const [], this.createdAt = null, this.updatedAt = null, this.deletedAt, this.isTruncated = false}): _meta = meta,_attachments = attachments,_reactionsCount = reactionsCount,_reactionsMade = reactionsMade,_reactions = reactions,_tags = tags,_categories = categories,_collections = collections; | ||||||
|   factory _SnPost.fromJson(Map<String, dynamic> json) => _$SnPostFromJson(json); |   factory _SnPost.fromJson(Map<String, dynamic> json) => _$SnPostFromJson(json); | ||||||
|  |  | ||||||
| @override final  String id; | @override final  String id; | ||||||
| @@ -326,6 +327,13 @@ class _SnPost implements SnPost { | |||||||
|   return EqualUnmodifiableMapView(_reactionsCount); |   return EqualUnmodifiableMapView(_reactionsCount); | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  final  Map<String, bool> _reactionsMade; | ||||||
|  | @override@JsonKey() Map<String, bool> get reactionsMade { | ||||||
|  |   if (_reactionsMade is EqualUnmodifiableMapView) return _reactionsMade; | ||||||
|  |   // ignore: implicit_dynamic_type | ||||||
|  |   return EqualUnmodifiableMapView(_reactionsMade); | ||||||
|  | } | ||||||
|  |  | ||||||
|  final  List<dynamic> _reactions; |  final  List<dynamic> _reactions; | ||||||
| @override@JsonKey() List<dynamic> get reactions { | @override@JsonKey() List<dynamic> get reactions { | ||||||
|   if (_reactions is EqualUnmodifiableListView) return _reactions; |   if (_reactions is EqualUnmodifiableListView) return _reactions; | ||||||
| @@ -372,16 +380,16 @@ Map<String, dynamic> toJson() { | |||||||
|  |  | ||||||
| @override | @override | ||||||
| bool operator ==(Object other) { | bool operator ==(Object other) { | ||||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPost&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.language, language) || other.language == language)&&(identical(other.editedAt, editedAt) || other.editedAt == editedAt)&&(identical(other.publishedAt, publishedAt) || other.publishedAt == publishedAt)&&(identical(other.visibility, visibility) || other.visibility == visibility)&&(identical(other.content, content) || other.content == content)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other._meta, _meta)&&(identical(other.viewsUnique, viewsUnique) || other.viewsUnique == viewsUnique)&&(identical(other.viewsTotal, viewsTotal) || other.viewsTotal == viewsTotal)&&(identical(other.upvotes, upvotes) || other.upvotes == upvotes)&&(identical(other.downvotes, downvotes) || other.downvotes == downvotes)&&(identical(other.repliesCount, repliesCount) || other.repliesCount == repliesCount)&&(identical(other.threadedPostId, threadedPostId) || other.threadedPostId == threadedPostId)&&(identical(other.threadedPost, threadedPost) || other.threadedPost == threadedPost)&&(identical(other.repliedPostId, repliedPostId) || other.repliedPostId == repliedPostId)&&(identical(other.repliedPost, repliedPost) || other.repliedPost == repliedPost)&&(identical(other.forwardedPostId, forwardedPostId) || other.forwardedPostId == forwardedPostId)&&(identical(other.forwardedPost, forwardedPost) || other.forwardedPost == forwardedPost)&&const DeepCollectionEquality().equals(other._attachments, _attachments)&&(identical(other.publisher, publisher) || other.publisher == publisher)&&const DeepCollectionEquality().equals(other._reactionsCount, _reactionsCount)&&const DeepCollectionEquality().equals(other._reactions, _reactions)&&const DeepCollectionEquality().equals(other._tags, _tags)&&const DeepCollectionEquality().equals(other._categories, _categories)&&const DeepCollectionEquality().equals(other._collections, _collections)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.isTruncated, isTruncated) || other.isTruncated == isTruncated)); |   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPost&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.language, language) || other.language == language)&&(identical(other.editedAt, editedAt) || other.editedAt == editedAt)&&(identical(other.publishedAt, publishedAt) || other.publishedAt == publishedAt)&&(identical(other.visibility, visibility) || other.visibility == visibility)&&(identical(other.content, content) || other.content == content)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other._meta, _meta)&&(identical(other.viewsUnique, viewsUnique) || other.viewsUnique == viewsUnique)&&(identical(other.viewsTotal, viewsTotal) || other.viewsTotal == viewsTotal)&&(identical(other.upvotes, upvotes) || other.upvotes == upvotes)&&(identical(other.downvotes, downvotes) || other.downvotes == downvotes)&&(identical(other.repliesCount, repliesCount) || other.repliesCount == repliesCount)&&(identical(other.threadedPostId, threadedPostId) || other.threadedPostId == threadedPostId)&&(identical(other.threadedPost, threadedPost) || other.threadedPost == threadedPost)&&(identical(other.repliedPostId, repliedPostId) || other.repliedPostId == repliedPostId)&&(identical(other.repliedPost, repliedPost) || other.repliedPost == repliedPost)&&(identical(other.forwardedPostId, forwardedPostId) || other.forwardedPostId == forwardedPostId)&&(identical(other.forwardedPost, forwardedPost) || other.forwardedPost == forwardedPost)&&const DeepCollectionEquality().equals(other._attachments, _attachments)&&(identical(other.publisher, publisher) || other.publisher == publisher)&&const DeepCollectionEquality().equals(other._reactionsCount, _reactionsCount)&&const DeepCollectionEquality().equals(other._reactionsMade, _reactionsMade)&&const DeepCollectionEquality().equals(other._reactions, _reactions)&&const DeepCollectionEquality().equals(other._tags, _tags)&&const DeepCollectionEquality().equals(other._categories, _categories)&&const DeepCollectionEquality().equals(other._collections, _collections)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.isTruncated, isTruncated) || other.isTruncated == isTruncated)); | ||||||
| } | } | ||||||
|  |  | ||||||
| @JsonKey(includeFromJson: false, includeToJson: false) | @JsonKey(includeFromJson: false, includeToJson: false) | ||||||
| @override | @override | ||||||
| int get hashCode => Object.hashAll([runtimeType,id,title,description,language,editedAt,publishedAt,visibility,content,type,const DeepCollectionEquality().hash(_meta),viewsUnique,viewsTotal,upvotes,downvotes,repliesCount,threadedPostId,threadedPost,repliedPostId,repliedPost,forwardedPostId,forwardedPost,const DeepCollectionEquality().hash(_attachments),publisher,const DeepCollectionEquality().hash(_reactionsCount),const DeepCollectionEquality().hash(_reactions),const DeepCollectionEquality().hash(_tags),const DeepCollectionEquality().hash(_categories),const DeepCollectionEquality().hash(_collections),createdAt,updatedAt,deletedAt,isTruncated]); | int get hashCode => Object.hashAll([runtimeType,id,title,description,language,editedAt,publishedAt,visibility,content,type,const DeepCollectionEquality().hash(_meta),viewsUnique,viewsTotal,upvotes,downvotes,repliesCount,threadedPostId,threadedPost,repliedPostId,repliedPost,forwardedPostId,forwardedPost,const DeepCollectionEquality().hash(_attachments),publisher,const DeepCollectionEquality().hash(_reactionsCount),const DeepCollectionEquality().hash(_reactionsMade),const DeepCollectionEquality().hash(_reactions),const DeepCollectionEquality().hash(_tags),const DeepCollectionEquality().hash(_categories),const DeepCollectionEquality().hash(_collections),createdAt,updatedAt,deletedAt,isTruncated]); | ||||||
|  |  | ||||||
| @override | @override | ||||||
| String toString() { | String toString() { | ||||||
|   return 'SnPost(id: $id, title: $title, description: $description, language: $language, editedAt: $editedAt, publishedAt: $publishedAt, visibility: $visibility, content: $content, type: $type, meta: $meta, viewsUnique: $viewsUnique, viewsTotal: $viewsTotal, upvotes: $upvotes, downvotes: $downvotes, repliesCount: $repliesCount, threadedPostId: $threadedPostId, threadedPost: $threadedPost, repliedPostId: $repliedPostId, repliedPost: $repliedPost, forwardedPostId: $forwardedPostId, forwardedPost: $forwardedPost, attachments: $attachments, publisher: $publisher, reactionsCount: $reactionsCount, reactions: $reactions, tags: $tags, categories: $categories, collections: $collections, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, isTruncated: $isTruncated)'; |   return 'SnPost(id: $id, title: $title, description: $description, language: $language, editedAt: $editedAt, publishedAt: $publishedAt, visibility: $visibility, content: $content, type: $type, meta: $meta, viewsUnique: $viewsUnique, viewsTotal: $viewsTotal, upvotes: $upvotes, downvotes: $downvotes, repliesCount: $repliesCount, threadedPostId: $threadedPostId, threadedPost: $threadedPost, repliedPostId: $repliedPostId, repliedPost: $repliedPost, forwardedPostId: $forwardedPostId, forwardedPost: $forwardedPost, attachments: $attachments, publisher: $publisher, reactionsCount: $reactionsCount, reactionsMade: $reactionsMade, reactions: $reactions, tags: $tags, categories: $categories, collections: $collections, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, isTruncated: $isTruncated)'; | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -392,7 +400,7 @@ abstract mixin class _$SnPostCopyWith<$Res> implements $SnPostCopyWith<$Res> { | |||||||
|   factory _$SnPostCopyWith(_SnPost value, $Res Function(_SnPost) _then) = __$SnPostCopyWithImpl; |   factory _$SnPostCopyWith(_SnPost value, $Res Function(_SnPost) _then) = __$SnPostCopyWithImpl; | ||||||
| @override @useResult | @override @useResult | ||||||
| $Res call({ | $Res call({ | ||||||
|  String id, String? title, String? description, String? language, DateTime? editedAt, DateTime? publishedAt, int visibility, String? content, int type, Map<String, dynamic>? meta, int viewsUnique, int viewsTotal, int upvotes, int downvotes, int repliesCount, String? threadedPostId, SnPost? threadedPost, String? repliedPostId, SnPost? repliedPost, String? forwardedPostId, SnPost? forwardedPost, List<SnCloudFile> attachments, SnPublisher publisher, Map<String, int> reactionsCount, List<dynamic> reactions, List<PostTag> tags, List<PostCategory> categories, List<dynamic> collections, DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, bool isTruncated |  String id, String? title, String? description, String? language, DateTime? editedAt, DateTime? publishedAt, int visibility, String? content, int type, Map<String, dynamic>? meta, int viewsUnique, int viewsTotal, int upvotes, int downvotes, int repliesCount, String? threadedPostId, SnPost? threadedPost, String? repliedPostId, SnPost? repliedPost, String? forwardedPostId, SnPost? forwardedPost, List<SnCloudFile> attachments, SnPublisher publisher, Map<String, int> reactionsCount, Map<String, bool> reactionsMade, List<dynamic> reactions, List<PostTag> tags, List<PostCategory> categories, List<dynamic> collections, DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, bool isTruncated | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -409,7 +417,7 @@ class __$SnPostCopyWithImpl<$Res> | |||||||
|  |  | ||||||
| /// Create a copy of SnPost | /// Create a copy of SnPost | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = freezed,Object? description = freezed,Object? language = freezed,Object? editedAt = freezed,Object? publishedAt = freezed,Object? visibility = null,Object? content = freezed,Object? type = null,Object? meta = freezed,Object? viewsUnique = null,Object? viewsTotal = null,Object? upvotes = null,Object? downvotes = null,Object? repliesCount = null,Object? threadedPostId = freezed,Object? threadedPost = freezed,Object? repliedPostId = freezed,Object? repliedPost = freezed,Object? forwardedPostId = freezed,Object? forwardedPost = freezed,Object? attachments = null,Object? publisher = null,Object? reactionsCount = null,Object? reactions = null,Object? tags = null,Object? categories = null,Object? collections = null,Object? createdAt = freezed,Object? updatedAt = freezed,Object? deletedAt = freezed,Object? isTruncated = null,}) { | @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = freezed,Object? description = freezed,Object? language = freezed,Object? editedAt = freezed,Object? publishedAt = freezed,Object? visibility = null,Object? content = freezed,Object? type = null,Object? meta = freezed,Object? viewsUnique = null,Object? viewsTotal = null,Object? upvotes = null,Object? downvotes = null,Object? repliesCount = null,Object? threadedPostId = freezed,Object? threadedPost = freezed,Object? repliedPostId = freezed,Object? repliedPost = freezed,Object? forwardedPostId = freezed,Object? forwardedPost = freezed,Object? attachments = null,Object? publisher = null,Object? reactionsCount = null,Object? reactionsMade = null,Object? reactions = null,Object? tags = null,Object? categories = null,Object? collections = null,Object? createdAt = freezed,Object? updatedAt = freezed,Object? deletedAt = freezed,Object? isTruncated = null,}) { | ||||||
|   return _then(_SnPost( |   return _then(_SnPost( | ||||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||||
| as String,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable | as String,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable | ||||||
| @@ -435,7 +443,8 @@ as String?,forwardedPost: freezed == forwardedPost ? _self.forwardedPost : forwa | |||||||
| as SnPost?,attachments: null == attachments ? _self._attachments : attachments // ignore: cast_nullable_to_non_nullable | as SnPost?,attachments: null == attachments ? _self._attachments : attachments // ignore: cast_nullable_to_non_nullable | ||||||
| as List<SnCloudFile>,publisher: null == publisher ? _self.publisher : publisher // ignore: cast_nullable_to_non_nullable | as List<SnCloudFile>,publisher: null == publisher ? _self.publisher : publisher // ignore: cast_nullable_to_non_nullable | ||||||
| as SnPublisher,reactionsCount: null == reactionsCount ? _self._reactionsCount : reactionsCount // ignore: cast_nullable_to_non_nullable | as SnPublisher,reactionsCount: null == reactionsCount ? _self._reactionsCount : reactionsCount // ignore: cast_nullable_to_non_nullable | ||||||
| as Map<String, int>,reactions: null == reactions ? _self._reactions : reactions // ignore: cast_nullable_to_non_nullable | as Map<String, int>,reactionsMade: null == reactionsMade ? _self._reactionsMade : reactionsMade // ignore: cast_nullable_to_non_nullable | ||||||
|  | as Map<String, bool>,reactions: null == reactions ? _self._reactions : reactions // ignore: cast_nullable_to_non_nullable | ||||||
| as List<dynamic>,tags: null == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable | as List<dynamic>,tags: null == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable | ||||||
| as List<PostTag>,categories: null == categories ? _self._categories : categories // ignore: cast_nullable_to_non_nullable | as List<PostTag>,categories: null == categories ? _self._categories : categories // ignore: cast_nullable_to_non_nullable | ||||||
| as List<PostCategory>,collections: null == collections ? _self._collections : collections // ignore: cast_nullable_to_non_nullable | as List<PostCategory>,collections: null == collections ? _self._collections : collections // ignore: cast_nullable_to_non_nullable | ||||||
|   | |||||||
| @@ -54,6 +54,11 @@ _SnPost _$SnPostFromJson(Map<String, dynamic> json) => _SnPost( | |||||||
|         (k, e) => MapEntry(k, (e as num).toInt()), |         (k, e) => MapEntry(k, (e as num).toInt()), | ||||||
|       ) ?? |       ) ?? | ||||||
|       const {}, |       const {}, | ||||||
|  |   reactionsMade: | ||||||
|  |       (json['reactions_made'] as Map<String, dynamic>?)?.map( | ||||||
|  |         (k, e) => MapEntry(k, e as bool), | ||||||
|  |       ) ?? | ||||||
|  |       const {}, | ||||||
|   reactions: json['reactions'] as List<dynamic>? ?? const [], |   reactions: json['reactions'] as List<dynamic>? ?? const [], | ||||||
|   tags: |   tags: | ||||||
|       (json['tags'] as List<dynamic>?) |       (json['tags'] as List<dynamic>?) | ||||||
| @@ -106,6 +111,7 @@ Map<String, dynamic> _$SnPostToJson(_SnPost instance) => <String, dynamic>{ | |||||||
|   'attachments': instance.attachments.map((e) => e.toJson()).toList(), |   'attachments': instance.attachments.map((e) => e.toJson()).toList(), | ||||||
|   'publisher': instance.publisher.toJson(), |   'publisher': instance.publisher.toJson(), | ||||||
|   'reactions_count': instance.reactionsCount, |   'reactions_count': instance.reactionsCount, | ||||||
|  |   'reactions_made': instance.reactionsMade, | ||||||
|   'reactions': instance.reactions, |   'reactions': instance.reactions, | ||||||
|   'tags': instance.tags.map((e) => e.toJson()).toList(), |   'tags': instance.tags.map((e) => e.toJson()).toList(), | ||||||
|   'categories': instance.categories.map((e) => e.toJson()).toList(), |   'categories': instance.categories.map((e) => e.toJson()).toList(), | ||||||
|   | |||||||
| @@ -1,13 +1,14 @@ | |||||||
| import 'package:island/pods/userinfo.dart'; | import 'dart:async'; | ||||||
| import 'package:island/screens/chat/chat.dart'; | import 'dart:developer'; | ||||||
|  | import 'dart:io'; | ||||||
|  | import 'package:flutter/foundation.dart'; | ||||||
|  | import 'package:flutter_webrtc/flutter_webrtc.dart'; | ||||||
| import 'package:island/widgets/chat/call_button.dart'; | import 'package:island/widgets/chat/call_button.dart'; | ||||||
| import 'package:livekit_client/livekit_client.dart'; | import 'package:livekit_client/livekit_client.dart'; | ||||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | import 'package:freezed_annotation/freezed_annotation.dart'; | ||||||
| import 'dart:async'; |  | ||||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||||
| import 'package:island/pods/network.dart'; | import 'package:island/pods/network.dart'; | ||||||
| import 'package:island/models/chat.dart'; | import 'package:island/models/chat.dart'; | ||||||
| import 'package:island/pods/websocket.dart'; |  | ||||||
|  |  | ||||||
| part 'call.g.dart'; | part 'call.g.dart'; | ||||||
| part 'call.freezed.dart'; | part 'call.freezed.dart'; | ||||||
| @@ -27,6 +28,7 @@ sealed class CallState with _$CallState { | |||||||
|     required bool isMicrophoneEnabled, |     required bool isMicrophoneEnabled, | ||||||
|     required bool isCameraEnabled, |     required bool isCameraEnabled, | ||||||
|     required bool isScreenSharing, |     required bool isScreenSharing, | ||||||
|  |     required bool isSpeakerphone, | ||||||
|     @Default(Duration(seconds: 0)) Duration duration, |     @Default(Duration(seconds: 0)) Duration duration, | ||||||
|     String? error, |     String? error, | ||||||
|   }) = _CallState; |   }) = _CallState; | ||||||
| @@ -42,7 +44,8 @@ sealed class CallParticipantLive with _$CallParticipantLive { | |||||||
|   }) = _CallParticipantLive; |   }) = _CallParticipantLive; | ||||||
|  |  | ||||||
|   bool get isSpeaking => remoteParticipant.isSpeaking; |   bool get isSpeaking => remoteParticipant.isSpeaking; | ||||||
|   bool get isMuted => remoteParticipant.isMuted; |   bool get isMuted => | ||||||
|  |       remoteParticipant.isMuted || !remoteParticipant.isMicrophoneEnabled(); | ||||||
|   bool get isScreenSharing => remoteParticipant.isScreenShareEnabled(); |   bool get isScreenSharing => remoteParticipant.isScreenShareEnabled(); | ||||||
|   bool get isScreenSharingWithAudio => |   bool get isScreenSharingWithAudio => | ||||||
|       remoteParticipant.isScreenShareAudioEnabled(); |       remoteParticipant.isScreenShareAudioEnabled(); | ||||||
| @@ -57,13 +60,14 @@ class CallNotifier extends _$CallNotifier { | |||||||
|   LocalParticipant? _localParticipant; |   LocalParticipant? _localParticipant; | ||||||
|   List<CallParticipantLive> _participants = []; |   List<CallParticipantLive> _participants = []; | ||||||
|   final Map<String, CallParticipant> _participantInfoByIdentity = {}; |   final Map<String, CallParticipant> _participantInfoByIdentity = {}; | ||||||
|   StreamSubscription? _wsSubscription; |  | ||||||
|   EventsListener? _roomListener; |   EventsListener? _roomListener; | ||||||
|  |  | ||||||
|   List<CallParticipantLive> get participants => |   List<CallParticipantLive> get participants => | ||||||
|       List.unmodifiable(_participants); |       List.unmodifiable(_participants); | ||||||
|   LocalParticipant? get localParticipant => _localParticipant; |   LocalParticipant? get localParticipant => _localParticipant; | ||||||
|  |  | ||||||
|  |   Map<String, double> participantsVolumes = {}; | ||||||
|  |  | ||||||
|   Timer? _durationTimer; |   Timer? _durationTimer; | ||||||
|  |  | ||||||
|   Room? get room => _room; |   Room? get room => _room; | ||||||
| @@ -71,36 +75,15 @@ class CallNotifier extends _$CallNotifier { | |||||||
|   @override |   @override | ||||||
|   CallState build() { |   CallState build() { | ||||||
|     // Subscribe to websocket updates |     // Subscribe to websocket updates | ||||||
|     _subscribeToParticipantsUpdate(); |  | ||||||
|     return const CallState( |     return const CallState( | ||||||
|       isConnected: false, |       isConnected: false, | ||||||
|       isMicrophoneEnabled: true, |       isMicrophoneEnabled: true, | ||||||
|       isCameraEnabled: false, |       isCameraEnabled: false, | ||||||
|       isScreenSharing: false, |       isScreenSharing: false, | ||||||
|  |       isSpeakerphone: true, | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void _subscribeToParticipantsUpdate() { |  | ||||||
|     // Only subscribe once |  | ||||||
|     if (_wsSubscription != null) return; |  | ||||||
|     final ws = ref.read(websocketProvider); |  | ||||||
|     _wsSubscription = ws.dataStream.listen((packet) { |  | ||||||
|       if (packet.type == 'call.participants.update' && packet.data != null) { |  | ||||||
|         final participantsData = packet.data!["participants"]; |  | ||||||
|         if (participantsData is List) { |  | ||||||
|           final parsed = |  | ||||||
|               participantsData |  | ||||||
|                   .map( |  | ||||||
|                     (e) => |  | ||||||
|                         CallParticipant.fromJson(Map<String, dynamic>.from(e)), |  | ||||||
|                   ) |  | ||||||
|                   .toList(); |  | ||||||
|           _updateLiveParticipants(parsed); |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   void _initRoomListeners() { |   void _initRoomListeners() { | ||||||
|     if (_room == null) return; |     if (_room == null) return; | ||||||
|     _roomListener?.dispose(); |     _roomListener?.dispose(); | ||||||
| @@ -143,8 +126,6 @@ class CallNotifier extends _$CallNotifier { | |||||||
|               identity: remote.identity, |               identity: remote.identity, | ||||||
|               name: remote.identity, |               name: remote.identity, | ||||||
|               joinedAt: DateTime.now(), |               joinedAt: DateTime.now(), | ||||||
|               accountId: null, |  | ||||||
|               profile: null, |  | ||||||
|             ); |             ); | ||||||
|         return CallParticipantLive( |         return CallParticipantLive( | ||||||
|           participant: match, |           participant: match, | ||||||
| @@ -169,16 +150,12 @@ class CallNotifier extends _$CallNotifier { | |||||||
|       if (idx != -1) return participants[idx]; |       if (idx != -1) return participants[idx]; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     final userInfo = ref.read(userInfoProvider); |  | ||||||
|     final roomIdentity = ref.read(chatroomIdentityProvider(_roomId)); |  | ||||||
|     // Otherwise, use info from the identity map or fallback to minimal |     // Otherwise, use info from the identity map or fallback to minimal | ||||||
|     return _participantInfoByIdentity[_localParticipant!.identity] ?? |     return _participantInfoByIdentity[_localParticipant!.identity] ?? | ||||||
|         CallParticipant( |         CallParticipant( | ||||||
|           identity: _localParticipant!.identity, |           identity: _localParticipant!.identity, | ||||||
|           name: _localParticipant!.identity, |           name: _localParticipant!.identity, | ||||||
|           joinedAt: DateTime.now(), |           joinedAt: DateTime.now(), | ||||||
|           accountId: userInfo.value?.id, |  | ||||||
|           profile: roomIdentity.value, |  | ||||||
|         ); |         ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -205,6 +182,7 @@ class CallNotifier extends _$CallNotifier { | |||||||
|           remoteParticipant: _localParticipant!, |           remoteParticipant: _localParticipant!, | ||||||
|         ), |         ), | ||||||
|       ); |       ); | ||||||
|  |       state = state.copyWith(); | ||||||
|     } |     } | ||||||
|     // Add remote participants |     // Add remote participants | ||||||
|     _participants.addAll( |     _participants.addAll( | ||||||
| @@ -233,7 +211,13 @@ class CallNotifier extends _$CallNotifier { | |||||||
|  |  | ||||||
|   Future<void> joinRoom(String roomId) async { |   Future<void> joinRoom(String roomId) async { | ||||||
|     if (_roomId == roomId && _room != null) { |     if (_roomId == roomId && _room != null) { | ||||||
|  |       log('[Call] Call skipped. Already has data'); | ||||||
|       return; |       return; | ||||||
|  |     } else if (_room != null) { | ||||||
|  |       if (!_room!.isDisposed && | ||||||
|  |           _room!.connectionState != ConnectionState.disconnected) { | ||||||
|  |         throw Exception('Call already connected'); | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|     _roomId = roomId; |     _roomId = roomId; | ||||||
|     if (_room != null) { |     if (_room != null) { | ||||||
| @@ -264,7 +248,8 @@ class CallNotifier extends _$CallNotifier { | |||||||
|             duration: Duration( |             duration: Duration( | ||||||
|               milliseconds: |               milliseconds: | ||||||
|                   (DateTime.now().millisecondsSinceEpoch - |                   (DateTime.now().millisecondsSinceEpoch - | ||||||
|                       (ongoingCall?.createdAt.millisecondsSinceEpoch ?? 0)), |                       (ongoingCall?.createdAt.millisecondsSinceEpoch ?? | ||||||
|  |                           DateTime.now().millisecondsSinceEpoch)), | ||||||
|             ), |             ), | ||||||
|           ); |           ); | ||||||
|         }); |         }); | ||||||
| @@ -286,6 +271,10 @@ class CallNotifier extends _$CallNotifier { | |||||||
|         _initRoomListeners(); |         _initRoomListeners(); | ||||||
|         _updateLiveParticipants(participants); |         _updateLiveParticipants(participants); | ||||||
|  |  | ||||||
|  |         if (!kIsWeb && (Platform.isIOS || Platform.isAndroid)) { | ||||||
|  |           Hardware.instance.setSpeakerphoneOn(true); | ||||||
|  |         } | ||||||
|  |  | ||||||
|         // Listen for connection updates |         // Listen for connection updates | ||||||
|         _room!.addListener(() { |         _room!.addListener(() { | ||||||
|           state = state.copyWith( |           state = state.copyWith( | ||||||
| @@ -318,6 +307,7 @@ class CallNotifier extends _$CallNotifier { | |||||||
|           stopOnMute: autostop, |           stopOnMute: autostop, | ||||||
|         ); |         ); | ||||||
|       } |       } | ||||||
|  |       state = state.copyWith(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -326,6 +316,7 @@ class CallNotifier extends _$CallNotifier { | |||||||
|       final target = !_localParticipant!.isCameraEnabled(); |       final target = !_localParticipant!.isCameraEnabled(); | ||||||
|       state = state.copyWith(isCameraEnabled: target); |       state = state.copyWith(isCameraEnabled: target); | ||||||
|       await _localParticipant!.setCameraEnabled(target); |       await _localParticipant!.setCameraEnabled(target); | ||||||
|  |       state = state.copyWith(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -334,9 +325,16 @@ class CallNotifier extends _$CallNotifier { | |||||||
|       final target = !_localParticipant!.isScreenShareEnabled(); |       final target = !_localParticipant!.isScreenShareEnabled(); | ||||||
|       state = state.copyWith(isScreenSharing: target); |       state = state.copyWith(isScreenSharing: target); | ||||||
|       await _localParticipant!.setScreenShareEnabled(target); |       await _localParticipant!.setScreenShareEnabled(target); | ||||||
|  |       state = state.copyWith(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   Future<void> toggleSpeakerphone() async { | ||||||
|  |     state = state.copyWith(isSpeakerphone: !state.isSpeakerphone); | ||||||
|  |     await Hardware.instance.setSpeakerphoneOn(state.isSpeakerphone); | ||||||
|  |     state = state.copyWith(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   Future<void> disconnect() async { |   Future<void> disconnect() async { | ||||||
|     if (_room != null) { |     if (_room != null) { | ||||||
|       await _room!.disconnect(); |       await _room!.disconnect(); | ||||||
| @@ -349,11 +347,39 @@ class CallNotifier extends _$CallNotifier { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   void setParticipantVolume(CallParticipantLive live, double volume) { | ||||||
|  |     if (participantsVolumes[live.remoteParticipant.sid] == null) { | ||||||
|  |       participantsVolumes[live.remoteParticipant.sid] = 1; | ||||||
|  |     } | ||||||
|  |     Helper.setVolume( | ||||||
|  |       volume, | ||||||
|  |       live | ||||||
|  |           .remoteParticipant | ||||||
|  |           .audioTrackPublications | ||||||
|  |           .first | ||||||
|  |           .track! | ||||||
|  |           .mediaStreamTrack, | ||||||
|  |     ); | ||||||
|  |     participantsVolumes[live.remoteParticipant.sid] = volume; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   double getParticipantVolume(CallParticipantLive live) { | ||||||
|  |     return participantsVolumes[live.remoteParticipant.sid] ?? 1; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   void dispose() { |   void dispose() { | ||||||
|     _wsSubscription?.cancel(); |     state = state.copyWith( | ||||||
|  |       error: null, | ||||||
|  |       isConnected: false, | ||||||
|  |       isMicrophoneEnabled: false, | ||||||
|  |       isCameraEnabled: false, | ||||||
|  |       isScreenSharing: false, | ||||||
|  |     ); | ||||||
|     _roomListener?.dispose(); |     _roomListener?.dispose(); | ||||||
|     _room?.removeListener(_onRoomChange); |     _room?.removeListener(_onRoomChange); | ||||||
|     _room?.dispose(); |     _room?.dispose(); | ||||||
|     _durationTimer?.cancel(); |     _durationTimer?.cancel(); | ||||||
|  |     _roomId = null; | ||||||
|  |     participantsVolumes = {}; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -12,9 +12,9 @@ part of 'call.dart'; | |||||||
| // dart format off | // dart format off | ||||||
| T _$identity<T>(T value) => value; | T _$identity<T>(T value) => value; | ||||||
| /// @nodoc | /// @nodoc | ||||||
| mixin _$CallState { | mixin _$CallState implements DiagnosticableTreeMixin { | ||||||
|  |  | ||||||
|  bool get isConnected; bool get isMicrophoneEnabled; bool get isCameraEnabled; bool get isScreenSharing; Duration get duration; String? get error; |  bool get isConnected; bool get isMicrophoneEnabled; bool get isCameraEnabled; bool get isScreenSharing; bool get isSpeakerphone; Duration get duration; String? get error; | ||||||
| /// Create a copy of CallState | /// Create a copy of CallState | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @JsonKey(includeFromJson: false, includeToJson: false) | @JsonKey(includeFromJson: false, includeToJson: false) | ||||||
| @@ -22,19 +22,25 @@ mixin _$CallState { | |||||||
| $CallStateCopyWith<CallState> get copyWith => _$CallStateCopyWithImpl<CallState>(this as CallState, _$identity); | $CallStateCopyWith<CallState> get copyWith => _$CallStateCopyWithImpl<CallState>(this as CallState, _$identity); | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @override | ||||||
|  | void debugFillProperties(DiagnosticPropertiesBuilder properties) { | ||||||
|  |   properties | ||||||
|  |     ..add(DiagnosticsProperty('type', 'CallState')) | ||||||
|  |     ..add(DiagnosticsProperty('isConnected', isConnected))..add(DiagnosticsProperty('isMicrophoneEnabled', isMicrophoneEnabled))..add(DiagnosticsProperty('isCameraEnabled', isCameraEnabled))..add(DiagnosticsProperty('isScreenSharing', isScreenSharing))..add(DiagnosticsProperty('isSpeakerphone', isSpeakerphone))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('error', error)); | ||||||
|  | } | ||||||
|  |  | ||||||
| @override | @override | ||||||
| bool operator ==(Object other) { | bool operator ==(Object other) { | ||||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.error, error) || other.error == error)); |   return identical(this, other) || (other.runtimeType == runtimeType&&other is CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.isSpeakerphone, isSpeakerphone) || other.isSpeakerphone == isSpeakerphone)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.error, error) || other.error == error)); | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| @override | @override | ||||||
| int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,duration,error); | int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,isSpeakerphone,duration,error); | ||||||
|  |  | ||||||
| @override | @override | ||||||
| String toString() { | String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { | ||||||
|   return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, duration: $duration, error: $error)'; |   return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, isSpeakerphone: $isSpeakerphone, duration: $duration, error: $error)'; | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -45,7 +51,7 @@ abstract mixin class $CallStateCopyWith<$Res>  { | |||||||
|   factory $CallStateCopyWith(CallState value, $Res Function(CallState) _then) = _$CallStateCopyWithImpl; |   factory $CallStateCopyWith(CallState value, $Res Function(CallState) _then) = _$CallStateCopyWithImpl; | ||||||
| @useResult | @useResult | ||||||
| $Res call({ | $Res call({ | ||||||
|  bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, Duration duration, String? error |  bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, String? error | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -62,12 +68,13 @@ class _$CallStateCopyWithImpl<$Res> | |||||||
|  |  | ||||||
| /// Create a copy of CallState | /// Create a copy of CallState | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @pragma('vm:prefer-inline') @override $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? duration = null,Object? error = freezed,}) { | @pragma('vm:prefer-inline') @override $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? isSpeakerphone = null,Object? duration = null,Object? error = freezed,}) { | ||||||
|   return _then(_self.copyWith( |   return _then(_self.copyWith( | ||||||
| isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable | isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable | ||||||
| as bool,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable | as bool,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable | ||||||
| as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCameraEnabled // ignore: cast_nullable_to_non_nullable | as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCameraEnabled // ignore: cast_nullable_to_non_nullable | ||||||
| as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable | as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable | ||||||
|  | as bool,isSpeakerphone: null == isSpeakerphone ? _self.isSpeakerphone : isSpeakerphone // ignore: cast_nullable_to_non_nullable | ||||||
| as bool,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable | as bool,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable | ||||||
| as Duration,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable | as Duration,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable | ||||||
| as String?, | as String?, | ||||||
| @@ -152,10 +159,10 @@ return $default(_that);case _: | |||||||
| /// } | /// } | ||||||
| /// ``` | /// ``` | ||||||
|  |  | ||||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isConnected,  bool isMicrophoneEnabled,  bool isCameraEnabled,  bool isScreenSharing,  Duration duration,  String? error)?  $default,{required TResult orElse(),}) {final _that = this; | @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isConnected,  bool isMicrophoneEnabled,  bool isCameraEnabled,  bool isScreenSharing,  bool isSpeakerphone,  Duration duration,  String? error)?  $default,{required TResult orElse(),}) {final _that = this; | ||||||
| switch (_that) { | switch (_that) { | ||||||
| case _CallState() when $default != null: | case _CallState() when $default != null: | ||||||
| return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.duration,_that.error);case _: | return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.error);case _: | ||||||
|   return orElse(); |   return orElse(); | ||||||
|  |  | ||||||
| } | } | ||||||
| @@ -173,10 +180,10 @@ return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnable | |||||||
| /// } | /// } | ||||||
| /// ``` | /// ``` | ||||||
|  |  | ||||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isConnected,  bool isMicrophoneEnabled,  bool isCameraEnabled,  bool isScreenSharing,  Duration duration,  String? error)  $default,) {final _that = this; | @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isConnected,  bool isMicrophoneEnabled,  bool isCameraEnabled,  bool isScreenSharing,  bool isSpeakerphone,  Duration duration,  String? error)  $default,) {final _that = this; | ||||||
| switch (_that) { | switch (_that) { | ||||||
| case _CallState(): | case _CallState(): | ||||||
| return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.duration,_that.error);} | return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.error);} | ||||||
| } | } | ||||||
| /// A variant of `when` that fallback to returning `null` | /// A variant of `when` that fallback to returning `null` | ||||||
| /// | /// | ||||||
| @@ -190,10 +197,10 @@ return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnable | |||||||
| /// } | /// } | ||||||
| /// ``` | /// ``` | ||||||
|  |  | ||||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isConnected,  bool isMicrophoneEnabled,  bool isCameraEnabled,  bool isScreenSharing,  Duration duration,  String? error)?  $default,) {final _that = this; | @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isConnected,  bool isMicrophoneEnabled,  bool isCameraEnabled,  bool isScreenSharing,  bool isSpeakerphone,  Duration duration,  String? error)?  $default,) {final _that = this; | ||||||
| switch (_that) { | switch (_that) { | ||||||
| case _CallState() when $default != null: | case _CallState() when $default != null: | ||||||
| return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.duration,_that.error);case _: | return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.error);case _: | ||||||
|   return null; |   return null; | ||||||
|  |  | ||||||
| } | } | ||||||
| @@ -204,14 +211,15 @@ return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnable | |||||||
| /// @nodoc | /// @nodoc | ||||||
|  |  | ||||||
|  |  | ||||||
| class _CallState implements CallState { | class _CallState with DiagnosticableTreeMixin implements CallState { | ||||||
|   const _CallState({required this.isConnected, required this.isMicrophoneEnabled, required this.isCameraEnabled, required this.isScreenSharing, this.duration = const Duration(seconds: 0), this.error}); |   const _CallState({required this.isConnected, required this.isMicrophoneEnabled, required this.isCameraEnabled, required this.isScreenSharing, required this.isSpeakerphone, this.duration = const Duration(seconds: 0), this.error}); | ||||||
|    |    | ||||||
|  |  | ||||||
| @override final  bool isConnected; | @override final  bool isConnected; | ||||||
| @override final  bool isMicrophoneEnabled; | @override final  bool isMicrophoneEnabled; | ||||||
| @override final  bool isCameraEnabled; | @override final  bool isCameraEnabled; | ||||||
| @override final  bool isScreenSharing; | @override final  bool isScreenSharing; | ||||||
|  | @override final  bool isSpeakerphone; | ||||||
| @override@JsonKey() final  Duration duration; | @override@JsonKey() final  Duration duration; | ||||||
| @override final  String? error; | @override final  String? error; | ||||||
|  |  | ||||||
| @@ -222,19 +230,25 @@ class _CallState implements CallState { | |||||||
| _$CallStateCopyWith<_CallState> get copyWith => __$CallStateCopyWithImpl<_CallState>(this, _$identity); | _$CallStateCopyWith<_CallState> get copyWith => __$CallStateCopyWithImpl<_CallState>(this, _$identity); | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @override | ||||||
|  | void debugFillProperties(DiagnosticPropertiesBuilder properties) { | ||||||
|  |   properties | ||||||
|  |     ..add(DiagnosticsProperty('type', 'CallState')) | ||||||
|  |     ..add(DiagnosticsProperty('isConnected', isConnected))..add(DiagnosticsProperty('isMicrophoneEnabled', isMicrophoneEnabled))..add(DiagnosticsProperty('isCameraEnabled', isCameraEnabled))..add(DiagnosticsProperty('isScreenSharing', isScreenSharing))..add(DiagnosticsProperty('isSpeakerphone', isSpeakerphone))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('error', error)); | ||||||
|  | } | ||||||
|  |  | ||||||
| @override | @override | ||||||
| bool operator ==(Object other) { | bool operator ==(Object other) { | ||||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.error, error) || other.error == error)); |   return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.isSpeakerphone, isSpeakerphone) || other.isSpeakerphone == isSpeakerphone)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.error, error) || other.error == error)); | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| @override | @override | ||||||
| int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,duration,error); | int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,isSpeakerphone,duration,error); | ||||||
|  |  | ||||||
| @override | @override | ||||||
| String toString() { | String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { | ||||||
|   return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, duration: $duration, error: $error)'; |   return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, isSpeakerphone: $isSpeakerphone, duration: $duration, error: $error)'; | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -245,7 +259,7 @@ abstract mixin class _$CallStateCopyWith<$Res> implements $CallStateCopyWith<$Re | |||||||
|   factory _$CallStateCopyWith(_CallState value, $Res Function(_CallState) _then) = __$CallStateCopyWithImpl; |   factory _$CallStateCopyWith(_CallState value, $Res Function(_CallState) _then) = __$CallStateCopyWithImpl; | ||||||
| @override @useResult | @override @useResult | ||||||
| $Res call({ | $Res call({ | ||||||
|  bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, Duration duration, String? error |  bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, String? error | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -262,12 +276,13 @@ class __$CallStateCopyWithImpl<$Res> | |||||||
|  |  | ||||||
| /// Create a copy of CallState | /// Create a copy of CallState | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @override @pragma('vm:prefer-inline') $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? duration = null,Object? error = freezed,}) { | @override @pragma('vm:prefer-inline') $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? isSpeakerphone = null,Object? duration = null,Object? error = freezed,}) { | ||||||
|   return _then(_CallState( |   return _then(_CallState( | ||||||
| isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable | isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable | ||||||
| as bool,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable | as bool,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable | ||||||
| as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCameraEnabled // ignore: cast_nullable_to_non_nullable | as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCameraEnabled // ignore: cast_nullable_to_non_nullable | ||||||
| as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable | as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable | ||||||
|  | as bool,isSpeakerphone: null == isSpeakerphone ? _self.isSpeakerphone : isSpeakerphone // ignore: cast_nullable_to_non_nullable | ||||||
| as bool,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable | as bool,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable | ||||||
| as Duration,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable | as Duration,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable | ||||||
| as String?, | as String?, | ||||||
| @@ -278,7 +293,7 @@ as String?, | |||||||
| } | } | ||||||
|  |  | ||||||
| /// @nodoc | /// @nodoc | ||||||
| mixin _$CallParticipantLive { | mixin _$CallParticipantLive implements DiagnosticableTreeMixin { | ||||||
|  |  | ||||||
|  CallParticipant get participant; Participant get remoteParticipant; |  CallParticipant get participant; Participant get remoteParticipant; | ||||||
| /// Create a copy of CallParticipantLive | /// Create a copy of CallParticipantLive | ||||||
| @@ -288,6 +303,12 @@ mixin _$CallParticipantLive { | |||||||
| $CallParticipantLiveCopyWith<CallParticipantLive> get copyWith => _$CallParticipantLiveCopyWithImpl<CallParticipantLive>(this as CallParticipantLive, _$identity); | $CallParticipantLiveCopyWith<CallParticipantLive> get copyWith => _$CallParticipantLiveCopyWithImpl<CallParticipantLive>(this as CallParticipantLive, _$identity); | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @override | ||||||
|  | void debugFillProperties(DiagnosticPropertiesBuilder properties) { | ||||||
|  |   properties | ||||||
|  |     ..add(DiagnosticsProperty('type', 'CallParticipantLive')) | ||||||
|  |     ..add(DiagnosticsProperty('participant', participant))..add(DiagnosticsProperty('remoteParticipant', remoteParticipant)); | ||||||
|  | } | ||||||
|  |  | ||||||
| @override | @override | ||||||
| bool operator ==(Object other) { | bool operator ==(Object other) { | ||||||
| @@ -299,7 +320,7 @@ bool operator ==(Object other) { | |||||||
| int get hashCode => Object.hash(runtimeType,participant,remoteParticipant); | int get hashCode => Object.hash(runtimeType,participant,remoteParticipant); | ||||||
|  |  | ||||||
| @override | @override | ||||||
| String toString() { | String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { | ||||||
|   return 'CallParticipantLive(participant: $participant, remoteParticipant: $remoteParticipant)'; |   return 'CallParticipantLive(participant: $participant, remoteParticipant: $remoteParticipant)'; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -475,7 +496,7 @@ return $default(_that.participant,_that.remoteParticipant);case _: | |||||||
| /// @nodoc | /// @nodoc | ||||||
|  |  | ||||||
|  |  | ||||||
| class _CallParticipantLive extends CallParticipantLive { | class _CallParticipantLive extends CallParticipantLive with DiagnosticableTreeMixin { | ||||||
|   const _CallParticipantLive({required this.participant, required this.remoteParticipant}): super._(); |   const _CallParticipantLive({required this.participant, required this.remoteParticipant}): super._(); | ||||||
|    |    | ||||||
|  |  | ||||||
| @@ -489,6 +510,12 @@ class _CallParticipantLive extends CallParticipantLive { | |||||||
| _$CallParticipantLiveCopyWith<_CallParticipantLive> get copyWith => __$CallParticipantLiveCopyWithImpl<_CallParticipantLive>(this, _$identity); | _$CallParticipantLiveCopyWith<_CallParticipantLive> get copyWith => __$CallParticipantLiveCopyWithImpl<_CallParticipantLive>(this, _$identity); | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @override | ||||||
|  | void debugFillProperties(DiagnosticPropertiesBuilder properties) { | ||||||
|  |   properties | ||||||
|  |     ..add(DiagnosticsProperty('type', 'CallParticipantLive')) | ||||||
|  |     ..add(DiagnosticsProperty('participant', participant))..add(DiagnosticsProperty('remoteParticipant', remoteParticipant)); | ||||||
|  | } | ||||||
|  |  | ||||||
| @override | @override | ||||||
| bool operator ==(Object other) { | bool operator ==(Object other) { | ||||||
| @@ -500,7 +527,7 @@ bool operator ==(Object other) { | |||||||
| int get hashCode => Object.hash(runtimeType,participant,remoteParticipant); | int get hashCode => Object.hash(runtimeType,participant,remoteParticipant); | ||||||
|  |  | ||||||
| @override | @override | ||||||
| String toString() { | String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { | ||||||
|   return 'CallParticipantLive(participant: $participant, remoteParticipant: $remoteParticipant)'; |   return 'CallParticipantLive(participant: $participant, remoteParticipant: $remoteParticipant)'; | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -6,7 +6,7 @@ part of 'call.dart'; | |||||||
| // RiverpodGenerator | // RiverpodGenerator | ||||||
| // ************************************************************************** | // ************************************************************************** | ||||||
|  |  | ||||||
| String _$callNotifierHash() => r'107174cd6cfab6bfafe44f8c4a72a67bcb93217b'; | String _$callNotifierHash() => r'18fb807f067eecd3ea42631c1426c3e5f1fb4280'; | ||||||
|  |  | ||||||
| /// See also [CallNotifier]. | /// See also [CallNotifier]. | ||||||
| @ProviderFor(CallNotifier) | @ProviderFor(CallNotifier) | ||||||
|   | |||||||
							
								
								
									
										38
									
								
								lib/pods/translate.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								lib/pods/translate.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  | import 'dart:developer'; | ||||||
|  |  | ||||||
|  | import 'package:freezed_annotation/freezed_annotation.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
|  | import 'package:island/pods/network.dart'; | ||||||
|  | import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||||
|  | import 'package:flutter_langdetect/flutter_langdetect.dart' as langdetect; | ||||||
|  |  | ||||||
|  | part 'translate.freezed.dart'; | ||||||
|  | part 'translate.g.dart'; | ||||||
|  |  | ||||||
|  | @freezed | ||||||
|  | sealed class TranslateQuery with _$TranslateQuery { | ||||||
|  |   const factory TranslateQuery({required String text, required String lang}) = | ||||||
|  |       _TranslateQuery; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @riverpod | ||||||
|  | Future<String> translateString(Ref ref, TranslateQuery query) async { | ||||||
|  |   final client = ref.watch(apiClientProvider); | ||||||
|  |   final response = await client.post( | ||||||
|  |     '/sphere/translate', | ||||||
|  |     queryParameters: {'to': query.lang}, | ||||||
|  |     data: jsonEncode(query.text), | ||||||
|  |   ); | ||||||
|  |   return response.data as String; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @riverpod | ||||||
|  | String? detectStringLanguage(Ref ref, String text) { | ||||||
|  |   try { | ||||||
|  |     return langdetect.detectLangs(text).firstOrNull?.lang; | ||||||
|  |   } catch (err) { | ||||||
|  |     log('[Language] Unable to detect text\'s language: $text'); | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										268
									
								
								lib/pods/translate.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										268
									
								
								lib/pods/translate.freezed.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,268 @@ | |||||||
|  | // 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 'translate.dart'; | ||||||
|  |  | ||||||
|  | // ************************************************************************** | ||||||
|  | // FreezedGenerator | ||||||
|  | // ************************************************************************** | ||||||
|  |  | ||||||
|  | // dart format off | ||||||
|  | T _$identity<T>(T value) => value; | ||||||
|  | /// @nodoc | ||||||
|  | mixin _$TranslateQuery { | ||||||
|  |  | ||||||
|  |  String get text; String get lang; | ||||||
|  | /// Create a copy of TranslateQuery | ||||||
|  | /// with the given fields replaced by the non-null parameter values. | ||||||
|  | @JsonKey(includeFromJson: false, includeToJson: false) | ||||||
|  | @pragma('vm:prefer-inline') | ||||||
|  | $TranslateQueryCopyWith<TranslateQuery> get copyWith => _$TranslateQueryCopyWithImpl<TranslateQuery>(this as TranslateQuery, _$identity); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @override | ||||||
|  | bool operator ==(Object other) { | ||||||
|  |   return identical(this, other) || (other.runtimeType == runtimeType&&other is TranslateQuery&&(identical(other.text, text) || other.text == text)&&(identical(other.lang, lang) || other.lang == lang)); | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @override | ||||||
|  | int get hashCode => Object.hash(runtimeType,text,lang); | ||||||
|  |  | ||||||
|  | @override | ||||||
|  | String toString() { | ||||||
|  |   return 'TranslateQuery(text: $text, lang: $lang)'; | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// @nodoc | ||||||
|  | abstract mixin class $TranslateQueryCopyWith<$Res>  { | ||||||
|  |   factory $TranslateQueryCopyWith(TranslateQuery value, $Res Function(TranslateQuery) _then) = _$TranslateQueryCopyWithImpl; | ||||||
|  | @useResult | ||||||
|  | $Res call({ | ||||||
|  |  String text, String lang | ||||||
|  | }); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | } | ||||||
|  | /// @nodoc | ||||||
|  | class _$TranslateQueryCopyWithImpl<$Res> | ||||||
|  |     implements $TranslateQueryCopyWith<$Res> { | ||||||
|  |   _$TranslateQueryCopyWithImpl(this._self, this._then); | ||||||
|  |  | ||||||
|  |   final TranslateQuery _self; | ||||||
|  |   final $Res Function(TranslateQuery) _then; | ||||||
|  |  | ||||||
|  | /// Create a copy of TranslateQuery | ||||||
|  | /// with the given fields replaced by the non-null parameter values. | ||||||
|  | @pragma('vm:prefer-inline') @override $Res call({Object? text = null,Object? lang = null,}) { | ||||||
|  |   return _then(_self.copyWith( | ||||||
|  | text: null == text ? _self.text : text // ignore: cast_nullable_to_non_nullable | ||||||
|  | as String,lang: null == lang ? _self.lang : lang // ignore: cast_nullable_to_non_nullable | ||||||
|  | as String, | ||||||
|  |   )); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /// Adds pattern-matching-related methods to [TranslateQuery]. | ||||||
|  | extension TranslateQueryPatterns on TranslateQuery { | ||||||
|  | /// 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( _TranslateQuery value)?  $default,{required TResult orElse(),}){ | ||||||
|  | final _that = this; | ||||||
|  | switch (_that) { | ||||||
|  | case _TranslateQuery() 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( _TranslateQuery value)  $default,){ | ||||||
|  | final _that = this; | ||||||
|  | switch (_that) { | ||||||
|  | case _TranslateQuery(): | ||||||
|  | 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( _TranslateQuery value)?  $default,){ | ||||||
|  | final _that = this; | ||||||
|  | switch (_that) { | ||||||
|  | case _TranslateQuery() 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 text,  String lang)?  $default,{required TResult orElse(),}) {final _that = this; | ||||||
|  | switch (_that) { | ||||||
|  | case _TranslateQuery() when $default != null: | ||||||
|  | return $default(_that.text,_that.lang);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 text,  String lang)  $default,) {final _that = this; | ||||||
|  | switch (_that) { | ||||||
|  | case _TranslateQuery(): | ||||||
|  | return $default(_that.text,_that.lang);} | ||||||
|  | } | ||||||
|  | /// 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 text,  String lang)?  $default,) {final _that = this; | ||||||
|  | switch (_that) { | ||||||
|  | case _TranslateQuery() when $default != null: | ||||||
|  | return $default(_that.text,_that.lang);case _: | ||||||
|  |   return null; | ||||||
|  |  | ||||||
|  | } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// @nodoc | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class _TranslateQuery implements TranslateQuery { | ||||||
|  |   const _TranslateQuery({required this.text, required this.lang}); | ||||||
|  |    | ||||||
|  |  | ||||||
|  | @override final  String text; | ||||||
|  | @override final  String lang; | ||||||
|  |  | ||||||
|  | /// Create a copy of TranslateQuery | ||||||
|  | /// with the given fields replaced by the non-null parameter values. | ||||||
|  | @override @JsonKey(includeFromJson: false, includeToJson: false) | ||||||
|  | @pragma('vm:prefer-inline') | ||||||
|  | _$TranslateQueryCopyWith<_TranslateQuery> get copyWith => __$TranslateQueryCopyWithImpl<_TranslateQuery>(this, _$identity); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @override | ||||||
|  | bool operator ==(Object other) { | ||||||
|  |   return identical(this, other) || (other.runtimeType == runtimeType&&other is _TranslateQuery&&(identical(other.text, text) || other.text == text)&&(identical(other.lang, lang) || other.lang == lang)); | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @override | ||||||
|  | int get hashCode => Object.hash(runtimeType,text,lang); | ||||||
|  |  | ||||||
|  | @override | ||||||
|  | String toString() { | ||||||
|  |   return 'TranslateQuery(text: $text, lang: $lang)'; | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// @nodoc | ||||||
|  | abstract mixin class _$TranslateQueryCopyWith<$Res> implements $TranslateQueryCopyWith<$Res> { | ||||||
|  |   factory _$TranslateQueryCopyWith(_TranslateQuery value, $Res Function(_TranslateQuery) _then) = __$TranslateQueryCopyWithImpl; | ||||||
|  | @override @useResult | ||||||
|  | $Res call({ | ||||||
|  |  String text, String lang | ||||||
|  | }); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | } | ||||||
|  | /// @nodoc | ||||||
|  | class __$TranslateQueryCopyWithImpl<$Res> | ||||||
|  |     implements _$TranslateQueryCopyWith<$Res> { | ||||||
|  |   __$TranslateQueryCopyWithImpl(this._self, this._then); | ||||||
|  |  | ||||||
|  |   final _TranslateQuery _self; | ||||||
|  |   final $Res Function(_TranslateQuery) _then; | ||||||
|  |  | ||||||
|  | /// Create a copy of TranslateQuery | ||||||
|  | /// with the given fields replaced by the non-null parameter values. | ||||||
|  | @override @pragma('vm:prefer-inline') $Res call({Object? text = null,Object? lang = null,}) { | ||||||
|  |   return _then(_TranslateQuery( | ||||||
|  | text: null == text ? _self.text : text // ignore: cast_nullable_to_non_nullable | ||||||
|  | as String,lang: null == lang ? _self.lang : lang // ignore: cast_nullable_to_non_nullable | ||||||
|  | as String, | ||||||
|  |   )); | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // dart format on | ||||||
							
								
								
									
										274
									
								
								lib/pods/translate.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										274
									
								
								lib/pods/translate.g.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,274 @@ | |||||||
|  | // GENERATED CODE - DO NOT MODIFY BY HAND | ||||||
|  |  | ||||||
|  | part of 'translate.dart'; | ||||||
|  |  | ||||||
|  | // ************************************************************************** | ||||||
|  | // RiverpodGenerator | ||||||
|  | // ************************************************************************** | ||||||
|  |  | ||||||
|  | String _$translateStringHash() => r'51d638cf07cbf3ffa9469298f5bd9c667bc0ccb7'; | ||||||
|  |  | ||||||
|  | /// Copied from Dart SDK | ||||||
|  | class _SystemHash { | ||||||
|  |   _SystemHash._(); | ||||||
|  |  | ||||||
|  |   static int combine(int hash, int value) { | ||||||
|  |     // ignore: parameter_assignments | ||||||
|  |     hash = 0x1fffffff & (hash + value); | ||||||
|  |     // ignore: parameter_assignments | ||||||
|  |     hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); | ||||||
|  |     return hash ^ (hash >> 6); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   static int finish(int hash) { | ||||||
|  |     // ignore: parameter_assignments | ||||||
|  |     hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); | ||||||
|  |     // ignore: parameter_assignments | ||||||
|  |     hash = hash ^ (hash >> 11); | ||||||
|  |     return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// See also [translateString]. | ||||||
|  | @ProviderFor(translateString) | ||||||
|  | const translateStringProvider = TranslateStringFamily(); | ||||||
|  |  | ||||||
|  | /// See also [translateString]. | ||||||
|  | class TranslateStringFamily extends Family<AsyncValue<String>> { | ||||||
|  |   /// See also [translateString]. | ||||||
|  |   const TranslateStringFamily(); | ||||||
|  |  | ||||||
|  |   /// See also [translateString]. | ||||||
|  |   TranslateStringProvider call(TranslateQuery query) { | ||||||
|  |     return TranslateStringProvider(query); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   TranslateStringProvider getProviderOverride( | ||||||
|  |     covariant TranslateStringProvider provider, | ||||||
|  |   ) { | ||||||
|  |     return call(provider.query); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   static const Iterable<ProviderOrFamily>? _dependencies = null; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Iterable<ProviderOrFamily>? get dependencies => _dependencies; | ||||||
|  |  | ||||||
|  |   static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Iterable<ProviderOrFamily>? get allTransitiveDependencies => | ||||||
|  |       _allTransitiveDependencies; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? get name => r'translateStringProvider'; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// See also [translateString]. | ||||||
|  | class TranslateStringProvider extends AutoDisposeFutureProvider<String> { | ||||||
|  |   /// See also [translateString]. | ||||||
|  |   TranslateStringProvider(TranslateQuery query) | ||||||
|  |     : this._internal( | ||||||
|  |         (ref) => translateString(ref as TranslateStringRef, query), | ||||||
|  |         from: translateStringProvider, | ||||||
|  |         name: r'translateStringProvider', | ||||||
|  |         debugGetCreateSourceHash: | ||||||
|  |             const bool.fromEnvironment('dart.vm.product') | ||||||
|  |                 ? null | ||||||
|  |                 : _$translateStringHash, | ||||||
|  |         dependencies: TranslateStringFamily._dependencies, | ||||||
|  |         allTransitiveDependencies: | ||||||
|  |             TranslateStringFamily._allTransitiveDependencies, | ||||||
|  |         query: query, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |   TranslateStringProvider._internal( | ||||||
|  |     super._createNotifier, { | ||||||
|  |     required super.name, | ||||||
|  |     required super.dependencies, | ||||||
|  |     required super.allTransitiveDependencies, | ||||||
|  |     required super.debugGetCreateSourceHash, | ||||||
|  |     required super.from, | ||||||
|  |     required this.query, | ||||||
|  |   }) : super.internal(); | ||||||
|  |  | ||||||
|  |   final TranslateQuery query; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Override overrideWith( | ||||||
|  |     FutureOr<String> Function(TranslateStringRef provider) create, | ||||||
|  |   ) { | ||||||
|  |     return ProviderOverride( | ||||||
|  |       origin: this, | ||||||
|  |       override: TranslateStringProvider._internal( | ||||||
|  |         (ref) => create(ref as TranslateStringRef), | ||||||
|  |         from: from, | ||||||
|  |         name: null, | ||||||
|  |         dependencies: null, | ||||||
|  |         allTransitiveDependencies: null, | ||||||
|  |         debugGetCreateSourceHash: null, | ||||||
|  |         query: query, | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   AutoDisposeFutureProviderElement<String> createElement() { | ||||||
|  |     return _TranslateStringProviderElement(this); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   bool operator ==(Object other) { | ||||||
|  |     return other is TranslateStringProvider && other.query == query; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   int get hashCode { | ||||||
|  |     var hash = _SystemHash.combine(0, runtimeType.hashCode); | ||||||
|  |     hash = _SystemHash.combine(hash, query.hashCode); | ||||||
|  |  | ||||||
|  |     return _SystemHash.finish(hash); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||||
|  | // ignore: unused_element | ||||||
|  | mixin TranslateStringRef on AutoDisposeFutureProviderRef<String> { | ||||||
|  |   /// The parameter `query` of this provider. | ||||||
|  |   TranslateQuery get query; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _TranslateStringProviderElement | ||||||
|  |     extends AutoDisposeFutureProviderElement<String> | ||||||
|  |     with TranslateStringRef { | ||||||
|  |   _TranslateStringProviderElement(super.provider); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   TranslateQuery get query => (origin as TranslateStringProvider).query; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | String _$detectStringLanguageHash() => | ||||||
|  |     r'697b68464b3d00927cc43ccc1ba8ba93f2a470ed'; | ||||||
|  |  | ||||||
|  | /// See also [detectStringLanguage]. | ||||||
|  | @ProviderFor(detectStringLanguage) | ||||||
|  | const detectStringLanguageProvider = DetectStringLanguageFamily(); | ||||||
|  |  | ||||||
|  | /// See also [detectStringLanguage]. | ||||||
|  | class DetectStringLanguageFamily extends Family<String?> { | ||||||
|  |   /// See also [detectStringLanguage]. | ||||||
|  |   const DetectStringLanguageFamily(); | ||||||
|  |  | ||||||
|  |   /// See also [detectStringLanguage]. | ||||||
|  |   DetectStringLanguageProvider call(String text) { | ||||||
|  |     return DetectStringLanguageProvider(text); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   DetectStringLanguageProvider getProviderOverride( | ||||||
|  |     covariant DetectStringLanguageProvider provider, | ||||||
|  |   ) { | ||||||
|  |     return call(provider.text); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   static const Iterable<ProviderOrFamily>? _dependencies = null; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Iterable<ProviderOrFamily>? get dependencies => _dependencies; | ||||||
|  |  | ||||||
|  |   static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Iterable<ProviderOrFamily>? get allTransitiveDependencies => | ||||||
|  |       _allTransitiveDependencies; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? get name => r'detectStringLanguageProvider'; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// See also [detectStringLanguage]. | ||||||
|  | class DetectStringLanguageProvider extends AutoDisposeProvider<String?> { | ||||||
|  |   /// See also [detectStringLanguage]. | ||||||
|  |   DetectStringLanguageProvider(String text) | ||||||
|  |     : this._internal( | ||||||
|  |         (ref) => detectStringLanguage(ref as DetectStringLanguageRef, text), | ||||||
|  |         from: detectStringLanguageProvider, | ||||||
|  |         name: r'detectStringLanguageProvider', | ||||||
|  |         debugGetCreateSourceHash: | ||||||
|  |             const bool.fromEnvironment('dart.vm.product') | ||||||
|  |                 ? null | ||||||
|  |                 : _$detectStringLanguageHash, | ||||||
|  |         dependencies: DetectStringLanguageFamily._dependencies, | ||||||
|  |         allTransitiveDependencies: | ||||||
|  |             DetectStringLanguageFamily._allTransitiveDependencies, | ||||||
|  |         text: text, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |   DetectStringLanguageProvider._internal( | ||||||
|  |     super._createNotifier, { | ||||||
|  |     required super.name, | ||||||
|  |     required super.dependencies, | ||||||
|  |     required super.allTransitiveDependencies, | ||||||
|  |     required super.debugGetCreateSourceHash, | ||||||
|  |     required super.from, | ||||||
|  |     required this.text, | ||||||
|  |   }) : super.internal(); | ||||||
|  |  | ||||||
|  |   final String text; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Override overrideWith( | ||||||
|  |     String? Function(DetectStringLanguageRef provider) create, | ||||||
|  |   ) { | ||||||
|  |     return ProviderOverride( | ||||||
|  |       origin: this, | ||||||
|  |       override: DetectStringLanguageProvider._internal( | ||||||
|  |         (ref) => create(ref as DetectStringLanguageRef), | ||||||
|  |         from: from, | ||||||
|  |         name: null, | ||||||
|  |         dependencies: null, | ||||||
|  |         allTransitiveDependencies: null, | ||||||
|  |         debugGetCreateSourceHash: null, | ||||||
|  |         text: text, | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   AutoDisposeProviderElement<String?> createElement() { | ||||||
|  |     return _DetectStringLanguageProviderElement(this); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   bool operator ==(Object other) { | ||||||
|  |     return other is DetectStringLanguageProvider && other.text == text; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   int get hashCode { | ||||||
|  |     var hash = _SystemHash.combine(0, runtimeType.hashCode); | ||||||
|  |     hash = _SystemHash.combine(hash, text.hashCode); | ||||||
|  |  | ||||||
|  |     return _SystemHash.finish(hash); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||||
|  | // ignore: unused_element | ||||||
|  | mixin DetectStringLanguageRef on AutoDisposeProviderRef<String?> { | ||||||
|  |   /// The parameter `text` of this provider. | ||||||
|  |   String get text; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _DetectStringLanguageProviderElement | ||||||
|  |     extends AutoDisposeProviderElement<String?> | ||||||
|  |     with DetectStringLanguageRef { | ||||||
|  |   _DetectStringLanguageProviderElement(super.provider); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String get text => (origin as DetectStringLanguageProvider).text; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 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 | ||||||
| @@ -46,6 +46,10 @@ class WebSocketService { | |||||||
|   final StreamController<WebSocketState> _statusStreamController = |   final StreamController<WebSocketState> _statusStreamController = | ||||||
|       StreamController<WebSocketState>.broadcast(); |       StreamController<WebSocketState>.broadcast(); | ||||||
|   Timer? _reconnectTimer; |   Timer? _reconnectTimer; | ||||||
|  |   Timer? _heartbeatTimer; | ||||||
|  |  | ||||||
|  |   DateTime? _heartbeatAt; | ||||||
|  |   Duration? _heartbeatDelay; | ||||||
|  |  | ||||||
|   Stream<WebSocketPacket> get dataStream => _streamController.stream; |   Stream<WebSocketPacket> get dataStream => _streamController.stream; | ||||||
|   Stream<WebSocketState> get statusStream => _statusStreamController.stream; |   Stream<WebSocketState> get statusStream => _statusStreamController.stream; | ||||||
| @@ -71,6 +75,7 @@ class WebSocketService { | |||||||
|       } |       } | ||||||
|       await _channel!.ready; |       await _channel!.ready; | ||||||
|       _statusStreamController.sink.add(WebSocketState.connected()); |       _statusStreamController.sink.add(WebSocketState.connected()); | ||||||
|  |       _scheduleHeartbeat(); | ||||||
|       _channel!.stream.listen( |       _channel!.stream.listen( | ||||||
|         (data) { |         (data) { | ||||||
|           final dataStr = |           final dataStr = | ||||||
| @@ -80,6 +85,13 @@ class WebSocketService { | |||||||
|           log( |           log( | ||||||
|             "[WebSocket] Received packet: ${packet.type} ${packet.errorMessage}", |             "[WebSocket] Received packet: ${packet.type} ${packet.errorMessage}", | ||||||
|           ); |           ); | ||||||
|  |           if (packet.type == 'pong' && _heartbeatAt != null) { | ||||||
|  |             var now = DateTime.now(); | ||||||
|  |             _heartbeatDelay = now.difference(_heartbeatAt!); | ||||||
|  |             log( | ||||||
|  |               "[WebSocket] Server respond last heartbeat for ${_heartbeatDelay!.inMilliseconds} ms", | ||||||
|  |             ); | ||||||
|  |           } | ||||||
|         }, |         }, | ||||||
|         onDone: () { |         onDone: () { | ||||||
|           log('[WebSocket] Connection closed, attempting to reconnect...'); |           log('[WebSocket] Connection closed, attempting to reconnect...'); | ||||||
| @@ -108,6 +120,19 @@ class WebSocketService { | |||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   void _scheduleHeartbeat() { | ||||||
|  |     _heartbeatTimer?.cancel(); | ||||||
|  |     _heartbeatTimer = Timer.periodic(const Duration(seconds: 60), (_) { | ||||||
|  |       _beatTheHeart(); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void _beatTheHeart() { | ||||||
|  |     _heartbeatAt = DateTime.now(); | ||||||
|  |     log('[WebSocket] We\'re beating the heart! $_heartbeatAt'); | ||||||
|  |     sendMessage(jsonEncode(WebSocketPacket(type: 'ping', data: null))); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   WebSocketChannel? get ws => _channel; |   WebSocketChannel? get ws => _channel; | ||||||
|  |  | ||||||
|   void sendMessage(String message) { |   void sendMessage(String message) { | ||||||
|   | |||||||
| @@ -28,9 +28,11 @@ import 'package:island/screens/creators/hub.dart'; | |||||||
| import 'package:island/screens/creators/posts/post_manage_list.dart'; | import 'package:island/screens/creators/posts/post_manage_list.dart'; | ||||||
| import 'package:island/screens/creators/stickers/stickers.dart'; | import 'package:island/screens/creators/stickers/stickers.dart'; | ||||||
| import 'package:island/screens/creators/stickers/pack_detail.dart'; | import 'package:island/screens/creators/stickers/pack_detail.dart'; | ||||||
|  | import 'package:island/screens/creators/poll/poll_list.dart'; | ||||||
| import 'package:island/screens/creators/publishers.dart'; | import 'package:island/screens/creators/publishers.dart'; | ||||||
| import 'package:island/screens/creators/webfeed/webfeed_list.dart'; | import 'package:island/screens/creators/webfeed/webfeed_list.dart'; | ||||||
| import 'package:island/screens/creators/webfeed/webfeed_edit.dart'; | import 'package:island/screens/creators/webfeed/webfeed_edit.dart'; | ||||||
|  | import 'package:island/screens/poll/poll_editor.dart'; | ||||||
| import 'package:island/screens/posts/compose.dart'; | import 'package:island/screens/posts/compose.dart'; | ||||||
| import 'package:island/screens/posts/post_detail.dart'; | import 'package:island/screens/posts/post_detail.dart'; | ||||||
| import 'package:island/screens/posts/pub_profile.dart'; | import 'package:island/screens/posts/pub_profile.dart'; | ||||||
| @@ -144,6 +146,37 @@ final routerProvider = Provider<GoRouter>((ref) { | |||||||
|                   return CreatorPostListScreen(pubName: name); |                   return CreatorPostListScreen(pubName: name); | ||||||
|                 }, |                 }, | ||||||
|               ), |               ), | ||||||
|  |               // Poll list route | ||||||
|  |               GoRoute( | ||||||
|  |                 name: 'creatorPolls', | ||||||
|  |                 path: '/creators/:name/polls', | ||||||
|  |                 builder: (context, state) { | ||||||
|  |                   final name = state.pathParameters['name']!; | ||||||
|  |                   return CreatorPollListScreen(pubName: name); | ||||||
|  |                 }, | ||||||
|  |               ), | ||||||
|  |               // Poll routes | ||||||
|  |               GoRoute( | ||||||
|  |                 name: 'creatorPollNew', | ||||||
|  |                 path: '/creators/:name/polls/new', | ||||||
|  |                 builder: (context, state) { | ||||||
|  |                   final name = state.pathParameters['name']!; | ||||||
|  |                   // initialPollId left null for create; initialPublisher prefilled | ||||||
|  |                   return PollEditorScreen(initialPublisher: name); | ||||||
|  |                 }, | ||||||
|  |               ), | ||||||
|  |               GoRoute( | ||||||
|  |                 name: 'creatorPollEdit', | ||||||
|  |                 path: '/creators/:name/polls/:id/edit', | ||||||
|  |                 builder: (context, state) { | ||||||
|  |                   final name = state.pathParameters['name']!; | ||||||
|  |                   final id = state.pathParameters['id']!; | ||||||
|  |                   return PollEditorScreen( | ||||||
|  |                     initialPollId: id, | ||||||
|  |                     initialPublisher: name, | ||||||
|  |                   ); | ||||||
|  |                 }, | ||||||
|  |               ), | ||||||
|               GoRoute( |               GoRoute( | ||||||
|                 name: 'creatorStickers', |                 name: 'creatorStickers', | ||||||
|                 path: '/creators/:name/stickers', |                 path: '/creators/:name/stickers', | ||||||
| @@ -287,12 +320,6 @@ final routerProvider = Provider<GoRouter>((ref) { | |||||||
|             builder: (context, state) => const AboutScreen(), |             builder: (context, state) => const AboutScreen(), | ||||||
|           ), |           ), | ||||||
|  |  | ||||||
|           GoRoute( |  | ||||||
|             name: 'reportList', |  | ||||||
|             path: '/safety/reports/me', |  | ||||||
|             builder: (context, state) => const AbuseReportListScreen(), |  | ||||||
|           ), |  | ||||||
|  |  | ||||||
|           GoRoute( |           GoRoute( | ||||||
|             name: 'reportDetail', |             name: 'reportDetail', | ||||||
|             path: '/safety/reports/me/:id', |             path: '/safety/reports/me/:id', | ||||||
| @@ -439,14 +466,6 @@ final routerProvider = Provider<GoRouter>((ref) { | |||||||
|                     path: '/account/relationships', |                     path: '/account/relationships', | ||||||
|                     builder: (context, state) => const RelationshipScreen(), |                     builder: (context, state) => const RelationshipScreen(), | ||||||
|                   ), |                   ), | ||||||
|                   GoRoute( |  | ||||||
|                     name: 'accountProfile', |  | ||||||
|                     path: '/account/:name', |  | ||||||
|                     builder: (context, state) { |  | ||||||
|                       final name = state.pathParameters['name']!; |  | ||||||
|                       return AccountProfileScreen(name: name); |  | ||||||
|                     }, |  | ||||||
|                   ), |  | ||||||
|                   GoRoute( |                   GoRoute( | ||||||
|                     name: 'profileUpdate', |                     name: 'profileUpdate', | ||||||
|                     path: '/account/me/update', |                     path: '/account/me/update', | ||||||
| @@ -462,8 +481,22 @@ final routerProvider = Provider<GoRouter>((ref) { | |||||||
|                     path: '/account/me/settings', |                     path: '/account/me/settings', | ||||||
|                     builder: (context, state) => const AccountSettingsScreen(), |                     builder: (context, state) => const AccountSettingsScreen(), | ||||||
|                   ), |                   ), | ||||||
|  |                   GoRoute( | ||||||
|  |                     name: 'reportList', | ||||||
|  |                     path: '/safety/reports/me', | ||||||
|  |                     builder: (context, state) => const AbuseReportListScreen(), | ||||||
|  |                   ), | ||||||
|                 ], |                 ], | ||||||
|               ), |               ), | ||||||
|  |  | ||||||
|  |               GoRoute( | ||||||
|  |                 name: 'accountProfile', | ||||||
|  |                 path: '/account/:name', | ||||||
|  |                 builder: (context, state) { | ||||||
|  |                   final name = state.pathParameters['name']!; | ||||||
|  |                   return AccountProfileScreen(name: name); | ||||||
|  |                 }, | ||||||
|  |               ), | ||||||
|             ], |             ], | ||||||
|           ), |           ), | ||||||
|         ], |         ], | ||||||
|   | |||||||
| @@ -93,6 +93,7 @@ class _AboutScreenState extends ConsumerState<AboutScreen> { | |||||||
|     final theme = Theme.of(context); |     final theme = Theme.of(context); | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|  |       isNoBackground: false, | ||||||
|       appBar: AppBar(title: Text('about'.tr()), elevation: 0), |       appBar: AppBar(title: Text('about'.tr()), elevation: 0), | ||||||
|       body: |       body: | ||||||
|           _isLoading |           _isLoading | ||||||
|   | |||||||
| @@ -64,7 +64,7 @@ class AccountScreen extends HookConsumerWidget { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       noBackground: isWide, |       isNoBackground: isWide, | ||||||
|       appBar: AppBar(backgroundColor: Colors.transparent, toolbarHeight: 0), |       appBar: AppBar(backgroundColor: Colors.transparent, toolbarHeight: 0), | ||||||
|       body: SingleChildScrollView( |       body: SingleChildScrollView( | ||||||
|         padding: getTabbedPadding(context), |         padding: getTabbedPadding(context), | ||||||
| @@ -231,7 +231,7 @@ class AccountScreen extends HookConsumerWidget { | |||||||
|             ListTile( |             ListTile( | ||||||
|               minTileHeight: 48, |               minTileHeight: 48, | ||||||
|               title: Text('abuseReports').tr(), |               title: Text('abuseReports').tr(), | ||||||
|               contentPadding: const EdgeInsets.only(left: 24, right: 17), |               contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|               leading: const Icon(Symbols.gavel), |               leading: const Icon(Symbols.gavel), | ||||||
|               trailing: const Icon(Symbols.chevron_right), |               trailing: const Icon(Symbols.chevron_right), | ||||||
|               onTap: () => context.pushNamed('reportList'), |               onTap: () => context.pushNamed('reportList'), | ||||||
|   | |||||||
| @@ -46,7 +46,7 @@ class EventCalanderScreen extends HookConsumerWidget { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       noBackground: false, |       isNoBackground: false, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         leading: const PageBackButton(), |         leading: const PageBackButton(), | ||||||
|         title: Text('eventCalander').tr(), |         title: Text('eventCalander').tr(), | ||||||
|   | |||||||
| @@ -280,7 +280,7 @@ class LevelingScreen extends HookConsumerWidget { | |||||||
|       try { |       try { | ||||||
|         showLoadingModal(context); |         showLoadingModal(context); | ||||||
|         final client = ref.watch(apiClientProvider); |         final client = ref.watch(apiClientProvider); | ||||||
|         await client.post('/subscriptions/${membership.identifier}/cancel'); |         await client.post('/id/subscriptions/${membership.identifier}/cancel'); | ||||||
|         ref.invalidate(accountStellarSubscriptionProvider); |         ref.invalidate(accountStellarSubscriptionProvider); | ||||||
|         ref.read(userInfoProvider.notifier).fetchUser(); |         ref.read(userInfoProvider.notifier).fetchUser(); | ||||||
|         if (context.mounted) { |         if (context.mounted) { | ||||||
| @@ -603,7 +603,7 @@ class LevelingScreen extends HookConsumerWidget { | |||||||
|     try { |     try { | ||||||
|       showLoadingModal(context); |       showLoadingModal(context); | ||||||
|       final resp = await client.post( |       final resp = await client.post( | ||||||
|         '/subscriptions', |         '/id/subscriptions', | ||||||
|         data: { |         data: { | ||||||
|           'identifier': tierId, |           'identifier': tierId, | ||||||
|           'payment_method': 'solian.wallet', |           'payment_method': 'solian.wallet', | ||||||
| @@ -615,7 +615,7 @@ class LevelingScreen extends HookConsumerWidget { | |||||||
|       final subscription = SnWalletSubscription.fromJson(resp.data); |       final subscription = SnWalletSubscription.fromJson(resp.data); | ||||||
|       if (subscription.status == 1) return; |       if (subscription.status == 1) return; | ||||||
|       final orderResp = await client.post( |       final orderResp = await client.post( | ||||||
|         '/subscriptions/${subscription.identifier}/order', |         '/id/subscriptions/${subscription.identifier}/order', | ||||||
|       ); |       ); | ||||||
|       final order = SnWalletOrder.fromJson(orderResp.data); |       final order = SnWalletOrder.fromJson(orderResp.data); | ||||||
|  |  | ||||||
| @@ -633,7 +633,7 @@ class LevelingScreen extends HookConsumerWidget { | |||||||
|  |  | ||||||
|       if (paidOrder != null) { |       if (paidOrder != null) { | ||||||
|         await client.post( |         await client.post( | ||||||
|           '/subscriptions/order/handle', |           '/id/subscriptions/order/handle', | ||||||
|           data: {'order_id': paidOrder.id}, |           data: {'order_id': paidOrder.id}, | ||||||
|         ); |         ); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -7,7 +7,7 @@ part of 'leveling.dart'; | |||||||
| // ************************************************************************** | // ************************************************************************** | ||||||
|  |  | ||||||
| String _$accountStellarSubscriptionHash() => | String _$accountStellarSubscriptionHash() => | ||||||
|     r'37fb821460e3ac50b5cf777c933b6779f732daee'; |     r'80abcdefb3868775fd8fe3c980215713efff5948'; | ||||||
|  |  | ||||||
| /// See also [accountStellarSubscription]. | /// See also [accountStellarSubscription]. | ||||||
| @ProviderFor(accountStellarSubscription) | @ProviderFor(accountStellarSubscription) | ||||||
|   | |||||||
| @@ -12,6 +12,7 @@ import 'package:island/pods/event_calendar.dart'; | |||||||
| import 'package:island/pods/network.dart'; | import 'package:island/pods/network.dart'; | ||||||
| import 'package:island/pods/userinfo.dart'; | import 'package:island/pods/userinfo.dart'; | ||||||
| import 'package:island/services/color.dart'; | import 'package:island/services/color.dart'; | ||||||
|  | import 'package:island/services/responsive.dart'; | ||||||
| import 'package:island/services/time.dart'; | import 'package:island/services/time.dart'; | ||||||
| import 'package:island/services/timezone/native.dart'; | import 'package:island/services/timezone/native.dart'; | ||||||
| import 'package:island/widgets/account/account_name.dart'; | import 'package:island/widgets/account/account_name.dart'; | ||||||
| @@ -22,10 +23,12 @@ import 'package:island/widgets/account/status.dart'; | |||||||
| import 'package:island/widgets/alert.dart'; | import 'package:island/widgets/alert.dart'; | ||||||
| import 'package:island/widgets/app_scaffold.dart'; | import 'package:island/widgets/app_scaffold.dart'; | ||||||
| import 'package:island/widgets/content/cloud_files.dart'; | 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:island/widgets/safety/abuse_report_helper.dart'; | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:palette_generator/palette_generator.dart'; | import 'package:palette_generator/palette_generator.dart'; | ||||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||||
|  | import 'package:share_plus/share_plus.dart'; | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  |  | ||||||
| part 'profile.g.dart'; | part 'profile.g.dart'; | ||||||
| @@ -248,294 +251,402 @@ class AccountProfileScreen extends HookConsumerWidget { | |||||||
|  |  | ||||||
|     final user = ref.watch(userInfoProvider); |     final user = ref.watch(userInfoProvider); | ||||||
|  |  | ||||||
|  |     Widget accountBasicInfo(SnAccount data) => Padding( | ||||||
|  |       padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), | ||||||
|  |       child: Row( | ||||||
|  |         crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |         children: [ | ||||||
|  |           ProfilePictureWidget(file: data.profile.picture, radius: 32), | ||||||
|  |           const Gap(20), | ||||||
|  |           Expanded( | ||||||
|  |             child: Column( | ||||||
|  |               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |               children: [ | ||||||
|  |                 Row( | ||||||
|  |                   children: [ | ||||||
|  |                     AccountName(account: data, style: TextStyle(fontSize: 20)), | ||||||
|  |                     const Gap(6), | ||||||
|  |                     Flexible( | ||||||
|  |                       child: Text( | ||||||
|  |                         '@${data.name}', | ||||||
|  |                         maxLines: 1, | ||||||
|  |                         overflow: TextOverflow.ellipsis, | ||||||
|  |                       ).fontSize(14).opacity(0.85), | ||||||
|  |                     ), | ||||||
|  |                   ], | ||||||
|  |                 ), | ||||||
|  |                 AccountStatusWidget(uname: name, padding: EdgeInsets.zero), | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |           IconButton( | ||||||
|  |             onPressed: () { | ||||||
|  |               SharePlus.instance.share( | ||||||
|  |                 ShareParams( | ||||||
|  |                   uri: Uri.parse('https://id.solian.app/@${data.name}'), | ||||||
|  |                 ), | ||||||
|  |               ); | ||||||
|  |             }, | ||||||
|  |             icon: const Icon(Symbols.share), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     Widget accountProfileBio(SnAccount data) => Card( | ||||||
|  |       child: Column( | ||||||
|  |         crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |         children: [ | ||||||
|  |           Text('bio').tr().bold().fontSize(15).padding(bottom: 8), | ||||||
|  |           if (data.profile.bio.isEmpty) | ||||||
|  |             Text('descriptionNone').tr().italic() | ||||||
|  |           else | ||||||
|  |             MarkdownTextContent( | ||||||
|  |               content: data.profile.bio, | ||||||
|  |               linesMargin: EdgeInsets.zero, | ||||||
|  |             ), | ||||||
|  |         ], | ||||||
|  |       ).padding(horizontal: 24, vertical: 20), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     Widget accountProfileDetail(SnAccount data) => Card( | ||||||
|  |       child: Column( | ||||||
|  |         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |         spacing: 24, | ||||||
|  |         children: [ | ||||||
|  |           if (buildSubcolumn(data).isNotEmpty) | ||||||
|  |             Column( | ||||||
|  |               crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |               spacing: 2, | ||||||
|  |               children: buildSubcolumn(data), | ||||||
|  |             ), | ||||||
|  |           if (data.profile.timeZone.isNotEmpty) | ||||||
|  |             Column( | ||||||
|  |               crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |               children: [ | ||||||
|  |                 Text('timeZone').tr().bold(), | ||||||
|  |                 Row( | ||||||
|  |                   crossAxisAlignment: CrossAxisAlignment.baseline, | ||||||
|  |                   textBaseline: TextBaseline.alphabetic, | ||||||
|  |                   spacing: 6, | ||||||
|  |                   children: [ | ||||||
|  |                     Text(data.profile.timeZone), | ||||||
|  |                     Text( | ||||||
|  |                       getTzInfo( | ||||||
|  |                         data.profile.timeZone, | ||||||
|  |                       ).$2.formatCustomGlobal('HH:mm'), | ||||||
|  |                     ), | ||||||
|  |                     Text( | ||||||
|  |                       getTzInfo(data.profile.timeZone).$1.formatOffsetLocal(), | ||||||
|  |                     ).fontSize(11), | ||||||
|  |                     Text( | ||||||
|  |                       'UTC${getTzInfo(data.profile.timeZone).$1.formatOffset()}', | ||||||
|  |                     ).fontSize(11).opacity(0.75), | ||||||
|  |                   ], | ||||||
|  |                 ), | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |         ], | ||||||
|  |       ).padding(horizontal: 24, vertical: 16), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     Widget accountAction(SnAccount data) => Card( | ||||||
|  |       child: Column( | ||||||
|  |         children: [ | ||||||
|  |           Row( | ||||||
|  |             spacing: 8, | ||||||
|  |             children: [ | ||||||
|  |               if (accountRelationship.value == null || | ||||||
|  |                   accountRelationship.value!.status > -100) | ||||||
|  |                 Expanded( | ||||||
|  |                   child: FilledButton.icon( | ||||||
|  |                     style: ButtonStyle( | ||||||
|  |                       backgroundColor: WidgetStatePropertyAll( | ||||||
|  |                         accountRelationship.value == null | ||||||
|  |                             ? null | ||||||
|  |                             : Theme.of(context).colorScheme.secondary, | ||||||
|  |                       ), | ||||||
|  |                       foregroundColor: WidgetStatePropertyAll( | ||||||
|  |                         accountRelationship.value == null | ||||||
|  |                             ? null | ||||||
|  |                             : Theme.of(context).colorScheme.onSecondary, | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                     onPressed: relationshipAction, | ||||||
|  |                     label: | ||||||
|  |                         Text( | ||||||
|  |                           accountRelationship.value == null | ||||||
|  |                               ? 'addFriendShort' | ||||||
|  |                               : 'added', | ||||||
|  |                         ).tr(), | ||||||
|  |                     icon: | ||||||
|  |                         accountRelationship.value == null | ||||||
|  |                             ? const Icon(Symbols.person_add) | ||||||
|  |                             : const Icon(Symbols.person_check), | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |               if (accountRelationship.value == null || | ||||||
|  |                   accountRelationship.value!.status <= -100) | ||||||
|  |                 Expanded( | ||||||
|  |                   child: FilledButton.icon( | ||||||
|  |                     style: ButtonStyle( | ||||||
|  |                       backgroundColor: WidgetStatePropertyAll( | ||||||
|  |                         accountRelationship.value == null | ||||||
|  |                             ? null | ||||||
|  |                             : Theme.of(context).colorScheme.secondary, | ||||||
|  |                       ), | ||||||
|  |                       foregroundColor: WidgetStatePropertyAll( | ||||||
|  |                         accountRelationship.value == null | ||||||
|  |                             ? null | ||||||
|  |                             : Theme.of(context).colorScheme.onSecondary, | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                     onPressed: blockAction, | ||||||
|  |                     label: | ||||||
|  |                         Text( | ||||||
|  |                           accountRelationship.value == null | ||||||
|  |                               ? 'blockUser' | ||||||
|  |                               : 'unblockUser', | ||||||
|  |                         ).tr(), | ||||||
|  |                     icon: | ||||||
|  |                         accountRelationship.value == null | ||||||
|  |                             ? const Icon(Symbols.block) | ||||||
|  |                             : const Icon(Symbols.person_cancel), | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |             ], | ||||||
|  |           ), | ||||||
|  |           Row( | ||||||
|  |             spacing: 8, | ||||||
|  |             children: [ | ||||||
|  |               Expanded( | ||||||
|  |                 child: FilledButton.icon( | ||||||
|  |                   onPressed: directMessageAction, | ||||||
|  |                   icon: const Icon(Symbols.message), | ||||||
|  |                   label: | ||||||
|  |                       Text( | ||||||
|  |                         accountChat.value == null | ||||||
|  |                             ? 'createDirectMessage' | ||||||
|  |                             : 'gotoDirectMessage', | ||||||
|  |                         maxLines: 1, | ||||||
|  |                       ).tr(), | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |               IconButton.filled( | ||||||
|  |                 onPressed: () { | ||||||
|  |                   showAbuseReportSheet( | ||||||
|  |                     context, | ||||||
|  |                     resourceIdentifier: 'account/${data.id}', | ||||||
|  |                   ); | ||||||
|  |                 }, | ||||||
|  |                 icon: Icon( | ||||||
|  |                   Symbols.flag, | ||||||
|  |                   color: Theme.of(context).colorScheme.onError, | ||||||
|  |                 ), | ||||||
|  |                 style: ButtonStyle( | ||||||
|  |                   backgroundColor: WidgetStatePropertyAll( | ||||||
|  |                     Theme.of(context).colorScheme.error, | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |             ], | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ).padding(horizontal: 16, vertical: 8), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     return account.when( |     return account.when( | ||||||
|       data: |       data: | ||||||
|           (data) => AppScaffold( |           (data) => AppScaffold( | ||||||
|             body: CustomScrollView( |             isNoBackground: false, | ||||||
|               slivers: [ |             appBar: | ||||||
|                 SliverAppBar( |                 isWideScreen(context) | ||||||
|                   foregroundColor: appbarColor.value, |                     ? AppBar( | ||||||
|                   expandedHeight: 180, |                       foregroundColor: appbarColor.value, | ||||||
|                   pinned: true, |                       leading: PageBackButton( | ||||||
|                   leading: PageBackButton( |                         color: appbarColor.value, | ||||||
|                     color: appbarColor.value, |                         shadows: [appbarShadow], | ||||||
|                     shadows: [appbarShadow], |  | ||||||
|                   ), |  | ||||||
|                   flexibleSpace: Stack( |  | ||||||
|                     children: [ |  | ||||||
|                       Positioned.fill( |  | ||||||
|                         child: |  | ||||||
|                             data.profile.background?.id != null |  | ||||||
|                                 ? CloudImageWidget( |  | ||||||
|                                   file: data.profile.background, |  | ||||||
|                                 ) |  | ||||||
|                                 : Container( |  | ||||||
|                                   color: |  | ||||||
|                                       Theme.of( |  | ||||||
|                                         context, |  | ||||||
|                                       ).appBarTheme.backgroundColor, |  | ||||||
|                                 ), |  | ||||||
|                       ), |                       ), | ||||||
|                       FlexibleSpaceBar( |                       flexibleSpace: Stack( | ||||||
|                         title: Text( |                         children: [ | ||||||
|                           data.nick, |                           Positioned.fill( | ||||||
|                           style: TextStyle( |                             child: | ||||||
|                             color: |                                 data.profile.background?.id != null | ||||||
|                                 appbarColor.value ?? |                                     ? CloudImageWidget( | ||||||
|                                 Theme.of(context).appBarTheme.foregroundColor, |                                       file: data.profile.background, | ||||||
|                             shadows: [appbarShadow], |                                     ) | ||||||
|  |                                     : Container( | ||||||
|  |                                       color: | ||||||
|  |                                           Theme.of( | ||||||
|  |                                             context, | ||||||
|  |                                           ).appBarTheme.backgroundColor, | ||||||
|  |                                     ), | ||||||
|  |                           ), | ||||||
|  |                           FlexibleSpaceBar( | ||||||
|  |                             title: Text( | ||||||
|  |                               data.nick, | ||||||
|  |                               style: TextStyle( | ||||||
|  |                                 color: | ||||||
|  |                                     appbarColor.value ?? | ||||||
|  |                                     Theme.of( | ||||||
|  |                                       context, | ||||||
|  |                                     ).appBarTheme.foregroundColor, | ||||||
|  |                                 shadows: [appbarShadow], | ||||||
|  |                               ), | ||||||
|  |                             ), | ||||||
|  |                           ), | ||||||
|  |                         ], | ||||||
|  |                       ), | ||||||
|  |                     ) | ||||||
|  |                     : null, | ||||||
|  |             body: | ||||||
|  |                 isWideScreen(context) | ||||||
|  |                     ? Row( | ||||||
|  |                       children: [ | ||||||
|  |                         Flexible( | ||||||
|  |                           child: CustomScrollView( | ||||||
|  |                             slivers: [ | ||||||
|  |                               SliverToBoxAdapter(child: accountBasicInfo(data)), | ||||||
|  |                               if (data.badges.isNotEmpty) | ||||||
|  |                                 SliverToBoxAdapter( | ||||||
|  |                                   child: BadgeList( | ||||||
|  |                                     badges: data.badges, | ||||||
|  |                                   ).padding(horizontal: 24, bottom: 24), | ||||||
|  |                                 ), | ||||||
|  |                               SliverToBoxAdapter( | ||||||
|  |                                 child: Column( | ||||||
|  |                                   spacing: 12, | ||||||
|  |                                   children: [ | ||||||
|  |                                     LevelingProgressCard( | ||||||
|  |                                       level: data.profile.level, | ||||||
|  |                                       experience: data.profile.experience, | ||||||
|  |                                       progress: data.profile.levelingProgress, | ||||||
|  |                                     ), | ||||||
|  |                                     if (data.profile.verification != null) | ||||||
|  |                                       Card( | ||||||
|  |                                         child: VerificationStatusCard( | ||||||
|  |                                           mark: data.profile.verification!, | ||||||
|  |                                         ), | ||||||
|  |                                       ), | ||||||
|  |                                   ], | ||||||
|  |                                 ).padding(horizontal: 4, top: 8), | ||||||
|  |                               ), | ||||||
|  |                               SliverToBoxAdapter( | ||||||
|  |                                 child: accountProfileBio(data).padding(top: 4), | ||||||
|  |                               ), | ||||||
|  |                               SliverToBoxAdapter( | ||||||
|  |                                 child: accountProfileDetail(data), | ||||||
|  |                               ), | ||||||
|  |                             ], | ||||||
|                           ), |                           ), | ||||||
|                         ), |                         ), | ||||||
|                       ), |                         Flexible( | ||||||
|                     ], |                           child: CustomScrollView( | ||||||
|                   ), |                             slivers: [ | ||||||
|                 ), |                               SliverGap(24), | ||||||
|                 SliverToBoxAdapter( |                               if (user.value != null) | ||||||
|                   child: Padding( |                                 SliverToBoxAdapter(child: accountAction(data)), | ||||||
|                     padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), |                               SliverToBoxAdapter( | ||||||
|                     child: Row( |                                 child: Card( | ||||||
|                       crossAxisAlignment: CrossAxisAlignment.start, |                                   child: FortuneGraphWidget( | ||||||
|                       children: [ |                                     events: accountEvents, | ||||||
|                         ProfilePictureWidget( |                                     eventCalanderUser: data.name, | ||||||
|                           file: data.profile.picture, |                                     margin: EdgeInsets.zero, | ||||||
|                           radius: 32, |  | ||||||
|                         ), |  | ||||||
|                         const Gap(20), |  | ||||||
|                         Expanded( |  | ||||||
|                           child: Column( |  | ||||||
|                             crossAxisAlignment: CrossAxisAlignment.stretch, |  | ||||||
|                             children: [ |  | ||||||
|                               Row( |  | ||||||
|                                 children: [ |  | ||||||
|                                   AccountName( |  | ||||||
|                                     account: data, |  | ||||||
|                                     style: TextStyle(fontSize: 20), |  | ||||||
|                                   ), |                                   ), | ||||||
|                                   const Gap(6), |                                 ), | ||||||
|                                   Text( |  | ||||||
|                                     '@${data.name}', |  | ||||||
|                                   ).fontSize(14).opacity(0.85), |  | ||||||
|                                 ], |  | ||||||
|                               ), |  | ||||||
|                               AccountStatusWidget( |  | ||||||
|                                 uname: name, |  | ||||||
|                                 padding: EdgeInsets.zero, |  | ||||||
|                               ), |                               ), | ||||||
|                             ], |                             ], | ||||||
|                           ), |                           ), | ||||||
|                         ), |                         ), | ||||||
|                       ], |                       ], | ||||||
|                     ), |                     ).padding(horizontal: 24) | ||||||
|                   ), |                     : CustomScrollView( | ||||||
|                 ), |                       slivers: [ | ||||||
|                 if (data.badges.isNotEmpty) |                         SliverAppBar( | ||||||
|                   SliverToBoxAdapter( |                           foregroundColor: appbarColor.value, | ||||||
|                     child: BadgeList( |                           expandedHeight: 180, | ||||||
|                       badges: data.badges, |                           pinned: true, | ||||||
|                     ).padding(horizontal: 24, bottom: 24), |                           leading: PageBackButton( | ||||||
|                   ), |                             color: appbarColor.value, | ||||||
|                 SliverToBoxAdapter( |                             shadows: [appbarShadow], | ||||||
|                   child: Column( |  | ||||||
|                     spacing: 12, |  | ||||||
|                     children: [ |  | ||||||
|                       LevelingProgressCard( |  | ||||||
|                         level: data.profile.level, |  | ||||||
|                         experience: data.profile.experience, |  | ||||||
|                         progress: data.profile.levelingProgress, |  | ||||||
|                       ), |  | ||||||
|                       if (data.profile.verification != null) |  | ||||||
|                         VerificationStatusCard( |  | ||||||
|                           mark: data.profile.verification!, |  | ||||||
|                         ), |  | ||||||
|                     ], |  | ||||||
|                   ).padding(horizontal: 20), |  | ||||||
|                 ), |  | ||||||
|  |  | ||||||
|                 SliverToBoxAdapter( |  | ||||||
|                   child: const Divider(height: 1).padding(vertical: 24), |  | ||||||
|                 ), |  | ||||||
|                 SliverToBoxAdapter( |  | ||||||
|                   child: Column( |  | ||||||
|                     crossAxisAlignment: CrossAxisAlignment.stretch, |  | ||||||
|                     spacing: 24, |  | ||||||
|                     children: [ |  | ||||||
|                       if (buildSubcolumn(data).isNotEmpty) |  | ||||||
|                         Column( |  | ||||||
|                           crossAxisAlignment: CrossAxisAlignment.start, |  | ||||||
|                           spacing: 2, |  | ||||||
|                           children: buildSubcolumn(data), |  | ||||||
|                         ), |  | ||||||
|                       Column( |  | ||||||
|                         crossAxisAlignment: CrossAxisAlignment.start, |  | ||||||
|                         children: [ |  | ||||||
|                           Text('bio').tr().bold(), |  | ||||||
|                           Text( |  | ||||||
|                             data.profile.bio.isEmpty |  | ||||||
|                                 ? 'descriptionNone'.tr() |  | ||||||
|                                 : data.profile.bio, |  | ||||||
|                           ), |                           ), | ||||||
|                         ], |                           flexibleSpace: Stack( | ||||||
|                       ), |                             children: [ | ||||||
|                       if (data.profile.timeZone.isNotEmpty) |                               Positioned.fill( | ||||||
|                         Column( |                                 child: | ||||||
|                           crossAxisAlignment: CrossAxisAlignment.start, |                                     data.profile.background?.id != null | ||||||
|                           children: [ |                                         ? CloudImageWidget( | ||||||
|                             Text('timeZone').tr().bold(), |                                           file: data.profile.background, | ||||||
|                             Row( |                                         ) | ||||||
|                               crossAxisAlignment: CrossAxisAlignment.baseline, |                                         : Container( | ||||||
|                               textBaseline: TextBaseline.alphabetic, |                                           color: | ||||||
|                               spacing: 6, |                                               Theme.of( | ||||||
|                               children: [ |                                                 context, | ||||||
|                                 Text(data.profile.timeZone), |                                               ).appBarTheme.backgroundColor, | ||||||
|                                 Text( |                                         ), | ||||||
|                                   getTzInfo( |                               ), | ||||||
|                                     data.profile.timeZone, |                               FlexibleSpaceBar( | ||||||
|                                   ).$2.formatCustomGlobal('HH:mm'), |                                 title: Text( | ||||||
|                                 ), |                                   data.nick, | ||||||
|                                 Text( |                                   style: TextStyle( | ||||||
|                                   getTzInfo( |                                     color: | ||||||
|                                     data.profile.timeZone, |                                         appbarColor.value ?? | ||||||
|                                   ).$1.formatOffsetLocal(), |                                         Theme.of( | ||||||
|                                 ).fontSize(11), |                                           context, | ||||||
|                                 Text( |                                         ).appBarTheme.foregroundColor, | ||||||
|                                   'UTC${getTzInfo(data.profile.timeZone).$1.formatOffset()}', |                                     shadows: [appbarShadow], | ||||||
|                                 ).fontSize(11).opacity(0.75), |                                   ), | ||||||
|                               ], |  | ||||||
|                             ), |  | ||||||
|                           ], |  | ||||||
|                         ), |  | ||||||
|                     ], |  | ||||||
|                   ).padding(horizontal: 24), |  | ||||||
|                 ), |  | ||||||
|  |  | ||||||
|                 if (user.value != null) |  | ||||||
|                   SliverToBoxAdapter( |  | ||||||
|                     child: const Divider( |  | ||||||
|                       height: 1, |  | ||||||
|                     ).padding(top: 24, bottom: 12), |  | ||||||
|                   ), |  | ||||||
|                 if (user.value != null) |  | ||||||
|                   SliverToBoxAdapter( |  | ||||||
|                     child: Row( |  | ||||||
|                       spacing: 8, |  | ||||||
|                       children: [ |  | ||||||
|                         if (accountRelationship.value == null || |  | ||||||
|                             accountRelationship.value!.status > -100) |  | ||||||
|                           Expanded( |  | ||||||
|                             child: FilledButton.icon( |  | ||||||
|                               style: ButtonStyle( |  | ||||||
|                                 backgroundColor: WidgetStatePropertyAll( |  | ||||||
|                                   accountRelationship.value == null |  | ||||||
|                                       ? null |  | ||||||
|                                       : Theme.of(context).colorScheme.secondary, |  | ||||||
|                                 ), |  | ||||||
|                                 foregroundColor: WidgetStatePropertyAll( |  | ||||||
|                                   accountRelationship.value == null |  | ||||||
|                                       ? null |  | ||||||
|                                       : Theme.of( |  | ||||||
|                                         context, |  | ||||||
|                                       ).colorScheme.onSecondary, |  | ||||||
|                                 ), |                                 ), | ||||||
|                               ), |                               ), | ||||||
|                               onPressed: relationshipAction, |                             ], | ||||||
|                               label: |  | ||||||
|                                   Text( |  | ||||||
|                                     accountRelationship.value == null |  | ||||||
|                                         ? 'addFriendShort' |  | ||||||
|                                         : 'added', |  | ||||||
|                                   ).tr(), |  | ||||||
|                               icon: |  | ||||||
|                                   accountRelationship.value == null |  | ||||||
|                                       ? const Icon(Symbols.person_add) |  | ||||||
|                                       : const Icon(Symbols.person_check), |  | ||||||
|                             ), |  | ||||||
|                           ), |                           ), | ||||||
|                         if (accountRelationship.value == null || |                         ), | ||||||
|                             accountRelationship.value!.status <= -100) |                         SliverToBoxAdapter(child: accountBasicInfo(data)), | ||||||
|                           Expanded( |                         if (data.badges.isNotEmpty) | ||||||
|                             child: FilledButton.icon( |                           SliverToBoxAdapter( | ||||||
|                               style: ButtonStyle( |                             child: BadgeList( | ||||||
|                                 backgroundColor: WidgetStatePropertyAll( |                               badges: data.badges, | ||||||
|                                   accountRelationship.value == null |                             ).padding(horizontal: 24, bottom: 24), | ||||||
|                                       ? null |  | ||||||
|                                       : Theme.of(context).colorScheme.secondary, |  | ||||||
|                                 ), |  | ||||||
|                                 foregroundColor: WidgetStatePropertyAll( |  | ||||||
|                                   accountRelationship.value == null |  | ||||||
|                                       ? null |  | ||||||
|                                       : Theme.of( |  | ||||||
|                                         context, |  | ||||||
|                                       ).colorScheme.onSecondary, |  | ||||||
|                                 ), |  | ||||||
|                               ), |  | ||||||
|                               onPressed: blockAction, |  | ||||||
|                               label: |  | ||||||
|                                   Text( |  | ||||||
|                                     accountRelationship.value == null |  | ||||||
|                                         ? 'blockUser' |  | ||||||
|                                         : 'unblockUser', |  | ||||||
|                                   ).tr(), |  | ||||||
|                               icon: |  | ||||||
|                                   accountRelationship.value == null |  | ||||||
|                                       ? const Icon(Symbols.block) |  | ||||||
|                                       : const Icon(Symbols.person_cancel), |  | ||||||
|                             ), |  | ||||||
|                           ), |                           ), | ||||||
|  |                         SliverToBoxAdapter( | ||||||
|  |                           child: Column( | ||||||
|  |                             children: [ | ||||||
|  |                               LevelingProgressCard( | ||||||
|  |                                 level: data.profile.level, | ||||||
|  |                                 experience: data.profile.experience, | ||||||
|  |                                 progress: data.profile.levelingProgress, | ||||||
|  |                               ).padding(top: 8, horizontal: 8, bottom: 4), | ||||||
|  |                               if (data.profile.verification != null) | ||||||
|  |                                 Card( | ||||||
|  |                                   child: VerificationStatusCard( | ||||||
|  |                                     mark: data.profile.verification!, | ||||||
|  |                                   ), | ||||||
|  |                                 ).padding(horizontal: 4), | ||||||
|  |                             ], | ||||||
|  |                           ), | ||||||
|  |                         ), | ||||||
|  |                         SliverToBoxAdapter( | ||||||
|  |                           child: accountProfileBio(data).padding(horizontal: 4), | ||||||
|  |                         ), | ||||||
|  |                         SliverToBoxAdapter( | ||||||
|  |                           child: accountProfileDetail( | ||||||
|  |                             data, | ||||||
|  |                           ).padding(horizontal: 4), | ||||||
|  |                         ), | ||||||
|  |                         if (user.value != null) | ||||||
|  |                           SliverToBoxAdapter( | ||||||
|  |                             child: accountAction(data).padding(horizontal: 4), | ||||||
|  |                           ), | ||||||
|  |                         SliverToBoxAdapter( | ||||||
|  |                           child: Card( | ||||||
|  |                             child: FortuneGraphWidget( | ||||||
|  |                               events: accountEvents, | ||||||
|  |                               eventCalanderUser: data.name, | ||||||
|  |                             ), | ||||||
|  |                           ).padding(horizontal: 4), | ||||||
|  |                         ), | ||||||
|                       ], |                       ], | ||||||
|                     ).padding(horizontal: 16), |                     ), | ||||||
|                   ), |  | ||||||
|                 SliverToBoxAdapter( |  | ||||||
|                   child: Row( |  | ||||||
|                     spacing: 8, |  | ||||||
|                     children: [ |  | ||||||
|                       Expanded( |  | ||||||
|                         child: FilledButton.icon( |  | ||||||
|                           onPressed: directMessageAction, |  | ||||||
|                           icon: const Icon(Symbols.message), |  | ||||||
|                           label: |  | ||||||
|                               Text( |  | ||||||
|                                 accountChat.value == null |  | ||||||
|                                     ? 'createDirectMessage' |  | ||||||
|                                     : 'gotoDirectMessage', |  | ||||||
|                                 maxLines: 1, |  | ||||||
|                               ).tr(), |  | ||||||
|                         ), |  | ||||||
|                       ), |  | ||||||
|                       IconButton.filled( |  | ||||||
|                         onPressed: () { |  | ||||||
|                           showAbuseReportSheet( |  | ||||||
|                             context, |  | ||||||
|                             resourceIdentifier: 'account/${data.id}', |  | ||||||
|                           ); |  | ||||||
|                         }, |  | ||||||
|                         icon: Icon( |  | ||||||
|                           Symbols.flag, |  | ||||||
|                           color: Theme.of(context).colorScheme.onError, |  | ||||||
|                         ), |  | ||||||
|                         style: ButtonStyle( |  | ||||||
|                           backgroundColor: WidgetStatePropertyAll( |  | ||||||
|                             Theme.of(context).colorScheme.error, |  | ||||||
|                           ), |  | ||||||
|                         ), |  | ||||||
|                       ), |  | ||||||
|                     ], |  | ||||||
|                   ).padding(horizontal: 16, top: 4), |  | ||||||
|                 ), |  | ||||||
|                 SliverToBoxAdapter( |  | ||||||
|                   child: const Divider(height: 1).padding(top: 12), |  | ||||||
|                 ), |  | ||||||
|                 SliverToBoxAdapter( |  | ||||||
|                   child: Column( |  | ||||||
|                     children: [ |  | ||||||
|                       FortuneGraphWidget( |  | ||||||
|                         events: accountEvents, |  | ||||||
|                         eventCalanderUser: data.name, |  | ||||||
|                       ), |  | ||||||
|                     ], |  | ||||||
|                   ).padding(all: 8), |  | ||||||
|                 ), |  | ||||||
|               ], |  | ||||||
|             ), |  | ||||||
|           ), |           ), | ||||||
|       error: |       error: | ||||||
|           (error, stackTrace) => AppScaffold( |           (error, stackTrace) => AppScaffold( | ||||||
|   | |||||||
| @@ -73,7 +73,7 @@ class CreateAccountScreen extends HookConsumerWidget { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       noBackground: false, |       isNoBackground: false, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         leading: const PageBackButton(), |         leading: const PageBackButton(), | ||||||
|         title: Text('createAccount').tr(), |         title: Text('createAccount').tr(), | ||||||
|   | |||||||
| @@ -55,7 +55,7 @@ class LoginScreen extends HookConsumerWidget { | |||||||
|     final factorPicked = useState<SnAuthFactor?>(null); |     final factorPicked = useState<SnAuthFactor?>(null); | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       noBackground: false, |       isNoBackground: false, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         leading: const PageBackButton(), |         leading: const PageBackButton(), | ||||||
|         title: Text('login').tr(), |         title: Text('login').tr(), | ||||||
|   | |||||||
| @@ -80,7 +80,7 @@ class _OidcScreenState extends ConsumerState<OidcScreen> { | |||||||
|                             : 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', |                             : 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', | ||||||
|                   ), |                   ), | ||||||
|                   initialUrlRequest: URLRequest( |                   initialUrlRequest: URLRequest( | ||||||
|                     url: WebUri('$serverUrl/auth/login/${widget.provider}'), |                     url: WebUri('$serverUrl/id/auth/login/${widget.provider}'), | ||||||
|                     headers: { |                     headers: { | ||||||
|                       if (token?.token.isNotEmpty ?? false) |                       if (token?.token.isNotEmpty ?? false) | ||||||
|                         'Authorization': 'AtField ${token!.token}', |                         'Authorization': 'AtField ${token!.token}', | ||||||
| @@ -120,7 +120,7 @@ class _OidcScreenState extends ConsumerState<OidcScreen> { | |||||||
|                       final queryParams = url.queryParameters; |                       final queryParams = url.queryParameters; | ||||||
|  |  | ||||||
|                       // Check if we're on the token page |                       // Check if we're on the token page | ||||||
|                       if (path.endsWith('/id/auth/callback')) { |                       if (path.endsWith('/auth/callback')) { | ||||||
|                         // Extract token from URL |                         // Extract token from URL | ||||||
|                         final challenge = queryParams['challenge']; |                         final challenge = queryParams['challenge']; | ||||||
|                         // Return the token and close the webview |                         // Return the token and close the webview | ||||||
| @@ -205,7 +205,7 @@ class _OidcScreenState extends ConsumerState<OidcScreen> { | |||||||
|                       onPressed: () { |                       onPressed: () { | ||||||
|                         if (currentUrl != null) { |                         if (currentUrl != null) { | ||||||
|                           Clipboard.setData(ClipboardData(text: currentUrl!)); |                           Clipboard.setData(ClipboardData(text: currentUrl!)); | ||||||
|                           showSnackBar('copyToClipboard'); |                           showSnackBar('copyToClipboard'.tr()); | ||||||
|                         } |                         } | ||||||
|                       }, |                       }, | ||||||
|                     ), |                     ), | ||||||
|   | |||||||
| @@ -1,14 +1,16 @@ | |||||||
|  | import 'dart:developer'; | ||||||
|  |  | ||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart' hide ConnectionState; | ||||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
| import 'package:gap/gap.dart'; | import 'package:gap/gap.dart'; | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
| import 'package:island/pods/call.dart'; | import 'package:island/pods/call.dart'; | ||||||
| import 'package:island/services/responsive.dart'; |  | ||||||
| import 'package:island/widgets/app_scaffold.dart'; | import 'package:island/widgets/app_scaffold.dart'; | ||||||
| import 'package:island/widgets/chat/call_button.dart'; | import 'package:island/widgets/chat/call_button.dart'; | ||||||
| import 'package:island/widgets/chat/call_overlay.dart'; | import 'package:island/widgets/chat/call_overlay.dart'; | ||||||
| import 'package:island/widgets/chat/call_participant_tile.dart'; | import 'package:island/widgets/chat/call_participant_tile.dart'; | ||||||
|  | import 'package:island/widgets/alert.dart'; | ||||||
| import 'package:livekit_client/livekit_client.dart'; | import 'package:livekit_client/livekit_client.dart'; | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
| @@ -21,17 +23,39 @@ class CallScreen extends HookConsumerWidget { | |||||||
|   Widget build(BuildContext context, WidgetRef ref) { |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|     final ongoingCall = ref.watch(ongoingCallProvider(roomId)); |     final ongoingCall = ref.watch(ongoingCallProvider(roomId)); | ||||||
|     final callState = ref.watch(callNotifierProvider); |     final callState = ref.watch(callNotifierProvider); | ||||||
|     final callNotifier = ref.read(callNotifierProvider.notifier); |     final callNotifier = ref.watch(callNotifierProvider.notifier); | ||||||
|  |  | ||||||
|     useEffect(() { |     useEffect(() { | ||||||
|       callNotifier.joinRoom(roomId); |       log('[Call] Joining the call...'); | ||||||
|  |       callNotifier.joinRoom(roomId).catchError((_) { | ||||||
|  |         showConfirmAlert( | ||||||
|  |           'Seems there already has a call connected, do you want override it?', | ||||||
|  |           'Call already connected', | ||||||
|  |         ).then((value) { | ||||||
|  |           if (value != true) return; | ||||||
|  |           log('[Call] Joining the call... with overrides'); | ||||||
|  |           callNotifier.disconnect(); | ||||||
|  |           callNotifier.dispose(); | ||||||
|  |           callNotifier.joinRoom(roomId); | ||||||
|  |         }); | ||||||
|  |       }); | ||||||
|       return null; |       return null; | ||||||
|     }, []); |     }, []); | ||||||
|  |  | ||||||
|     final viewMode = useState<String>('grid'); |     final allAudioOnly = callNotifier.participants.every( | ||||||
|  |       (p) => | ||||||
|  |           !(p.hasVideo && | ||||||
|  |               p.remoteParticipant.trackPublications.values.any( | ||||||
|  |                 (pub) => | ||||||
|  |                     pub.track != null && | ||||||
|  |                     pub.kind == TrackType.VIDEO && | ||||||
|  |                     !pub.muted && | ||||||
|  |                     !pub.isDisposed, | ||||||
|  |               )), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       noBackground: false, |       isNoBackground: false, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         leading: PageBackButton(), |         leading: PageBackButton(), | ||||||
|         title: Column( |         title: Column( | ||||||
| @@ -44,45 +68,55 @@ class CallScreen extends HookConsumerWidget { | |||||||
|             Text( |             Text( | ||||||
|               callState.isConnected |               callState.isConnected | ||||||
|                   ? formatDuration(callState.duration) |                   ? formatDuration(callState.duration) | ||||||
|                   : 'Connecting', |                   : (switch (callNotifier.room?.connectionState) { | ||||||
|  |                     ConnectionState.connected => 'connected', | ||||||
|  |                     ConnectionState.connecting => 'connecting', | ||||||
|  |                     ConnectionState.reconnecting => 'reconnecting', | ||||||
|  |                     _ => 'disconnected', | ||||||
|  |                   }).tr(), | ||||||
|               style: const TextStyle(fontSize: 14), |               style: const TextStyle(fontSize: 14), | ||||||
|             ), |             ), | ||||||
|           ], |           ], | ||||||
|         ), |         ), | ||||||
|         actions: [ |         actions: [ | ||||||
|           Row( |           if (!allAudioOnly) | ||||||
|             mainAxisAlignment: MainAxisAlignment.end, |             SingleChildScrollView( | ||||||
|             children: [ |               child: Row( | ||||||
|               IconButton( |                 spacing: 4, | ||||||
|                 icon: Icon(Symbols.grid_view), |                 children: [ | ||||||
|                 tooltip: 'Grid View', |                   for (final live in callNotifier.participants) | ||||||
|                 onPressed: () => viewMode.value = 'grid', |                     SpeakingRippleAvatar(live: live, size: 30), | ||||||
|                 color: |                   const Gap(8), | ||||||
|                     viewMode.value == 'grid' |                 ], | ||||||
|                         ? Theme.of(context).colorScheme.primary |  | ||||||
|                         : null, |  | ||||||
|               ), |               ), | ||||||
|               IconButton( |             ), | ||||||
|                 icon: Icon(Symbols.view_agenda), |  | ||||||
|                 tooltip: 'Stage View', |  | ||||||
|                 onPressed: () => viewMode.value = 'stage', |  | ||||||
|                 color: |  | ||||||
|                     viewMode.value == 'stage' |  | ||||||
|                         ? Theme.of(context).colorScheme.primary |  | ||||||
|                         : null, |  | ||||||
|               ), |  | ||||||
|             ], |  | ||||||
|           ), |  | ||||||
|           const Gap(8), |  | ||||||
|         ], |         ], | ||||||
|       ), |       ), | ||||||
|       body: |       body: | ||||||
|           callState.error != null |           callState.error != null | ||||||
|               ? Center( |               ? Center( | ||||||
|                 child: Text( |                 child: ConstrainedBox( | ||||||
|                   callState.error!, |                   constraints: const BoxConstraints(maxWidth: 320), | ||||||
|                   textAlign: TextAlign.center, |                   child: Column( | ||||||
|                   style: const TextStyle(color: Colors.red), |                     children: [ | ||||||
|  |                       const Icon(Symbols.error_outline, size: 48), | ||||||
|  |                       const Gap(4), | ||||||
|  |                       Text( | ||||||
|  |                         callState.error!, | ||||||
|  |                         textAlign: TextAlign.center, | ||||||
|  |                         style: const TextStyle(color: Color(0xFF757575)), | ||||||
|  |                       ), | ||||||
|  |                       const Gap(8), | ||||||
|  |                       TextButton( | ||||||
|  |                         onPressed: () { | ||||||
|  |                           callNotifier.disconnect(); | ||||||
|  |                           callNotifier.dispose(); | ||||||
|  |                           callNotifier.joinRoom(roomId); | ||||||
|  |                         }, | ||||||
|  |                         child: Text('retry').tr(), | ||||||
|  |                       ), | ||||||
|  |                     ], | ||||||
|  |                   ), | ||||||
|                 ), |                 ), | ||||||
|               ) |               ) | ||||||
|               : Column( |               : Column( | ||||||
| @@ -100,17 +134,8 @@ class CallScreen extends HookConsumerWidget { | |||||||
|                             child: Text('No participants in call'), |                             child: Text('No participants in call'), | ||||||
|                           ); |                           ); | ||||||
|                         } |                         } | ||||||
|  |  | ||||||
|                         final participants = callNotifier.participants; |                         final participants = callNotifier.participants; | ||||||
|                         final allAudioOnly = participants.every( |  | ||||||
|                           (p) => |  | ||||||
|                               !(p.hasVideo && |  | ||||||
|                                   p.remoteParticipant.trackPublications.values |  | ||||||
|                                       .any( |  | ||||||
|                                         (pub) => |  | ||||||
|                                             pub.track != null && |  | ||||||
|                                             pub.kind == TrackType.VIDEO, |  | ||||||
|                                       )), |  | ||||||
|                         ); |  | ||||||
|                         if (allAudioOnly) { |                         if (allAudioOnly) { | ||||||
|                           // Audio-only: show avatars in a compact row |                           // Audio-only: show avatars in a compact row | ||||||
|                           return Center( |                           return Center( | ||||||
| @@ -123,138 +148,41 @@ class CallScreen extends HookConsumerWidget { | |||||||
|                                 runSpacing: 8, |                                 runSpacing: 8, | ||||||
|                                 children: [ |                                 children: [ | ||||||
|                                   for (final live in participants) |                                   for (final live in participants) | ||||||
|                                     Padding( |                                     SpeakingRippleAvatar( | ||||||
|                                       padding: const EdgeInsets.symmetric( |                                       live: live, | ||||||
|                                         horizontal: 8, |                                       size: 72, | ||||||
|                                       ), |                                     ).padding(horizontal: 4), | ||||||
|                                       child: SpeakingRippleAvatar( |  | ||||||
|                                         isSpeaking: live.isSpeaking, |  | ||||||
|                                         audioLevel: |  | ||||||
|                                             live.remoteParticipant.audioLevel, |  | ||||||
|                                         pictureId: |  | ||||||
|                                             live |  | ||||||
|                                                 .participant |  | ||||||
|                                                 .profile |  | ||||||
|                                                 ?.account |  | ||||||
|                                                 .profile |  | ||||||
|                                                 .picture |  | ||||||
|                                                 ?.id, |  | ||||||
|                                         size: 72, |  | ||||||
|                                       ), |  | ||||||
|                                     ), |  | ||||||
|                                 ], |                                 ], | ||||||
|                               ), |                               ), | ||||||
|                             ), |                             ), | ||||||
|                           ); |                           ); | ||||||
|                         } |                         } | ||||||
|                         if (viewMode.value == 'stage') { |  | ||||||
|                           // Stage view: show main speaker(s) large, others in row |                         // Stage view: show main speaker(s) large, others in row | ||||||
|                           final mainSpeakers = |                         final mainSpeakers = | ||||||
|                               participants |                             participants | ||||||
|                                   .where( |                                 .where( | ||||||
|                                     (p) => p |                                   (p) => p | ||||||
|                                         .remoteParticipant |                                       .remoteParticipant | ||||||
|                                         .trackPublications |                                       .trackPublications | ||||||
|                                         .values |                                       .values | ||||||
|                                         .any( |                                       .any( | ||||||
|                                           (pub) => |                                         (pub) => | ||||||
|                                               pub.track != null && |                                             pub.track != null && | ||||||
|                                               pub.kind == TrackType.VIDEO, |                                             pub.kind == TrackType.VIDEO, | ||||||
|                                         ), |  | ||||||
|                                   ) |  | ||||||
|                                   .toList(); |  | ||||||
|                           if (mainSpeakers.isEmpty && participants.isNotEmpty) { |  | ||||||
|                             mainSpeakers.add(participants.first); |  | ||||||
|                           } |  | ||||||
|                           final others = |  | ||||||
|                               participants |  | ||||||
|                                   .where((p) => !mainSpeakers.contains(p)) |  | ||||||
|                                   .toList(); |  | ||||||
|                           return Column( |  | ||||||
|                             children: [ |  | ||||||
|                               Expanded( |  | ||||||
|                                 child: Row( |  | ||||||
|                                   mainAxisAlignment: MainAxisAlignment.center, |  | ||||||
|                                   children: [ |  | ||||||
|                                     for (final speaker in mainSpeakers) |  | ||||||
|                                       Expanded( |  | ||||||
|                                         child: |  | ||||||
|                                             AspectRatio( |  | ||||||
|                                               aspectRatio: 16 / 9, |  | ||||||
|                                               child: Card( |  | ||||||
|                                                 margin: EdgeInsets.zero, |  | ||||||
|                                                 child: ClipRRect( |  | ||||||
|                                                   borderRadius: |  | ||||||
|                                                       BorderRadius.circular(8), |  | ||||||
|                                                   child: Column( |  | ||||||
|                                                     children: [ |  | ||||||
|                                                       CallParticipantTile( |  | ||||||
|                                                         live: speaker, |  | ||||||
|                                                       ), |  | ||||||
|                                                     ], |  | ||||||
|                                                   ), |  | ||||||
|                                                 ), |  | ||||||
|                                               ), |  | ||||||
|                                             ).center(), |  | ||||||
|                                       ), |                                       ), | ||||||
|                                   ], |                                 ) | ||||||
|                                 ).padding(horizontal: 12), |                                 .toList(); | ||||||
|                               ), |                         if (mainSpeakers.isEmpty && participants.isNotEmpty) { | ||||||
|                               if (others.isNotEmpty) |                           mainSpeakers.add(participants.first); | ||||||
|                                 SizedBox( |  | ||||||
|                                   height: 100, |  | ||||||
|                                   child: ListView( |  | ||||||
|                                     scrollDirection: Axis.horizontal, |  | ||||||
|                                     children: [ |  | ||||||
|                                       for (final other in others) |  | ||||||
|                                         Padding( |  | ||||||
|                                           padding: const EdgeInsets.symmetric( |  | ||||||
|                                             horizontal: 8, |  | ||||||
|                                           ), |  | ||||||
|                                           child: CallParticipantTile( |  | ||||||
|                                             live: other, |  | ||||||
|                                           ), |  | ||||||
|                                         ), |  | ||||||
|                                     ], |  | ||||||
|                                   ), |  | ||||||
|                                 ), |  | ||||||
|                             ], |  | ||||||
|                           ); |  | ||||||
|                         } |                         } | ||||||
|                         // Default: grid view |                         return Column( | ||||||
|                         return GridView.builder( |                           children: [ | ||||||
|                           padding: const EdgeInsets.symmetric( |                             for (final speaker in mainSpeakers) | ||||||
|                             horizontal: 12, |                               Expanded( | ||||||
|                             vertical: 8, |                                 child: CallParticipantTile(live: speaker), | ||||||
|                           ), |  | ||||||
|                           gridDelegate: |  | ||||||
|                               SliverGridDelegateWithFixedCrossAxisCount( |  | ||||||
|                                 crossAxisCount: |  | ||||||
|                                     isWidestScreen(context) |  | ||||||
|                                         ? 4 |  | ||||||
|                                         : isWiderScreen(context) |  | ||||||
|                                         ? 3 |  | ||||||
|                                         : 2, |  | ||||||
|                                 childAspectRatio: 16 / 9, |  | ||||||
|                                 crossAxisSpacing: 8, |  | ||||||
|                                 mainAxisSpacing: 8, |  | ||||||
|                               ), |                               ), | ||||||
|                           itemCount: participants.length, |                           ], | ||||||
|                           itemBuilder: (context, idx) { |  | ||||||
|                             final live = participants[idx]; |  | ||||||
|                             return AspectRatio( |  | ||||||
|                               aspectRatio: 16 / 9, |  | ||||||
|                               child: Card( |  | ||||||
|                                 margin: EdgeInsets.zero, |  | ||||||
|                                 child: ClipRRect( |  | ||||||
|                                   borderRadius: BorderRadius.circular(8), |  | ||||||
|                                   child: Column( |  | ||||||
|                                     children: [CallParticipantTile(live: live)], |  | ||||||
|                                   ), |  | ||||||
|                                 ), |  | ||||||
|                               ), |  | ||||||
|                             ).center(); |  | ||||||
|                           }, |  | ||||||
|                         ); |                         ); | ||||||
|                       }, |                       }, | ||||||
|                     ), |                     ), | ||||||
|   | |||||||
| @@ -21,7 +21,6 @@ import 'package:island/services/responsive.dart'; | |||||||
| import 'package:island/widgets/account/account_picker.dart'; | import 'package:island/widgets/account/account_picker.dart'; | ||||||
| import 'package:island/widgets/alert.dart'; | import 'package:island/widgets/alert.dart'; | ||||||
| import 'package:island/widgets/app_scaffold.dart'; | import 'package:island/widgets/app_scaffold.dart'; | ||||||
| import 'package:island/widgets/chat/call_overlay.dart'; |  | ||||||
| import 'package:island/widgets/content/cloud_files.dart'; | import 'package:island/widgets/content/cloud_files.dart'; | ||||||
| import 'package:island/widgets/content/sheet.dart'; | import 'package:island/widgets/content/sheet.dart'; | ||||||
| import 'package:island/widgets/realms/selection_dropdown.dart'; | import 'package:island/widgets/realms/selection_dropdown.dart'; | ||||||
| @@ -346,91 +345,79 @@ class ChatListScreen extends HookConsumerWidget { | |||||||
|         child: const Icon(Symbols.add), |         child: const Icon(Symbols.add), | ||||||
|       ), |       ), | ||||||
|       floatingActionButtonLocation: TabbedFabLocation(context), |       floatingActionButtonLocation: TabbedFabLocation(context), | ||||||
|       body: Stack( |       body: Column( | ||||||
|         children: [ |         children: [ | ||||||
|           Column( |           Consumer( | ||||||
|             children: [ |             builder: (context, ref, _) { | ||||||
|               Consumer( |               final summaryState = ref.watch(chatSummaryProvider); | ||||||
|                 builder: (context, ref, _) { |               return summaryState.maybeWhen( | ||||||
|                   final summaryState = ref.watch(chatSummaryProvider); |                 loading: | ||||||
|                   return summaryState.maybeWhen( |                     () => const LinearProgressIndicator( | ||||||
|                     loading: |                       minHeight: 2, | ||||||
|                         () => const LinearProgressIndicator( |                       borderRadius: BorderRadius.zero, | ||||||
|                           minHeight: 2, |                     ), | ||||||
|                           borderRadius: BorderRadius.zero, |                 orElse: () => const SizedBox.shrink(), | ||||||
|                         ), |               ); | ||||||
|                     orElse: () => const SizedBox.shrink(), |             }, | ||||||
|                   ); |           ), | ||||||
|                 }, |           Expanded( | ||||||
|               ), |             child: chats.when( | ||||||
|               Expanded( |               data: | ||||||
|                 child: chats.when( |                   (items) => RefreshIndicator( | ||||||
|                   data: |                     onRefresh: | ||||||
|                       (items) => RefreshIndicator( |                         () => Future.sync(() { | ||||||
|                         onRefresh: |                           ref.invalidate(chatroomsJoinedProvider); | ||||||
|                             () => Future.sync(() { |                         }), | ||||||
|                               ref.invalidate(chatroomsJoinedProvider); |                     child: ListView.builder( | ||||||
|                             }), |                       padding: getTabbedPadding( | ||||||
|                         child: ListView.builder( |                         context, | ||||||
|                           padding: getTabbedPadding( |                         bottom: callState.isConnected ? 96 : null, | ||||||
|                             context, |                       ), | ||||||
|                             bottom: callState.isConnected ? 96 : null, |                       itemCount: | ||||||
|                           ), |                           items | ||||||
|                           itemCount: |                               .where( | ||||||
|                               items |                                 (item) => | ||||||
|                                   .where( |                                     selectedTab.value == 0 || | ||||||
|                                     (item) => |                                     (selectedTab.value == 1 && | ||||||
|                                         selectedTab.value == 0 || |                                         item.type == 1) || | ||||||
|                                         (selectedTab.value == 1 && |                                     (selectedTab.value == 2 && item.type != 1), | ||||||
|                                             item.type == 1) || |                               ) | ||||||
|                                         (selectedTab.value == 2 && |                               .length, | ||||||
|                                             item.type != 1), |                       itemBuilder: (context, index) { | ||||||
|                                   ) |                         final filteredItems = | ||||||
|                                   .length, |                             items | ||||||
|                           itemBuilder: (context, index) { |                                 .where( | ||||||
|                             final filteredItems = |                                   (item) => | ||||||
|                                 items |                                       selectedTab.value == 0 || | ||||||
|                                     .where( |                                       (selectedTab.value == 1 && | ||||||
|                                       (item) => |                                           item.type == 1) || | ||||||
|                                           selectedTab.value == 0 || |                                       (selectedTab.value == 2 && | ||||||
|                                           (selectedTab.value == 1 && |                                           item.type != 1), | ||||||
|                                               item.type == 1) || |                                 ) | ||||||
|                                           (selectedTab.value == 2 && |                                 .toList(); | ||||||
|                                               item.type != 1), |                         final item = filteredItems[index]; | ||||||
|                                     ) |                         return ChatRoomListTile( | ||||||
|                                     .toList(); |                           room: item, | ||||||
|                             final item = filteredItems[index]; |                           isDirect: item.type == 1, | ||||||
|                             return ChatRoomListTile( |                           onTap: () { | ||||||
|                               room: item, |                             context.pushNamed( | ||||||
|                               isDirect: item.type == 1, |                               'chatRoom', | ||||||
|                               onTap: () { |                               pathParameters: {'id': item.id}, | ||||||
|                                 context.pushNamed( |  | ||||||
|                                   'chatRoom', |  | ||||||
|                                   pathParameters: {'id': item.id}, |  | ||||||
|                                 ); |  | ||||||
|                               }, |  | ||||||
|                             ); |                             ); | ||||||
|                           }, |                           }, | ||||||
|                         ), |                         ); | ||||||
|                       ), |                       }, | ||||||
|                   loading: |                     ), | ||||||
|                       () => const Center(child: CircularProgressIndicator()), |                   ), | ||||||
|                   error: |               loading: () => const Center(child: CircularProgressIndicator()), | ||||||
|                       (error, stack) => ResponseErrorWidget( |               error: | ||||||
|                         error: error, |                   (error, stack) => ResponseErrorWidget( | ||||||
|                         onRetry: () { |                     error: error, | ||||||
|                           ref.invalidate(chatroomsJoinedProvider); |                     onRetry: () { | ||||||
|                         }, |                       ref.invalidate(chatroomsJoinedProvider); | ||||||
|                       ), |                     }, | ||||||
|                 ), |                   ), | ||||||
|               ), |             ), | ||||||
|             ], |  | ||||||
|           ), |  | ||||||
|           Positioned( |  | ||||||
|             left: 0, |  | ||||||
|             right: 0, |  | ||||||
|             bottom: getTabbedPadding(context).bottom + 8, |  | ||||||
|             child: const CallOverlayBar().padding(horizontal: 16, vertical: 12), |  | ||||||
|           ), |           ), | ||||||
|         ], |         ], | ||||||
|       ), |       ), | ||||||
|   | |||||||
| @@ -1070,6 +1070,10 @@ class _ChatInput extends HookConsumerWidget { | |||||||
|                     item: attachments[idx], |                     item: attachments[idx], | ||||||
|                     onRequestUpload: () => onUploadAttachment(idx), |                     onRequestUpload: () => onUploadAttachment(idx), | ||||||
|                     onDelete: () => onDeleteAttachment(idx), |                     onDelete: () => onDeleteAttachment(idx), | ||||||
|  |                     onUpdate: (value) { | ||||||
|  |                       attachments[idx] = value; | ||||||
|  |                       onAttachmentsChanged(attachments); | ||||||
|  |                     }, | ||||||
|                     onMove: (delta) => onMoveAttachment(idx, delta), |                     onMove: (delta) => onMoveAttachment(idx, delta), | ||||||
|                   ); |                   ); | ||||||
|                 }, |                 }, | ||||||
|   | |||||||
| @@ -41,7 +41,7 @@ class ChatDetailScreen extends HookConsumerWidget { | |||||||
|       try { |       try { | ||||||
|         final client = ref.watch(apiClientProvider); |         final client = ref.watch(apiClientProvider); | ||||||
|         await client.patch( |         await client.patch( | ||||||
|           '/chat/$id/members/me/notify', |           '/sphere/chat/$id/members/me/notify', | ||||||
|           data: {'notify_level': level}, |           data: {'notify_level': level}, | ||||||
|         ); |         ); | ||||||
|         ref.invalidate(chatroomIdentityProvider(id)); |         ref.invalidate(chatroomIdentityProvider(id)); | ||||||
| @@ -59,7 +59,7 @@ class ChatDetailScreen extends HookConsumerWidget { | |||||||
|       try { |       try { | ||||||
|         final client = ref.watch(apiClientProvider); |         final client = ref.watch(apiClientProvider); | ||||||
|         await client.patch( |         await client.patch( | ||||||
|           '/chat/$id/members/me/notify', |           '/sphere/chat/$id/members/me/notify', | ||||||
|           data: {'break_until': until.toUtc().toIso8601String()}, |           data: {'break_until': until.toUtc().toIso8601String()}, | ||||||
|         ); |         ); | ||||||
|         ref.invalidate(chatroomProvider(id)); |         ref.invalidate(chatroomProvider(id)); | ||||||
| @@ -421,10 +421,10 @@ class _ChatRoomActionMenu extends HookConsumerWidget { | |||||||
|                   showConfirmAlert( |                   showConfirmAlert( | ||||||
|                     'deleteChatRoomHint'.tr(), |                     'deleteChatRoomHint'.tr(), | ||||||
|                     'deleteChatRoom'.tr(), |                     'deleteChatRoom'.tr(), | ||||||
|                   ).then((confirm) { |                   ).then((confirm) async { | ||||||
|                     if (confirm) { |                     if (confirm) { | ||||||
|                       final client = ref.watch(apiClientProvider); |                       final client = ref.watch(apiClientProvider); | ||||||
|                       client.delete('/sphere/chat/$id'); |                       await client.delete('/sphere/chat/$id'); | ||||||
|                       ref.invalidate(chatroomsJoinedProvider); |                       ref.invalidate(chatroomsJoinedProvider); | ||||||
|                       if (context.mounted) { |                       if (context.mounted) { | ||||||
|                         context.pop(); |                         context.pop(); | ||||||
| @@ -454,10 +454,10 @@ class _ChatRoomActionMenu extends HookConsumerWidget { | |||||||
|                   showConfirmAlert( |                   showConfirmAlert( | ||||||
|                     'leaveChatRoomHint'.tr(), |                     'leaveChatRoomHint'.tr(), | ||||||
|                     'leaveChatRoom'.tr(), |                     'leaveChatRoom'.tr(), | ||||||
|                   ).then((confirm) { |                   ).then((confirm) async { | ||||||
|                     if (confirm) { |                     if (confirm) { | ||||||
|                       final client = ref.watch(apiClientProvider); |                       final client = ref.watch(apiClientProvider); | ||||||
|                       client.delete('/sphere/chat/$id/members/me'); |                       await client.delete('/sphere/chat/$id/members/me'); | ||||||
|                       ref.invalidate(chatroomsJoinedProvider); |                       ref.invalidate(chatroomsJoinedProvider); | ||||||
|                       if (context.mounted) { |                       if (context.mounted) { | ||||||
|                         context.pop(); |                         context.pop(); | ||||||
|   | |||||||
| @@ -114,9 +114,9 @@ class CreatorHubShellScreen extends StatelessWidget { | |||||||
|         isRoot: true, |         isRoot: true, | ||||||
|         child: Row( |         child: Row( | ||||||
|           children: [ |           children: [ | ||||||
|             SizedBox(width: 360, child: const CreatorHubScreen(isAside: true)), |             Flexible(flex: 2, child: const CreatorHubScreen(isAside: true)), | ||||||
|             const VerticalDivider(width: 1), |             const VerticalDivider(width: 1), | ||||||
|             Expanded(child: child), |             Flexible(flex: 3, child: child), | ||||||
|           ], |           ], | ||||||
|         ), |         ), | ||||||
|       ); |       ); | ||||||
| @@ -380,6 +380,23 @@ class CreatorHubScreen extends HookConsumerWidget { | |||||||
|                               ); |                               ); | ||||||
|                             }, |                             }, | ||||||
|                           ), |                           ), | ||||||
|  |                           ListTile( | ||||||
|  |                             minTileHeight: 48, | ||||||
|  |                             title: const Text('Polls'), | ||||||
|  |                             trailing: const Icon(Symbols.chevron_right), | ||||||
|  |                             leading: const Icon(Symbols.poll), | ||||||
|  |                             contentPadding: const EdgeInsets.symmetric( | ||||||
|  |                               horizontal: 24, | ||||||
|  |                             ), | ||||||
|  |                             onTap: () { | ||||||
|  |                               context.pushNamed( | ||||||
|  |                                 'creatorPolls', | ||||||
|  |                                 pathParameters: { | ||||||
|  |                                   'name': currentPublisher.value!.name, | ||||||
|  |                                 }, | ||||||
|  |                               ); | ||||||
|  |                             }, | ||||||
|  |                           ), | ||||||
|                           ListTile( |                           ListTile( | ||||||
|                             minTileHeight: 48, |                             minTileHeight: 48, | ||||||
|                             title: Text('publisherMembers').tr(), |                             title: Text('publisherMembers').tr(), | ||||||
|   | |||||||
							
								
								
									
										175
									
								
								lib/screens/creators/poll/poll_list.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										175
									
								
								lib/screens/creators/poll/poll_list.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,175 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:go_router/go_router.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
|  | import 'package:island/models/poll.dart'; | ||||||
|  | import 'package:island/pods/network.dart'; | ||||||
|  | import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||||
|  | import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||||
|  |  | ||||||
|  | part 'poll_list.g.dart'; | ||||||
|  |  | ||||||
|  | @riverpod | ||||||
|  | class PollListNotifier extends _$PollListNotifier | ||||||
|  |     with CursorPagingNotifierMixin<SnPoll> { | ||||||
|  |   static const int _pageSize = 20; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<CursorPagingData<SnPoll>> build(String? pubName) { | ||||||
|  |     // immediately load first page | ||||||
|  |     return fetch(cursor: null); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<CursorPagingData<SnPoll>> fetch({required String? cursor}) async { | ||||||
|  |     final client = ref.read(apiClientProvider); | ||||||
|  |     final offset = cursor == null ? 0 : int.parse(cursor); | ||||||
|  |  | ||||||
|  |     // read the current family argument passed to provider | ||||||
|  |     final currentPub = pubName; | ||||||
|  |     final queryParams = { | ||||||
|  |       'offset': offset, | ||||||
|  |       'take': _pageSize, | ||||||
|  |       if (currentPub != null) 'pub': currentPub, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     final response = await client.get( | ||||||
|  |       '/sphere/polls/me', | ||||||
|  |       queryParameters: queryParams, | ||||||
|  |     ); | ||||||
|  |     final total = int.parse(response.headers.value('X-Total') ?? '0'); | ||||||
|  |     final List<dynamic> data = response.data; | ||||||
|  |     final items = data.map((json) => SnPoll.fromJson(json)).toList(); | ||||||
|  |  | ||||||
|  |     final hasMore = offset + items.length < total; | ||||||
|  |     final nextCursor = hasMore ? (offset + items.length).toString() : null; | ||||||
|  |  | ||||||
|  |     return CursorPagingData( | ||||||
|  |       items: items, | ||||||
|  |       hasMore: hasMore, | ||||||
|  |       nextCursor: nextCursor, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class CreatorPollListScreen extends HookConsumerWidget { | ||||||
|  |   const CreatorPollListScreen({super.key, required this.pubName}); | ||||||
|  |  | ||||||
|  |   final String pubName; | ||||||
|  |  | ||||||
|  |   Future<void> _createPoll(BuildContext context) async { | ||||||
|  |     // Use named route defined in router with :name param (creatorPollNew) | ||||||
|  |     final result = await GoRouter.of( | ||||||
|  |       context, | ||||||
|  |     ).pushNamed('creatorPollNew', pathParameters: {'name': pubName}); | ||||||
|  |     // If PollEditorScreen returns a created SnPoll on success, pop back with it | ||||||
|  |     if (result is SnPoll && context.mounted) { | ||||||
|  |       Navigator.of(context).maybePop(result); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     return Scaffold( | ||||||
|  |       appBar: AppBar(title: const Text('Polls')), | ||||||
|  |       floatingActionButton: FloatingActionButton( | ||||||
|  |         onPressed: () => _createPoll(context), | ||||||
|  |         child: const Icon(Icons.add), | ||||||
|  |       ), | ||||||
|  |       body: RefreshIndicator( | ||||||
|  |         onRefresh: () => ref.refresh(pollListNotifierProvider(pubName).future), | ||||||
|  |         child: CustomScrollView( | ||||||
|  |           slivers: [ | ||||||
|  |             PagingHelperSliverView( | ||||||
|  |               provider: pollListNotifierProvider(pubName), | ||||||
|  |               futureRefreshable: pollListNotifierProvider(pubName).future, | ||||||
|  |               notifierRefreshable: pollListNotifierProvider(pubName).notifier, | ||||||
|  |               contentBuilder: | ||||||
|  |                   (data, widgetCount, endItemView) => SliverList.builder( | ||||||
|  |                     itemCount: widgetCount, | ||||||
|  |                     itemBuilder: (context, index) { | ||||||
|  |                       if (index == widgetCount - 1) { | ||||||
|  |                         return endItemView; | ||||||
|  |                       } | ||||||
|  |                       final poll = data.items[index]; | ||||||
|  |                       return _CreatorPollItem(poll: poll); | ||||||
|  |                     }, | ||||||
|  |                   ), | ||||||
|  |             ), | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _CreatorPollItem extends StatelessWidget { | ||||||
|  |   const _CreatorPollItem({required this.poll}); | ||||||
|  |  | ||||||
|  |   final SnPoll poll; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     final theme = Theme.of(context); | ||||||
|  |     final ended = poll.endedAt; | ||||||
|  |     final endedText = | ||||||
|  |         ended == null | ||||||
|  |             ? 'No end' | ||||||
|  |             : MaterialLocalizations.of(context).formatFullDate(ended); | ||||||
|  |  | ||||||
|  |     return Card( | ||||||
|  |       margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), | ||||||
|  |       clipBehavior: Clip.antiAlias, | ||||||
|  |       child: ListTile( | ||||||
|  |         title: Text(poll.title ?? 'Untitled poll'), | ||||||
|  |         subtitle: Column( | ||||||
|  |           crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |           children: [ | ||||||
|  |             if (poll.description != null && poll.description!.isNotEmpty) | ||||||
|  |               Padding( | ||||||
|  |                 padding: const EdgeInsets.only(top: 4), | ||||||
|  |                 child: Text( | ||||||
|  |                   poll.description!, | ||||||
|  |                   maxLines: 2, | ||||||
|  |                   overflow: TextOverflow.ellipsis, | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |             Padding( | ||||||
|  |               padding: const EdgeInsets.only(top: 4), | ||||||
|  |               child: Text( | ||||||
|  |                 'Questions: ${poll.questions.length} · Ends: $endedText', | ||||||
|  |                 style: theme.textTheme.bodySmall, | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |         trailing: PopupMenuButton<String>( | ||||||
|  |           onSelected: (v) { | ||||||
|  |             switch (v) { | ||||||
|  |               case 'edit': | ||||||
|  |                 // Use global router helper if desired | ||||||
|  |                 // context.push('/creators/${poll.publisher?.name ?? ''}/polls/${poll.id}/edit'); | ||||||
|  |                 Navigator.of(context).pushNamed( | ||||||
|  |                   'creatorPollEdit', | ||||||
|  |                   arguments: { | ||||||
|  |                     'name': poll.publisher?.name ?? '', | ||||||
|  |                     'id': poll.id, | ||||||
|  |                   }, | ||||||
|  |                 ); | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|  |           }, | ||||||
|  |           itemBuilder: | ||||||
|  |               (context) => [ | ||||||
|  |                 const PopupMenuItem(value: 'edit', child: Text('Edit')), | ||||||
|  |               ], | ||||||
|  |         ), | ||||||
|  |         onTap: () { | ||||||
|  |           // Open editor for edit | ||||||
|  |           // Navigator push by path to keep consistency with rest of app: | ||||||
|  |           // Note: pub name string may be required in route; when absent, route may need query or pick later. | ||||||
|  |           // For safety, just do nothing if no publisher in list item. | ||||||
|  |         }, | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										179
									
								
								lib/screens/creators/poll/poll_list.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								lib/screens/creators/poll/poll_list.g.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,179 @@ | |||||||
|  | // GENERATED CODE - DO NOT MODIFY BY HAND | ||||||
|  |  | ||||||
|  | part of 'poll_list.dart'; | ||||||
|  |  | ||||||
|  | // ************************************************************************** | ||||||
|  | // RiverpodGenerator | ||||||
|  | // ************************************************************************** | ||||||
|  |  | ||||||
|  | String _$pollListNotifierHash() => r'd3da24ff6bbb8f35b06d57fc41625dc0312508e4'; | ||||||
|  |  | ||||||
|  | /// Copied from Dart SDK | ||||||
|  | class _SystemHash { | ||||||
|  |   _SystemHash._(); | ||||||
|  |  | ||||||
|  |   static int combine(int hash, int value) { | ||||||
|  |     // ignore: parameter_assignments | ||||||
|  |     hash = 0x1fffffff & (hash + value); | ||||||
|  |     // ignore: parameter_assignments | ||||||
|  |     hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); | ||||||
|  |     return hash ^ (hash >> 6); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   static int finish(int hash) { | ||||||
|  |     // ignore: parameter_assignments | ||||||
|  |     hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); | ||||||
|  |     // ignore: parameter_assignments | ||||||
|  |     hash = hash ^ (hash >> 11); | ||||||
|  |     return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | abstract class _$PollListNotifier | ||||||
|  |     extends BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnPoll>> { | ||||||
|  |   late final String? pubName; | ||||||
|  |  | ||||||
|  |   FutureOr<CursorPagingData<SnPoll>> build(String? pubName); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// See also [PollListNotifier]. | ||||||
|  | @ProviderFor(PollListNotifier) | ||||||
|  | const pollListNotifierProvider = PollListNotifierFamily(); | ||||||
|  |  | ||||||
|  | /// See also [PollListNotifier]. | ||||||
|  | class PollListNotifierFamily | ||||||
|  |     extends Family<AsyncValue<CursorPagingData<SnPoll>>> { | ||||||
|  |   /// See also [PollListNotifier]. | ||||||
|  |   const PollListNotifierFamily(); | ||||||
|  |  | ||||||
|  |   /// See also [PollListNotifier]. | ||||||
|  |   PollListNotifierProvider call(String? pubName) { | ||||||
|  |     return PollListNotifierProvider(pubName); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   PollListNotifierProvider getProviderOverride( | ||||||
|  |     covariant PollListNotifierProvider provider, | ||||||
|  |   ) { | ||||||
|  |     return call(provider.pubName); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   static const Iterable<ProviderOrFamily>? _dependencies = null; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Iterable<ProviderOrFamily>? get dependencies => _dependencies; | ||||||
|  |  | ||||||
|  |   static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Iterable<ProviderOrFamily>? get allTransitiveDependencies => | ||||||
|  |       _allTransitiveDependencies; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? get name => r'pollListNotifierProvider'; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// See also [PollListNotifier]. | ||||||
|  | class PollListNotifierProvider | ||||||
|  |     extends | ||||||
|  |         AutoDisposeAsyncNotifierProviderImpl< | ||||||
|  |           PollListNotifier, | ||||||
|  |           CursorPagingData<SnPoll> | ||||||
|  |         > { | ||||||
|  |   /// See also [PollListNotifier]. | ||||||
|  |   PollListNotifierProvider(String? pubName) | ||||||
|  |     : this._internal( | ||||||
|  |         () => PollListNotifier()..pubName = pubName, | ||||||
|  |         from: pollListNotifierProvider, | ||||||
|  |         name: r'pollListNotifierProvider', | ||||||
|  |         debugGetCreateSourceHash: | ||||||
|  |             const bool.fromEnvironment('dart.vm.product') | ||||||
|  |                 ? null | ||||||
|  |                 : _$pollListNotifierHash, | ||||||
|  |         dependencies: PollListNotifierFamily._dependencies, | ||||||
|  |         allTransitiveDependencies: | ||||||
|  |             PollListNotifierFamily._allTransitiveDependencies, | ||||||
|  |         pubName: pubName, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |   PollListNotifierProvider._internal( | ||||||
|  |     super._createNotifier, { | ||||||
|  |     required super.name, | ||||||
|  |     required super.dependencies, | ||||||
|  |     required super.allTransitiveDependencies, | ||||||
|  |     required super.debugGetCreateSourceHash, | ||||||
|  |     required super.from, | ||||||
|  |     required this.pubName, | ||||||
|  |   }) : super.internal(); | ||||||
|  |  | ||||||
|  |   final String? pubName; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   FutureOr<CursorPagingData<SnPoll>> runNotifierBuild( | ||||||
|  |     covariant PollListNotifier notifier, | ||||||
|  |   ) { | ||||||
|  |     return notifier.build(pubName); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Override overrideWith(PollListNotifier Function() create) { | ||||||
|  |     return ProviderOverride( | ||||||
|  |       origin: this, | ||||||
|  |       override: PollListNotifierProvider._internal( | ||||||
|  |         () => create()..pubName = pubName, | ||||||
|  |         from: from, | ||||||
|  |         name: null, | ||||||
|  |         dependencies: null, | ||||||
|  |         allTransitiveDependencies: null, | ||||||
|  |         debugGetCreateSourceHash: null, | ||||||
|  |         pubName: pubName, | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   AutoDisposeAsyncNotifierProviderElement< | ||||||
|  |     PollListNotifier, | ||||||
|  |     CursorPagingData<SnPoll> | ||||||
|  |   > | ||||||
|  |   createElement() { | ||||||
|  |     return _PollListNotifierProviderElement(this); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   bool operator ==(Object other) { | ||||||
|  |     return other is PollListNotifierProvider && other.pubName == pubName; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   int get hashCode { | ||||||
|  |     var hash = _SystemHash.combine(0, runtimeType.hashCode); | ||||||
|  |     hash = _SystemHash.combine(hash, pubName.hashCode); | ||||||
|  |  | ||||||
|  |     return _SystemHash.finish(hash); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||||
|  | // ignore: unused_element | ||||||
|  | mixin PollListNotifierRef | ||||||
|  |     on AutoDisposeAsyncNotifierProviderRef<CursorPagingData<SnPoll>> { | ||||||
|  |   /// The parameter `pubName` of this provider. | ||||||
|  |   String? get pubName; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _PollListNotifierProviderElement | ||||||
|  |     extends | ||||||
|  |         AutoDisposeAsyncNotifierProviderElement< | ||||||
|  |           PollListNotifier, | ||||||
|  |           CursorPagingData<SnPoll> | ||||||
|  |         > | ||||||
|  |     with PollListNotifierRef { | ||||||
|  |   _PollListNotifierProviderElement(super.provider); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? get pubName => (origin as PollListNotifierProvider).pubName; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 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 | ||||||
| @@ -26,7 +26,7 @@ part 'pack_detail.freezed.dart'; | |||||||
| @riverpod | @riverpod | ||||||
| Future<List<SnSticker>> stickerPackContent(Ref ref, String packId) async { | Future<List<SnSticker>> stickerPackContent(Ref ref, String packId) async { | ||||||
|   final apiClient = ref.watch(apiClientProvider); |   final apiClient = ref.watch(apiClientProvider); | ||||||
|   final resp = await apiClient.get('/stickers/$packId/content'); |   final resp = await apiClient.get('/sphere/stickers/$packId/content'); | ||||||
|   return resp.data |   return resp.data | ||||||
|       .map<SnSticker>((e) => SnSticker.fromJson(e)) |       .map<SnSticker>((e) => SnSticker.fromJson(e)) | ||||||
|       .cast<SnSticker>() |       .cast<SnSticker>() | ||||||
| @@ -74,13 +74,16 @@ class StickerPackDetailScreen extends HookConsumerWidget { | |||||||
|           IconButton( |           IconButton( | ||||||
|             icon: const Icon(Symbols.add_circle), |             icon: const Icon(Symbols.add_circle), | ||||||
|             onPressed: () { |             onPressed: () { | ||||||
|               context.pushNamed('creatorStickerNew', pathParameters: {'packId': id}).then(( |               context | ||||||
|                 value, |                   .pushNamed( | ||||||
|               ) { |                     'creatorStickerNew', | ||||||
|                 if (value != null) { |                     pathParameters: {'name': pubName, 'packId': id}, | ||||||
|                   ref.invalidate(stickerPackContentProvider(id)); |                   ) | ||||||
|                 } |                   .then((value) { | ||||||
|               }); |                     if (value != null) { | ||||||
|  |                       ref.invalidate(stickerPackContentProvider(id)); | ||||||
|  |                     } | ||||||
|  |                   }); | ||||||
|             }, |             }, | ||||||
|           ), |           ), | ||||||
|           _StickerPackActionMenu( |           _StickerPackActionMenu( | ||||||
| @@ -173,9 +176,13 @@ class StickerPackDetailScreen extends HookConsumerWidget { | |||||||
|                                         title: 'edit'.tr(), |                                         title: 'edit'.tr(), | ||||||
|                                         image: MenuImage.icon(Symbols.edit), |                                         image: MenuImage.icon(Symbols.edit), | ||||||
|                                         callback: () { |                                         callback: () { | ||||||
|                                           context.pushNamed( |                                           context | ||||||
|  |                                               .pushNamed( | ||||||
|                                                 'creatorStickerEdit', |                                                 'creatorStickerEdit', | ||||||
|                                                 pathParameters: {'packId': id, 'id': sticker.id}, |                                                 pathParameters: { | ||||||
|  |                                                   'packId': id, | ||||||
|  |                                                   'id': sticker.id, | ||||||
|  |                                                 }, | ||||||
|                                               ) |                                               ) | ||||||
|                                               .then((value) { |                                               .then((value) { | ||||||
|                                                 if (value != null) { |                                                 if (value != null) { | ||||||
| @@ -259,9 +266,7 @@ class _StickerPackActionMenu extends HookConsumerWidget { | |||||||
|           (context) => [ |           (context) => [ | ||||||
|             PopupMenuItem( |             PopupMenuItem( | ||||||
|               onTap: () { |               onTap: () { | ||||||
|                 context.push( |                 context.push('/creators/$pubName/stickers/$packId/edit'); | ||||||
|                   '/creators/$pubName/stickers/$packId/edit', |  | ||||||
|                 ); |  | ||||||
|               }, |               }, | ||||||
|               child: Row( |               child: Row( | ||||||
|                 children: [ |                 children: [ | ||||||
|   | |||||||
| @@ -7,7 +7,7 @@ part of 'pack_detail.dart'; | |||||||
| // ************************************************************************** | // ************************************************************************** | ||||||
|  |  | ||||||
| String _$stickerPackContentHash() => | String _$stickerPackContentHash() => | ||||||
|     r'78de848fba1f341f217f8ae4b9eef2d8afa67964'; |     r'42d74f51022e67e35cb601c2f30f4f02e1f2be9d'; | ||||||
|  |  | ||||||
| /// Copied from Dart SDK | /// Copied from Dart SDK | ||||||
| class _SystemHash { | class _SystemHash { | ||||||
|   | |||||||
| @@ -31,7 +31,7 @@ class StickersScreen extends HookConsumerWidget { | |||||||
|               context |               context | ||||||
|                   .pushNamed( |                   .pushNamed( | ||||||
|                     'creatorStickerPackNew', |                     'creatorStickerPackNew', | ||||||
|                     queryParameters: {'pubName': pubName}, |                     queryParameters: {'name': pubName}, | ||||||
|                   ) |                   ) | ||||||
|                   .then((value) { |                   .then((value) { | ||||||
|                     if (value != null) { |                     if (value != null) { | ||||||
| @@ -76,7 +76,7 @@ class SliverStickerPacksList extends HookConsumerWidget { | |||||||
|                 onTap: () { |                 onTap: () { | ||||||
|                   context.pushNamed( |                   context.pushNamed( | ||||||
|                     'creatorStickerPackDetail', |                     'creatorStickerPackDetail', | ||||||
|                     pathParameters: {'pubName': pubName, 'packId': sticker.id}, |                     pathParameters: {'name': pubName, 'packId': sticker.id}, | ||||||
|                   ); |                   ); | ||||||
|                 }, |                 }, | ||||||
|               ); |               ); | ||||||
|   | |||||||
| @@ -51,12 +51,9 @@ class DeveloperHubShellScreen extends StatelessWidget { | |||||||
|         isRoot: true, |         isRoot: true, | ||||||
|         child: Row( |         child: Row( | ||||||
|           children: [ |           children: [ | ||||||
|             SizedBox( |             Flexible(flex: 2, child: const DeveloperHubScreen(isAside: true)), | ||||||
|               width: 360, |  | ||||||
|               child: const DeveloperHubScreen(isAside: true), |  | ||||||
|             ), |  | ||||||
|             const VerticalDivider(width: 1), |             const VerticalDivider(width: 1), | ||||||
|             Expanded(child: child), |             Flexible(flex: 3, child: child), | ||||||
|           ], |           ], | ||||||
|         ), |         ), | ||||||
|       ); |       ); | ||||||
| @@ -114,7 +111,7 @@ class DeveloperHubScreen extends HookConsumerWidget { | |||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       noBackground: false, |       isNoBackground: false, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         leading: !isWide ? const PageBackButton() : null, |         leading: !isWide ? const PageBackButton() : null, | ||||||
|         title: Text('developerHub').tr(), |         title: Text('developerHub').tr(), | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ class DiscoveryRealmsScreen extends HookConsumerWidget { | |||||||
|     final currentQuery = useState<String?>(null); |     final currentQuery = useState<String?>(null); | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       noBackground: false, |       isNoBackground: false, | ||||||
|       appBar: AppBar(title: Text('discoverRealms'.tr())), |       appBar: AppBar(title: Text('discoverRealms'.tr())), | ||||||
|       body: Stack( |       body: Stack( | ||||||
|         children: [ |         children: [ | ||||||
|   | |||||||
| @@ -84,8 +84,10 @@ class ExploreScreen extends HookConsumerWidget { | |||||||
|       selectedDay.value = day; |       selectedDay.value = day; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     final user = ref.watch(userInfoProvider); | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       noBackground: false, |       isNoBackground: false, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         toolbarHeight: 0, |         toolbarHeight: 0, | ||||||
|         bottom: PreferredSize( |         bottom: PreferredSize( | ||||||
| @@ -167,67 +169,100 @@ class ExploreScreen extends HookConsumerWidget { | |||||||
|               ), |               ), | ||||||
|         ), |         ), | ||||||
|       ), |       ), | ||||||
|       floatingActionButton: FloatingActionButton( |       floatingActionButton: InkWell( | ||||||
|         heroTag: Key("explore-page-fab"), |         onLongPress: () { | ||||||
|         onPressed: () { |           context.pushNamed('postCompose', queryParameters: {'type': '1'}).then( | ||||||
|           context.pushNamed('postCompose').then((value) { |             (value) { | ||||||
|             if (value != null) { |               if (value != null) { | ||||||
|               activitiesNotifier.forceRefresh(); |                 activitiesNotifier.forceRefresh(); | ||||||
|             } |               } | ||||||
|           }); |             }, | ||||||
|  |           ); | ||||||
|         }, |         }, | ||||||
|         child: const Icon(Symbols.edit), |         child: FloatingActionButton( | ||||||
|  |           heroTag: Key("explore-page-fab"), | ||||||
|  |           onPressed: () { | ||||||
|  |             context.pushNamed('postCompose').then((value) { | ||||||
|  |               if (value != null) { | ||||||
|  |                 activitiesNotifier.forceRefresh(); | ||||||
|  |               } | ||||||
|  |             }); | ||||||
|  |           }, | ||||||
|  |           child: const Icon(Symbols.edit), | ||||||
|  |         ), | ||||||
|       ), |       ), | ||||||
|       floatingActionButtonLocation: TabbedFabLocation(context), |       floatingActionButtonLocation: TabbedFabLocation(context), | ||||||
|       body: Builder( |       body: Builder( | ||||||
|         builder: (context) { |         builder: (context) { | ||||||
|           final isWider = isWiderScreen(context); |           final isWider = isWiderScreen(context); | ||||||
|  |  | ||||||
|           final bodyView = TabBarView( |           final bodyView = _buildActivityList( | ||||||
|             controller: tabController, |             context, | ||||||
|             physics: const NeverScrollableScrollPhysics(), |             ref, | ||||||
|             children: [ |             currentFilter.value, | ||||||
|               _buildActivityList(context, ref, null), |  | ||||||
|               _buildActivityList(context, ref, 'subscriptions'), |  | ||||||
|               _buildActivityList(context, ref, 'friends'), |  | ||||||
|             ], |  | ||||||
|           ); |           ); | ||||||
|  |  | ||||||
|           if (isWider) { |           if (isWider) { | ||||||
|             return Row( |             return Row( | ||||||
|               children: [ |               children: [ | ||||||
|                 Flexible(flex: 3, child: bodyView), |                 Flexible(flex: 3, child: bodyView.padding(left: 8)), | ||||||
|                 const VerticalDivider(width: 1), |                 if (user.value != null) | ||||||
|                 Flexible( |                   Flexible( | ||||||
|                   flex: 2, |                     flex: 2, | ||||||
|                   child: SingleChildScrollView( |                     child: SingleChildScrollView( | ||||||
|                     child: Column( |                       child: Column( | ||||||
|                       children: [ |                         children: [ | ||||||
|                         CheckInWidget(), |                           CheckInWidget( | ||||||
|                         Card( |                             margin: EdgeInsets.only( | ||||||
|                           margin: EdgeInsets.only(left: 16, right: 16, top: 8), |                               left: 8, | ||||||
|                           child: Column( |                               right: 12, | ||||||
|                             children: [ |                               top: 16, | ||||||
|                               // Use the reusable EventCalendarWidget |                             ), | ||||||
|                               EventCalendarWidget( |  | ||||||
|                                 events: events, |  | ||||||
|                                 initialDate: now, |  | ||||||
|                                 showEventDetails: true, |  | ||||||
|                                 onMonthChanged: onMonthChanged, |  | ||||||
|                                 onDaySelected: onDaySelected, |  | ||||||
|                               ), |  | ||||||
|                             ], |  | ||||||
|                           ), |                           ), | ||||||
|                         ), |                           Card( | ||||||
|                         FortuneGraphWidget( |                             margin: EdgeInsets.only(left: 8, right: 12, top: 8), | ||||||
|                           events: events, |                             child: Column( | ||||||
|                           constrainWidth: true, |                               children: [ | ||||||
|                           onPointSelected: onDaySelected, |                                 // Use the reusable EventCalendarWidget | ||||||
|  |                                 EventCalendarWidget( | ||||||
|  |                                   events: events, | ||||||
|  |                                   initialDate: now, | ||||||
|  |                                   showEventDetails: true, | ||||||
|  |                                   onMonthChanged: onMonthChanged, | ||||||
|  |                                   onDaySelected: onDaySelected, | ||||||
|  |                                 ), | ||||||
|  |                               ], | ||||||
|  |                             ), | ||||||
|  |                           ), | ||||||
|  |                           FortuneGraphWidget( | ||||||
|  |                             margin: EdgeInsets.only(left: 8, right: 12, top: 8), | ||||||
|  |                             events: events, | ||||||
|  |                             constrainWidth: true, | ||||||
|  |                             onPointSelected: onDaySelected, | ||||||
|  |                           ), | ||||||
|  |                         ], | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                   ) | ||||||
|  |                 else | ||||||
|  |                   Flexible( | ||||||
|  |                     flex: 2, | ||||||
|  |                     child: Column( | ||||||
|  |                       mainAxisAlignment: MainAxisAlignment.center, | ||||||
|  |                       crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                       children: [ | ||||||
|  |                         Text( | ||||||
|  |                           'Welcome to\nthe Solar Network', | ||||||
|  |                           style: Theme.of(context).textTheme.titleLarge, | ||||||
|  |                         ).bold(), | ||||||
|  |                         const Gap(2), | ||||||
|  |                         Text( | ||||||
|  |                           'Login to explore more!', | ||||||
|  |                           style: Theme.of(context).textTheme.bodyLarge, | ||||||
|                         ), |                         ), | ||||||
|                       ], |                       ], | ||||||
|                     ), |                     ).padding(horizontal: 36, vertical: 16), | ||||||
|                   ), |                   ), | ||||||
|                 ), |  | ||||||
|               ], |               ], | ||||||
|             ); |             ); | ||||||
|           } |           } | ||||||
| @@ -280,56 +315,62 @@ class _DiscoveryActivityItem extends StatelessWidget { | |||||||
|     final items = data['items'] as List; |     final items = data['items'] as List; | ||||||
|     final type = items.firstOrNull?['type'] ?? 'unknown'; |     final type = items.firstOrNull?['type'] ?? 'unknown'; | ||||||
|  |  | ||||||
|     return Column( |     return Card( | ||||||
|       crossAxisAlignment: CrossAxisAlignment.start, |       margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4), | ||||||
|       children: [ |       child: Column( | ||||||
|         Row( |         crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|           crossAxisAlignment: CrossAxisAlignment.center, |         children: [ | ||||||
|           children: [ |           Row( | ||||||
|             const Icon(Symbols.explore, size: 19), |             crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|             const Gap(8), |             children: [ | ||||||
|             Text( |               const Icon(Symbols.explore, size: 19), | ||||||
|               (switch (type) { |               const Gap(8), | ||||||
|                 'realm' => 'discoverRealms', |               Text( | ||||||
|                 'publisher' => 'discoverPublishers', |                 (switch (type) { | ||||||
|                 'article' => 'discoverWebArticles', |                   'realm' => 'discoverRealms', | ||||||
|                 _ => 'unknown', |                   'publisher' => 'discoverPublishers', | ||||||
|               }).tr(), |                   'article' => 'discoverWebArticles', | ||||||
|               style: Theme.of(context).textTheme.titleMedium, |                   _ => 'unknown', | ||||||
|             ).padding(top: 1), |                 }).tr(), | ||||||
|           ], |                 style: Theme.of(context).textTheme.titleMedium, | ||||||
|         ).padding(horizontal: 20, top: 8, bottom: 4), |               ).padding(top: 1), | ||||||
|         SizedBox( |             ], | ||||||
|           height: 180, |           ).padding(horizontal: 20, top: 8, bottom: 4), | ||||||
|           child: ListView.builder( |           SizedBox( | ||||||
|             scrollDirection: Axis.horizontal, |             height: 180, | ||||||
|             itemCount: items.length, |             child: ConstrainedBox( | ||||||
|             padding: const EdgeInsets.symmetric(horizontal: 8), |               constraints: const BoxConstraints(maxHeight: 200), | ||||||
|             itemBuilder: (context, index) { |               child: CarouselView.weighted( | ||||||
|               final item = items[index]; |                 flexWeights: | ||||||
|               switch (type) { |                     isWideScreen(context) ? <int>[3, 2, 1] : <int>[4, 1], | ||||||
|                 case 'realm': |                 consumeMaxWeight: false, | ||||||
|                   return RealmCard( |                 enableSplash: false, | ||||||
|                     realm: SnRealm.fromJson(item['data']), |                 shape: RoundedRectangleBorder( | ||||||
|                     maxWidth: 280, |                   borderRadius: BorderRadius.all(Radius.circular(8)), | ||||||
|                   ); |                 ), | ||||||
|                 case 'publisher': |                 children: [ | ||||||
|                   return PublisherCard( |                   for (final item in items) | ||||||
|                     publisher: SnPublisher.fromJson(item['data']), |                     switch (type) { | ||||||
|                     maxWidth: 280, |                       'realm' => RealmCard( | ||||||
|                   ); |                         realm: SnRealm.fromJson(item['data']), | ||||||
|                 case 'article': |                         maxWidth: 280, | ||||||
|                   return WebArticleCard( |                       ), | ||||||
|                     article: SnWebArticle.fromJson(item['data']), |                       'publisher' => PublisherCard( | ||||||
|                     maxWidth: 280, |                         publisher: SnPublisher.fromJson(item['data']), | ||||||
|                   ); |                         maxWidth: 280, | ||||||
|                 default: |                       ), | ||||||
|                   return Placeholder(); |                       'article' => WebArticleCard( | ||||||
|               } |                         article: SnWebArticle.fromJson(item['data']), | ||||||
|             }, |                         maxWidth: 280, | ||||||
|           ), |                       ), | ||||||
|         ).padding(bottom: 4), |                       _ => Placeholder(), | ||||||
|       ], |                     }, | ||||||
|  |                 ], | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ).padding(bottom: 8, horizontal: 8), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
| @@ -355,8 +396,13 @@ class _ActivityListView extends HookConsumerWidget { | |||||||
|  |  | ||||||
|     return CustomScrollView( |     return CustomScrollView( | ||||||
|       slivers: [ |       slivers: [ | ||||||
|  |         SliverGap(12), | ||||||
|         if (user.value != null && !contentOnly) |         if (user.value != null && !contentOnly) | ||||||
|           SliverToBoxAdapter(child: CheckInWidget()), |           SliverToBoxAdapter( | ||||||
|  |             child: CheckInWidget( | ||||||
|  |               margin: EdgeInsets.only(left: 8, right: 8, bottom: 4), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|         SliverList.builder( |         SliverList.builder( | ||||||
|           itemCount: widgetCount, |           itemCount: widgetCount, | ||||||
|           itemBuilder: (context, index) { |           itemBuilder: (context, index) { | ||||||
| @@ -373,19 +419,9 @@ class _ActivityListView extends HookConsumerWidget { | |||||||
|             switch (item.type) { |             switch (item.type) { | ||||||
|               case 'posts.new': |               case 'posts.new': | ||||||
|               case 'posts.new.replies': |               case 'posts.new.replies': | ||||||
|                 final isReply = item.type == 'posts.new.replies'; |                 itemWidget = PostActionableItem( | ||||||
|                 itemWidget = PostItem( |                   borderRadius: 8, | ||||||
|                   backgroundColor: |  | ||||||
|                       isWideScreen(context) ? Colors.transparent : null, |  | ||||||
|                   item: SnPost.fromJson(item.data!), |                   item: SnPost.fromJson(item.data!), | ||||||
|                   padding: |  | ||||||
|                       isReply |  | ||||||
|                           ? const EdgeInsets.only( |  | ||||||
|                             left: 16, |  | ||||||
|                             right: 16, |  | ||||||
|                             bottom: 16, |  | ||||||
|                           ) |  | ||||||
|                           : null, |  | ||||||
|                   onRefresh: () { |                   onRefresh: () { | ||||||
|                     activitiesNotifier.forceRefresh(); |                     activitiesNotifier.forceRefresh(); | ||||||
|                   }, |                   }, | ||||||
| @@ -396,21 +432,10 @@ class _ActivityListView extends HookConsumerWidget { | |||||||
|                     ); |                     ); | ||||||
|                   }, |                   }, | ||||||
|                 ); |                 ); | ||||||
|                 if (isReply) { |                 itemWidget = Card( | ||||||
|                   itemWidget = Column( |                   margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4), | ||||||
|                     crossAxisAlignment: CrossAxisAlignment.stretch, |                   child: itemWidget, | ||||||
|                     children: [ |                 ); | ||||||
|                       Row( |  | ||||||
|                         children: [ |  | ||||||
|                           const Icon(Symbols.reply), |  | ||||||
|                           const Gap(8), |  | ||||||
|                           Text('Replying your post'), |  | ||||||
|                         ], |  | ||||||
|                       ).padding(horizontal: 20, vertical: 8), |  | ||||||
|                       itemWidget, |  | ||||||
|                     ], |  | ||||||
|                   ); |  | ||||||
|                 } |  | ||||||
|                 break; |                 break; | ||||||
|               case 'discovery': |               case 'discovery': | ||||||
|                 itemWidget = _DiscoveryActivityItem(data: item.data!); |                 itemWidget = _DiscoveryActivityItem(data: item.data!); | ||||||
| @@ -419,7 +444,7 @@ class _ActivityListView extends HookConsumerWidget { | |||||||
|                 itemWidget = const Placeholder(); |                 itemWidget = const Placeholder(); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             return Column(children: [itemWidget, const Divider(height: 1)]); |             return itemWidget; | ||||||
|           }, |           }, | ||||||
|         ), |         ), | ||||||
|         SliverGap(getTabbedPadding(context).bottom), |         SliverGap(getTabbedPadding(context).bottom), | ||||||
|   | |||||||
							
								
								
									
										1100
									
								
								lib/screens/poll/poll_editor.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1100
									
								
								lib/screens/poll/poll_editor.dart
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -18,7 +18,8 @@ import 'package:island/widgets/post/publishers_modal.dart'; | |||||||
| import 'package:island/screens/posts/post_detail.dart'; | import 'package:island/screens/posts/post_detail.dart'; | ||||||
| import 'package:island/widgets/post/compose_settings_sheet.dart'; | import 'package:island/widgets/post/compose_settings_sheet.dart'; | ||||||
| import 'package:island/services/compose_storage_db.dart'; | import 'package:island/services/compose_storage_db.dart'; | ||||||
| import 'package:island/widgets/post/draft_manager.dart'; | // DraftManagerSheet is now imported through compose_toolbar.dart | ||||||
|  | import 'package:island/widgets/post/compose_toolbar.dart'; | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  |  | ||||||
| @@ -52,13 +53,13 @@ class PostEditScreen extends HookConsumerWidget { | |||||||
|       data: (post) => PostComposeScreen(originalPost: post), |       data: (post) => PostComposeScreen(originalPost: post), | ||||||
|       loading: |       loading: | ||||||
|           () => AppScaffold( |           () => AppScaffold( | ||||||
|             noBackground: false, |             isNoBackground: false, | ||||||
|             appBar: AppBar(leading: const PageBackButton()), |             appBar: AppBar(leading: const PageBackButton()), | ||||||
|             body: const Center(child: CircularProgressIndicator()), |             body: const Center(child: CircularProgressIndicator()), | ||||||
|           ), |           ), | ||||||
|       error: |       error: | ||||||
|           (e, _) => AppScaffold( |           (e, _) => AppScaffold( | ||||||
|             noBackground: false, |             isNoBackground: false, | ||||||
|             appBar: AppBar(leading: const PageBackButton()), |             appBar: AppBar(leading: const PageBackButton()), | ||||||
|             body: Text('Error: $e', textAlign: TextAlign.center), |             body: Text('Error: $e', textAlign: TextAlign.center), | ||||||
|           ), |           ), | ||||||
| @@ -92,7 +93,6 @@ class PostComposeScreen extends HookConsumerWidget { | |||||||
|  |  | ||||||
|     // Otherwise, continue with regular post compose |     // Otherwise, continue with regular post compose | ||||||
|     final theme = Theme.of(context); |     final theme = Theme.of(context); | ||||||
|     final colorScheme = theme.colorScheme; |  | ||||||
|  |  | ||||||
|     // When editing, preserve the original replied/forwarded post references |     // When editing, preserve the original replied/forwarded post references | ||||||
|     final effectiveRepliedPost = repliedPost ?? originalPost?.repliedPost; |     final effectiveRepliedPost = repliedPost ?? originalPost?.repliedPost; | ||||||
| @@ -238,6 +238,8 @@ class PostComposeScreen extends HookConsumerWidget { | |||||||
|             onRequestUpload: |             onRequestUpload: | ||||||
|                 () => ComposeLogic.uploadAttachment(ref, state, idx), |                 () => ComposeLogic.uploadAttachment(ref, state, idx), | ||||||
|             onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx), |             onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx), | ||||||
|  |             onUpdate: | ||||||
|  |                 (value) => ComposeLogic.updateAttachment(state, value, idx), | ||||||
|             onMove: (delta) { |             onMove: (delta) { | ||||||
|               state.attachments.value = ComposeLogic.moveAttachment( |               state.attachments.value = ComposeLogic.moveAttachment( | ||||||
|                 state.attachments.value, |                 state.attachments.value, | ||||||
| @@ -265,6 +267,9 @@ class PostComposeScreen extends HookConsumerWidget { | |||||||
|                       () => ComposeLogic.uploadAttachment(ref, state, idx), |                       () => ComposeLogic.uploadAttachment(ref, state, idx), | ||||||
|                   onDelete: |                   onDelete: | ||||||
|                       () => ComposeLogic.deleteAttachment(ref, state, idx), |                       () => ComposeLogic.deleteAttachment(ref, state, idx), | ||||||
|  |                   onUpdate: | ||||||
|  |                       (value) => | ||||||
|  |                           ComposeLogic.updateAttachment(state, value, idx), | ||||||
|                   onMove: (delta) { |                   onMove: (delta) { | ||||||
|                     state.attachments.value = ComposeLogic.moveAttachment( |                     state.attachments.value = ComposeLogic.moveAttachment( | ||||||
|                       state.attachments.value, |                       state.attachments.value, | ||||||
| @@ -287,43 +292,10 @@ class PostComposeScreen extends HookConsumerWidget { | |||||||
|         } |         } | ||||||
|       }, |       }, | ||||||
|       child: AppScaffold( |       child: AppScaffold( | ||||||
|         noBackground: false, |         isNoBackground: false, | ||||||
|         appBar: AppBar( |         appBar: AppBar( | ||||||
|           leading: const PageBackButton(), |           leading: const PageBackButton(), | ||||||
|           actions: [ |           actions: [ | ||||||
|             if (originalPost == null) // Only show drafts for new posts |  | ||||||
|               IconButton( |  | ||||||
|                 icon: const Icon(Symbols.draft), |  | ||||||
|                 onPressed: () { |  | ||||||
|                   showModalBottomSheet( |  | ||||||
|                     context: context, |  | ||||||
|                     isScrollControlled: true, |  | ||||||
|                     builder: |  | ||||||
|                         (context) => DraftManagerSheet( |  | ||||||
|                           onDraftSelected: (draftId) { |  | ||||||
|                             final draft = |  | ||||||
|                                 ref.read( |  | ||||||
|                                   composeStorageNotifierProvider, |  | ||||||
|                                 )[draftId]; |  | ||||||
|                             if (draft != null) { |  | ||||||
|                               state.titleController.text = draft.title ?? ''; |  | ||||||
|                               state.descriptionController.text = |  | ||||||
|                                   draft.description ?? ''; |  | ||||||
|                               state.contentController.text = |  | ||||||
|                                   draft.content ?? ''; |  | ||||||
|                               state.visibility.value = draft.visibility; |  | ||||||
|                             } |  | ||||||
|                           }, |  | ||||||
|                         ), |  | ||||||
|                   ); |  | ||||||
|                 }, |  | ||||||
|                 tooltip: 'drafts'.tr(), |  | ||||||
|               ), |  | ||||||
|             IconButton( |  | ||||||
|               icon: const Icon(Symbols.save), |  | ||||||
|               onPressed: () => ComposeLogic.saveDraft(ref, state), |  | ||||||
|               tooltip: 'saveDraft'.tr(), |  | ||||||
|             ), |  | ||||||
|             IconButton( |             IconButton( | ||||||
|               icon: const Icon(Symbols.settings), |               icon: const Icon(Symbols.settings), | ||||||
|               onPressed: showSettingsSheet, |               onPressed: showSettingsSheet, | ||||||
| @@ -455,27 +427,7 @@ class PostComposeScreen extends HookConsumerWidget { | |||||||
|             ), |             ), | ||||||
|  |  | ||||||
|             // Bottom toolbar |             // Bottom toolbar | ||||||
|             Material( |             ComposeToolbar(state: state, originalPost: originalPost), | ||||||
|               elevation: 4, |  | ||||||
|               child: Row( |  | ||||||
|                 children: [ |  | ||||||
|                   IconButton( |  | ||||||
|                     onPressed: () => ComposeLogic.pickPhotoMedia(ref, state), |  | ||||||
|                     icon: const Icon(Symbols.add_a_photo), |  | ||||||
|                     color: colorScheme.primary, |  | ||||||
|                   ), |  | ||||||
|                   IconButton( |  | ||||||
|                     onPressed: () => ComposeLogic.pickVideoMedia(ref, state), |  | ||||||
|                     icon: const Icon(Symbols.videocam), |  | ||||||
|                     color: colorScheme.primary, |  | ||||||
|                   ), |  | ||||||
|                 ], |  | ||||||
|               ).padding( |  | ||||||
|                 bottom: MediaQuery.of(context).padding.bottom + 16, |  | ||||||
|                 horizontal: 16, |  | ||||||
|                 top: 8, |  | ||||||
|               ), |  | ||||||
|             ), |  | ||||||
|           ], |           ], | ||||||
|         ), |         ), | ||||||
|       ), |       ), | ||||||
| @@ -650,7 +602,7 @@ class PostComposeScreen extends HookConsumerWidget { | |||||||
|                             child: SingleChildScrollView( |                             child: SingleChildScrollView( | ||||||
|                               controller: scrollController, |                               controller: scrollController, | ||||||
|                               padding: const EdgeInsets.all(16), |                               padding: const EdgeInsets.all(16), | ||||||
|                               child: PostItem(item: post, isOpenable: false), |                               child: PostItem(item: post), | ||||||
|                             ), |                             ), | ||||||
|                           ), |                           ), | ||||||
|                         ], |                         ], | ||||||
|   | |||||||
| @@ -19,8 +19,8 @@ import 'package:island/widgets/content/markdown.dart'; | |||||||
| import 'package:island/widgets/post/compose_shared.dart'; | import 'package:island/widgets/post/compose_shared.dart'; | ||||||
| import 'package:island/widgets/post/compose_settings_sheet.dart'; | import 'package:island/widgets/post/compose_settings_sheet.dart'; | ||||||
| import 'package:island/services/compose_storage_db.dart'; | import 'package:island/services/compose_storage_db.dart'; | ||||||
|  | import 'package:island/widgets/post/compose_toolbar.dart'; | ||||||
| import 'package:island/widgets/post/publishers_modal.dart'; | import 'package:island/widgets/post/publishers_modal.dart'; | ||||||
| import 'package:island/widgets/post/draft_manager.dart'; |  | ||||||
|  |  | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
| @@ -153,6 +153,57 @@ class ArticleComposeScreen extends HookConsumerWidget { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     Widget buildPreviewPane() { |     Widget buildPreviewPane() { | ||||||
|  |       final widgetItem = SingleChildScrollView( | ||||||
|  |         padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), | ||||||
|  |         child: ValueListenableBuilder<TextEditingValue>( | ||||||
|  |           valueListenable: state.titleController, | ||||||
|  |           builder: (context, titleValue, _) { | ||||||
|  |             return ValueListenableBuilder<TextEditingValue>( | ||||||
|  |               valueListenable: state.descriptionController, | ||||||
|  |               builder: (context, descriptionValue, _) { | ||||||
|  |                 return ValueListenableBuilder<TextEditingValue>( | ||||||
|  |                   valueListenable: state.contentController, | ||||||
|  |                   builder: (context, contentValue, _) { | ||||||
|  |                     return Column( | ||||||
|  |                       crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                       children: [ | ||||||
|  |                         if (titleValue.text.isNotEmpty) ...[ | ||||||
|  |                           Text( | ||||||
|  |                             titleValue.text, | ||||||
|  |                             style: theme.textTheme.headlineSmall?.copyWith( | ||||||
|  |                               fontWeight: FontWeight.bold, | ||||||
|  |                             ), | ||||||
|  |                           ), | ||||||
|  |                           const Gap(16), | ||||||
|  |                         ], | ||||||
|  |                         if (descriptionValue.text.isNotEmpty) ...[ | ||||||
|  |                           Text( | ||||||
|  |                             descriptionValue.text, | ||||||
|  |                             style: theme.textTheme.bodyLarge?.copyWith( | ||||||
|  |                               color: colorScheme.onSurface.withOpacity(0.7), | ||||||
|  |                             ), | ||||||
|  |                           ), | ||||||
|  |                           const Gap(16), | ||||||
|  |                         ], | ||||||
|  |                         if (contentValue.text.isNotEmpty) | ||||||
|  |                           MarkdownTextContent( | ||||||
|  |                             content: contentValue.text, | ||||||
|  |                             textStyle: theme.textTheme.bodyMedium, | ||||||
|  |                           ), | ||||||
|  |                       ], | ||||||
|  |                     ); | ||||||
|  |                   }, | ||||||
|  |                 ); | ||||||
|  |               }, | ||||||
|  |             ); | ||||||
|  |           }, | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       if (isWideScreen(context)) { | ||||||
|  |         return Align(alignment: Alignment.topLeft, child: widgetItem); | ||||||
|  |       } | ||||||
|  |  | ||||||
|       return Container( |       return Container( | ||||||
|         decoration: BoxDecoration( |         decoration: BoxDecoration( | ||||||
|           border: Border.all(color: colorScheme.outline.withOpacity(0.3)), |           border: Border.all(color: colorScheme.outline.withOpacity(0.3)), | ||||||
| @@ -178,210 +229,126 @@ class ArticleComposeScreen extends HookConsumerWidget { | |||||||
|                 ], |                 ], | ||||||
|               ), |               ), | ||||||
|             ), |             ), | ||||||
|             Expanded( |             Expanded(child: widgetItem), | ||||||
|               child: SingleChildScrollView( |  | ||||||
|                 padding: const EdgeInsets.all(16), |  | ||||||
|                 child: ValueListenableBuilder<TextEditingValue>( |  | ||||||
|                   valueListenable: state.titleController, |  | ||||||
|                   builder: (context, titleValue, _) { |  | ||||||
|                     return ValueListenableBuilder<TextEditingValue>( |  | ||||||
|                       valueListenable: state.descriptionController, |  | ||||||
|                       builder: (context, descriptionValue, _) { |  | ||||||
|                         return ValueListenableBuilder<TextEditingValue>( |  | ||||||
|                           valueListenable: state.contentController, |  | ||||||
|                           builder: (context, contentValue, _) { |  | ||||||
|                             return Column( |  | ||||||
|                               crossAxisAlignment: CrossAxisAlignment.start, |  | ||||||
|                               children: [ |  | ||||||
|                                 if (titleValue.text.isNotEmpty) ...[ |  | ||||||
|                                   Text( |  | ||||||
|                                     titleValue.text, |  | ||||||
|                                     style: theme.textTheme.headlineSmall |  | ||||||
|                                         ?.copyWith(fontWeight: FontWeight.bold), |  | ||||||
|                                   ), |  | ||||||
|                                   const Gap(16), |  | ||||||
|                                 ], |  | ||||||
|                                 if (descriptionValue.text.isNotEmpty) ...[ |  | ||||||
|                                   Text( |  | ||||||
|                                     descriptionValue.text, |  | ||||||
|                                     style: theme.textTheme.bodyLarge?.copyWith( |  | ||||||
|                                       color: colorScheme.onSurface.withOpacity( |  | ||||||
|                                         0.7, |  | ||||||
|                                       ), |  | ||||||
|                                     ), |  | ||||||
|                                   ), |  | ||||||
|                                   const Gap(16), |  | ||||||
|                                 ], |  | ||||||
|                                 if (contentValue.text.isNotEmpty) |  | ||||||
|                                   MarkdownTextContent( |  | ||||||
|                                     content: contentValue.text, |  | ||||||
|                                     textStyle: theme.textTheme.bodyMedium, |  | ||||||
|                                   ), |  | ||||||
|                               ], |  | ||||||
|                             ); |  | ||||||
|                           }, |  | ||||||
|                         ); |  | ||||||
|                       }, |  | ||||||
|                     ); |  | ||||||
|                   }, |  | ||||||
|                 ), |  | ||||||
|               ), |  | ||||||
|             ), |  | ||||||
|           ], |           ], | ||||||
|         ), |         ), | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     Widget buildEditorPane() { |     Widget buildEditorPane() { | ||||||
|       return Column( |       return Center( | ||||||
|         crossAxisAlignment: CrossAxisAlignment.start, |         child: ConstrainedBox( | ||||||
|         children: [ |           constraints: const BoxConstraints(maxWidth: 560), | ||||||
|           // Publisher row |           child: Column( | ||||||
|           Card( |             crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|             margin: EdgeInsets.only(top: 8), |             children: [ | ||||||
|             elevation: 1, |               Expanded( | ||||||
|             child: Padding( |                 child: RawKeyboardListener( | ||||||
|               padding: const EdgeInsets.all(12), |                   focusNode: FocusNode(), | ||||||
|               child: Row( |                   onKey: | ||||||
|                 children: [ |                       (event) => _handleKeyPress( | ||||||
|                   GestureDetector( |                         event, | ||||||
|                     child: ProfilePictureWidget( |                         state, | ||||||
|                       fileId: state.currentPublisher.value?.picture?.id, |                         ref, | ||||||
|                       radius: 20, |                         context, | ||||||
|                       fallbackIcon: |                         originalPost: originalPost, | ||||||
|                           state.currentPublisher.value == null |                       ), | ||||||
|                               ? Symbols.question_mark |                   child: TextField( | ||||||
|                               : null, |                     controller: state.contentController, | ||||||
|  |                     style: theme.textTheme.bodyMedium, | ||||||
|  |                     decoration: InputDecoration( | ||||||
|  |                       border: InputBorder.none, | ||||||
|  |                       hintText: 'postContent'.tr(), | ||||||
|  |                       contentPadding: const EdgeInsets.symmetric( | ||||||
|  |                         vertical: 16, | ||||||
|  |                         horizontal: 8, | ||||||
|  |                       ), | ||||||
|                     ), |                     ), | ||||||
|                     onTap: () { |                     maxLines: null, | ||||||
|                       showModalBottomSheet( |                     expands: true, | ||||||
|                         isScrollControlled: true, |                     textAlignVertical: TextAlignVertical.top, | ||||||
|                         context: context, |                     onTapOutside: | ||||||
|                         builder: (context) => const PublisherModal(), |                         (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                       ).then((value) { |  | ||||||
|                         if (value != null) { |  | ||||||
|                           state.currentPublisher.value = value; |  | ||||||
|                         } |  | ||||||
|                       }); |  | ||||||
|                     }, |  | ||||||
|                   ), |                   ), | ||||||
|                   const Gap(16), |  | ||||||
|                   if (state.currentPublisher.value == null) |  | ||||||
|                     Text( |  | ||||||
|                       'postPublisherUnselected'.tr(), |  | ||||||
|                       style: theme.textTheme.bodyMedium, |  | ||||||
|                     ) |  | ||||||
|                   else |  | ||||||
|                     Column( |  | ||||||
|                       crossAxisAlignment: CrossAxisAlignment.start, |  | ||||||
|                       children: [ |  | ||||||
|                         Text(state.currentPublisher.value!.nick).bold(), |  | ||||||
|                         Text( |  | ||||||
|                           '@${state.currentPublisher.value!.name}', |  | ||||||
|                         ).fontSize(12), |  | ||||||
|                       ], |  | ||||||
|                     ), |  | ||||||
|                 ], |  | ||||||
|               ), |  | ||||||
|             ), |  | ||||||
|           ), |  | ||||||
|  |  | ||||||
|           // Content field with keyboard listener |  | ||||||
|           Expanded( |  | ||||||
|             child: RawKeyboardListener( |  | ||||||
|               focusNode: FocusNode(), |  | ||||||
|               onKey: |  | ||||||
|                   (event) => _handleKeyPress( |  | ||||||
|                     event, |  | ||||||
|                     state, |  | ||||||
|                     ref, |  | ||||||
|                     context, |  | ||||||
|                     originalPost: originalPost, |  | ||||||
|                   ), |  | ||||||
|               child: TextField( |  | ||||||
|                 controller: state.contentController, |  | ||||||
|                 style: theme.textTheme.bodyMedium, |  | ||||||
|                 decoration: InputDecoration( |  | ||||||
|                   border: InputBorder.none, |  | ||||||
|                   hintText: 'postContent'.tr(), |  | ||||||
|                   contentPadding: const EdgeInsets.all(8), |  | ||||||
|                 ), |                 ), | ||||||
|                 maxLines: null, |  | ||||||
|                 expands: true, |  | ||||||
|                 textAlignVertical: TextAlignVertical.top, |  | ||||||
|                 onTapOutside: |  | ||||||
|                     (_) => FocusManager.instance.primaryFocus?.unfocus(), |  | ||||||
|               ), |               ), | ||||||
|             ), |  | ||||||
|           ), |  | ||||||
|  |  | ||||||
|           // Attachments preview |               // Attachments preview | ||||||
|           ValueListenableBuilder<List<UniversalFile>>( |               ValueListenableBuilder<List<UniversalFile>>( | ||||||
|             valueListenable: state.attachments, |                 valueListenable: state.attachments, | ||||||
|             builder: (context, attachments, _) { |                 builder: (context, attachments, _) { | ||||||
|               if (attachments.isEmpty) return const SizedBox.shrink(); |                   if (attachments.isEmpty) return const SizedBox.shrink(); | ||||||
|               return Column( |                   return Column( | ||||||
|                 crossAxisAlignment: CrossAxisAlignment.start, |                     crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|                 children: [ |                     children: [ | ||||||
|                   const Gap(16), |                       const Gap(16), | ||||||
|                   Text( |                       Text( | ||||||
|                     'articleAttachmentHint'.tr(), |                         'articleAttachmentHint'.tr(), | ||||||
|                     style: Theme.of(context).textTheme.bodySmall?.copyWith( |                         style: Theme.of(context).textTheme.bodySmall?.copyWith( | ||||||
|                       color: Theme.of(context).colorScheme.onSurfaceVariant, |                           color: Theme.of(context).colorScheme.onSurfaceVariant, | ||||||
|                     ), |                         ), | ||||||
|                   ).padding(bottom: 8), |                       ).padding(bottom: 8), | ||||||
|                   ValueListenableBuilder<Map<int, double>>( |                       ValueListenableBuilder<Map<int, double>>( | ||||||
|                     valueListenable: state.attachmentProgress, |                         valueListenable: state.attachmentProgress, | ||||||
|                     builder: (context, progressMap, _) { |                         builder: (context, progressMap, _) { | ||||||
|                       return Wrap( |                           return Wrap( | ||||||
|                         spacing: 8, |                             spacing: 8, | ||||||
|                         runSpacing: 8, |                             runSpacing: 8, | ||||||
|                         children: [ |                             children: [ | ||||||
|                           for (var idx = 0; idx < attachments.length; idx++) |                               for (var idx = 0; idx < attachments.length; idx++) | ||||||
|                             SizedBox( |                                 SizedBox( | ||||||
|                               width: 280, |                                   width: 280, | ||||||
|                               height: 280, |                                   height: 280, | ||||||
|                               child: AttachmentPreview( |                                   child: AttachmentPreview( | ||||||
|                                 item: attachments[idx], |                                     item: attachments[idx], | ||||||
|                                 progress: progressMap[idx], |                                     progress: progressMap[idx], | ||||||
|                                 onRequestUpload: |                                     onRequestUpload: | ||||||
|                                     () => ComposeLogic.uploadAttachment( |                                         () => ComposeLogic.uploadAttachment( | ||||||
|                                       ref, |                                           ref, | ||||||
|                                       state, |                                           state, | ||||||
|                                       idx, |                                           idx, | ||||||
|                                     ), |                                         ), | ||||||
|                                 onDelete: |                                     onUpdate: | ||||||
|                                     () => ComposeLogic.deleteAttachment( |                                         (value) => | ||||||
|                                       ref, |                                             ComposeLogic.updateAttachment( | ||||||
|                                       state, |                                               state, | ||||||
|                                       idx, |                                               value, | ||||||
|                                     ), |                                               idx, | ||||||
|                                 onMove: (delta) { |                                             ), | ||||||
|                                   state |                                     onDelete: | ||||||
|                                       .attachments |                                         () => ComposeLogic.deleteAttachment( | ||||||
|                                       .value = ComposeLogic.moveAttachment( |                                           ref, | ||||||
|                                     state.attachments.value, |                                           state, | ||||||
|                                     idx, |                                           idx, | ||||||
|                                     delta, |                                         ), | ||||||
|                                   ); |                                     onMove: (delta) { | ||||||
|                                 }, |                                       state | ||||||
|                                 onInsert: |                                           .attachments | ||||||
|                                     () => ComposeLogic.insertAttachment( |                                           .value = ComposeLogic.moveAttachment( | ||||||
|                                       ref, |                                         state.attachments.value, | ||||||
|                                       state, |                                         idx, | ||||||
|                                       idx, |                                         delta, | ||||||
|                                     ), |                                       ); | ||||||
|                               ), |                                     }, | ||||||
|                             ), |                                     onInsert: | ||||||
|                         ], |                                         () => ComposeLogic.insertAttachment( | ||||||
|                       ); |                                           ref, | ||||||
|                     }, |                                           state, | ||||||
|                   ), |                                           idx, | ||||||
|                 ], |                                         ), | ||||||
|               ); |                                   ), | ||||||
|             }, |                                 ), | ||||||
|  |                             ], | ||||||
|  |                           ); | ||||||
|  |                         }, | ||||||
|  |                       ), | ||||||
|  |                     ], | ||||||
|  |                   ); | ||||||
|  |                 }, | ||||||
|  |               ), | ||||||
|  |             ], | ||||||
|           ), |           ), | ||||||
|         ], |         ), | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -392,7 +359,7 @@ class ArticleComposeScreen extends HookConsumerWidget { | |||||||
|         } |         } | ||||||
|       }, |       }, | ||||||
|       child: AppScaffold( |       child: AppScaffold( | ||||||
|         noBackground: false, |         isNoBackground: false, | ||||||
|         appBar: AppBar( |         appBar: AppBar( | ||||||
|           leading: const PageBackButton(), |           leading: const PageBackButton(), | ||||||
|           title: ValueListenableBuilder<TextEditingValue>( |           title: ValueListenableBuilder<TextEditingValue>( | ||||||
| @@ -406,38 +373,26 @@ class ArticleComposeScreen extends HookConsumerWidget { | |||||||
|           actions: [ |           actions: [ | ||||||
|             // Info banner for article compose |             // Info banner for article compose | ||||||
|             const SizedBox.shrink(), |             const SizedBox.shrink(), | ||||||
|             if (originalPost == null) // Only show drafts for new articles |  | ||||||
|               IconButton( |  | ||||||
|                 icon: const Icon(Symbols.draft), |  | ||||||
|                 onPressed: () { |  | ||||||
|                   showModalBottomSheet( |  | ||||||
|                     context: context, |  | ||||||
|                     isScrollControlled: true, |  | ||||||
|                     builder: |  | ||||||
|                         (context) => DraftManagerSheet( |  | ||||||
|                           onDraftSelected: (draftId) { |  | ||||||
|                             final draft = |  | ||||||
|                                 ref.read( |  | ||||||
|                                   composeStorageNotifierProvider, |  | ||||||
|                                 )[draftId]; |  | ||||||
|                             if (draft != null) { |  | ||||||
|                               state.titleController.text = draft.title ?? ''; |  | ||||||
|                               state.descriptionController.text = |  | ||||||
|                                   draft.description ?? ''; |  | ||||||
|                               state.contentController.text = |  | ||||||
|                                   draft.content ?? ''; |  | ||||||
|                               state.visibility.value = draft.visibility; |  | ||||||
|                             } |  | ||||||
|                           }, |  | ||||||
|                         ), |  | ||||||
|                   ); |  | ||||||
|                 }, |  | ||||||
|                 tooltip: 'drafts'.tr(), |  | ||||||
|               ), |  | ||||||
|             IconButton( |             IconButton( | ||||||
|               icon: const Icon(Symbols.save), |               icon: ProfilePictureWidget( | ||||||
|               onPressed: () => ComposeLogic.saveDraft(ref, state), |                 fileId: state.currentPublisher.value?.picture?.id, | ||||||
|               tooltip: 'saveDraft'.tr(), |                 radius: 12, | ||||||
|  |                 fallbackIcon: | ||||||
|  |                     state.currentPublisher.value == null | ||||||
|  |                         ? Symbols.question_mark | ||||||
|  |                         : null, | ||||||
|  |               ), | ||||||
|  |               onPressed: () { | ||||||
|  |                 showModalBottomSheet( | ||||||
|  |                   isScrollControlled: true, | ||||||
|  |                   context: context, | ||||||
|  |                   builder: (context) => const PublisherModal(), | ||||||
|  |                 ).then((value) { | ||||||
|  |                   if (value != null) { | ||||||
|  |                     state.currentPublisher.value = value; | ||||||
|  |                   } | ||||||
|  |                 }); | ||||||
|  |               }, | ||||||
|             ), |             ), | ||||||
|             IconButton( |             IconButton( | ||||||
|               icon: const Icon(Symbols.settings), |               icon: const Icon(Symbols.settings), | ||||||
| @@ -499,6 +454,7 @@ class ArticleComposeScreen extends HookConsumerWidget { | |||||||
|                               flex: showPreview.value ? 1 : 2, |                               flex: showPreview.value ? 1 : 2, | ||||||
|                               child: buildEditorPane(), |                               child: buildEditorPane(), | ||||||
|                             ), |                             ), | ||||||
|  |                             const VerticalDivider(), | ||||||
|                             if (showPreview.value) |                             if (showPreview.value) | ||||||
|                               Expanded(child: buildPreviewPane()), |                               Expanded(child: buildPreviewPane()), | ||||||
|                           ], |                           ], | ||||||
| @@ -510,27 +466,7 @@ class ArticleComposeScreen extends HookConsumerWidget { | |||||||
|             ), |             ), | ||||||
|  |  | ||||||
|             // Bottom toolbar |             // Bottom toolbar | ||||||
|             Material( |             ComposeToolbar(state: state, originalPost: originalPost), | ||||||
|               elevation: 4, |  | ||||||
|               child: Row( |  | ||||||
|                 children: [ |  | ||||||
|                   IconButton( |  | ||||||
|                     onPressed: () => ComposeLogic.pickPhotoMedia(ref, state), |  | ||||||
|                     icon: const Icon(Symbols.add_a_photo), |  | ||||||
|                     color: colorScheme.primary, |  | ||||||
|                   ), |  | ||||||
|                   IconButton( |  | ||||||
|                     onPressed: () => ComposeLogic.pickVideoMedia(ref, state), |  | ||||||
|                     icon: const Icon(Symbols.videocam), |  | ||||||
|                     color: colorScheme.primary, |  | ||||||
|                   ), |  | ||||||
|                 ], |  | ||||||
|               ).padding( |  | ||||||
|                 bottom: MediaQuery.of(context).padding.bottom + 16, |  | ||||||
|                 horizontal: 16, |  | ||||||
|                 top: 8, |  | ||||||
|               ), |  | ||||||
|             ), |  | ||||||
|           ], |           ], | ||||||
|         ), |         ), | ||||||
|       ), |       ), | ||||||
|   | |||||||
| @@ -4,7 +4,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; | |||||||
| import 'package:island/models/post.dart'; | import 'package:island/models/post.dart'; | ||||||
| import 'package:island/pods/network.dart'; | import 'package:island/pods/network.dart'; | ||||||
| import 'package:island/pods/userinfo.dart'; | import 'package:island/pods/userinfo.dart'; | ||||||
| import 'package:island/services/responsive.dart'; |  | ||||||
| import 'package:island/widgets/app_scaffold.dart'; | import 'package:island/widgets/app_scaffold.dart'; | ||||||
| import 'package:island/widgets/post/post_item.dart'; | import 'package:island/widgets/post/post_item.dart'; | ||||||
| import 'package:island/widgets/post/post_quick_reply.dart'; | import 'package:island/widgets/post/post_quick_reply.dart'; | ||||||
| @@ -54,10 +53,8 @@ class PostDetailScreen extends HookConsumerWidget { | |||||||
|     final postState = ref.watch(postStateProvider(id)); |     final postState = ref.watch(postStateProvider(id)); | ||||||
|     final user = ref.watch(userInfoProvider); |     final user = ref.watch(userInfoProvider); | ||||||
|  |  | ||||||
|     final isWide = isWideScreen(context); |  | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       noBackground: false, |       isNoBackground: false, | ||||||
|       appBar: AppBar(title: const Text('Post')), |       appBar: AppBar(title: const Text('Post')), | ||||||
|       body: postState.when( |       body: postState.when( | ||||||
|         data: (post) { |         data: (post) { | ||||||
| @@ -67,13 +64,13 @@ class PostDetailScreen extends HookConsumerWidget { | |||||||
|               CustomScrollView( |               CustomScrollView( | ||||||
|                 slivers: [ |                 slivers: [ | ||||||
|                   SliverToBoxAdapter( |                   SliverToBoxAdapter( | ||||||
|                     child: Column( |                     child: Center( | ||||||
|                       children: [ |                       child: ConstrainedBox( | ||||||
|                         PostItem( |                         constraints: BoxConstraints(maxWidth: 600), | ||||||
|  |                         child: PostItem( | ||||||
|                           item: post!, |                           item: post!, | ||||||
|                           isOpenable: false, |  | ||||||
|                           isFullPost: true, |                           isFullPost: true, | ||||||
|                           backgroundColor: isWide ? Colors.transparent : null, |                           isEmbedReply: false, | ||||||
|                           onUpdate: (newItem) { |                           onUpdate: (newItem) { | ||||||
|                             // Update the local state with the new post data |                             // Update the local state with the new post data | ||||||
|                             ref |                             ref | ||||||
| @@ -81,11 +78,10 @@ class PostDetailScreen extends HookConsumerWidget { | |||||||
|                                 .updatePost(newItem); |                                 .updatePost(newItem); | ||||||
|                           }, |                           }, | ||||||
|                         ), |                         ), | ||||||
|                         const Divider(height: 1), |                       ), | ||||||
|                       ], |  | ||||||
|                     ), |                     ), | ||||||
|                   ), |                   ), | ||||||
|                   PostRepliesList(postId: id), |                   PostRepliesList(postId: id, maxWidth: 600), | ||||||
|                   SliverGap(MediaQuery.of(context).padding.bottom + 80), |                   SliverGap(MediaQuery.of(context).padding.bottom + 80), | ||||||
|                 ], |                 ], | ||||||
|               ), |               ), | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ import 'package:island/models/post.dart'; | |||||||
| import 'package:island/pods/network.dart'; | import 'package:island/pods/network.dart'; | ||||||
| import 'package:island/widgets/app_scaffold.dart'; | import 'package:island/widgets/app_scaffold.dart'; | ||||||
| import 'package:island/widgets/post/post_item.dart'; | import 'package:island/widgets/post/post_item.dart'; | ||||||
|  | import 'package:island/widgets/response.dart'; | ||||||
| import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||||
|  |  | ||||||
| final postSearchNotifierProvider = StateNotifierProvider.autoDispose< | final postSearchNotifierProvider = StateNotifierProvider.autoDispose< | ||||||
| @@ -55,7 +56,7 @@ class PostSearchNotifier | |||||||
|           'query': _currentQuery, |           'query': _currentQuery, | ||||||
|           'offset': offset, |           'offset': offset, | ||||||
|           'take': _pageSize, |           'take': _pageSize, | ||||||
|           'useVector': true, |           'useVector': false, | ||||||
|         }, |         }, | ||||||
|       ); |       ); | ||||||
|  |  | ||||||
| @@ -109,7 +110,7 @@ class _PostSearchScreenState extends ConsumerState<PostSearchScreen> { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       noBackground: false, |       isNoBackground: false, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         title: TextField( |         title: TextField( | ||||||
|           controller: _searchController, |           controller: _searchController, | ||||||
| @@ -141,6 +142,7 @@ class _PostSearchScreenState extends ConsumerState<PostSearchScreen> { | |||||||
|               } |               } | ||||||
|  |  | ||||||
|               return ListView.builder( |               return ListView.builder( | ||||||
|  |                 padding: EdgeInsets.zero, | ||||||
|                 itemCount: data.items.length + (data.hasMore ? 1 : 0), |                 itemCount: data.items.length + (data.hasMore ? 1 : 0), | ||||||
|                 itemBuilder: (context, index) { |                 itemBuilder: (context, index) { | ||||||
|                   if (index >= data.items.length) { |                   if (index >= data.items.length) { | ||||||
| @@ -151,14 +153,27 @@ class _PostSearchScreenState extends ConsumerState<PostSearchScreen> { | |||||||
|                   } |                   } | ||||||
|  |  | ||||||
|                   final post = data.items[index]; |                   final post = data.items[index]; | ||||||
|                   return Column( |                   return Center( | ||||||
|                     children: [PostItem(item: post), const Divider(height: 1)], |                     child: ConstrainedBox( | ||||||
|  |                       constraints: BoxConstraints(maxWidth: 600), | ||||||
|  |                       child: Card( | ||||||
|  |                         margin: EdgeInsets.symmetric( | ||||||
|  |                           horizontal: 8, | ||||||
|  |                           vertical: 4, | ||||||
|  |                         ), | ||||||
|  |                         child: PostActionableItem(item: post, borderRadius: 8), | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|                   ); |                   ); | ||||||
|                 }, |                 }, | ||||||
|               ); |               ); | ||||||
|             }, |             }, | ||||||
|             loading: () => const Center(child: CircularProgressIndicator()), |             loading: () => const Center(child: CircularProgressIndicator()), | ||||||
|             error: (error, stack) => Center(child: Text('Error: $error')), |             error: | ||||||
|  |                 (error, stack) => ResponseErrorWidget( | ||||||
|  |                   error: error, | ||||||
|  |                   onRetry: () => ref.invalidate(postSearchNotifierProvider), | ||||||
|  |                 ), | ||||||
|           ); |           ); | ||||||
|         }, |         }, | ||||||
|       ), |       ), | ||||||
|   | |||||||
| @@ -11,12 +11,14 @@ import 'package:island/models/user.dart'; | |||||||
| import 'package:island/pods/config.dart'; | import 'package:island/pods/config.dart'; | ||||||
| import 'package:island/pods/network.dart'; | import 'package:island/pods/network.dart'; | ||||||
| import 'package:island/services/color.dart'; | import 'package:island/services/color.dart'; | ||||||
|  | import 'package:island/services/responsive.dart'; | ||||||
| import 'package:island/widgets/account/account_name.dart'; | import 'package:island/widgets/account/account_name.dart'; | ||||||
| import 'package:island/widgets/account/badge.dart'; | import 'package:island/widgets/account/badge.dart'; | ||||||
| import 'package:island/widgets/account/status.dart'; | import 'package:island/widgets/account/status.dart'; | ||||||
| import 'package:island/widgets/alert.dart'; | import 'package:island/widgets/alert.dart'; | ||||||
| import 'package:island/widgets/app_scaffold.dart'; | import 'package:island/widgets/app_scaffold.dart'; | ||||||
| import 'package:island/widgets/content/cloud_files.dart'; | 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:island/widgets/post/post_list.dart'; | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:palette_generator/palette_generator.dart'; | import 'package:palette_generator/palette_generator.dart'; | ||||||
| @@ -121,210 +123,295 @@ class PublisherProfileScreen extends HookConsumerWidget { | |||||||
|       offset: Offset(1.0, 1.0), |       offset: Offset(1.0, 1.0), | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|  |     Widget publisherBasisWidget(SnPublisher data) => Row( | ||||||
|  |       crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |       spacing: 20, | ||||||
|  |       children: [ | ||||||
|  |         GestureDetector( | ||||||
|  |           child: Badge( | ||||||
|  |             isLabelVisible: data.type == 0, | ||||||
|  |             padding: EdgeInsets.all(4), | ||||||
|  |             label: Icon( | ||||||
|  |               Symbols.launch, | ||||||
|  |               size: 16, | ||||||
|  |               color: Theme.of(context).colorScheme.onPrimary, | ||||||
|  |             ), | ||||||
|  |             backgroundColor: Theme.of(context).colorScheme.primary, | ||||||
|  |             offset: Offset(0, 48), | ||||||
|  |             child: ProfilePictureWidget(file: data.picture, radius: 32), | ||||||
|  |           ), | ||||||
|  |           onTap: () { | ||||||
|  |             Navigator.pop(context, true); | ||||||
|  |             if (data.account?.name != null) { | ||||||
|  |               context.pushNamed( | ||||||
|  |                 'accountProfile', | ||||||
|  |                 pathParameters: {'name': data.account!.name}, | ||||||
|  |               ); | ||||||
|  |             } | ||||||
|  |           }, | ||||||
|  |         ), | ||||||
|  |         Expanded( | ||||||
|  |           child: Column( | ||||||
|  |             crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |             children: [ | ||||||
|  |               Row( | ||||||
|  |                 spacing: 6, | ||||||
|  |                 children: [ | ||||||
|  |                   Text(data.nick).fontSize(20), | ||||||
|  |                   if (data.verification != null) | ||||||
|  |                     VerificationMark(mark: data.verification!), | ||||||
|  |                   Expanded( | ||||||
|  |                     child: Text( | ||||||
|  |                       '@${data.name}', | ||||||
|  |                       maxLines: 1, | ||||||
|  |                       overflow: TextOverflow.ellipsis, | ||||||
|  |                     ).fontSize(14).opacity(0.85), | ||||||
|  |                   ), | ||||||
|  |                 ], | ||||||
|  |               ), | ||||||
|  |               if (data.type == 0 && data.account != null) | ||||||
|  |                 Row( | ||||||
|  |                   crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |                   spacing: 6, | ||||||
|  |                   children: [ | ||||||
|  |                     Icon( | ||||||
|  |                       data.type == 0 ? Symbols.person : Symbols.workspaces, | ||||||
|  |                       fill: 1, | ||||||
|  |                       size: 17, | ||||||
|  |                     ), | ||||||
|  |                     Text( | ||||||
|  |                       'publisherBelongsTo'.tr(args: ['@${data.account!.name}']), | ||||||
|  |                     ).fontSize(14), | ||||||
|  |                   ], | ||||||
|  |                 ).opacity(0.85).padding(bottom: 6), | ||||||
|  |               if (data.type == 0 && data.account != null) | ||||||
|  |                 AccountStatusWidget( | ||||||
|  |                   uname: data.account!.name, | ||||||
|  |                   padding: EdgeInsets.zero, | ||||||
|  |                 ), | ||||||
|  |               subStatus | ||||||
|  |                   .when( | ||||||
|  |                     data: | ||||||
|  |                         (status) => FilledButton.icon( | ||||||
|  |                           onPressed: | ||||||
|  |                               subscribing.value | ||||||
|  |                                   ? null | ||||||
|  |                                   : (status.isSubscribed | ||||||
|  |                                       ? unsubscribe | ||||||
|  |                                       : subscribe), | ||||||
|  |                           icon: Icon( | ||||||
|  |                             status.isSubscribed | ||||||
|  |                                 ? Symbols.remove_circle | ||||||
|  |                                 : Symbols.add_circle, | ||||||
|  |                           ), | ||||||
|  |                           label: | ||||||
|  |                               Text( | ||||||
|  |                                 status.isSubscribed | ||||||
|  |                                     ? 'unsubscribe' | ||||||
|  |                                     : 'subscribe', | ||||||
|  |                               ).tr(), | ||||||
|  |                           style: ButtonStyle( | ||||||
|  |                             visualDensity: VisualDensity(vertical: -2), | ||||||
|  |                           ), | ||||||
|  |                         ), | ||||||
|  |                     error: (_, _) => const SizedBox(), | ||||||
|  |                     loading: | ||||||
|  |                         () => const SizedBox( | ||||||
|  |                           height: 36, | ||||||
|  |                           child: Center( | ||||||
|  |                             child: SizedBox( | ||||||
|  |                               width: 20, | ||||||
|  |                               height: 20, | ||||||
|  |                               child: CircularProgressIndicator(strokeWidth: 2), | ||||||
|  |                             ), | ||||||
|  |                           ), | ||||||
|  |                         ), | ||||||
|  |                   ) | ||||||
|  |                   .padding(top: 8), | ||||||
|  |             ], | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|  |     ).padding(horizontal: 24, top: 24); | ||||||
|  |  | ||||||
|  |     Widget publisherBadgesWidget(SnPublisher data) => | ||||||
|  |         (badges.value?.isNotEmpty ?? false) | ||||||
|  |             ? Card( | ||||||
|  |               child: BadgeList( | ||||||
|  |                 badges: badges.value!, | ||||||
|  |               ).padding(horizontal: 26, vertical: 20), | ||||||
|  |             ).padding(horizontal: 4) | ||||||
|  |             : const SizedBox.shrink(); | ||||||
|  |  | ||||||
|  |     Widget publisherVerificationWidget(SnPublisher data) => | ||||||
|  |         (data.verification != null) | ||||||
|  |             ? Card( | ||||||
|  |               margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4), | ||||||
|  |               child: VerificationStatusCard(mark: data.verification!), | ||||||
|  |             ) | ||||||
|  |             : const SizedBox.shrink(); | ||||||
|  |  | ||||||
|  |     Widget publisherBioWidget(SnPublisher data) => Card( | ||||||
|  |       margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4), | ||||||
|  |       child: Column( | ||||||
|  |         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |         children: [ | ||||||
|  |           Text('bio').tr().bold().fontSize(15).padding(bottom: 8), | ||||||
|  |           if (data.bio.isEmpty) | ||||||
|  |             Text('descriptionNone').tr().italic() | ||||||
|  |           else | ||||||
|  |             MarkdownTextContent( | ||||||
|  |               content: data.bio, | ||||||
|  |               linesMargin: EdgeInsets.zero, | ||||||
|  |             ), | ||||||
|  |         ], | ||||||
|  |       ).padding(horizontal: 20, vertical: 16), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     return publisher.when( |     return publisher.when( | ||||||
|       data: |       data: | ||||||
|           (data) => AppScaffold( |           (data) => AppScaffold( | ||||||
|             noBackground: false, |             isNoBackground: false, | ||||||
|             body: CustomScrollView( |             appBar: | ||||||
|               slivers: [ |                 isWideScreen(context) | ||||||
|                 SliverAppBar( |                     ? AppBar( | ||||||
|                   foregroundColor: appbarColor.value, |                       foregroundColor: appbarColor.value, | ||||||
|                   expandedHeight: 180, |                       leading: PageBackButton( | ||||||
|                   pinned: true, |                         color: appbarColor.value, | ||||||
|                   leading: PageBackButton( |                         shadows: [appbarShadow], | ||||||
|                     color: appbarColor.value, |  | ||||||
|                     shadows: [appbarShadow], |  | ||||||
|                   ), |  | ||||||
|                   flexibleSpace: Stack( |  | ||||||
|                     children: [ |  | ||||||
|                       Positioned.fill( |  | ||||||
|                         child: |  | ||||||
|                             data.background?.id != null |  | ||||||
|                                 ? CloudImageWidget(file: data.background) |  | ||||||
|                                 : Container( |  | ||||||
|                                   color: |  | ||||||
|                                       Theme.of( |  | ||||||
|                                         context, |  | ||||||
|                                       ).appBarTheme.backgroundColor, |  | ||||||
|                                 ), |  | ||||||
|                       ), |                       ), | ||||||
|                       FlexibleSpaceBar( |                       flexibleSpace: Stack( | ||||||
|                         title: Text( |                         children: [ | ||||||
|                           data.nick, |                           Positioned.fill( | ||||||
|                           style: TextStyle( |                             child: | ||||||
|                             color: |                                 data.background?.id != null | ||||||
|                                 appbarColor.value ?? |                                     ? CloudImageWidget(file: data.background) | ||||||
|                                 Theme.of(context).appBarTheme.foregroundColor, |                                     : Container( | ||||||
|  |                                       color: | ||||||
|  |                                           Theme.of( | ||||||
|  |                                             context, | ||||||
|  |                                           ).appBarTheme.backgroundColor, | ||||||
|  |                                     ), | ||||||
|  |                           ), | ||||||
|  |                           FlexibleSpaceBar( | ||||||
|  |                             title: Text( | ||||||
|  |                               data.nick, | ||||||
|  |                               style: TextStyle( | ||||||
|  |                                 color: | ||||||
|  |                                     appbarColor.value ?? | ||||||
|  |                                     Theme.of( | ||||||
|  |                                       context, | ||||||
|  |                                     ).appBarTheme.foregroundColor, | ||||||
|  |                                 shadows: [appbarShadow], | ||||||
|  |                               ), | ||||||
|  |                             ), | ||||||
|  |                             background: | ||||||
|  |                                 Container(), // Empty container since background is handled by Stack | ||||||
|  |                           ), | ||||||
|  |                         ], | ||||||
|  |                       ), | ||||||
|  |                     ) | ||||||
|  |                     : null, | ||||||
|  |             body: | ||||||
|  |                 isWideScreen(context) | ||||||
|  |                     ? Row( | ||||||
|  |                       children: [ | ||||||
|  |                         Flexible( | ||||||
|  |                           flex: 4, | ||||||
|  |                           child: CustomScrollView( | ||||||
|  |                             slivers: [ | ||||||
|  |                               SliverGap(16), | ||||||
|  |                               SliverPostList(pubName: name), | ||||||
|  |                               SliverGap( | ||||||
|  |                                 MediaQuery.of(context).padding.bottom + 16, | ||||||
|  |                               ), | ||||||
|  |                             ], | ||||||
|  |                           ).padding(left: 8), | ||||||
|  |                         ), | ||||||
|  |                         Flexible( | ||||||
|  |                           flex: 3, | ||||||
|  |                           child: Align( | ||||||
|  |                             alignment: Alignment.topLeft, | ||||||
|  |                             child: SingleChildScrollView( | ||||||
|  |                               child: Column( | ||||||
|  |                                 crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                                 children: [ | ||||||
|  |                                   publisherBasisWidget(data), | ||||||
|  |                                   publisherBadgesWidget(data), | ||||||
|  |                                   publisherVerificationWidget(data), | ||||||
|  |                                   publisherBioWidget(data), | ||||||
|  |                                 ], | ||||||
|  |                               ), | ||||||
|  |                             ), | ||||||
|  |                           ), | ||||||
|  |                         ), | ||||||
|  |                       ], | ||||||
|  |                     ) | ||||||
|  |                     : CustomScrollView( | ||||||
|  |                       slivers: [ | ||||||
|  |                         SliverAppBar( | ||||||
|  |                           foregroundColor: appbarColor.value, | ||||||
|  |                           expandedHeight: 180, | ||||||
|  |                           pinned: true, | ||||||
|  |                           leading: PageBackButton( | ||||||
|  |                             color: appbarColor.value, | ||||||
|                             shadows: [appbarShadow], |                             shadows: [appbarShadow], | ||||||
|                           ), |                           ), | ||||||
|                         ), |                           flexibleSpace: Stack( | ||||||
|                         background: |                             children: [ | ||||||
|                             Container(), // Empty container since background is handled by Stack |                               Positioned.fill( | ||||||
|                       ), |                                 child: | ||||||
|                     ], |                                     data.background?.id != null | ||||||
|                   ), |                                         ? CloudImageWidget( | ||||||
|                 ), |                                           file: data.background, | ||||||
|                 SliverToBoxAdapter( |                                         ) | ||||||
|                   child: Row( |                                         : Container( | ||||||
|                     crossAxisAlignment: CrossAxisAlignment.start, |                                           color: | ||||||
|                     spacing: 20, |                                               Theme.of( | ||||||
|                     children: [ |                                                 context, | ||||||
|                       GestureDetector( |                                               ).appBarTheme.backgroundColor, | ||||||
|                         child: Badge( |                                         ), | ||||||
|                           isLabelVisible: data.type == 0, |  | ||||||
|                           padding: EdgeInsets.all(4), |  | ||||||
|                           label: Icon( |  | ||||||
|                             Symbols.launch, |  | ||||||
|                             size: 16, |  | ||||||
|                             color: Theme.of(context).colorScheme.onPrimary, |  | ||||||
|                           ), |  | ||||||
|                           backgroundColor: |  | ||||||
|                               Theme.of(context).colorScheme.primary, |  | ||||||
|                           offset: Offset(0, 48), |  | ||||||
|                           child: ProfilePictureWidget( |  | ||||||
|                             file: data.picture, |  | ||||||
|                             radius: 32, |  | ||||||
|                           ), |  | ||||||
|                         ), |  | ||||||
|                         onTap: () { |  | ||||||
|                           Navigator.pop(context, true); |  | ||||||
|                           if (data.account?.name != null) { |  | ||||||
|                             context.pushNamed( |  | ||||||
|                               'accountProfile', |  | ||||||
|                               pathParameters: {'name': data.account!.name}, |  | ||||||
|                             ); |  | ||||||
|                           } |  | ||||||
|                         }, |  | ||||||
|                       ), |  | ||||||
|                       Expanded( |  | ||||||
|                         child: Column( |  | ||||||
|                           crossAxisAlignment: CrossAxisAlignment.stretch, |  | ||||||
|                           children: [ |  | ||||||
|                             Row( |  | ||||||
|                               spacing: 6, |  | ||||||
|                               children: [ |  | ||||||
|                                 Text(data.nick).fontSize(20), |  | ||||||
|                                 if (data.verification != null) |  | ||||||
|                                   VerificationMark(mark: data.verification!), |  | ||||||
|                                 Text( |  | ||||||
|                                   '@${data.name}', |  | ||||||
|                                 ).fontSize(14).opacity(0.85), |  | ||||||
|                               ], |  | ||||||
|                             ), |  | ||||||
|                             if (data.type == 0 && data.account != null) |  | ||||||
|                               Row( |  | ||||||
|                                 crossAxisAlignment: CrossAxisAlignment.center, |  | ||||||
|                                 spacing: 6, |  | ||||||
|                                 children: [ |  | ||||||
|                                   Icon( |  | ||||||
|                                     data.type == 0 |  | ||||||
|                                         ? Symbols.person |  | ||||||
|                                         : Symbols.workspaces, |  | ||||||
|                                     fill: 1, |  | ||||||
|                                     size: 17, |  | ||||||
|                                   ), |  | ||||||
|                                   Text( |  | ||||||
|                                     'publisherBelongsTo'.tr( |  | ||||||
|                                       args: ['@${data.account!.name}'], |  | ||||||
|                                     ), |  | ||||||
|                                   ).fontSize(14), |  | ||||||
|                                 ], |  | ||||||
|                               ).opacity(0.85).padding(bottom: 6), |  | ||||||
|                             if (data.type == 0 && data.account != null) |  | ||||||
|                               AccountStatusWidget( |  | ||||||
|                                 uname: data.account!.name, |  | ||||||
|                                 padding: EdgeInsets.zero, |  | ||||||
|                               ), |                               ), | ||||||
|                             subStatus |                               FlexibleSpaceBar( | ||||||
|                                 .when( |                                 title: Text( | ||||||
|                                   data: |                                   data.nick, | ||||||
|                                       (status) => FilledButton.icon( |                                   style: TextStyle( | ||||||
|                                         onPressed: |                                     color: | ||||||
|                                             subscribing.value |                                         appbarColor.value ?? | ||||||
|                                                 ? null |                                         Theme.of( | ||||||
|                                                 : (status.isSubscribed |                                           context, | ||||||
|                                                     ? unsubscribe |                                         ).appBarTheme.foregroundColor, | ||||||
|                                                     : subscribe), |                                     shadows: [appbarShadow], | ||||||
|                                         icon: Icon( |                                   ), | ||||||
|                                           status.isSubscribed |                                 ), | ||||||
|                                               ? Symbols.remove_circle |                                 background: | ||||||
|                                               : Symbols.add_circle, |                                     Container(), // Empty container since background is handled by Stack | ||||||
|                                         ), |                               ), | ||||||
|                                         label: |                             ], | ||||||
|                                             Text( |                           ), | ||||||
|                                               status.isSubscribed |  | ||||||
|                                                   ? 'unsubscribe' |  | ||||||
|                                                   : 'subscribe', |  | ||||||
|                                             ).tr(), |  | ||||||
|                                         style: ButtonStyle( |  | ||||||
|                                           visualDensity: VisualDensity( |  | ||||||
|                                             vertical: -2, |  | ||||||
|                                           ), |  | ||||||
|                                         ), |  | ||||||
|                                       ), |  | ||||||
|                                   error: (_, _) => const SizedBox(), |  | ||||||
|                                   loading: |  | ||||||
|                                       () => const SizedBox( |  | ||||||
|                                         height: 36, |  | ||||||
|                                         child: Center( |  | ||||||
|                                           child: SizedBox( |  | ||||||
|                                             width: 20, |  | ||||||
|                                             height: 20, |  | ||||||
|                                             child: CircularProgressIndicator( |  | ||||||
|                                               strokeWidth: 2, |  | ||||||
|                                             ), |  | ||||||
|                                           ), |  | ||||||
|                                         ), |  | ||||||
|                                       ), |  | ||||||
|                                 ) |  | ||||||
|                                 .padding(top: 8), |  | ||||||
|                           ], |  | ||||||
|                         ), |                         ), | ||||||
|                       ), |                         SliverToBoxAdapter( | ||||||
|                     ], |                           child: publisherBasisWidget(data).padding(bottom: 8), | ||||||
|                   ).padding(horizontal: 24, top: 24), |                         ), | ||||||
|                 ), |                         SliverToBoxAdapter(child: publisherBadgesWidget(data)), | ||||||
|                 SliverToBoxAdapter( |                         SliverToBoxAdapter( | ||||||
|                   child: Column( |                           child: publisherVerificationWidget(data), | ||||||
|                     children: [ |                         ), | ||||||
|                       if (badges.value?.isNotEmpty ?? false) |                         SliverToBoxAdapter(child: publisherBioWidget(data)), | ||||||
|                         BadgeList(badges: badges.value!).padding(top: 16), |                         SliverPostList(pubName: name), | ||||||
|                       if (data.verification != null) |                         SliverGap(MediaQuery.of(context).padding.bottom + 16), | ||||||
|                         VerificationStatusCard( |                       ], | ||||||
|                           mark: data.verification!, |                     ), | ||||||
|                         ).padding(top: 16), |  | ||||||
|                     ], |  | ||||||
|                   ).padding(horizontal: 24), |  | ||||||
|                 ), |  | ||||||
|                 SliverToBoxAdapter( |  | ||||||
|                   child: const Divider(height: 1).padding(vertical: 24), |  | ||||||
|                 ), |  | ||||||
|                 SliverToBoxAdapter( |  | ||||||
|                   child: Column( |  | ||||||
|                     crossAxisAlignment: CrossAxisAlignment.stretch, |  | ||||||
|                     children: [ |  | ||||||
|                       Text('bio').tr().bold(), |  | ||||||
|                       Text( |  | ||||||
|                         data.bio.isEmpty ? 'descriptionNone'.tr() : data.bio, |  | ||||||
|                       ), |  | ||||||
|                     ], |  | ||||||
|                   ).padding(horizontal: 24), |  | ||||||
|                 ), |  | ||||||
|                 SliverToBoxAdapter( |  | ||||||
|                   child: const Divider(height: 1).padding(top: 24), |  | ||||||
|                 ), |  | ||||||
|                 SliverPostList(pubName: name), |  | ||||||
|                 SliverGap(MediaQuery.of(context).padding.bottom + 16), |  | ||||||
|               ], |  | ||||||
|             ), |  | ||||||
|           ), |           ), | ||||||
|       error: |       error: | ||||||
|           (error, stackTrace) => AppScaffold( |           (error, stackTrace) => AppScaffold( | ||||||
|  |             isNoBackground: false, | ||||||
|             appBar: AppBar(leading: const PageBackButton()), |             appBar: AppBar(leading: const PageBackButton()), | ||||||
|             body: Center(child: Text(error.toString())), |             body: Center(child: Text(error.toString())), | ||||||
|           ), |           ), | ||||||
|       loading: |       loading: | ||||||
|           () => AppScaffold( |           () => AppScaffold( | ||||||
|  |             isNoBackground: false, | ||||||
|             appBar: AppBar(leading: const PageBackButton()), |             appBar: AppBar(leading: const PageBackButton()), | ||||||
|             body: Center(child: CircularProgressIndicator()), |             body: Center(child: CircularProgressIndicator()), | ||||||
|           ), |           ), | ||||||
|   | |||||||
| @@ -79,7 +79,7 @@ class RealmDetailScreen extends HookConsumerWidget { | |||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       noBackground: false, |       isNoBackground: false, | ||||||
|       body: realmState.when( |       body: realmState.when( | ||||||
|         loading: () => const Center(child: CircularProgressIndicator()), |         loading: () => const Center(child: CircularProgressIndicator()), | ||||||
|         error: (error, _) => Center(child: Text('Error: $error')), |         error: (error, _) => Center(child: Text('Error: $error')), | ||||||
| @@ -321,10 +321,10 @@ class _RealmActionMenu extends HookConsumerWidget { | |||||||
|                               showConfirmAlert( |                               showConfirmAlert( | ||||||
|                                 'leaveRealmHint'.tr(), |                                 'leaveRealmHint'.tr(), | ||||||
|                                 'leaveRealm'.tr(), |                                 'leaveRealm'.tr(), | ||||||
|                               ).then((confirm) { |                               ).then((confirm) async { | ||||||
|                                 if (confirm) { |                                 if (confirm) { | ||||||
|                                   final client = ref.watch(apiClientProvider); |                                   final client = ref.watch(apiClientProvider); | ||||||
|                                   client.delete( |                                   await client.delete( | ||||||
|                                     '/sphere/realms/$realmSlug/members/me', |                                     '/sphere/realms/$realmSlug/members/me', | ||||||
|                                   ); |                                   ); | ||||||
|                                   ref.invalidate(realmsJoinedProvider); |                                   ref.invalidate(realmsJoinedProvider); | ||||||
| @@ -361,10 +361,12 @@ class _RealmActionMenu extends HookConsumerWidget { | |||||||
|                       showConfirmAlert( |                       showConfirmAlert( | ||||||
|                         'leaveRealmHint'.tr(), |                         'leaveRealmHint'.tr(), | ||||||
|                         'leaveRealm'.tr(), |                         'leaveRealm'.tr(), | ||||||
|                       ).then((confirm) { |                       ).then((confirm) async { | ||||||
|                         if (confirm) { |                         if (confirm) { | ||||||
|                           final client = ref.watch(apiClientProvider); |                           final client = ref.watch(apiClientProvider); | ||||||
|                           client.delete('/sphere/realms/$realmSlug/members/me'); |                           await client.delete( | ||||||
|  |                             '/sphere/realms/$realmSlug/members/me', | ||||||
|  |                           ); | ||||||
|                           ref.invalidate(realmsJoinedProvider); |                           ref.invalidate(realmsJoinedProvider); | ||||||
|                           if (context.mounted) { |                           if (context.mounted) { | ||||||
|                             context.pop(true); |                             context.pop(true); | ||||||
|   | |||||||
| @@ -41,7 +41,7 @@ class RealmListScreen extends HookConsumerWidget { | |||||||
|     final realmInvites = ref.watch(realmInvitesProvider); |     final realmInvites = ref.watch(realmInvitesProvider); | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       noBackground: false, |       isNoBackground: false, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         title: const Text('realms').tr(), |         title: const Text('realms').tr(), | ||||||
|         actions: [ |         actions: [ | ||||||
| @@ -279,7 +279,7 @@ class EditRealmScreen extends HookConsumerWidget { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       noBackground: false, |       isNoBackground: false, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         title: Text(slug == null ? 'createRealm'.tr() : 'editRealm'.tr()), |         title: Text(slug == null ? 'createRealm'.tr() : 'editRealm'.tr()), | ||||||
|         leading: const PageBackButton(), |         leading: const PageBackButton(), | ||||||
|   | |||||||
| @@ -552,7 +552,7 @@ class SettingsScreen extends HookConsumerWidget { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       noBackground: false, |       isNoBackground: false, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         title: Text('settings').tr(), |         title: Text('settings').tr(), | ||||||
|         actions: |         actions: | ||||||
|   | |||||||
| @@ -15,6 +15,7 @@ Future<XFile?> cropImage( | |||||||
|   BuildContext context, { |   BuildContext context, { | ||||||
|   required XFile image, |   required XFile image, | ||||||
|   List<CropAspectRatio?>? allowedAspectRatios, |   List<CropAspectRatio?>? allowedAspectRatios, | ||||||
|  |   bool replacePath = false, | ||||||
| }) async { | }) async { | ||||||
|   final result = await showMaterialImageCropper( |   final result = await showMaterialImageCropper( | ||||||
|     context, |     context, | ||||||
| @@ -34,7 +35,7 @@ Future<XFile?> cropImage( | |||||||
|   croppedFile.dispose(); |   croppedFile.dispose(); | ||||||
|   return XFile.fromData( |   return XFile.fromData( | ||||||
|     croppedBytes.buffer.asUint8List(), |     croppedBytes.buffer.asUint8List(), | ||||||
|     path: image.path, |     path: !replacePath ? image.path : null, | ||||||
|     mimeType: image.mimeType, |     mimeType: image.mimeType, | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -20,6 +20,33 @@ extension DurationFormatter on Duration { | |||||||
|     return '${isNegative ? '-' : ''}$hours:$minutes:$seconds'; |     return '${isNegative ? '-' : ''}$hours:$minutes:$seconds'; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   String formatShortDuration() { | ||||||
|  |     final isNegative = inMicroseconds < 0; | ||||||
|  |     final positiveDuration = isNegative ? -this : this; | ||||||
|  |  | ||||||
|  |     final hours = positiveDuration.inHours; | ||||||
|  |     final minutes = (positiveDuration.inMinutes % 60).toString().padLeft( | ||||||
|  |       2, | ||||||
|  |       '0', | ||||||
|  |     ); | ||||||
|  |     final seconds = (positiveDuration.inSeconds % 60).toString().padLeft( | ||||||
|  |       2, | ||||||
|  |       '0', | ||||||
|  |     ); | ||||||
|  |     final milliseconds = (positiveDuration.inMilliseconds % 1000) | ||||||
|  |         .toString() | ||||||
|  |         .padLeft(3, '0'); | ||||||
|  |  | ||||||
|  |     String result; | ||||||
|  |     if (hours > 0) { | ||||||
|  |       result = | ||||||
|  |           '${isNegative ? '-' : ''}${hours.toString().padLeft(2, '0')}:$minutes:$seconds.$milliseconds'; | ||||||
|  |     } else { | ||||||
|  |       result = '${isNegative ? '-' : ''}$minutes:$seconds.$milliseconds'; | ||||||
|  |     } | ||||||
|  |     return result; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   String formatOffset() { |   String formatOffset() { | ||||||
|     final isNegative = inMicroseconds < 0; |     final isNegative = inMicroseconds < 0; | ||||||
|     final positiveDuration = isNegative ? -this : this; |     final positiveDuration = isNegative ? -this : this; | ||||||
|   | |||||||
| @@ -140,30 +140,27 @@ class VerificationStatusCard extends StatelessWidget { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return Card( |     return Column( | ||||||
|       margin: EdgeInsets.zero, |       crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|       child: Column( |       children: [ | ||||||
|         crossAxisAlignment: CrossAxisAlignment.start, |         Icon( | ||||||
|         children: [ |           mark.type == 4 | ||||||
|           Icon( |               ? Symbols.play_circle | ||||||
|             mark.type == 4 |               : mark.type == 0 | ||||||
|                 ? Symbols.play_circle |               ? Symbols.build_circle | ||||||
|                 : mark.type == 0 |               : Symbols.verified, | ||||||
|                 ? Symbols.build_circle |           size: 32, | ||||||
|                 : Symbols.verified, |           color: kVerificationMarkColors[mark.type], | ||||||
|             size: 32, |           fill: 1, | ||||||
|             color: kVerificationMarkColors[mark.type], |         ), | ||||||
|             fill: 1, |         const Gap(8), | ||||||
|           ), |         Text(mark.title ?? 'No title').bold(), | ||||||
|           const Gap(8), |         Text(mark.description ?? 'descriptionNone'.tr()), | ||||||
|           Text(mark.title ?? 'No title').bold(), |         const Gap(6), | ||||||
|           Text(mark.description ?? 'descriptionNone'.tr()), |         Text( | ||||||
|           const Gap(6), |           'Verified by\n${mark.verifiedBy ?? 'No one verified it'}', | ||||||
|           Text( |         ).fontSize(11).opacity(0.8), | ||||||
|             'Verified by\n${mark.verifiedBy ?? 'No one verified it'}', |       ], | ||||||
|           ).fontSize(11).opacity(0.8), |     ).padding(horizontal: 24, vertical: 16); | ||||||
|         ], |  | ||||||
|       ).padding(horizontal: 24, vertical: 16), |  | ||||||
|     ); |  | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -167,6 +167,7 @@ Future<void> showAccountProfileCard( | |||||||
|     offset: offset ?? Offset.zero, |     offset: offset ?? Offset.zero, | ||||||
|     context: context, |     context: context, | ||||||
|     builder: (context) => AccountProfileCard(uname: uname), |     builder: (context) => AccountProfileCard(uname: uname), | ||||||
|  |     alignment: Alignment.center, | ||||||
|     dimBackground: true, |     dimBackground: true, | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -32,12 +32,12 @@ class BadgeItem extends StatelessWidget { | |||||||
|       child: Container( |       child: Container( | ||||||
|         padding: const EdgeInsets.all(4), |         padding: const EdgeInsets.all(4), | ||||||
|         decoration: BoxDecoration( |         decoration: BoxDecoration( | ||||||
|           color: (template?.color ?? Colors.blue).withOpacity(0.1), |           color: (template?.color ?? Colors.blue).withOpacity(0.2), | ||||||
|           shape: BoxShape.circle, |           shape: BoxShape.circle, | ||||||
|         ), |         ), | ||||||
|         child: Icon( |         child: Icon( | ||||||
|           template?.icon ?? Icons.stars, |           template?.icon ?? Icons.stars, | ||||||
|           color: template?.color ?? Colors.orange, |           color: template?.color ?? Colors.blue, | ||||||
|           size: 20, |           size: 20, | ||||||
|         ), |         ), | ||||||
|       ), |       ), | ||||||
|   | |||||||
| @@ -26,6 +26,8 @@ class FortuneGraphWidget extends HookConsumerWidget { | |||||||
|  |  | ||||||
|   final String? eventCalanderUser; |   final String? eventCalanderUser; | ||||||
|  |  | ||||||
|  |   final EdgeInsets? margin; | ||||||
|  |  | ||||||
|   const FortuneGraphWidget({ |   const FortuneGraphWidget({ | ||||||
|     super.key, |     super.key, | ||||||
|     required this.events, |     required this.events, | ||||||
| @@ -34,6 +36,7 @@ class FortuneGraphWidget extends HookConsumerWidget { | |||||||
|     this.height = 180, |     this.height = 180, | ||||||
|     this.onPointSelected, |     this.onPointSelected, | ||||||
|     this.eventCalanderUser, |     this.eventCalanderUser, | ||||||
|  |     this.margin, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
| @@ -249,7 +252,7 @@ class FortuneGraphWidget extends HookConsumerWidget { | |||||||
|     if (constrainWidth) { |     if (constrainWidth) { | ||||||
|       return ConstrainedBox( |       return ConstrainedBox( | ||||||
|         constraints: BoxConstraints(maxWidth: maxWidth), |         constraints: BoxConstraints(maxWidth: maxWidth), | ||||||
|         child: Card(margin: EdgeInsets.all(16), child: content), |         child: Card(margin: margin ?? EdgeInsets.all(16), child: content), | ||||||
|       ).center(); |       ).center(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -32,7 +32,7 @@ class RestorePurchaseSheet extends HookConsumerWidget { | |||||||
|       try { |       try { | ||||||
|         final client = ref.read(apiClientProvider); |         final client = ref.read(apiClientProvider); | ||||||
|         await client.post( |         await client.post( | ||||||
|           '/subscriptions/order/restore/${selectedProvider.value!}', |           '/id/subscriptions/order/restore/${selectedProvider.value!}', | ||||||
|           data: {'order_id': orderIdController.text.trim()}, |           data: {'order_id': orderIdController.text.trim()}, | ||||||
|         ); |         ); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -86,6 +86,8 @@ class AccountStatusCreationWidget extends HookConsumerWidget { | |||||||
|       onTap: () { |       onTap: () { | ||||||
|         showModalBottomSheet( |         showModalBottomSheet( | ||||||
|           context: context, |           context: context, | ||||||
|  |           isScrollControlled: true, | ||||||
|  |           useRootNavigator: true, | ||||||
|           builder: |           builder: | ||||||
|               (context) => AccountStatusCreationSheet( |               (context) => AccountStatusCreationSheet( | ||||||
|                 initialStatus: |                 initialStatus: | ||||||
|   | |||||||
| @@ -9,6 +9,7 @@ import 'package:island/pods/network.dart'; | |||||||
| import 'package:island/pods/userinfo.dart'; | import 'package:island/pods/userinfo.dart'; | ||||||
| import 'package:island/widgets/account/status.dart'; | import 'package:island/widgets/account/status.dart'; | ||||||
| import 'package:island/widgets/alert.dart'; | import 'package:island/widgets/alert.dart'; | ||||||
|  | import 'package:island/widgets/content/sheet.dart'; | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
|  |  | ||||||
| class AccountStatusCreationSheet extends HookConsumerWidget { | class AccountStatusCreationSheet extends HookConsumerWidget { | ||||||
| @@ -49,7 +50,7 @@ class AccountStatusCreationSheet extends HookConsumerWidget { | |||||||
|         final user = ref.watch(userInfoProvider); |         final user = ref.watch(userInfoProvider); | ||||||
|         final apiClient = ref.read(apiClientProvider); |         final apiClient = ref.read(apiClientProvider); | ||||||
|         await apiClient.request( |         await apiClient.request( | ||||||
|           '/accounts/me/statuses', |           '/id/accounts/me/statuses', | ||||||
|           data: { |           data: { | ||||||
|             'attitude': attitude.value, |             'attitude': attitude.value, | ||||||
|             'is_invisible': isInvisible.value, |             'is_invisible': isInvisible.value, | ||||||
| @@ -71,178 +72,145 @@ class AccountStatusCreationSheet extends HookConsumerWidget { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return Container( |     return SheetScaffold( | ||||||
|       constraints: BoxConstraints( |       heightFactor: 0.6, | ||||||
|         maxHeight: MediaQuery.of(context).size.height * 0.8, |       titleText: | ||||||
|       ), |           initialStatus == null ? 'statusCreate'.tr() : 'statusUpdate'.tr(), | ||||||
|       child: Column( |       actions: [ | ||||||
|         children: [ |         TextButton.icon( | ||||||
|           Padding( |           onPressed: | ||||||
|             padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12), |               submitting.value | ||||||
|             child: Row( |                   ? null | ||||||
|               children: [ |                   : () { | ||||||
|                 Text( |                     submitStatus(); | ||||||
|                   initialStatus == null |                   }, | ||||||
|                       ? 'statusCreate'.tr() |           icon: const Icon(Symbols.upload), | ||||||
|                       : 'statusUpdate'.tr(), |           label: Text(initialStatus == null ? 'create' : 'update').tr(), | ||||||
|                   style: Theme.of(context).textTheme.headlineSmall?.copyWith( |           style: ButtonStyle( | ||||||
|                     fontWeight: FontWeight.w600, |             visualDensity: VisualDensity( | ||||||
|                     letterSpacing: -0.5, |               horizontal: VisualDensity.minimumDensity, | ||||||
|                   ), |             ), | ||||||
|  |             foregroundColor: WidgetStatePropertyAll( | ||||||
|  |               Theme.of(context).colorScheme.onSurface, | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |         if (initialStatus != null) | ||||||
|  |           IconButton( | ||||||
|  |             icon: const Icon(Symbols.delete), | ||||||
|  |             onPressed: submitting.value ? null : () => clearStatus(), | ||||||
|  |             style: IconButton.styleFrom(minimumSize: const Size(36, 36)), | ||||||
|  |           ), | ||||||
|  |       ], | ||||||
|  |       child: SingleChildScrollView( | ||||||
|  |         padding: const EdgeInsets.symmetric(horizontal: 20), | ||||||
|  |         child: Column( | ||||||
|  |           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |           children: [ | ||||||
|  |             const Gap(24), | ||||||
|  |             TextField( | ||||||
|  |               controller: labelController, | ||||||
|  |               decoration: InputDecoration( | ||||||
|  |                 labelText: 'statusLabel'.tr(), | ||||||
|  |                 border: const OutlineInputBorder( | ||||||
|  |                   borderRadius: BorderRadius.all(Radius.circular(12)), | ||||||
|                 ), |                 ), | ||||||
|                 const Spacer(), |               ), | ||||||
|                 TextButton.icon( |               onTapOutside: | ||||||
|                   onPressed: |                   (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                       submitting.value |             ), | ||||||
|                           ? null |             const SizedBox(height: 24), | ||||||
|                           : () { |             Text( | ||||||
|                             submitStatus(); |               'statusAttitude'.tr(), | ||||||
|                           }, |               style: Theme.of(context).textTheme.titleMedium, | ||||||
|                   icon: const Icon(Symbols.upload), |             ), | ||||||
|                   label: Text(initialStatus == null ? 'create' : 'update').tr(), |             const SizedBox(height: 8), | ||||||
|                   style: ButtonStyle( |             SegmentedButton( | ||||||
|                     visualDensity: VisualDensity( |               segments: [ | ||||||
|                       horizontal: VisualDensity.minimumDensity, |                 ButtonSegment( | ||||||
|                     ), |                   value: 0, | ||||||
|                     foregroundColor: WidgetStatePropertyAll( |                   icon: const Icon(Symbols.sentiment_satisfied), | ||||||
|                       Theme.of(context).colorScheme.onSurface, |                   label: Text('attitudePositive'.tr()), | ||||||
|                     ), |  | ||||||
|                   ), |  | ||||||
|                 ), |                 ), | ||||||
|                 if (initialStatus != null) |                 ButtonSegment( | ||||||
|                   IconButton( |                   value: 1, | ||||||
|                     icon: const Icon(Symbols.delete), |                   icon: const Icon(Symbols.sentiment_stressed), | ||||||
|                     onPressed: submitting.value ? null : () => clearStatus(), |                   label: Text('attitudeNeutral'.tr()), | ||||||
|                     style: IconButton.styleFrom( |                 ), | ||||||
|                       minimumSize: const Size(36, 36), |                 ButtonSegment( | ||||||
|                     ), |                   value: 2, | ||||||
|                   ), |                   icon: const Icon(Symbols.sentiment_sad), | ||||||
|                 IconButton( |                   label: Text('attitudeNegative'.tr()), | ||||||
|                   icon: const Icon(Symbols.close), |  | ||||||
|                   onPressed: () => Navigator.pop(context), |  | ||||||
|                   style: IconButton.styleFrom(minimumSize: const Size(36, 36)), |  | ||||||
|                 ), |                 ), | ||||||
|               ], |               ], | ||||||
|  |               selected: {attitude.value}, | ||||||
|  |               onSelectionChanged: (Set<int> newSelection) { | ||||||
|  |                 attitude.value = newSelection.first; | ||||||
|  |               }, | ||||||
|             ), |             ), | ||||||
|           ), |             const Gap(12), | ||||||
|           const Divider(height: 1), |             SwitchListTile( | ||||||
|           Expanded( |               title: Text('statusInvisible'.tr()), | ||||||
|             child: SingleChildScrollView( |               subtitle: Text('statusInvisibleDescription'.tr()), | ||||||
|               padding: const EdgeInsets.symmetric(horizontal: 20), |               value: isInvisible.value, | ||||||
|               child: Column( |               contentPadding: EdgeInsets.symmetric(horizontal: 8), | ||||||
|                 crossAxisAlignment: CrossAxisAlignment.start, |               onChanged: (bool value) { | ||||||
|                 children: [ |                 isInvisible.value = value; | ||||||
|                   const Gap(24), |               }, | ||||||
|                   TextField( |             ), | ||||||
|                     controller: labelController, |             SwitchListTile( | ||||||
|                     decoration: InputDecoration( |               title: Text('statusNotDisturb'.tr()), | ||||||
|                       labelText: 'statusLabel'.tr(), |               subtitle: Text('statusNotDisturbDescription'.tr()), | ||||||
|                       border: const OutlineInputBorder( |               value: isNotDisturb.value, | ||||||
|                         borderRadius: BorderRadius.all(Radius.circular(12)), |               contentPadding: EdgeInsets.symmetric(horizontal: 8), | ||||||
|                       ), |               onChanged: (bool value) { | ||||||
|                     ), |                 isNotDisturb.value = value; | ||||||
|                     onTapOutside: |               }, | ||||||
|                         (_) => FocusManager.instance.primaryFocus?.unfocus(), |             ), | ||||||
|                   ), |             const SizedBox(height: 24), | ||||||
|                   const SizedBox(height: 24), |             Text( | ||||||
|                   Text( |               'statusClearTime'.tr(), | ||||||
|                     'statusAttitude'.tr(), |               style: Theme.of(context).textTheme.titleMedium, | ||||||
|                     style: Theme.of(context).textTheme.titleMedium, |             ), | ||||||
|                   ), |             const SizedBox(height: 8), | ||||||
|                   const SizedBox(height: 8), |             ListTile( | ||||||
|                   SegmentedButton( |               title: Text( | ||||||
|                     segments: [ |                 clearedAt.value == null | ||||||
|                       ButtonSegment( |                     ? 'statusNoAutoClear'.tr() | ||||||
|                         value: 0, |                     : DateFormat.yMMMd().add_jm().format(clearedAt.value!), | ||||||
|                         icon: const Icon(Symbols.sentiment_satisfied), |  | ||||||
|                         label: Text('attitudePositive'.tr()), |  | ||||||
|                       ), |  | ||||||
|                       ButtonSegment( |  | ||||||
|                         value: 1, |  | ||||||
|                         icon: const Icon(Symbols.sentiment_stressed), |  | ||||||
|                         label: Text('attitudeNeutral'.tr()), |  | ||||||
|                       ), |  | ||||||
|                       ButtonSegment( |  | ||||||
|                         value: 2, |  | ||||||
|                         icon: const Icon(Symbols.sentiment_sad), |  | ||||||
|                         label: Text('attitudeNegative'.tr()), |  | ||||||
|                       ), |  | ||||||
|                     ], |  | ||||||
|                     selected: {attitude.value}, |  | ||||||
|                     onSelectionChanged: (Set<int> newSelection) { |  | ||||||
|                       attitude.value = newSelection.first; |  | ||||||
|                     }, |  | ||||||
|                   ), |  | ||||||
|                   const Gap(12), |  | ||||||
|                   SwitchListTile( |  | ||||||
|                     title: Text('statusInvisible'.tr()), |  | ||||||
|                     subtitle: Text('statusInvisibleDescription'.tr()), |  | ||||||
|                     value: isInvisible.value, |  | ||||||
|                     contentPadding: EdgeInsets.symmetric(horizontal: 8), |  | ||||||
|                     onChanged: (bool value) { |  | ||||||
|                       isInvisible.value = value; |  | ||||||
|                     }, |  | ||||||
|                   ), |  | ||||||
|                   SwitchListTile( |  | ||||||
|                     title: Text('statusNotDisturb'.tr()), |  | ||||||
|                     subtitle: Text('statusNotDisturbDescription'.tr()), |  | ||||||
|                     value: isNotDisturb.value, |  | ||||||
|                     contentPadding: EdgeInsets.symmetric(horizontal: 8), |  | ||||||
|                     onChanged: (bool value) { |  | ||||||
|                       isNotDisturb.value = value; |  | ||||||
|                     }, |  | ||||||
|                   ), |  | ||||||
|                   const SizedBox(height: 24), |  | ||||||
|                   Text( |  | ||||||
|                     'statusClearTime'.tr(), |  | ||||||
|                     style: Theme.of(context).textTheme.titleMedium, |  | ||||||
|                   ), |  | ||||||
|                   const SizedBox(height: 8), |  | ||||||
|                   ListTile( |  | ||||||
|                     title: Text( |  | ||||||
|                       clearedAt.value == null |  | ||||||
|                           ? 'statusNoAutoClear'.tr() |  | ||||||
|                           : DateFormat.yMMMd().add_jm().format( |  | ||||||
|                             clearedAt.value!, |  | ||||||
|                           ), |  | ||||||
|                     ), |  | ||||||
|                     trailing: const Icon(Symbols.schedule), |  | ||||||
|                     shape: RoundedRectangleBorder( |  | ||||||
|                       borderRadius: BorderRadius.circular(8), |  | ||||||
|                       side: BorderSide( |  | ||||||
|                         color: Theme.of(context).colorScheme.outline, |  | ||||||
|                       ), |  | ||||||
|                     ), |  | ||||||
|                     onTap: () async { |  | ||||||
|                       final now = DateTime.now(); |  | ||||||
|                       final date = await showDatePicker( |  | ||||||
|                         context: context, |  | ||||||
|                         initialDate: now, |  | ||||||
|                         firstDate: now, |  | ||||||
|                         lastDate: now.add(const Duration(days: 365)), |  | ||||||
|                       ); |  | ||||||
|                       if (date == null) return; |  | ||||||
|                       if (!context.mounted) return; |  | ||||||
|                       final time = await showTimePicker( |  | ||||||
|                         context: context, |  | ||||||
|                         initialTime: TimeOfDay.now(), |  | ||||||
|                       ); |  | ||||||
|                       if (time == null) return; |  | ||||||
|                       clearedAt.value = DateTime( |  | ||||||
|                         date.year, |  | ||||||
|                         date.month, |  | ||||||
|                         date.day, |  | ||||||
|                         time.hour, |  | ||||||
|                         time.minute, |  | ||||||
|                       ); |  | ||||||
|                     }, |  | ||||||
|                   ), |  | ||||||
|                   Gap(MediaQuery.of(context).padding.bottom + 24), |  | ||||||
|                 ], |  | ||||||
|               ), |               ), | ||||||
|  |               trailing: const Icon(Symbols.schedule), | ||||||
|  |               shape: RoundedRectangleBorder( | ||||||
|  |                 borderRadius: BorderRadius.circular(8), | ||||||
|  |                 side: BorderSide(color: Theme.of(context).colorScheme.outline), | ||||||
|  |               ), | ||||||
|  |               onTap: () async { | ||||||
|  |                 final now = DateTime.now(); | ||||||
|  |                 final date = await showDatePicker( | ||||||
|  |                   context: context, | ||||||
|  |                   initialDate: now, | ||||||
|  |                   firstDate: now, | ||||||
|  |                   lastDate: now.add(const Duration(days: 365)), | ||||||
|  |                 ); | ||||||
|  |                 if (date == null) return; | ||||||
|  |                 if (!context.mounted) return; | ||||||
|  |                 final time = await showTimePicker( | ||||||
|  |                   context: context, | ||||||
|  |                   initialTime: TimeOfDay.now(), | ||||||
|  |                 ); | ||||||
|  |                 if (time == null) return; | ||||||
|  |                 clearedAt.value = DateTime( | ||||||
|  |                   date.year, | ||||||
|  |                   date.month, | ||||||
|  |                   date.day, | ||||||
|  |                   time.hour, | ||||||
|  |                   time.minute, | ||||||
|  |                 ); | ||||||
|  |               }, | ||||||
|             ), |             ), | ||||||
|           ), |             Gap(MediaQuery.of(context).padding.bottom + 24), | ||||||
|         ], |           ], | ||||||
|  |         ), | ||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -165,7 +165,7 @@ class AppScaffold extends StatelessWidget { | |||||||
|   final AppBar? appBar; |   final AppBar? appBar; | ||||||
|   final DrawerCallback? onDrawerChanged; |   final DrawerCallback? onDrawerChanged; | ||||||
|   final DrawerCallback? onEndDrawerChanged; |   final DrawerCallback? onEndDrawerChanged; | ||||||
|   final bool? noBackground; |   final bool? isNoBackground; | ||||||
|   final bool? extendBody; |   final bool? extendBody; | ||||||
|  |  | ||||||
|   const AppScaffold({ |   const AppScaffold({ | ||||||
| @@ -181,7 +181,7 @@ class AppScaffold extends StatelessWidget { | |||||||
|     this.endDrawer, |     this.endDrawer, | ||||||
|     this.onDrawerChanged, |     this.onDrawerChanged, | ||||||
|     this.onEndDrawerChanged, |     this.onEndDrawerChanged, | ||||||
|     this.noBackground, |     this.isNoBackground, | ||||||
|     this.extendBody, |     this.extendBody, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
| @@ -190,7 +190,7 @@ class AppScaffold extends StatelessWidget { | |||||||
|     final appBarHeight = appBar?.preferredSize.height ?? 0; |     final appBarHeight = appBar?.preferredSize.height ?? 0; | ||||||
|     final safeTop = MediaQuery.of(context).padding.top; |     final safeTop = MediaQuery.of(context).padding.top; | ||||||
|  |  | ||||||
|     final noBackground = this.noBackground ?? isWideScreen(context); |     final noBackground = isNoBackground ?? isWideScreen(context); | ||||||
|  |  | ||||||
|     final content = Column( |     final content = Column( | ||||||
|       children: [ |       children: [ | ||||||
|   | |||||||
| @@ -4,9 +4,11 @@ import 'package:go_router/go_router.dart'; | |||||||
| import 'package:gap/gap.dart'; | import 'package:gap/gap.dart'; | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
| import 'package:island/pods/call.dart'; | import 'package:island/pods/call.dart'; | ||||||
|  | import 'package:island/pods/network.dart'; | ||||||
| import 'package:island/widgets/alert.dart'; | import 'package:island/widgets/alert.dart'; | ||||||
| import 'package:island/widgets/chat/call_participant_tile.dart'; | import 'package:island/widgets/chat/call_participant_tile.dart'; | ||||||
| import 'package:island/widgets/content/sheet.dart'; | import 'package:island/widgets/content/sheet.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
| import 'package:livekit_client/livekit_client.dart'; | import 'package:livekit_client/livekit_client.dart'; | ||||||
|  |  | ||||||
| @@ -20,8 +22,10 @@ class CallControlsBar extends HookConsumerWidget { | |||||||
|  |  | ||||||
|     return Container( |     return Container( | ||||||
|       padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), |       padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), | ||||||
|       child: Row( |       child: Wrap( | ||||||
|         mainAxisAlignment: MainAxisAlignment.center, |         alignment: WrapAlignment.center, | ||||||
|  |         runSpacing: 16, | ||||||
|  |         spacing: 16, | ||||||
|         children: [ |         children: [ | ||||||
|           _buildCircularButtonWithDropdown( |           _buildCircularButtonWithDropdown( | ||||||
|             context: context, |             context: context, | ||||||
| @@ -33,7 +37,6 @@ class CallControlsBar extends HookConsumerWidget { | |||||||
|             hasDropdown: true, |             hasDropdown: true, | ||||||
|             deviceType: 'videoinput', |             deviceType: 'videoinput', | ||||||
|           ), |           ), | ||||||
|           const Gap(16), |  | ||||||
|           _buildCircularButton( |           _buildCircularButton( | ||||||
|             icon: |             icon: | ||||||
|                 callState.isScreenSharing |                 callState.isScreenSharing | ||||||
| @@ -42,7 +45,6 @@ class CallControlsBar extends HookConsumerWidget { | |||||||
|             onPressed: () => callNotifier.toggleScreenShare(), |             onPressed: () => callNotifier.toggleScreenShare(), | ||||||
|             backgroundColor: const Color(0xFF424242), |             backgroundColor: const Color(0xFF424242), | ||||||
|           ), |           ), | ||||||
|           const Gap(16), |  | ||||||
|           _buildCircularButtonWithDropdown( |           _buildCircularButtonWithDropdown( | ||||||
|             context: context, |             context: context, | ||||||
|             ref: ref, |             ref: ref, | ||||||
| @@ -52,10 +54,62 @@ class CallControlsBar extends HookConsumerWidget { | |||||||
|             hasDropdown: true, |             hasDropdown: true, | ||||||
|             deviceType: 'audioinput', |             deviceType: 'audioinput', | ||||||
|           ), |           ), | ||||||
|           const Gap(16), |           _buildCircularButton( | ||||||
|  |             icon: | ||||||
|  |                 callState.isSpeakerphone | ||||||
|  |                     ? Symbols.mobile_speaker | ||||||
|  |                     : Symbols.ear_sound, | ||||||
|  |             onPressed: () => callNotifier.toggleSpeakerphone(), | ||||||
|  |             backgroundColor: const Color(0xFF424242), | ||||||
|  |           ), | ||||||
|           _buildCircularButton( |           _buildCircularButton( | ||||||
|             icon: Icons.call_end, |             icon: Icons.call_end, | ||||||
|             onPressed: () => callNotifier.disconnect(), |             onPressed: | ||||||
|  |                 () => showModalBottomSheet( | ||||||
|  |                   context: context, | ||||||
|  |                   isScrollControlled: true, | ||||||
|  |                   useRootNavigator: true, | ||||||
|  |                   builder: | ||||||
|  |                       (innerContext) => Column( | ||||||
|  |                         mainAxisSize: MainAxisSize.min, | ||||||
|  |                         children: [ | ||||||
|  |                           ListTile( | ||||||
|  |                             leading: const Icon(Symbols.logout, fill: 1), | ||||||
|  |                             title: Text('callLeave').tr(), | ||||||
|  |                             onTap: () { | ||||||
|  |                               callNotifier.disconnect(); | ||||||
|  |                               Navigator.of(context).pop(); | ||||||
|  |                               Navigator.of(innerContext).pop(); | ||||||
|  |                             }, | ||||||
|  |                           ), | ||||||
|  |                           ListTile( | ||||||
|  |                             leading: const Icon(Symbols.call_end, fill: 1), | ||||||
|  |                             iconColor: Colors.red, | ||||||
|  |                             title: Text('callEnd').tr(), | ||||||
|  |                             onTap: () async { | ||||||
|  |                               callNotifier.disconnect(); | ||||||
|  |                               final apiClient = ref.watch(apiClientProvider); | ||||||
|  |                               try { | ||||||
|  |                                 showLoadingModal(context); | ||||||
|  |                                 await apiClient.delete( | ||||||
|  |                                   '/sphere/chat/realtime/${callNotifier.roomId}', | ||||||
|  |                                 ); | ||||||
|  |                                 callNotifier.dispose(); | ||||||
|  |                                 if (context.mounted) { | ||||||
|  |                                   Navigator.of(context).pop(); | ||||||
|  |                                   Navigator.of(innerContext).pop(); | ||||||
|  |                                 } | ||||||
|  |                               } catch (err) { | ||||||
|  |                                 showErrorAlert(err); | ||||||
|  |                               } finally { | ||||||
|  |                                 if (context.mounted) hideLoadingModal(context); | ||||||
|  |                               } | ||||||
|  |                             }, | ||||||
|  |                           ), | ||||||
|  |                           Gap(MediaQuery.of(context).padding.bottom), | ||||||
|  |                         ], | ||||||
|  |                       ), | ||||||
|  |                 ), | ||||||
|             backgroundColor: const Color(0xFFE53E3E), |             backgroundColor: const Color(0xFFE53E3E), | ||||||
|             iconColor: Colors.white, |             iconColor: Colors.white, | ||||||
|           ), |           ), | ||||||
| @@ -212,24 +266,14 @@ class CallControlsBar extends HookConsumerWidget { | |||||||
|       } |       } | ||||||
|  |  | ||||||
|       if (context.mounted) { |       if (context.mounted) { | ||||||
|         ScaffoldMessenger.of(context).showSnackBar( |         showSnackBar( | ||||||
|           SnackBar( |           'switchedTo'.tr( | ||||||
|             content: Text( |             args: [device.label.isNotEmpty ? device.label : 'device'], | ||||||
|               '${'switchedTo'.tr()} ${device.label.isNotEmpty ? device.label : 'selectedDevice'.tr()}', |  | ||||||
|             ), |  | ||||||
|             backgroundColor: Colors.green, |  | ||||||
|           ), |  | ||||||
|         ); |  | ||||||
|       } |  | ||||||
|     } catch (e) { |  | ||||||
|       if (context.mounted) { |  | ||||||
|         ScaffoldMessenger.of(context).showSnackBar( |  | ||||||
|           SnackBar( |  | ||||||
|             content: Text('${'failedToSwitchDevice'.tr()}: $e'), |  | ||||||
|             backgroundColor: Colors.red, |  | ||||||
|           ), |           ), | ||||||
|         ); |         ); | ||||||
|       } |       } | ||||||
|  |     } catch (err) { | ||||||
|  |       showErrorAlert(err); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
| @@ -279,7 +323,7 @@ class CallOverlayBar extends HookConsumerWidget { | |||||||
|       child: Card( |       child: Card( | ||||||
|         margin: EdgeInsets.zero, |         margin: EdgeInsets.zero, | ||||||
|         child: Row( |         child: Row( | ||||||
|           crossAxisAlignment: CrossAxisAlignment.start, |           crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|           children: [ |           children: [ | ||||||
|             Expanded( |             Expanded( | ||||||
|               child: Row( |               child: Row( | ||||||
| @@ -294,17 +338,7 @@ class CallOverlayBar extends HookConsumerWidget { | |||||||
|                         height: 40, |                         height: 40, | ||||||
|                         child: |                         child: | ||||||
|                             SpeakingRippleAvatar( |                             SpeakingRippleAvatar( | ||||||
|                               isSpeaking: lastSpeaker.isSpeaking, |                               live: lastSpeaker, | ||||||
|                               audioLevel: |  | ||||||
|                                   lastSpeaker.remoteParticipant.audioLevel, |  | ||||||
|                               pictureId: |  | ||||||
|                                   lastSpeaker |  | ||||||
|                                       .participant |  | ||||||
|                                       .profile |  | ||||||
|                                       ?.account |  | ||||||
|                                       .profile |  | ||||||
|                                       .picture |  | ||||||
|                                       ?.id, |  | ||||||
|                               size: 36, |                               size: 36, | ||||||
|                             ).center(), |                             ).center(), | ||||||
|                       ); |                       ); | ||||||
| @@ -314,10 +348,7 @@ class CallOverlayBar extends HookConsumerWidget { | |||||||
|                   Column( |                   Column( | ||||||
|                     crossAxisAlignment: CrossAxisAlignment.start, |                     crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|                     children: [ |                     children: [ | ||||||
|                       Text( |                       Text('@${lastSpeaker.participant.identity}').bold(), | ||||||
|                         lastSpeaker.participant.profile?.account.nick ?? |  | ||||||
|                             'unknown'.tr(), |  | ||||||
|                       ).bold(), |  | ||||||
|                       Text( |                       Text( | ||||||
|                         formatDuration(callState.duration), |                         formatDuration(callState.duration), | ||||||
|                         style: Theme.of(context).textTheme.bodySmall, |                         style: Theme.of(context).textTheme.bodySmall, | ||||||
| @@ -360,7 +391,10 @@ class CallOverlayBar extends HookConsumerWidget { | |||||||
|         ).padding(all: 16), |         ).padding(all: 16), | ||||||
|       ), |       ), | ||||||
|       onTap: () { |       onTap: () { | ||||||
|         context.pushNamed('chatCall', pathParameters: {'id': callNotifier.roomId!}); |         context.pushNamed( | ||||||
|  |           'chatCall', | ||||||
|  |           pathParameters: {'id': callNotifier.roomId!}, | ||||||
|  |         ); | ||||||
|       }, |       }, | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|   | |||||||
							
								
								
									
										127
									
								
								lib/widgets/chat/call_participant_card.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								lib/widgets/chat/call_participant_card.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,127 @@ | |||||||
|  | import 'dart:math' as math; | ||||||
|  |  | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
|  | import 'package:flutter_popup_card/flutter_popup_card.dart'; | ||||||
|  | import 'package:gap/gap.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
|  | import 'package:island/pods/call.dart'; | ||||||
|  | import 'package:island/widgets/account/account_nameplate.dart'; | ||||||
|  | import 'package:livekit_client/livekit_client.dart'; | ||||||
|  | import 'package:material_symbols_icons/material_symbols_icons.dart'; | ||||||
|  | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  |  | ||||||
|  | class CallParticipantCard extends HookConsumerWidget { | ||||||
|  |   final CallParticipantLive live; | ||||||
|  |   const CallParticipantCard({super.key, required this.live}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     final width = | ||||||
|  |         math.min(MediaQuery.of(context).size.width - 80, 360).toDouble(); | ||||||
|  |     final callNotifier = ref.watch(callNotifierProvider.notifier); | ||||||
|  |  | ||||||
|  |     final volumeSliderValue = useState(callNotifier.getParticipantVolume(live)); | ||||||
|  |  | ||||||
|  |     return PopupCard( | ||||||
|  |       elevation: 8, | ||||||
|  |       shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), | ||||||
|  |       child: SizedBox( | ||||||
|  |         width: width, | ||||||
|  |         child: Column( | ||||||
|  |           mainAxisSize: MainAxisSize.min, | ||||||
|  |           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |           children: [ | ||||||
|  |             Column( | ||||||
|  |               spacing: 4, | ||||||
|  |               children: [ | ||||||
|  |                 Row( | ||||||
|  |                   children: [ | ||||||
|  |                     const Icon(Symbols.sound_detection_loud_sound, size: 16), | ||||||
|  |                     const Gap(8), | ||||||
|  |                     Expanded( | ||||||
|  |                       child: Slider( | ||||||
|  |                         max: 2, | ||||||
|  |                         value: volumeSliderValue.value, | ||||||
|  |                         onChanged: (value) { | ||||||
|  |                           volumeSliderValue.value = value; | ||||||
|  |                         }, | ||||||
|  |                         onChangeEnd: (value) { | ||||||
|  |                           callNotifier.setParticipantVolume(live, value); | ||||||
|  |                         }, | ||||||
|  |                         year2023: true, | ||||||
|  |                         padding: EdgeInsets.zero, | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                     const Gap(16), | ||||||
|  |                     SizedBox( | ||||||
|  |                       width: 40, | ||||||
|  |                       child: Text( | ||||||
|  |                         '${(volumeSliderValue.value * 100).toStringAsFixed(0)}%', | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                   ], | ||||||
|  |                 ), | ||||||
|  |                 Row( | ||||||
|  |                   children: [ | ||||||
|  |                     const Icon(Symbols.wifi, size: 16), | ||||||
|  |                     const Gap(8), | ||||||
|  |                     Text(switch (live.remoteParticipant.connectionQuality) { | ||||||
|  |                       ConnectionQuality.excellent => 'Excellent', | ||||||
|  |                       ConnectionQuality.good => 'Good', | ||||||
|  |                       ConnectionQuality.poor => 'Bad', | ||||||
|  |                       ConnectionQuality.lost => 'Lost', | ||||||
|  |                       _ => 'Connecting', | ||||||
|  |                     }), | ||||||
|  |                   ], | ||||||
|  |                 ), | ||||||
|  |               ], | ||||||
|  |             ).padding(horizontal: 20, top: 16), | ||||||
|  |             AccountNameplate( | ||||||
|  |               name: live.participant.identity, | ||||||
|  |               isOutlined: false, | ||||||
|  |             ), | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class CallParticipantGestureDetector extends StatelessWidget { | ||||||
|  |   final CallParticipantLive participant; | ||||||
|  |   final Widget child; | ||||||
|  |   const CallParticipantGestureDetector({ | ||||||
|  |     super.key, | ||||||
|  |     required this.participant, | ||||||
|  |     required this.child, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return GestureDetector( | ||||||
|  |       child: child, | ||||||
|  |       onTapDown: (details) { | ||||||
|  |         showCallParticipantCard( | ||||||
|  |           context, | ||||||
|  |           participant, | ||||||
|  |           offset: details.localPosition, | ||||||
|  |         ); | ||||||
|  |       }, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | Future<void> showCallParticipantCard( | ||||||
|  |   BuildContext context, | ||||||
|  |   CallParticipantLive participant, { | ||||||
|  |   Offset? offset, | ||||||
|  | }) async { | ||||||
|  |   await showPopupCard<void>( | ||||||
|  |     offset: offset ?? Offset.zero, | ||||||
|  |     context: context, | ||||||
|  |     builder: (context) => CallParticipantCard(live: participant), | ||||||
|  |     alignment: Alignment.center, | ||||||
|  |     dimBackground: true, | ||||||
|  |   ); | ||||||
|  | } | ||||||
| @@ -1,92 +1,127 @@ | |||||||
| import 'package:easy_localization/easy_localization.dart'; |  | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
| import 'package:island/pods/call.dart'; | import 'package:island/pods/call.dart'; | ||||||
|  | import 'package:island/screens/account/profile.dart'; | ||||||
|  | import 'package:island/widgets/chat/call_participant_card.dart'; | ||||||
| import 'package:island/widgets/content/cloud_files.dart'; | import 'package:island/widgets/content/cloud_files.dart'; | ||||||
| import 'package:livekit_client/livekit_client.dart'; | import 'package:livekit_client/livekit_client.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
|  | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  |  | ||||||
| class SpeakingRippleAvatar extends StatelessWidget { | class SpeakingRippleAvatar extends HookConsumerWidget { | ||||||
|   final bool isSpeaking; |   final CallParticipantLive live; | ||||||
|   final double audioLevel; |  | ||||||
|   final String? pictureId; |  | ||||||
|   final double size; |   final double size; | ||||||
|  |  | ||||||
|   const SpeakingRippleAvatar({ |   const SpeakingRippleAvatar({super.key, required this.live, this.size = 96}); | ||||||
|     super.key, |  | ||||||
|     required this.isSpeaking, |  | ||||||
|     required this.audioLevel, |  | ||||||
|     required this.pictureId, |  | ||||||
|     this.size = 96, |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     final account = ref.watch(accountProvider(live.participant.identity)); | ||||||
|  |  | ||||||
|     final avatarRadius = size / 2; |     final avatarRadius = size / 2; | ||||||
|     final clampedLevel = audioLevel.clamp(0.0, 1.0); |     final clampedLevel = live.remoteParticipant.audioLevel.clamp(0.0, 1.0); | ||||||
|     final rippleRadius = avatarRadius + clampedLevel * (size * 0.333); |     final rippleRadius = avatarRadius + clampedLevel * (size * 0.333); | ||||||
|     return TweenAnimationBuilder<double>( |     return SizedBox( | ||||||
|       tween: Tween<double>( |       width: size + 8, | ||||||
|         begin: avatarRadius, |       height: size + 8, | ||||||
|         end: isSpeaking ? rippleRadius : avatarRadius, |       child: TweenAnimationBuilder<double>( | ||||||
|       ), |         tween: Tween<double>( | ||||||
|       duration: const Duration(milliseconds: 250), |           begin: avatarRadius, | ||||||
|       curve: Curves.easeOut, |           end: live.remoteParticipant.isSpeaking ? rippleRadius : avatarRadius, | ||||||
|       builder: (context, animatedRadius, child) { |         ), | ||||||
|         return Stack( |         duration: const Duration(milliseconds: 250), | ||||||
|           alignment: Alignment.center, |         curve: Curves.easeOut, | ||||||
|           children: [ |         builder: (context, animatedRadius, child) { | ||||||
|             if (isSpeaking) |           return Stack( | ||||||
|  |             alignment: Alignment.center, | ||||||
|  |             children: [ | ||||||
|  |               if (live.remoteParticipant.isSpeaking) | ||||||
|  |                 Container( | ||||||
|  |                   width: animatedRadius * 2, | ||||||
|  |                   height: animatedRadius * 2, | ||||||
|  |                   decoration: BoxDecoration( | ||||||
|  |                     shape: BoxShape.circle, | ||||||
|  |                     color: Colors.green.withOpacity(0.75 + 0.25 * clampedLevel), | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|               Container( |               Container( | ||||||
|                 width: animatedRadius * 2, |                 width: size, | ||||||
|                 height: animatedRadius * 2, |                 height: size, | ||||||
|                 decoration: BoxDecoration( |                 alignment: Alignment.center, | ||||||
|                   shape: BoxShape.circle, |                 decoration: BoxDecoration(shape: BoxShape.circle), | ||||||
|                   color: Colors.green.withOpacity(0.75 + 0.25 * clampedLevel), |                 child: account.when( | ||||||
|  |                   data: | ||||||
|  |                       (value) => CallParticipantGestureDetector( | ||||||
|  |                         participant: live, | ||||||
|  |                         child: ProfilePictureWidget( | ||||||
|  |                           file: value.profile.picture, | ||||||
|  |                           radius: size / 2, | ||||||
|  |                         ), | ||||||
|  |                       ), | ||||||
|  |                   error: | ||||||
|  |                       (_, _) => CircleAvatar( | ||||||
|  |                         radius: size / 2, | ||||||
|  |                         child: const Icon(Symbols.person_remove), | ||||||
|  |                       ), | ||||||
|  |                   loading: | ||||||
|  |                       () => CircleAvatar( | ||||||
|  |                         radius: size / 2, | ||||||
|  |                         child: CircularProgressIndicator(), | ||||||
|  |                       ), | ||||||
|                 ), |                 ), | ||||||
|               ), |               ), | ||||||
|             Container( |               if (live.remoteParticipant.isMuted) | ||||||
|               width: size, |                 Positioned( | ||||||
|               height: size, |                   bottom: 4, | ||||||
|               alignment: Alignment.center, |                   right: 4, | ||||||
|               decoration: BoxDecoration(shape: BoxShape.circle), |                   child: Container( | ||||||
|               child: ProfilePictureWidget(fileId: pictureId, radius: size / 2), |                     width: 20, | ||||||
|             ), |                     height: 20, | ||||||
|           ], |                     decoration: BoxDecoration( | ||||||
|         ); |                       color: Colors.red, | ||||||
|       }, |                       borderRadius: BorderRadius.all(Radius.circular(10)), | ||||||
|  |                     ), | ||||||
|  |                     child: const Icon( | ||||||
|  |                       Symbols.mic_off, | ||||||
|  |                       size: 14, | ||||||
|  |                       fill: 1, | ||||||
|  |                     ).padding(left: 1.5, top: 1.5), | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |             ], | ||||||
|  |           ); | ||||||
|  |         }, | ||||||
|  |       ), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| class CallParticipantTile extends StatelessWidget { | class CallParticipantTile extends HookConsumerWidget { | ||||||
|   final CallParticipantLive live; |   final CallParticipantLive live; | ||||||
|  |  | ||||||
|   const CallParticipantTile({super.key, required this.live}); |   const CallParticipantTile({super.key, required this.live}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|     final hasVideo = |     final hasVideo = | ||||||
|         live.hasVideo && |         live.hasVideo && | ||||||
|         live.remoteParticipant.trackPublications.values |         live.remoteParticipant.trackPublications.values | ||||||
|             .where((pub) => pub.track != null && pub.kind == TrackType.VIDEO) |             .where((pub) => pub.track != null && pub.kind == TrackType.VIDEO) | ||||||
|             .isNotEmpty; |             .isNotEmpty; | ||||||
|     final audioLevel = live.remoteParticipant.audioLevel; |  | ||||||
|  |  | ||||||
|     if (hasVideo) { |     if (hasVideo) { | ||||||
|       return Stack( |       return Stack( | ||||||
|         fit: StackFit.loose, |         fit: StackFit.loose, | ||||||
|         children: [ |         children: [ | ||||||
|           Container( |           AspectRatio( | ||||||
|             color: Theme.of(context).colorScheme.surfaceContainerHigh, |             aspectRatio: 16 / 9, | ||||||
|             child: AspectRatio( |             child: VideoTrackRenderer( | ||||||
|               aspectRatio: 16 / 9, |               live.remoteParticipant.trackPublications.values | ||||||
|               child: VideoTrackRenderer( |                       .where((track) => track.kind == TrackType.VIDEO) | ||||||
|                 live.remoteParticipant.trackPublications.values |                       .first | ||||||
|                         .where((track) => track.kind == TrackType.VIDEO) |                       .track | ||||||
|                         .first |                   as VideoTrack, | ||||||
|                         .track |               renderMode: VideoRenderMode.platformView, | ||||||
|                     as VideoTrack, |  | ||||||
|                 renderMode: VideoRenderMode.platformView, |  | ||||||
|               ), |  | ||||||
|             ), |             ), | ||||||
|           ), |           ), | ||||||
|           Positioned( |           Positioned( | ||||||
| @@ -94,21 +129,26 @@ class CallParticipantTile extends StatelessWidget { | |||||||
|             right: 8, |             right: 8, | ||||||
|             bottom: 8, |             bottom: 8, | ||||||
|             child: Text( |             child: Text( | ||||||
|               live.participant.profile?.account.nick ?? |               '@${live.participant.name}', | ||||||
|                   '${'unknown'.tr()}\'s video', |  | ||||||
|               textAlign: TextAlign.center, |               textAlign: TextAlign.center, | ||||||
|               style: const TextStyle(fontSize: 14, color: Colors.white), |               style: const TextStyle( | ||||||
|  |                 fontSize: 14, | ||||||
|  |                 color: Colors.white, | ||||||
|  |                 shadows: [ | ||||||
|  |                   BoxShadow( | ||||||
|  |                     color: Colors.black54, | ||||||
|  |                     offset: Offset(1, 1), | ||||||
|  |                     spreadRadius: 8, | ||||||
|  |                     blurRadius: 8, | ||||||
|  |                   ), | ||||||
|  |                 ], | ||||||
|  |               ), | ||||||
|             ), |             ), | ||||||
|           ), |           ), | ||||||
|         ], |         ], | ||||||
|       ); |       ); | ||||||
|     } else { |     } else { | ||||||
|       return SpeakingRippleAvatar( |       return SpeakingRippleAvatar(size: 84, live: live); | ||||||
|         isSpeaking: live.isSpeaking, |  | ||||||
|         audioLevel: audioLevel, |  | ||||||
|         pictureId: live.participant.profile?.account.profile.picture?.id, |  | ||||||
|         size: 84, |  | ||||||
|       ); |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -5,16 +5,19 @@ import 'package:easy_localization/easy_localization.dart'; | |||||||
| import 'package:flutter/foundation.dart'; | import 'package:flutter/foundation.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter/services.dart'; | import 'package:flutter/services.dart'; | ||||||
|  | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
| import 'package:gap/gap.dart'; | import 'package:gap/gap.dart'; | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
| import 'package:island/database/message.dart'; | import 'package:island/database/message.dart'; | ||||||
| import 'package:island/models/chat.dart'; | import 'package:island/models/chat.dart'; | ||||||
| import 'package:island/models/embed.dart'; | import 'package:island/models/embed.dart'; | ||||||
| import 'package:island/pods/call.dart'; | import 'package:island/pods/call.dart'; | ||||||
|  | import 'package:island/pods/translate.dart'; | ||||||
| import 'package:island/screens/chat/room.dart'; | import 'package:island/screens/chat/room.dart'; | ||||||
| import 'package:island/widgets/account/account_name.dart'; | import 'package:island/widgets/account/account_name.dart'; | ||||||
| import 'package:island/widgets/account/account_pfc.dart'; | import 'package:island/widgets/account/account_pfc.dart'; | ||||||
| import 'package:island/widgets/app_scaffold.dart'; | import 'package:island/widgets/app_scaffold.dart'; | ||||||
|  | import 'package:island/widgets/content/alert.native.dart'; | ||||||
| import 'package:island/widgets/content/cloud_file_collection.dart'; | import 'package:island/widgets/content/cloud_file_collection.dart'; | ||||||
| import 'package:island/widgets/content/cloud_files.dart'; | import 'package:island/widgets/content/cloud_files.dart'; | ||||||
| import 'package:island/widgets/content/embed/link.dart'; | import 'package:island/widgets/content/embed/link.dart'; | ||||||
| @@ -67,6 +70,46 @@ class MessageItem extends HookConsumerWidget { | |||||||
|  |  | ||||||
|     final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS); |     final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS); | ||||||
|  |  | ||||||
|  |     final messageLanguage = | ||||||
|  |         remoteMessage.content != null | ||||||
|  |             ? ref.watch(detectStringLanguageProvider(remoteMessage.content!)) | ||||||
|  |             : null; | ||||||
|  |  | ||||||
|  |     final currentLanguage = context.locale.toString(); | ||||||
|  |     final translatableLanguage = | ||||||
|  |         messageLanguage != null | ||||||
|  |             ? messageLanguage.substring(0, 2) != currentLanguage.substring(0, 2) | ||||||
|  |             : false; | ||||||
|  |  | ||||||
|  |     final translating = useState(false); | ||||||
|  |     final translatedText = useState<String?>(null); | ||||||
|  |  | ||||||
|  |     Future<void> translate() async { | ||||||
|  |       if (translatedText.value != null) { | ||||||
|  |         translatedText.value = null; | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (translating.value) return; | ||||||
|  |       if (remoteMessage.content == null) return; | ||||||
|  |       translating.value = true; | ||||||
|  |       try { | ||||||
|  |         final text = await ref.watch( | ||||||
|  |           translateStringProvider( | ||||||
|  |             TranslateQuery( | ||||||
|  |               text: remoteMessage.content!, | ||||||
|  |               lang: currentLanguage.substring(0, 2), | ||||||
|  |             ), | ||||||
|  |           ).future, | ||||||
|  |         ); | ||||||
|  |         translatedText.value = text; | ||||||
|  |       } catch (err) { | ||||||
|  |         showErrorAlert(err); | ||||||
|  |       } finally { | ||||||
|  |         translating.value = false; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     return ContextMenuWidget( |     return ContextMenuWidget( | ||||||
|       menuProvider: (_) { |       menuProvider: (_) { | ||||||
|         if (onAction == null) return Menu(children: []); |         if (onAction == null) return Menu(children: []); | ||||||
| @@ -103,6 +146,18 @@ class MessageItem extends HookConsumerWidget { | |||||||
|                 onAction!.call(MessageItemAction.forward); |                 onAction!.call(MessageItemAction.forward); | ||||||
|               }, |               }, | ||||||
|             ), |             ), | ||||||
|  |             if (translatableLanguage) MenuSeparator(), | ||||||
|  |             if (translatableLanguage) | ||||||
|  |               MenuAction( | ||||||
|  |                 title: | ||||||
|  |                     translatedText.value == null | ||||||
|  |                         ? 'translate'.tr() | ||||||
|  |                         : translating.value | ||||||
|  |                         ? 'translating'.tr() | ||||||
|  |                         : 'translated'.tr(), | ||||||
|  |                 image: MenuImage.icon(Symbols.translate), | ||||||
|  |                 callback: translate, | ||||||
|  |               ), | ||||||
|             if (isMobile) MenuSeparator(), |             if (isMobile) MenuSeparator(), | ||||||
|             if (isMobile) |             if (isMobile) | ||||||
|               MenuAction( |               MenuAction( | ||||||
| @@ -221,14 +276,18 @@ class MessageItem extends HookConsumerWidget { | |||||||
|                               isReply: false, |                               isReply: false, | ||||||
|                             ).padding(vertical: 4), |                             ).padding(vertical: 4), | ||||||
|                           if (_MessageItemContent.hasContent(remoteMessage)) |                           if (_MessageItemContent.hasContent(remoteMessage)) | ||||||
|                             _MessageItemContent(item: remoteMessage), |                             _MessageItemContent( | ||||||
|  |                               item: remoteMessage, | ||||||
|  |                               translatedText: translatedText.value, | ||||||
|  |                             ), | ||||||
|                           if (remoteMessage.attachments.isNotEmpty) |                           if (remoteMessage.attachments.isNotEmpty) | ||||||
|                             LayoutBuilder( |                             LayoutBuilder( | ||||||
|                               builder: (context, constraints) { |                               builder: (context, constraints) { | ||||||
|                                 return CloudFileList( |                                 return CloudFileList( | ||||||
|                                   files: remoteMessage.attachments, |                                   files: remoteMessage.attachments, | ||||||
|                                   maxWidth: constraints.maxWidth, |                                   maxWidth: constraints.maxWidth, | ||||||
|                                 ).padding(vertical: 4); |                                   padding: EdgeInsets.symmetric(vertical: 4), | ||||||
|  |                                 ); | ||||||
|                               }, |                               }, | ||||||
|                             ), |                             ), | ||||||
|                           if (remoteMessage.meta['embeds'] != null) |                           if (remoteMessage.meta['embeds'] != null) | ||||||
| @@ -481,7 +540,8 @@ class MessageQuoteWidget extends HookConsumerWidget { | |||||||
|  |  | ||||||
| class _MessageItemContent extends StatelessWidget { | class _MessageItemContent extends StatelessWidget { | ||||||
|   final SnChatMessage item; |   final SnChatMessage item; | ||||||
|   const _MessageItemContent({required this.item}); |   final String? translatedText; | ||||||
|  |   const _MessageItemContent({required this.item, this.translatedText}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
| @@ -494,10 +554,40 @@ class _MessageItemContent extends StatelessWidget { | |||||||
|         ); |         ); | ||||||
|       case 'text': |       case 'text': | ||||||
|       default: |       default: | ||||||
|         return MarkdownTextContent( |         return Column( | ||||||
|           content: item.content!, |           mainAxisSize: MainAxisSize.min, | ||||||
|           isSelectable: true, |           crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|           linesMargin: EdgeInsets.zero, |           children: [ | ||||||
|  |             MarkdownTextContent( | ||||||
|  |               content: item.content!, | ||||||
|  |               isSelectable: true, | ||||||
|  |               linesMargin: EdgeInsets.zero, | ||||||
|  |             ), | ||||||
|  |             if (translatedText?.isNotEmpty ?? false) | ||||||
|  |               ...([ | ||||||
|  |                 ConstrainedBox( | ||||||
|  |                   constraints: BoxConstraints( | ||||||
|  |                     maxWidth: math.min( | ||||||
|  |                       280, | ||||||
|  |                       MediaQuery.of(context).size.width * 0.4, | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|  |                   child: Row( | ||||||
|  |                     mainAxisSize: MainAxisSize.min, | ||||||
|  |                     children: [ | ||||||
|  |                       Text('translated').tr().fontSize(11).opacity(0.75), | ||||||
|  |                       const Gap(8), | ||||||
|  |                       Flexible(child: Divider()), | ||||||
|  |                     ], | ||||||
|  |                   ).padding(vertical: 4), | ||||||
|  |                 ), | ||||||
|  |                 MarkdownTextContent( | ||||||
|  |                   content: translatedText!, | ||||||
|  |                   isSelectable: true, | ||||||
|  |                   linesMargin: EdgeInsets.zero, | ||||||
|  |                 ), | ||||||
|  |               ]), | ||||||
|  |           ], | ||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -35,7 +35,8 @@ Future<SnCheckInResult?> checkInResultToday(Ref ref) async { | |||||||
| } | } | ||||||
|  |  | ||||||
| class CheckInWidget extends HookConsumerWidget { | class CheckInWidget extends HookConsumerWidget { | ||||||
|   const CheckInWidget({super.key}); |   final EdgeInsets? margin; | ||||||
|  |   const CheckInWidget({super.key, this.margin}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context, WidgetRef ref) { |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
| @@ -66,7 +67,8 @@ class CheckInWidget extends HookConsumerWidget { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     return Card( |     return Card( | ||||||
|       margin: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: 8), |       margin: | ||||||
|  |           margin ?? EdgeInsets.only(left: 16, right: 16, top: 16, bottom: 8), | ||||||
|       child: Row( |       child: Row( | ||||||
|         crossAxisAlignment: CrossAxisAlignment.center, |         crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|         spacing: 16, |         spacing: 16, | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | import 'dart:convert'; | ||||||
| import 'dart:io'; | import 'dart:io'; | ||||||
|  |  | ||||||
| import 'package:cross_file/cross_file.dart'; | import 'package:cross_file/cross_file.dart'; | ||||||
| @@ -5,18 +6,89 @@ import 'package:easy_localization/easy_localization.dart'; | |||||||
| import 'package:flutter/foundation.dart'; | import 'package:flutter/foundation.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:gap/gap.dart'; | import 'package:gap/gap.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
| import 'package:island/models/file.dart'; | import 'package:island/models/file.dart'; | ||||||
|  | import 'package:island/pods/network.dart'; | ||||||
|  | import 'package:island/services/file.dart'; | ||||||
|  | import 'package:island/widgets/alert.dart'; | ||||||
| import 'package:island/widgets/content/cloud_files.dart'; | import 'package:island/widgets/content/cloud_files.dart'; | ||||||
|  | import 'package:island/widgets/content/sheet.dart'; | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:super_context_menu/super_context_menu.dart'; | ||||||
|  |  | ||||||
| class AttachmentPreview extends StatelessWidget { | import 'sensitive.dart'; | ||||||
|  |  | ||||||
|  | class SensitiveMarksSelector extends StatefulWidget { | ||||||
|  |   final List<int> initial; | ||||||
|  |   final ValueChanged<List<int>>? onChanged; | ||||||
|  |  | ||||||
|  |   const SensitiveMarksSelector({ | ||||||
|  |     super.key, | ||||||
|  |     required this.initial, | ||||||
|  |     this.onChanged, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<SensitiveMarksSelector> createState() => SensitiveMarksSelectorState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class SensitiveMarksSelectorState extends State<SensitiveMarksSelector> { | ||||||
|  |   late List<int> _selected; | ||||||
|  |  | ||||||
|  |   List<int> get current => _selected; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _selected = [...widget.initial]; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void _toggle(int value) { | ||||||
|  |     setState(() { | ||||||
|  |       if (_selected.contains(value)) { | ||||||
|  |         _selected.remove(value); | ||||||
|  |       } else { | ||||||
|  |         _selected.add(value); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |     widget.onChanged?.call([..._selected]); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     // Build a list of all categories in fixed order as int list indices | ||||||
|  |     final categories = kSensitiveCategoriesOrdered; | ||||||
|  |  | ||||||
|  |     return Column( | ||||||
|  |       crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |       children: [ | ||||||
|  |         Wrap( | ||||||
|  |           spacing: 8, | ||||||
|  |           children: [ | ||||||
|  |             for (var i = 0; i < categories.length; i++) | ||||||
|  |               FilterChip( | ||||||
|  |                 label: Text(categories[i].i18nKey.tr()), | ||||||
|  |                 avatar: Text(categories[i].symbol), | ||||||
|  |                 selected: _selected.contains(i), | ||||||
|  |                 onSelected: (_) => _toggle(i), | ||||||
|  |               ), | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class AttachmentPreview extends HookConsumerWidget { | ||||||
|   final UniversalFile item; |   final UniversalFile item; | ||||||
|   final double? progress; |   final double? progress; | ||||||
|   final Function(int)? onMove; |   final Function(int)? onMove; | ||||||
|   final Function? onDelete; |   final Function? onDelete; | ||||||
|   final Function? onInsert; |   final Function? onInsert; | ||||||
|  |   final Function(UniversalFile)? onUpdate; | ||||||
|   final Function? onRequestUpload; |   final Function? onRequestUpload; | ||||||
|  |  | ||||||
|   const AttachmentPreview({ |   const AttachmentPreview({ | ||||||
|     super.key, |     super.key, | ||||||
|     required this.item, |     required this.item, | ||||||
| @@ -24,11 +96,170 @@ class AttachmentPreview extends StatelessWidget { | |||||||
|     this.onRequestUpload, |     this.onRequestUpload, | ||||||
|     this.onMove, |     this.onMove, | ||||||
|     this.onDelete, |     this.onDelete, | ||||||
|  |     this.onUpdate, | ||||||
|     this.onInsert, |     this.onInsert, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|  |   // GlobalKey for selector | ||||||
|  |   static final GlobalKey<SensitiveMarksSelectorState> _sensitiveSelectorKey = | ||||||
|  |       GlobalKey<SensitiveMarksSelectorState>(); | ||||||
|  |  | ||||||
|  |   Future<void> _showRenameDialog(BuildContext context, WidgetRef ref) async { | ||||||
|  |     final nameController = TextEditingController(text: item.data.name); | ||||||
|  |     String? errorMessage; | ||||||
|  |  | ||||||
|  |     await showModalBottomSheet( | ||||||
|  |       context: context, | ||||||
|  |       isScrollControlled: true, | ||||||
|  |       builder: | ||||||
|  |           (context) => SheetScaffold( | ||||||
|  |             heightFactor: 0.6, | ||||||
|  |             titleText: 'rename'.tr(), | ||||||
|  |             child: Column( | ||||||
|  |               mainAxisSize: MainAxisSize.min, | ||||||
|  |               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |               children: [ | ||||||
|  |                 Padding( | ||||||
|  |                   padding: const EdgeInsets.symmetric( | ||||||
|  |                     horizontal: 24, | ||||||
|  |                     vertical: 24, | ||||||
|  |                   ), | ||||||
|  |                   child: TextField( | ||||||
|  |                     controller: nameController, | ||||||
|  |                     decoration: InputDecoration( | ||||||
|  |                       labelText: 'fileName'.tr(), | ||||||
|  |                       border: const OutlineInputBorder(), | ||||||
|  |                       errorText: errorMessage, | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |                 Row( | ||||||
|  |                   mainAxisAlignment: MainAxisAlignment.end, | ||||||
|  |                   children: [ | ||||||
|  |                     TextButton( | ||||||
|  |                       onPressed: () => Navigator.pop(context), | ||||||
|  |                       child: Text('cancel'.tr()), | ||||||
|  |                     ), | ||||||
|  |                     const Gap(8), | ||||||
|  |                     TextButton( | ||||||
|  |                       onPressed: () async { | ||||||
|  |                         final newName = nameController.text.trim(); | ||||||
|  |                         if (newName.isEmpty) { | ||||||
|  |                           errorMessage = 'fieldCannotBeEmpty'.tr(); | ||||||
|  |                           return; | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         try { | ||||||
|  |                           showLoadingModal(context); | ||||||
|  |                           final apiClient = ref.watch(apiClientProvider); | ||||||
|  |                           await apiClient.patch( | ||||||
|  |                             '/drive/files/${item.data.id}/name', | ||||||
|  |                             data: jsonEncode(newName), | ||||||
|  |                           ); | ||||||
|  |                           final newData = item.data; | ||||||
|  |                           newData.name = newName; | ||||||
|  |                           final updatedFile = item.copyWith(data: newData); | ||||||
|  |                           onUpdate?.call(item.copyWith(data: updatedFile)); | ||||||
|  |                           if (context.mounted) Navigator.pop(context); | ||||||
|  |                         } catch (err) { | ||||||
|  |                           showErrorAlert(err); | ||||||
|  |                         } finally { | ||||||
|  |                           if (context.mounted) hideLoadingModal(context); | ||||||
|  |                         } | ||||||
|  |                       }, | ||||||
|  |                       child: Text('rename'.tr()), | ||||||
|  |                     ), | ||||||
|  |                   ], | ||||||
|  |                 ).padding(horizontal: 16, vertical: 8), | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> _showSensitiveDialog(BuildContext context, WidgetRef ref) async { | ||||||
|  |     await showModalBottomSheet( | ||||||
|  |       context: context, | ||||||
|  |       isScrollControlled: true, | ||||||
|  |       builder: | ||||||
|  |           (context) => SheetScaffold( | ||||||
|  |             heightFactor: 0.6, | ||||||
|  |             titleText: 'markAsSensitive'.tr(), | ||||||
|  |             child: Column( | ||||||
|  |               mainAxisSize: MainAxisSize.min, | ||||||
|  |               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |               children: [ | ||||||
|  |                 Padding( | ||||||
|  |                   padding: const EdgeInsets.symmetric( | ||||||
|  |                     horizontal: 24, | ||||||
|  |                     vertical: 24, | ||||||
|  |                   ), | ||||||
|  |                   child: Column( | ||||||
|  |                     children: [ | ||||||
|  |                       // Sensitive categories checklist | ||||||
|  |                       SensitiveMarksSelector( | ||||||
|  |                         key: _sensitiveSelectorKey, | ||||||
|  |                         initial: | ||||||
|  |                             (item.data.sensitiveMarks ?? []) | ||||||
|  |                                 .map((e) => e as int) | ||||||
|  |                                 .cast<int>() | ||||||
|  |                                 .toList(), | ||||||
|  |                         onChanged: (marks) { | ||||||
|  |                           // Update local data immediately (optimistic) | ||||||
|  |                           final newData = item.data; | ||||||
|  |                           newData.sensitiveMarks = marks; | ||||||
|  |                           final updatedFile = item.copyWith(data: newData); | ||||||
|  |                           onUpdate?.call(item.copyWith(data: updatedFile)); | ||||||
|  |                         }, | ||||||
|  |                       ), | ||||||
|  |                     ], | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |                 Row( | ||||||
|  |                   mainAxisAlignment: MainAxisAlignment.end, | ||||||
|  |                   children: [ | ||||||
|  |                     TextButton( | ||||||
|  |                       onPressed: () => Navigator.pop(context), | ||||||
|  |                       child: Text('cancel'.tr()), | ||||||
|  |                     ), | ||||||
|  |                     const Gap(8), | ||||||
|  |                     TextButton( | ||||||
|  |                       onPressed: () async { | ||||||
|  |                         try { | ||||||
|  |                           showLoadingModal(context); | ||||||
|  |                           final apiClient = ref.watch(apiClientProvider); | ||||||
|  |                           // Use the current selections from stateful selector via GlobalKey | ||||||
|  |                           final selectorState = | ||||||
|  |                               _sensitiveSelectorKey.currentState; | ||||||
|  |                           final marks = selectorState?.current ?? <int>[]; | ||||||
|  |                           await apiClient.put( | ||||||
|  |                             '/drive/files/${item.data.id}/marks', | ||||||
|  |                             data: jsonEncode({'sensitive_marks': marks}), | ||||||
|  |                           ); | ||||||
|  |                           final newData = item.data as SnCloudFile; | ||||||
|  |                           final updatedFile = item.copyWith( | ||||||
|  |                             data: newData.copyWith(sensitiveMarks: marks), | ||||||
|  |                           ); | ||||||
|  |                           onUpdate?.call(updatedFile); | ||||||
|  |                           if (context.mounted) Navigator.pop(context); | ||||||
|  |                         } catch (err) { | ||||||
|  |                           showErrorAlert(err); | ||||||
|  |                         } finally { | ||||||
|  |                           if (context.mounted) hideLoadingModal(context); | ||||||
|  |                         } | ||||||
|  |                       }, | ||||||
|  |                       child: Text('confirm'.tr()), | ||||||
|  |                     ), | ||||||
|  |                   ], | ||||||
|  |                 ).padding(horizontal: 16, vertical: 8), | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|     var ratio = |     var ratio = | ||||||
|         item.isOnCloud |         item.isOnCloud | ||||||
|             ? (item.data.fileMeta?['ratio'] is num |             ? (item.data.fileMeta?['ratio'] is num | ||||||
| @@ -37,217 +268,265 @@ class AttachmentPreview extends StatelessWidget { | |||||||
|             : 1.0; |             : 1.0; | ||||||
|     if (ratio == 0) ratio = 1.0; |     if (ratio == 0) ratio = 1.0; | ||||||
|  |  | ||||||
|     return AspectRatio( |     final contentWidget = ClipRRect( | ||||||
|       aspectRatio: ratio, |       borderRadius: BorderRadius.circular(8), | ||||||
|       child: ClipRRect( |       child: Container( | ||||||
|         borderRadius: BorderRadius.circular(8), |         color: Theme.of(context).colorScheme.surfaceContainer, | ||||||
|         child: Stack( |         child: Column( | ||||||
|           fit: StackFit.expand, |  | ||||||
|           children: [ |           children: [ | ||||||
|             Container( |             Row( | ||||||
|               color: Theme.of(context).colorScheme.surfaceContainerHigh, |               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||||
|               child: Builder( |               children: [ | ||||||
|                 builder: (context) { |                 ClipRRect( | ||||||
|                   if (item.isOnCloud) { |                   borderRadius: BorderRadius.circular(8), | ||||||
|                     return CloudFileWidget(item: item.data); |                   child: Container( | ||||||
|                   } else if (item.data is XFile) { |                     color: Colors.black.withOpacity(0.5), | ||||||
|                     if (item.type == UniversalFileType.image) { |                     child: Material( | ||||||
|                       final file = item.data as XFile; |                       color: Colors.transparent, | ||||||
|                       if (file.path.isEmpty) { |                       child: Row( | ||||||
|                         return FutureBuilder<Uint8List>( |                         mainAxisSize: MainAxisSize.min, | ||||||
|                           future: file.readAsBytes(), |                         children: [ | ||||||
|                           builder: (context, snapshot) { |                           if (onDelete != null) | ||||||
|                             if (snapshot.hasData) { |                             InkWell( | ||||||
|                               return Image.memory(snapshot.data!); |                               borderRadius: BorderRadius.circular(8), | ||||||
|                             } |                               child: Icon( | ||||||
|                             return const Center( |                                 item.isLink ? Symbols.link_off : Symbols.delete, | ||||||
|                               child: CircularProgressIndicator(), |                                 size: 14, | ||||||
|  |                                 color: Colors.white, | ||||||
|  |                               ).padding(horizontal: 8, vertical: 6), | ||||||
|  |                               onTap: () { | ||||||
|  |                                 onDelete?.call(); | ||||||
|  |                               }, | ||||||
|  |                             ), | ||||||
|  |                           if (onDelete != null && onMove != null) | ||||||
|  |                             SizedBox( | ||||||
|  |                               height: 26, | ||||||
|  |                               child: const VerticalDivider( | ||||||
|  |                                 width: 0.3, | ||||||
|  |                                 color: Colors.white, | ||||||
|  |                                 thickness: 0.3, | ||||||
|  |                               ), | ||||||
|  |                             ).padding(horizontal: 2), | ||||||
|  |                           if (onMove != null) | ||||||
|  |                             InkWell( | ||||||
|  |                               borderRadius: BorderRadius.circular(8), | ||||||
|  |                               child: const Icon( | ||||||
|  |                                 Symbols.keyboard_arrow_up, | ||||||
|  |                                 size: 14, | ||||||
|  |                                 color: Colors.white, | ||||||
|  |                               ).padding(horizontal: 8, vertical: 6), | ||||||
|  |                               onTap: () { | ||||||
|  |                                 onMove?.call(-1); | ||||||
|  |                               }, | ||||||
|  |                             ), | ||||||
|  |                           if (onMove != null) | ||||||
|  |                             InkWell( | ||||||
|  |                               borderRadius: BorderRadius.circular(8), | ||||||
|  |                               child: const Icon( | ||||||
|  |                                 Symbols.keyboard_arrow_down, | ||||||
|  |                                 size: 14, | ||||||
|  |                                 color: Colors.white, | ||||||
|  |                               ).padding(horizontal: 8, vertical: 6), | ||||||
|  |                               onTap: () { | ||||||
|  |                                 onMove?.call(1); | ||||||
|  |                               }, | ||||||
|  |                             ), | ||||||
|  |                           if (onInsert != null) | ||||||
|  |                             InkWell( | ||||||
|  |                               borderRadius: BorderRadius.circular(8), | ||||||
|  |                               child: const Icon( | ||||||
|  |                                 Symbols.add, | ||||||
|  |                                 size: 14, | ||||||
|  |                                 color: Colors.white, | ||||||
|  |                               ).padding(horizontal: 8, vertical: 6), | ||||||
|  |                               onTap: () { | ||||||
|  |                                 onInsert?.call(); | ||||||
|  |                               }, | ||||||
|  |                             ), | ||||||
|  |                         ], | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |                 if (onRequestUpload != null) | ||||||
|  |                   InkWell( | ||||||
|  |                     borderRadius: BorderRadius.circular(8), | ||||||
|  |                     onTap: () => onRequestUpload?.call(), | ||||||
|  |                     child: ClipRRect( | ||||||
|  |                       borderRadius: BorderRadius.circular(8), | ||||||
|  |                       child: Container( | ||||||
|  |                         color: Colors.black.withOpacity(0.5), | ||||||
|  |                         padding: EdgeInsets.symmetric( | ||||||
|  |                           horizontal: 8, | ||||||
|  |                           vertical: 4, | ||||||
|  |                         ), | ||||||
|  |                         child: | ||||||
|  |                             (item.isOnCloud) | ||||||
|  |                                 ? Row( | ||||||
|  |                                   mainAxisSize: MainAxisSize.min, | ||||||
|  |                                   children: [ | ||||||
|  |                                     Icon( | ||||||
|  |                                       Symbols.cloud, | ||||||
|  |                                       size: 16, | ||||||
|  |                                       color: Colors.white, | ||||||
|  |                                     ), | ||||||
|  |                                     const Gap(8), | ||||||
|  |                                     Text( | ||||||
|  |                                       'On-cloud', | ||||||
|  |                                       style: TextStyle(color: Colors.white), | ||||||
|  |                                     ), | ||||||
|  |                                   ], | ||||||
|  |                                 ) | ||||||
|  |                                 : Row( | ||||||
|  |                                   mainAxisSize: MainAxisSize.min, | ||||||
|  |                                   children: [ | ||||||
|  |                                     Icon( | ||||||
|  |                                       Symbols.cloud_off, | ||||||
|  |                                       size: 16, | ||||||
|  |                                       color: Colors.white, | ||||||
|  |                                     ), | ||||||
|  |                                     const Gap(8), | ||||||
|  |                                     Text( | ||||||
|  |                                       'On-device', | ||||||
|  |                                       style: TextStyle(color: Colors.white), | ||||||
|  |                                     ), | ||||||
|  |                                   ], | ||||||
|  |                                 ), | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|  |               ], | ||||||
|  |             ).padding(horizontal: 12, vertical: 8), | ||||||
|  |             AspectRatio( | ||||||
|  |               aspectRatio: ratio, | ||||||
|  |               child: Stack( | ||||||
|  |                 fit: StackFit.expand, | ||||||
|  |                 children: [ | ||||||
|  |                   Builder( | ||||||
|  |                     key: ValueKey(item.hashCode), | ||||||
|  |                     builder: (context) { | ||||||
|  |                       if (item.isOnCloud) { | ||||||
|  |                         return CloudFileWidget(item: item.data); | ||||||
|  |                       } else if (item.data is XFile) { | ||||||
|  |                         final file = item.data as XFile; | ||||||
|  |                         if (file.path.isEmpty) { | ||||||
|  |                           return FutureBuilder<Uint8List>( | ||||||
|  |                             future: file.readAsBytes(), | ||||||
|  |                             builder: (context, snapshot) { | ||||||
|  |                               if (snapshot.hasData) { | ||||||
|  |                                 return Image.memory(snapshot.data!); | ||||||
|  |                               } | ||||||
|  |                               return const Center( | ||||||
|  |                                 child: CircularProgressIndicator(), | ||||||
|  |                               ); | ||||||
|  |                             }, | ||||||
|  |                           ); | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         switch (item.type) { | ||||||
|  |                           case UniversalFileType.image: | ||||||
|  |                             return kIsWeb | ||||||
|  |                                 ? Image.network(file.path) | ||||||
|  |                                 : Image.file(File(file.path)); | ||||||
|  |                           default: | ||||||
|  |                             return Column( | ||||||
|  |                               children: [ | ||||||
|  |                                 const Icon(Symbols.document_scanner), | ||||||
|  |                                 Text(file.name), | ||||||
|  |                               ], | ||||||
|                             ); |                             ); | ||||||
|                           }, |                         } | ||||||
|                         ); |                       } else if (item is List<int> || item is Uint8List) { | ||||||
|  |                         switch (item.type) { | ||||||
|  |                           case UniversalFileType.image: | ||||||
|  |                             return Image.memory(item.data); | ||||||
|  |                           default: | ||||||
|  |                             return Column( | ||||||
|  |                               children: [const Icon(Symbols.document_scanner)], | ||||||
|  |                             ); | ||||||
|  |                         } | ||||||
|                       } |                       } | ||||||
|                       return kIsWeb |                       return Placeholder(); | ||||||
|                           ? Image.network(file.path) |                     }, | ||||||
|                           : Image.file(File(file.path)); |                   ), | ||||||
|                     } else { |                   if (progress != null) | ||||||
|                       return Center( |                     Positioned.fill( | ||||||
|                         child: Text( |                       child: Container( | ||||||
|                           'Preview is not supported for ${item.type}', |                         color: Colors.black.withOpacity(0.3), | ||||||
|                           textAlign: TextAlign.center, |                         padding: EdgeInsets.symmetric( | ||||||
|  |                           horizontal: 40, | ||||||
|  |                           vertical: 16, | ||||||
|                         ), |                         ), | ||||||
|                       ); |                         child: Column( | ||||||
|                     } |                           mainAxisAlignment: MainAxisAlignment.center, | ||||||
|                   } else if (item is List<int> || item is Uint8List) { |                           crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|                     if (item.type == UniversalFileType.image) { |                           children: [ | ||||||
|                       return Image.memory(item.data); |                             if (progress != null) | ||||||
|                     } else { |                               Text( | ||||||
|                       return Center( |                                 '${progress!.toStringAsFixed(2)}%', | ||||||
|                         child: Text( |                                 style: TextStyle(color: Colors.white), | ||||||
|                           'Preview is not supported for ${item.type}', |                               ) | ||||||
|                           textAlign: TextAlign.center, |                             else | ||||||
|                         ), |                               Text( | ||||||
|                       ); |                                 'uploading'.tr(), | ||||||
|                     } |                                 style: TextStyle(color: Colors.white), | ||||||
|                   } |                               ), | ||||||
|                   return Placeholder(); |                             Gap(6), | ||||||
|                 }, |                             Center( | ||||||
|               ), |                               child: LinearProgressIndicator( | ||||||
|             ), |                                 value: | ||||||
|             if (progress != null) |                                     progress != null ? progress! / 100.0 : null, | ||||||
|               Positioned.fill( |                               ), | ||||||
|                 child: Container( |                             ), | ||||||
|                   color: Colors.black.withOpacity(0.3), |                           ], | ||||||
|                   padding: EdgeInsets.symmetric(horizontal: 40, vertical: 16), |  | ||||||
|                   child: Column( |  | ||||||
|                     mainAxisAlignment: MainAxisAlignment.center, |  | ||||||
|                     crossAxisAlignment: CrossAxisAlignment.center, |  | ||||||
|                     children: [ |  | ||||||
|                       if (progress != null) |  | ||||||
|                         Text( |  | ||||||
|                           '${progress!.toStringAsFixed(2)}%', |  | ||||||
|                           style: TextStyle(color: Colors.white), |  | ||||||
|                         ) |  | ||||||
|                       else |  | ||||||
|                         Text( |  | ||||||
|                           'uploading'.tr(), |  | ||||||
|                           style: TextStyle(color: Colors.white), |  | ||||||
|                         ), |  | ||||||
|                       Gap(6), |  | ||||||
|                       Center( |  | ||||||
|                         child: LinearProgressIndicator( |  | ||||||
|                           value: progress != null ? progress! / 100.0 : null, |  | ||||||
|                         ), |                         ), | ||||||
|                       ), |                       ), | ||||||
|                     ], |  | ||||||
|                   ), |  | ||||||
|                 ), |  | ||||||
|               ), |  | ||||||
|             Positioned( |  | ||||||
|               left: 8, |  | ||||||
|               top: 8, |  | ||||||
|               child: ClipRRect( |  | ||||||
|                 borderRadius: BorderRadius.circular(8), |  | ||||||
|                 child: Container( |  | ||||||
|                   color: Colors.black.withOpacity(0.5), |  | ||||||
|                   child: Material( |  | ||||||
|                     color: Colors.transparent, |  | ||||||
|                     child: Row( |  | ||||||
|                       mainAxisSize: MainAxisSize.min, |  | ||||||
|                       children: [ |  | ||||||
|                         if (onDelete != null) |  | ||||||
|                           InkWell( |  | ||||||
|                             borderRadius: BorderRadius.circular(8), |  | ||||||
|                             child: const Icon( |  | ||||||
|                               Symbols.delete, |  | ||||||
|                               size: 14, |  | ||||||
|                               color: Colors.white, |  | ||||||
|                             ).padding(horizontal: 8, vertical: 6), |  | ||||||
|                             onTap: () { |  | ||||||
|                               onDelete?.call(); |  | ||||||
|                             }, |  | ||||||
|                           ), |  | ||||||
|                         if (onDelete != null && onMove != null) |  | ||||||
|                           SizedBox( |  | ||||||
|                             height: 26, |  | ||||||
|                             child: const VerticalDivider( |  | ||||||
|                               width: 0.3, |  | ||||||
|                               color: Colors.white, |  | ||||||
|                               thickness: 0.3, |  | ||||||
|                             ), |  | ||||||
|                           ).padding(horizontal: 2), |  | ||||||
|                         if (onMove != null) |  | ||||||
|                           InkWell( |  | ||||||
|                             borderRadius: BorderRadius.circular(8), |  | ||||||
|                             child: const Icon( |  | ||||||
|                               Symbols.keyboard_arrow_up, |  | ||||||
|                               size: 14, |  | ||||||
|                               color: Colors.white, |  | ||||||
|                             ).padding(horizontal: 8, vertical: 6), |  | ||||||
|                             onTap: () { |  | ||||||
|                               onMove?.call(-1); |  | ||||||
|                             }, |  | ||||||
|                           ), |  | ||||||
|                         if (onMove != null) |  | ||||||
|                           InkWell( |  | ||||||
|                             borderRadius: BorderRadius.circular(8), |  | ||||||
|                             child: const Icon( |  | ||||||
|                               Symbols.keyboard_arrow_down, |  | ||||||
|                               size: 14, |  | ||||||
|                               color: Colors.white, |  | ||||||
|                             ).padding(horizontal: 8, vertical: 6), |  | ||||||
|                             onTap: () { |  | ||||||
|                               onMove?.call(1); |  | ||||||
|                             }, |  | ||||||
|                           ), |  | ||||||
|                         if (onInsert != null) |  | ||||||
|                           InkWell( |  | ||||||
|                             borderRadius: BorderRadius.circular(8), |  | ||||||
|                             child: const Icon( |  | ||||||
|                               Symbols.add, |  | ||||||
|                               size: 14, |  | ||||||
|                               color: Colors.white, |  | ||||||
|                             ).padding(horizontal: 8, vertical: 6), |  | ||||||
|                             onTap: () { |  | ||||||
|                               onInsert?.call(); |  | ||||||
|                             }, |  | ||||||
|                           ), |  | ||||||
|                       ], |  | ||||||
|                     ), |                     ), | ||||||
|                   ), |                 ], | ||||||
|                 ), |  | ||||||
|               ), |               ), | ||||||
|             ), |             ), | ||||||
|             if (onRequestUpload != null) |  | ||||||
|               Positioned( |  | ||||||
|                 top: 8, |  | ||||||
|                 right: 8, |  | ||||||
|                 child: InkWell( |  | ||||||
|                   borderRadius: BorderRadius.circular(8), |  | ||||||
|                   onTap: () => onRequestUpload?.call(), |  | ||||||
|                   child: ClipRRect( |  | ||||||
|                     borderRadius: BorderRadius.circular(8), |  | ||||||
|                     child: Container( |  | ||||||
|                       color: Colors.black.withOpacity(0.5), |  | ||||||
|                       padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), |  | ||||||
|                       child: |  | ||||||
|                           (item.isOnCloud) |  | ||||||
|                               ? Row( |  | ||||||
|                                 mainAxisSize: MainAxisSize.min, |  | ||||||
|                                 children: [ |  | ||||||
|                                   Icon( |  | ||||||
|                                     Symbols.cloud, |  | ||||||
|                                     size: 16, |  | ||||||
|                                     color: Colors.white, |  | ||||||
|                                   ), |  | ||||||
|                                   const Gap(8), |  | ||||||
|                                   Text( |  | ||||||
|                                     'On-cloud', |  | ||||||
|                                     style: TextStyle(color: Colors.white), |  | ||||||
|                                   ), |  | ||||||
|                                 ], |  | ||||||
|                               ) |  | ||||||
|                               : Row( |  | ||||||
|                                 mainAxisSize: MainAxisSize.min, |  | ||||||
|                                 children: [ |  | ||||||
|                                   Icon( |  | ||||||
|                                     Symbols.cloud_off, |  | ||||||
|                                     size: 16, |  | ||||||
|                                     color: Colors.white, |  | ||||||
|                                   ), |  | ||||||
|                                   const Gap(8), |  | ||||||
|                                   Text( |  | ||||||
|                                     'On-device', |  | ||||||
|                                     style: TextStyle(color: Colors.white), |  | ||||||
|                                   ), |  | ||||||
|                                 ], |  | ||||||
|                               ), |  | ||||||
|                     ), |  | ||||||
|                   ), |  | ||||||
|                 ), |  | ||||||
|               ), |  | ||||||
|           ], |           ], | ||||||
|         ), |         ), | ||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|  |     return ContextMenuWidget( | ||||||
|  |       menuProvider: | ||||||
|  |           (MenuRequest request) => Menu( | ||||||
|  |             children: [ | ||||||
|  |               if (item.isOnDevice && item.type == UniversalFileType.image) | ||||||
|  |                 MenuAction( | ||||||
|  |                   title: 'crop'.tr(), | ||||||
|  |                   image: MenuImage.icon(Symbols.crop), | ||||||
|  |                   callback: () async { | ||||||
|  |                     final result = await cropImage( | ||||||
|  |                       context, | ||||||
|  |                       image: item.data, | ||||||
|  |                       replacePath: true, | ||||||
|  |                     ); | ||||||
|  |                     if (result == null) return; | ||||||
|  |                     onUpdate?.call(item.copyWith(data: result)); | ||||||
|  |                   }, | ||||||
|  |                 ), | ||||||
|  |               if (item.isOnCloud) | ||||||
|  |                 MenuAction( | ||||||
|  |                   title: 'rename'.tr(), | ||||||
|  |                   image: MenuImage.icon(Symbols.edit), | ||||||
|  |                   callback: () async { | ||||||
|  |                     await _showRenameDialog(context, ref); | ||||||
|  |                   }, | ||||||
|  |                 ), | ||||||
|  |               if (item.isOnCloud) | ||||||
|  |                 MenuAction( | ||||||
|  |                   title: 'markAsSensitive'.tr(), | ||||||
|  |                   image: MenuImage.icon(Symbols.no_adult_content), | ||||||
|  |                   callback: () async { | ||||||
|  |                     await _showSensitiveDialog(context, ref); | ||||||
|  |                   }, | ||||||
|  |                 ), | ||||||
|  |             ], | ||||||
|  |           ), | ||||||
|  |       child: contentWidget, | ||||||
|  |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										168
									
								
								lib/widgets/content/audio.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										168
									
								
								lib/widgets/content/audio.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,168 @@ | |||||||
|  | import 'dart:developer'; | ||||||
|  |  | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_cache_manager/flutter_cache_manager.dart'; | ||||||
|  | import 'package:gap/gap.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
|  | import 'package:island/pods/network.dart'; | ||||||
|  | import 'package:island/services/time.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
|  | import 'package:media_kit/media_kit.dart'; | ||||||
|  | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  |  | ||||||
|  | class UniversalAudio extends ConsumerStatefulWidget { | ||||||
|  |   final String uri; | ||||||
|  |   final String filename; | ||||||
|  |   final bool autoplay; | ||||||
|  |   const UniversalAudio({ | ||||||
|  |     super.key, | ||||||
|  |     required this.uri, | ||||||
|  |     required this.filename, | ||||||
|  |     this.autoplay = false, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   ConsumerState<UniversalAudio> createState() => _UniversalAudioState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _UniversalAudioState extends ConsumerState<UniversalAudio> { | ||||||
|  |   Player? _player; | ||||||
|  |  | ||||||
|  |   Duration _duration = Duration(seconds: 1); | ||||||
|  |   Duration _duartionBuffered = Duration(seconds: 1); | ||||||
|  |   Duration _position = Duration(seconds: 0); | ||||||
|  |  | ||||||
|  |   bool _sliderWorking = false; | ||||||
|  |   Duration _sliderPosition = Duration(seconds: 0); | ||||||
|  |  | ||||||
|  |   void _openAudio() async { | ||||||
|  |     final url = widget.uri; | ||||||
|  |     MediaKit.ensureInitialized(); | ||||||
|  |  | ||||||
|  |     _player = Player(); | ||||||
|  |     _player!.stream.position.listen((value) { | ||||||
|  |       _position = value; | ||||||
|  |       if (!_sliderWorking) _sliderPosition = _position; | ||||||
|  |       setState(() {}); | ||||||
|  |     }); | ||||||
|  |     _player!.stream.buffer.listen((value) { | ||||||
|  |       _duartionBuffered = value; | ||||||
|  |       setState(() {}); | ||||||
|  |     }); | ||||||
|  |     _player!.stream.duration.listen((value) { | ||||||
|  |       _duration = value; | ||||||
|  |       setState(() {}); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     String? uri; | ||||||
|  |     final inCacheInfo = await DefaultCacheManager().getFileFromCache(url); | ||||||
|  |     if (inCacheInfo == null) { | ||||||
|  |       log('[MediaPlayer] Miss cache: $url'); | ||||||
|  |       final token = ref.watch(tokenProvider)?.token; | ||||||
|  |       DefaultCacheManager().downloadFile( | ||||||
|  |         url, | ||||||
|  |         authHeaders: {'Authorization': 'AtField $token'}, | ||||||
|  |       ); | ||||||
|  |       uri = url; | ||||||
|  |     } else { | ||||||
|  |       uri = inCacheInfo.file.path; | ||||||
|  |       log('[MediaPlayer] Hit cache: $url'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     _player!.open(Media(uri), play: widget.autoplay); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _openAudio(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void dispose() { | ||||||
|  |     super.dispose(); | ||||||
|  |     _player?.dispose(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     if (_player == null) { | ||||||
|  |       return Center(child: CircularProgressIndicator()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return Card( | ||||||
|  |       color: Theme.of(context).colorScheme.surfaceContainerLowest, | ||||||
|  |       child: Row( | ||||||
|  |         children: [ | ||||||
|  |           IconButton.filled( | ||||||
|  |             onPressed: () { | ||||||
|  |               _player!.playOrPause().then((_) { | ||||||
|  |                 if (mounted) setState(() {}); | ||||||
|  |               }); | ||||||
|  |             }, | ||||||
|  |             icon: | ||||||
|  |                 _player!.state.playing | ||||||
|  |                     ? const Icon(Symbols.pause, fill: 1, color: Colors.white) | ||||||
|  |                     : const Icon( | ||||||
|  |                       Symbols.play_arrow, | ||||||
|  |                       fill: 1, | ||||||
|  |                       color: Colors.white, | ||||||
|  |                     ), | ||||||
|  |           ), | ||||||
|  |           const Gap(20), | ||||||
|  |           Expanded( | ||||||
|  |             child: Column( | ||||||
|  |               mainAxisSize: MainAxisSize.min, | ||||||
|  |               crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |               children: [ | ||||||
|  |                 AnimatedSwitcher( | ||||||
|  |                   duration: const Duration(milliseconds: 300), | ||||||
|  |                   child: | ||||||
|  |                       (_player!.state.playing || _sliderWorking) | ||||||
|  |                           ? SizedBox( | ||||||
|  |                             width: double.infinity, | ||||||
|  |                             key: const ValueKey('playing'), | ||||||
|  |                             child: Text( | ||||||
|  |                               '${_position.formatShortDuration()} / ${_duration.formatShortDuration()}', | ||||||
|  |                             ), | ||||||
|  |                           ) | ||||||
|  |                           : SizedBox( | ||||||
|  |                             width: double.infinity, | ||||||
|  |                             key: const ValueKey('filename'), | ||||||
|  |                             child: Text( | ||||||
|  |                               widget.filename.isEmpty | ||||||
|  |                                   ? 'Audio' | ||||||
|  |                                   : widget.filename, | ||||||
|  |                               maxLines: 1, | ||||||
|  |                               overflow: TextOverflow.ellipsis, | ||||||
|  |                             ), | ||||||
|  |                           ), | ||||||
|  |                 ), | ||||||
|  |                 Slider( | ||||||
|  |                   value: _sliderPosition.inMilliseconds.toDouble(), | ||||||
|  |                   secondaryTrackValue: | ||||||
|  |                       _duartionBuffered.inMilliseconds.toDouble(), | ||||||
|  |                   max: _duration.inMilliseconds.toDouble(), | ||||||
|  |                   onChangeStart: (_) { | ||||||
|  |                     _sliderWorking = true; | ||||||
|  |                   }, | ||||||
|  |                   onChanged: (value) { | ||||||
|  |                     _sliderPosition = Duration(milliseconds: value.toInt()); | ||||||
|  |                     setState(() {}); | ||||||
|  |                   }, | ||||||
|  |                   onChangeEnd: (value) { | ||||||
|  |                     _sliderPosition = Duration(milliseconds: value.toInt()); | ||||||
|  |                     _sliderWorking = false; | ||||||
|  |                     _player!.seek(_sliderPosition); | ||||||
|  |                   }, | ||||||
|  |                   year2023: true, | ||||||
|  |                   padding: EdgeInsets.zero, | ||||||
|  |                 ), | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ).padding(horizontal: 24, vertical: 16), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -2,6 +2,7 @@ import 'dart:math' as math; | |||||||
| import 'dart:ui'; | import 'dart:ui'; | ||||||
|  |  | ||||||
| import 'package:dismissible_page/dismissible_page.dart'; | import 'package:dismissible_page/dismissible_page.dart'; | ||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter_blurhash/flutter_blurhash.dart'; | import 'package:flutter_blurhash/flutter_blurhash.dart'; | ||||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
| @@ -13,7 +14,9 @@ import 'package:island/pods/config.dart'; | |||||||
| import 'package:island/pods/network.dart'; | import 'package:island/pods/network.dart'; | ||||||
| import 'package:island/widgets/alert.dart'; | import 'package:island/widgets/alert.dart'; | ||||||
| import 'package:island/widgets/content/cloud_files.dart'; | import 'package:island/widgets/content/cloud_files.dart'; | ||||||
|  | import 'package:island/widgets/content/sensitive.dart'; | ||||||
| import 'package:island/widgets/content/sheet.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/path.dart' show extension; | ||||||
| import 'package:path_provider/path_provider.dart'; | import 'package:path_provider/path_provider.dart'; | ||||||
| import 'package:photo_view/photo_view.dart'; | import 'package:photo_view/photo_view.dart'; | ||||||
| @@ -27,14 +30,16 @@ class CloudFileList extends HookConsumerWidget { | |||||||
|   final double? minWidth; |   final double? minWidth; | ||||||
|   final bool disableZoomIn; |   final bool disableZoomIn; | ||||||
|   final bool disableConstraint; |   final bool disableConstraint; | ||||||
|  |   final EdgeInsets? padding; | ||||||
|   const CloudFileList({ |   const CloudFileList({ | ||||||
|     super.key, |     super.key, | ||||||
|     required this.files, |     required this.files, | ||||||
|     this.maxHeight = 360, |     this.maxHeight = 560, | ||||||
|     this.maxWidth = double.infinity, |     this.maxWidth = double.infinity, | ||||||
|     this.minWidth, |     this.minWidth, | ||||||
|     this.disableZoomIn = false, |     this.disableZoomIn = false, | ||||||
|     this.disableConstraint = false, |     this.disableConstraint = false, | ||||||
|  |     this.padding, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   double calculateAspectRatio() { |   double calculateAspectRatio() { | ||||||
| @@ -60,42 +65,43 @@ class CloudFileList extends HookConsumerWidget { | |||||||
|     if (files.isEmpty) return const SizedBox.shrink(); |     if (files.isEmpty) return const SizedBox.shrink(); | ||||||
|     if (files.length == 1) { |     if (files.length == 1) { | ||||||
|       final isImage = files.first.mimeType?.startsWith('image') ?? false; |       final isImage = files.first.mimeType?.startsWith('image') ?? false; | ||||||
|       return ConstrainedBox( |       final isAudio = files.first.mimeType?.startsWith('audio') ?? false; | ||||||
|  |       final widgetItem = ClipRRect( | ||||||
|  |         borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||||
|  |         child: _CloudFileListEntry( | ||||||
|  |           file: files.first, | ||||||
|  |           heroTag: heroTags.first, | ||||||
|  |           isImage: isImage, | ||||||
|  |           disableZoomIn: disableZoomIn, | ||||||
|  |           onTap: () { | ||||||
|  |             if (!isImage) { | ||||||
|  |               return; | ||||||
|  |             } | ||||||
|  |             if (!disableZoomIn) { | ||||||
|  |               context.pushTransparentRoute( | ||||||
|  |                 CloudFileZoomIn(item: files.first, heroTag: heroTags.first), | ||||||
|  |                 rootNavigator: true, | ||||||
|  |               ); | ||||||
|  |             } | ||||||
|  |           }, | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |       return Container( | ||||||
|  |         padding: padding, | ||||||
|         constraints: BoxConstraints( |         constraints: BoxConstraints( | ||||||
|           maxHeight: disableConstraint ? double.infinity : maxHeight, |           maxHeight: disableConstraint ? double.infinity : maxHeight, | ||||||
|           minWidth: minWidth ?? 0, |           minWidth: minWidth ?? 0, | ||||||
|           maxWidth: |           maxWidth: files.length == 1 ? maxWidth : double.infinity, | ||||||
|               files.length == 1 |  | ||||||
|                   ? math.max( |  | ||||||
|                     math.min(520, MediaQuery.of(context).size.width * 0.85), |  | ||||||
|                     minWidth ?? 0, |  | ||||||
|                   ) |  | ||||||
|                   : double.infinity, |  | ||||||
|         ), |         ), | ||||||
|         child: AspectRatio( |         height: isAudio ? 120 : null, | ||||||
|           aspectRatio: calculateAspectRatio(), |         child: | ||||||
|           child: ClipRRect( |             isAudio | ||||||
|             borderRadius: const BorderRadius.all(Radius.circular(16)), |                 ? widgetItem | ||||||
|             child: _CloudFileListEntry( |                 : AspectRatio( | ||||||
|               file: files.first, |                   aspectRatio: calculateAspectRatio(), | ||||||
|               heroTag: heroTags.first, |                   child: widgetItem, | ||||||
|               isImage: isImage, |                 ), | ||||||
|               disableZoomIn: disableZoomIn, |       ); | ||||||
|               onTap: () { |  | ||||||
|                 if (!isImage) { |  | ||||||
|                   return; |  | ||||||
|                 } |  | ||||||
|                 if (!disableZoomIn) { |  | ||||||
|                   context.pushTransparentRoute( |  | ||||||
|                     CloudFileZoomIn(item: files.first, heroTag: heroTags.first), |  | ||||||
|                     rootNavigator: true, |  | ||||||
|                   ); |  | ||||||
|                 } |  | ||||||
|               }, |  | ||||||
|             ), |  | ||||||
|           ), |  | ||||||
|         ), |  | ||||||
|       ).padding(horizontal: 3); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     final allImages = |     final allImages = | ||||||
| @@ -108,36 +114,57 @@ class CloudFileList extends HookConsumerWidget { | |||||||
|         constraints: BoxConstraints(maxHeight: maxHeight, minWidth: maxWidth), |         constraints: BoxConstraints(maxHeight: maxHeight, minWidth: maxWidth), | ||||||
|         child: AspectRatio( |         child: AspectRatio( | ||||||
|           aspectRatio: calculateAspectRatio(), |           aspectRatio: calculateAspectRatio(), | ||||||
|           child: CarouselView( |           child: Padding( | ||||||
|             itemExtent: math.min( |             padding: padding ?? EdgeInsets.zero, | ||||||
|               MediaQuery.of(context).size.width * 0.85, |             child: CarouselView( | ||||||
|               maxWidth * 0.85, |               itemSnapping: true, | ||||||
|             ), |               itemExtent: math.min( | ||||||
|             itemSnapping: true, |                 math.min( | ||||||
|             shape: RoundedRectangleBorder( |                   MediaQuery.of(context).size.width * 0.75, | ||||||
|               borderRadius: const BorderRadius.all(Radius.circular(16)), |                   maxWidth * 0.75, | ||||||
|             ), |  | ||||||
|             children: [ |  | ||||||
|               for (var i = 0; i < files.length; i++) |  | ||||||
|                 _CloudFileListEntry( |  | ||||||
|                   file: files[i], |  | ||||||
|                   heroTag: heroTags[i], |  | ||||||
|                   isImage: files[i].mimeType?.startsWith('image') ?? false, |  | ||||||
|                   disableZoomIn: disableZoomIn, |  | ||||||
|                   fit: BoxFit.cover, |  | ||||||
|                 ), |                 ), | ||||||
|             ], |                 640, | ||||||
|             onTap: (i) { |               ), | ||||||
|               if (!(files[i].mimeType?.startsWith('image') ?? false)) { |               shape: RoundedRectangleBorder( | ||||||
|                 return; |                 borderRadius: const BorderRadius.all(Radius.circular(16)), | ||||||
|               } |               ), | ||||||
|               if (!disableZoomIn) { |               children: [ | ||||||
|                 context.pushTransparentRoute( |                 for (var i = 0; i < files.length; i++) | ||||||
|                   CloudFileZoomIn(item: files[i], heroTag: heroTags[i]), |                   Stack( | ||||||
|                   rootNavigator: true, |                     children: [ | ||||||
|                 ); |                       _CloudFileListEntry( | ||||||
|               } |                         file: files[i], | ||||||
|             }, |                         heroTag: heroTags[i], | ||||||
|  |                         isImage: | ||||||
|  |                             files[i].mimeType?.startsWith('image') ?? false, | ||||||
|  |                         disableZoomIn: disableZoomIn, | ||||||
|  |                       ), | ||||||
|  |                       Positioned( | ||||||
|  |                         bottom: 12, | ||||||
|  |                         left: 16, | ||||||
|  |                         child: Text('${i + 1}/${files.length}') | ||||||
|  |                             .textColor(Colors.white) | ||||||
|  |                             .textShadow( | ||||||
|  |                               color: Colors.black54, | ||||||
|  |                               offset: Offset(1, 1), | ||||||
|  |                               blurRadius: 3, | ||||||
|  |                             ), | ||||||
|  |                       ), | ||||||
|  |                     ], | ||||||
|  |                   ), | ||||||
|  |               ], | ||||||
|  |               onTap: (i) { | ||||||
|  |                 if (!(files[i].mimeType?.startsWith('image') ?? false)) { | ||||||
|  |                   return; | ||||||
|  |                 } | ||||||
|  |                 if (!disableZoomIn) { | ||||||
|  |                   context.pushTransparentRoute( | ||||||
|  |                     CloudFileZoomIn(item: files[i], heroTag: heroTags[i]), | ||||||
|  |                     rootNavigator: true, | ||||||
|  |                   ); | ||||||
|  |                 } | ||||||
|  |               }, | ||||||
|  |             ), | ||||||
|           ), |           ), | ||||||
|         ), |         ), | ||||||
|       ); |       ); | ||||||
| @@ -150,28 +177,52 @@ class CloudFileList extends HookConsumerWidget { | |||||||
|         child: ListView.separated( |         child: ListView.separated( | ||||||
|           scrollDirection: Axis.horizontal, |           scrollDirection: Axis.horizontal, | ||||||
|           itemCount: files.length, |           itemCount: files.length, | ||||||
|           padding: EdgeInsets.symmetric(horizontal: 3), |           padding: padding, | ||||||
|           itemBuilder: (context, index) { |           itemBuilder: (context, index) { | ||||||
|             return ClipRRect( |             return AspectRatio( | ||||||
|               borderRadius: const BorderRadius.all(Radius.circular(16)), |               aspectRatio: | ||||||
|               child: _CloudFileListEntry( |                   files[index].fileMeta?['ratio'] is num | ||||||
|                 file: files[index], |                       ? files[index].fileMeta!['ratio'].toDouble() | ||||||
|                 heroTag: heroTags[index], |                       : 1.0, | ||||||
|                 isImage: files[index].mimeType?.startsWith('image') ?? false, |               child: Stack( | ||||||
|                 disableZoomIn: disableZoomIn, |                 children: [ | ||||||
|                 onTap: () { |                   ClipRRect( | ||||||
|                   if (!(files[index].mimeType?.startsWith('image') ?? false)) { |                     borderRadius: const BorderRadius.all(Radius.circular(16)), | ||||||
|                     return; |                     child: _CloudFileListEntry( | ||||||
|                   } |                       file: files[index], | ||||||
|                   if (!disableZoomIn) { |                       heroTag: heroTags[index], | ||||||
|                     context.pushTransparentRoute( |                       isImage: | ||||||
|                       CloudFileZoomIn( |                           files[index].mimeType?.startsWith('image') ?? false, | ||||||
|                         item: files[index], |                       disableZoomIn: disableZoomIn, | ||||||
|                         heroTag: heroTags[index], |                       onTap: () { | ||||||
|                       ), |                         if (!(files[index].mimeType?.startsWith('image') ?? | ||||||
|                     ); |                             false)) { | ||||||
|                   } |                           return; | ||||||
|                 }, |                         } | ||||||
|  |                         if (!disableZoomIn) { | ||||||
|  |                           context.pushTransparentRoute( | ||||||
|  |                             CloudFileZoomIn( | ||||||
|  |                               item: files[index], | ||||||
|  |                               heroTag: heroTags[index], | ||||||
|  |                             ), | ||||||
|  |                             rootNavigator: true, | ||||||
|  |                           ); | ||||||
|  |                         } | ||||||
|  |                       }, | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|  |                   Positioned( | ||||||
|  |                     bottom: 12, | ||||||
|  |                     left: 16, | ||||||
|  |                     child: Text('${index + 1}/${files.length}') | ||||||
|  |                         .textColor(Colors.white) | ||||||
|  |                         .textShadow( | ||||||
|  |                           color: Colors.black54, | ||||||
|  |                           offset: Offset(1, 1), | ||||||
|  |                           blurRadius: 3, | ||||||
|  |                         ), | ||||||
|  |                   ), | ||||||
|  |                 ], | ||||||
|               ), |               ), | ||||||
|             ); |             ); | ||||||
|           }, |           }, | ||||||
| @@ -193,6 +244,8 @@ class CloudFileZoomIn extends HookConsumerWidget { | |||||||
|     final photoViewController = useMemoized(() => PhotoViewController(), []); |     final photoViewController = useMemoized(() => PhotoViewController(), []); | ||||||
|     final rotation = useState(0); |     final rotation = useState(0); | ||||||
|  |  | ||||||
|  |     final showOriginal = useState(false); | ||||||
|  |  | ||||||
|     Future<void> saveToGallery() async { |     Future<void> saveToGallery() async { | ||||||
|       try { |       try { | ||||||
|         // Show loading indicator |         // Show loading indicator | ||||||
| @@ -206,7 +259,7 @@ class CloudFileZoomIn extends HookConsumerWidget { | |||||||
|         final filePath = '${tempDir.path}/${item.id}.${extension(item.name)}'; |         final filePath = '${tempDir.path}/${item.id}.${extension(item.name)}'; | ||||||
|  |  | ||||||
|         await client.download( |         await client.download( | ||||||
|           '/files/${item.id}', |           '/drive/files/${item.id}', | ||||||
|           filePath, |           filePath, | ||||||
|           queryParameters: {'original': true}, |           queryParameters: {'original': true}, | ||||||
|         ); |         ); | ||||||
| @@ -356,7 +409,7 @@ class CloudFileZoomIn extends HookConsumerWidget { | |||||||
|               imageProvider: CloudImageWidget.provider( |               imageProvider: CloudImageWidget.provider( | ||||||
|                 fileId: item.id, |                 fileId: item.id, | ||||||
|                 serverUrl: serverUrl, |                 serverUrl: serverUrl, | ||||||
|                 original: true, |                 original: showOriginal.value, | ||||||
|               ), |               ), | ||||||
|               // Apply rotation transformation |               // Apply rotation transformation | ||||||
|               customSize: MediaQuery.of(context).size, |               customSize: MediaQuery.of(context).size, | ||||||
| @@ -390,6 +443,22 @@ class CloudFileZoomIn extends HookConsumerWidget { | |||||||
|                         saveToGallery(); |                         saveToGallery(); | ||||||
|                       }, |                       }, | ||||||
|                     ), |                     ), | ||||||
|  |                     IconButton( | ||||||
|  |                       onPressed: () { | ||||||
|  |                         showOriginal.value = !showOriginal.value; | ||||||
|  |                       }, | ||||||
|  |                       icon: Icon( | ||||||
|  |                         showOriginal.value ? Symbols.hd : Symbols.sd, | ||||||
|  |                         color: Colors.white, | ||||||
|  |                         shadows: [ | ||||||
|  |                           Shadow( | ||||||
|  |                             color: Colors.black54, | ||||||
|  |                             blurRadius: 5.0, | ||||||
|  |                             offset: Offset(1.0, 1.0), | ||||||
|  |                           ), | ||||||
|  |                         ], | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|                   ], |                   ], | ||||||
|                 ), |                 ), | ||||||
|                 IconButton( |                 IconButton( | ||||||
| @@ -491,13 +560,12 @@ class CloudFileZoomIn extends HookConsumerWidget { | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| class _CloudFileListEntry extends StatelessWidget { | class _CloudFileListEntry extends HookConsumerWidget { | ||||||
|   final SnCloudFile file; |   final SnCloudFile file; | ||||||
|   final String heroTag; |   final String heroTag; | ||||||
|   final bool isImage; |   final bool isImage; | ||||||
|   final bool disableZoomIn; |   final bool disableZoomIn; | ||||||
|   final VoidCallback? onTap; |   final VoidCallback? onTap; | ||||||
|   final BoxFit fit; |  | ||||||
|  |  | ||||||
|   const _CloudFileListEntry({ |   const _CloudFileListEntry({ | ||||||
|     required this.file, |     required this.file, | ||||||
| @@ -505,12 +573,13 @@ class _CloudFileListEntry extends StatelessWidget { | |||||||
|     required this.isImage, |     required this.isImage, | ||||||
|     required this.disableZoomIn, |     required this.disableZoomIn, | ||||||
|     this.onTap, |     this.onTap, | ||||||
|     this.fit = BoxFit.contain, |  | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|     final content = Stack( |     final showMature = useState(false); | ||||||
|  |  | ||||||
|  |     var content = Stack( | ||||||
|       fit: StackFit.expand, |       fit: StackFit.expand, | ||||||
|       children: [ |       children: [ | ||||||
|         if (isImage) |         if (isImage) | ||||||
| @@ -528,17 +597,140 @@ class _CloudFileListEntry extends StatelessWidget { | |||||||
|             item: file, |             item: file, | ||||||
|             heroTag: heroTag, |             heroTag: heroTag, | ||||||
|             noBlurhash: true, |             noBlurhash: true, | ||||||
|             fit: fit, |             fit: BoxFit.contain, | ||||||
|           ) |           ) | ||||||
|         else |         else | ||||||
|           CloudFileWidget(item: file, heroTag: heroTag, fit: fit), |           CloudFileWidget(item: file, heroTag: heroTag, fit: BoxFit.contain), | ||||||
|       ], |       ], | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|  |     if (file.sensitiveMarks.isNotEmpty) { | ||||||
|  |       // Show a blurred overlay only when not revealed yet, with a smooth transition | ||||||
|  |       content = Stack( | ||||||
|  |         children: [ | ||||||
|  |           content, | ||||||
|  |           // Toggle blur overlay with animation | ||||||
|  |           Positioned.fill( | ||||||
|  |             child: AnimatedSwitcher( | ||||||
|  |               duration: const Duration(milliseconds: 250), | ||||||
|  |               switchInCurve: Curves.easeOut, | ||||||
|  |               switchOutCurve: Curves.easeIn, | ||||||
|  |               layoutBuilder: | ||||||
|  |                   (currentChild, previousChildren) => Stack( | ||||||
|  |                     fit: StackFit.expand, | ||||||
|  |                     children: [ | ||||||
|  |                       ...previousChildren, | ||||||
|  |                       if (currentChild != null) currentChild, | ||||||
|  |                     ], | ||||||
|  |                   ), | ||||||
|  |               child: | ||||||
|  |                   showMature.value | ||||||
|  |                       ? const SizedBox.shrink(key: ValueKey('revealed')) | ||||||
|  |                       : ColoredBox( | ||||||
|  |                         key: const ValueKey('blurred'), | ||||||
|  |                         color: Colors.transparent, | ||||||
|  |                         child: BackdropFilter( | ||||||
|  |                           filter: ImageFilter.blur(sigmaX: 64, sigmaY: 64), | ||||||
|  |                           child: Stack( | ||||||
|  |                             fit: StackFit.expand, | ||||||
|  |                             children: [ | ||||||
|  |                               const ColoredBox(color: Colors.transparent), | ||||||
|  |                               Center( | ||||||
|  |                                 child: Container( | ||||||
|  |                                   margin: const EdgeInsets.all(12), | ||||||
|  |                                   padding: const EdgeInsets.symmetric( | ||||||
|  |                                     horizontal: 12, | ||||||
|  |                                     vertical: 8, | ||||||
|  |                                   ), | ||||||
|  |                                   decoration: BoxDecoration( | ||||||
|  |                                     color: Colors.black54, | ||||||
|  |                                     borderRadius: BorderRadius.circular(12), | ||||||
|  |                                   ), | ||||||
|  |                                   child: ConstrainedBox( | ||||||
|  |                                     constraints: const BoxConstraints( | ||||||
|  |                                       maxWidth: 280, | ||||||
|  |                                     ), | ||||||
|  |                                     child: Column( | ||||||
|  |                                       mainAxisSize: MainAxisSize.min, | ||||||
|  |                                       children: [ | ||||||
|  |                                         const Icon( | ||||||
|  |                                           Icons.warning, | ||||||
|  |                                           color: Colors.white, | ||||||
|  |                                           fill: 1, | ||||||
|  |                                           size: 24, | ||||||
|  |                                         ), | ||||||
|  |                                         const Gap(4), | ||||||
|  |                                         Text( | ||||||
|  |                                           file.sensitiveMarks | ||||||
|  |                                               .map( | ||||||
|  |                                                 (e) => | ||||||
|  |                                                     SensitiveCategory | ||||||
|  |                                                         .values[e] | ||||||
|  |                                                         .i18nKey | ||||||
|  |                                                         .tr(), | ||||||
|  |                                               ) | ||||||
|  |                                               .join(' · '), | ||||||
|  |                                           style: const TextStyle( | ||||||
|  |                                             color: Colors.white, | ||||||
|  |                                             fontWeight: FontWeight.w600, | ||||||
|  |                                           ), | ||||||
|  |                                           textAlign: TextAlign.center, | ||||||
|  |                                         ), | ||||||
|  |                                         Text( | ||||||
|  |                                           'Sensitive Content', | ||||||
|  |                                           style: TextStyle( | ||||||
|  |                                             color: Colors.white, | ||||||
|  |                                             fontSize: 13, | ||||||
|  |                                           ), | ||||||
|  |                                         ), | ||||||
|  |                                         const Gap(4), | ||||||
|  |                                         Text( | ||||||
|  |                                           'Tap to Reveal', | ||||||
|  |                                           style: TextStyle( | ||||||
|  |                                             color: Colors.white, | ||||||
|  |                                             fontSize: 11, | ||||||
|  |                                           ), | ||||||
|  |                                         ), | ||||||
|  |                                       ], | ||||||
|  |                                     ), | ||||||
|  |                                   ).padding(horizontal: 24, vertical: 16), | ||||||
|  |                                 ), | ||||||
|  |                               ), | ||||||
|  |                             ], | ||||||
|  |                           ), | ||||||
|  |                         ), | ||||||
|  |                       ), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |           // When revealed (no blur), show a small control at top-left to re-enable blur | ||||||
|  |           if (showMature.value) | ||||||
|  |             Positioned( | ||||||
|  |               top: 3, | ||||||
|  |               left: 4, | ||||||
|  |               child: IconButton( | ||||||
|  |                 iconSize: 16, | ||||||
|  |                 constraints: const BoxConstraints(), | ||||||
|  |                 icon: const Icon(Icons.visibility_off, color: Colors.white), | ||||||
|  |                 tooltip: 'Blur content', | ||||||
|  |                 onPressed: () { | ||||||
|  |                   showMature.value = false; | ||||||
|  |                 }, | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |         ], | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     if (onTap != null) { |     if (onTap != null) { | ||||||
|       return InkWell( |       return InkWell( | ||||||
|         borderRadius: const BorderRadius.all(Radius.circular(16)), |         borderRadius: const BorderRadius.all(Radius.circular(16)), | ||||||
|         onTap: onTap, |         onTap: () { | ||||||
|  |           if (!showMature.value) { | ||||||
|  |             showMature.value = true; | ||||||
|  |           } else { | ||||||
|  |             onTap?.call(); | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|         child: content, |         child: content, | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,15 +1,21 @@ | |||||||
|  | import 'dart:math' as math; | ||||||
|  | import 'dart:ui'; | ||||||
|  |  | ||||||
| import 'package:cached_network_image/cached_network_image.dart'; | import 'package:cached_network_image/cached_network_image.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter_riverpod/flutter_riverpod.dart'; | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
| import 'package:island/models/file.dart'; | import 'package:island/models/file.dart'; | ||||||
| import 'package:island/pods/config.dart'; | import 'package:island/pods/config.dart'; | ||||||
|  | import 'package:island/services/time.dart'; | ||||||
|  | import 'package:island/widgets/content/audio.dart'; | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  |  | ||||||
| import 'image.dart'; | import 'image.dart'; | ||||||
| import 'video.dart'; | import 'video.dart'; | ||||||
|  |  | ||||||
| class CloudFileWidget extends ConsumerWidget { | class CloudFileWidget extends HookConsumerWidget { | ||||||
|   final SnCloudFile item; |   final SnCloudFile item; | ||||||
|   final BoxFit fit; |   final BoxFit fit; | ||||||
|   final String? heroTag; |   final String? heroTag; | ||||||
| @@ -32,7 +38,7 @@ class CloudFileWidget extends ConsumerWidget { | |||||||
|             ? item.fileMeta!['ratio'].toDouble() |             ? item.fileMeta!['ratio'].toDouble() | ||||||
|             : 1.0; |             : 1.0; | ||||||
|     if (ratio == 0) ratio = 1.0; |     if (ratio == 0) ratio = 1.0; | ||||||
|     final content = switch (item.mimeType?.split('/').firstOrNull) { |     var content = switch (item.mimeType?.split('/').firstOrNull) { | ||||||
|       "image" => AspectRatio( |       "image" => AspectRatio( | ||||||
|         aspectRatio: ratio, |         aspectRatio: ratio, | ||||||
|         child: UniversalImage( |         child: UniversalImage( | ||||||
| @@ -45,19 +51,140 @@ class CloudFileWidget extends ConsumerWidget { | |||||||
|       ), |       ), | ||||||
|       "video" => AspectRatio( |       "video" => AspectRatio( | ||||||
|         aspectRatio: ratio, |         aspectRatio: ratio, | ||||||
|         child: UniversalVideo(uri: uri, aspectRatio: ratio), |         child: CloudVideoWidget(item: item), | ||||||
|  |       ), | ||||||
|  |       "audio" => Center( | ||||||
|  |         child: ConstrainedBox( | ||||||
|  |           constraints: BoxConstraints( | ||||||
|  |             maxWidth: math.min(360, MediaQuery.of(context).size.width * 0.8), | ||||||
|  |           ), | ||||||
|  |           child: UniversalAudio(uri: uri, filename: item.name), | ||||||
|  |         ), | ||||||
|       ), |       ), | ||||||
|       _ => Text('Unable render for ${item.mimeType}'), |       _ => Text('Unable render for ${item.mimeType}'), | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     if (heroTag != null) { |     if (heroTag != null) { | ||||||
|       return Hero(tag: heroTag!, child: content); |       content = Hero(tag: heroTag!, child: content); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return content; |     return content; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | class CloudVideoWidget extends HookConsumerWidget { | ||||||
|  |   final SnCloudFile item; | ||||||
|  |   const CloudVideoWidget({super.key, required this.item}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     final open = useState(false); | ||||||
|  |  | ||||||
|  |     final serverUrl = ref.watch(serverUrlProvider); | ||||||
|  |     final uri = '$serverUrl/drive/files/${item.id}'; | ||||||
|  |  | ||||||
|  |     var ratio = | ||||||
|  |         item.fileMeta?['ratio'] is num | ||||||
|  |             ? item.fileMeta!['ratio'].toDouble() | ||||||
|  |             : 1.0; | ||||||
|  |     if (ratio == 0) ratio = 1.0; | ||||||
|  |  | ||||||
|  |     if (open.value) { | ||||||
|  |       return UniversalVideo(uri: uri, aspectRatio: ratio, autoplay: true); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return GestureDetector( | ||||||
|  |       child: Stack( | ||||||
|  |         children: [ | ||||||
|  |           UniversalImage(uri: '$uri?thumbnail=true'), | ||||||
|  |           Positioned.fill( | ||||||
|  |             child: Center( | ||||||
|  |               child: const Icon( | ||||||
|  |                 Symbols.play_arrow, | ||||||
|  |                 fill: 1, | ||||||
|  |                 size: 32, | ||||||
|  |                 shadows: [ | ||||||
|  |                   BoxShadow( | ||||||
|  |                     color: Colors.black54, | ||||||
|  |                     offset: Offset(1, 1), | ||||||
|  |                     spreadRadius: 8, | ||||||
|  |                     blurRadius: 8, | ||||||
|  |                   ), | ||||||
|  |                 ], | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |           Positioned( | ||||||
|  |             bottom: 0, | ||||||
|  |             left: 0, | ||||||
|  |             right: 0, | ||||||
|  |             child: Column( | ||||||
|  |               mainAxisAlignment: MainAxisAlignment.end, | ||||||
|  |               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |               children: [ | ||||||
|  |                 Row( | ||||||
|  |                   spacing: 8, | ||||||
|  |                   children: [ | ||||||
|  |                     if (item.fileMeta?['duration'] != null) | ||||||
|  |                       Text( | ||||||
|  |                         Duration( | ||||||
|  |                           milliseconds: | ||||||
|  |                               ((item.fileMeta?['duration'] as num) * 1000) | ||||||
|  |                                   .toInt(), | ||||||
|  |                         ).formatDuration(), | ||||||
|  |                         style: TextStyle( | ||||||
|  |                           shadows: [ | ||||||
|  |                             BoxShadow( | ||||||
|  |                               color: Colors.black54, | ||||||
|  |                               offset: Offset(1, 1), | ||||||
|  |                               spreadRadius: 8, | ||||||
|  |                               blurRadius: 8, | ||||||
|  |                             ), | ||||||
|  |                           ], | ||||||
|  |                         ), | ||||||
|  |                       ), | ||||||
|  |                     if (item.fileMeta?['bit_rate'] != null) | ||||||
|  |                       Text( | ||||||
|  |                         '${int.parse(item.fileMeta?['bit_rate'] as String) ~/ 1000} Kbps', | ||||||
|  |                         style: TextStyle( | ||||||
|  |                           shadows: [ | ||||||
|  |                             BoxShadow( | ||||||
|  |                               color: Colors.black54, | ||||||
|  |                               offset: Offset(1, 1), | ||||||
|  |                               spreadRadius: 8, | ||||||
|  |                               blurRadius: 8, | ||||||
|  |                             ), | ||||||
|  |                           ], | ||||||
|  |                         ), | ||||||
|  |                       ), | ||||||
|  |                   ], | ||||||
|  |                 ), | ||||||
|  |                 Text( | ||||||
|  |                   item.name, | ||||||
|  |                   style: TextStyle( | ||||||
|  |                     fontWeight: FontWeight.bold, | ||||||
|  |                     shadows: [ | ||||||
|  |                       BoxShadow( | ||||||
|  |                         color: Colors.black54, | ||||||
|  |                         offset: Offset(1, 1), | ||||||
|  |                         spreadRadius: 8, | ||||||
|  |                         blurRadius: 8, | ||||||
|  |                       ), | ||||||
|  |                     ], | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |           ).padding(horizontal: 16, bottom: 12), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |       onTap: () { | ||||||
|  |         open.value = true; | ||||||
|  |       }, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
| class CloudImageWidget extends ConsumerWidget { | class CloudImageWidget extends ConsumerWidget { | ||||||
|   final String? fileId; |   final String? fileId; | ||||||
|   final SnCloudFile? file; |   final SnCloudFile? file; | ||||||
| @@ -92,7 +219,10 @@ class CloudImageWidget extends ConsumerWidget { | |||||||
|     required String serverUrl, |     required String serverUrl, | ||||||
|     bool original = false, |     bool original = false, | ||||||
|   }) { |   }) { | ||||||
|     final uri = '$serverUrl/drive/files/$fileId?original=$original'; |     final uri = | ||||||
|  |         original | ||||||
|  |             ? '$serverUrl/drive/files/$fileId?original=true' | ||||||
|  |             : '$serverUrl/drive/files/$fileId'; | ||||||
|     return CachedNetworkImageProvider(uri); |     return CachedNetworkImageProvider(uri); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -81,7 +81,10 @@ class MarkdownTextContent extends HookConsumerWidget { | |||||||
|               if (url != null) { |               if (url != null) { | ||||||
|                 if (url.scheme == 'solian') { |                 if (url.scheme == 'solian') { | ||||||
|                   if (url.host == 'account') { |                   if (url.host == 'account') { | ||||||
|                     context.pushNamed('accountProfile', pathParameters: {'name': url.pathSegments[0]}); |                     context.pushNamed( | ||||||
|  |                       'accountProfile', | ||||||
|  |                       pathParameters: {'name': url.pathSegments[0]}, | ||||||
|  |                     ); | ||||||
|                   } |                   } | ||||||
|                   return; |                   return; | ||||||
|                 } |                 } | ||||||
| @@ -153,7 +156,7 @@ class MarkdownTextContent extends HookConsumerWidget { | |||||||
|                         ), |                         ), | ||||||
|                         child: UniversalImage( |                         child: UniversalImage( | ||||||
|                           uri: |                           uri: | ||||||
|                               '$baseUrl/stickers/lookup/${uri.pathSegments[0]}/open', |                               '$baseUrl/sphere/stickers/lookup/${uri.pathSegments[0]}/open', | ||||||
|                           width: size, |                           width: size, | ||||||
|                           height: size, |                           height: size, | ||||||
|                           fit: BoxFit.cover, |                           fit: BoxFit.cover, | ||||||
|   | |||||||
							
								
								
									
										71
									
								
								lib/widgets/content/sensitive.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								lib/widgets/content/sensitive.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | |||||||
|  | // Copyright (c) Solsynth | ||||||
|  | // Sensitive content categories for content warnings, in fixed order. | ||||||
|  |  | ||||||
|  | enum SensitiveCategory { | ||||||
|  |   language, | ||||||
|  |   sexualContent, | ||||||
|  |   violence, | ||||||
|  |   profanity, | ||||||
|  |   hateSpeech, | ||||||
|  |   racism, | ||||||
|  |   adultContent, | ||||||
|  |   drugAbuse, | ||||||
|  |   alcoholAbuse, | ||||||
|  |   gambling, | ||||||
|  |   selfHarm, | ||||||
|  |   childAbuse, | ||||||
|  |   other, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | extension SensitiveCategoryI18n on SensitiveCategory { | ||||||
|  |   /// i18n key to look up localized label | ||||||
|  |   String get i18nKey => switch (this) { | ||||||
|  |     SensitiveCategory.language => 'sensitiveCategories.language', | ||||||
|  |     SensitiveCategory.sexualContent => 'sensitiveCategories.sexualContent', | ||||||
|  |     SensitiveCategory.violence => 'sensitiveCategories.violence', | ||||||
|  |     SensitiveCategory.profanity => 'sensitiveCategories.profanity', | ||||||
|  |     SensitiveCategory.hateSpeech => 'sensitiveCategories.hateSpeech', | ||||||
|  |     SensitiveCategory.racism => 'sensitiveCategories.racism', | ||||||
|  |     SensitiveCategory.adultContent => 'sensitiveCategories.adultContent', | ||||||
|  |     SensitiveCategory.drugAbuse => 'sensitiveCategories.drugAbuse', | ||||||
|  |     SensitiveCategory.alcoholAbuse => 'sensitiveCategories.alcoholAbuse', | ||||||
|  |     SensitiveCategory.gambling => 'sensitiveCategories.gambling', | ||||||
|  |     SensitiveCategory.selfHarm => 'sensitiveCategories.selfHarm', | ||||||
|  |     SensitiveCategory.childAbuse => 'sensitiveCategories.childAbuse', | ||||||
|  |     SensitiveCategory.other => 'sensitiveCategories.other', | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   /// Optional symbol you can use alongside the label in UI | ||||||
|  |   String get symbol => switch (this) { | ||||||
|  |     SensitiveCategory.language => '🌐', | ||||||
|  |     SensitiveCategory.sexualContent => '🔞', | ||||||
|  |     SensitiveCategory.violence => '⚠️', | ||||||
|  |     SensitiveCategory.profanity => '🗯️', | ||||||
|  |     SensitiveCategory.hateSpeech => '🚫', | ||||||
|  |     SensitiveCategory.racism => '✋', | ||||||
|  |     SensitiveCategory.adultContent => '🍑', | ||||||
|  |     SensitiveCategory.drugAbuse => '💊', | ||||||
|  |     SensitiveCategory.alcoholAbuse => '🍺', | ||||||
|  |     SensitiveCategory.gambling => '🎲', | ||||||
|  |     SensitiveCategory.selfHarm => '🆘', | ||||||
|  |     SensitiveCategory.childAbuse => '🛑', | ||||||
|  |     SensitiveCategory.other => '❗', | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// Ordered list for UI consumption, matching enum declaration order. | ||||||
|  | const List<SensitiveCategory> kSensitiveCategoriesOrdered = [ | ||||||
|  |   SensitiveCategory.language, | ||||||
|  |   SensitiveCategory.sexualContent, | ||||||
|  |   SensitiveCategory.violence, | ||||||
|  |   SensitiveCategory.profanity, | ||||||
|  |   SensitiveCategory.hateSpeech, | ||||||
|  |   SensitiveCategory.racism, | ||||||
|  |   SensitiveCategory.adultContent, | ||||||
|  |   SensitiveCategory.drugAbuse, | ||||||
|  |   SensitiveCategory.alcoholAbuse, | ||||||
|  |   SensitiveCategory.gambling, | ||||||
|  |   SensitiveCategory.selfHarm, | ||||||
|  |   SensitiveCategory.childAbuse, | ||||||
|  |   SensitiveCategory.other, | ||||||
|  | ]; | ||||||
| @@ -33,6 +33,7 @@ class SheetScaffold extends StatelessWidget { | |||||||
|         ); |         ); | ||||||
|  |  | ||||||
|     return Container( |     return Container( | ||||||
|  |       padding: MediaQuery.of(context).viewInsets, | ||||||
|       constraints: BoxConstraints( |       constraints: BoxConstraints( | ||||||
|         maxHeight: height ?? MediaQuery.of(context).size.height * heightFactor, |         maxHeight: height ?? MediaQuery.of(context).size.height * heightFactor, | ||||||
|       ), |       ), | ||||||
|   | |||||||
| @@ -11,10 +11,12 @@ import 'package:media_kit_video/media_kit_video.dart'; | |||||||
| class UniversalVideo extends ConsumerStatefulWidget { | class UniversalVideo extends ConsumerStatefulWidget { | ||||||
|   final String uri; |   final String uri; | ||||||
|   final double aspectRatio; |   final double aspectRatio; | ||||||
|  |   final bool autoplay; | ||||||
|   const UniversalVideo({ |   const UniversalVideo({ | ||||||
|     super.key, |     super.key, | ||||||
|     required this.uri, |     required this.uri, | ||||||
|     this.aspectRatio = 16 / 9, |     this.aspectRatio = 16 / 9, | ||||||
|  |     this.autoplay = false, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
| @@ -47,7 +49,7 @@ class _UniversalVideoState extends ConsumerState<UniversalVideo> { | |||||||
|       log('[MediaPlayer] Hit cache: $url'); |       log('[MediaPlayer] Hit cache: $url'); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     _player!.open(Media(uri), play: false); |     _player!.open(Media(uri), play: widget.autoplay); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   | |||||||
| @@ -4,10 +4,12 @@ import 'package:flutter/material.dart'; | |||||||
| class UniversalVideo extends StatelessWidget { | class UniversalVideo extends StatelessWidget { | ||||||
|   final String uri; |   final String uri; | ||||||
|   final double aspectRatio; |   final double aspectRatio; | ||||||
|  |   final bool autoplay; | ||||||
|   const UniversalVideo({ |   const UniversalVideo({ | ||||||
|     super.key, |     super.key, | ||||||
|     required this.uri, |     required this.uri, | ||||||
|     required this.aspectRatio, |     required this.aspectRatio, | ||||||
|  |     this.autoplay = false, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   | |||||||
| @@ -1,84 +0,0 @@ | |||||||
| import 'package:flutter/foundation.dart'; |  | ||||||
| import 'package:flutter/material.dart'; |  | ||||||
| import 'package:flutter_hooks/flutter_hooks.dart'; |  | ||||||
|  |  | ||||||
| typedef ContextMenuBuilder = |  | ||||||
|     Widget Function(BuildContext context, Offset offset); |  | ||||||
|  |  | ||||||
| class ContextMenuRegion extends HookWidget { |  | ||||||
|   final Offset? mobileAnchor; |  | ||||||
|   final Widget child; |  | ||||||
|   final ContextMenuBuilder contextMenuBuilder; |  | ||||||
|   const ContextMenuRegion({ |  | ||||||
|     super.key, |  | ||||||
|     required this.child, |  | ||||||
|     required this.contextMenuBuilder, |  | ||||||
|     this.mobileAnchor, |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   Widget build(BuildContext context) { |  | ||||||
|     final contextMenuController = useMemoized(() => ContextMenuController()); |  | ||||||
|     final mobileOffset = useState<Offset?>(null); |  | ||||||
|  |  | ||||||
|     bool canBeTouchScreen = switch (defaultTargetPlatform) { |  | ||||||
|       TargetPlatform.android || TargetPlatform.iOS => true, |  | ||||||
|       _ => false, |  | ||||||
|     }; |  | ||||||
|  |  | ||||||
|     void showMenu(Offset position) { |  | ||||||
|       contextMenuController.show( |  | ||||||
|         context: context, |  | ||||||
|         contextMenuBuilder: (BuildContext context) { |  | ||||||
|           return contextMenuBuilder(context, position); |  | ||||||
|         }, |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     void hideMenu() { |  | ||||||
|       contextMenuController.remove(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     void onSecondaryTapUp(TapUpDetails details) { |  | ||||||
|       showMenu(details.globalPosition); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     void onTap() { |  | ||||||
|       if (!contextMenuController.isShown) { |  | ||||||
|         return; |  | ||||||
|       } |  | ||||||
|       hideMenu(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     void onLongPressStart(LongPressStartDetails details) { |  | ||||||
|       mobileOffset.value = details.globalPosition; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     void onLongPress() { |  | ||||||
|       assert(mobileOffset.value != null); |  | ||||||
|       showMenu(mobileAnchor ?? mobileOffset.value!); |  | ||||||
|       mobileOffset.value = null; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     useEffect(() { |  | ||||||
|       return () { |  | ||||||
|         hideMenu(); |  | ||||||
|       }; |  | ||||||
|     }, []); |  | ||||||
|  |  | ||||||
|     return TapRegion( |  | ||||||
|       behavior: HitTestBehavior.opaque, |  | ||||||
|       child: GestureDetector( |  | ||||||
|         behavior: HitTestBehavior.opaque, |  | ||||||
|         onSecondaryTapUp: onSecondaryTapUp, |  | ||||||
|         onTap: onTap, |  | ||||||
|         onLongPress: canBeTouchScreen ? onLongPress : null, |  | ||||||
|         onLongPressStart: canBeTouchScreen ? onLongPressStart : null, |  | ||||||
|         child: child, |  | ||||||
|       ), |  | ||||||
|       onTapOutside: (_) { |  | ||||||
|         hideMenu(); |  | ||||||
|       }, |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
							
								
								
									
										501
									
								
								lib/widgets/poll/poll_submit.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										501
									
								
								lib/widgets/poll/poll_submit.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,501 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||||||
|  | import 'package:island/models/poll.dart'; | ||||||
|  | import 'package:island/pods/network.dart'; | ||||||
|  |  | ||||||
|  | /// A poll answering widget that shows one question at a time and collects answers. | ||||||
|  | /// | ||||||
|  | /// Usage: | ||||||
|  | /// PollSubmit( | ||||||
|  | ///   poll: poll, | ||||||
|  | ///   onSubmit: (answers) { | ||||||
|  | ///     // answers is Map<String, dynamic>: questionId -> answer | ||||||
|  | ///     // answer types by question: | ||||||
|  | ///     // - singleChoice: String optionId | ||||||
|  | ///     // - multipleChoice: List<String> optionIds | ||||||
|  | ///     // - yesNo: bool | ||||||
|  | ///     // - rating: int (1..5) | ||||||
|  | ///     // - freeText: String | ||||||
|  | ///   }, | ||||||
|  | /// ) | ||||||
|  | class PollSubmit extends ConsumerStatefulWidget { | ||||||
|  |   const PollSubmit({ | ||||||
|  |     super.key, | ||||||
|  |     required this.poll, | ||||||
|  |     required this.onSubmit, | ||||||
|  |     this.initialAnswers, | ||||||
|  |     this.onCancel, | ||||||
|  |     this.showProgress = true, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   final SnPollWithStats poll; | ||||||
|  |  | ||||||
|  |   /// Callback when user submits all answers. Map questionId -> answer. | ||||||
|  |   final void Function(Map<String, dynamic> answers) onSubmit; | ||||||
|  |  | ||||||
|  |   /// Optional initial answers, keyed by questionId. | ||||||
|  |   final Map<String, dynamic>? initialAnswers; | ||||||
|  |  | ||||||
|  |   /// Optional cancel callback. | ||||||
|  |   final VoidCallback? onCancel; | ||||||
|  |  | ||||||
|  |   /// Whether to show a progress indicator (e.g., "2 / N"). | ||||||
|  |   final bool showProgress; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   ConsumerState<PollSubmit> createState() => _PollSubmitState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _PollSubmitState extends ConsumerState<PollSubmit> { | ||||||
|  |   late final List<SnPollQuestion> _questions; | ||||||
|  |   int _index = 0; | ||||||
|  |   bool _submitting = false; | ||||||
|  |  | ||||||
|  |   /// Collected answers, keyed by questionId | ||||||
|  |   late Map<String, dynamic> _answers; | ||||||
|  |  | ||||||
|  |   /// Local controller for free text input | ||||||
|  |   final TextEditingController _textController = TextEditingController(); | ||||||
|  |  | ||||||
|  |   /// Local state holders for inputs to avoid rebuilding whole list | ||||||
|  |   String? _singleChoiceSelected; // optionId | ||||||
|  |   final Set<String> _multiChoiceSelected = {}; | ||||||
|  |   bool? _yesNoSelected; | ||||||
|  |   int? _ratingSelected; // 1..5 | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     // Ensure questions are ordered by `order` | ||||||
|  |     _questions = [...widget.poll.questions] | ||||||
|  |       ..sort((a, b) => a.order.compareTo(b.order)); | ||||||
|  |     _answers = Map<String, dynamic>.from(widget.initialAnswers ?? {}); | ||||||
|  |     _loadCurrentIntoLocalState(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void didUpdateWidget(covariant PollSubmit oldWidget) { | ||||||
|  |     super.didUpdateWidget(oldWidget); | ||||||
|  |     if (oldWidget.poll.id != widget.poll.id) { | ||||||
|  |       _index = 0; | ||||||
|  |       _answers = Map<String, dynamic>.from(widget.initialAnswers ?? {}); | ||||||
|  |       _questions | ||||||
|  |         ..clear() | ||||||
|  |         ..addAll( | ||||||
|  |           [...widget.poll.questions] | ||||||
|  |             ..sort((a, b) => a.order.compareTo(b.order)), | ||||||
|  |         ); | ||||||
|  |       _loadCurrentIntoLocalState(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void dispose() { | ||||||
|  |     _textController.dispose(); | ||||||
|  |     super.dispose(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   SnPollQuestion get _current => _questions[_index]; | ||||||
|  |  | ||||||
|  |   void _loadCurrentIntoLocalState() { | ||||||
|  |     final q = _current; | ||||||
|  |     final saved = _answers[q.id]; | ||||||
|  |  | ||||||
|  |     _singleChoiceSelected = null; | ||||||
|  |     _multiChoiceSelected.clear(); | ||||||
|  |     _yesNoSelected = null; | ||||||
|  |     _ratingSelected = null; | ||||||
|  |     _textController.text = ''; | ||||||
|  |  | ||||||
|  |     switch (q.type) { | ||||||
|  |       case SnPollQuestionType.singleChoice: | ||||||
|  |         if (saved is String) _singleChoiceSelected = saved; | ||||||
|  |         break; | ||||||
|  |       case SnPollQuestionType.multipleChoice: | ||||||
|  |         if (saved is List) { | ||||||
|  |           _multiChoiceSelected.addAll(saved.whereType<String>()); | ||||||
|  |         } | ||||||
|  |         break; | ||||||
|  |       case SnPollQuestionType.yesNo: | ||||||
|  |         if (saved is bool) _yesNoSelected = saved; | ||||||
|  |         break; | ||||||
|  |       case SnPollQuestionType.rating: | ||||||
|  |         if (saved is int) _ratingSelected = saved; | ||||||
|  |         break; | ||||||
|  |       case SnPollQuestionType.freeText: | ||||||
|  |         if (saved is String) _textController.text = saved; | ||||||
|  |         break; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   bool _isCurrentAnswered() { | ||||||
|  |     final q = _current; | ||||||
|  |     if (!q.isRequired) return true; | ||||||
|  |  | ||||||
|  |     switch (q.type) { | ||||||
|  |       case SnPollQuestionType.singleChoice: | ||||||
|  |         return _singleChoiceSelected != null; | ||||||
|  |       case SnPollQuestionType.multipleChoice: | ||||||
|  |         return _multiChoiceSelected.isNotEmpty; | ||||||
|  |       case SnPollQuestionType.yesNo: | ||||||
|  |         return _yesNoSelected != null; | ||||||
|  |       case SnPollQuestionType.rating: | ||||||
|  |         return (_ratingSelected ?? 0) > 0; | ||||||
|  |       case SnPollQuestionType.freeText: | ||||||
|  |         return _textController.text.trim().isNotEmpty; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void _persistCurrentAnswer() { | ||||||
|  |     final q = _current; | ||||||
|  |     switch (q.type) { | ||||||
|  |       case SnPollQuestionType.singleChoice: | ||||||
|  |         if (_singleChoiceSelected == null) { | ||||||
|  |           _answers.remove(q.id); | ||||||
|  |         } else { | ||||||
|  |           _answers[q.id] = _singleChoiceSelected!; | ||||||
|  |         } | ||||||
|  |         break; | ||||||
|  |       case SnPollQuestionType.multipleChoice: | ||||||
|  |         if (_multiChoiceSelected.isEmpty) { | ||||||
|  |           _answers.remove(q.id); | ||||||
|  |         } else { | ||||||
|  |           _answers[q.id] = _multiChoiceSelected.toList(growable: false); | ||||||
|  |         } | ||||||
|  |         break; | ||||||
|  |       case SnPollQuestionType.yesNo: | ||||||
|  |         if (_yesNoSelected == null) { | ||||||
|  |           _answers.remove(q.id); | ||||||
|  |         } else { | ||||||
|  |           _answers[q.id] = _yesNoSelected!; | ||||||
|  |         } | ||||||
|  |         break; | ||||||
|  |       case SnPollQuestionType.rating: | ||||||
|  |         if (_ratingSelected == null) { | ||||||
|  |           _answers.remove(q.id); | ||||||
|  |         } else { | ||||||
|  |           _answers[q.id] = _ratingSelected!; | ||||||
|  |         } | ||||||
|  |         break; | ||||||
|  |       case SnPollQuestionType.freeText: | ||||||
|  |         final text = _textController.text.trim(); | ||||||
|  |         if (text.isEmpty) { | ||||||
|  |           _answers.remove(q.id); | ||||||
|  |         } else { | ||||||
|  |           _answers[q.id] = text; | ||||||
|  |         } | ||||||
|  |         break; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> _submitToServer() async { | ||||||
|  |     // Persist current question before final submit | ||||||
|  |     _persistCurrentAnswer(); | ||||||
|  |  | ||||||
|  |     setState(() { | ||||||
|  |       _submitting = true; | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       final dio = ref.read(apiClientProvider); | ||||||
|  |  | ||||||
|  |       await dio.post( | ||||||
|  |         '/sphere/polls/${widget.poll.id}/answer', | ||||||
|  |         data: {'answer': _answers}, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       // Only call onSubmit after server accepts | ||||||
|  |       widget.onSubmit(Map<String, dynamic>.unmodifiable(_answers)); | ||||||
|  |     } catch (e) { | ||||||
|  |       if (mounted) { | ||||||
|  |         ScaffoldMessenger.of( | ||||||
|  |           context, | ||||||
|  |         ).showSnackBar(SnackBar(content: Text('Failed to submit poll: $e'))); | ||||||
|  |       } | ||||||
|  |     } finally { | ||||||
|  |       if (mounted) { | ||||||
|  |         setState(() { | ||||||
|  |           _submitting = false; | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void _next() { | ||||||
|  |     if (_submitting) return; | ||||||
|  |     _persistCurrentAnswer(); | ||||||
|  |     if (_index < _questions.length - 1) { | ||||||
|  |       setState(() { | ||||||
|  |         _index++; | ||||||
|  |         _loadCurrentIntoLocalState(); | ||||||
|  |       }); | ||||||
|  |     } else { | ||||||
|  |       // Final submit to API | ||||||
|  |       _submitToServer(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void _back() { | ||||||
|  |     if (_submitting) return; | ||||||
|  |     _persistCurrentAnswer(); | ||||||
|  |     if (_index > 0) { | ||||||
|  |       setState(() { | ||||||
|  |         _index--; | ||||||
|  |         _loadCurrentIntoLocalState(); | ||||||
|  |       }); | ||||||
|  |     } else { | ||||||
|  |       // at the first question; allow cancel if provided | ||||||
|  |       widget.onCancel?.call(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Widget _buildHeader(BuildContext context) { | ||||||
|  |     final q = _current; | ||||||
|  |     return Column( | ||||||
|  |       crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |       children: [ | ||||||
|  |         if (widget.poll.title != null || widget.poll.description != null) | ||||||
|  |           Padding( | ||||||
|  |             padding: const EdgeInsets.only(bottom: 12), | ||||||
|  |             child: Column( | ||||||
|  |               crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |               children: [ | ||||||
|  |                 if (widget.poll.title != null) | ||||||
|  |                   Text( | ||||||
|  |                     widget.poll.title!, | ||||||
|  |                     style: Theme.of(context).textTheme.titleLarge, | ||||||
|  |                   ), | ||||||
|  |                 if (widget.poll.description != null) | ||||||
|  |                   Padding( | ||||||
|  |                     padding: const EdgeInsets.only(top: 4), | ||||||
|  |                     child: Text( | ||||||
|  |                       widget.poll.description!, | ||||||
|  |                       style: Theme.of(context).textTheme.bodyMedium?.copyWith( | ||||||
|  |                         color: Theme.of( | ||||||
|  |                           context, | ||||||
|  |                         ).textTheme.bodyMedium?.color?.withOpacity(0.7), | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         if (widget.showProgress) | ||||||
|  |           Text( | ||||||
|  |             '${_index + 1} / ${_questions.length}', | ||||||
|  |             style: Theme.of(context).textTheme.labelMedium, | ||||||
|  |           ), | ||||||
|  |         Row( | ||||||
|  |           children: [ | ||||||
|  |             Expanded( | ||||||
|  |               child: Text( | ||||||
|  |                 q.title, | ||||||
|  |                 style: Theme.of(context).textTheme.titleMedium, | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |             if (q.isRequired) | ||||||
|  |               Padding( | ||||||
|  |                 padding: const EdgeInsets.only(left: 8), | ||||||
|  |                 child: Text( | ||||||
|  |                   '*', | ||||||
|  |                   style: Theme.of(context).textTheme.titleMedium?.copyWith( | ||||||
|  |                     color: Theme.of(context).colorScheme.error, | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |         if (q.description != null) | ||||||
|  |           Padding( | ||||||
|  |             padding: const EdgeInsets.only(top: 4), | ||||||
|  |             child: Text( | ||||||
|  |               q.description!, | ||||||
|  |               style: Theme.of(context).textTheme.bodySmall?.copyWith( | ||||||
|  |                 color: Theme.of( | ||||||
|  |                   context, | ||||||
|  |                 ).textTheme.bodySmall?.color?.withOpacity(0.7), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Widget _buildBody(BuildContext context) { | ||||||
|  |     final q = _current; | ||||||
|  |     switch (q.type) { | ||||||
|  |       case SnPollQuestionType.singleChoice: | ||||||
|  |         return _buildSingleChoice(context, q); | ||||||
|  |       case SnPollQuestionType.multipleChoice: | ||||||
|  |         return _buildMultipleChoice(context, q); | ||||||
|  |       case SnPollQuestionType.yesNo: | ||||||
|  |         return _buildYesNo(context, q); | ||||||
|  |       case SnPollQuestionType.rating: | ||||||
|  |         return _buildRating(context, q); | ||||||
|  |       case SnPollQuestionType.freeText: | ||||||
|  |         return _buildFreeText(context, q); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Widget _buildSingleChoice(BuildContext context, SnPollQuestion q) { | ||||||
|  |     final options = [...?q.options]..sort((a, b) => a.order.compareTo(b.order)); | ||||||
|  |     return Column( | ||||||
|  |       children: [ | ||||||
|  |         for (final opt in options) | ||||||
|  |           RadioListTile<String>( | ||||||
|  |             value: opt.id, | ||||||
|  |             groupValue: _singleChoiceSelected, | ||||||
|  |             onChanged: (val) => setState(() => _singleChoiceSelected = val), | ||||||
|  |             title: Text(opt.label), | ||||||
|  |             subtitle: opt.description != null ? Text(opt.description!) : null, | ||||||
|  |           ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Widget _buildMultipleChoice(BuildContext context, SnPollQuestion q) { | ||||||
|  |     final options = [...?q.options]..sort((a, b) => a.order.compareTo(b.order)); | ||||||
|  |     return Column( | ||||||
|  |       children: [ | ||||||
|  |         for (final opt in options) | ||||||
|  |           CheckboxListTile( | ||||||
|  |             value: _multiChoiceSelected.contains(opt.id), | ||||||
|  |             onChanged: (val) { | ||||||
|  |               setState(() { | ||||||
|  |                 if (val == true) { | ||||||
|  |                   _multiChoiceSelected.add(opt.id); | ||||||
|  |                 } else { | ||||||
|  |                   _multiChoiceSelected.remove(opt.id); | ||||||
|  |                 } | ||||||
|  |               }); | ||||||
|  |             }, | ||||||
|  |             title: Text(opt.label), | ||||||
|  |             subtitle: opt.description != null ? Text(opt.description!) : null, | ||||||
|  |           ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Widget _buildYesNo(BuildContext context, SnPollQuestion q) { | ||||||
|  |     return Row( | ||||||
|  |       children: [ | ||||||
|  |         Expanded( | ||||||
|  |           child: SegmentedButton<bool>( | ||||||
|  |             segments: const [ | ||||||
|  |               ButtonSegment(value: true, label: Text('Yes')), | ||||||
|  |               ButtonSegment(value: false, label: Text('No')), | ||||||
|  |             ], | ||||||
|  |             selected: _yesNoSelected == null ? {} : {_yesNoSelected!}, | ||||||
|  |             onSelectionChanged: (sel) { | ||||||
|  |               setState(() { | ||||||
|  |                 _yesNoSelected = sel.isEmpty ? null : sel.first; | ||||||
|  |               }); | ||||||
|  |             }, | ||||||
|  |             multiSelectionEnabled: false, | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Widget _buildRating(BuildContext context, SnPollQuestion q) { | ||||||
|  |     const max = 5; | ||||||
|  |     return Row( | ||||||
|  |       mainAxisAlignment: MainAxisAlignment.center, | ||||||
|  |       children: List.generate(max, (i) { | ||||||
|  |         final value = i + 1; | ||||||
|  |         final selected = (_ratingSelected ?? 0) >= value; | ||||||
|  |         return IconButton( | ||||||
|  |           icon: Icon( | ||||||
|  |             selected ? Icons.star : Icons.star_border, | ||||||
|  |             color: selected ? Colors.amber : null, | ||||||
|  |           ), | ||||||
|  |           onPressed: () { | ||||||
|  |             setState(() { | ||||||
|  |               _ratingSelected = value; | ||||||
|  |             }); | ||||||
|  |           }, | ||||||
|  |         ); | ||||||
|  |       }), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Widget _buildFreeText(BuildContext context, SnPollQuestion q) { | ||||||
|  |     return TextField( | ||||||
|  |       controller: _textController, | ||||||
|  |       maxLines: 6, | ||||||
|  |       decoration: const InputDecoration(border: OutlineInputBorder()), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Widget _buildNavBar(BuildContext context) { | ||||||
|  |     final isLast = _index == _questions.length - 1; | ||||||
|  |     final canProceed = _isCurrentAnswered() && !_submitting; | ||||||
|  |  | ||||||
|  |     return Row( | ||||||
|  |       children: [ | ||||||
|  |         OutlinedButton.icon( | ||||||
|  |           icon: const Icon(Icons.arrow_back), | ||||||
|  |           label: Text(_index == 0 ? 'Cancel' : 'Back'), | ||||||
|  |           onPressed: _submitting ? null : _back, | ||||||
|  |         ), | ||||||
|  |         const Spacer(), | ||||||
|  |         FilledButton.icon( | ||||||
|  |           icon: | ||||||
|  |               _submitting | ||||||
|  |                   ? const SizedBox( | ||||||
|  |                     width: 16, | ||||||
|  |                     height: 16, | ||||||
|  |                     child: CircularProgressIndicator(strokeWidth: 2), | ||||||
|  |                   ) | ||||||
|  |                   : Icon(isLast ? Icons.check : Icons.arrow_forward), | ||||||
|  |           label: Text(isLast ? 'Submit' : 'Next'), | ||||||
|  |           onPressed: canProceed ? _next : null, | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     if (_questions.isEmpty) { | ||||||
|  |       return const SizedBox.shrink(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return Column( | ||||||
|  |       crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |       children: [ | ||||||
|  |         _buildHeader(context), | ||||||
|  |         const SizedBox(height: 12), | ||||||
|  |         _AnimatedStep(key: ValueKey(_current.id), child: _buildBody(context)), | ||||||
|  |         const SizedBox(height: 16), | ||||||
|  |         _buildNavBar(context), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// Simple fade/slide transition between questions. | ||||||
|  | class _AnimatedStep extends StatelessWidget { | ||||||
|  |   const _AnimatedStep({super.key, required this.child}); | ||||||
|  |   final Widget child; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AnimatedSwitcher( | ||||||
|  |       duration: const Duration(milliseconds: 250), | ||||||
|  |       transitionBuilder: (child, anim) { | ||||||
|  |         final offset = Tween<Offset>( | ||||||
|  |           begin: const Offset(0.1, 0), | ||||||
|  |           end: Offset.zero, | ||||||
|  |         ).animate(anim); | ||||||
|  |         final fade = CurvedAnimation(parent: anim, curve: Curves.easeInOut); | ||||||
|  |         return FadeTransition( | ||||||
|  |           opacity: fade, | ||||||
|  |           child: SlideTransition(position: offset, child: child), | ||||||
|  |         ); | ||||||
|  |       }, | ||||||
|  |       child: child, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										204
									
								
								lib/widgets/post/compose_link_attachments.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										204
									
								
								lib/widgets/post/compose_link_attachments.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,204 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.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/widgets/content/cloud_files.dart'; | ||||||
|  | import 'package:island/widgets/content/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'; | ||||||
|  | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
|  |  | ||||||
|  | part 'compose_link_attachments.g.dart'; | ||||||
|  |  | ||||||
|  | @riverpod | ||||||
|  | class CloudFileListNotifier extends _$CloudFileListNotifier | ||||||
|  |     with CursorPagingNotifierMixin<SnCloudFile> { | ||||||
|  |   @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 = {'offset': offset, 'take': take}; | ||||||
|  |  | ||||||
|  |     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, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class ComposeLinkAttachment extends HookConsumerWidget { | ||||||
|  |   const ComposeLinkAttachment({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     final idController = useTextEditingController(); | ||||||
|  |     final errorMessage = useState<String?>(null); | ||||||
|  |  | ||||||
|  |     return SheetScaffold( | ||||||
|  |       heightFactor: 0.6, | ||||||
|  |       titleText: 'linkAttachment'.tr(), | ||||||
|  |       child: DefaultTabController( | ||||||
|  |         length: 2, | ||||||
|  |         child: Column( | ||||||
|  |           mainAxisSize: MainAxisSize.min, | ||||||
|  |           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |           children: [ | ||||||
|  |             TabBar( | ||||||
|  |               tabs: [ | ||||||
|  |                 Tab(text: 'attachmentsRecentUploads'.tr()), | ||||||
|  |                 Tab(text: 'attachmentsManualInput'.tr()), | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |             Expanded( | ||||||
|  |               child: TabBarView( | ||||||
|  |                 children: [ | ||||||
|  |                   PagingHelperView( | ||||||
|  |                     provider: cloudFileListNotifierProvider, | ||||||
|  |                     futureRefreshable: cloudFileListNotifierProvider.future, | ||||||
|  |                     notifierRefreshable: cloudFileListNotifierProvider.notifier, | ||||||
|  |                     contentBuilder: | ||||||
|  |                         (data, widgetCount, endItemView) => ListView.builder( | ||||||
|  |                           padding: EdgeInsets.only(top: 8), | ||||||
|  |                           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), | ||||||
|  |                               onTap: () { | ||||||
|  |                                 Navigator.pop(context, item); | ||||||
|  |                               }, | ||||||
|  |                             ); | ||||||
|  |                           }, | ||||||
|  |                         ), | ||||||
|  |                   ), | ||||||
|  |                   SingleChildScrollView( | ||||||
|  |                     child: Column( | ||||||
|  |                       crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                       children: [ | ||||||
|  |                         TextField( | ||||||
|  |                           controller: idController, | ||||||
|  |                           decoration: InputDecoration( | ||||||
|  |                             labelText: 'fileId'.tr(), | ||||||
|  |                             helperText: 'fileIdHint'.tr(), | ||||||
|  |                             helperMaxLines: 3, | ||||||
|  |                             errorText: errorMessage.value, | ||||||
|  |                             border: OutlineInputBorder(), | ||||||
|  |                           ), | ||||||
|  |                           onTapOutside: | ||||||
|  |                               (_) => | ||||||
|  |                                   FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |                         ), | ||||||
|  |                         const Gap(16), | ||||||
|  |                         InkWell( | ||||||
|  |                           child: Text( | ||||||
|  |                             'fileIdLinkHint', | ||||||
|  |                           ).tr().fontSize(13).opacity(0.85), | ||||||
|  |                           onTap: () { | ||||||
|  |                             launchUrlString('https://fs.solian.app'); | ||||||
|  |                           }, | ||||||
|  |                         ).padding(horizontal: 14), | ||||||
|  |                         const Gap(16), | ||||||
|  |                         Align( | ||||||
|  |                           alignment: Alignment.centerRight, | ||||||
|  |                           child: TextButton.icon( | ||||||
|  |                             icon: const Icon(Symbols.add), | ||||||
|  |                             label: Text('add'.tr()), | ||||||
|  |                             onPressed: () async { | ||||||
|  |                               final fileId = idController.text.trim(); | ||||||
|  |                               if (fileId.isEmpty) { | ||||||
|  |                                 errorMessage.value = 'fileIdCannotBeEmpty'.tr(); | ||||||
|  |                                 return; | ||||||
|  |                               } | ||||||
|  |  | ||||||
|  |                               try { | ||||||
|  |                                 final client = ref.read(apiClientProvider); | ||||||
|  |                                 final response = await client.get( | ||||||
|  |                                   '/drive/files/$fileId/info', | ||||||
|  |                                 ); | ||||||
|  |                                 final SnCloudFile cloudFile = | ||||||
|  |                                     SnCloudFile.fromJson(response.data); | ||||||
|  |  | ||||||
|  |                                 if (context.mounted) { | ||||||
|  |                                   Navigator.of(context).pop(cloudFile); | ||||||
|  |                                 } | ||||||
|  |                               } catch (e) { | ||||||
|  |                                 errorMessage.value = 'failedToFetchFile'.tr( | ||||||
|  |                                   args: [e.toString()], | ||||||
|  |                                 ); | ||||||
|  |                               } | ||||||
|  |                             }, | ||||||
|  |                           ), | ||||||
|  |                         ), | ||||||
|  |                       ], | ||||||
|  |                     ).padding(horizontal: 24, vertical: 24), | ||||||
|  |                   ), | ||||||
|  |                 ], | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										31
									
								
								lib/widgets/post/compose_link_attachments.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								lib/widgets/post/compose_link_attachments.g.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | |||||||
|  | // GENERATED CODE - DO NOT MODIFY BY HAND | ||||||
|  |  | ||||||
|  | part of 'compose_link_attachments.dart'; | ||||||
|  |  | ||||||
|  | // ************************************************************************** | ||||||
|  | // RiverpodGenerator | ||||||
|  | // ************************************************************************** | ||||||
|  |  | ||||||
|  | String _$cloudFileListNotifierHash() => | ||||||
|  |     r'e2c8a076a9e635c7b43a87d00f78775427ba6334'; | ||||||
|  |  | ||||||
|  | /// 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 | ||||||
							
								
								
									
										201
									
								
								lib/widgets/post/compose_poll.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								lib/widgets/post/compose_poll.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,201 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
|  | import 'package:gap/gap.dart'; | ||||||
|  | import 'package:go_router/go_router.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
|  | import 'package:island/models/poll.dart'; | ||||||
|  | import 'package:island/models/publisher.dart'; | ||||||
|  | import 'package:island/screens/creators/poll/poll_list.dart'; | ||||||
|  | import 'package:island/widgets/content/sheet.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
|  | import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||||
|  | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:island/widgets/post/publishers_modal.dart'; | ||||||
|  |  | ||||||
|  | /// Bottom sheet for selecting or creating a poll. Returns SnPoll via Navigator.pop. | ||||||
|  | class ComposePollSheet extends HookConsumerWidget { | ||||||
|  |   /// Optional publisher name to filter polls and prefill creation. | ||||||
|  |   final String? pubName; | ||||||
|  |  | ||||||
|  |   const ComposePollSheet({super.key, this.pubName}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     final selectedPublisher = useState<String?>(pubName); | ||||||
|  |     final isPushing = useState(false); | ||||||
|  |     final errorText = useState<String?>(null); | ||||||
|  |  | ||||||
|  |     return SheetScaffold( | ||||||
|  |       heightFactor: 0.6, | ||||||
|  |       titleText: 'poll'.tr(), | ||||||
|  |       child: DefaultTabController( | ||||||
|  |         length: 2, | ||||||
|  |         child: Column( | ||||||
|  |           mainAxisSize: MainAxisSize.min, | ||||||
|  |           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |           children: [ | ||||||
|  |             TabBar( | ||||||
|  |               tabs: [ | ||||||
|  |                 Tab(text: 'pollsRecent'.tr()), | ||||||
|  |                 Tab(text: 'pollCreateNew'.tr()), | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |             Expanded( | ||||||
|  |               child: TabBarView( | ||||||
|  |                 children: [ | ||||||
|  |                   // Link/Select existing poll list | ||||||
|  |                   PagingHelperView( | ||||||
|  |                     provider: pollListNotifierProvider(pubName), | ||||||
|  |                     futureRefreshable: pollListNotifierProvider(pubName).future, | ||||||
|  |                     notifierRefreshable: | ||||||
|  |                         pollListNotifierProvider(pubName).notifier, | ||||||
|  |                     contentBuilder: | ||||||
|  |                         (data, widgetCount, endItemView) => ListView.builder( | ||||||
|  |                           padding: EdgeInsets.zero, | ||||||
|  |                           itemCount: widgetCount, | ||||||
|  |                           itemBuilder: (context, index) { | ||||||
|  |                             if (index == widgetCount - 1) { | ||||||
|  |                               return endItemView; | ||||||
|  |                             } | ||||||
|  |  | ||||||
|  |                             final poll = data.items[index]; | ||||||
|  |  | ||||||
|  |                             return ListTile( | ||||||
|  |                               leading: const Icon(Symbols.how_to_vote, fill: 1), | ||||||
|  |                               title: Text(poll.title ?? 'untitled'.tr()), | ||||||
|  |                               subtitle: _buildPollSubtitle(poll), | ||||||
|  |                               onTap: () { | ||||||
|  |                                 Navigator.of(context).pop(poll); | ||||||
|  |                               }, | ||||||
|  |                             ); | ||||||
|  |                           }, | ||||||
|  |                         ), | ||||||
|  |                   ), | ||||||
|  |  | ||||||
|  |                   // Create new poll and return it | ||||||
|  |                   SingleChildScrollView( | ||||||
|  |                     child: Column( | ||||||
|  |                       crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                       children: [ | ||||||
|  |                         Text( | ||||||
|  |                           'pollCreateNewHint', | ||||||
|  |                         ).tr().fontSize(13).opacity(0.85).padding(bottom: 8), | ||||||
|  |                         ListTile( | ||||||
|  |                           title: Text( | ||||||
|  |                             selectedPublisher.value == null | ||||||
|  |                                 ? 'publisher'.tr() | ||||||
|  |                                 : '@${selectedPublisher.value}', | ||||||
|  |                           ), | ||||||
|  |                           subtitle: Text( | ||||||
|  |                             selectedPublisher.value == null | ||||||
|  |                                 ? 'publisherHint'.tr() | ||||||
|  |                                 : 'selected'.tr(), | ||||||
|  |                           ), | ||||||
|  |                           leading: const Icon(Symbols.account_circle), | ||||||
|  |                           trailing: const Icon(Symbols.chevron_right), | ||||||
|  |                           onTap: () async { | ||||||
|  |                             final picked = | ||||||
|  |                                 await showModalBottomSheet<SnPublisher>( | ||||||
|  |                                   context: context, | ||||||
|  |                                   isScrollControlled: true, | ||||||
|  |                                   builder: (context) => const PublisherModal(), | ||||||
|  |                                 ); | ||||||
|  |                             if (picked != null) { | ||||||
|  |                               try { | ||||||
|  |                                 final name = picked.name; | ||||||
|  |                                 if (name.isNotEmpty) { | ||||||
|  |                                   selectedPublisher.value = name; | ||||||
|  |                                   errorText.value = null; | ||||||
|  |                                 } | ||||||
|  |                               } catch (_) { | ||||||
|  |                                 // ignore | ||||||
|  |                               } | ||||||
|  |                             } | ||||||
|  |                           }, | ||||||
|  |                         ), | ||||||
|  |                         if (errorText.value != null) | ||||||
|  |                           Padding( | ||||||
|  |                             padding: const EdgeInsets.only( | ||||||
|  |                               left: 16, | ||||||
|  |                               right: 16, | ||||||
|  |                               top: 4, | ||||||
|  |                             ), | ||||||
|  |                             child: Text( | ||||||
|  |                               errorText.value!, | ||||||
|  |                               style: TextStyle(color: Colors.red[700]), | ||||||
|  |                             ), | ||||||
|  |                           ), | ||||||
|  |                         const Gap(16), | ||||||
|  |                         Align( | ||||||
|  |                           alignment: Alignment.centerRight, | ||||||
|  |                           child: FilledButton.icon( | ||||||
|  |                             icon: | ||||||
|  |                                 isPushing.value | ||||||
|  |                                     ? const SizedBox( | ||||||
|  |                                       width: 18, | ||||||
|  |                                       height: 18, | ||||||
|  |                                       child: CircularProgressIndicator( | ||||||
|  |                                         strokeWidth: 2, | ||||||
|  |                                         color: Colors.white, | ||||||
|  |                                       ), | ||||||
|  |                                     ) | ||||||
|  |                                     : const Icon(Symbols.add_circle), | ||||||
|  |                             label: Text('create'.tr()), | ||||||
|  |                             onPressed: | ||||||
|  |                                 isPushing.value | ||||||
|  |                                     ? null | ||||||
|  |                                     : () async { | ||||||
|  |                                       final pub = selectedPublisher.value ?? ''; | ||||||
|  |                                       if (pub.isEmpty) { | ||||||
|  |                                         errorText.value = | ||||||
|  |                                             'publisherCannotBeEmpty'.tr(); | ||||||
|  |                                         return; | ||||||
|  |                                       } | ||||||
|  |                                       errorText.value = null; | ||||||
|  |  | ||||||
|  |                                       isPushing.value = true; | ||||||
|  |                                       // Push to creatorPollNew route and await result | ||||||
|  |                                       final result = await GoRouter.of( | ||||||
|  |                                         context, | ||||||
|  |                                       ).push<SnPoll>( | ||||||
|  |                                         '/creators/$pub/polls/new', | ||||||
|  |                                       ); | ||||||
|  |  | ||||||
|  |                                       if (result == null) { | ||||||
|  |                                         isPushing.value = false; | ||||||
|  |                                         return; | ||||||
|  |                                       } | ||||||
|  |  | ||||||
|  |                                       if (!context.mounted) return; | ||||||
|  |  | ||||||
|  |                                       // Return created poll to caller of this bottom sheet | ||||||
|  |                                       Navigator.of(context).pop(result); | ||||||
|  |                                     }, | ||||||
|  |                           ), | ||||||
|  |                         ), | ||||||
|  |                       ], | ||||||
|  |                     ).padding(horizontal: 24, vertical: 24), | ||||||
|  |                   ), | ||||||
|  |                 ], | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Widget? _buildPollSubtitle(SnPoll poll) { | ||||||
|  |     try { | ||||||
|  |       final SnPoll dyn = poll; | ||||||
|  |       final List<SnPollQuestion>? options = dyn.questions; | ||||||
|  |       if (options == null || options.isEmpty) return null; | ||||||
|  |       final preview = options.take(3).map((e) => e.title).join(' · '); | ||||||
|  |       if (preview.trim().isEmpty) return null; | ||||||
|  |       return Text(preview); | ||||||
|  |     } catch (_) { | ||||||
|  |       return null; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										147
									
								
								lib/widgets/post/compose_recorder.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								lib/widgets/post/compose_recorder.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,147 @@ | |||||||
|  | import 'dart:async'; | ||||||
|  | import 'dart:developer'; | ||||||
|  |  | ||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:file_picker/file_picker.dart'; | ||||||
|  | import 'package:flutter/foundation.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/services/time.dart'; | ||||||
|  | import 'package:island/widgets/alert.dart'; | ||||||
|  | import 'package:island/widgets/content/sheet.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
|  | import 'package:path_provider/path_provider.dart'; | ||||||
|  | import 'package:record/record.dart' hide Amplitude; | ||||||
|  | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:uuid/uuid.dart'; | ||||||
|  | import 'package:waveform_flutter/waveform_flutter.dart'; | ||||||
|  |  | ||||||
|  | class ComposeRecorder extends HookConsumerWidget { | ||||||
|  |   const ComposeRecorder({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     final recording = useState(false); | ||||||
|  |     final recordingStartAt = useState<DateTime?>(null); | ||||||
|  |     final recordingDuration = useState<Duration>(Duration(seconds: 0)); | ||||||
|  |  | ||||||
|  |     StreamSubscription? originalAmplitude; | ||||||
|  |     StreamController<Amplitude> amplitudeStream = StreamController(); | ||||||
|  |     var record = AudioRecorder(); | ||||||
|  |  | ||||||
|  |     final resultPath = useState<String?>(null); | ||||||
|  |  | ||||||
|  |     Future<void> startRecord() async { | ||||||
|  |       recording.value = true; | ||||||
|  |  | ||||||
|  |       // Check and request permission if needed | ||||||
|  |       final tempPath = !kIsWeb ? (await getTemporaryDirectory()).path : 'temp'; | ||||||
|  |       final uuid = const Uuid().v4().substring(0, 8); | ||||||
|  |       if (!await record.hasPermission()) return; | ||||||
|  |  | ||||||
|  |       const recordConfig = RecordConfig( | ||||||
|  |         encoder: AudioEncoder.pcm16bits, | ||||||
|  |         autoGain: true, | ||||||
|  |         echoCancel: true, | ||||||
|  |         noiseSuppress: true, | ||||||
|  |       ); | ||||||
|  |       resultPath.value = '$tempPath/solar-network-record-$uuid.m4a'; | ||||||
|  |       await record.start(recordConfig, path: resultPath.value!); | ||||||
|  |  | ||||||
|  |       recordingStartAt.value = DateTime.now(); | ||||||
|  |       originalAmplitude = record | ||||||
|  |           .onAmplitudeChanged(const Duration(milliseconds: 100)) | ||||||
|  |           .listen((value) async { | ||||||
|  |             amplitudeStream.add( | ||||||
|  |               Amplitude(current: value.current, max: value.max), | ||||||
|  |             ); | ||||||
|  |             recordingDuration.value = DateTime.now().difference( | ||||||
|  |               recordingStartAt.value!, | ||||||
|  |             ); | ||||||
|  |           }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     useEffect(() { | ||||||
|  |       return () { | ||||||
|  |         // Called when widget is unmounted | ||||||
|  |         log('[Recorder] Clean up!'); | ||||||
|  |         originalAmplitude?.cancel(); | ||||||
|  |         amplitudeStream.close(); | ||||||
|  |         record.dispose(); | ||||||
|  |       }; | ||||||
|  |     }, []); | ||||||
|  |  | ||||||
|  |     Future<void> stopRecord() async { | ||||||
|  |       recording.value = false; | ||||||
|  |       await record.pause(); | ||||||
|  |       final newResult = await record.stop(); | ||||||
|  |       await record.cancel(); | ||||||
|  |       if (newResult != null) resultPath.value = newResult; | ||||||
|  |  | ||||||
|  |       if (context.mounted) Navigator.of(context).pop(resultPath.value); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     Future<void> addExistingAudio() async { | ||||||
|  |       var result = await FilePicker.platform.pickFiles( | ||||||
|  |         type: FileType.custom, | ||||||
|  |         allowedExtensions: ['mp3', 'm4a', 'wav', 'aac', 'flac', 'ogg', 'opus'], | ||||||
|  |         onFileLoading: (status) { | ||||||
|  |           if (!context.mounted) return; | ||||||
|  |           if (status == FilePickerStatus.picking) { | ||||||
|  |             showLoadingModal(context); | ||||||
|  |           } else { | ||||||
|  |             hideLoadingModal(context); | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |       ); | ||||||
|  |       if (result == null || result.count == 0) return; | ||||||
|  |       if (context.mounted) Navigator.of(context).pop(result.files.first.path); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return SheetScaffold( | ||||||
|  |       titleText: "recordAudio".tr(), | ||||||
|  |       actions: [ | ||||||
|  |         IconButton( | ||||||
|  |           onPressed: addExistingAudio, | ||||||
|  |           icon: const Icon(Symbols.upload), | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|  |       child: Column( | ||||||
|  |         crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |         children: [ | ||||||
|  |           const Gap(32), | ||||||
|  |           Text( | ||||||
|  |             recordingDuration.value.formatShortDuration(), | ||||||
|  |           ).fontSize(20).bold().padding(bottom: 8), | ||||||
|  |           SizedBox( | ||||||
|  |             height: 120, | ||||||
|  |             child: Center( | ||||||
|  |               child: ConstrainedBox( | ||||||
|  |                 constraints: const BoxConstraints(maxWidth: 480), | ||||||
|  |                 child: Card( | ||||||
|  |                   color: Theme.of(context).colorScheme.surfaceContainer, | ||||||
|  |                   child: AnimatedWaveList(stream: amplitudeStream.stream), | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ).padding(horizontal: 24), | ||||||
|  |           const Gap(12), | ||||||
|  |           IconButton.filled( | ||||||
|  |             onPressed: recording.value ? stopRecord : startRecord, | ||||||
|  |             iconSize: 32, | ||||||
|  |             icon: | ||||||
|  |                 recording.value | ||||||
|  |                     ? const Icon(Symbols.stop, fill: 1, color: Colors.white) | ||||||
|  |                     : const Icon( | ||||||
|  |                       Symbols.play_arrow, | ||||||
|  |                       fill: 1, | ||||||
|  |                       color: Colors.white, | ||||||
|  |                     ), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user