Compare commits
	
		
			38 Commits
		
	
	
		
			3.0.0+104
			...
			89fd80bcb8
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 89fd80bcb8 | |||
| ab4f4faafe | |||
| 52111c4b95 | |||
| 15a5848785 | |||
| e29a2fc054 | |||
| 7f4e489f51 | |||
| eb4d2c2e2f | |||
| 9b67d58ee4 | |||
| 4dbee27718 | |||
| 4b9c9aec92 | |||
| 00b3dc7be6 | |||
| 7f26196e85 | |||
| 3e5669780f | |||
| 484ded03b1 | |||
| b3786827ef | |||
| 217a0c0a54 | |||
| 5c0f7225e6 | |||
| a7cb7170b8 | |||
| 2fac5e5383 | |||
| 00b9c4b957 | |||
| 6fbf3d9fc4 | |||
| 36fb06b81c | |||
| 129c215a02 | |||
| 0f125f45f0 | |||
| 30416f7ca0 | |||
| 6e74cf3a93 | |||
| 0d6424e155 | |||
| 0424eb0c2a | |||
| be01bac5db | |||
| 7bb4e68054 | |||
| 61e61866d7 | |||
| 280c261ea1 | |||
| 49bc6b9a98 | |||
| 461f32545a | |||
| 36b9026e9e | |||
| 78f258dcea | |||
| 044fb983d6 | |||
| 2a7876e22f | 
| @@ -18,9 +18,7 @@ android { | ||||
|         targetCompatibility = JavaVersion.VERSION_17 | ||||
|     } | ||||
|  | ||||
|     kotlinOptions { | ||||
|         jvmTarget = JavaVersion.VERSION_17.toString() | ||||
|     } | ||||
|     kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() } | ||||
|  | ||||
|     defaultConfig { | ||||
|         applicationId = "dev.solsynth.solian" | ||||
| @@ -32,11 +30,20 @@ android { | ||||
|         versionName = flutter.versionName | ||||
|     } | ||||
|  | ||||
|     signingConfigs { | ||||
|         release { | ||||
|             keyAlias = keystoreProperties['keyAlias'] | ||||
|             keyPassword = keystoreProperties['keyPassword'] | ||||
|             storeFile = keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null | ||||
|             storePassword = keystoreProperties['storePassword'] | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     buildTypes { | ||||
|         release { | ||||
|             // TODO: Add your own signing config for the release build. | ||||
|             // Signing with the debug keys for now, so `flutter run --release` works. | ||||
|             signingConfig = signingConfigs.getByName("debug") | ||||
|             signingConfig = signingConfigs.getByName("release") | ||||
|             minifyEnabled = true | ||||
|             shrinkResources = true | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -42,6 +42,22 @@ | ||||
|                 <category android:name="android.intent.category.LAUNCHER" /> | ||||
|             </intent-filter> | ||||
|         </activity> | ||||
|  | ||||
|         <!-- Sign in with Apple --> | ||||
|         <activity | ||||
|             android:name="com.aboutyou.dart_packages.sign_in_with_apple.SignInWithAppleCallback" | ||||
|             android:exported="true" | ||||
|         > | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.VIEW" /> | ||||
|                 <category android:name="android.intent.category.DEFAULT" /> | ||||
|                 <category android:name="android.intent.category.BROWSABLE" /> | ||||
|  | ||||
|                 <data android:scheme="signinwithapple" /> | ||||
|                 <data android:path="callback" /> | ||||
|             </intent-filter> | ||||
|         </activity> | ||||
|  | ||||
|         <provider | ||||
|             android:name="androidx.core.content.FileProvider" | ||||
|             android:authorities="dev.solsynth.solian.provider" | ||||
|   | ||||
| @@ -10,6 +10,8 @@ | ||||
|   "loginEnterPassword": "Enter the code", | ||||
|   "loginSuccess": "Logged in as {}", | ||||
|   "loginGreeting": "Welcome back!", | ||||
|   "loginOr": "Or login with\nthird parties", | ||||
|   "loginInProgress": "Logging you in...", | ||||
|   "username": "Username", | ||||
|   "usernameCannotChangeHint": "Username cannot be updated after created.", | ||||
|   "usernameLookupHint": "We also take your email address.", | ||||
| @@ -27,7 +29,7 @@ | ||||
|   "fieldCannotBeEmpty": "This field cannot be empty.", | ||||
|   "fieldEmailAddressMustBeValid": "The email address must be valid.", | ||||
|   "logout": "Logout", | ||||
|   "updateYourProfile": "Edit Profile", | ||||
|   "updateYourProfile": "Profile Settings", | ||||
|   "accountBasicInfo": "Basic Info", | ||||
|   "accountProfile": "Your Profile", | ||||
|   "saveChanges": "Save Changes", | ||||
| @@ -113,7 +115,8 @@ | ||||
|   "addVideo": "Add video", | ||||
|   "addPhoto": "Add photo", | ||||
|   "addFile": "Add file", | ||||
|   "createDirectMessage": "New direct message", | ||||
|   "createDirectMessage": "Send new DM", | ||||
|   "gotoDirectMessage": "Go to DM", | ||||
|   "react": "React", | ||||
|   "reactions": { | ||||
|     "zero": "Reactions", | ||||
| @@ -126,6 +129,24 @@ | ||||
|   "connectionConnected": "Connected", | ||||
|   "connectionDisconnected": "Disconnected", | ||||
|   "connectionReconnecting": "Reconnecting", | ||||
|   "accountConnections": "Account Connections", | ||||
|   "accountConnectionsDescription": "Manage your external account connections", | ||||
|   "accountConnectionAdd": "Add Connection", | ||||
|   "accountConnectionDelete": "Delete Connection", | ||||
|   "accountConnectionDeleteHint": "Are you sure you want to delete this connection? This action cannot be undone.", | ||||
|   "accountConnectionsEmpty": "No connections found. Add a connection to get started.", | ||||
|   "accountConnectionProvider": "Provider", | ||||
|   "accountConnectionProviderHint": "Enter provider name", | ||||
|   "accountConnectionIdentifier": "Identifier", | ||||
|   "accountConnectionIdentifierHint": "Enter your identifier for this provider", | ||||
|   "accountConnectionDescription": "Add a connection to link your account with external services.", | ||||
|   "accountConnectionAddSuccess": "Connection added successfully.", | ||||
|   "accountConnectionAddError": "Unable to setup connection.", | ||||
|   "accountConnectionProviderApple": "Apple", | ||||
|   "accountConnectionProviderMicrosoft": "Microsoft", | ||||
|   "accountConnectionProviderGoogle": "Google", | ||||
|   "accountConnectionProviderGithub": "GitHub", | ||||
|   "accountConnectionProviderDiscord": "Discord", | ||||
|   "checkIn": "Check In", | ||||
|   "checkInNone": "Not checked-in yet", | ||||
|   "checkInNoneHint": "Get your fortune tips and daily rewards by checking in.", | ||||
| @@ -134,14 +155,11 @@ | ||||
|   "checkInResultLevel2": "A Normal Day", | ||||
|   "checkInResultLevel3": "Good Luck", | ||||
|   "checkInResultLevel4": "Best Luck", | ||||
|   "checkInResultLevelShort0": "Wrost", | ||||
|   "checkInResultLevelShort1": "Bad", | ||||
|   "checkInResultLevelShort2": "Normal", | ||||
|   "checkInResultLevelShort3": "Good", | ||||
|   "checkInResultLevelShort4": "Best", | ||||
|   "checkInActivityTitle": "{} checked in on {} and got a {}", | ||||
|   "eventCalander": "Event Calander", | ||||
|   "eventCalanderEmpty": "No events on that day.", | ||||
|   "fortuneGraph": "Fortune Trend", | ||||
|   "noFortuneData": "No fortune data available for this month.", | ||||
|   "creatorHub": "Creator Hub", | ||||
|   "creatorHubDescription": "Manage posts, analytics, and more.", | ||||
|   "developerPortal": "Developer Portal", | ||||
| @@ -233,6 +251,7 @@ | ||||
|   "creatorHubUnselectedHint": "Pick / create a publisher to get started.", | ||||
|   "relationships": "Relationships", | ||||
|   "addFriend": "Send a Friend Request", | ||||
|   "addFriendShort": "Add as Friend", | ||||
|   "addFriendHint": "Add a friend to your relationship list.", | ||||
|   "pendingRequest": "Pending", | ||||
|   "waitingRequest": "Waiting", | ||||
| @@ -277,11 +296,13 @@ | ||||
|   "posts": "Posts", | ||||
|   "settingsBackgroundImage": "Background Image", | ||||
|   "settingsBackgroundImageClear": "Clear Background Image", | ||||
|   "settingsBackgroundGenerateColor": "Generate color scheme from Bacground Image", | ||||
|   "messageNone": "No content to display", | ||||
|   "unreadMessages": { | ||||
|     "one": "{} unread message", | ||||
|     "other": "{} unread messages" | ||||
|   }, | ||||
|   "chatBreakNone": "None", | ||||
|   "settingsRealmCompactView": "Compact Realm View", | ||||
|   "settingsMixedFeed": "Mixed Feed", | ||||
|   "settingsAutoTranslate": "Auto Translate", | ||||
| @@ -315,7 +336,10 @@ | ||||
|   "accountSettingsHelpContent": "This page allows you to manage your account security, privacy, and other settings. If you need assistance, please contact support.", | ||||
|   "unauthorized": "Unauthorized", | ||||
|   "unauthorizedHint": "You're not signed in or session expired, please sign in again.", | ||||
|   "publisherVisitAccountPage": "Visit the profile of {}", | ||||
|   "publisherBelongsTo": "Belongs to {}", | ||||
|   "postContent": "Content", | ||||
|   "postSettings": "Settings", | ||||
|   "postPublisherUnselected": "Publisher Unspecified", | ||||
|   "postVisibility": "Visibility", | ||||
|   "postVisibilityPublic": "Public", | ||||
|   "postVisibilityFriends": "Friends Only", | ||||
| @@ -396,5 +420,39 @@ | ||||
|   "contactMethodPrimary": "Primary", | ||||
|   "contactMethodSetPrimary": "Set as Primary", | ||||
|   "contactMethodSetPrimaryHint": "Set this contact method as your primary contact method for account recovery and notifications", | ||||
|   "contactMethodDeleteHint": "Are you sure to delete this contact method? This action cannot be undone." | ||||
|   "contactMethodDeleteHint": "Are you sure to delete this contact method? This action cannot be undone.", | ||||
|   "chatNotifyLevel": "Notify Level", | ||||
|   "chatNotifyLevelDescription": "Decide how many notifications you will receive.", | ||||
|   "chatNotifyLevelAll": "All", | ||||
|   "chatNotifyLevelMention": "Mentions", | ||||
|   "chatNotifyLevelNone": "None", | ||||
|   "chatNotifyLevelUpdated": "The notify level has been updated to {}.", | ||||
|   "chatBreak": "Take a Break", | ||||
|   "chatBreakDescription": "Set a time, before that time, your notification level will be metions only, to take a break of the current topic they're talking about.", | ||||
|   "chatBreakClear": "Clear the break time", | ||||
|   "chatBreakHour": "{} break", | ||||
|   "chatBreakDay": "{} day break", | ||||
|   "chatBreakSet": "Break set for {}", | ||||
|   "chatBreakCleared": "Chat break has been cleared.", | ||||
|   "chatBreakCustom": "Custom duration", | ||||
|   "chatBreakEnterMinutes": "Enter minutes", | ||||
|   "firstName": "First Name", | ||||
|   "middleName": "Middle Name", | ||||
|   "lastName": "Last Name", | ||||
|   "gender": "Gender", | ||||
|   "pronouns": "Pronouns", | ||||
|   "location": "Location", | ||||
|   "timeZone": "Time Zone", | ||||
|   "birthday": "Birthday", | ||||
|   "selectADate": "Select a date", | ||||
|   "checkInResultT0": "Worst", | ||||
|   "checkInResultT1": "Poor", | ||||
|   "checkInResultT2": "Mid", | ||||
|   "checkInResultT3": "Good", | ||||
|   "checkInResultT4": "Best", | ||||
|   "accountProfileView": "View Profile", | ||||
|   "unspecified": "Unspecified", | ||||
|   "added": "Added", | ||||
|   "preview": "Preview", | ||||
|   "togglePreview": "Toggle Preview" | ||||
| } | ||||
|   | ||||
| @@ -291,5 +291,26 @@ | ||||
|   "postVisibilityPublic": "公开", | ||||
|   "postVisibilityFriends": "仅好友可见", | ||||
|   "postVisibilityUnlisted": "不公开", | ||||
|   "postVisibilityPrivate": "私密" | ||||
|   "postVisibilityPrivate": "私密", | ||||
|   "chatNotifyLevel": "通知级别", | ||||
|   "chatNotifyLevelDescription": "决定您将收到多少通知。", | ||||
|   "chatNotifyLevelAll": "全部", | ||||
|   "chatNotifyLevelMention": "提及", | ||||
|   "chatNotifyLevelNone": "无", | ||||
|   "chatNotifyLevelUpdated": "通知级别已更新为 {}。", | ||||
|   "chatBreak": "暂停聊天", | ||||
|   "chatBreakDescription": "设置一个时间,在该时间之前,您的通知级别将仅为提及,以暂时休息当前讨论的话题。", | ||||
|   "chatBreakClear": "清除暂停时间", | ||||
|   "chatBreakHour": "暂停 {} 分钟", | ||||
|   "chatBreakDay": "暂停 {} 天", | ||||
|   "chatBreakSet": "已设置暂停 {}", | ||||
|   "chatBreakCleared": "聊天暂停已清除。", | ||||
|   "chatBreakCustom": "自定义时长", | ||||
|   "chatBreakEnterMinutes": "输入分钟数", | ||||
|   "chatBreakNone": "无", | ||||
|   "checkInResultT0": "大凶", | ||||
|   "checkInResultT1": "凶", | ||||
|   "checkInResultT2": "中平", | ||||
|   "checkInResultT3": "吉", | ||||
|   "checkInResultT4": "大吉" | ||||
| } | ||||
| @@ -68,7 +68,7 @@ | ||||
|   "createRealmHint": "結識志同道合的朋友、建立社群等等。", | ||||
|   "editRealm": "編輯領域", | ||||
|   "deleteRealm": "刪除領域", | ||||
|   "deleteRealmHint": "確定要刪除此領域嗎?這也將刪除此領域下的所有頻道、發佈者和貼文。", | ||||
|   "deleteRealmHint": "確定要刪除此領域嗎?這也將刪除該領域下的所有頻道、發佈者和貼文。", | ||||
|   "explore": "探索", | ||||
|   "account": "帳號", | ||||
|   "name": "名稱", | ||||
| @@ -291,5 +291,21 @@ | ||||
|   "postVisibilityPublic": "公開", | ||||
|   "postVisibilityFriends": "僅好友可見", | ||||
|   "postVisibilityUnlisted": "不公開", | ||||
|   "postVisibilityPrivate": "私密" | ||||
|   "postVisibilityPrivate": "私密", | ||||
|   "chatNotifyLevel": "通知等級", | ||||
|   "chatNotifyLevelDescription": "決定您將收到多少通知。", | ||||
|   "chatNotifyLevelAll": "全部", | ||||
|   "chatNotifyLevelMention": "提及", | ||||
|   "chatNotifyLevelNone": "無", | ||||
|   "chatNotifyLevelUpdated": "通知等級已更新為 {}。", | ||||
|   "chatBreak": "暫停聊天", | ||||
|   "chatBreakDescription": "設定一個時間,在該時間之前,您的通知等級將僅為提及,以暫時休息當前討論的話題。", | ||||
|   "chatBreakClear": "清除暫停時間", | ||||
|   "chatBreakHour": "暫停 {} 分鐘", | ||||
|   "chatBreakDay": "暫停 {} 天", | ||||
|   "chatBreakSet": "已設定暫停 {}", | ||||
|   "chatBreakCleared": "聊天暫停已清除。", | ||||
|   "chatBreakCustom": "自訂時長", | ||||
|   "chatBreakEnterMinutes": "輸入分鐘數", | ||||
|   "chatBreakNone": "無" | ||||
| } | ||||
							
								
								
									
										3
									
								
								assets/images/oidc/apple.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								assets/images/oidc/apple.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="814" height="1000"> | ||||
|   <path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 660 B | 
							
								
								
									
										1
									
								
								assets/images/oidc/discord.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								assets/images/oidc/discord.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?><svg id="Discord-Logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.644 96"><path id="Discord-Symbol-Black" d="M81.15,0c-1.2376,2.1973-2.3489,4.4704-3.3591,6.794-9.5975-1.4396-19.3718-1.4396-28.9945,0-.985-2.3236-2.1216-4.5967-3.3591-6.794-9.0166,1.5407-17.8059,4.2431-26.1405,8.0568C2.779,32.5304-1.6914,56.3725.5312,79.8863c9.6732,7.1476,20.5083,12.603,32.0505,16.0884,2.6014-3.4854,4.8998-7.1981,6.8698-11.0623-3.738-1.3891-7.3497-3.1318-10.8098-5.1523.9092-.6567,1.7932-1.3386,2.6519-1.9953,20.281,9.547,43.7696,9.547,64.0758,0,.8587.7072,1.7427,1.3891,2.6519,1.9953-3.4601,2.0457-7.0718,3.7632-10.835,5.1776,1.97,3.8642,4.2683,7.5769,6.8698,11.0623,11.5419-3.4854,22.3769-8.9156,32.0509-16.0631,2.626-27.2771-4.496-50.9172-18.817-71.8548C98.9811,4.2684,90.1918,1.5659,81.1752.0505l-.0252-.0505ZM42.2802,65.4144c-6.2383,0-11.4159-5.6575-11.4159-12.6535s4.9755-12.6788,11.3907-12.6788,11.5169,5.708,11.4159,12.6788c-.101,6.9708-5.026,12.6535-11.3907,12.6535ZM84.3576,65.4144c-6.2637,0-11.3907-5.6575-11.3907-12.6535s4.9755-12.6788,11.3907-12.6788,11.4917,5.708,11.3906,12.6788c-.101,6.9708-5.026,12.6535-11.3906,12.6535Z"/></svg> | ||||
| After Width: | Height: | Size: 1.1 KiB | 
							
								
								
									
										1
									
								
								assets/images/oidc/github.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								assets/images/oidc/github.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| <svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg> | ||||
| After Width: | Height: | Size: 963 B | 
							
								
								
									
										104
									
								
								assets/images/oidc/google.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								assets/images/oidc/google.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,104 @@ | ||||
| <svg version="1.1" viewBox="0 0 268.1522 273.8827" overflow="hidden" xml:space="preserve" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> | ||||
|   <defs> | ||||
|     <linearGradient id="a"> | ||||
|       <stop offset="0" stop-color="#0fbc5c"/> | ||||
|       <stop offset="1" stop-color="#0cba65"/> | ||||
|     </linearGradient> | ||||
|     <linearGradient id="g"> | ||||
|       <stop offset=".2312727" stop-color="#0fbc5f"/> | ||||
|       <stop offset=".3115468" stop-color="#0fbc5f"/> | ||||
|       <stop offset=".3660131" stop-color="#0fbc5e"/> | ||||
|       <stop offset=".4575163" stop-color="#0fbc5d"/> | ||||
|       <stop offset=".540305" stop-color="#12bc58"/> | ||||
|       <stop offset=".6993464" stop-color="#28bf3c"/> | ||||
|       <stop offset=".7712418" stop-color="#38c02b"/> | ||||
|       <stop offset=".8605665" stop-color="#52c218"/> | ||||
|       <stop offset=".9150327" stop-color="#67c30f"/> | ||||
|       <stop offset="1" stop-color="#86c504"/> | ||||
|     </linearGradient> | ||||
|     <linearGradient id="h"> | ||||
|       <stop offset=".1416122" stop-color="#1abd4d"/> | ||||
|       <stop offset=".2475151" stop-color="#6ec30d"/> | ||||
|       <stop offset=".3115468" stop-color="#8ac502"/> | ||||
|       <stop offset=".3660131" stop-color="#a2c600"/> | ||||
|       <stop offset=".4456735" stop-color="#c8c903"/> | ||||
|       <stop offset=".540305" stop-color="#ebcb03"/> | ||||
|       <stop offset=".6156363" stop-color="#f7cd07"/> | ||||
|       <stop offset=".6993454" stop-color="#fdcd04"/> | ||||
|       <stop offset=".7712418" stop-color="#fdce05"/> | ||||
|       <stop offset=".8605661" stop-color="#ffce0a"/> | ||||
|     </linearGradient> | ||||
|     <linearGradient id="f"> | ||||
|       <stop offset=".3159041" stop-color="#ff4c3c"/> | ||||
|       <stop offset=".6038179" stop-color="#ff692c"/> | ||||
|       <stop offset=".7268366" stop-color="#ff7825"/> | ||||
|       <stop offset=".884534" stop-color="#ff8d1b"/> | ||||
|       <stop offset="1" stop-color="#ff9f13"/> | ||||
|     </linearGradient> | ||||
|     <linearGradient id="b"> | ||||
|       <stop offset=".2312727" stop-color="#ff4541"/> | ||||
|       <stop offset=".3115468" stop-color="#ff4540"/> | ||||
|       <stop offset=".4575163" stop-color="#ff4640"/> | ||||
|       <stop offset=".540305" stop-color="#ff473f"/> | ||||
|       <stop offset=".6993464" stop-color="#ff5138"/> | ||||
|       <stop offset=".7712418" stop-color="#ff5b33"/> | ||||
|       <stop offset=".8605665" stop-color="#ff6c29"/> | ||||
|       <stop offset="1" stop-color="#ff8c18"/> | ||||
|     </linearGradient> | ||||
|     <linearGradient id="d"> | ||||
|       <stop offset=".4084578" stop-color="#fb4e5a"/> | ||||
|       <stop offset="1" stop-color="#ff4540"/> | ||||
|     </linearGradient> | ||||
|     <linearGradient id="c"> | ||||
|       <stop offset=".1315461" stop-color="#0cba65"/> | ||||
|       <stop offset=".2097843" stop-color="#0bb86d"/> | ||||
|       <stop offset=".2972969" stop-color="#09b479"/> | ||||
|       <stop offset=".3962575" stop-color="#08ad93"/> | ||||
|       <stop offset=".4771242" stop-color="#0aa6a9"/> | ||||
|       <stop offset=".5684245" stop-color="#0d9cc6"/> | ||||
|       <stop offset=".667385" stop-color="#1893dd"/> | ||||
|       <stop offset=".7687273" stop-color="#258bf1"/> | ||||
|       <stop offset=".8585063" stop-color="#3086ff"/> | ||||
|     </linearGradient> | ||||
|     <linearGradient id="e"> | ||||
|       <stop offset=".3660131" stop-color="#ff4e3a"/> | ||||
|       <stop offset=".4575163" stop-color="#ff8a1b"/> | ||||
|       <stop offset=".540305" stop-color="#ffa312"/> | ||||
|       <stop offset=".6156363" stop-color="#ffb60c"/> | ||||
|       <stop offset=".7712418" stop-color="#ffcd0a"/> | ||||
|       <stop offset=".8605665" stop-color="#fecf0a"/> | ||||
|       <stop offset=".9150327" stop-color="#fecf08"/> | ||||
|       <stop offset="1" stop-color="#fdcd01"/> | ||||
|     </linearGradient> | ||||
|     <linearGradient xlink:href="#a" id="s" x1="219.6997" y1="329.5351" x2="254.4673" y2="329.5351" gradientUnits="userSpaceOnUse"/> | ||||
|     <radialGradient xlink:href="#b" id="m" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-1.936885,1.043001,1.455731,2.555422,290.5254,-400.6338)" cx="109.6267" cy="135.8619" fx="109.6267" fy="135.8619" r="71.46001"/> | ||||
|     <radialGradient xlink:href="#c" id="n" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.512595,-4.45809,-1.692547,1.260616,870.8006,191.554)" cx="45.25866" cy="279.2738" fx="45.25866" fy="279.2738" r="71.46001"/> | ||||
|     <radialGradient xlink:href="#d" id="l" cx="304.0166" cy="118.0089" fx="304.0166" fy="118.0089" r="47.85445" gradientTransform="matrix(2.064353,-4.926832e-6,-2.901531e-6,2.592041,-297.6788,-151.7469)" gradientUnits="userSpaceOnUse"/> | ||||
|     <radialGradient xlink:href="#e" id="o" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-0.2485783,2.083138,2.962486,0.3341668,-255.1463,-331.1636)" cx="181.001" cy="177.2013" fx="181.001" fy="177.2013" r="71.46001"/> | ||||
|     <radialGradient xlink:href="#f" id="p" cx="207.6733" cy="108.0972" fx="207.6733" fy="108.0972" r="41.1025" gradientTransform="matrix(-1.249206,1.343263,-3.896837,-3.425693,880.5011,194.9051)" gradientUnits="userSpaceOnUse"/> | ||||
|     <radialGradient xlink:href="#g" id="r" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-1.936885,-1.043001,1.455731,-2.555422,290.5254,838.6834)" cx="109.6267" cy="135.8619" fx="109.6267" fy="135.8619" r="71.46001"/> | ||||
|     <radialGradient xlink:href="#h" id="j" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-0.081402,-1.93722,2.926737,-0.1162508,-215.1345,632.8606)" cx="154.8697" cy="145.9691" fx="154.8697" fy="145.9691" r="71.46001"/> | ||||
|     <filter id="q" x="-.04842873" y="-.0582241" width="1.096857" height="1.116448" color-interpolation-filters="sRGB"> | ||||
|       <feGaussianBlur stdDeviation="1.700914"/> | ||||
|     </filter> | ||||
|     <filter id="k" x="-.01670084" y="-.01009856" width="1.033402" height="1.020197" color-interpolation-filters="sRGB"> | ||||
|       <feGaussianBlur stdDeviation=".2419367"/> | ||||
|     </filter> | ||||
|     <clipPath clipPathUnits="userSpaceOnUse" id="i"> | ||||
|       <path d="M371.3784 193.2406H237.0825v53.4375h77.167c-1.2405 7.5627-4.0259 15.0024-8.1049 21.7862-4.6734 7.7723-10.4511 13.6895-16.373 18.1957-17.7389 13.4983-38.42 16.2584-52.7828 16.2584-36.2824 0-67.2833-23.2865-79.2844-54.9287-.4843-1.1482-.8059-2.3344-1.1975-3.5068-2.652-8.0533-4.101-16.5825-4.101-25.4474 0-9.226 1.5691-18.0575 4.4301-26.3985 11.2851-32.8967 42.9849-57.4674 80.1789-57.4674 7.4811 0 14.6854.8843 21.5173 2.6481 15.6135 4.0309 26.6578 11.9698 33.4252 18.2494l40.834-39.7111c-24.839-22.616-57.2194-36.3201-95.8444-36.3201-30.8782-.00066-59.3863 9.55308-82.7477 25.6992-18.9454 13.0941-34.4833 30.6254-44.9695 50.9861-9.75366 18.8785-15.09441 39.7994-15.09441 62.2934 0 22.495 5.34891 43.6334 15.10261 62.3374v.126c10.3023 19.8567 25.3678 36.9537 43.6783 49.9878 15.9962 11.3866 44.6789 26.5516 84.0307 26.5516 22.6301 0 42.6867-4.0517 60.3748-11.6447 12.76-5.4775 24.0655-12.6217 34.3012-21.8036 13.5247-12.1323 24.1168-27.1388 31.3465-44.4041 7.2297-17.2654 11.097-36.7895 11.097-57.957 0-9.858-.9971-19.8694-2.6881-28.9684Z" fill="#000"/> | ||||
|     </clipPath> | ||||
|   </defs> | ||||
|   <g transform="matrix(0.957922,0,0,0.985255,-90.17436,-78.85577)"> | ||||
|     <g clip-path="url(#i)"> | ||||
|       <path d="M92.07563 219.9585c.14844 22.14 6.5014 44.983 16.11767 63.4234v.1269c6.9482 13.3919 16.4444 23.9704 27.2604 34.4518l65.326-23.67c-12.3593-6.2344-14.2452-10.0546-23.1048-17.0253-9.0537-9.0658-15.8015-19.4735-20.0038-31.677h-.1693l.1693-.1269c-2.7646-8.0587-3.0373-16.6129-3.1393-25.5029Z" fill="url(#j)" filter="url(#k)"/> | ||||
|       <path d="M237.0835 79.02491c-6.4568 22.52569-3.988 44.42139 0 57.16129 7.4561.0055 14.6388.8881 21.4494 2.6464 15.6135 4.0309 26.6566 11.97 33.424 18.2496l41.8794-40.7256c-24.8094-22.58904-54.6663-37.2961-96.7528-37.33169Z" fill="url(#l)" filter="url(#k)"/> | ||||
|       <path d="M236.9434 78.84678c-31.6709-.00068-60.9107 9.79833-84.8718 26.35902-8.8968 6.149-17.0612 13.2521-24.3311 21.1509-1.9045 17.7429 14.2569 39.5507 46.2615 39.3702 15.5284-17.9373 38.4946-29.5427 64.0561-29.5427.0233 0 .046.0019.0693.002l-1.0439-57.33536c-.0472-.00003-.0929-.00406-.1401-.00406Z" fill="url(#m)" filter="url(#k)"/> | ||||
|       <path d="m341.4751 226.3788-28.2685 19.2848c-1.2405 7.5627-4.0278 15.0023-8.1068 21.7861-4.6734 7.7723-10.4506 13.6898-16.3725 18.196-17.7022 13.4704-38.3286 16.2439-52.6877 16.2553-14.8415 25.1018-17.4435 37.6749 1.0439 57.9342 22.8762-.0167 43.157-4.1174 61.0458-11.7965 12.9312-5.551 24.3879-12.7913 34.7609-22.0964 13.7061-12.295 24.4421-27.5034 31.7688-45.0003 7.3267-17.497 11.2446-37.2822 11.2446-58.7336Z" fill="url(#n)" filter="url(#k)"/> | ||||
|       <path d="M234.9956 191.2104v57.4981h136.0062c1.1962-7.8745 5.1523-18.0644 5.1523-26.5001 0-9.858-.9963-21.899-2.6873-30.998Z" fill="#3086ff" filter="url(#k)"/> | ||||
|       <path d="M128.3894 124.3268c-8.393 9.1191-15.5632 19.326-21.2483 30.3646-9.75351 18.8785-15.09402 41.8295-15.09402 64.3235 0 .317.02642.6271.02855.9436 4.31953 8.2244 59.66647 6.6495 62.45617 0-.0035-.3103-.0387-.6128-.0387-.9238 0-9.226 1.5696-16.0262 4.4306-24.3672 3.5294-10.2885 9.0557-19.7628 16.1223-27.9257 1.6019-2.0309 5.8748-6.3969 7.1214-9.0157.4749-.9975-.8621-1.5574-.9369-1.9085-.0836-.3927-1.8762-.0769-2.2778-.3694-1.2751-.9288-3.8001-1.4138-5.3334-1.8449-3.2772-.9215-8.7085-2.9536-11.7252-5.0601-9.5357-6.6586-24.417-14.6122-33.5047-24.2164Z" fill="url(#o)" filter="url(#k)"/> | ||||
|       <path d="M162.0989 155.8569c22.1123 13.3013 28.4714-6.7139 43.173-12.9771L179.698 90.21568c-9.4075 3.92642-18.2957 8.80465-26.5426 14.50442-12.316 8.5122-23.192 18.8995-32.1763 30.7204Z" fill="url(#p)" filter="url(#q)"/> | ||||
|       <path d="M171.0987 290.222c-29.6829 10.6413-34.3299 11.023-37.0622 29.2903 5.2213 5.0597 10.8312 9.74 16.7926 13.9835 15.9962 11.3867 46.766 26.5517 86.1178 26.5517.0462 0 .0904-.004.1366-.004v-59.1574c-.0298.0001-.064.002-.0938.002-14.7359 0-26.5113-3.8435-38.5848-10.5273-2.9768-1.6479-8.3775 2.7772-11.1229.799-3.7865-2.7284-12.8991 2.3508-16.1833-.9378Z" fill="url(#r)" filter="url(#k)"/> | ||||
|       <path d="M219.6997 299.0227v59.9959c5.506.6402 11.2361 1.0289 17.2472 1.0289 6.0259 0 11.8556-.3073 17.5204-.8723v-59.7481c-6.3482 1.0777-12.3272 1.461-17.4776 1.461-5.9318 0-11.7005-.6858-17.29-1.8654Z" opacity=".5" fill="url(#s)" filter="url(#k)"/> | ||||
|     </g> | ||||
|   </g> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 9.6 KiB | 
							
								
								
									
										1
									
								
								assets/images/oidc/microsoft.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								assets/images/oidc/microsoft.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 21"><path fill="#f35325" d="M0 0h10v10H0z"/><path fill="#81bc06" d="M11 0h10v10H11z"/><path fill="#05a6f0" d="M0 11h10v10H0z"/><path fill="#ffba08" d="M11 11h10v10H11z"/></svg> | ||||
| After Width: | Height: | Size: 232 B | 
							
								
								
									
										121
									
								
								ios/Podfile.lock
									
									
									
									
									
								
							
							
						
						
									
										121
									
								
								ios/Podfile.lock
									
									
									
									
									
								
							| @@ -40,37 +40,37 @@ PODS: | ||||
|   - file_picker (0.0.1): | ||||
|     - DKImagePickerController/PhotoGallery | ||||
|     - Flutter | ||||
|   - Firebase/CoreOnly (11.10.0): | ||||
|     - FirebaseCore (~> 11.10.0) | ||||
|   - Firebase/Messaging (11.10.0): | ||||
|   - Firebase/CoreOnly (11.13.0): | ||||
|     - FirebaseCore (~> 11.13.0) | ||||
|   - Firebase/Messaging (11.13.0): | ||||
|     - Firebase/CoreOnly | ||||
|     - FirebaseMessaging (~> 11.10.0) | ||||
|   - firebase_core (3.13.1): | ||||
|     - Firebase/CoreOnly (= 11.10.0) | ||||
|     - FirebaseMessaging (~> 11.13.0) | ||||
|   - firebase_core (3.14.0): | ||||
|     - Firebase/CoreOnly (= 11.13.0) | ||||
|     - Flutter | ||||
|   - firebase_messaging (15.2.6): | ||||
|     - Firebase/Messaging (= 11.10.0) | ||||
|   - firebase_messaging (15.2.7): | ||||
|     - Firebase/Messaging (= 11.13.0) | ||||
|     - firebase_core | ||||
|     - Flutter | ||||
|   - FirebaseCore (11.10.0): | ||||
|     - FirebaseCoreInternal (~> 11.10.0) | ||||
|     - GoogleUtilities/Environment (~> 8.0) | ||||
|     - GoogleUtilities/Logger (~> 8.0) | ||||
|   - FirebaseCoreInternal (11.10.0): | ||||
|     - "GoogleUtilities/NSData+zlib (~> 8.0)" | ||||
|   - FirebaseInstallations (11.10.0): | ||||
|     - FirebaseCore (~> 11.10.0) | ||||
|     - GoogleUtilities/Environment (~> 8.0) | ||||
|     - GoogleUtilities/UserDefaults (~> 8.0) | ||||
|   - FirebaseCore (11.13.0): | ||||
|     - FirebaseCoreInternal (~> 11.13.0) | ||||
|     - GoogleUtilities/Environment (~> 8.1) | ||||
|     - GoogleUtilities/Logger (~> 8.1) | ||||
|   - FirebaseCoreInternal (11.13.0): | ||||
|     - "GoogleUtilities/NSData+zlib (~> 8.1)" | ||||
|   - FirebaseInstallations (11.13.0): | ||||
|     - FirebaseCore (~> 11.13.0) | ||||
|     - GoogleUtilities/Environment (~> 8.1) | ||||
|     - GoogleUtilities/UserDefaults (~> 8.1) | ||||
|     - PromisesObjC (~> 2.4) | ||||
|   - FirebaseMessaging (11.10.0): | ||||
|     - FirebaseCore (~> 11.10.0) | ||||
|   - FirebaseMessaging (11.13.0): | ||||
|     - FirebaseCore (~> 11.13.0) | ||||
|     - FirebaseInstallations (~> 11.0) | ||||
|     - GoogleDataTransport (~> 10.0) | ||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.0) | ||||
|     - GoogleUtilities/Environment (~> 8.0) | ||||
|     - GoogleUtilities/Reachability (~> 8.0) | ||||
|     - GoogleUtilities/UserDefaults (~> 8.0) | ||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.1) | ||||
|     - GoogleUtilities/Environment (~> 8.1) | ||||
|     - GoogleUtilities/Reachability (~> 8.1) | ||||
|     - GoogleUtilities/UserDefaults (~> 8.1) | ||||
|     - nanopb (~> 3.30910.0) | ||||
|   - Flutter (1.0.0) | ||||
|   - flutter_inappwebview_ios (0.0.1): | ||||
| @@ -84,6 +84,8 @@ PODS: | ||||
|     - Flutter | ||||
|   - flutter_platform_alert (0.0.1): | ||||
|     - Flutter | ||||
|   - flutter_timezone (0.0.1): | ||||
|     - Flutter | ||||
|   - flutter_udid (0.0.1): | ||||
|     - Flutter | ||||
|     - SAMKeychain | ||||
| @@ -92,6 +94,7 @@ PODS: | ||||
|     - WebRTC-SDK (= 125.6422.07) | ||||
|   - gal (1.0.0): | ||||
|     - Flutter | ||||
|     - FlutterMacOS | ||||
|   - GoogleDataTransport (10.1.0): | ||||
|     - nanopb (~> 3.30910.0) | ||||
|     - PromisesObjC (~> 2.4) | ||||
| @@ -124,7 +127,7 @@ PODS: | ||||
|   - irondash_engine_context (0.0.1): | ||||
|     - Flutter | ||||
|   - Kingfisher (8.3.2) | ||||
|   - livekit_client (2.4.7): | ||||
|   - livekit_client (2.4.8): | ||||
|     - Flutter | ||||
|     - flutter_webrtc | ||||
|     - WebRTC-SDK (= 125.6422.07) | ||||
| @@ -137,6 +140,8 @@ PODS: | ||||
|     - nanopb/encode (= 3.30910.0) | ||||
|   - nanopb/decode (3.30910.0) | ||||
|   - nanopb/encode (3.30910.0) | ||||
|   - native_exif (0.0.1): | ||||
|     - Flutter | ||||
|   - OrderedSet (6.0.3) | ||||
|   - package_info_plus (0.4.5): | ||||
|     - Flutter | ||||
| @@ -149,32 +154,34 @@ PODS: | ||||
|   - record_ios (1.0.0): | ||||
|     - Flutter | ||||
|   - SAMKeychain (1.5.3) | ||||
|   - SDWebImage (5.21.0): | ||||
|     - SDWebImage/Core (= 5.21.0) | ||||
|   - SDWebImage/Core (5.21.0) | ||||
|   - SDWebImage (5.21.1): | ||||
|     - SDWebImage/Core (= 5.21.1) | ||||
|   - SDWebImage/Core (5.21.1) | ||||
|   - shared_preferences_foundation (0.0.1): | ||||
|     - Flutter | ||||
|     - FlutterMacOS | ||||
|   - sign_in_with_apple (0.0.1): | ||||
|     - Flutter | ||||
|   - sqflite_darwin (0.0.4): | ||||
|     - Flutter | ||||
|     - FlutterMacOS | ||||
|   - sqlite3 (3.49.2): | ||||
|     - sqlite3/common (= 3.49.2) | ||||
|   - sqlite3/common (3.49.2) | ||||
|   - sqlite3/dbstatvtab (3.49.2): | ||||
|   - sqlite3 (3.50.1): | ||||
|     - sqlite3/common (= 3.50.1) | ||||
|   - sqlite3/common (3.50.1) | ||||
|   - sqlite3/dbstatvtab (3.50.1): | ||||
|     - sqlite3/common | ||||
|   - sqlite3/fts5 (3.49.2): | ||||
|   - sqlite3/fts5 (3.50.1): | ||||
|     - sqlite3/common | ||||
|   - sqlite3/math (3.49.2): | ||||
|   - sqlite3/math (3.50.1): | ||||
|     - sqlite3/common | ||||
|   - sqlite3/perf-threadsafe (3.49.2): | ||||
|   - sqlite3/perf-threadsafe (3.50.1): | ||||
|     - sqlite3/common | ||||
|   - sqlite3/rtree (3.49.2): | ||||
|   - sqlite3/rtree (3.50.1): | ||||
|     - sqlite3/common | ||||
|   - sqlite3_flutter_libs (0.0.1): | ||||
|     - Flutter | ||||
|     - FlutterMacOS | ||||
|     - sqlite3 (~> 3.49.2) | ||||
|     - sqlite3 (~> 3.50.1) | ||||
|     - sqlite3/dbstatvtab | ||||
|     - sqlite3/fts5 | ||||
|     - sqlite3/math | ||||
| @@ -203,20 +210,23 @@ DEPENDENCIES: | ||||
|   - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) | ||||
|   - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) | ||||
|   - flutter_platform_alert (from `.symlinks/plugins/flutter_platform_alert/ios`) | ||||
|   - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`) | ||||
|   - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) | ||||
|   - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) | ||||
|   - gal (from `.symlinks/plugins/gal/ios`) | ||||
|   - gal (from `.symlinks/plugins/gal/darwin`) | ||||
|   - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) | ||||
|   - irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`) | ||||
|   - Kingfisher (~> 8.0) | ||||
|   - livekit_client (from `.symlinks/plugins/livekit_client/ios`) | ||||
|   - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) | ||||
|   - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) | ||||
|   - native_exif (from `.symlinks/plugins/native_exif/ios`) | ||||
|   - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) | ||||
|   - pasteboard (from `.symlinks/plugins/pasteboard/ios`) | ||||
|   - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) | ||||
|   - record_ios (from `.symlinks/plugins/record_ios/ios`) | ||||
|   - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) | ||||
|   - sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`) | ||||
|   - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) | ||||
|   - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) | ||||
|   - super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`) | ||||
| @@ -267,12 +277,14 @@ EXTERNAL SOURCES: | ||||
|     :path: ".symlinks/plugins/flutter_native_splash/ios" | ||||
|   flutter_platform_alert: | ||||
|     :path: ".symlinks/plugins/flutter_platform_alert/ios" | ||||
|   flutter_timezone: | ||||
|     :path: ".symlinks/plugins/flutter_timezone/ios" | ||||
|   flutter_udid: | ||||
|     :path: ".symlinks/plugins/flutter_udid/ios" | ||||
|   flutter_webrtc: | ||||
|     :path: ".symlinks/plugins/flutter_webrtc/ios" | ||||
|   gal: | ||||
|     :path: ".symlinks/plugins/gal/ios" | ||||
|     :path: ".symlinks/plugins/gal/darwin" | ||||
|   image_picker_ios: | ||||
|     :path: ".symlinks/plugins/image_picker_ios/ios" | ||||
|   irondash_engine_context: | ||||
| @@ -283,6 +295,8 @@ EXTERNAL SOURCES: | ||||
|     :path: ".symlinks/plugins/media_kit_libs_ios_video/ios" | ||||
|   media_kit_video: | ||||
|     :path: ".symlinks/plugins/media_kit_video/ios" | ||||
|   native_exif: | ||||
|     :path: ".symlinks/plugins/native_exif/ios" | ||||
|   package_info_plus: | ||||
|     :path: ".symlinks/plugins/package_info_plus/ios" | ||||
|   pasteboard: | ||||
| @@ -293,6 +307,8 @@ EXTERNAL SOURCES: | ||||
|     :path: ".symlinks/plugins/record_ios/ios" | ||||
|   shared_preferences_foundation: | ||||
|     :path: ".symlinks/plugins/shared_preferences_foundation/darwin" | ||||
|   sign_in_with_apple: | ||||
|     :path: ".symlinks/plugins/sign_in_with_apple/ios" | ||||
|   sqflite_darwin: | ||||
|     :path: ".symlinks/plugins/sqflite_darwin/darwin" | ||||
|   sqlite3_flutter_libs: | ||||
| @@ -314,29 +330,31 @@ SPEC CHECKSUMS: | ||||
|   DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c | ||||
|   DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 | ||||
|   file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be | ||||
|   Firebase: 1fe1c0a7d9aaea32efe01fbea5f0ebd8d70e53a2 | ||||
|   firebase_core: ba71b44041571da878cb624ce0d80250bcbe58ad | ||||
|   firebase_messaging: 13129fe2ca166d1ed2d095062d76cee88943d067 | ||||
|   FirebaseCore: 8344daef5e2661eb004b177488d6f9f0f24251b7 | ||||
|   FirebaseCoreInternal: ef4505d2afb1d0ebbc33162cb3795382904b5679 | ||||
|   FirebaseInstallations: 9980995bdd06ec8081dfb6ab364162bdd64245c3 | ||||
|   FirebaseMessaging: 2b9f56aa4ed286e1f0ce2ee1d413aabb8f9f5cb9 | ||||
|   Firebase: 3435bc66b4d494c2f22c79fd3aae4c1db6662327 | ||||
|   firebase_core: 700bac7ed92bb754fd70fbf01d72b36ecdd6d450 | ||||
|   firebase_messaging: 860c017fcfbb5e27c163062d1d3135388f3ef954 | ||||
|   FirebaseCore: c692c7f1c75305ab6aff2b367f25e11d73aa8bd0 | ||||
|   FirebaseCoreInternal: 29d7b3af4aaf0b8f3ed20b568c13df399b06f68c | ||||
|   FirebaseInstallations: 0ee9074f2c1e86561ace168ee1470dc67aabaf02 | ||||
|   FirebaseMessaging: 195bbdb73e6ca1dbc76cd46e73f3552c084ef6e4 | ||||
|   Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 | ||||
|   flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 | ||||
|   flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf | ||||
|   flutter_platform_alert: bf3b5fcd4ac14bd637e20527e9c471633071afd3 | ||||
|   flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 | ||||
|   flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9 | ||||
|   flutter_webrtc: fd0d3bdef8766a0736dbbe2e5b7e85f1f3c52117 | ||||
|   gal: 29e711cd17bccb47f839d9b30afe9bc9750950b2 | ||||
|   gal: baecd024ebfd13c441269ca7404792a7152fde89 | ||||
|   GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 | ||||
|   GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 | ||||
|   image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a | ||||
|   irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 | ||||
|   Kingfisher: 0621d0ac0c78fecb19f6dc5303bde2b52abaf2f5 | ||||
|   livekit_client: c30950bf36aa4c0244dd5551b1818cd15f90ba32 | ||||
|   livekit_client: 9e901890552514206e5ff828903ed271531da264 | ||||
|   media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 | ||||
|   media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 | ||||
|   nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 | ||||
|   native_exif: 0eb73d3d5b3ca892719228df8d2d1b13d1ae396c | ||||
|   OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 | ||||
|   package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 | ||||
|   pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c | ||||
| @@ -344,11 +362,12 @@ SPEC CHECKSUMS: | ||||
|   PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 | ||||
|   record_ios: fee1c924aa4879b882ebca2b4bce6011bcfc3d8b | ||||
|   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c | ||||
|   SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868 | ||||
|   SDWebImage: f29024626962457f3470184232766516dee8dfea | ||||
|   shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 | ||||
|   sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418 | ||||
|   sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 | ||||
|   sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 | ||||
|   sqlite3_flutter_libs: 74334e3ef2dbdb7d37e50859bb45da43935779c4 | ||||
|   sqlite3: 1d85290c3321153511f6e900ede7a1608718bbd5 | ||||
|   sqlite3_flutter_libs: e7fc8c9ea2200ff3271f08f127842131746b70e2 | ||||
|   super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4 | ||||
|   SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 | ||||
|   url_launcher_ios: 694010445543906933d732453a59da0a173ae33d | ||||
|   | ||||
| @@ -2,6 +2,14 @@ | ||||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||||
| <plist version="1.0"> | ||||
| <dict> | ||||
| 	<key>CLIENT_ID</key> | ||||
| 	<string>961776991058-stt7et4qvn3cpscl4r61gl1hnlatqkig.apps.googleusercontent.com</string> | ||||
| 	<key>REVERSED_CLIENT_ID</key> | ||||
| 	<string>com.googleusercontent.apps.961776991058-stt7et4qvn3cpscl4r61gl1hnlatqkig</string> | ||||
| 	<key>PLIST_VERSION</key> | ||||
| 	<string>1</string> | ||||
| 	<key>BUNDLE_ID</key> | ||||
| 	<string>dev.solsynth.solian</string> | ||||
| 	<key>ITSAppUsesNonExemptEncryption</key> | ||||
| 	<false/> | ||||
| 	<key>CADisableMinimumFrameDurationOnPhone</key> | ||||
|   | ||||
| @@ -4,6 +4,16 @@ | ||||
| <dict> | ||||
| 	<key>aps-environment</key> | ||||
| 	<string>development</string> | ||||
| 	<key>com.apple.developer.applesignin</key> | ||||
| 	<array> | ||||
| 		<string>Default</string> | ||||
| 	</array> | ||||
| 	<key>com.apple.developer.associated-domains</key> | ||||
| 	<array> | ||||
| 		<string>webcredentials:solian.app</string> | ||||
| 	</array> | ||||
| 	<key>com.apple.developer.device-information.user-assigned-device-name</key> | ||||
| 	<true/> | ||||
| 	<key>com.apple.developer.usernotifications.communication</key> | ||||
| 	<true/> | ||||
| </dict> | ||||
|   | ||||
| @@ -1,6 +1,3 @@ | ||||
| import 'dart:convert'; | ||||
| import 'dart:developer'; | ||||
|  | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:island/database/drift_db.dart'; | ||||
| import 'package:island/database/message.dart'; | ||||
| @@ -48,13 +45,6 @@ class MessageRepository { | ||||
|         }, | ||||
|       ); | ||||
|  | ||||
|       for (final item in resp.data['changes']) { | ||||
|         if (item['message']['sender']['account'] == null) | ||||
|           log(jsonEncode(item['message']['sender']['account'])); | ||||
|         // if (item['message'] != null && | ||||
|         //     item['message']['sender']['account'] == null) { | ||||
|         // } | ||||
|       } | ||||
|       final response = MessageSyncResponse.fromJson(resp.data); | ||||
|       for (final change in response.changes) { | ||||
|         switch (change.action) { | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import 'dart:io'; | ||||
| import 'package:croppy/croppy.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart' hide TextDirection; | ||||
| import 'package:firebase_core/firebase_core.dart'; | ||||
| import 'package:firebase_messaging/firebase_messaging.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| @@ -19,12 +20,14 @@ import 'package:island/pods/websocket.dart'; | ||||
| import 'package:island/route.dart'; | ||||
| import 'package:island/screens/auth/tabs.dart'; | ||||
| import 'package:island/services/notify.dart'; | ||||
| import 'package:island/services/timezone.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:relative_time/relative_time.dart'; | ||||
| import 'package:shared_preferences/shared_preferences.dart'; | ||||
| import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; | ||||
| import 'package:flutter_native_splash/flutter_native_splash.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| void main() async { | ||||
|   final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); | ||||
| @@ -45,6 +48,14 @@ void main() async { | ||||
|     showErrorAlert(err); | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     log("[SplashScreen] Loading timezone database..."); | ||||
|     await initializeTzdb(); | ||||
|     log("[SplashScreen] Time zone database was loaded!"); | ||||
|   } catch (err) { | ||||
|     log("[SplashScreen] Failed to load timezone database... $err"); | ||||
|   } | ||||
|  | ||||
|   final prefs = await SharedPreferences.getInstance(); | ||||
|  | ||||
|   if (!kIsWeb && (Platform.isMacOS || Platform.isLinux || Platform.isWindows)) { | ||||
| @@ -93,7 +104,7 @@ void main() async { | ||||
|   ); | ||||
| } | ||||
|  | ||||
| final _appRouter = AppRouter(); | ||||
| final appRouter = AppRouter(); | ||||
|  | ||||
| class IslandApp extends HookConsumerWidget { | ||||
|   const IslandApp({super.key}); | ||||
| @@ -102,6 +113,33 @@ class IslandApp extends HookConsumerWidget { | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final theme = ref.watch(themeProvider); | ||||
|  | ||||
|     void handleMessage(RemoteMessage notification) { | ||||
|       if (notification.data['action_uri'] != null) { | ||||
|         var uri = notification.data['action_uri'] as String; | ||||
|         if (uri.startsWith('/')) { | ||||
|           // In-app routes | ||||
|           appRouter.pushPath(notification.data['action_uri']); | ||||
|         } else { | ||||
|           // External links | ||||
|           launchUrlString(uri); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     useEffect(() { | ||||
|       Future(() async { | ||||
|         RemoteMessage? initialMessage = | ||||
|             await FirebaseMessaging.instance.getInitialMessage(); | ||||
|         if (initialMessage != null) { | ||||
|           handleMessage(initialMessage); | ||||
|         } | ||||
|  | ||||
|         FirebaseMessaging.onMessageOpenedApp.listen(handleMessage); | ||||
|       }); | ||||
|  | ||||
|       return null; | ||||
|     }, []); | ||||
|  | ||||
|     useEffect(() { | ||||
|       // Load userinfo | ||||
|       final userNotifier = ref.read(userInfoProvider.notifier); | ||||
| @@ -126,7 +164,7 @@ class IslandApp extends HookConsumerWidget { | ||||
|       theme: theme?.light, | ||||
|       darkTheme: theme?.dark, | ||||
|       themeMode: ThemeMode.system, | ||||
|       routerConfig: _appRouter.config( | ||||
|       routerConfig: appRouter.config( | ||||
|         navigatorObservers: | ||||
|             () => [ | ||||
|               TabNavigationObserver( | ||||
| @@ -149,9 +187,9 @@ class IslandApp extends HookConsumerWidget { | ||||
|             OverlayEntry( | ||||
|               builder: | ||||
|                   (_) => WindowScaffold( | ||||
|                     router: _appRouter, | ||||
|                     router: appRouter, | ||||
|                     child: TabsNavigationWidget( | ||||
|                       router: _appRouter, | ||||
|                       router: appRouter, | ||||
|                       child: child ?? const SizedBox.shrink(), | ||||
|                     ), | ||||
|                   ), | ||||
|   | ||||
| @@ -91,3 +91,21 @@ sealed class SnAuthDevice with _$SnAuthDevice { | ||||
|   factory SnAuthDevice.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnAuthDeviceFromJson(json); | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| sealed class SnAccountConnection with _$SnAccountConnection { | ||||
|   const factory SnAccountConnection({ | ||||
|     required String id, | ||||
|     required String accountId, | ||||
|     required String provider, | ||||
|     required String providedIdentifier, | ||||
|     @Default({}) Map<String, dynamic> meta, | ||||
|     required DateTime lastUsedAt, | ||||
|     required DateTime createdAt, | ||||
|     required DateTime updatedAt, | ||||
|     required DateTime? deletedAt, | ||||
|   }) = _SnAccountConnection; | ||||
|  | ||||
|   factory SnAccountConnection.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnAccountConnectionFromJson(json); | ||||
| } | ||||
|   | ||||
| @@ -847,6 +847,169 @@ as bool, | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$SnAccountConnection { | ||||
|  | ||||
|  String get id; String get accountId; String get provider; String get providedIdentifier; Map<String, dynamic> get meta; DateTime get lastUsedAt; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; | ||||
| /// Create a copy of SnAccountConnection | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnAccountConnectionCopyWith<SnAccountConnection> get copyWith => _$SnAccountConnectionCopyWithImpl<SnAccountConnection>(this as SnAccountConnection, _$identity); | ||||
|  | ||||
|   /// Serializes this SnAccountConnection to a JSON map. | ||||
|   Map<String, dynamic> toJson(); | ||||
|  | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAccountConnection&&(identical(other.id, id) || other.id == id)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.provider, provider) || other.provider == provider)&&(identical(other.providedIdentifier, providedIdentifier) || other.providedIdentifier == providedIdentifier)&&const DeepCollectionEquality().equals(other.meta, meta)&&(identical(other.lastUsedAt, lastUsedAt) || other.lastUsedAt == lastUsedAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,accountId,provider,providedIdentifier,const DeepCollectionEquality().hash(meta),lastUsedAt,createdAt,updatedAt,deletedAt); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnAccountConnection(id: $id, accountId: $accountId, provider: $provider, providedIdentifier: $providedIdentifier, meta: $meta, lastUsedAt: $lastUsedAt, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class $SnAccountConnectionCopyWith<$Res>  { | ||||
|   factory $SnAccountConnectionCopyWith(SnAccountConnection value, $Res Function(SnAccountConnection) _then) = _$SnAccountConnectionCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String id, String accountId, String provider, String providedIdentifier, Map<String, dynamic> meta, DateTime lastUsedAt, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class _$SnAccountConnectionCopyWithImpl<$Res> | ||||
|     implements $SnAccountConnectionCopyWith<$Res> { | ||||
|   _$SnAccountConnectionCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final SnAccountConnection _self; | ||||
|   final $Res Function(SnAccountConnection) _then; | ||||
|  | ||||
| /// Create a copy of SnAccountConnection | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? accountId = null,Object? provider = null,Object? providedIdentifier = null,Object? meta = null,Object? lastUsedAt = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
|   return _then(_self.copyWith( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as String,provider: null == provider ? _self.provider : provider // ignore: cast_nullable_to_non_nullable | ||||
| as String,providedIdentifier: null == providedIdentifier ? _self.providedIdentifier : providedIdentifier // ignore: cast_nullable_to_non_nullable | ||||
| as String,meta: null == meta ? _self.meta : meta // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>,lastUsedAt: null == lastUsedAt ? _self.lastUsedAt : lastUsedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SnAccountConnection implements SnAccountConnection { | ||||
|   const _SnAccountConnection({required this.id, required this.accountId, required this.provider, required this.providedIdentifier, final  Map<String, dynamic> meta = const {}, required this.lastUsedAt, required this.createdAt, required this.updatedAt, required this.deletedAt}): _meta = meta; | ||||
|   factory _SnAccountConnection.fromJson(Map<String, dynamic> json) => _$SnAccountConnectionFromJson(json); | ||||
|  | ||||
| @override final  String id; | ||||
| @override final  String accountId; | ||||
| @override final  String provider; | ||||
| @override final  String providedIdentifier; | ||||
|  final  Map<String, dynamic> _meta; | ||||
| @override@JsonKey() Map<String, dynamic> get meta { | ||||
|   if (_meta is EqualUnmodifiableMapView) return _meta; | ||||
|   // ignore: implicit_dynamic_type | ||||
|   return EqualUnmodifiableMapView(_meta); | ||||
| } | ||||
|  | ||||
| @override final  DateTime lastUsedAt; | ||||
| @override final  DateTime createdAt; | ||||
| @override final  DateTime updatedAt; | ||||
| @override final  DateTime? deletedAt; | ||||
|  | ||||
| /// Create a copy of SnAccountConnection | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| _$SnAccountConnectionCopyWith<_SnAccountConnection> get copyWith => __$SnAccountConnectionCopyWithImpl<_SnAccountConnection>(this, _$identity); | ||||
|  | ||||
| @override | ||||
| Map<String, dynamic> toJson() { | ||||
|   return _$SnAccountConnectionToJson(this, ); | ||||
| } | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAccountConnection&&(identical(other.id, id) || other.id == id)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.provider, provider) || other.provider == provider)&&(identical(other.providedIdentifier, providedIdentifier) || other.providedIdentifier == providedIdentifier)&&const DeepCollectionEquality().equals(other._meta, _meta)&&(identical(other.lastUsedAt, lastUsedAt) || other.lastUsedAt == lastUsedAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,accountId,provider,providedIdentifier,const DeepCollectionEquality().hash(_meta),lastUsedAt,createdAt,updatedAt,deletedAt); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnAccountConnection(id: $id, accountId: $accountId, provider: $provider, providedIdentifier: $providedIdentifier, meta: $meta, lastUsedAt: $lastUsedAt, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class _$SnAccountConnectionCopyWith<$Res> implements $SnAccountConnectionCopyWith<$Res> { | ||||
|   factory _$SnAccountConnectionCopyWith(_SnAccountConnection value, $Res Function(_SnAccountConnection) _then) = __$SnAccountConnectionCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String id, String accountId, String provider, String providedIdentifier, Map<String, dynamic> meta, DateTime lastUsedAt, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class __$SnAccountConnectionCopyWithImpl<$Res> | ||||
|     implements _$SnAccountConnectionCopyWith<$Res> { | ||||
|   __$SnAccountConnectionCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final _SnAccountConnection _self; | ||||
|   final $Res Function(_SnAccountConnection) _then; | ||||
|  | ||||
| /// Create a copy of SnAccountConnection | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? accountId = null,Object? provider = null,Object? providedIdentifier = null,Object? meta = null,Object? lastUsedAt = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
|   return _then(_SnAccountConnection( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as String,provider: null == provider ? _self.provider : provider // ignore: cast_nullable_to_non_nullable | ||||
| as String,providedIdentifier: null == providedIdentifier ? _self.providedIdentifier : providedIdentifier // ignore: cast_nullable_to_non_nullable | ||||
| as String,meta: null == meta ? _self._meta : meta // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>,lastUsedAt: null == lastUsedAt ? _self.lastUsedAt : lastUsedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?, | ||||
|   )); | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| // dart format on | ||||
|   | ||||
| @@ -155,3 +155,33 @@ Map<String, dynamic> _$SnAuthDeviceToJson(_SnAuthDevice instance) => | ||||
|       'sessions': instance.sessions.map((e) => e.toJson()).toList(), | ||||
|       'is_current': instance.isCurrent, | ||||
|     }; | ||||
|  | ||||
| _SnAccountConnection _$SnAccountConnectionFromJson(Map<String, dynamic> json) => | ||||
|     _SnAccountConnection( | ||||
|       id: json['id'] as String, | ||||
|       accountId: json['account_id'] as String, | ||||
|       provider: json['provider'] as String, | ||||
|       providedIdentifier: json['provided_identifier'] as String, | ||||
|       meta: json['meta'] as Map<String, dynamic>? ?? const {}, | ||||
|       lastUsedAt: DateTime.parse(json['last_used_at'] 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> _$SnAccountConnectionToJson( | ||||
|   _SnAccountConnection instance, | ||||
| ) => <String, dynamic>{ | ||||
|   'id': instance.id, | ||||
|   'account_id': instance.accountId, | ||||
|   'provider': instance.provider, | ||||
|   'provided_identifier': instance.providedIdentifier, | ||||
|   'meta': instance.meta, | ||||
|   'last_used_at': instance.lastUsedAt.toIso8601String(), | ||||
|   'created_at': instance.createdAt.toIso8601String(), | ||||
|   'updated_at': instance.updatedAt.toIso8601String(), | ||||
|   'deleted_at': instance.deletedAt?.toIso8601String(), | ||||
| }; | ||||
|   | ||||
| @@ -1,6 +1,3 @@ | ||||
| import 'dart:convert'; | ||||
| import 'dart:developer'; | ||||
|  | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:island/models/realm.dart'; | ||||
| @@ -90,7 +87,10 @@ sealed class SnChatMember with _$SnChatMember { | ||||
|     required int role, | ||||
|     required int notify, | ||||
|     required DateTime? joinedAt, | ||||
|     required DateTime? breakUntil, | ||||
|     required DateTime? timeoutUntil, | ||||
|     required bool isBot, | ||||
|     // Frontend data | ||||
|     DateTime? lastTyped, | ||||
|   }) = _SnChatMember; | ||||
|  | ||||
|   | ||||
| @@ -663,7 +663,8 @@ $SnChatMemberCopyWith<$Res> get sender { | ||||
| /// @nodoc | ||||
| mixin _$SnChatMember { | ||||
|  | ||||
|  DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; String get id; String get chatRoomId; SnChatRoom? get chatRoom; String get accountId; SnAccount get account; String? get nick; int get role; int get notify; DateTime? get joinedAt; bool get isBot; DateTime? get lastTyped; | ||||
|  DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; String get id; String get chatRoomId; SnChatRoom? get chatRoom; String get accountId; SnAccount get account; String? get nick; int get role; int get notify; DateTime? get joinedAt; DateTime? get breakUntil; DateTime? get timeoutUntil; bool get isBot;// Frontend data | ||||
|  DateTime? get lastTyped; | ||||
| /// Create a copy of SnChatMember | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -676,16 +677,16 @@ $SnChatMemberCopyWith<SnChatMember> get copyWith => _$SnChatMemberCopyWithImpl<S | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnChatMember&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.id, id) || other.id == id)&&(identical(other.chatRoomId, chatRoomId) || other.chatRoomId == chatRoomId)&&(identical(other.chatRoom, chatRoom) || other.chatRoom == chatRoom)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.role, role) || other.role == role)&&(identical(other.notify, notify) || other.notify == notify)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)&&(identical(other.isBot, isBot) || other.isBot == isBot)&&(identical(other.lastTyped, lastTyped) || other.lastTyped == lastTyped)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnChatMember&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.id, id) || other.id == id)&&(identical(other.chatRoomId, chatRoomId) || other.chatRoomId == chatRoomId)&&(identical(other.chatRoom, chatRoom) || other.chatRoom == chatRoom)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.role, role) || other.role == role)&&(identical(other.notify, notify) || other.notify == notify)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)&&(identical(other.breakUntil, breakUntil) || other.breakUntil == breakUntil)&&(identical(other.timeoutUntil, timeoutUntil) || other.timeoutUntil == timeoutUntil)&&(identical(other.isBot, isBot) || other.isBot == isBot)&&(identical(other.lastTyped, lastTyped) || other.lastTyped == lastTyped)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,chatRoomId,chatRoom,accountId,account,nick,role,notify,joinedAt,isBot,lastTyped); | ||||
| int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,chatRoomId,chatRoom,accountId,account,nick,role,notify,joinedAt,breakUntil,timeoutUntil,isBot,lastTyped); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnChatMember(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, chatRoomId: $chatRoomId, chatRoom: $chatRoom, accountId: $accountId, account: $account, nick: $nick, role: $role, notify: $notify, joinedAt: $joinedAt, isBot: $isBot, lastTyped: $lastTyped)'; | ||||
|   return 'SnChatMember(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, chatRoomId: $chatRoomId, chatRoom: $chatRoom, accountId: $accountId, account: $account, nick: $nick, role: $role, notify: $notify, joinedAt: $joinedAt, breakUntil: $breakUntil, timeoutUntil: $timeoutUntil, isBot: $isBot, lastTyped: $lastTyped)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -696,7 +697,7 @@ abstract mixin class $SnChatMemberCopyWith<$Res>  { | ||||
|   factory $SnChatMemberCopyWith(SnChatMember value, $Res Function(SnChatMember) _then) = _$SnChatMemberCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String chatRoomId, SnChatRoom? chatRoom, String accountId, SnAccount account, String? nick, int role, int notify, DateTime? joinedAt, bool isBot, DateTime? lastTyped | ||||
|  DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String chatRoomId, SnChatRoom? chatRoom, String accountId, SnAccount account, String? nick, int role, int notify, DateTime? joinedAt, DateTime? breakUntil, DateTime? timeoutUntil, bool isBot, DateTime? lastTyped | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -713,7 +714,7 @@ class _$SnChatMemberCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnChatMember | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? chatRoomId = null,Object? chatRoom = freezed,Object? accountId = null,Object? account = null,Object? nick = freezed,Object? role = null,Object? notify = null,Object? joinedAt = freezed,Object? isBot = null,Object? lastTyped = freezed,}) { | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? chatRoomId = null,Object? chatRoom = freezed,Object? accountId = null,Object? account = null,Object? nick = freezed,Object? role = null,Object? notify = null,Object? joinedAt = freezed,Object? breakUntil = freezed,Object? timeoutUntil = freezed,Object? isBot = null,Object? lastTyped = freezed,}) { | ||||
|   return _then(_self.copyWith( | ||||
| createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
| @@ -727,6 +728,8 @@ as SnAccount,nick: freezed == nick ? _self.nick : nick // ignore: cast_nullable_ | ||||
| as String?,role: null == role ? _self.role : role // ignore: cast_nullable_to_non_nullable | ||||
| as int,notify: null == notify ? _self.notify : notify // ignore: cast_nullable_to_non_nullable | ||||
| as int,joinedAt: freezed == joinedAt ? _self.joinedAt : joinedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,breakUntil: freezed == breakUntil ? _self.breakUntil : breakUntil // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,timeoutUntil: freezed == timeoutUntil ? _self.timeoutUntil : timeoutUntil // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,isBot: null == isBot ? _self.isBot : isBot // ignore: cast_nullable_to_non_nullable | ||||
| as bool,lastTyped: freezed == lastTyped ? _self.lastTyped : lastTyped // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?, | ||||
| @@ -761,7 +764,7 @@ $SnAccountCopyWith<$Res> get account { | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SnChatMember implements SnChatMember { | ||||
|   const _SnChatMember({required this.createdAt, required this.updatedAt, required this.deletedAt, required this.id, required this.chatRoomId, required this.chatRoom, required this.accountId, required this.account, required this.nick, required this.role, required this.notify, required this.joinedAt, required this.isBot, this.lastTyped}); | ||||
|   const _SnChatMember({required this.createdAt, required this.updatedAt, required this.deletedAt, required this.id, required this.chatRoomId, required this.chatRoom, required this.accountId, required this.account, required this.nick, required this.role, required this.notify, required this.joinedAt, required this.breakUntil, required this.timeoutUntil, required this.isBot, this.lastTyped}); | ||||
|   factory _SnChatMember.fromJson(Map<String, dynamic> json) => _$SnChatMemberFromJson(json); | ||||
|  | ||||
| @override final  DateTime createdAt; | ||||
| @@ -776,7 +779,10 @@ class _SnChatMember implements SnChatMember { | ||||
| @override final  int role; | ||||
| @override final  int notify; | ||||
| @override final  DateTime? joinedAt; | ||||
| @override final  DateTime? breakUntil; | ||||
| @override final  DateTime? timeoutUntil; | ||||
| @override final  bool isBot; | ||||
| // Frontend data | ||||
| @override final  DateTime? lastTyped; | ||||
|  | ||||
| /// Create a copy of SnChatMember | ||||
| @@ -792,16 +798,16 @@ Map<String, dynamic> toJson() { | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnChatMember&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.id, id) || other.id == id)&&(identical(other.chatRoomId, chatRoomId) || other.chatRoomId == chatRoomId)&&(identical(other.chatRoom, chatRoom) || other.chatRoom == chatRoom)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.role, role) || other.role == role)&&(identical(other.notify, notify) || other.notify == notify)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)&&(identical(other.isBot, isBot) || other.isBot == isBot)&&(identical(other.lastTyped, lastTyped) || other.lastTyped == lastTyped)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnChatMember&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.id, id) || other.id == id)&&(identical(other.chatRoomId, chatRoomId) || other.chatRoomId == chatRoomId)&&(identical(other.chatRoom, chatRoom) || other.chatRoom == chatRoom)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.role, role) || other.role == role)&&(identical(other.notify, notify) || other.notify == notify)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)&&(identical(other.breakUntil, breakUntil) || other.breakUntil == breakUntil)&&(identical(other.timeoutUntil, timeoutUntil) || other.timeoutUntil == timeoutUntil)&&(identical(other.isBot, isBot) || other.isBot == isBot)&&(identical(other.lastTyped, lastTyped) || other.lastTyped == lastTyped)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,chatRoomId,chatRoom,accountId,account,nick,role,notify,joinedAt,isBot,lastTyped); | ||||
| int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,chatRoomId,chatRoom,accountId,account,nick,role,notify,joinedAt,breakUntil,timeoutUntil,isBot,lastTyped); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnChatMember(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, chatRoomId: $chatRoomId, chatRoom: $chatRoom, accountId: $accountId, account: $account, nick: $nick, role: $role, notify: $notify, joinedAt: $joinedAt, isBot: $isBot, lastTyped: $lastTyped)'; | ||||
|   return 'SnChatMember(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, chatRoomId: $chatRoomId, chatRoom: $chatRoom, accountId: $accountId, account: $account, nick: $nick, role: $role, notify: $notify, joinedAt: $joinedAt, breakUntil: $breakUntil, timeoutUntil: $timeoutUntil, isBot: $isBot, lastTyped: $lastTyped)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -812,7 +818,7 @@ abstract mixin class _$SnChatMemberCopyWith<$Res> implements $SnChatMemberCopyWi | ||||
|   factory _$SnChatMemberCopyWith(_SnChatMember value, $Res Function(_SnChatMember) _then) = __$SnChatMemberCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String chatRoomId, SnChatRoom? chatRoom, String accountId, SnAccount account, String? nick, int role, int notify, DateTime? joinedAt, bool isBot, DateTime? lastTyped | ||||
|  DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String chatRoomId, SnChatRoom? chatRoom, String accountId, SnAccount account, String? nick, int role, int notify, DateTime? joinedAt, DateTime? breakUntil, DateTime? timeoutUntil, bool isBot, DateTime? lastTyped | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -829,7 +835,7 @@ class __$SnChatMemberCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnChatMember | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? chatRoomId = null,Object? chatRoom = freezed,Object? accountId = null,Object? account = null,Object? nick = freezed,Object? role = null,Object? notify = null,Object? joinedAt = freezed,Object? isBot = null,Object? lastTyped = freezed,}) { | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? chatRoomId = null,Object? chatRoom = freezed,Object? accountId = null,Object? account = null,Object? nick = freezed,Object? role = null,Object? notify = null,Object? joinedAt = freezed,Object? breakUntil = freezed,Object? timeoutUntil = freezed,Object? isBot = null,Object? lastTyped = freezed,}) { | ||||
|   return _then(_SnChatMember( | ||||
| createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
| @@ -843,6 +849,8 @@ as SnAccount,nick: freezed == nick ? _self.nick : nick // ignore: cast_nullable_ | ||||
| as String?,role: null == role ? _self.role : role // ignore: cast_nullable_to_non_nullable | ||||
| as int,notify: null == notify ? _self.notify : notify // ignore: cast_nullable_to_non_nullable | ||||
| as int,joinedAt: freezed == joinedAt ? _self.joinedAt : joinedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,breakUntil: freezed == breakUntil ? _self.breakUntil : breakUntil // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,timeoutUntil: freezed == timeoutUntil ? _self.timeoutUntil : timeoutUntil // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,isBot: null == isBot ? _self.isBot : isBot // ignore: cast_nullable_to_non_nullable | ||||
| as bool,lastTyped: freezed == lastTyped ? _self.lastTyped : lastTyped // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?, | ||||
|   | ||||
| @@ -166,6 +166,14 @@ _SnChatMember _$SnChatMemberFromJson(Map<String, dynamic> json) => | ||||
|           json['joined_at'] == null | ||||
|               ? null | ||||
|               : DateTime.parse(json['joined_at'] as String), | ||||
|       breakUntil: | ||||
|           json['break_until'] == null | ||||
|               ? null | ||||
|               : DateTime.parse(json['break_until'] as String), | ||||
|       timeoutUntil: | ||||
|           json['timeout_until'] == null | ||||
|               ? null | ||||
|               : DateTime.parse(json['timeout_until'] as String), | ||||
|       isBot: json['is_bot'] as bool, | ||||
|       lastTyped: | ||||
|           json['last_typed'] == null | ||||
| @@ -187,6 +195,8 @@ Map<String, dynamic> _$SnChatMemberToJson(_SnChatMember instance) => | ||||
|       'role': instance.role, | ||||
|       'notify': instance.notify, | ||||
|       'joined_at': instance.joinedAt?.toIso8601String(), | ||||
|       'break_until': instance.breakUntil?.toIso8601String(), | ||||
|       'timeout_until': instance.timeoutUntil?.toIso8601String(), | ||||
|       'is_bot': instance.isBot, | ||||
|       'last_typed': instance.lastTyped?.toIso8601String(), | ||||
|     }; | ||||
|   | ||||
| @@ -59,6 +59,7 @@ sealed class SnPublisher with _$SnPublisher { | ||||
|     required DateTime updatedAt, | ||||
|     required DateTime? deletedAt, | ||||
|     required String? realmId, | ||||
|     required SnVerificationMark? verification, | ||||
|   }) = _SnPublisher; | ||||
|  | ||||
|   factory SnPublisher.fromJson(Map<String, dynamic> json) => | ||||
|   | ||||
| @@ -370,7 +370,7 @@ $SnPublisherCopyWith<$Res> get publisher { | ||||
| /// @nodoc | ||||
| mixin _$SnPublisher { | ||||
|  | ||||
|  String get id; int get type; String get name; String get nick; String get bio; SnCloudFile? get picture; SnCloudFile? get background; SnAccount? get account; String? get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; String? get realmId; | ||||
|  String get id; int get type; String get name; String get nick; String get bio; SnCloudFile? get picture; SnCloudFile? get background; SnAccount? get account; String? get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; String? get realmId; SnVerificationMark? get verification; | ||||
| /// Create a copy of SnPublisher | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -383,16 +383,16 @@ $SnPublisherCopyWith<SnPublisher> get copyWith => _$SnPublisherCopyWithImpl<SnPu | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPublisher&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.name, name) || other.name == name)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.account, account) || other.account == account)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.realmId, realmId) || other.realmId == realmId)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPublisher&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.name, name) || other.name == name)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.account, account) || other.account == account)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.realmId, realmId) || other.realmId == realmId)&&(identical(other.verification, verification) || other.verification == verification)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,type,name,nick,bio,picture,background,account,accountId,createdAt,updatedAt,deletedAt,realmId); | ||||
| int get hashCode => Object.hash(runtimeType,id,type,name,nick,bio,picture,background,account,accountId,createdAt,updatedAt,deletedAt,realmId,verification); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnPublisher(id: $id, type: $type, name: $name, nick: $nick, bio: $bio, picture: $picture, background: $background, account: $account, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, realmId: $realmId)'; | ||||
|   return 'SnPublisher(id: $id, type: $type, name: $name, nick: $nick, bio: $bio, picture: $picture, background: $background, account: $account, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, realmId: $realmId, verification: $verification)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -403,11 +403,11 @@ abstract mixin class $SnPublisherCopyWith<$Res>  { | ||||
|   factory $SnPublisherCopyWith(SnPublisher value, $Res Function(SnPublisher) _then) = _$SnPublisherCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String id, int type, String name, String nick, String bio, SnCloudFile? picture, SnCloudFile? background, SnAccount? account, String? accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String? realmId | ||||
|  String id, int type, String name, String nick, String bio, SnCloudFile? picture, SnCloudFile? background, SnAccount? account, String? accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String? realmId, SnVerificationMark? verification | ||||
| }); | ||||
|  | ||||
|  | ||||
| $SnCloudFileCopyWith<$Res>? get picture;$SnCloudFileCopyWith<$Res>? get background;$SnAccountCopyWith<$Res>? get account; | ||||
| $SnCloudFileCopyWith<$Res>? get picture;$SnCloudFileCopyWith<$Res>? get background;$SnAccountCopyWith<$Res>? get account;$SnVerificationMarkCopyWith<$Res>? get verification; | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| @@ -420,7 +420,7 @@ class _$SnPublisherCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnPublisher | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? name = null,Object? nick = null,Object? bio = null,Object? picture = freezed,Object? background = freezed,Object? account = freezed,Object? accountId = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? realmId = freezed,}) { | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? name = null,Object? nick = null,Object? bio = null,Object? picture = freezed,Object? background = freezed,Object? account = freezed,Object? accountId = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? realmId = freezed,Object? verification = freezed,}) { | ||||
|   return _then(_self.copyWith( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||
| @@ -435,7 +435,8 @@ as String?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: | ||||
| as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,realmId: freezed == realmId ? _self.realmId : realmId // ignore: cast_nullable_to_non_nullable | ||||
| as String?, | ||||
| as String?,verification: freezed == verification ? _self.verification : verification // ignore: cast_nullable_to_non_nullable | ||||
| as SnVerificationMark?, | ||||
|   )); | ||||
| } | ||||
| /// Create a copy of SnPublisher | ||||
| @@ -474,6 +475,18 @@ $SnAccountCopyWith<$Res>? get account { | ||||
|   return $SnAccountCopyWith<$Res>(_self.account!, (value) { | ||||
|     return _then(_self.copyWith(account: value)); | ||||
|   }); | ||||
| }/// Create a copy of SnPublisher | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnVerificationMarkCopyWith<$Res>? get verification { | ||||
|     if (_self.verification == null) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return $SnVerificationMarkCopyWith<$Res>(_self.verification!, (value) { | ||||
|     return _then(_self.copyWith(verification: value)); | ||||
|   }); | ||||
| } | ||||
| } | ||||
|  | ||||
| @@ -482,7 +495,7 @@ $SnAccountCopyWith<$Res>? get account { | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SnPublisher implements SnPublisher { | ||||
|   const _SnPublisher({required this.id, required this.type, required this.name, required this.nick, this.bio = '', required this.picture, required this.background, required this.account, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt, required this.realmId}); | ||||
|   const _SnPublisher({required this.id, required this.type, required this.name, required this.nick, this.bio = '', required this.picture, required this.background, required this.account, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt, required this.realmId, required this.verification}); | ||||
|   factory _SnPublisher.fromJson(Map<String, dynamic> json) => _$SnPublisherFromJson(json); | ||||
|  | ||||
| @override final  String id; | ||||
| @@ -498,6 +511,7 @@ class _SnPublisher implements SnPublisher { | ||||
| @override final  DateTime updatedAt; | ||||
| @override final  DateTime? deletedAt; | ||||
| @override final  String? realmId; | ||||
| @override final  SnVerificationMark? verification; | ||||
|  | ||||
| /// Create a copy of SnPublisher | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @@ -512,16 +526,16 @@ Map<String, dynamic> toJson() { | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPublisher&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.name, name) || other.name == name)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.account, account) || other.account == account)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.realmId, realmId) || other.realmId == realmId)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPublisher&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.name, name) || other.name == name)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.account, account) || other.account == account)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.realmId, realmId) || other.realmId == realmId)&&(identical(other.verification, verification) || other.verification == verification)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,type,name,nick,bio,picture,background,account,accountId,createdAt,updatedAt,deletedAt,realmId); | ||||
| int get hashCode => Object.hash(runtimeType,id,type,name,nick,bio,picture,background,account,accountId,createdAt,updatedAt,deletedAt,realmId,verification); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnPublisher(id: $id, type: $type, name: $name, nick: $nick, bio: $bio, picture: $picture, background: $background, account: $account, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, realmId: $realmId)'; | ||||
|   return 'SnPublisher(id: $id, type: $type, name: $name, nick: $nick, bio: $bio, picture: $picture, background: $background, account: $account, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, realmId: $realmId, verification: $verification)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -532,11 +546,11 @@ abstract mixin class _$SnPublisherCopyWith<$Res> implements $SnPublisherCopyWith | ||||
|   factory _$SnPublisherCopyWith(_SnPublisher value, $Res Function(_SnPublisher) _then) = __$SnPublisherCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String id, int type, String name, String nick, String bio, SnCloudFile? picture, SnCloudFile? background, SnAccount? account, String? accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String? realmId | ||||
|  String id, int type, String name, String nick, String bio, SnCloudFile? picture, SnCloudFile? background, SnAccount? account, String? accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String? realmId, SnVerificationMark? verification | ||||
| }); | ||||
|  | ||||
|  | ||||
| @override $SnCloudFileCopyWith<$Res>? get picture;@override $SnCloudFileCopyWith<$Res>? get background;@override $SnAccountCopyWith<$Res>? get account; | ||||
| @override $SnCloudFileCopyWith<$Res>? get picture;@override $SnCloudFileCopyWith<$Res>? get background;@override $SnAccountCopyWith<$Res>? get account;@override $SnVerificationMarkCopyWith<$Res>? get verification; | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| @@ -549,7 +563,7 @@ class __$SnPublisherCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnPublisher | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? name = null,Object? nick = null,Object? bio = null,Object? picture = freezed,Object? background = freezed,Object? account = freezed,Object? accountId = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? realmId = freezed,}) { | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? name = null,Object? nick = null,Object? bio = null,Object? picture = freezed,Object? background = freezed,Object? account = freezed,Object? accountId = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? realmId = freezed,Object? verification = freezed,}) { | ||||
|   return _then(_SnPublisher( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||
| @@ -564,7 +578,8 @@ as String?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: | ||||
| as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,realmId: freezed == realmId ? _self.realmId : realmId // ignore: cast_nullable_to_non_nullable | ||||
| as String?, | ||||
| as String?,verification: freezed == verification ? _self.verification : verification // ignore: cast_nullable_to_non_nullable | ||||
| as SnVerificationMark?, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| @@ -604,6 +619,18 @@ $SnAccountCopyWith<$Res>? get account { | ||||
|   return $SnAccountCopyWith<$Res>(_self.account!, (value) { | ||||
|     return _then(_self.copyWith(account: value)); | ||||
|   }); | ||||
| }/// Create a copy of SnPublisher | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnVerificationMarkCopyWith<$Res>? get verification { | ||||
|     if (_self.verification == null) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return $SnVerificationMarkCopyWith<$Res>(_self.verification!, (value) { | ||||
|     return _then(_self.copyWith(verification: value)); | ||||
|   }); | ||||
| } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -120,6 +120,12 @@ _SnPublisher _$SnPublisherFromJson(Map<String, dynamic> json) => _SnPublisher( | ||||
|           ? null | ||||
|           : DateTime.parse(json['deleted_at'] as String), | ||||
|   realmId: json['realm_id'] as String?, | ||||
|   verification: | ||||
|       json['verification'] == null | ||||
|           ? null | ||||
|           : SnVerificationMark.fromJson( | ||||
|             json['verification'] as Map<String, dynamic>, | ||||
|           ), | ||||
| ); | ||||
|  | ||||
| Map<String, dynamic> _$SnPublisherToJson(_SnPublisher instance) => | ||||
| @@ -137,6 +143,7 @@ Map<String, dynamic> _$SnPublisherToJson(_SnPublisher instance) => | ||||
|       'updated_at': instance.updatedAt.toIso8601String(), | ||||
|       'deleted_at': instance.deletedAt?.toIso8601String(), | ||||
|       'realm_id': instance.realmId, | ||||
|       'verification': instance.verification?.toJson(), | ||||
|     }; | ||||
|  | ||||
| _SnPublisherStats _$SnPublisherStatsFromJson(Map<String, dynamic> json) => | ||||
|   | ||||
| @@ -27,15 +27,23 @@ sealed class SnAccount with _$SnAccount { | ||||
| sealed class SnAccountProfile with _$SnAccountProfile { | ||||
|   const factory SnAccountProfile({ | ||||
|     required String id, | ||||
|     required String? firstName, | ||||
|     required String? middleName, | ||||
|     required String? lastName, | ||||
|     @Default('') String firstName, | ||||
|     @Default('') String middleName, | ||||
|     @Default('') String lastName, | ||||
|     @Default('') String bio, | ||||
|     @Default('') String gender, | ||||
|     @Default('') String pronouns, | ||||
|     @Default('') String location, | ||||
|     @Default('') String timeZone, | ||||
|     DateTime? birthday, | ||||
|     DateTime? lastSeenAt, | ||||
|     SnAccountBadge? activeBadge, | ||||
|     required int experience, | ||||
|     required int level, | ||||
|     required double levelingProgress, | ||||
|     required SnCloudFile? picture, | ||||
|     required SnCloudFile? background, | ||||
|     required SnVerificationMark? verification, | ||||
|     required DateTime createdAt, | ||||
|     required DateTime updatedAt, | ||||
|     required DateTime? deletedAt, | ||||
| @@ -78,6 +86,7 @@ sealed class SnAccountBadge with _$SnAccountBadge { | ||||
|     required String accountId, | ||||
|     required DateTime createdAt, | ||||
|     required DateTime updatedAt, | ||||
|     required DateTime? activatedAt, | ||||
|     required DateTime? deletedAt, | ||||
|   }) = _SnAccountBadge; | ||||
|  | ||||
| @@ -123,3 +132,16 @@ sealed class SnNotification with _$SnNotification { | ||||
|   factory SnNotification.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnNotificationFromJson(json); | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| sealed class SnVerificationMark with _$SnVerificationMark { | ||||
|   const factory SnVerificationMark({ | ||||
|     required int type, | ||||
|     required String? title, | ||||
|     required String? description, | ||||
|     required String? verifiedBy, | ||||
|   }) = _SnVerificationMark; | ||||
|  | ||||
|   factory SnVerificationMark.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnVerificationMarkFromJson(json); | ||||
| } | ||||
|   | ||||
| @@ -200,7 +200,7 @@ $SnAccountProfileCopyWith<$Res> get profile { | ||||
| /// @nodoc | ||||
| mixin _$SnAccountProfile { | ||||
|  | ||||
|  String get id; String? get firstName; String? get middleName; String? get lastName; String get bio; int get experience; int get level; double get levelingProgress; SnCloudFile? get picture; SnCloudFile? get background; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; | ||||
|  String get id; String get firstName; String get middleName; String get lastName; String get bio; String get gender; String get pronouns; String get location; String get timeZone; DateTime? get birthday; DateTime? get lastSeenAt; SnAccountBadge? get activeBadge; int get experience; int get level; double get levelingProgress; SnCloudFile? get picture; SnCloudFile? get background; SnVerificationMark? get verification; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; | ||||
| /// Create a copy of SnAccountProfile | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -213,16 +213,16 @@ $SnAccountProfileCopyWith<SnAccountProfile> get copyWith => _$SnAccountProfileCo | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAccountProfile&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.middleName, middleName) || other.middleName == middleName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.experience, experience) || other.experience == experience)&&(identical(other.level, level) || other.level == level)&&(identical(other.levelingProgress, levelingProgress) || other.levelingProgress == levelingProgress)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(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 SnAccountProfile&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.middleName, middleName) || other.middleName == middleName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.gender, gender) || other.gender == gender)&&(identical(other.pronouns, pronouns) || other.pronouns == pronouns)&&(identical(other.location, location) || other.location == location)&&(identical(other.timeZone, timeZone) || other.timeZone == timeZone)&&(identical(other.birthday, birthday) || other.birthday == birthday)&&(identical(other.lastSeenAt, lastSeenAt) || other.lastSeenAt == lastSeenAt)&&(identical(other.activeBadge, activeBadge) || other.activeBadge == activeBadge)&&(identical(other.experience, experience) || other.experience == experience)&&(identical(other.level, level) || other.level == level)&&(identical(other.levelingProgress, levelingProgress) || other.levelingProgress == levelingProgress)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.verification, verification) || other.verification == verification)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,firstName,middleName,lastName,bio,experience,level,levelingProgress,picture,background,createdAt,updatedAt,deletedAt); | ||||
| int get hashCode => Object.hashAll([runtimeType,id,firstName,middleName,lastName,bio,gender,pronouns,location,timeZone,birthday,lastSeenAt,activeBadge,experience,level,levelingProgress,picture,background,verification,createdAt,updatedAt,deletedAt]); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnAccountProfile(id: $id, firstName: $firstName, middleName: $middleName, lastName: $lastName, bio: $bio, experience: $experience, level: $level, levelingProgress: $levelingProgress, picture: $picture, background: $background, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
|   return 'SnAccountProfile(id: $id, firstName: $firstName, middleName: $middleName, lastName: $lastName, bio: $bio, gender: $gender, pronouns: $pronouns, location: $location, timeZone: $timeZone, birthday: $birthday, lastSeenAt: $lastSeenAt, activeBadge: $activeBadge, experience: $experience, level: $level, levelingProgress: $levelingProgress, picture: $picture, background: $background, verification: $verification, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -233,11 +233,11 @@ abstract mixin class $SnAccountProfileCopyWith<$Res>  { | ||||
|   factory $SnAccountProfileCopyWith(SnAccountProfile value, $Res Function(SnAccountProfile) _then) = _$SnAccountProfileCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String id, String? firstName, String? middleName, String? lastName, String bio, int experience, int level, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
|  String id, String firstName, String middleName, String lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
| $SnCloudFileCopyWith<$Res>? get picture;$SnCloudFileCopyWith<$Res>? get background; | ||||
| $SnAccountBadgeCopyWith<$Res>? get activeBadge;$SnCloudFileCopyWith<$Res>? get picture;$SnCloudFileCopyWith<$Res>? get background;$SnVerificationMarkCopyWith<$Res>? get verification; | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| @@ -250,19 +250,27 @@ class _$SnAccountProfileCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnAccountProfile | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? firstName = freezed,Object? middleName = freezed,Object? lastName = freezed,Object? bio = null,Object? experience = null,Object? level = null,Object? levelingProgress = null,Object? picture = freezed,Object? background = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? firstName = null,Object? middleName = null,Object? lastName = null,Object? bio = null,Object? gender = null,Object? pronouns = null,Object? location = null,Object? timeZone = null,Object? birthday = freezed,Object? lastSeenAt = freezed,Object? activeBadge = freezed,Object? experience = null,Object? level = null,Object? levelingProgress = null,Object? picture = freezed,Object? background = freezed,Object? verification = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
|   return _then(_self.copyWith( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,firstName: freezed == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable | ||||
| as String?,middleName: freezed == middleName ? _self.middleName : middleName // ignore: cast_nullable_to_non_nullable | ||||
| as String?,lastName: freezed == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable | ||||
| as String?,bio: null == bio ? _self.bio : bio // ignore: cast_nullable_to_non_nullable | ||||
| as String,experience: null == experience ? _self.experience : experience // ignore: cast_nullable_to_non_nullable | ||||
| as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable | ||||
| as String,middleName: null == middleName ? _self.middleName : middleName // ignore: cast_nullable_to_non_nullable | ||||
| as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable | ||||
| as String,bio: null == bio ? _self.bio : bio // ignore: cast_nullable_to_non_nullable | ||||
| as String,gender: null == gender ? _self.gender : gender // ignore: cast_nullable_to_non_nullable | ||||
| as String,pronouns: null == pronouns ? _self.pronouns : pronouns // ignore: cast_nullable_to_non_nullable | ||||
| as String,location: null == location ? _self.location : location // ignore: cast_nullable_to_non_nullable | ||||
| as String,timeZone: null == timeZone ? _self.timeZone : timeZone // ignore: cast_nullable_to_non_nullable | ||||
| as String,birthday: freezed == birthday ? _self.birthday : birthday // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,lastSeenAt: freezed == lastSeenAt ? _self.lastSeenAt : lastSeenAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,activeBadge: freezed == activeBadge ? _self.activeBadge : activeBadge // ignore: cast_nullable_to_non_nullable | ||||
| as SnAccountBadge?,experience: null == experience ? _self.experience : experience // ignore: cast_nullable_to_non_nullable | ||||
| as int,level: null == level ? _self.level : level // ignore: cast_nullable_to_non_nullable | ||||
| as int,levelingProgress: null == levelingProgress ? _self.levelingProgress : levelingProgress // ignore: cast_nullable_to_non_nullable | ||||
| as double,picture: freezed == picture ? _self.picture : picture // ignore: cast_nullable_to_non_nullable | ||||
| as SnCloudFile?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable | ||||
| as SnCloudFile?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as SnCloudFile?,verification: freezed == verification ? _self.verification : verification // ignore: cast_nullable_to_non_nullable | ||||
| as SnVerificationMark?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?, | ||||
| @@ -272,6 +280,18 @@ as DateTime?, | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnAccountBadgeCopyWith<$Res>? get activeBadge { | ||||
|     if (_self.activeBadge == null) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return $SnAccountBadgeCopyWith<$Res>(_self.activeBadge!, (value) { | ||||
|     return _then(_self.copyWith(activeBadge: value)); | ||||
|   }); | ||||
| }/// Create a copy of SnAccountProfile | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnCloudFileCopyWith<$Res>? get picture { | ||||
|     if (_self.picture == null) { | ||||
|     return null; | ||||
| @@ -292,6 +312,18 @@ $SnCloudFileCopyWith<$Res>? get background { | ||||
|   return $SnCloudFileCopyWith<$Res>(_self.background!, (value) { | ||||
|     return _then(_self.copyWith(background: value)); | ||||
|   }); | ||||
| }/// Create a copy of SnAccountProfile | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnVerificationMarkCopyWith<$Res>? get verification { | ||||
|     if (_self.verification == null) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return $SnVerificationMarkCopyWith<$Res>(_self.verification!, (value) { | ||||
|     return _then(_self.copyWith(verification: value)); | ||||
|   }); | ||||
| } | ||||
| } | ||||
|  | ||||
| @@ -300,19 +332,27 @@ $SnCloudFileCopyWith<$Res>? get background { | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SnAccountProfile implements SnAccountProfile { | ||||
|   const _SnAccountProfile({required this.id, required this.firstName, required this.middleName, required this.lastName, this.bio = '', required this.experience, required this.level, required this.levelingProgress, required this.picture, required this.background, required this.createdAt, required this.updatedAt, required this.deletedAt}); | ||||
|   const _SnAccountProfile({required this.id, this.firstName = '', this.middleName = '', this.lastName = '', this.bio = '', this.gender = '', this.pronouns = '', this.location = '', this.timeZone = '', this.birthday, this.lastSeenAt, this.activeBadge, required this.experience, required this.level, required this.levelingProgress, required this.picture, required this.background, required this.verification, required this.createdAt, required this.updatedAt, required this.deletedAt}); | ||||
|   factory _SnAccountProfile.fromJson(Map<String, dynamic> json) => _$SnAccountProfileFromJson(json); | ||||
|  | ||||
| @override final  String id; | ||||
| @override final  String? firstName; | ||||
| @override final  String? middleName; | ||||
| @override final  String? lastName; | ||||
| @override@JsonKey() final  String firstName; | ||||
| @override@JsonKey() final  String middleName; | ||||
| @override@JsonKey() final  String lastName; | ||||
| @override@JsonKey() final  String bio; | ||||
| @override@JsonKey() final  String gender; | ||||
| @override@JsonKey() final  String pronouns; | ||||
| @override@JsonKey() final  String location; | ||||
| @override@JsonKey() final  String timeZone; | ||||
| @override final  DateTime? birthday; | ||||
| @override final  DateTime? lastSeenAt; | ||||
| @override final  SnAccountBadge? activeBadge; | ||||
| @override final  int experience; | ||||
| @override final  int level; | ||||
| @override final  double levelingProgress; | ||||
| @override final  SnCloudFile? picture; | ||||
| @override final  SnCloudFile? background; | ||||
| @override final  SnVerificationMark? verification; | ||||
| @override final  DateTime createdAt; | ||||
| @override final  DateTime updatedAt; | ||||
| @override final  DateTime? deletedAt; | ||||
| @@ -330,16 +370,16 @@ Map<String, dynamic> toJson() { | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAccountProfile&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.middleName, middleName) || other.middleName == middleName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.experience, experience) || other.experience == experience)&&(identical(other.level, level) || other.level == level)&&(identical(other.levelingProgress, levelingProgress) || other.levelingProgress == levelingProgress)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(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 _SnAccountProfile&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.middleName, middleName) || other.middleName == middleName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.gender, gender) || other.gender == gender)&&(identical(other.pronouns, pronouns) || other.pronouns == pronouns)&&(identical(other.location, location) || other.location == location)&&(identical(other.timeZone, timeZone) || other.timeZone == timeZone)&&(identical(other.birthday, birthday) || other.birthday == birthday)&&(identical(other.lastSeenAt, lastSeenAt) || other.lastSeenAt == lastSeenAt)&&(identical(other.activeBadge, activeBadge) || other.activeBadge == activeBadge)&&(identical(other.experience, experience) || other.experience == experience)&&(identical(other.level, level) || other.level == level)&&(identical(other.levelingProgress, levelingProgress) || other.levelingProgress == levelingProgress)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.verification, verification) || other.verification == verification)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,firstName,middleName,lastName,bio,experience,level,levelingProgress,picture,background,createdAt,updatedAt,deletedAt); | ||||
| int get hashCode => Object.hashAll([runtimeType,id,firstName,middleName,lastName,bio,gender,pronouns,location,timeZone,birthday,lastSeenAt,activeBadge,experience,level,levelingProgress,picture,background,verification,createdAt,updatedAt,deletedAt]); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnAccountProfile(id: $id, firstName: $firstName, middleName: $middleName, lastName: $lastName, bio: $bio, experience: $experience, level: $level, levelingProgress: $levelingProgress, picture: $picture, background: $background, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
|   return 'SnAccountProfile(id: $id, firstName: $firstName, middleName: $middleName, lastName: $lastName, bio: $bio, gender: $gender, pronouns: $pronouns, location: $location, timeZone: $timeZone, birthday: $birthday, lastSeenAt: $lastSeenAt, activeBadge: $activeBadge, experience: $experience, level: $level, levelingProgress: $levelingProgress, picture: $picture, background: $background, verification: $verification, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -350,11 +390,11 @@ abstract mixin class _$SnAccountProfileCopyWith<$Res> implements $SnAccountProfi | ||||
|   factory _$SnAccountProfileCopyWith(_SnAccountProfile value, $Res Function(_SnAccountProfile) _then) = __$SnAccountProfileCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String id, String? firstName, String? middleName, String? lastName, String bio, int experience, int level, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
|  String id, String firstName, String middleName, String lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
| @override $SnCloudFileCopyWith<$Res>? get picture;@override $SnCloudFileCopyWith<$Res>? get background; | ||||
| @override $SnAccountBadgeCopyWith<$Res>? get activeBadge;@override $SnCloudFileCopyWith<$Res>? get picture;@override $SnCloudFileCopyWith<$Res>? get background;@override $SnVerificationMarkCopyWith<$Res>? get verification; | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| @@ -367,19 +407,27 @@ class __$SnAccountProfileCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnAccountProfile | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? firstName = freezed,Object? middleName = freezed,Object? lastName = freezed,Object? bio = null,Object? experience = null,Object? level = null,Object? levelingProgress = null,Object? picture = freezed,Object? background = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? firstName = null,Object? middleName = null,Object? lastName = null,Object? bio = null,Object? gender = null,Object? pronouns = null,Object? location = null,Object? timeZone = null,Object? birthday = freezed,Object? lastSeenAt = freezed,Object? activeBadge = freezed,Object? experience = null,Object? level = null,Object? levelingProgress = null,Object? picture = freezed,Object? background = freezed,Object? verification = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
|   return _then(_SnAccountProfile( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,firstName: freezed == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable | ||||
| as String?,middleName: freezed == middleName ? _self.middleName : middleName // ignore: cast_nullable_to_non_nullable | ||||
| as String?,lastName: freezed == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable | ||||
| as String?,bio: null == bio ? _self.bio : bio // ignore: cast_nullable_to_non_nullable | ||||
| as String,experience: null == experience ? _self.experience : experience // ignore: cast_nullable_to_non_nullable | ||||
| as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable | ||||
| as String,middleName: null == middleName ? _self.middleName : middleName // ignore: cast_nullable_to_non_nullable | ||||
| as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable | ||||
| as String,bio: null == bio ? _self.bio : bio // ignore: cast_nullable_to_non_nullable | ||||
| as String,gender: null == gender ? _self.gender : gender // ignore: cast_nullable_to_non_nullable | ||||
| as String,pronouns: null == pronouns ? _self.pronouns : pronouns // ignore: cast_nullable_to_non_nullable | ||||
| as String,location: null == location ? _self.location : location // ignore: cast_nullable_to_non_nullable | ||||
| as String,timeZone: null == timeZone ? _self.timeZone : timeZone // ignore: cast_nullable_to_non_nullable | ||||
| as String,birthday: freezed == birthday ? _self.birthday : birthday // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,lastSeenAt: freezed == lastSeenAt ? _self.lastSeenAt : lastSeenAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,activeBadge: freezed == activeBadge ? _self.activeBadge : activeBadge // ignore: cast_nullable_to_non_nullable | ||||
| as SnAccountBadge?,experience: null == experience ? _self.experience : experience // ignore: cast_nullable_to_non_nullable | ||||
| as int,level: null == level ? _self.level : level // ignore: cast_nullable_to_non_nullable | ||||
| as int,levelingProgress: null == levelingProgress ? _self.levelingProgress : levelingProgress // ignore: cast_nullable_to_non_nullable | ||||
| as double,picture: freezed == picture ? _self.picture : picture // ignore: cast_nullable_to_non_nullable | ||||
| as SnCloudFile?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable | ||||
| as SnCloudFile?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as SnCloudFile?,verification: freezed == verification ? _self.verification : verification // ignore: cast_nullable_to_non_nullable | ||||
| as SnVerificationMark?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?, | ||||
| @@ -390,6 +438,18 @@ as DateTime?, | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnAccountBadgeCopyWith<$Res>? get activeBadge { | ||||
|     if (_self.activeBadge == null) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return $SnAccountBadgeCopyWith<$Res>(_self.activeBadge!, (value) { | ||||
|     return _then(_self.copyWith(activeBadge: value)); | ||||
|   }); | ||||
| }/// Create a copy of SnAccountProfile | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnCloudFileCopyWith<$Res>? get picture { | ||||
|     if (_self.picture == null) { | ||||
|     return null; | ||||
| @@ -410,6 +470,18 @@ $SnCloudFileCopyWith<$Res>? get background { | ||||
|   return $SnCloudFileCopyWith<$Res>(_self.background!, (value) { | ||||
|     return _then(_self.copyWith(background: value)); | ||||
|   }); | ||||
| }/// Create a copy of SnAccountProfile | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnVerificationMarkCopyWith<$Res>? get verification { | ||||
|     if (_self.verification == null) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return $SnVerificationMarkCopyWith<$Res>(_self.verification!, (value) { | ||||
|     return _then(_self.copyWith(verification: value)); | ||||
|   }); | ||||
| } | ||||
| } | ||||
|  | ||||
| @@ -583,7 +655,7 @@ as DateTime?, | ||||
| /// @nodoc | ||||
| mixin _$SnAccountBadge { | ||||
|  | ||||
|  String get id; String get type; String? get label; String? get caption; Map<String, dynamic> get meta; DateTime? get expiredAt; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; | ||||
|  String get id; String get type; String? get label; String? get caption; Map<String, dynamic> get meta; DateTime? get expiredAt; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get activatedAt; DateTime? get deletedAt; | ||||
| /// Create a copy of SnAccountBadge | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -596,16 +668,16 @@ $SnAccountBadgeCopyWith<SnAccountBadge> get copyWith => _$SnAccountBadgeCopyWith | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAccountBadge&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.label, label) || other.label == label)&&(identical(other.caption, caption) || other.caption == caption)&&const DeepCollectionEquality().equals(other.meta, meta)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAccountBadge&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.label, label) || other.label == label)&&(identical(other.caption, caption) || other.caption == caption)&&const DeepCollectionEquality().equals(other.meta, meta)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.activatedAt, activatedAt) || other.activatedAt == activatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,type,label,caption,const DeepCollectionEquality().hash(meta),expiredAt,accountId,createdAt,updatedAt,deletedAt); | ||||
| int get hashCode => Object.hash(runtimeType,id,type,label,caption,const DeepCollectionEquality().hash(meta),expiredAt,accountId,createdAt,updatedAt,activatedAt,deletedAt); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnAccountBadge(id: $id, type: $type, label: $label, caption: $caption, meta: $meta, expiredAt: $expiredAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
|   return 'SnAccountBadge(id: $id, type: $type, label: $label, caption: $caption, meta: $meta, expiredAt: $expiredAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, activatedAt: $activatedAt, deletedAt: $deletedAt)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -616,7 +688,7 @@ abstract mixin class $SnAccountBadgeCopyWith<$Res>  { | ||||
|   factory $SnAccountBadgeCopyWith(SnAccountBadge value, $Res Function(SnAccountBadge) _then) = _$SnAccountBadgeCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String id, String type, String? label, String? caption, Map<String, dynamic> meta, DateTime? expiredAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
|  String id, String type, String? label, String? caption, Map<String, dynamic> meta, DateTime? expiredAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? activatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -633,7 +705,7 @@ class _$SnAccountBadgeCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnAccountBadge | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? label = freezed,Object? caption = freezed,Object? meta = null,Object? expiredAt = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? label = freezed,Object? caption = freezed,Object? meta = null,Object? expiredAt = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? activatedAt = freezed,Object? deletedAt = freezed,}) { | ||||
|   return _then(_self.copyWith( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||
| @@ -644,7 +716,8 @@ as Map<String, dynamic>,expiredAt: freezed == expiredAt ? _self.expiredAt : expi | ||||
| as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,activatedAt: freezed == activatedAt ? _self.activatedAt : activatedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?, | ||||
|   )); | ||||
| } | ||||
| @@ -656,7 +729,7 @@ as DateTime?, | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SnAccountBadge implements SnAccountBadge { | ||||
|   const _SnAccountBadge({required this.id, required this.type, required this.label, required this.caption, required final  Map<String, dynamic> meta, required this.expiredAt, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}): _meta = meta; | ||||
|   const _SnAccountBadge({required this.id, required this.type, required this.label, required this.caption, required final  Map<String, dynamic> meta, required this.expiredAt, required this.accountId, required this.createdAt, required this.updatedAt, required this.activatedAt, required this.deletedAt}): _meta = meta; | ||||
|   factory _SnAccountBadge.fromJson(Map<String, dynamic> json) => _$SnAccountBadgeFromJson(json); | ||||
|  | ||||
| @override final  String id; | ||||
| @@ -674,6 +747,7 @@ class _SnAccountBadge implements SnAccountBadge { | ||||
| @override final  String accountId; | ||||
| @override final  DateTime createdAt; | ||||
| @override final  DateTime updatedAt; | ||||
| @override final  DateTime? activatedAt; | ||||
| @override final  DateTime? deletedAt; | ||||
|  | ||||
| /// Create a copy of SnAccountBadge | ||||
| @@ -689,16 +763,16 @@ Map<String, dynamic> toJson() { | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAccountBadge&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.label, label) || other.label == label)&&(identical(other.caption, caption) || other.caption == caption)&&const DeepCollectionEquality().equals(other._meta, _meta)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAccountBadge&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.label, label) || other.label == label)&&(identical(other.caption, caption) || other.caption == caption)&&const DeepCollectionEquality().equals(other._meta, _meta)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.activatedAt, activatedAt) || other.activatedAt == activatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,type,label,caption,const DeepCollectionEquality().hash(_meta),expiredAt,accountId,createdAt,updatedAt,deletedAt); | ||||
| int get hashCode => Object.hash(runtimeType,id,type,label,caption,const DeepCollectionEquality().hash(_meta),expiredAt,accountId,createdAt,updatedAt,activatedAt,deletedAt); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnAccountBadge(id: $id, type: $type, label: $label, caption: $caption, meta: $meta, expiredAt: $expiredAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
|   return 'SnAccountBadge(id: $id, type: $type, label: $label, caption: $caption, meta: $meta, expiredAt: $expiredAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, activatedAt: $activatedAt, deletedAt: $deletedAt)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -709,7 +783,7 @@ abstract mixin class _$SnAccountBadgeCopyWith<$Res> implements $SnAccountBadgeCo | ||||
|   factory _$SnAccountBadgeCopyWith(_SnAccountBadge value, $Res Function(_SnAccountBadge) _then) = __$SnAccountBadgeCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String id, String type, String? label, String? caption, Map<String, dynamic> meta, DateTime? expiredAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
|  String id, String type, String? label, String? caption, Map<String, dynamic> meta, DateTime? expiredAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? activatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -726,7 +800,7 @@ class __$SnAccountBadgeCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnAccountBadge | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? label = freezed,Object? caption = freezed,Object? meta = null,Object? expiredAt = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? label = freezed,Object? caption = freezed,Object? meta = null,Object? expiredAt = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? activatedAt = freezed,Object? deletedAt = freezed,}) { | ||||
|   return _then(_SnAccountBadge( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||
| @@ -737,7 +811,8 @@ as Map<String, dynamic>,expiredAt: freezed == expiredAt ? _self.expiredAt : expi | ||||
| as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,activatedAt: freezed == activatedAt ? _self.activatedAt : activatedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?, | ||||
|   )); | ||||
| } | ||||
| @@ -1072,6 +1147,148 @@ as String, | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$SnVerificationMark { | ||||
|  | ||||
|  int get type; String? get title; String? get description; String? get verifiedBy; | ||||
| /// Create a copy of SnVerificationMark | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnVerificationMarkCopyWith<SnVerificationMark> get copyWith => _$SnVerificationMarkCopyWithImpl<SnVerificationMark>(this as SnVerificationMark, _$identity); | ||||
|  | ||||
|   /// Serializes this SnVerificationMark to a JSON map. | ||||
|   Map<String, dynamic> toJson(); | ||||
|  | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnVerificationMark&&(identical(other.type, type) || other.type == type)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.verifiedBy, verifiedBy) || other.verifiedBy == verifiedBy)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,type,title,description,verifiedBy); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnVerificationMark(type: $type, title: $title, description: $description, verifiedBy: $verifiedBy)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class $SnVerificationMarkCopyWith<$Res>  { | ||||
|   factory $SnVerificationMarkCopyWith(SnVerificationMark value, $Res Function(SnVerificationMark) _then) = _$SnVerificationMarkCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  int type, String? title, String? description, String? verifiedBy | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class _$SnVerificationMarkCopyWithImpl<$Res> | ||||
|     implements $SnVerificationMarkCopyWith<$Res> { | ||||
|   _$SnVerificationMarkCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final SnVerificationMark _self; | ||||
|   final $Res Function(SnVerificationMark) _then; | ||||
|  | ||||
| /// Create a copy of SnVerificationMark | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? title = freezed,Object? description = freezed,Object? verifiedBy = freezed,}) { | ||||
|   return _then(_self.copyWith( | ||||
| type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||
| as int,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable | ||||
| as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable | ||||
| as String?,verifiedBy: freezed == verifiedBy ? _self.verifiedBy : verifiedBy // ignore: cast_nullable_to_non_nullable | ||||
| as String?, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SnVerificationMark implements SnVerificationMark { | ||||
|   const _SnVerificationMark({required this.type, required this.title, required this.description, required this.verifiedBy}); | ||||
|   factory _SnVerificationMark.fromJson(Map<String, dynamic> json) => _$SnVerificationMarkFromJson(json); | ||||
|  | ||||
| @override final  int type; | ||||
| @override final  String? title; | ||||
| @override final  String? description; | ||||
| @override final  String? verifiedBy; | ||||
|  | ||||
| /// Create a copy of SnVerificationMark | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| _$SnVerificationMarkCopyWith<_SnVerificationMark> get copyWith => __$SnVerificationMarkCopyWithImpl<_SnVerificationMark>(this, _$identity); | ||||
|  | ||||
| @override | ||||
| Map<String, dynamic> toJson() { | ||||
|   return _$SnVerificationMarkToJson(this, ); | ||||
| } | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnVerificationMark&&(identical(other.type, type) || other.type == type)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.verifiedBy, verifiedBy) || other.verifiedBy == verifiedBy)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,type,title,description,verifiedBy); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnVerificationMark(type: $type, title: $title, description: $description, verifiedBy: $verifiedBy)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class _$SnVerificationMarkCopyWith<$Res> implements $SnVerificationMarkCopyWith<$Res> { | ||||
|   factory _$SnVerificationMarkCopyWith(_SnVerificationMark value, $Res Function(_SnVerificationMark) _then) = __$SnVerificationMarkCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  int type, String? title, String? description, String? verifiedBy | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class __$SnVerificationMarkCopyWithImpl<$Res> | ||||
|     implements _$SnVerificationMarkCopyWith<$Res> { | ||||
|   __$SnVerificationMarkCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final _SnVerificationMark _self; | ||||
|   final $Res Function(_SnVerificationMark) _then; | ||||
|  | ||||
| /// Create a copy of SnVerificationMark | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? title = freezed,Object? description = freezed,Object? verifiedBy = freezed,}) { | ||||
|   return _then(_SnVerificationMark( | ||||
| type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||
| as int,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable | ||||
| as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable | ||||
| as String?,verifiedBy: freezed == verifiedBy ? _self.verifiedBy : verifiedBy // ignore: cast_nullable_to_non_nullable | ||||
| as String?, | ||||
|   )); | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| // dart format on | ||||
|   | ||||
| @@ -43,10 +43,28 @@ Map<String, dynamic> _$SnAccountToJson(_SnAccount instance) => | ||||
| _SnAccountProfile _$SnAccountProfileFromJson(Map<String, dynamic> json) => | ||||
|     _SnAccountProfile( | ||||
|       id: json['id'] as String, | ||||
|       firstName: json['first_name'] as String?, | ||||
|       middleName: json['middle_name'] as String?, | ||||
|       lastName: json['last_name'] as String?, | ||||
|       firstName: json['first_name'] as String? ?? '', | ||||
|       middleName: json['middle_name'] as String? ?? '', | ||||
|       lastName: json['last_name'] as String? ?? '', | ||||
|       bio: json['bio'] as String? ?? '', | ||||
|       gender: json['gender'] as String? ?? '', | ||||
|       pronouns: json['pronouns'] as String? ?? '', | ||||
|       location: json['location'] as String? ?? '', | ||||
|       timeZone: json['time_zone'] as String? ?? '', | ||||
|       birthday: | ||||
|           json['birthday'] == null | ||||
|               ? null | ||||
|               : DateTime.parse(json['birthday'] as String), | ||||
|       lastSeenAt: | ||||
|           json['last_seen_at'] == null | ||||
|               ? null | ||||
|               : DateTime.parse(json['last_seen_at'] as String), | ||||
|       activeBadge: | ||||
|           json['active_badge'] == null | ||||
|               ? null | ||||
|               : SnAccountBadge.fromJson( | ||||
|                 json['active_badge'] as Map<String, dynamic>, | ||||
|               ), | ||||
|       experience: (json['experience'] as num).toInt(), | ||||
|       level: (json['level'] as num).toInt(), | ||||
|       levelingProgress: (json['leveling_progress'] as num).toDouble(), | ||||
| @@ -60,6 +78,12 @@ _SnAccountProfile _$SnAccountProfileFromJson(Map<String, dynamic> json) => | ||||
|               : SnCloudFile.fromJson( | ||||
|                 json['background'] as Map<String, dynamic>, | ||||
|               ), | ||||
|       verification: | ||||
|           json['verification'] == null | ||||
|               ? null | ||||
|               : SnVerificationMark.fromJson( | ||||
|                 json['verification'] as Map<String, dynamic>, | ||||
|               ), | ||||
|       createdAt: DateTime.parse(json['created_at'] as String), | ||||
|       updatedAt: DateTime.parse(json['updated_at'] as String), | ||||
|       deletedAt: | ||||
| @@ -75,11 +99,19 @@ Map<String, dynamic> _$SnAccountProfileToJson(_SnAccountProfile instance) => | ||||
|       'middle_name': instance.middleName, | ||||
|       'last_name': instance.lastName, | ||||
|       'bio': instance.bio, | ||||
|       'gender': instance.gender, | ||||
|       'pronouns': instance.pronouns, | ||||
|       'location': instance.location, | ||||
|       'time_zone': instance.timeZone, | ||||
|       'birthday': instance.birthday?.toIso8601String(), | ||||
|       'last_seen_at': instance.lastSeenAt?.toIso8601String(), | ||||
|       'active_badge': instance.activeBadge?.toJson(), | ||||
|       'experience': instance.experience, | ||||
|       'level': instance.level, | ||||
|       'leveling_progress': instance.levelingProgress, | ||||
|       'picture': instance.picture?.toJson(), | ||||
|       'background': instance.background?.toJson(), | ||||
|       'verification': instance.verification?.toJson(), | ||||
|       'created_at': instance.createdAt.toIso8601String(), | ||||
|       'updated_at': instance.updatedAt.toIso8601String(), | ||||
|       'deleted_at': instance.deletedAt?.toIso8601String(), | ||||
| @@ -137,6 +169,10 @@ _SnAccountBadge _$SnAccountBadgeFromJson(Map<String, dynamic> json) => | ||||
|       accountId: json['account_id'] as String, | ||||
|       createdAt: DateTime.parse(json['created_at'] as String), | ||||
|       updatedAt: DateTime.parse(json['updated_at'] as String), | ||||
|       activatedAt: | ||||
|           json['activated_at'] == null | ||||
|               ? null | ||||
|               : DateTime.parse(json['activated_at'] as String), | ||||
|       deletedAt: | ||||
|           json['deleted_at'] == null | ||||
|               ? null | ||||
| @@ -154,6 +190,7 @@ Map<String, dynamic> _$SnAccountBadgeToJson(_SnAccountBadge instance) => | ||||
|       'account_id': instance.accountId, | ||||
|       'created_at': instance.createdAt.toIso8601String(), | ||||
|       'updated_at': instance.updatedAt.toIso8601String(), | ||||
|       'activated_at': instance.activatedAt?.toIso8601String(), | ||||
|       'deleted_at': instance.deletedAt?.toIso8601String(), | ||||
|     }; | ||||
|  | ||||
| @@ -226,3 +263,19 @@ Map<String, dynamic> _$SnNotificationToJson(_SnNotification instance) => | ||||
|       'viewed_at': instance.viewedAt?.toIso8601String(), | ||||
|       'account_id': instance.accountId, | ||||
|     }; | ||||
|  | ||||
| _SnVerificationMark _$SnVerificationMarkFromJson(Map<String, dynamic> json) => | ||||
|     _SnVerificationMark( | ||||
|       type: (json['type'] as num).toInt(), | ||||
|       title: json['title'] as String?, | ||||
|       description: json['description'] as String?, | ||||
|       verifiedBy: json['verified_by'] as String?, | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$SnVerificationMarkToJson(_SnVerificationMark instance) => | ||||
|     <String, dynamic>{ | ||||
|       'type': instance.type, | ||||
|       'title': instance.title, | ||||
|       'description': instance.description, | ||||
|       'verified_by': instance.verifiedBy, | ||||
|     }; | ||||
|   | ||||
| @@ -230,6 +230,9 @@ class CallNotifier extends _$CallNotifier { | ||||
|   String? get roomId => _roomId; | ||||
|  | ||||
|   Future<void> joinRoom(String roomId) async { | ||||
|     if (_roomId == roomId && _room != null) { | ||||
|       return; | ||||
|     } | ||||
|     _roomId = roomId; | ||||
|     if (_room != null) { | ||||
|       await _room!.disconnect(); | ||||
|   | ||||
| @@ -6,7 +6,7 @@ part of 'call.dart'; | ||||
| // RiverpodGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| String _$callNotifierHash() => r'2082a572b5cfb4bf929dc1ed492c52cd2735452e'; | ||||
| String _$callNotifierHash() => r'e04cea314c823e407d49fd616d90d77491232c12'; | ||||
|  | ||||
| /// See also [CallNotifier]. | ||||
| @ProviderFor(CallNotifier) | ||||
|   | ||||
							
								
								
									
										56
									
								
								lib/pods/event_calendar.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								lib/pods/event_calendar.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/activity.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
|  | ||||
| part 'event_calendar.g.dart'; | ||||
|  | ||||
| /// Query parameters for fetching event calendar data | ||||
| class EventCalendarQuery { | ||||
|   /// Username to fetch calendar for, null means current user ('me') | ||||
|   final String? uname; | ||||
|    | ||||
|   /// Year to fetch calendar for | ||||
|   final int year; | ||||
|    | ||||
|   /// Month to fetch calendar for | ||||
|   final int month; | ||||
|  | ||||
|   const EventCalendarQuery({ | ||||
|     required this.uname, | ||||
|     required this.year, | ||||
|     required this.month, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) => | ||||
|       identical(this, other) || | ||||
|       other is EventCalendarQuery && | ||||
|           runtimeType == other.runtimeType && | ||||
|           uname == other.uname && | ||||
|           year == other.year && | ||||
|           month == other.month; | ||||
|  | ||||
|   @override | ||||
|   int get hashCode => uname.hashCode ^ year.hashCode ^ month.hashCode; | ||||
| } | ||||
|  | ||||
| /// Provider for fetching event calendar data | ||||
| /// This can be used anywhere in the app where calendar data is needed | ||||
| @riverpod | ||||
| Future<List<SnEventCalendarEntry>> eventCalendar( | ||||
|   Ref ref, | ||||
|   EventCalendarQuery query, | ||||
| ) async { | ||||
|   final client = ref.watch(apiClientProvider); | ||||
|   final resp = await client.get('/accounts/${query.uname ?? 'me'}/calendar',  | ||||
|     queryParameters: { | ||||
|       'year': query.year, | ||||
|       'month': query.month, | ||||
|     }, | ||||
|   ); | ||||
|   return resp.data | ||||
|       .map((e) => SnEventCalendarEntry.fromJson(e)) | ||||
|       .cast<SnEventCalendarEntry>() | ||||
|       .toList(); | ||||
| } | ||||
| @@ -6,8 +6,7 @@ part of 'event_calendar.dart'; | ||||
| // RiverpodGenerator | ||||
| // ************************************************************************** | ||||
| 
 | ||||
| String _$accountEventCalendarHash() => | ||||
|     r'57405caaf53a83d121b6bb4b70540134fb581525'; | ||||
| String _$eventCalendarHash() => r'6f2454404fa8660b96334d654490e1a40ee53e10'; | ||||
| 
 | ||||
| /// Copied from Dart SDK | ||||
| class _SystemHash { | ||||
| @@ -30,24 +29,36 @@ class _SystemHash { | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /// See also [accountEventCalendar]. | ||||
| @ProviderFor(accountEventCalendar) | ||||
| const accountEventCalendarProvider = AccountEventCalendarFamily(); | ||||
| /// Provider for fetching event calendar data | ||||
| /// This can be used anywhere in the app where calendar data is needed | ||||
| /// | ||||
| /// Copied from [eventCalendar]. | ||||
| @ProviderFor(eventCalendar) | ||||
| const eventCalendarProvider = EventCalendarFamily(); | ||||
| 
 | ||||
| /// See also [accountEventCalendar]. | ||||
| class AccountEventCalendarFamily | ||||
| /// Provider for fetching event calendar data | ||||
| /// This can be used anywhere in the app where calendar data is needed | ||||
| /// | ||||
| /// Copied from [eventCalendar]. | ||||
| class EventCalendarFamily | ||||
|     extends Family<AsyncValue<List<SnEventCalendarEntry>>> { | ||||
|   /// See also [accountEventCalendar]. | ||||
|   const AccountEventCalendarFamily(); | ||||
|   /// Provider for fetching event calendar data | ||||
|   /// This can be used anywhere in the app where calendar data is needed | ||||
|   /// | ||||
|   /// Copied from [eventCalendar]. | ||||
|   const EventCalendarFamily(); | ||||
| 
 | ||||
|   /// See also [accountEventCalendar]. | ||||
|   AccountEventCalendarProvider call(EventCalendarQuery query) { | ||||
|     return AccountEventCalendarProvider(query); | ||||
|   /// Provider for fetching event calendar data | ||||
|   /// This can be used anywhere in the app where calendar data is needed | ||||
|   /// | ||||
|   /// Copied from [eventCalendar]. | ||||
|   EventCalendarProvider call(EventCalendarQuery query) { | ||||
|     return EventCalendarProvider(query); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   AccountEventCalendarProvider getProviderOverride( | ||||
|     covariant AccountEventCalendarProvider provider, | ||||
|   EventCalendarProvider getProviderOverride( | ||||
|     covariant EventCalendarProvider provider, | ||||
|   ) { | ||||
|     return call(provider.query); | ||||
|   } | ||||
| @@ -64,29 +75,35 @@ class AccountEventCalendarFamily | ||||
|       _allTransitiveDependencies; | ||||
| 
 | ||||
|   @override | ||||
|   String? get name => r'accountEventCalendarProvider'; | ||||
|   String? get name => r'eventCalendarProvider'; | ||||
| } | ||||
| 
 | ||||
| /// See also [accountEventCalendar]. | ||||
| class AccountEventCalendarProvider | ||||
| /// Provider for fetching event calendar data | ||||
| /// This can be used anywhere in the app where calendar data is needed | ||||
| /// | ||||
| /// Copied from [eventCalendar]. | ||||
| class EventCalendarProvider | ||||
|     extends AutoDisposeFutureProvider<List<SnEventCalendarEntry>> { | ||||
|   /// See also [accountEventCalendar]. | ||||
|   AccountEventCalendarProvider(EventCalendarQuery query) | ||||
|   /// Provider for fetching event calendar data | ||||
|   /// This can be used anywhere in the app where calendar data is needed | ||||
|   /// | ||||
|   /// Copied from [eventCalendar]. | ||||
|   EventCalendarProvider(EventCalendarQuery query) | ||||
|     : this._internal( | ||||
|         (ref) => accountEventCalendar(ref as AccountEventCalendarRef, query), | ||||
|         from: accountEventCalendarProvider, | ||||
|         name: r'accountEventCalendarProvider', | ||||
|         (ref) => eventCalendar(ref as EventCalendarRef, query), | ||||
|         from: eventCalendarProvider, | ||||
|         name: r'eventCalendarProvider', | ||||
|         debugGetCreateSourceHash: | ||||
|             const bool.fromEnvironment('dart.vm.product') | ||||
|                 ? null | ||||
|                 : _$accountEventCalendarHash, | ||||
|         dependencies: AccountEventCalendarFamily._dependencies, | ||||
|                 : _$eventCalendarHash, | ||||
|         dependencies: EventCalendarFamily._dependencies, | ||||
|         allTransitiveDependencies: | ||||
|             AccountEventCalendarFamily._allTransitiveDependencies, | ||||
|             EventCalendarFamily._allTransitiveDependencies, | ||||
|         query: query, | ||||
|       ); | ||||
| 
 | ||||
|   AccountEventCalendarProvider._internal( | ||||
|   EventCalendarProvider._internal( | ||||
|     super._createNotifier, { | ||||
|     required super.name, | ||||
|     required super.dependencies, | ||||
| @@ -100,15 +117,13 @@ class AccountEventCalendarProvider | ||||
| 
 | ||||
|   @override | ||||
|   Override overrideWith( | ||||
|     FutureOr<List<SnEventCalendarEntry>> Function( | ||||
|       AccountEventCalendarRef provider, | ||||
|     ) | ||||
|     FutureOr<List<SnEventCalendarEntry>> Function(EventCalendarRef provider) | ||||
|     create, | ||||
|   ) { | ||||
|     return ProviderOverride( | ||||
|       origin: this, | ||||
|       override: AccountEventCalendarProvider._internal( | ||||
|         (ref) => create(ref as AccountEventCalendarRef), | ||||
|       override: EventCalendarProvider._internal( | ||||
|         (ref) => create(ref as EventCalendarRef), | ||||
|         from: from, | ||||
|         name: null, | ||||
|         dependencies: null, | ||||
| @@ -121,12 +136,12 @@ class AccountEventCalendarProvider | ||||
| 
 | ||||
|   @override | ||||
|   AutoDisposeFutureProviderElement<List<SnEventCalendarEntry>> createElement() { | ||||
|     return _AccountEventCalendarProviderElement(this); | ||||
|     return _EventCalendarProviderElement(this); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     return other is AccountEventCalendarProvider && other.query == query; | ||||
|     return other is EventCalendarProvider && other.query == query; | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
| @@ -140,20 +155,19 @@ class AccountEventCalendarProvider | ||||
| 
 | ||||
| @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||
| // ignore: unused_element | ||||
| mixin AccountEventCalendarRef | ||||
| mixin EventCalendarRef | ||||
|     on AutoDisposeFutureProviderRef<List<SnEventCalendarEntry>> { | ||||
|   /// The parameter `query` of this provider. | ||||
|   EventCalendarQuery get query; | ||||
| } | ||||
| 
 | ||||
| class _AccountEventCalendarProviderElement | ||||
| class _EventCalendarProviderElement | ||||
|     extends AutoDisposeFutureProviderElement<List<SnEventCalendarEntry>> | ||||
|     with AccountEventCalendarRef { | ||||
|   _AccountEventCalendarProviderElement(super.provider); | ||||
|     with EventCalendarRef { | ||||
|   _EventCalendarProviderElement(super.provider); | ||||
| 
 | ||||
|   @override | ||||
|   EventCalendarQuery get query => | ||||
|       (origin as AccountEventCalendarProvider).query; | ||||
|   EventCalendarQuery get query => (origin as EventCalendarProvider).query; | ||||
| } | ||||
| 
 | ||||
| // ignore_for_file: type=lint | ||||
| @@ -33,6 +33,7 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> { | ||||
|     final prefs = _ref.read(sharedPreferencesProvider); | ||||
|     await prefs.remove(kTokenPairStoreKey); | ||||
|     _ref.invalidate(userInfoProvider); | ||||
|     _ref.invalidate(tokenProvider); | ||||
|   } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -8,14 +8,14 @@ class AppRouter extends RootStackRouter { | ||||
|  | ||||
|   @override | ||||
|   List<AutoRoute> get routes => [ | ||||
|     AutoRoute(page: PostComposeRoute.page, path: '/posts/compose'), | ||||
|     AutoRoute(page: PostEditRoute.page, path: '/posts/:id/edit'), | ||||
|     AutoRoute( | ||||
|       page: ExploreShellRoute.page, | ||||
|       path: '/', | ||||
|       children: [ | ||||
|         AutoRoute(page: ExploreRoute.page, path: ''), | ||||
|         AutoRoute(page: PostComposeRoute.page, path: 'posts/compose'), | ||||
|         AutoRoute(page: PostDetailRoute.page, path: 'posts/:id'), | ||||
|         AutoRoute(page: PostEditRoute.page, path: 'posts/:id/edit'), | ||||
|         AutoRoute(page: PublisherProfileRoute.page, path: 'publishers/:name'), | ||||
|       ], | ||||
|     ), | ||||
| @@ -51,6 +51,7 @@ class AppRouter extends RootStackRouter { | ||||
|       path: '/creators', | ||||
|       children: [ | ||||
|         AutoRoute(page: CreatorHubRoute.page, path: ''), | ||||
|         AutoRoute(page: CreatorPostListRoute.page, path: ':name/posts'), | ||||
|         AutoRoute(page: StickersRoute.page, path: ':name/stickers'), | ||||
|         AutoRoute(page: NewStickerPacksRoute.page, path: ':name/stickers/new'), | ||||
|         AutoRoute( | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -11,6 +11,7 @@ import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:island/screens/notification.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/widgets/account/account_name.dart'; | ||||
| import 'package:island/widgets/account/status.dart'; | ||||
| import 'package:island/widgets/account/leveling_progress.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| @@ -83,7 +84,7 @@ class AccountScreen extends HookConsumerWidget { | ||||
|                       child: AspectRatio( | ||||
|                         aspectRatio: 16 / 7, | ||||
|                         child: CloudImageWidget( | ||||
|                           fileId: user.value!.profile.background!.id, | ||||
|                           file: user.value?.profile.background, | ||||
|                           fit: BoxFit.cover, | ||||
|                         ), | ||||
|                       ), | ||||
| @@ -94,7 +95,7 @@ class AccountScreen extends HookConsumerWidget { | ||||
|                     children: [ | ||||
|                       GestureDetector( | ||||
|                         child: ProfilePictureWidget( | ||||
|                           fileId: user.value?.profile.picture?.id, | ||||
|                           file: user.value?.profile.picture, | ||||
|                           radius: 24, | ||||
|                         ), | ||||
|                         onTap: () { | ||||
| @@ -112,7 +113,13 @@ class AccountScreen extends HookConsumerWidget { | ||||
|                               crossAxisAlignment: CrossAxisAlignment.baseline, | ||||
|                               textBaseline: TextBaseline.alphabetic, | ||||
|                               children: [ | ||||
|                                 Text(user.value!.nick).bold().fontSize(16), | ||||
|                                 AccountName( | ||||
|                                   account: user.value!, | ||||
|                                   style: TextStyle( | ||||
|                                     fontSize: 16, | ||||
|                                     fontWeight: FontWeight.bold, | ||||
|                                   ), | ||||
|                                 ), | ||||
|                                 Text('@${user.value!.name}'), | ||||
|                               ], | ||||
|                             ), | ||||
| @@ -214,16 +221,6 @@ class AccountScreen extends HookConsumerWidget { | ||||
|                 context.router.push(RelationshipRoute()); | ||||
|               }, | ||||
|             ), | ||||
|             ListTile( | ||||
|               minTileHeight: 48, | ||||
|               leading: const Icon(Symbols.edit), | ||||
|               trailing: const Icon(Symbols.chevron_right), | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|               title: Text('updateYourProfile').tr(), | ||||
|               onTap: () { | ||||
|                 context.router.push(UpdateProfileRoute()); | ||||
|               }, | ||||
|             ), | ||||
|             const Divider(height: 1).padding(vertical: 8), | ||||
|             ListTile( | ||||
|               minTileHeight: 48, | ||||
| @@ -235,6 +232,16 @@ class AccountScreen extends HookConsumerWidget { | ||||
|                 context.router.push(SettingsRoute()); | ||||
|               }, | ||||
|             ), | ||||
|             ListTile( | ||||
|               minTileHeight: 48, | ||||
|               leading: const Icon(Symbols.person_edit), | ||||
|               trailing: const Icon(Symbols.chevron_right), | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|               title: Text('updateYourProfile').tr(), | ||||
|               onTap: () { | ||||
|                 context.router.push(UpdateProfileRoute()); | ||||
|               }, | ||||
|             ), | ||||
|             ListTile( | ||||
|               minTileHeight: 48, | ||||
|               leading: const Icon(Symbols.manage_accounts), | ||||
|   | ||||
							
								
								
									
										120
									
								
								lib/screens/account/event_calendar.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								lib/screens/account/event_calendar.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,120 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| 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/pods/event_calendar.dart'; | ||||
| import 'package:island/screens/account/profile.dart'; | ||||
| import 'package:island/widgets/account/account_nameplate.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/account/event_calendar.dart'; | ||||
| import 'package:island/widgets/account/fortune_graph.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @RoutePage() | ||||
| class EventCalanderScreen extends HookConsumerWidget { | ||||
|   final String name; | ||||
|   const EventCalanderScreen({super.key, @PathParam("name") required this.name}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     // Get the current date | ||||
|     final now = DateTime.now(); | ||||
|  | ||||
|     // Create the query for the current month | ||||
|     final query = useState( | ||||
|       EventCalendarQuery(uname: name, year: now.year, month: now.month), | ||||
|     ); | ||||
|  | ||||
|     // Watch the event calendar data | ||||
|     final events = ref.watch(eventCalendarProvider(query.value)); | ||||
|     final user = ref.watch(accountProvider(name)); | ||||
|  | ||||
|     // Track the selected day for synchronizing between widgets | ||||
|     final selectedDay = useState(now); | ||||
|  | ||||
|     void onMonthChanged(int year, int month) { | ||||
|       query.value = EventCalendarQuery( | ||||
|         uname: query.value.uname, | ||||
|         year: year, | ||||
|         month: month, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     // Function to handle day selection for synchronizing between widgets | ||||
|     void onDaySelected(DateTime day) { | ||||
|       selectedDay.value = day; | ||||
|     } | ||||
|  | ||||
|     return AppScaffold( | ||||
|       noBackground: false, | ||||
|       appBar: AppBar( | ||||
|         leading: const PageBackButton(), | ||||
|         title: Text('eventCalander').tr(), | ||||
|       ), | ||||
|       body: SingleChildScrollView( | ||||
|         child: | ||||
|             MediaQuery.of(context).size.width > 480 | ||||
|                 ? ConstrainedBox( | ||||
|                   constraints: BoxConstraints(maxWidth: 480), | ||||
|                   child: Column( | ||||
|                     children: [ | ||||
|                       Card( | ||||
|                         margin: EdgeInsets.all(16), | ||||
|                         child: Column( | ||||
|                           children: [ | ||||
|                             // Use the reusable EventCalendarWidget | ||||
|                             EventCalendarWidget( | ||||
|                               events: events, | ||||
|                               initialDate: now, | ||||
|                               showEventDetails: true, | ||||
|                               onMonthChanged: onMonthChanged, | ||||
|                               onDaySelected: onDaySelected, | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                       ), | ||||
|  | ||||
|                       // Add the fortune graph widget | ||||
|                       const Divider(height: 1), | ||||
|                       FortuneGraphWidget( | ||||
|                         events: events, | ||||
|                         constrainWidth: true, | ||||
|                         onPointSelected: onDaySelected, | ||||
|                       ), | ||||
|  | ||||
|                       // Show user profile if viewing someone else's calendar | ||||
|                       if (name != 'me' && user.hasValue) | ||||
|                         AccountNameplate(name: name), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ).center() | ||||
|                 : Column( | ||||
|                   children: [ | ||||
|                     // Use the reusable EventCalendarWidget | ||||
|                     EventCalendarWidget( | ||||
|                       events: events, | ||||
|                       initialDate: now, | ||||
|                       showEventDetails: true, | ||||
|                       onMonthChanged: onMonthChanged, | ||||
|                       onDaySelected: onDaySelected, | ||||
|                     ), | ||||
|  | ||||
|                     // Add the fortune graph widget | ||||
|                     const Divider(height: 1), | ||||
|                     FortuneGraphWidget( | ||||
|                       events: events, | ||||
|                       onPointSelected: onDaySelected, | ||||
|                     ).padding(horizontal: 8, vertical: 4), | ||||
|  | ||||
|                     // Show user profile if viewing someone else's calendar | ||||
|                     if (name != 'me' && user.hasValue) | ||||
|                       AccountNameplate(name: name), | ||||
|                     Gap(MediaQuery.of(context).padding.bottom + 16), | ||||
|                   ], | ||||
|                 ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -1,232 +0,0 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/activity.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/screens/account/profile.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:table_calendar/table_calendar.dart'; | ||||
|  | ||||
| part 'event_calendar.g.dart'; | ||||
| part 'event_calendar.freezed.dart'; | ||||
|  | ||||
| @freezed | ||||
| sealed class EventCalendarQuery with _$EventCalendarQuery { | ||||
|   const factory EventCalendarQuery({ | ||||
|     required String? uname, | ||||
|     required int year, | ||||
|     required int month, | ||||
|   }) = _EventCalendarQuery; | ||||
| } | ||||
|  | ||||
| @riverpod | ||||
| Future<List<SnEventCalendarEntry>> accountEventCalendar( | ||||
|   Ref ref, | ||||
|   EventCalendarQuery query, | ||||
| ) async { | ||||
|   final client = ref.watch(apiClientProvider); | ||||
|   final resp = await client.get('/accounts/${query.uname ?? 'me'}/calendar'); | ||||
|   return resp.data | ||||
|       .map((e) => SnEventCalendarEntry.fromJson(e)) | ||||
|       .cast<SnEventCalendarEntry>() | ||||
|       .toList(); | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class EventCalanderScreen extends HookConsumerWidget { | ||||
|   final String name; | ||||
|   const EventCalanderScreen({super.key, @PathParam("name") required this.name}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final selectedMonth = useState(DateTime.now().month); | ||||
|     final selectedYear = useState(DateTime.now().year); | ||||
|  | ||||
|     final selectedDay = useState(DateTime.now()); | ||||
|  | ||||
|     final user = ref.watch(accountProvider(name)); | ||||
|     final events = ref.watch( | ||||
|       accountEventCalendarProvider( | ||||
|         EventCalendarQuery( | ||||
|           uname: name, | ||||
|           year: selectedYear.value, | ||||
|           month: selectedMonth.value, | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|  | ||||
|     final content = Column( | ||||
|       children: [ | ||||
|         TableCalendar( | ||||
|           locale: EasyLocalization.of(context)!.locale.toString(), | ||||
|           firstDay: DateTime.now().add(Duration(days: -3650)), | ||||
|           lastDay: DateTime.now().add(Duration(days: 3650)), | ||||
|           focusedDay: DateTime.utc( | ||||
|             selectedYear.value, | ||||
|             selectedMonth.value, | ||||
|             DateTime.now().day, | ||||
|           ), | ||||
|           calendarFormat: CalendarFormat.month, | ||||
|           selectedDayPredicate: (day) { | ||||
|             return isSameDay(selectedDay.value, day); | ||||
|           }, | ||||
|           onDaySelected: (value, _) { | ||||
|             selectedDay.value = value; | ||||
|           }, | ||||
|           onPageChanged: (focusedDay) { | ||||
|             selectedMonth.value = focusedDay.month; | ||||
|             selectedYear.value = focusedDay.year; | ||||
|           }, | ||||
|           eventLoader: (day) { | ||||
|             return events.value | ||||
|                     ?.where((e) => isSameDay(e.date, day)) | ||||
|                     .expand((e) => [...e.statuses, e.checkInResult]) | ||||
|                     .where((e) => e != null) | ||||
|                     .toList() ?? | ||||
|                 []; | ||||
|           }, | ||||
|           calendarBuilders: CalendarBuilders( | ||||
|             dowBuilder: (context, day) { | ||||
|               final text = DateFormat.EEEEE().format(day); | ||||
|               return Center(child: Text(text)); | ||||
|             }, | ||||
|             markerBuilder: (context, day, events) { | ||||
|               var checkInResult = | ||||
|                   events.whereType<SnCheckInResult>().firstOrNull; | ||||
|               if (checkInResult != null) { | ||||
|                 return Positioned( | ||||
|                   top: 32, | ||||
|                   child: Text( | ||||
|                     ['大凶', '凶', '中平', '吉', '大吉'][checkInResult.level], | ||||
|                     style: TextStyle( | ||||
|                       fontSize: 9, | ||||
|                       color: | ||||
|                           isSameDay(selectedDay.value, day) | ||||
|                               ? Theme.of(context).colorScheme.onPrimaryContainer | ||||
|                               : isSameDay(DateTime.now(), day) | ||||
|                               ? Theme.of( | ||||
|                                 context, | ||||
|                               ).colorScheme.onSecondaryContainer | ||||
|                               : Theme.of(context).colorScheme.onSurface, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ); | ||||
|               } | ||||
|               return null; | ||||
|             }, | ||||
|           ), | ||||
|         ), | ||||
|         const Divider(height: 1).padding(top: 8), | ||||
|         AnimatedSwitcher( | ||||
|           duration: const Duration(milliseconds: 300), | ||||
|           child: Builder( | ||||
|             builder: (context) { | ||||
|               final event = | ||||
|                   events.value | ||||
|                       ?.where((e) => isSameDay(e.date, selectedDay.value)) | ||||
|                       .firstOrNull; | ||||
|               return Column( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                 children: [ | ||||
|                   Text(DateFormat.EEEE().format(selectedDay.value)) | ||||
|                       .fontSize(16) | ||||
|                       .bold() | ||||
|                       .textColor( | ||||
|                         Theme.of(context).colorScheme.onSecondaryContainer, | ||||
|                       ), | ||||
|                   Text(DateFormat.yMd().format(selectedDay.value)) | ||||
|                       .fontSize(12) | ||||
|                       .textColor( | ||||
|                         Theme.of(context).colorScheme.onSecondaryContainer, | ||||
|                       ), | ||||
|                   const Gap(16), | ||||
|                   if (event?.checkInResult != null) | ||||
|                     Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                       children: [ | ||||
|                         Text( | ||||
|                           'checkInResultLevel${event!.checkInResult!.level}', | ||||
|                         ).tr().fontSize(16).bold(), | ||||
|                         for (final tip in event.checkInResult!.tips) | ||||
|                           Row( | ||||
|                             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                             spacing: 8, | ||||
|                             children: [ | ||||
|                               Icon( | ||||
|                                 Symbols.circle, | ||||
|                                 size: 12, | ||||
|                                 fill: 1, | ||||
|                               ).padding(top: 4, right: 4), | ||||
|                               Expanded( | ||||
|                                 child: Column( | ||||
|                                   crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                                   children: [ | ||||
|                                     Text(tip.title).bold(), | ||||
|                                     Text(tip.content), | ||||
|                                   ], | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ], | ||||
|                           ).padding(top: 8), | ||||
|                       ], | ||||
|                     ), | ||||
|                   if (event?.checkInResult == null && | ||||
|                       (event?.statuses.isEmpty ?? true)) | ||||
|                     Text('eventCalanderEmpty').tr(), | ||||
|                 ], | ||||
|               ).padding(vertical: 24, horizontal: 24); | ||||
|             }, | ||||
|           ), | ||||
|         ), | ||||
|         if (name != 'me' && user.hasValue) | ||||
|           Container( | ||||
|             decoration: BoxDecoration( | ||||
|               border: Border.all( | ||||
|                 width: 1 / MediaQuery.of(context).devicePixelRatio, | ||||
|                 color: Theme.of(context).dividerColor, | ||||
|               ), | ||||
|               borderRadius: BorderRadius.all(Radius.circular(8)), | ||||
|             ), | ||||
|             margin: EdgeInsets.all(16), | ||||
|             child: Card( | ||||
|               margin: EdgeInsets.zero, | ||||
|               elevation: 0, | ||||
|               color: Colors.transparent, | ||||
|               child: ListTile( | ||||
|                 leading: ProfilePictureWidget( | ||||
|                   fileId: user.value!.profile.picture?.id, | ||||
|                 ), | ||||
|                 title: Text(user.value!.nick).bold(), | ||||
|                 subtitle: Text('@${user.value!.name}'), | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|       ], | ||||
|     ); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       noBackground: false, | ||||
|       appBar: AppBar( | ||||
|         leading: const PageBackButton(), | ||||
|         title: Text('eventCalander').tr(), | ||||
|       ), | ||||
|       body: SingleChildScrollView( | ||||
|         child: | ||||
|             MediaQuery.of(context).size.width > 480 | ||||
|                 ? ConstrainedBox( | ||||
|                   constraints: BoxConstraints(maxWidth: 480), | ||||
|                   child: Card(margin: EdgeInsets.all(16), child: content), | ||||
|                 ).center() | ||||
|                 : content, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -1,148 +0,0 @@ | ||||
| // dart format width=80 | ||||
| // coverage:ignore-file | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| // 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 'event_calendar.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // FreezedGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| // dart format off | ||||
| T _$identity<T>(T value) => value; | ||||
| /// @nodoc | ||||
| mixin _$EventCalendarQuery { | ||||
|  | ||||
|  String? get uname; int get year; int get month; | ||||
| /// Create a copy of EventCalendarQuery | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| $EventCalendarQueryCopyWith<EventCalendarQuery> get copyWith => _$EventCalendarQueryCopyWithImpl<EventCalendarQuery>(this as EventCalendarQuery, _$identity); | ||||
|  | ||||
|  | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is EventCalendarQuery&&(identical(other.uname, uname) || other.uname == uname)&&(identical(other.year, year) || other.year == year)&&(identical(other.month, month) || other.month == month)); | ||||
| } | ||||
|  | ||||
|  | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,uname,year,month); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'EventCalendarQuery(uname: $uname, year: $year, month: $month)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class $EventCalendarQueryCopyWith<$Res>  { | ||||
|   factory $EventCalendarQueryCopyWith(EventCalendarQuery value, $Res Function(EventCalendarQuery) _then) = _$EventCalendarQueryCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String? uname, int year, int month | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class _$EventCalendarQueryCopyWithImpl<$Res> | ||||
|     implements $EventCalendarQueryCopyWith<$Res> { | ||||
|   _$EventCalendarQueryCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final EventCalendarQuery _self; | ||||
|   final $Res Function(EventCalendarQuery) _then; | ||||
|  | ||||
| /// Create a copy of EventCalendarQuery | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? uname = freezed,Object? year = null,Object? month = null,}) { | ||||
|   return _then(_self.copyWith( | ||||
| uname: freezed == uname ? _self.uname : uname // ignore: cast_nullable_to_non_nullable | ||||
| as String?,year: null == year ? _self.year : year // ignore: cast_nullable_to_non_nullable | ||||
| as int,month: null == month ? _self.month : month // ignore: cast_nullable_to_non_nullable | ||||
| as int, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
| /// @nodoc | ||||
|  | ||||
|  | ||||
| class _EventCalendarQuery implements EventCalendarQuery { | ||||
|   const _EventCalendarQuery({required this.uname, required this.year, required this.month}); | ||||
|    | ||||
|  | ||||
| @override final  String? uname; | ||||
| @override final  int year; | ||||
| @override final  int month; | ||||
|  | ||||
| /// Create a copy of EventCalendarQuery | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| _$EventCalendarQueryCopyWith<_EventCalendarQuery> get copyWith => __$EventCalendarQueryCopyWithImpl<_EventCalendarQuery>(this, _$identity); | ||||
|  | ||||
|  | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _EventCalendarQuery&&(identical(other.uname, uname) || other.uname == uname)&&(identical(other.year, year) || other.year == year)&&(identical(other.month, month) || other.month == month)); | ||||
| } | ||||
|  | ||||
|  | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,uname,year,month); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'EventCalendarQuery(uname: $uname, year: $year, month: $month)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class _$EventCalendarQueryCopyWith<$Res> implements $EventCalendarQueryCopyWith<$Res> { | ||||
|   factory _$EventCalendarQueryCopyWith(_EventCalendarQuery value, $Res Function(_EventCalendarQuery) _then) = __$EventCalendarQueryCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String? uname, int year, int month | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class __$EventCalendarQueryCopyWithImpl<$Res> | ||||
|     implements _$EventCalendarQueryCopyWith<$Res> { | ||||
|   __$EventCalendarQueryCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final _EventCalendarQuery _self; | ||||
|   final $Res Function(_EventCalendarQuery) _then; | ||||
|  | ||||
| /// Create a copy of EventCalendarQuery | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? uname = freezed,Object? year = null,Object? month = null,}) { | ||||
|   return _then(_EventCalendarQuery( | ||||
| uname: freezed == uname ? _self.uname : uname // ignore: cast_nullable_to_non_nullable | ||||
| as String?,year: null == year ? _self.year : year // ignore: cast_nullable_to_non_nullable | ||||
| as int,month: null == month ? _self.month : month // ignore: cast_nullable_to_non_nullable | ||||
| as int, | ||||
|   )); | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| // dart format on | ||||
| @@ -1,4 +1,3 @@ | ||||
| import 'dart:convert'; | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:auto_route/annotations.dart'; | ||||
| @@ -6,24 +5,22 @@ import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:flutter_otp_text_field/flutter_otp_text_field.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/auth.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/screens/account/me/settings_auth_factors.dart'; | ||||
| import 'package:island/screens/account/me/settings_connections.dart'; | ||||
| import 'package:island/screens/account/me/settings_contacts.dart'; | ||||
| import 'package:island/screens/auth/captcha.dart'; | ||||
| import 'package:island/screens/auth/login.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/widgets/account/account_session_sheet.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:island/widgets/response.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:qr_flutter/qr_flutter.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @@ -45,6 +42,15 @@ Future<List<SnContactMethod>> contactMethods(Ref ref) async { | ||||
|       .toList(); | ||||
| } | ||||
|  | ||||
| @riverpod | ||||
| Future<List<SnAccountConnection>> accountConnections(Ref ref) async { | ||||
|   final client = ref.read(apiClientProvider); | ||||
|   final resp = await client.get('/accounts/me/connections'); | ||||
|   return resp.data | ||||
|       .map<SnAccountConnection>((e) => SnAccountConnection.fromJson(e)) | ||||
|       .toList(); | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class AccountSettingsScreen extends HookConsumerWidget { | ||||
|   const AccountSettingsScreen({super.key}); | ||||
| @@ -86,7 +92,7 @@ class AccountSettingsScreen extends HookConsumerWidget { | ||||
|       ).push(MaterialPageRoute(builder: (context) => CaptchaScreen())); | ||||
|       if (captchaTk == null) return; | ||||
|       try { | ||||
|         showLoadingModal(context); | ||||
|         if (context.mounted) showLoadingModal(context); | ||||
|         final userInfo = ref.read(userInfoProvider); | ||||
|         final client = ref.read(apiClientProvider); | ||||
|         await client.post( | ||||
| @@ -122,6 +128,96 @@ class AccountSettingsScreen extends HookConsumerWidget { | ||||
|           ); | ||||
|         }, | ||||
|       ), | ||||
|       ExpansionTile( | ||||
|         leading: const Icon( | ||||
|           Symbols.link, | ||||
|         ).alignment(Alignment.centerLeft).width(48), | ||||
|         title: Text('accountConnections').tr(), | ||||
|         subtitle: Text('accountConnectionsDescription').tr().fontSize(12), | ||||
|         tilePadding: const EdgeInsets.only(left: 24, right: 17), | ||||
|         children: [ | ||||
|           ref | ||||
|               .watch(accountConnectionsProvider) | ||||
|               .when( | ||||
|                 data: | ||||
|                     (connections) => Column( | ||||
|                       children: [ | ||||
|                         for (final connection in connections) | ||||
|                           ListTile( | ||||
|                             minLeadingWidth: 48, | ||||
|                             contentPadding: const EdgeInsets.only( | ||||
|                               left: 16, | ||||
|                               right: 17, | ||||
|                               top: 2, | ||||
|                               bottom: 4, | ||||
|                             ), | ||||
|                             title: | ||||
|                                 Text( | ||||
|                                   getLocalizedProviderName(connection.provider), | ||||
|                                 ).tr(), | ||||
|                             subtitle: | ||||
|                                 connection.meta['email'] != null | ||||
|                                     ? Text(connection.meta['email']) | ||||
|                                     : Text(connection.providedIdentifier), | ||||
|                             leading: CircleAvatar( | ||||
|                               child: getProviderIcon( | ||||
|                                 connection.provider, | ||||
|                                 size: 16, | ||||
|                                 color: | ||||
|                                     Theme.of( | ||||
|                                       context, | ||||
|                                     ).colorScheme.onPrimaryContainer, | ||||
|                               ), | ||||
|                             ).padding(top: 4), | ||||
|                             trailing: const Icon(Symbols.chevron_right), | ||||
|                             onTap: () { | ||||
|                               showModalBottomSheet( | ||||
|                                 context: context, | ||||
|                                 builder: | ||||
|                                     (context) => AccountConnectionSheet( | ||||
|                                       connection: connection, | ||||
|                                     ), | ||||
|                               ).then((value) { | ||||
|                                 if (value == true) { | ||||
|                                   ref.invalidate(accountConnectionsProvider); | ||||
|                                 } | ||||
|                               }); | ||||
|                             }, | ||||
|                           ), | ||||
|                         if (connections.isNotEmpty) const Divider(height: 1), | ||||
|                         ListTile( | ||||
|                           minLeadingWidth: 48, | ||||
|                           contentPadding: const EdgeInsets.only( | ||||
|                             left: 24, | ||||
|                             right: 17, | ||||
|                           ), | ||||
|                           title: Text('accountConnectionAdd').tr(), | ||||
|                           leading: const Icon(Symbols.add), | ||||
|                           trailing: const Icon(Symbols.chevron_right), | ||||
|                           onTap: () { | ||||
|                             showModalBottomSheet( | ||||
|                               context: context, | ||||
|                               builder: | ||||
|                                   (context) => | ||||
|                                       const AccountConnectionNewSheet(), | ||||
|                             ).then((value) { | ||||
|                               if (value == true) { | ||||
|                                 ref.invalidate(accountConnectionsProvider); | ||||
|                               } | ||||
|                             }); | ||||
|                           }, | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                 error: | ||||
|                     (err, _) => ResponseErrorWidget( | ||||
|                       error: err, | ||||
|                       onRetry: () => ref.invalidate(accountConnectionsProvider), | ||||
|                     ), | ||||
|                 loading: () => const ResponseLoadingWidget(), | ||||
|               ), | ||||
|         ], | ||||
|       ), | ||||
|       ExpansionTile( | ||||
|         leading: const Icon( | ||||
|           Symbols.security, | ||||
| @@ -184,7 +280,7 @@ class AccountSettingsScreen extends HookConsumerWidget { | ||||
|                           showModalBottomSheet( | ||||
|                             context: context, | ||||
|                             builder: | ||||
|                                 (context) => _AuthFactorSheet(factor: factor), | ||||
|                                 (context) => AuthFactorSheet(factor: factor), | ||||
|                           ).then((value) { | ||||
|                             if (value == true) { | ||||
|                               ref.invalidate(authFactorsProvider); | ||||
| @@ -205,7 +301,7 @@ class AccountSettingsScreen extends HookConsumerWidget { | ||||
|                       onTap: () { | ||||
|                         showModalBottomSheet( | ||||
|                           context: context, | ||||
|                           builder: (context) => const _AuthFactorNewSheet(), | ||||
|                           builder: (context) => const AuthFactorNewSheet(), | ||||
|                         ).then((value) { | ||||
|                           if (value == true) { | ||||
|                             ref.invalidate(authFactorsProvider); | ||||
| @@ -289,7 +385,7 @@ class AccountSettingsScreen extends HookConsumerWidget { | ||||
|                                 context: context, | ||||
|                                 builder: | ||||
|                                     (context) => | ||||
|                                         _ContactMethodSheet(contact: contact), | ||||
|                                         ContactMethodSheet(contact: contact), | ||||
|                               ).then((value) { | ||||
|                                 if (value == true) { | ||||
|                                   ref.invalidate(contactMethodsProvider); | ||||
| @@ -311,7 +407,7 @@ class AccountSettingsScreen extends HookConsumerWidget { | ||||
|                             showModalBottomSheet( | ||||
|                               context: context, | ||||
|                               builder: | ||||
|                                   (context) => const _ContactMethodNewSheet(), | ||||
|                                   (context) => const ContactMethodNewSheet(), | ||||
|                             ).then((value) { | ||||
|                               if (value == true) { | ||||
|                                 ref.invalidate(contactMethodsProvider); | ||||
| @@ -471,599 +567,3 @@ class _SettingsSection extends StatelessWidget { | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _AuthFactorSheet extends HookConsumerWidget { | ||||
|   final SnAuthFactor factor; | ||||
|   const _AuthFactorSheet({required this.factor}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     Future<void> deleteFactor() async { | ||||
|       final confirm = await showConfirmAlert( | ||||
|         'authFactorDeleteHint'.tr(), | ||||
|         'authFactorDelete'.tr(), | ||||
|       ); | ||||
|       if (!confirm || !context.mounted) return; | ||||
|       try { | ||||
|         showLoadingModal(context); | ||||
|         final client = ref.read(apiClientProvider); | ||||
|         await client.delete('/accounts/me/factors/${factor.id}'); | ||||
|         if (context.mounted) Navigator.pop(context, true); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Future<void> disableFactor() async { | ||||
|       final confirm = await showConfirmAlert( | ||||
|         'authFactorDisableHint'.tr(), | ||||
|         'authFactorDisable'.tr(), | ||||
|       ); | ||||
|       if (!confirm || !context.mounted) return; | ||||
|       try { | ||||
|         showLoadingModal(context); | ||||
|         final client = ref.read(apiClientProvider); | ||||
|         await client.post('/accounts/me/factors/${factor.id}/disable'); | ||||
|         if (context.mounted) Navigator.pop(context, true); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Future<void> enableFactor() async { | ||||
|       String? password; | ||||
|       if ([3].contains(factor.type)) { | ||||
|         final confirmed = await showDialog<bool>( | ||||
|           context: context, | ||||
|           builder: | ||||
|               (context) => AlertDialog( | ||||
|                 title: Text('authFactorEnable').tr(), | ||||
|                 content: Column( | ||||
|                   mainAxisSize: MainAxisSize.min, | ||||
|                   children: [ | ||||
|                     Text('authFactorEnableHint').tr(), | ||||
|                     const SizedBox(height: 16), | ||||
|                     OtpTextField( | ||||
|                       showCursor: false, | ||||
|                       numberOfFields: 6, | ||||
|                       obscureText: false, | ||||
|                       showFieldAsBox: true, | ||||
|                       focusedBorderColor: Theme.of(context).colorScheme.primary, | ||||
|                       onSubmit: (String verificationCode) { | ||||
|                         password = verificationCode; | ||||
|                       }, | ||||
|                       textStyle: Theme.of(context).textTheme.titleLarge!, | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|                 actions: [ | ||||
|                   TextButton( | ||||
|                     onPressed: () => Navigator.of(context).pop(false), | ||||
|                     child: Text('cancel').tr(), | ||||
|                   ), | ||||
|                   TextButton( | ||||
|                     onPressed: () => Navigator.of(context).pop(true), | ||||
|                     child: Text('confirm').tr(), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|         ); | ||||
|         if (confirmed == false || | ||||
|             (password?.isEmpty ?? true) || | ||||
|             !context.mounted) { | ||||
|           return; | ||||
|         } | ||||
|       } | ||||
|       try { | ||||
|         showLoadingModal(context); | ||||
|         final client = ref.read(apiClientProvider); | ||||
|         await client.post( | ||||
|           '/accounts/me/factors/${factor.id}/enable', | ||||
|           data: jsonEncode(password), | ||||
|         ); | ||||
|         if (context.mounted) Navigator.pop(context, true); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: 'authFactor'.tr(), | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             mainAxisAlignment: MainAxisAlignment.center, | ||||
|             children: [ | ||||
|               Icon(kFactorTypes[factor.type]!.$3, size: 32), | ||||
|               const Gap(8), | ||||
|               Text(kFactorTypes[factor.type]!.$1).tr(), | ||||
|               const Gap(4), | ||||
|               Text( | ||||
|                 kFactorTypes[factor.type]!.$2, | ||||
|                 style: Theme.of(context).textTheme.bodySmall, | ||||
|               ).tr(), | ||||
|               const Gap(10), | ||||
|               Row( | ||||
|                 children: [ | ||||
|                   if (factor.enabledAt == null) | ||||
|                     Badge( | ||||
|                       label: Text('authFactorDisabled'.tr()), | ||||
|                       textColor: Theme.of(context).colorScheme.onSecondary, | ||||
|                       backgroundColor: Theme.of(context).colorScheme.secondary, | ||||
|                     ) | ||||
|                   else | ||||
|                     Badge( | ||||
|                       label: Text('authFactorEnabled'.tr()), | ||||
|                       textColor: Theme.of(context).colorScheme.onPrimary, | ||||
|                       backgroundColor: Theme.of(context).colorScheme.primary, | ||||
|                     ), | ||||
|                 ], | ||||
|               ), | ||||
|             ], | ||||
|           ).padding(all: 20), | ||||
|           const Divider(height: 1), | ||||
|           if (factor.enabledAt != null) | ||||
|             ListTile( | ||||
|               leading: const Icon(Symbols.disabled_by_default), | ||||
|               title: Text('authFactorDisable').tr(), | ||||
|               onTap: disableFactor, | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 20), | ||||
|             ) | ||||
|           else | ||||
|             ListTile( | ||||
|               leading: const Icon(Symbols.check_circle), | ||||
|               title: Text('authFactorEnable').tr(), | ||||
|               onTap: enableFactor, | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 20), | ||||
|             ), | ||||
|           ListTile( | ||||
|             leading: const Icon(Symbols.delete), | ||||
|             title: Text('authFactorDelete').tr(), | ||||
|             onTap: deleteFactor, | ||||
|             contentPadding: EdgeInsets.symmetric(horizontal: 20), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _AuthFactorNewSheet extends HookConsumerWidget { | ||||
|   const _AuthFactorNewSheet(); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final factorType = useState<int>(0); | ||||
|     final secretController = useTextEditingController(); | ||||
|  | ||||
|     Future<void> addFactor() async { | ||||
|       try { | ||||
|         showLoadingModal(context); | ||||
|         final apiClient = ref.read(apiClientProvider); | ||||
|         final resp = await apiClient.post( | ||||
|           '/accounts/me/factors', | ||||
|           data: {'type': factorType.value, 'secret': secretController.text}, | ||||
|         ); | ||||
|         final factor = SnAuthFactor.fromJson(resp.data); | ||||
|         if (!context.mounted) return; | ||||
|         hideLoadingModal(context); | ||||
|         if (factor.type == 3) { | ||||
|           showModalBottomSheet( | ||||
|             context: context, | ||||
|             builder: (context) => _AuthFactorNewAdditonalSheet(factor: factor), | ||||
|           ).then((_) { | ||||
|             if (context.mounted) { | ||||
|               showSnackBar(context, 'contactMethodVerificationNeeded'.tr()); | ||||
|             } | ||||
|             if (context.mounted) Navigator.pop(context, true); | ||||
|           }); | ||||
|         } else { | ||||
|           Navigator.pop(context, true); | ||||
|         } | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: 'authFactorNew'.tr(), | ||||
|       child: Column( | ||||
|         spacing: 16, | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           DropdownButtonFormField<int>( | ||||
|             value: factorType.value, | ||||
|             decoration: InputDecoration( | ||||
|               labelText: 'authFactor'.tr(), | ||||
|               border: const OutlineInputBorder(), | ||||
|             ), | ||||
|             items: | ||||
|                 kFactorTypes.entries.map((entry) { | ||||
|                   return DropdownMenuItem<int>( | ||||
|                     value: entry.key, | ||||
|                     child: Row( | ||||
|                       children: [ | ||||
|                         Icon(entry.value.$3), | ||||
|                         const Gap(8), | ||||
|                         Text(entry.value.$1).tr(), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ); | ||||
|                 }).toList(), | ||||
|             onChanged: (value) { | ||||
|               if (value != null) { | ||||
|                 factorType.value = value; | ||||
|               } | ||||
|             }, | ||||
|           ), | ||||
|           if (factorType.value == 0) | ||||
|             TextField( | ||||
|               controller: secretController, | ||||
|               decoration: InputDecoration( | ||||
|                 prefixIcon: const Icon(Symbols.password_2), | ||||
|                 labelText: 'authFactorSecret'.tr(), | ||||
|                 hintText: 'authFactorSecretHint'.tr(), | ||||
|                 border: const OutlineInputBorder(), | ||||
|               ), | ||||
|               onTapOutside: | ||||
|                   (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|             ), | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.symmetric(horizontal: 16.0), | ||||
|             child: Text(kFactorTypes[factorType.value]!.$2).tr(), | ||||
|           ), | ||||
|           Row( | ||||
|             mainAxisAlignment: MainAxisAlignment.end, | ||||
|             children: [ | ||||
|               TextButton.icon( | ||||
|                 onPressed: addFactor, | ||||
|                 icon: Icon(Symbols.add), | ||||
|                 label: Text('create').tr(), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ], | ||||
|       ).padding(horizontal: 20, vertical: 24), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _AuthFactorNewAdditonalSheet extends StatelessWidget { | ||||
|   final SnAuthFactor factor; | ||||
|   const _AuthFactorNewAdditonalSheet({required this.factor}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final uri = factor.createdResponse?['uri']; | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: 'authFactorAdditional'.tr(), | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           if (uri != null) ...[ | ||||
|             const SizedBox(height: 16), | ||||
|             Center( | ||||
|               child: ClipRRect( | ||||
|                 borderRadius: BorderRadius.circular(16), | ||||
|                 child: QrImageView( | ||||
|                   data: uri, | ||||
|                   version: QrVersions.auto, | ||||
|                   size: 200, | ||||
|                   backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|                   foregroundColor: Theme.of(context).colorScheme.onSurface, | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|             const Gap(16), | ||||
|             Padding( | ||||
|               padding: const EdgeInsets.symmetric(horizontal: 16), | ||||
|               child: Text( | ||||
|                 'authFactorQrCodeScan'.tr(), | ||||
|                 textAlign: TextAlign.center, | ||||
|                 style: Theme.of(context).textTheme.bodySmall, | ||||
|               ), | ||||
|             ), | ||||
|           ] else ...[ | ||||
|             const SizedBox(height: 16), | ||||
|             Center( | ||||
|               child: Text( | ||||
|                 'authFactorNoQrCode'.tr(), | ||||
|                 textAlign: TextAlign.center, | ||||
|                 style: Theme.of(context).textTheme.bodyMedium, | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|           const Gap(16), | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.symmetric(horizontal: 16), | ||||
|             child: TextButton.icon( | ||||
|               onPressed: () => Navigator.of(context).pop(), | ||||
|               icon: const Icon(Symbols.check), | ||||
|               label: Text('next'.tr()), | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _ContactMethodSheet extends HookConsumerWidget { | ||||
|   final SnContactMethod contact; | ||||
|   const _ContactMethodSheet({required this.contact}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     Future<void> deleteContactMethod() async { | ||||
|       final confirm = await showConfirmAlert( | ||||
|         'contactMethodDeleteHint'.tr(), | ||||
|         'contactMethodDelete'.tr(), | ||||
|       ); | ||||
|       if (!confirm || !context.mounted) return; | ||||
|       try { | ||||
|         showLoadingModal(context); | ||||
|         final client = ref.read(apiClientProvider); | ||||
|         await client.delete('/accounts/me/contacts/${contact.id}'); | ||||
|         if (context.mounted) Navigator.pop(context, true); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Future<void> verifyContactMethod() async { | ||||
|       try { | ||||
|         showLoadingModal(context); | ||||
|         final client = ref.read(apiClientProvider); | ||||
|         await client.post('/accounts/me/contacts/${contact.id}/verify'); | ||||
|         if (context.mounted) { | ||||
|           showSnackBar(context, 'contactMethodVerificationSent'.tr()); | ||||
|         } | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Future<void> setContactMethodAsPrimary() async { | ||||
|       try { | ||||
|         showLoadingModal(context); | ||||
|         final client = ref.read(apiClientProvider); | ||||
|         await client.post('/accounts/me/contacts/${contact.id}/primary'); | ||||
|         if (context.mounted) Navigator.pop(context, true); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: 'contactMethod'.tr(), | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             mainAxisAlignment: MainAxisAlignment.center, | ||||
|             children: [ | ||||
|               Icon(switch (contact.type) { | ||||
|                 0 => Symbols.mail, | ||||
|                 1 => Symbols.phone, | ||||
|                 _ => Symbols.home, | ||||
|               }, size: 32), | ||||
|               const Gap(8), | ||||
|               Text(switch (contact.type) { | ||||
|                 0 => 'contactMethodTypeEmail'.tr(), | ||||
|                 1 => 'contactMethodTypePhone'.tr(), | ||||
|                 _ => 'contactMethodTypeAddress'.tr(), | ||||
|               }), | ||||
|               const Gap(4), | ||||
|               Text( | ||||
|                 contact.content, | ||||
|                 style: Theme.of(context).textTheme.bodySmall, | ||||
|               ), | ||||
|               const Gap(10), | ||||
|               Row( | ||||
|                 children: [ | ||||
|                   if (contact.verifiedAt == null) | ||||
|                     Badge( | ||||
|                       label: Text('contactMethodUnverified'.tr()), | ||||
|                       textColor: Theme.of(context).colorScheme.onSecondary, | ||||
|                       backgroundColor: Theme.of(context).colorScheme.secondary, | ||||
|                     ) | ||||
|                   else | ||||
|                     Badge( | ||||
|                       label: Text('contactMethodVerified'.tr()), | ||||
|                       textColor: Theme.of(context).colorScheme.onPrimary, | ||||
|                       backgroundColor: Theme.of(context).colorScheme.primary, | ||||
|                     ), | ||||
|                   if (contact.isPrimary) | ||||
|                     Padding( | ||||
|                       padding: const EdgeInsets.only(left: 8.0), | ||||
|                       child: Badge( | ||||
|                         label: Text('contactMethodPrimary'.tr()), | ||||
|                         textColor: Theme.of(context).colorScheme.onTertiary, | ||||
|                         backgroundColor: Theme.of(context).colorScheme.tertiary, | ||||
|                       ), | ||||
|                     ), | ||||
|                 ], | ||||
|               ), | ||||
|             ], | ||||
|           ).padding(all: 20), | ||||
|           const Divider(height: 1), | ||||
|           if (contact.verifiedAt == null) | ||||
|             ListTile( | ||||
|               leading: const Icon(Symbols.verified), | ||||
|               title: Text('contactMethodVerify').tr(), | ||||
|               onTap: verifyContactMethod, | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 20), | ||||
|             ), | ||||
|           if (contact.verifiedAt != null && !contact.isPrimary) | ||||
|             ListTile( | ||||
|               leading: const Icon(Symbols.star), | ||||
|               title: Text('contactMethodSetPrimary').tr(), | ||||
|               onTap: setContactMethodAsPrimary, | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 20), | ||||
|             ), | ||||
|           ListTile( | ||||
|             leading: const Icon(Symbols.delete), | ||||
|             title: Text('contactMethodDelete').tr(), | ||||
|             onTap: deleteContactMethod, | ||||
|             contentPadding: EdgeInsets.symmetric(horizontal: 20), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _ContactMethodNewSheet extends HookConsumerWidget { | ||||
|   const _ContactMethodNewSheet(); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final contactType = useState<int>(0); | ||||
|     final contentController = useTextEditingController(); | ||||
|  | ||||
|     Future<void> addContactMethod() async { | ||||
|       if (contentController.text.isEmpty) { | ||||
|         showSnackBar(context, 'contactMethodContentEmpty'.tr()); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       try { | ||||
|         showLoadingModal(context); | ||||
|         final apiClient = ref.read(apiClientProvider); | ||||
|         await apiClient.post( | ||||
|           '/accounts/me/contacts', | ||||
|           data: {'type': contactType.value, 'content': contentController.text}, | ||||
|         ); | ||||
|         if (context.mounted) { | ||||
|           showSnackBar(context, 'contactMethodVerificationNeeded'.tr()); | ||||
|           Navigator.pop(context, true); | ||||
|         } | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: 'contactMethodNew'.tr(), | ||||
|       child: Column( | ||||
|         spacing: 16, | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           DropdownButtonFormField<int>( | ||||
|             value: contactType.value, | ||||
|             decoration: InputDecoration( | ||||
|               labelText: 'contactMethodType'.tr(), | ||||
|               border: const OutlineInputBorder(), | ||||
|             ), | ||||
|             items: [ | ||||
|               DropdownMenuItem<int>( | ||||
|                 value: 0, | ||||
|                 child: Row( | ||||
|                   children: [ | ||||
|                     Icon(Symbols.mail), | ||||
|                     const Gap(8), | ||||
|                     Text('contactMethodTypeEmail'.tr()), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|               DropdownMenuItem<int>( | ||||
|                 value: 1, | ||||
|                 child: Row( | ||||
|                   children: [ | ||||
|                     Icon(Symbols.phone), | ||||
|                     const Gap(8), | ||||
|                     Text('contactMethodTypePhone'.tr()), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|               DropdownMenuItem<int>( | ||||
|                 value: 2, | ||||
|                 child: Row( | ||||
|                   children: [ | ||||
|                     Icon(Symbols.home), | ||||
|                     const Gap(8), | ||||
|                     Text('contactMethodTypeAddress'.tr()), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|             onChanged: (value) { | ||||
|               if (value != null) { | ||||
|                 contactType.value = value; | ||||
|               } | ||||
|             }, | ||||
|           ), | ||||
|           TextField( | ||||
|             controller: contentController, | ||||
|             decoration: InputDecoration( | ||||
|               prefixIcon: Icon(switch (contactType.value) { | ||||
|                 0 => Symbols.mail, | ||||
|                 1 => Symbols.phone, | ||||
|                 _ => Symbols.home, | ||||
|               }), | ||||
|               labelText: switch (contactType.value) { | ||||
|                 0 => 'contactMethodTypeEmail'.tr(), | ||||
|                 1 => 'contactMethodTypePhone'.tr(), | ||||
|                 _ => 'contactMethodTypeAddress'.tr(), | ||||
|               }, | ||||
|               hintText: switch (contactType.value) { | ||||
|                 0 => 'contactMethodEmailHint'.tr(), | ||||
|                 1 => 'contactMethodPhoneHint'.tr(), | ||||
|                 _ => 'contactMethodAddressHint'.tr(), | ||||
|               }, | ||||
|               border: const OutlineInputBorder(), | ||||
|             ), | ||||
|             keyboardType: switch (contactType.value) { | ||||
|               0 => TextInputType.emailAddress, | ||||
|               1 => TextInputType.phone, | ||||
|               _ => TextInputType.multiline, | ||||
|             }, | ||||
|             maxLines: switch (contactType.value) { | ||||
|               2 => 3, | ||||
|               _ => 1, | ||||
|             }, | ||||
|             onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|           ), | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.symmetric(horizontal: 16.0), | ||||
|             child: | ||||
|                 Text(switch (contactType.value) { | ||||
|                   0 => 'contactMethodEmailDescription', | ||||
|                   1 => 'contactMethodPhoneDescription', | ||||
|                   _ => 'contactMethodAddressDescription', | ||||
|                 }).tr(), | ||||
|           ), | ||||
|           Row( | ||||
|             mainAxisAlignment: MainAxisAlignment.end, | ||||
|             children: [ | ||||
|               TextButton.icon( | ||||
|                 onPressed: addContactMethod, | ||||
|                 icon: Icon(Symbols.add), | ||||
|                 label: Text('create').tr(), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ], | ||||
|       ).padding(horizontal: 20, vertical: 24), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -44,5 +44,26 @@ final contactMethodsProvider = | ||||
| @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||
| // ignore: unused_element | ||||
| typedef ContactMethodsRef = AutoDisposeFutureProviderRef<List<SnContactMethod>>; | ||||
| String _$accountConnectionsHash() => | ||||
|     r'38a309d596e0ea2539cd92ea86984e1e4fb346e4'; | ||||
|  | ||||
| /// See also [accountConnections]. | ||||
| @ProviderFor(accountConnections) | ||||
| final accountConnectionsProvider = | ||||
|     AutoDisposeFutureProvider<List<SnAccountConnection>>.internal( | ||||
|       accountConnections, | ||||
|       name: r'accountConnectionsProvider', | ||||
|       debugGetCreateSourceHash: | ||||
|           const bool.fromEnvironment('dart.vm.product') | ||||
|               ? null | ||||
|               : _$accountConnectionsHash, | ||||
|       dependencies: null, | ||||
|       allTransitiveDependencies: null, | ||||
|     ); | ||||
|  | ||||
| @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||
| // ignore: unused_element | ||||
| typedef AccountConnectionsRef = | ||||
|     AutoDisposeFutureProviderRef<List<SnAccountConnection>>; | ||||
| // 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 | ||||
|   | ||||
							
								
								
									
										342
									
								
								lib/screens/account/me/settings_auth_factors.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										342
									
								
								lib/screens/account/me/settings_auth_factors.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,342 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:flutter_otp_text_field/flutter_otp_text_field.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/auth.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/screens/auth/login.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:qr_flutter/qr_flutter.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| class AuthFactorSheet extends HookConsumerWidget { | ||||
|   final SnAuthFactor factor; | ||||
|   const AuthFactorSheet({super.key, required this.factor}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     Future<void> deleteFactor() async { | ||||
|       final confirm = await showConfirmAlert( | ||||
|         'authFactorDeleteHint'.tr(), | ||||
|         'authFactorDelete'.tr(), | ||||
|       ); | ||||
|       if (!confirm || !context.mounted) return; | ||||
|       try { | ||||
|         showLoadingModal(context); | ||||
|         final client = ref.read(apiClientProvider); | ||||
|         await client.delete('/accounts/me/factors/${factor.id}'); | ||||
|         if (context.mounted) Navigator.pop(context, true); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Future<void> disableFactor() async { | ||||
|       final confirm = await showConfirmAlert( | ||||
|         'authFactorDisableHint'.tr(), | ||||
|         'authFactorDisable'.tr(), | ||||
|       ); | ||||
|       if (!confirm || !context.mounted) return; | ||||
|       try { | ||||
|         showLoadingModal(context); | ||||
|         final client = ref.read(apiClientProvider); | ||||
|         await client.post('/accounts/me/factors/${factor.id}/disable'); | ||||
|         if (context.mounted) Navigator.pop(context, true); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Future<void> enableFactor() async { | ||||
|       String? password; | ||||
|       if ([3].contains(factor.type)) { | ||||
|         final confirmed = await showDialog<bool>( | ||||
|           context: context, | ||||
|           builder: | ||||
|               (context) => AlertDialog( | ||||
|                 title: Text('authFactorEnable').tr(), | ||||
|                 content: Column( | ||||
|                   mainAxisSize: MainAxisSize.min, | ||||
|                   children: [ | ||||
|                     Text('authFactorEnableHint').tr(), | ||||
|                     const SizedBox(height: 16), | ||||
|                     OtpTextField( | ||||
|                       showCursor: false, | ||||
|                       numberOfFields: 6, | ||||
|                       obscureText: false, | ||||
|                       showFieldAsBox: true, | ||||
|                       focusedBorderColor: Theme.of(context).colorScheme.primary, | ||||
|                       onSubmit: (String verificationCode) { | ||||
|                         password = verificationCode; | ||||
|                       }, | ||||
|                       textStyle: Theme.of(context).textTheme.titleLarge!, | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|                 actions: [ | ||||
|                   TextButton( | ||||
|                     onPressed: () => Navigator.of(context).pop(false), | ||||
|                     child: Text('cancel').tr(), | ||||
|                   ), | ||||
|                   TextButton( | ||||
|                     onPressed: () => Navigator.of(context).pop(true), | ||||
|                     child: Text('confirm').tr(), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|         ); | ||||
|         if (confirmed == false || | ||||
|             (password?.isEmpty ?? true) || | ||||
|             !context.mounted) { | ||||
|           return; | ||||
|         } | ||||
|       } | ||||
|       try { | ||||
|         showLoadingModal(context); | ||||
|         final client = ref.read(apiClientProvider); | ||||
|         await client.post( | ||||
|           '/accounts/me/factors/${factor.id}/enable', | ||||
|           data: jsonEncode(password), | ||||
|         ); | ||||
|         if (context.mounted) Navigator.pop(context, true); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: 'authFactor'.tr(), | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             mainAxisAlignment: MainAxisAlignment.center, | ||||
|             children: [ | ||||
|               Icon(kFactorTypes[factor.type]!.$3, size: 32), | ||||
|               const Gap(8), | ||||
|               Text(kFactorTypes[factor.type]!.$1).tr(), | ||||
|               const Gap(4), | ||||
|               Text( | ||||
|                 kFactorTypes[factor.type]!.$2, | ||||
|                 style: Theme.of(context).textTheme.bodySmall, | ||||
|               ).tr(), | ||||
|               const Gap(10), | ||||
|               Row( | ||||
|                 children: [ | ||||
|                   if (factor.enabledAt == null) | ||||
|                     Badge( | ||||
|                       label: Text('authFactorDisabled'.tr()), | ||||
|                       textColor: Theme.of(context).colorScheme.onSecondary, | ||||
|                       backgroundColor: Theme.of(context).colorScheme.secondary, | ||||
|                     ) | ||||
|                   else | ||||
|                     Badge( | ||||
|                       label: Text('authFactorEnabled'.tr()), | ||||
|                       textColor: Theme.of(context).colorScheme.onPrimary, | ||||
|                       backgroundColor: Theme.of(context).colorScheme.primary, | ||||
|                     ), | ||||
|                 ], | ||||
|               ), | ||||
|             ], | ||||
|           ).padding(all: 20), | ||||
|           const Divider(height: 1), | ||||
|           if (factor.enabledAt != null) | ||||
|             ListTile( | ||||
|               leading: const Icon(Symbols.disabled_by_default), | ||||
|               title: Text('authFactorDisable').tr(), | ||||
|               onTap: disableFactor, | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 20), | ||||
|             ) | ||||
|           else | ||||
|             ListTile( | ||||
|               leading: const Icon(Symbols.check_circle), | ||||
|               title: Text('authFactorEnable').tr(), | ||||
|               onTap: enableFactor, | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 20), | ||||
|             ), | ||||
|           ListTile( | ||||
|             leading: const Icon(Symbols.delete), | ||||
|             title: Text('authFactorDelete').tr(), | ||||
|             onTap: deleteFactor, | ||||
|             contentPadding: EdgeInsets.symmetric(horizontal: 20), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class AuthFactorNewSheet extends HookConsumerWidget { | ||||
|   const AuthFactorNewSheet({super.key}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final factorType = useState<int>(0); | ||||
|     final secretController = useTextEditingController(); | ||||
|  | ||||
|     Future<void> addFactor() async { | ||||
|       try { | ||||
|         showLoadingModal(context); | ||||
|         final apiClient = ref.read(apiClientProvider); | ||||
|         final resp = await apiClient.post( | ||||
|           '/accounts/me/factors', | ||||
|           data: {'type': factorType.value, 'secret': secretController.text}, | ||||
|         ); | ||||
|         final factor = SnAuthFactor.fromJson(resp.data); | ||||
|         if (!context.mounted) return; | ||||
|         hideLoadingModal(context); | ||||
|         if (factor.type == 3) { | ||||
|           showModalBottomSheet( | ||||
|             context: context, | ||||
|             builder: (context) => AuthFactorNewAdditonalSheet(factor: factor), | ||||
|           ).then((_) { | ||||
|             if (context.mounted) { | ||||
|               showSnackBar(context, 'contactMethodVerificationNeeded'.tr()); | ||||
|             } | ||||
|             if (context.mounted) Navigator.pop(context, true); | ||||
|           }); | ||||
|         } else { | ||||
|           Navigator.pop(context, true); | ||||
|         } | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: 'authFactorNew'.tr(), | ||||
|       child: Column( | ||||
|         spacing: 16, | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           DropdownButtonFormField<int>( | ||||
|             value: factorType.value, | ||||
|             decoration: InputDecoration( | ||||
|               labelText: 'authFactor'.tr(), | ||||
|               border: const OutlineInputBorder(), | ||||
|             ), | ||||
|             items: | ||||
|                 kFactorTypes.entries.map((entry) { | ||||
|                   return DropdownMenuItem<int>( | ||||
|                     value: entry.key, | ||||
|                     child: Row( | ||||
|                       children: [ | ||||
|                         Icon(entry.value.$3), | ||||
|                         const Gap(8), | ||||
|                         Text(entry.value.$1).tr(), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ); | ||||
|                 }).toList(), | ||||
|             onChanged: (value) { | ||||
|               if (value != null) { | ||||
|                 factorType.value = value; | ||||
|               } | ||||
|             }, | ||||
|           ), | ||||
|           if (factorType.value == 0) | ||||
|             TextField( | ||||
|               controller: secretController, | ||||
|               decoration: InputDecoration( | ||||
|                 prefixIcon: const Icon(Symbols.password_2), | ||||
|                 labelText: 'authFactorSecret'.tr(), | ||||
|                 hintText: 'authFactorSecretHint'.tr(), | ||||
|                 border: const OutlineInputBorder(), | ||||
|               ), | ||||
|               onTapOutside: | ||||
|                   (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|             ), | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.symmetric(horizontal: 16.0), | ||||
|             child: Text(kFactorTypes[factorType.value]!.$2).tr(), | ||||
|           ), | ||||
|           Row( | ||||
|             mainAxisAlignment: MainAxisAlignment.end, | ||||
|             children: [ | ||||
|               TextButton.icon( | ||||
|                 onPressed: addFactor, | ||||
|                 icon: Icon(Symbols.add), | ||||
|                 label: Text('create').tr(), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ], | ||||
|       ).padding(horizontal: 20, vertical: 24), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class AuthFactorNewAdditonalSheet extends StatelessWidget { | ||||
|   final SnAuthFactor factor; | ||||
|   const AuthFactorNewAdditonalSheet({super.key, required this.factor}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final uri = factor.createdResponse?['uri']; | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: 'authFactorAdditional'.tr(), | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           if (uri != null) ...[ | ||||
|             const SizedBox(height: 16), | ||||
|             Center( | ||||
|               child: ClipRRect( | ||||
|                 borderRadius: BorderRadius.circular(16), | ||||
|                 child: QrImageView( | ||||
|                   data: uri, | ||||
|                   version: QrVersions.auto, | ||||
|                   size: 200, | ||||
|                   backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|                   foregroundColor: Theme.of(context).colorScheme.onSurface, | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|             const Gap(16), | ||||
|             Padding( | ||||
|               padding: const EdgeInsets.symmetric(horizontal: 16), | ||||
|               child: Text( | ||||
|                 'authFactorQrCodeScan'.tr(), | ||||
|                 textAlign: TextAlign.center, | ||||
|                 style: Theme.of(context).textTheme.bodySmall, | ||||
|               ), | ||||
|             ), | ||||
|           ] else ...[ | ||||
|             const SizedBox(height: 16), | ||||
|             Center( | ||||
|               child: Text( | ||||
|                 'authFactorNoQrCode'.tr(), | ||||
|                 textAlign: TextAlign.center, | ||||
|                 style: Theme.of(context).textTheme.bodyMedium, | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|           const Gap(16), | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.symmetric(horizontal: 16), | ||||
|             child: TextButton.icon( | ||||
|               onPressed: () => Navigator.of(context).pop(), | ||||
|               icon: const Icon(Symbols.check), | ||||
|               label: Text('next'.tr()), | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										381
									
								
								lib/screens/account/me/settings_connections.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										381
									
								
								lib/screens/account/me/settings_connections.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,381 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:flutter_svg/flutter_svg.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/auth.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/screens/account/me/settings.dart'; | ||||
| import 'package:island/screens/auth/oidc.native.dart'; | ||||
| import 'package:island/services/text.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:island/widgets/response.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:sign_in_with_apple/sign_in_with_apple.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| // Helper function to get provider icon and localized name | ||||
| Widget getProviderIcon(String provider, {double size = 24, Color? color}) { | ||||
|   final providerLower = provider.toLowerCase(); | ||||
|  | ||||
|   // Check if we have an SVG for this provider | ||||
|   switch (providerLower) { | ||||
|     case 'apple': | ||||
|     case 'microsoft': | ||||
|     case 'google': | ||||
|     case 'github': | ||||
|     case 'discord': | ||||
|       return SvgPicture.asset( | ||||
|         'assets/images/oidc/$providerLower.svg', | ||||
|         width: size, | ||||
|         height: size, | ||||
|         color: color, | ||||
|       ); | ||||
|     default: | ||||
|       return Icon(Symbols.link, size: size); | ||||
|   } | ||||
| } | ||||
|  | ||||
| String getLocalizedProviderName(String provider) { | ||||
|   switch (provider.toLowerCase()) { | ||||
|     case 'apple': | ||||
|       return 'accountConnectionProviderApple'.tr(); | ||||
|     case 'microsoft': | ||||
|       return 'accountConnectionProviderMicrosoft'.tr(); | ||||
|     case 'google': | ||||
|       return 'accountConnectionProviderGoogle'.tr(); | ||||
|     case 'github': | ||||
|       return 'accountConnectionProviderGithub'.tr(); | ||||
|     case 'discord': | ||||
|       return 'accountConnectionProviderDiscord'.tr(); | ||||
|     default: | ||||
|       return provider; | ||||
|   } | ||||
| } | ||||
|  | ||||
| class AccountConnectionSheet extends HookConsumerWidget { | ||||
|   final SnAccountConnection connection; | ||||
|   const AccountConnectionSheet({super.key, required this.connection}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     Future<void> deleteConnection() async { | ||||
|       final confirm = await showConfirmAlert( | ||||
|         'accountConnectionDeleteHint'.tr(), | ||||
|         'accountConnectionDelete'.tr(), | ||||
|       ); | ||||
|       if (!confirm || !context.mounted) return; | ||||
|       try { | ||||
|         showLoadingModal(context); | ||||
|         final client = ref.read(apiClientProvider); | ||||
|         await client.delete('/accounts/me/connections/${connection.id}'); | ||||
|         if (context.mounted) Navigator.pop(context, true); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: 'accountConnections'.tr(), | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             mainAxisAlignment: MainAxisAlignment.center, | ||||
|             children: [ | ||||
|               getProviderIcon( | ||||
|                 connection.provider, | ||||
|                 size: 32, | ||||
|                 color: Theme.of(context).colorScheme.onSurface, | ||||
|               ), | ||||
|               const Gap(8), | ||||
|               Text(getLocalizedProviderName(connection.provider)).tr(), | ||||
|               const Gap(4), | ||||
|               if (connection.meta.isNotEmpty) | ||||
|                 Column( | ||||
|                   crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                   mainAxisSize: MainAxisSize.min, | ||||
|                   children: [ | ||||
|                     for (final meta in connection.meta.entries) | ||||
|                       Text( | ||||
|                         '${meta.key.replaceAll('_', ' ').capitalizeEachWord()}: ${meta.value}', | ||||
|                         style: const TextStyle(fontSize: 12), | ||||
|                       ), | ||||
|                   ], | ||||
|                 ), | ||||
|               Text( | ||||
|                 connection.providedIdentifier, | ||||
|                 style: Theme.of(context).textTheme.bodySmall, | ||||
|               ), | ||||
|               const Gap(8), | ||||
|               Text( | ||||
|                 connection.lastUsedAt.formatSystem(), | ||||
|                 style: Theme.of(context).textTheme.bodySmall, | ||||
|               ).opacity(0.85), | ||||
|             ], | ||||
|           ).padding(all: 20), | ||||
|           const Divider(height: 1), | ||||
|           ListTile( | ||||
|             leading: const Icon(Symbols.delete), | ||||
|             title: Text('accountConnectionDelete').tr(), | ||||
|             onTap: deleteConnection, | ||||
|             contentPadding: const EdgeInsets.symmetric(horizontal: 20), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class AccountConnectionNewSheet extends HookConsumerWidget { | ||||
|   const AccountConnectionNewSheet({super.key}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final selectedProvider = useState<String>('apple'); | ||||
|  | ||||
|     // List of available providers | ||||
|     final providers = ['apple', 'microsoft', 'google', 'github', 'discord']; | ||||
|  | ||||
|     Future<void> addConnection() async { | ||||
|       final client = ref.watch(apiClientProvider); | ||||
|  | ||||
|       switch (selectedProvider.value.toLowerCase()) { | ||||
|         case 'apple': | ||||
|           try { | ||||
|             final credential = await SignInWithApple.getAppleIDCredential( | ||||
|               scopes: [AppleIDAuthorizationScopes.email], | ||||
|               webAuthenticationOptions: WebAuthenticationOptions( | ||||
|                 clientId: 'dev.solsynth.solarpass', | ||||
|                 redirectUri: Uri.parse( | ||||
|                   'https://nt.solian.app/auth/callback/apple', | ||||
|                 ), | ||||
|               ), | ||||
|             ); | ||||
|  | ||||
|             if (context.mounted) showLoadingModal(context); | ||||
|  | ||||
|             await client.post( | ||||
|               '/auth/connect/apple/mobile', | ||||
|               data: { | ||||
|                 'identity_token': credential.identityToken!, | ||||
|                 'authorization_code': credential.authorizationCode, | ||||
|               }, | ||||
|             ); | ||||
|             if (context.mounted) { | ||||
|               showSnackBar(context, 'accountConnectionAddSuccess'.tr()); | ||||
|               Navigator.pop(context, true); | ||||
|             } | ||||
|           } catch (err) { | ||||
|             if (err is SignInWithAppleAuthorizationException) return; | ||||
|             showErrorAlert(err); | ||||
|           } finally { | ||||
|             if (context.mounted) hideLoadingModal(context); | ||||
|           } | ||||
|         case 'microsoft': | ||||
|         case 'google': | ||||
|         case 'github': | ||||
|         case 'discord': | ||||
|           await Navigator.of(context).push( | ||||
|             MaterialPageRoute( | ||||
|               builder: | ||||
|                   (context) => OidcScreen( | ||||
|                     provider: selectedProvider.value.toLowerCase(), | ||||
|                     title: | ||||
|                         'Connect with ${selectedProvider.value.capitalizeEachWord()}', | ||||
|                   ), | ||||
|             ), | ||||
|           ); | ||||
|           if (context.mounted) Navigator.pop(context, true); | ||||
|           break; | ||||
|         default: | ||||
|           showSnackBar(context, 'accountConnectionAddError'.tr()); | ||||
|           return; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: 'accountConnectionAdd'.tr(), | ||||
|       child: Column( | ||||
|         spacing: 16, | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           DropdownButtonFormField<String>( | ||||
|             value: selectedProvider.value, | ||||
|             decoration: InputDecoration( | ||||
|               prefixIcon: getProviderIcon( | ||||
|                 selectedProvider.value, | ||||
|                 size: 16, | ||||
|                 color: Theme.of(context).colorScheme.onSurface, | ||||
|               ).padding(all: 16), | ||||
|               labelText: 'accountConnectionProvider'.tr(), | ||||
|               border: const OutlineInputBorder(), | ||||
|             ), | ||||
|             items: | ||||
|                 providers.map((String provider) { | ||||
|                   return DropdownMenuItem<String>( | ||||
|                     value: provider, | ||||
|                     child: Row( | ||||
|                       children: [Text(getLocalizedProviderName(provider)).tr()], | ||||
|                     ), | ||||
|                   ); | ||||
|                 }).toList(), | ||||
|             onChanged: (String? newValue) { | ||||
|               if (newValue != null) { | ||||
|                 selectedProvider.value = newValue; | ||||
|               } | ||||
|             }, | ||||
|           ), | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.symmetric(horizontal: 16.0), | ||||
|             child: Text('accountConnectionDescription'.tr()), | ||||
|           ), | ||||
|           Row( | ||||
|             mainAxisAlignment: MainAxisAlignment.end, | ||||
|             children: [ | ||||
|               TextButton.icon( | ||||
|                 onPressed: addConnection, | ||||
|                 icon: const Icon(Symbols.add), | ||||
|                 label: Text('next').tr(), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ], | ||||
|       ).padding(horizontal: 20, vertical: 24), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class AccountConnectionsSheet extends HookConsumerWidget { | ||||
|   const AccountConnectionsSheet({super.key}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final connections = ref.watch(accountConnectionsProvider); | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: 'accountConnections'.tr(), | ||||
|       actions: [ | ||||
|         IconButton( | ||||
|           icon: const Icon(Symbols.add), | ||||
|           onPressed: () async { | ||||
|             final result = await showModalBottomSheet<bool>( | ||||
|               context: context, | ||||
|               isScrollControlled: true, | ||||
|               builder: (context) => const AccountConnectionNewSheet(), | ||||
|             ); | ||||
|             if (result == true) { | ||||
|               ref.invalidate(accountConnectionsProvider); | ||||
|             } | ||||
|           }, | ||||
|         ), | ||||
|       ], | ||||
|       child: connections.when( | ||||
|         data: | ||||
|             (data) => RefreshIndicator( | ||||
|               onRefresh: | ||||
|                   () => Future.sync( | ||||
|                     () => ref.invalidate(accountConnectionsProvider), | ||||
|                   ), | ||||
|               child: | ||||
|                   data.isEmpty | ||||
|                       ? Center( | ||||
|                         child: Text( | ||||
|                           'accountConnectionsEmpty'.tr(), | ||||
|                           textAlign: TextAlign.center, | ||||
|                         ).padding(horizontal: 32), | ||||
|                       ) | ||||
|                       : ListView.builder( | ||||
|                         padding: EdgeInsets.zero, | ||||
|                         itemCount: data.length, | ||||
|                         itemBuilder: (context, index) { | ||||
|                           final connection = data[index]; | ||||
|                           return Dismissible( | ||||
|                             key: Key('connection-${connection.id}'), | ||||
|                             direction: DismissDirection.endToStart, | ||||
|                             background: Container( | ||||
|                               color: Colors.red, | ||||
|                               alignment: Alignment.centerRight, | ||||
|                               padding: const EdgeInsets.symmetric( | ||||
|                                 horizontal: 20, | ||||
|                               ), | ||||
|                               child: const Icon( | ||||
|                                 Icons.delete, | ||||
|                                 color: Colors.white, | ||||
|                               ), | ||||
|                             ), | ||||
|                             confirmDismiss: (direction) async { | ||||
|                               final confirm = await showConfirmAlert( | ||||
|                                 'accountConnectionDeleteHint'.tr(), | ||||
|                                 'accountConnectionDelete'.tr(), | ||||
|                               ); | ||||
|                               if (confirm && context.mounted) { | ||||
|                                 try { | ||||
|                                   final client = ref.read(apiClientProvider); | ||||
|                                   await client.delete( | ||||
|                                     '/accounts/me/connections/${connection.id}', | ||||
|                                   ); | ||||
|                                   ref.invalidate(accountConnectionsProvider); | ||||
|                                   return true; | ||||
|                                 } catch (err) { | ||||
|                                   showErrorAlert(err); | ||||
|                                   return false; | ||||
|                                 } | ||||
|                               } | ||||
|                               return false; | ||||
|                             }, | ||||
|                             child: ListTile( | ||||
|                               leading: getProviderIcon( | ||||
|                                 connection.provider, | ||||
|                                 color: Theme.of(context).colorScheme.onSurface, | ||||
|                               ), | ||||
|                               title: | ||||
|                                   Text( | ||||
|                                     getLocalizedProviderName( | ||||
|                                       connection.provider, | ||||
|                                     ), | ||||
|                                   ).tr(), | ||||
|                               subtitle: | ||||
|                                   connection.meta['email'] != null | ||||
|                                       ? Text(connection.meta['email']) | ||||
|                                       : Text(connection.providedIdentifier), | ||||
|                               trailing: Text( | ||||
|                                 DateFormat.yMd().format( | ||||
|                                   connection.lastUsedAt.toLocal(), | ||||
|                                 ), | ||||
|                                 style: Theme.of(context).textTheme.bodySmall, | ||||
|                               ), | ||||
|                               onTap: () async { | ||||
|                                 final result = await showModalBottomSheet<bool>( | ||||
|                                   context: context, | ||||
|                                   isScrollControlled: true, | ||||
|                                   builder: | ||||
|                                       (context) => AccountConnectionSheet( | ||||
|                                         connection: connection, | ||||
|                                       ), | ||||
|                                 ); | ||||
|                                 if (result == true) { | ||||
|                                   ref.invalidate(accountConnectionsProvider); | ||||
|                                 } | ||||
|                               }, | ||||
|                             ), | ||||
|                           ); | ||||
|                         }, | ||||
|                       ), | ||||
|             ), | ||||
|         error: | ||||
|             (err, _) => ResponseErrorWidget( | ||||
|               error: err, | ||||
|               onRetry: () => ref.invalidate(accountConnectionsProvider), | ||||
|             ), | ||||
|         loading: () => const ResponseLoadingWidget(), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										281
									
								
								lib/screens/account/me/settings_contacts.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										281
									
								
								lib/screens/account/me/settings_contacts.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,281 @@ | ||||
| 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/user.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| class ContactMethodSheet extends HookConsumerWidget { | ||||
|   final SnContactMethod contact; | ||||
|   const ContactMethodSheet({super.key, required this.contact}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     Future<void> deleteContactMethod() async { | ||||
|       final confirm = await showConfirmAlert( | ||||
|         'contactMethodDeleteHint'.tr(), | ||||
|         'contactMethodDelete'.tr(), | ||||
|       ); | ||||
|       if (!confirm || !context.mounted) return; | ||||
|       try { | ||||
|         showLoadingModal(context); | ||||
|         final client = ref.read(apiClientProvider); | ||||
|         await client.delete('/accounts/me/contacts/${contact.id}'); | ||||
|         if (context.mounted) Navigator.pop(context, true); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Future<void> verifyContactMethod() async { | ||||
|       try { | ||||
|         showLoadingModal(context); | ||||
|         final client = ref.read(apiClientProvider); | ||||
|         await client.post('/accounts/me/contacts/${contact.id}/verify'); | ||||
|         if (context.mounted) { | ||||
|           showSnackBar(context, 'contactMethodVerificationSent'.tr()); | ||||
|         } | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Future<void> setContactMethodAsPrimary() async { | ||||
|       try { | ||||
|         showLoadingModal(context); | ||||
|         final client = ref.read(apiClientProvider); | ||||
|         await client.post('/accounts/me/contacts/${contact.id}/primary'); | ||||
|         if (context.mounted) Navigator.pop(context, true); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: 'contactMethod'.tr(), | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             mainAxisAlignment: MainAxisAlignment.center, | ||||
|             children: [ | ||||
|               Icon(switch (contact.type) { | ||||
|                 0 => Symbols.mail, | ||||
|                 1 => Symbols.phone, | ||||
|                 _ => Symbols.home, | ||||
|               }, size: 32), | ||||
|               const Gap(8), | ||||
|               Text(switch (contact.type) { | ||||
|                 0 => 'contactMethodTypeEmail'.tr(), | ||||
|                 1 => 'contactMethodTypePhone'.tr(), | ||||
|                 _ => 'contactMethodTypeAddress'.tr(), | ||||
|               }), | ||||
|               const Gap(4), | ||||
|               Text( | ||||
|                 contact.content, | ||||
|                 style: Theme.of(context).textTheme.bodySmall, | ||||
|               ), | ||||
|               const Gap(10), | ||||
|               Row( | ||||
|                 children: [ | ||||
|                   if (contact.verifiedAt == null) | ||||
|                     Badge( | ||||
|                       label: Text('contactMethodUnverified'.tr()), | ||||
|                       textColor: Theme.of(context).colorScheme.onSecondary, | ||||
|                       backgroundColor: Theme.of(context).colorScheme.secondary, | ||||
|                     ) | ||||
|                   else | ||||
|                     Badge( | ||||
|                       label: Text('contactMethodVerified'.tr()), | ||||
|                       textColor: Theme.of(context).colorScheme.onPrimary, | ||||
|                       backgroundColor: Theme.of(context).colorScheme.primary, | ||||
|                     ), | ||||
|                   if (contact.isPrimary) | ||||
|                     Padding( | ||||
|                       padding: const EdgeInsets.only(left: 8.0), | ||||
|                       child: Badge( | ||||
|                         label: Text('contactMethodPrimary'.tr()), | ||||
|                         textColor: Theme.of(context).colorScheme.onTertiary, | ||||
|                         backgroundColor: Theme.of(context).colorScheme.tertiary, | ||||
|                       ), | ||||
|                     ), | ||||
|                 ], | ||||
|               ), | ||||
|             ], | ||||
|           ).padding(all: 20), | ||||
|           const Divider(height: 1), | ||||
|           if (contact.verifiedAt == null) | ||||
|             ListTile( | ||||
|               leading: const Icon(Symbols.verified), | ||||
|               title: Text('contactMethodVerify').tr(), | ||||
|               onTap: verifyContactMethod, | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 20), | ||||
|             ), | ||||
|           if (contact.verifiedAt != null && !contact.isPrimary) | ||||
|             ListTile( | ||||
|               leading: const Icon(Symbols.star), | ||||
|               title: Text('contactMethodSetPrimary').tr(), | ||||
|               onTap: setContactMethodAsPrimary, | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 20), | ||||
|             ), | ||||
|           ListTile( | ||||
|             leading: const Icon(Symbols.delete), | ||||
|             title: Text('contactMethodDelete').tr(), | ||||
|             onTap: deleteContactMethod, | ||||
|             contentPadding: EdgeInsets.symmetric(horizontal: 20), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class ContactMethodNewSheet extends HookConsumerWidget { | ||||
|   const ContactMethodNewSheet({super.key}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final contactType = useState<int>(0); | ||||
|     final contentController = useTextEditingController(); | ||||
|  | ||||
|     Future<void> addContactMethod() async { | ||||
|       if (contentController.text.isEmpty) { | ||||
|         showSnackBar(context, 'contactMethodContentEmpty'.tr()); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       try { | ||||
|         showLoadingModal(context); | ||||
|         final apiClient = ref.read(apiClientProvider); | ||||
|         await apiClient.post( | ||||
|           '/accounts/me/contacts', | ||||
|           data: {'type': contactType.value, 'content': contentController.text}, | ||||
|         ); | ||||
|         if (context.mounted) { | ||||
|           showSnackBar(context, 'contactMethodVerificationNeeded'.tr()); | ||||
|           Navigator.pop(context, true); | ||||
|         } | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: 'contactMethodNew'.tr(), | ||||
|       child: Column( | ||||
|         spacing: 16, | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           DropdownButtonFormField<int>( | ||||
|             value: contactType.value, | ||||
|             decoration: InputDecoration( | ||||
|               labelText: 'contactMethodType'.tr(), | ||||
|               border: const OutlineInputBorder(), | ||||
|             ), | ||||
|             items: [ | ||||
|               DropdownMenuItem<int>( | ||||
|                 value: 0, | ||||
|                 child: Row( | ||||
|                   children: [ | ||||
|                     Icon(Symbols.mail), | ||||
|                     const Gap(8), | ||||
|                     Text('contactMethodTypeEmail'.tr()), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|               DropdownMenuItem<int>( | ||||
|                 value: 1, | ||||
|                 child: Row( | ||||
|                   children: [ | ||||
|                     Icon(Symbols.phone), | ||||
|                     const Gap(8), | ||||
|                     Text('contactMethodTypePhone'.tr()), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|               DropdownMenuItem<int>( | ||||
|                 value: 2, | ||||
|                 child: Row( | ||||
|                   children: [ | ||||
|                     Icon(Symbols.home), | ||||
|                     const Gap(8), | ||||
|                     Text('contactMethodTypeAddress'.tr()), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|             onChanged: (value) { | ||||
|               if (value != null) { | ||||
|                 contactType.value = value; | ||||
|               } | ||||
|             }, | ||||
|           ), | ||||
|           TextField( | ||||
|             controller: contentController, | ||||
|             decoration: InputDecoration( | ||||
|               prefixIcon: Icon(switch (contactType.value) { | ||||
|                 0 => Symbols.mail, | ||||
|                 1 => Symbols.phone, | ||||
|                 _ => Symbols.home, | ||||
|               }), | ||||
|               labelText: switch (contactType.value) { | ||||
|                 0 => 'contactMethodTypeEmail'.tr(), | ||||
|                 1 => 'contactMethodTypePhone'.tr(), | ||||
|                 _ => 'contactMethodTypeAddress'.tr(), | ||||
|               }, | ||||
|               hintText: switch (contactType.value) { | ||||
|                 0 => 'contactMethodEmailHint'.tr(), | ||||
|                 1 => 'contactMethodPhoneHint'.tr(), | ||||
|                 _ => 'contactMethodAddressHint'.tr(), | ||||
|               }, | ||||
|               border: const OutlineInputBorder(), | ||||
|             ), | ||||
|             keyboardType: switch (contactType.value) { | ||||
|               0 => TextInputType.emailAddress, | ||||
|               1 => TextInputType.phone, | ||||
|               _ => TextInputType.multiline, | ||||
|             }, | ||||
|             maxLines: switch (contactType.value) { | ||||
|               2 => 3, | ||||
|               _ => 1, | ||||
|             }, | ||||
|             onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|           ), | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.symmetric(horizontal: 16.0), | ||||
|             child: | ||||
|                 Text(switch (contactType.value) { | ||||
|                   0 => 'contactMethodEmailDescription', | ||||
|                   1 => 'contactMethodPhoneDescription', | ||||
|                   _ => 'contactMethodAddressDescription', | ||||
|                 }).tr(), | ||||
|           ), | ||||
|           Row( | ||||
|             mainAxisAlignment: MainAxisAlignment.end, | ||||
|             children: [ | ||||
|               TextButton.icon( | ||||
|                 onPressed: addContactMethod, | ||||
|                 icon: Icon(Symbols.add), | ||||
|                 label: Text('create').tr(), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ], | ||||
|       ).padding(horizontal: 20, vertical: 24), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -11,6 +11,7 @@ import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/services/file.dart'; | ||||
| import 'package:island/services/timezone.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| @@ -120,9 +121,33 @@ class UpdateProfileScreen extends HookConsumerWidget { | ||||
|     } | ||||
|  | ||||
|     final formKeyProfile = useMemoized(GlobalKey<FormState>.new, const []); | ||||
|     final birthday = useState<DateTime?>( | ||||
|       user.value!.profile.birthday?.toLocal(), | ||||
|     ); | ||||
|     final firstNameController = useTextEditingController( | ||||
|       text: user.value!.profile.firstName, | ||||
|     ); | ||||
|     final middleNameController = useTextEditingController( | ||||
|       text: user.value!.profile.middleName, | ||||
|     ); | ||||
|     final lastNameController = useTextEditingController( | ||||
|       text: user.value!.profile.lastName, | ||||
|     ); | ||||
|     final bioController = useTextEditingController( | ||||
|       text: user.value!.profile.bio, | ||||
|     ); | ||||
|     final genderController = useTextEditingController( | ||||
|       text: user.value!.profile.gender, | ||||
|     ); | ||||
|     final pronounsController = useTextEditingController( | ||||
|       text: user.value!.profile.pronouns, | ||||
|     ); | ||||
|     final locationController = useTextEditingController( | ||||
|       text: user.value!.profile.location, | ||||
|     ); | ||||
|     final timeZoneController = useTextEditingController( | ||||
|       text: user.value!.profile.timeZone, | ||||
|     ); | ||||
|  | ||||
|     void updateProfile() async { | ||||
|       if (!formKeyProfile.currentState!.validate()) return; | ||||
| @@ -132,7 +157,17 @@ class UpdateProfileScreen extends HookConsumerWidget { | ||||
|         final client = ref.watch(apiClientProvider); | ||||
|         await client.patch( | ||||
|           '/accounts/me/profile', | ||||
|           data: {'bio': bioController.text}, | ||||
|           data: { | ||||
|             'bio': bioController.text, | ||||
|             'first_name': firstNameController.text, | ||||
|             'middle_name': middleNameController.text, | ||||
|             'last_name': lastNameController.text, | ||||
|             'gender': genderController.text, | ||||
|             'pronouns': pronounsController.text, | ||||
|             'location': locationController.text, | ||||
|             'time_zone': timeZoneController.text, | ||||
|             'birthday': birthday.value?.toUtc().toIso8601String(), | ||||
|           }, | ||||
|         ); | ||||
|         final userNotifier = ref.read(userInfoProvider.notifier); | ||||
|         userNotifier.fetchUser(); | ||||
| @@ -268,6 +303,45 @@ class UpdateProfileScreen extends HookConsumerWidget { | ||||
|                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                 spacing: 16, | ||||
|                 children: [ | ||||
|                   Row( | ||||
|                     spacing: 16, | ||||
|                     children: [ | ||||
|                       Expanded( | ||||
|                         child: TextFormField( | ||||
|                           decoration: InputDecoration( | ||||
|                             labelText: 'firstName'.tr(), | ||||
|                           ), | ||||
|                           controller: firstNameController, | ||||
|                           onTapOutside: | ||||
|                               (_) => | ||||
|                                   FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                         ), | ||||
|                       ), | ||||
|                       Expanded( | ||||
|                         child: TextFormField( | ||||
|                           decoration: InputDecoration( | ||||
|                             labelText: 'middleName'.tr(), | ||||
|                           ), | ||||
|                           controller: middleNameController, | ||||
|                           onTapOutside: | ||||
|                               (_) => | ||||
|                                   FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                         ), | ||||
|                       ), | ||||
|                       Expanded( | ||||
|                         child: TextFormField( | ||||
|                           decoration: InputDecoration( | ||||
|                             labelText: 'lastName'.tr(), | ||||
|                           ), | ||||
|                           controller: lastNameController, | ||||
|                           onTapOutside: | ||||
|                               (_) => | ||||
|                                   FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|  | ||||
|                   TextFormField( | ||||
|                     decoration: InputDecoration(labelText: 'bio'.tr()), | ||||
|                     maxLines: null, | ||||
| @@ -276,6 +350,213 @@ class UpdateProfileScreen extends HookConsumerWidget { | ||||
|                     onTapOutside: | ||||
|                         (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                   ), | ||||
|                   Row( | ||||
|                     spacing: 16, | ||||
|                     children: [ | ||||
|                       Expanded( | ||||
|                         child: Autocomplete<String>( | ||||
|                           optionsBuilder: (TextEditingValue textEditingValue) { | ||||
|                             final options = ['Male', 'Female']; | ||||
|                             if (textEditingValue.text == '') { | ||||
|                               return options; | ||||
|                             } | ||||
|                             return options.where( | ||||
|                               (option) => option.toLowerCase().contains( | ||||
|                                 textEditingValue.text.toLowerCase(), | ||||
|                               ), | ||||
|                             ); | ||||
|                           }, | ||||
|                           onSelected: (String selection) { | ||||
|                             genderController.text = selection; | ||||
|                           }, | ||||
|                           fieldViewBuilder: ( | ||||
|                             context, | ||||
|                             controller, | ||||
|                             focusNode, | ||||
|                             onFieldSubmitted, | ||||
|                           ) { | ||||
|                             // Initialize the controller with the current value | ||||
|                             if (controller.text.isEmpty && | ||||
|                                 genderController.text.isNotEmpty) { | ||||
|                               controller.text = genderController.text; | ||||
|                             } | ||||
|  | ||||
|                             return TextFormField( | ||||
|                               controller: controller, | ||||
|                               focusNode: focusNode, | ||||
|                               decoration: InputDecoration( | ||||
|                                 labelText: 'gender'.tr(), | ||||
|                               ), | ||||
|                               onChanged: (value) { | ||||
|                                 genderController.text = value; | ||||
|                               }, | ||||
|                               onTapOutside: | ||||
|                                   (_) => | ||||
|                                       FocusManager.instance.primaryFocus | ||||
|                                           ?.unfocus(), | ||||
|                             ); | ||||
|                           }, | ||||
|                         ), | ||||
|                       ), | ||||
|                       Expanded( | ||||
|                         child: TextFormField( | ||||
|                           decoration: InputDecoration( | ||||
|                             labelText: 'pronouns'.tr(), | ||||
|                           ), | ||||
|                           controller: pronounsController, | ||||
|                           onTapOutside: | ||||
|                               (_) => | ||||
|                                   FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                   Row( | ||||
|                     spacing: 16, | ||||
|                     children: [ | ||||
|                       Expanded( | ||||
|                         child: TextFormField( | ||||
|                           decoration: InputDecoration( | ||||
|                             labelText: 'location'.tr(), | ||||
|                           ), | ||||
|                           controller: locationController, | ||||
|                           onTapOutside: | ||||
|                               (_) => | ||||
|                                   FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                         ), | ||||
|                       ), | ||||
|                       Expanded( | ||||
|                         child: Autocomplete<String>( | ||||
|                           optionsBuilder: (TextEditingValue textEditingValue) { | ||||
|                             if (textEditingValue.text.isEmpty) { | ||||
|                               return const Iterable<String>.empty(); | ||||
|                             } | ||||
|                             final lowercaseQuery = | ||||
|                                 textEditingValue.text.toLowerCase(); | ||||
|                             return getAvailableTz().where((tz) { | ||||
|                               return tz.toLowerCase().contains(lowercaseQuery); | ||||
|                             }); | ||||
|                           }, | ||||
|                           onSelected: (String selection) { | ||||
|                             timeZoneController.text = selection; | ||||
|                           }, | ||||
|                           fieldViewBuilder: ( | ||||
|                             context, | ||||
|                             controller, | ||||
|                             focusNode, | ||||
|                             onFieldSubmitted, | ||||
|                           ) { | ||||
|                             // Sync the controller with timeZoneController when the widget is built | ||||
|                             if (controller.text != timeZoneController.text) { | ||||
|                               controller.text = timeZoneController.text; | ||||
|                             } | ||||
|  | ||||
|                             return TextFormField( | ||||
|                               controller: controller, | ||||
|                               focusNode: focusNode, | ||||
|                               decoration: InputDecoration( | ||||
|                                 labelText: 'timeZone'.tr(), | ||||
|                                 suffix: InkWell( | ||||
|                                   child: const Icon( | ||||
|                                     Symbols.my_location, | ||||
|                                     size: 18, | ||||
|                                   ), | ||||
|                                   onTap: () async { | ||||
|                                     try { | ||||
|                                       showLoadingModal(context); | ||||
|                                       final machineTz = await getMachineTz(); | ||||
|                                       controller.text = machineTz; | ||||
|                                       timeZoneController.text = machineTz; | ||||
|                                     } finally { | ||||
|                                       if (context.mounted) { | ||||
|                                         hideLoadingModal(context); | ||||
|                                       } | ||||
|                                     } | ||||
|                                   }, | ||||
|                                 ), | ||||
|                               ), | ||||
|                               onChanged: (value) { | ||||
|                                 timeZoneController.text = value; | ||||
|                               }, | ||||
|                             ); | ||||
|                           }, | ||||
|                           optionsViewBuilder: (context, onSelected, options) { | ||||
|                             return Align( | ||||
|                               alignment: Alignment.topLeft, | ||||
|                               child: Material( | ||||
|                                 elevation: 4.0, | ||||
|                                 child: ConstrainedBox( | ||||
|                                   constraints: const BoxConstraints( | ||||
|                                     maxHeight: 200, | ||||
|                                     maxWidth: 300, | ||||
|                                   ), | ||||
|                                   child: ListView.builder( | ||||
|                                     padding: const EdgeInsets.all(8.0), | ||||
|                                     itemCount: options.length, | ||||
|                                     itemBuilder: ( | ||||
|                                       BuildContext context, | ||||
|                                       int index, | ||||
|                                     ) { | ||||
|                                       final option = options.elementAt(index); | ||||
|                                       return ListTile( | ||||
|                                         title: Text( | ||||
|                                           option, | ||||
|                                           overflow: TextOverflow.ellipsis, | ||||
|                                         ), | ||||
|                                         onTap: () { | ||||
|                                           onSelected(option); | ||||
|                                         }, | ||||
|                                       ); | ||||
|                                     }, | ||||
|                                   ), | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ); | ||||
|                           }, | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                   GestureDetector( | ||||
|                     onTap: () async { | ||||
|                       final date = await showDatePicker( | ||||
|                         context: context, | ||||
|                         initialDate: birthday.value ?? DateTime.now(), | ||||
|                         firstDate: DateTime(1900), | ||||
|                         lastDate: DateTime.now(), | ||||
|                       ); | ||||
|                       if (date != null) { | ||||
|                         birthday.value = date; | ||||
|                       } | ||||
|                     }, | ||||
|                     child: Container( | ||||
|                       padding: const EdgeInsets.symmetric(vertical: 8), | ||||
|                       decoration: BoxDecoration( | ||||
|                         border: Border( | ||||
|                           bottom: BorderSide( | ||||
|                             color: Theme.of(context).dividerColor, | ||||
|                             width: 1, | ||||
|                           ), | ||||
|                         ), | ||||
|                       ), | ||||
|                       child: Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                         children: [ | ||||
|                           Text( | ||||
|                             'birthday'.tr(), | ||||
|                             style: TextStyle( | ||||
|                               color: Theme.of(context).hintColor, | ||||
|                             ), | ||||
|                           ), | ||||
|                           Text( | ||||
|                             birthday.value != null | ||||
|                                 ? DateFormat.yMMMd().format(birthday.value!) | ||||
|                                 : 'Select a date'.tr(), | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                   Align( | ||||
|                     alignment: Alignment.centerRight, | ||||
|                     child: TextButton.icon( | ||||
|   | ||||
| @@ -1,16 +1,29 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/chat.dart'; | ||||
| import 'package:island/models/relationship.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/event_calendar.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/services/color.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
| import 'package:island/services/timezone/native.dart'; | ||||
| import 'package:island/widgets/account/account_name.dart'; | ||||
| import 'package:island/widgets/account/badge.dart'; | ||||
| import 'package:island/widgets/account/fortune_graph.dart'; | ||||
| import 'package:island/widgets/account/leveling_progress.dart'; | ||||
| import 'package:island/widgets/account/status.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:palette_generator/palette_generator.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @@ -38,6 +51,51 @@ Future<List<SnAccountBadge>> accountBadges(Ref ref, String uname) async { | ||||
|   ); | ||||
| } | ||||
|  | ||||
| @riverpod | ||||
| Future<Color?> accountAppbarForcegroundColor(Ref ref, String uname) async { | ||||
|   final account = await ref.watch(accountProvider(uname).future); | ||||
|   if (account.profile.background == null) return null; | ||||
|   final palette = await PaletteGenerator.fromImageProvider( | ||||
|     CloudImageWidget.provider( | ||||
|       fileId: account.profile.background!.id, | ||||
|       serverUrl: ref.watch(serverUrlProvider), | ||||
|     ), | ||||
|   ); | ||||
|   final dominantColor = palette.dominantColor?.color; | ||||
|   if (dominantColor == null) return null; | ||||
|   return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white; | ||||
| } | ||||
|  | ||||
| @riverpod | ||||
| Future<SnChatRoom?> accountDirectChat(Ref ref, String uname) async { | ||||
|   final account = await ref.watch(accountProvider(uname).future); | ||||
|   final apiClient = ref.watch(apiClientProvider); | ||||
|   try { | ||||
|     final resp = await apiClient.get("/chat/direct/${account.id}"); | ||||
|     return SnChatRoom.fromJson(resp.data); | ||||
|   } catch (err) { | ||||
|     if (err is DioException && err.response?.statusCode == 404) { | ||||
|       return null; | ||||
|     } | ||||
|     rethrow; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @riverpod | ||||
| Future<SnRelationship?> accountRelationship(Ref ref, String uname) async { | ||||
|   final account = await ref.watch(accountProvider(uname).future); | ||||
|   final apiClient = ref.watch(apiClientProvider); | ||||
|   try { | ||||
|     final resp = await apiClient.get("/relationships/${account.id}"); | ||||
|     return SnRelationship.fromJson(resp.data); | ||||
|   } catch (err) { | ||||
|     if (err is DioException && err.response?.statusCode == 404) { | ||||
|       return null; | ||||
|     } | ||||
|     rethrow; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class AccountProfileScreen extends HookConsumerWidget { | ||||
|   final String name; | ||||
| @@ -48,13 +106,118 @@ class AccountProfileScreen extends HookConsumerWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final account = ref.watch(accountProvider(name)); | ||||
|     final now = DateTime.now(); | ||||
|  | ||||
|     final iconShadow = Shadow( | ||||
|       color: Colors.black54, | ||||
|       blurRadius: 5.0, | ||||
|       offset: const Offset(1.0, 1.0), | ||||
|     final account = ref.watch(accountProvider(name)); | ||||
|     final accountEvents = ref.watch( | ||||
|       eventCalendarProvider( | ||||
|         EventCalendarQuery(uname: name, year: now.year, month: now.month), | ||||
|       ), | ||||
|     ); | ||||
|     final accountChat = ref.watch(accountDirectChatProvider(name)); | ||||
|     final accountRelationship = ref.watch(accountRelationshipProvider(name)); | ||||
|  | ||||
|     final appbarColor = ref.watch(accountAppbarForcegroundColorProvider(name)); | ||||
|  | ||||
|     final appbarShadow = Shadow( | ||||
|       color: appbarColor.value?.invert ?? Colors.transparent, | ||||
|       blurRadius: 5.0, | ||||
|       offset: Offset(1.0, 1.0), | ||||
|     ); | ||||
|  | ||||
|     Future<void> relationshipAction() async { | ||||
|       if (accountRelationship.value != null) return; | ||||
|       showLoadingModal(context); | ||||
|       try { | ||||
|         final client = ref.watch(apiClientProvider); | ||||
|         await client.post('/relationships/${account.value!.id}/friends'); | ||||
|         ref.invalidate(accountRelationshipProvider(name)); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Future<void> directMessageAction() async { | ||||
|       if (!account.hasValue) return; | ||||
|       if (accountChat.value != null) { | ||||
|         context.router.pushPath('/chat/${accountChat.value!.id}'); | ||||
|         return; | ||||
|       } | ||||
|       showLoadingModal(context); | ||||
|       try { | ||||
|         final client = ref.watch(apiClientProvider); | ||||
|         final resp = await client.post( | ||||
|           '/chat/direct', | ||||
|           data: {'related_user_id': account.value!.id}, | ||||
|         ); | ||||
|         final chat = SnChatRoom.fromJson(resp.data); | ||||
|         if (context.mounted) context.router.pushPath('/chat/${chat.id}'); | ||||
|         ref.invalidate(accountDirectChatProvider(name)); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     List<Widget> buildSubcolumn(SnAccount data) { | ||||
|       return [ | ||||
|         if (data.profile.birthday != null) | ||||
|           Row( | ||||
|             spacing: 6, | ||||
|             children: [ | ||||
|               const Icon(Symbols.cake, size: 17, fill: 1), | ||||
|               Text(data.profile.birthday!.formatCustom('yyyy-MM-dd')), | ||||
|               Text('·').bold(), | ||||
|               Text( | ||||
|                 '${DateTime.now().difference(data.profile.birthday!).inDays ~/ 365} yrs old', | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         if (data.profile.location.isNotEmpty) | ||||
|           Row( | ||||
|             spacing: 6, | ||||
|             children: [ | ||||
|               const Icon(Symbols.location_on, size: 17, fill: 1), | ||||
|               Text(data.profile.location), | ||||
|             ], | ||||
|           ), | ||||
|         if (data.profile.pronouns.isNotEmpty || data.profile.gender.isNotEmpty) | ||||
|           Row( | ||||
|             spacing: 6, | ||||
|             children: [ | ||||
|               const Icon(Symbols.person, size: 17, fill: 1), | ||||
|               Text( | ||||
|                 data.profile.gender.isEmpty | ||||
|                     ? 'unspecified'.tr() | ||||
|                     : data.profile.gender, | ||||
|               ), | ||||
|               Text('·').bold(), | ||||
|               Text( | ||||
|                 data.profile.pronouns.isEmpty | ||||
|                     ? 'unspecified'.tr() | ||||
|                     : data.profile.pronouns, | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         if (data.profile.firstName.isNotEmpty || | ||||
|             data.profile.middleName.isNotEmpty || | ||||
|             data.profile.lastName.isNotEmpty) | ||||
|           Row( | ||||
|             spacing: 6, | ||||
|             children: [ | ||||
|               const Icon(Symbols.id_card, size: 17, fill: 1), | ||||
|               if (data.profile.firstName.isNotEmpty) | ||||
|                 Text(data.profile.firstName), | ||||
|               if (data.profile.middleName.isNotEmpty) | ||||
|                 Text(data.profile.middleName), | ||||
|               if (data.profile.lastName.isNotEmpty) Text(data.profile.lastName), | ||||
|             ], | ||||
|           ), | ||||
|       ]; | ||||
|     } | ||||
|  | ||||
|     return account.when( | ||||
|       data: | ||||
| @@ -62,26 +225,40 @@ class AccountProfileScreen extends HookConsumerWidget { | ||||
|             body: CustomScrollView( | ||||
|               slivers: [ | ||||
|                 SliverAppBar( | ||||
|                   foregroundColor: appbarColor.value, | ||||
|                   expandedHeight: 180, | ||||
|                   pinned: true, | ||||
|                   leading: PageBackButton(shadows: [iconShadow]), | ||||
|                   flexibleSpace: FlexibleSpaceBar( | ||||
|                     background: | ||||
|                         data.profile.background?.id != null | ||||
|                             ? CloudImageWidget( | ||||
|                               fileId: data.profile.background!.id, | ||||
|                             ) | ||||
|                             : Container( | ||||
|                               color: | ||||
|                                   Theme.of(context).appBarTheme.backgroundColor, | ||||
|                             ), | ||||
|                     title: Text( | ||||
|                       data.nick, | ||||
|                       style: TextStyle( | ||||
|                         color: Theme.of(context).appBarTheme.foregroundColor, | ||||
|                         shadows: [iconShadow], | ||||
|                   leading: PageBackButton( | ||||
|                     color: appbarColor.value, | ||||
|                     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( | ||||
|                         title: Text( | ||||
|                           data.nick, | ||||
|                           style: TextStyle( | ||||
|                             color: | ||||
|                                 appbarColor.value ?? | ||||
|                                 Theme.of(context).appBarTheme.foregroundColor, | ||||
|                             shadows: [appbarShadow], | ||||
|                           ), | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|                 SliverToBoxAdapter( | ||||
| @@ -91,7 +268,7 @@ class AccountProfileScreen extends HookConsumerWidget { | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         ProfilePictureWidget( | ||||
|                           fileId: data.profile.picture?.id, | ||||
|                           file: data.profile.picture, | ||||
|                           radius: 32, | ||||
|                         ), | ||||
|                         const Gap(20), | ||||
| @@ -124,29 +301,144 @@ class AccountProfileScreen extends HookConsumerWidget { | ||||
|                     child: BadgeList( | ||||
|                       badges: data.badges, | ||||
|                     ).padding(horizontal: 24, bottom: 24), | ||||
|                   ) | ||||
|                 else | ||||
|                   const SliverGap(4), | ||||
|                 SliverToBoxAdapter( | ||||
|                   child: LevelingProgressCard( | ||||
|                     level: data.profile.level, | ||||
|                     experience: data.profile.experience, | ||||
|                     progress: data.profile.levelingProgress, | ||||
|                   ).padding(horizontal: 20, bottom: 24), | ||||
|                 ), | ||||
|                 SliverToBoxAdapter( | ||||
|                   child: const Divider(height: 1).padding(bottom: 24), | ||||
|                 ), | ||||
|                 if (data.profile.bio.isNotEmpty) | ||||
|                   SliverToBoxAdapter( | ||||
|                     child: Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                       children: [ | ||||
|                         Text('bio').tr().bold(), | ||||
|                         Text(data.profile.bio), | ||||
|                       ], | ||||
|                     ).padding(horizontal: 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) | ||||
|                         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, | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|                       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), | ||||
|                 ), | ||||
|  | ||||
|                 SliverToBoxAdapter( | ||||
|                   child: const Divider(height: 1).padding(top: 24, bottom: 12), | ||||
|                 ), | ||||
|                 SliverToBoxAdapter( | ||||
|                   child: Row( | ||||
|                     spacing: 8, | ||||
|                     children: [ | ||||
|                       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), | ||||
|                         ), | ||||
|                       ), | ||||
|                       Expanded( | ||||
|                         child: FilledButton.icon( | ||||
|                           onPressed: directMessageAction, | ||||
|                           icon: const Icon(Symbols.message), | ||||
|                           label: | ||||
|                               Text( | ||||
|                                 accountChat.value == null | ||||
|                                     ? 'createDirectMessage' | ||||
|                                     : 'gotoDirectMessage', | ||||
|                                 maxLines: 1, | ||||
|                               ).tr(), | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ).padding(horizontal: 16), | ||||
|                 ), | ||||
|                 SliverToBoxAdapter( | ||||
|                   child: const Divider(height: 1).padding(top: 12), | ||||
|                 ), | ||||
|                 SliverToBoxAdapter( | ||||
|                   child: Column( | ||||
|                     children: [ | ||||
|                       FortuneGraphWidget( | ||||
|                         events: accountEvents, | ||||
|                         eventCalanderUser: data.name, | ||||
|                       ), | ||||
|                     ], | ||||
|                   ).padding(all: 8), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|   | ||||
| @@ -267,5 +267,377 @@ class _AccountBadgesProviderElement | ||||
|   String get uname => (origin as AccountBadgesProvider).uname; | ||||
| } | ||||
|  | ||||
| String _$accountAppbarForcegroundColorHash() => | ||||
|     r'f654a7a5594eda1500906e9ad023c22772257a9b'; | ||||
|  | ||||
| /// See also [accountAppbarForcegroundColor]. | ||||
| @ProviderFor(accountAppbarForcegroundColor) | ||||
| const accountAppbarForcegroundColorProvider = | ||||
|     AccountAppbarForcegroundColorFamily(); | ||||
|  | ||||
| /// See also [accountAppbarForcegroundColor]. | ||||
| class AccountAppbarForcegroundColorFamily extends Family<AsyncValue<Color?>> { | ||||
|   /// See also [accountAppbarForcegroundColor]. | ||||
|   const AccountAppbarForcegroundColorFamily(); | ||||
|  | ||||
|   /// See also [accountAppbarForcegroundColor]. | ||||
|   AccountAppbarForcegroundColorProvider call(String uname) { | ||||
|     return AccountAppbarForcegroundColorProvider(uname); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   AccountAppbarForcegroundColorProvider getProviderOverride( | ||||
|     covariant AccountAppbarForcegroundColorProvider provider, | ||||
|   ) { | ||||
|     return call(provider.uname); | ||||
|   } | ||||
|  | ||||
|   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'accountAppbarForcegroundColorProvider'; | ||||
| } | ||||
|  | ||||
| /// See also [accountAppbarForcegroundColor]. | ||||
| class AccountAppbarForcegroundColorProvider | ||||
|     extends AutoDisposeFutureProvider<Color?> { | ||||
|   /// See also [accountAppbarForcegroundColor]. | ||||
|   AccountAppbarForcegroundColorProvider(String uname) | ||||
|     : this._internal( | ||||
|         (ref) => accountAppbarForcegroundColor( | ||||
|           ref as AccountAppbarForcegroundColorRef, | ||||
|           uname, | ||||
|         ), | ||||
|         from: accountAppbarForcegroundColorProvider, | ||||
|         name: r'accountAppbarForcegroundColorProvider', | ||||
|         debugGetCreateSourceHash: | ||||
|             const bool.fromEnvironment('dart.vm.product') | ||||
|                 ? null | ||||
|                 : _$accountAppbarForcegroundColorHash, | ||||
|         dependencies: AccountAppbarForcegroundColorFamily._dependencies, | ||||
|         allTransitiveDependencies: | ||||
|             AccountAppbarForcegroundColorFamily._allTransitiveDependencies, | ||||
|         uname: uname, | ||||
|       ); | ||||
|  | ||||
|   AccountAppbarForcegroundColorProvider._internal( | ||||
|     super._createNotifier, { | ||||
|     required super.name, | ||||
|     required super.dependencies, | ||||
|     required super.allTransitiveDependencies, | ||||
|     required super.debugGetCreateSourceHash, | ||||
|     required super.from, | ||||
|     required this.uname, | ||||
|   }) : super.internal(); | ||||
|  | ||||
|   final String uname; | ||||
|  | ||||
|   @override | ||||
|   Override overrideWith( | ||||
|     FutureOr<Color?> Function(AccountAppbarForcegroundColorRef provider) create, | ||||
|   ) { | ||||
|     return ProviderOverride( | ||||
|       origin: this, | ||||
|       override: AccountAppbarForcegroundColorProvider._internal( | ||||
|         (ref) => create(ref as AccountAppbarForcegroundColorRef), | ||||
|         from: from, | ||||
|         name: null, | ||||
|         dependencies: null, | ||||
|         allTransitiveDependencies: null, | ||||
|         debugGetCreateSourceHash: null, | ||||
|         uname: uname, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   AutoDisposeFutureProviderElement<Color?> createElement() { | ||||
|     return _AccountAppbarForcegroundColorProviderElement(this); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     return other is AccountAppbarForcegroundColorProvider && | ||||
|         other.uname == uname; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode { | ||||
|     var hash = _SystemHash.combine(0, runtimeType.hashCode); | ||||
|     hash = _SystemHash.combine(hash, uname.hashCode); | ||||
|  | ||||
|     return _SystemHash.finish(hash); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||
| // ignore: unused_element | ||||
| mixin AccountAppbarForcegroundColorRef on AutoDisposeFutureProviderRef<Color?> { | ||||
|   /// The parameter `uname` of this provider. | ||||
|   String get uname; | ||||
| } | ||||
|  | ||||
| class _AccountAppbarForcegroundColorProviderElement | ||||
|     extends AutoDisposeFutureProviderElement<Color?> | ||||
|     with AccountAppbarForcegroundColorRef { | ||||
|   _AccountAppbarForcegroundColorProviderElement(super.provider); | ||||
|  | ||||
|   @override | ||||
|   String get uname => (origin as AccountAppbarForcegroundColorProvider).uname; | ||||
| } | ||||
|  | ||||
| String _$accountDirectChatHash() => r'60d0015fc2a3c8fc2190bb41d6818cf3027d9d0a'; | ||||
|  | ||||
| /// See also [accountDirectChat]. | ||||
| @ProviderFor(accountDirectChat) | ||||
| const accountDirectChatProvider = AccountDirectChatFamily(); | ||||
|  | ||||
| /// See also [accountDirectChat]. | ||||
| class AccountDirectChatFamily extends Family<AsyncValue<SnChatRoom?>> { | ||||
|   /// See also [accountDirectChat]. | ||||
|   const AccountDirectChatFamily(); | ||||
|  | ||||
|   /// See also [accountDirectChat]. | ||||
|   AccountDirectChatProvider call(String uname) { | ||||
|     return AccountDirectChatProvider(uname); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   AccountDirectChatProvider getProviderOverride( | ||||
|     covariant AccountDirectChatProvider provider, | ||||
|   ) { | ||||
|     return call(provider.uname); | ||||
|   } | ||||
|  | ||||
|   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'accountDirectChatProvider'; | ||||
| } | ||||
|  | ||||
| /// See also [accountDirectChat]. | ||||
| class AccountDirectChatProvider extends AutoDisposeFutureProvider<SnChatRoom?> { | ||||
|   /// See also [accountDirectChat]. | ||||
|   AccountDirectChatProvider(String uname) | ||||
|     : this._internal( | ||||
|         (ref) => accountDirectChat(ref as AccountDirectChatRef, uname), | ||||
|         from: accountDirectChatProvider, | ||||
|         name: r'accountDirectChatProvider', | ||||
|         debugGetCreateSourceHash: | ||||
|             const bool.fromEnvironment('dart.vm.product') | ||||
|                 ? null | ||||
|                 : _$accountDirectChatHash, | ||||
|         dependencies: AccountDirectChatFamily._dependencies, | ||||
|         allTransitiveDependencies: | ||||
|             AccountDirectChatFamily._allTransitiveDependencies, | ||||
|         uname: uname, | ||||
|       ); | ||||
|  | ||||
|   AccountDirectChatProvider._internal( | ||||
|     super._createNotifier, { | ||||
|     required super.name, | ||||
|     required super.dependencies, | ||||
|     required super.allTransitiveDependencies, | ||||
|     required super.debugGetCreateSourceHash, | ||||
|     required super.from, | ||||
|     required this.uname, | ||||
|   }) : super.internal(); | ||||
|  | ||||
|   final String uname; | ||||
|  | ||||
|   @override | ||||
|   Override overrideWith( | ||||
|     FutureOr<SnChatRoom?> Function(AccountDirectChatRef provider) create, | ||||
|   ) { | ||||
|     return ProviderOverride( | ||||
|       origin: this, | ||||
|       override: AccountDirectChatProvider._internal( | ||||
|         (ref) => create(ref as AccountDirectChatRef), | ||||
|         from: from, | ||||
|         name: null, | ||||
|         dependencies: null, | ||||
|         allTransitiveDependencies: null, | ||||
|         debugGetCreateSourceHash: null, | ||||
|         uname: uname, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   AutoDisposeFutureProviderElement<SnChatRoom?> createElement() { | ||||
|     return _AccountDirectChatProviderElement(this); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     return other is AccountDirectChatProvider && other.uname == uname; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode { | ||||
|     var hash = _SystemHash.combine(0, runtimeType.hashCode); | ||||
|     hash = _SystemHash.combine(hash, uname.hashCode); | ||||
|  | ||||
|     return _SystemHash.finish(hash); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||
| // ignore: unused_element | ||||
| mixin AccountDirectChatRef on AutoDisposeFutureProviderRef<SnChatRoom?> { | ||||
|   /// The parameter `uname` of this provider. | ||||
|   String get uname; | ||||
| } | ||||
|  | ||||
| class _AccountDirectChatProviderElement | ||||
|     extends AutoDisposeFutureProviderElement<SnChatRoom?> | ||||
|     with AccountDirectChatRef { | ||||
|   _AccountDirectChatProviderElement(super.provider); | ||||
|  | ||||
|   @override | ||||
|   String get uname => (origin as AccountDirectChatProvider).uname; | ||||
| } | ||||
|  | ||||
| String _$accountRelationshipHash() => | ||||
|     r'cb7d0d3f8cd4f23ad9d2d529872c540dac483d4f'; | ||||
|  | ||||
| /// See also [accountRelationship]. | ||||
| @ProviderFor(accountRelationship) | ||||
| const accountRelationshipProvider = AccountRelationshipFamily(); | ||||
|  | ||||
| /// See also [accountRelationship]. | ||||
| class AccountRelationshipFamily extends Family<AsyncValue<SnRelationship?>> { | ||||
|   /// See also [accountRelationship]. | ||||
|   const AccountRelationshipFamily(); | ||||
|  | ||||
|   /// See also [accountRelationship]. | ||||
|   AccountRelationshipProvider call(String uname) { | ||||
|     return AccountRelationshipProvider(uname); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   AccountRelationshipProvider getProviderOverride( | ||||
|     covariant AccountRelationshipProvider provider, | ||||
|   ) { | ||||
|     return call(provider.uname); | ||||
|   } | ||||
|  | ||||
|   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'accountRelationshipProvider'; | ||||
| } | ||||
|  | ||||
| /// See also [accountRelationship]. | ||||
| class AccountRelationshipProvider | ||||
|     extends AutoDisposeFutureProvider<SnRelationship?> { | ||||
|   /// See also [accountRelationship]. | ||||
|   AccountRelationshipProvider(String uname) | ||||
|     : this._internal( | ||||
|         (ref) => accountRelationship(ref as AccountRelationshipRef, uname), | ||||
|         from: accountRelationshipProvider, | ||||
|         name: r'accountRelationshipProvider', | ||||
|         debugGetCreateSourceHash: | ||||
|             const bool.fromEnvironment('dart.vm.product') | ||||
|                 ? null | ||||
|                 : _$accountRelationshipHash, | ||||
|         dependencies: AccountRelationshipFamily._dependencies, | ||||
|         allTransitiveDependencies: | ||||
|             AccountRelationshipFamily._allTransitiveDependencies, | ||||
|         uname: uname, | ||||
|       ); | ||||
|  | ||||
|   AccountRelationshipProvider._internal( | ||||
|     super._createNotifier, { | ||||
|     required super.name, | ||||
|     required super.dependencies, | ||||
|     required super.allTransitiveDependencies, | ||||
|     required super.debugGetCreateSourceHash, | ||||
|     required super.from, | ||||
|     required this.uname, | ||||
|   }) : super.internal(); | ||||
|  | ||||
|   final String uname; | ||||
|  | ||||
|   @override | ||||
|   Override overrideWith( | ||||
|     FutureOr<SnRelationship?> Function(AccountRelationshipRef provider) create, | ||||
|   ) { | ||||
|     return ProviderOverride( | ||||
|       origin: this, | ||||
|       override: AccountRelationshipProvider._internal( | ||||
|         (ref) => create(ref as AccountRelationshipRef), | ||||
|         from: from, | ||||
|         name: null, | ||||
|         dependencies: null, | ||||
|         allTransitiveDependencies: null, | ||||
|         debugGetCreateSourceHash: null, | ||||
|         uname: uname, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   AutoDisposeFutureProviderElement<SnRelationship?> createElement() { | ||||
|     return _AccountRelationshipProviderElement(this); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     return other is AccountRelationshipProvider && other.uname == uname; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode { | ||||
|     var hash = _SystemHash.combine(0, runtimeType.hashCode); | ||||
|     hash = _SystemHash.combine(hash, uname.hashCode); | ||||
|  | ||||
|     return _SystemHash.finish(hash); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||
| // ignore: unused_element | ||||
| mixin AccountRelationshipRef on AutoDisposeFutureProviderRef<SnRelationship?> { | ||||
|   /// The parameter `uname` of this provider. | ||||
|   String get uname; | ||||
| } | ||||
|  | ||||
| class _AccountRelationshipProviderElement | ||||
|     extends AutoDisposeFutureProviderElement<SnRelationship?> | ||||
|     with AccountRelationshipRef { | ||||
|   _AccountRelationshipProviderElement(super.provider); | ||||
|  | ||||
|   @override | ||||
|   String get uname => (origin as AccountRelationshipProvider).uname; | ||||
| } | ||||
|  | ||||
| // 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 | ||||
|   | ||||
| @@ -18,11 +18,14 @@ import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/pods/websocket.dart'; | ||||
| import 'package:island/screens/account/me/settings_connections.dart'; | ||||
| import 'package:island/screens/auth/oidc.dart'; | ||||
| import 'package:island/services/notify.dart'; | ||||
| import 'package:island/services/udid.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:sign_in_with_apple/sign_in_with_apple.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| @@ -104,7 +107,7 @@ class LoginScreen extends HookConsumerWidget { | ||||
|                       child: switch (period.value % 3) { | ||||
|                         1 => _LoginPickerScreen( | ||||
|                           key: const ValueKey(1), | ||||
|                           ticket: currentTicket.value, | ||||
|                           challenge: currentTicket.value, | ||||
|                           factors: factors.value, | ||||
|                           onChallenge: | ||||
|                               (SnAuthChallenge? p0) => currentTicket.value = p0, | ||||
| @@ -172,6 +175,89 @@ class _LoginCheckScreen extends HookConsumerWidget { | ||||
|       return null; | ||||
|     }, [isBusy]); | ||||
|  | ||||
|     Future<void> getToken({String? code}) async { | ||||
|       // Get token if challenge is completed | ||||
|       final client = ref.watch(apiClientProvider); | ||||
|       final tokenResp = await client.post( | ||||
|         '/auth/token', | ||||
|         data: { | ||||
|           'grant_type': 'authorization_code', | ||||
|           'code': code ?? challenge!.id, | ||||
|         }, | ||||
|       ); | ||||
|       final token = tokenResp.data['token']; | ||||
|       setToken(ref.watch(sharedPreferencesProvider), token); | ||||
|       ref.invalidate(tokenProvider); | ||||
|       if (!context.mounted) return; | ||||
|  | ||||
|       // Do post login tasks | ||||
|       final userNotifier = ref.read(userInfoProvider.notifier); | ||||
|       userNotifier.fetchUser().then((_) { | ||||
|         final apiClient = ref.read(apiClientProvider); | ||||
|         subscribePushNotification(apiClient); | ||||
|         final wsNotifier = ref.read(websocketStateProvider.notifier); | ||||
|         wsNotifier.connect(); | ||||
|         if (context.mounted) Navigator.pop(context, true); | ||||
|       }); | ||||
|  | ||||
|       // Update the sessions' device name is available | ||||
|       if (!kIsWeb) { | ||||
|         String? name; | ||||
|         if (Platform.isIOS) { | ||||
|           final deviceInfo = await DeviceInfoPlugin().iosInfo; | ||||
|           name = deviceInfo.name; | ||||
|         } else if (Platform.isAndroid) { | ||||
|           final deviceInfo = await DeviceInfoPlugin().androidInfo; | ||||
|           name = deviceInfo.name; | ||||
|         } else if (Platform.isWindows) { | ||||
|           final deviceInfo = await DeviceInfoPlugin().windowsInfo; | ||||
|           name = deviceInfo.computerName; | ||||
|         } | ||||
|         if (name != null) { | ||||
|           final client = ref.watch(apiClientProvider); | ||||
|           await client.patch( | ||||
|             '/accounts/me/sessions/current/label', | ||||
|             data: jsonEncode(name), | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     useEffect(() { | ||||
|       if (challenge != null && challenge?.stepRemain == 0) { | ||||
|         Future(() { | ||||
|           isBusy.value = true; | ||||
|           getToken().catchError((err) { | ||||
|             showErrorAlert(err); | ||||
|             isBusy.value = false; | ||||
|           }); | ||||
|         }); | ||||
|       } | ||||
|       return null; | ||||
|     }, [challenge]); | ||||
|  | ||||
|     if (factor == null) { | ||||
|       // Logging in by third parties | ||||
|       return Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           Align( | ||||
|             alignment: Alignment.centerLeft, | ||||
|             child: CircleAvatar( | ||||
|               radius: 26, | ||||
|               child: const Icon(Symbols.asterisk, size: 28), | ||||
|             ).padding(bottom: 8), | ||||
|           ), | ||||
|           Text( | ||||
|             'loginInProgress'.tr(), | ||||
|             style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900), | ||||
|           ).padding(left: 4, bottom: 16), | ||||
|           const Gap(16), | ||||
|           CircularProgressIndicator().alignment(Alignment.centerLeft), | ||||
|         ], | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     Future<void> performCheckTicket() async { | ||||
|       final pwd = passwordController.value.text; | ||||
|       if (pwd.isEmpty) return; | ||||
| @@ -190,50 +276,7 @@ class _LoginCheckScreen extends HookConsumerWidget { | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         // Get token if challenge is completed | ||||
|         final tokenResp = await client.post( | ||||
|           '/auth/token', | ||||
|           data: {'grant_type': 'authorization_code', 'code': result.id}, | ||||
|         ); | ||||
|         final token = tokenResp.data['token']; | ||||
|         setToken(ref.watch(sharedPreferencesProvider), token); | ||||
|         ref.invalidate(tokenProvider); | ||||
|         if (!context.mounted) return; | ||||
|  | ||||
|         // Do post login tasks | ||||
|         final userNotifier = ref.read(userInfoProvider.notifier); | ||||
|         userNotifier.fetchUser().then((_) { | ||||
|           final apiClient = ref.read(apiClientProvider); | ||||
|           subscribePushNotification(apiClient); | ||||
|           final wsNotifier = ref.read(websocketStateProvider.notifier); | ||||
|           wsNotifier.connect(); | ||||
|           if (context.mounted) Navigator.pop(context, true); | ||||
|         }); | ||||
|  | ||||
|         // Update the sessions' device name is available | ||||
|         if (!kIsWeb) { | ||||
|           String? name; | ||||
|           if (Platform.isIOS) { | ||||
|             return; | ||||
|             // TODO waiting for apple to respond to grant my access to com.apple.developer.device-information.user-assigned-device-name | ||||
|             // ignore: dead_code | ||||
|             final deviceInfo = await DeviceInfoPlugin().iosInfo; | ||||
|             name = deviceInfo.name; | ||||
|           } else if (Platform.isAndroid) { | ||||
|             final deviceInfo = await DeviceInfoPlugin().androidInfo; | ||||
|             name = deviceInfo.name; | ||||
|           } else if (Platform.isWindows) { | ||||
|             final deviceInfo = await DeviceInfoPlugin().windowsInfo; | ||||
|             name = deviceInfo.computerName; | ||||
|           } | ||||
|           if (name != null) { | ||||
|             final client = ref.watch(apiClientProvider); | ||||
|             await client.patch( | ||||
|               '/accounts/me/sessions/current/label', | ||||
|               data: jsonEncode(name), | ||||
|             ); | ||||
|           } | ||||
|         } | ||||
|         await getToken(code: result.id); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|         return; | ||||
| @@ -271,7 +314,6 @@ class _LoginCheckScreen extends HookConsumerWidget { | ||||
|             ], | ||||
|             decoration: InputDecoration( | ||||
|               isDense: true, | ||||
|               border: const OutlineInputBorder(), | ||||
|               labelText: 'password'.tr(), | ||||
|             ), | ||||
|             onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
| @@ -292,14 +334,12 @@ class _LoginCheckScreen extends HookConsumerWidget { | ||||
|             textStyle: Theme.of(context).textTheme.titleLarge!, | ||||
|           ), | ||||
|         const Gap(12), | ||||
|         Card( | ||||
|           child: ListTile( | ||||
|             leading: Icon( | ||||
|               kFactorTypes[factor!.type]?.$3 ?? Symbols.question_mark, | ||||
|             ), | ||||
|             title: Text(kFactorTypes[factor!.type]?.$1 ?? 'unknown').tr(), | ||||
|             subtitle: Text(kFactorTypes[factor!.type]?.$2 ?? 'unknown').tr(), | ||||
|         ListTile( | ||||
|           leading: Icon( | ||||
|             kFactorTypes[factor!.type]?.$3 ?? Symbols.question_mark, | ||||
|           ), | ||||
|           title: Text(kFactorTypes[factor!.type]?.$1 ?? 'unknown').tr(), | ||||
|           subtitle: Text(kFactorTypes[factor!.type]?.$2 ?? 'unknown').tr(), | ||||
|         ), | ||||
|         const Gap(12), | ||||
|         Row( | ||||
| @@ -323,7 +363,7 @@ class _LoginCheckScreen extends HookConsumerWidget { | ||||
| } | ||||
|  | ||||
| class _LoginPickerScreen extends HookConsumerWidget { | ||||
|   final SnAuthChallenge? ticket; | ||||
|   final SnAuthChallenge? challenge; | ||||
|   final List<SnAuthFactor>? factors; | ||||
|   final Function(SnAuthChallenge?) onChallenge; | ||||
|   final Function(SnAuthFactor) onPickFactor; | ||||
| @@ -332,7 +372,7 @@ class _LoginPickerScreen extends HookConsumerWidget { | ||||
|  | ||||
|   const _LoginPickerScreen({ | ||||
|     super.key, | ||||
|     required this.ticket, | ||||
|     required this.challenge, | ||||
|     required this.factors, | ||||
|     required this.onChallenge, | ||||
|     required this.onPickFactor, | ||||
| @@ -350,6 +390,15 @@ class _LoginPickerScreen extends HookConsumerWidget { | ||||
|       return null; | ||||
|     }, [isBusy]); | ||||
|  | ||||
|     useEffect(() { | ||||
|       if (challenge != null && challenge?.stepRemain == 0) { | ||||
|         Future(() { | ||||
|           onNext(); | ||||
|         }); | ||||
|       } | ||||
|       return null; | ||||
|     }, [challenge]); | ||||
|  | ||||
|     final unfocusColor = Theme.of( | ||||
|       context, | ||||
|     ).colorScheme.onSurface.withAlpha((255 * 0.75).round()); | ||||
| @@ -364,7 +413,7 @@ class _LoginPickerScreen extends HookConsumerWidget { | ||||
|  | ||||
|       try { | ||||
|         await client.post( | ||||
|           '/auth/challenge/${ticket!.id}/factors/${factorPicked.value!.id}', | ||||
|           '/auth/challenge/${challenge!.id}/factors/${factorPicked.value!.id}', | ||||
|           data: | ||||
|               hintController.text.isNotEmpty | ||||
|                   ? jsonEncode(hintController.text) | ||||
| @@ -388,20 +437,6 @@ class _LoginPickerScreen extends HookConsumerWidget { | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     useEffect(() { | ||||
|       if (ticket == null || (factors?.isEmpty ?? true)) return; | ||||
|       if (ticket!.blacklistFactors.isEmpty) { | ||||
|         Future(() { | ||||
|           var password = factors!.where((x) => x.type == 0).firstOrNull; | ||||
|           if (password != null) { | ||||
|             factorPicked.value = password; | ||||
|             performGetFactorCode(); | ||||
|           } | ||||
|         }); | ||||
|       } | ||||
|       return null; | ||||
|     }, [ticket, factors]); | ||||
|  | ||||
|     return Column( | ||||
|       key: const ValueKey<int>(1), | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
| @@ -432,7 +467,7 @@ class _LoginPickerScreen extends HookConsumerWidget { | ||||
|                           kFactorTypes[x.type]?.$3 ?? Symbols.question_mark, | ||||
|                         ), | ||||
|                         title: Text(kFactorTypes[x.type]?.$1 ?? 'unknown').tr(), | ||||
|                         enabled: !ticket!.blacklistFactors.contains(x.id), | ||||
|                         enabled: !challenge!.blacklistFactors.contains(x.id), | ||||
|                         value: factorPicked.value == x, | ||||
|                         onChanged: (value) { | ||||
|                           if (value == true) { | ||||
| @@ -457,7 +492,7 @@ class _LoginPickerScreen extends HookConsumerWidget { | ||||
|           ).padding(top: 12, bottom: 4, horizontal: 4), | ||||
|         const Gap(8), | ||||
|         Text( | ||||
|           'loginMultiFactor'.plural(ticket!.stepRemain), | ||||
|           'loginMultiFactor'.plural(challenge!.stepRemain), | ||||
|           style: TextStyle(color: unfocusColor, fontSize: 13), | ||||
|         ).padding(horizontal: 16), | ||||
|         const Gap(12), | ||||
| @@ -575,6 +610,72 @@ class _LoginLookupScreen extends HookConsumerWidget { | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Future<void> withApple() async { | ||||
|       final client = ref.watch(apiClientProvider); | ||||
|       try { | ||||
|         final credential = await SignInWithApple.getAppleIDCredential( | ||||
|           scopes: [AppleIDAuthorizationScopes.email], | ||||
|           webAuthenticationOptions: WebAuthenticationOptions( | ||||
|             clientId: 'dev.solsynth.solarpass', | ||||
|             redirectUri: Uri.parse('https://nt.solian.app/auth/callback/apple'), | ||||
|           ), | ||||
|         ); | ||||
|  | ||||
|         if (context.mounted) showLoadingModal(context); | ||||
|         final resp = await client.post( | ||||
|           '/auth/login/apple/mobile', | ||||
|           data: { | ||||
|             'identity_token': credential.identityToken!, | ||||
|             'authorization_code': credential.authorizationCode, | ||||
|             'device_id': await getUdid(), | ||||
|           }, | ||||
|         ); | ||||
|  | ||||
|         final challenge = SnAuthChallenge.fromJson(resp.data); | ||||
|         onChallenge(challenge); | ||||
|         final factorResp = await client.get( | ||||
|           '/auth/challenge/${challenge.id}/factors', | ||||
|         ); | ||||
|         onFactor( | ||||
|           List<SnAuthFactor>.from( | ||||
|             factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)), | ||||
|           ), | ||||
|         ); | ||||
|         onNext(); | ||||
|       } catch (err) { | ||||
|         if (err is SignInWithAppleAuthorizationException) return; | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Future<void> withOidc(String provider) async { | ||||
|       final challengeId = await Navigator.of(context).push( | ||||
|         MaterialPageRoute( | ||||
|           builder: (context) => OidcScreen(provider: provider.toLowerCase()), | ||||
|         ), | ||||
|       ); | ||||
|  | ||||
|       final client = ref.watch(apiClientProvider); | ||||
|       try { | ||||
|         final resp = await client.get('/auth/challenge/$challengeId'); | ||||
|         final challenge = SnAuthChallenge.fromJson(resp.data); | ||||
|         onChallenge(challenge); | ||||
|         final factorResp = await client.get( | ||||
|           '/auth/challenge/${challenge.id}/factors', | ||||
|         ); | ||||
|         onFactor( | ||||
|           List<SnAuthFactor>.from( | ||||
|             factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)), | ||||
|           ), | ||||
|         ); | ||||
|         onNext(); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
| @@ -603,7 +704,45 @@ class _LoginLookupScreen extends HookConsumerWidget { | ||||
|           onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|           onSubmitted: isBusy.value ? null : (_) => performNewTicket(), | ||||
|         ).padding(horizontal: 7), | ||||
|         const Gap(12), | ||||
|         Row( | ||||
|           spacing: 6, | ||||
|           crossAxisAlignment: CrossAxisAlignment.center, | ||||
|           children: <Widget>[ | ||||
|             Text("loginOr").tr().fontSize(11).opacity(0.85), | ||||
|             const Gap(8), | ||||
|             Spacer(), | ||||
|             IconButton.filledTonal( | ||||
|               onPressed: () => withOidc('github'), | ||||
|               padding: EdgeInsets.zero, | ||||
|               icon: getProviderIcon( | ||||
|                 "github", | ||||
|                 size: 16, | ||||
|                 color: Theme.of(context).colorScheme.onPrimaryContainer, | ||||
|               ), | ||||
|               tooltip: 'GitHub', | ||||
|             ), | ||||
|             IconButton.filledTonal( | ||||
|               onPressed: () => withOidc('google'), | ||||
|               padding: EdgeInsets.zero, | ||||
|               icon: getProviderIcon( | ||||
|                 "google", | ||||
|                 size: 16, | ||||
|                 color: Theme.of(context).colorScheme.onPrimaryContainer, | ||||
|               ), | ||||
|               tooltip: 'Google', | ||||
|             ), | ||||
|             IconButton.filledTonal( | ||||
|               onPressed: withApple, | ||||
|               padding: EdgeInsets.zero, | ||||
|               icon: getProviderIcon( | ||||
|                 "apple", | ||||
|                 size: 16, | ||||
|                 color: Theme.of(context).colorScheme.onPrimaryContainer, | ||||
|               ), | ||||
|               tooltip: 'Apple Account', | ||||
|             ), | ||||
|           ], | ||||
|         ).padding(horizontal: 8, vertical: 8), | ||||
|         Row( | ||||
|           mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|           children: [ | ||||
|   | ||||
							
								
								
									
										1
									
								
								lib/screens/auth/oidc.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								lib/screens/auth/oidc.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| export 'oidc.native.dart' if (dart.library.html) 'oidc.web.dart'; | ||||
							
								
								
									
										225
									
								
								lib/screens/auth/oidc.native.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										225
									
								
								lib/screens/auth/oidc.native.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,225 @@ | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:flutter_inappwebview/flutter_inappwebview.dart'; | ||||
| import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/services/udid.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| class OidcScreen extends ConsumerStatefulWidget { | ||||
|   final String provider; | ||||
|   final String? title; | ||||
|  | ||||
|   const OidcScreen({super.key, required this.provider, this.title}); | ||||
|  | ||||
|   @override | ||||
|   ConsumerState<OidcScreen> createState() => _OidcScreenState(); | ||||
| } | ||||
|  | ||||
| class _OidcScreenState extends ConsumerState<OidcScreen> { | ||||
|   String? authToken; | ||||
|   String? currentUrl; | ||||
|   final TextEditingController _urlController = TextEditingController(); | ||||
|   bool _isLoading = true; | ||||
|   late Future<String> _deviceIdFuture; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _deviceIdFuture = getUdid(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _urlController.dispose(); | ||||
|     super.dispose(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final serverUrl = ref.watch(serverUrlProvider); | ||||
|     final token = ref.watch(tokenProvider); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar( | ||||
|         title: widget.title != null ? Text(widget.title!) : Text('login').tr(), | ||||
|       ), | ||||
|       body: FutureBuilder<String>( | ||||
|         future: _deviceIdFuture, | ||||
|         builder: (context, snapshot) { | ||||
|           if (snapshot.connectionState == ConnectionState.waiting) { | ||||
|             return const Center(child: CircularProgressIndicator()); | ||||
|           } | ||||
|  | ||||
|           if (snapshot.hasError) { | ||||
|             return Center(child: Text('somethingWentWrong').tr()); | ||||
|           } | ||||
|  | ||||
|           final deviceId = snapshot.data!; | ||||
|  | ||||
|           return Column( | ||||
|             children: [ | ||||
|               Expanded( | ||||
|                 child: InAppWebView( | ||||
|                   initialSettings: InAppWebViewSettings( | ||||
|                     userAgent: | ||||
|                         kIsWeb | ||||
|                             ? null | ||||
|                             : Platform.isIOS | ||||
|                             ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1' | ||||
|                             : Platform.isAndroid | ||||
|                             ? 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile 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( | ||||
|                     url: WebUri('$serverUrl/auth/login/${widget.provider}'), | ||||
|                     headers: { | ||||
|                       if (token?.token.isNotEmpty ?? false) | ||||
|                         'Authorization': 'AtField ${token!.token}', | ||||
|                       'X-Device-Id': deviceId, | ||||
|                     }, | ||||
|                   ), | ||||
|                   onWebViewCreated: (controller) { | ||||
|                     // Register a handler to receive the token from JavaScript | ||||
|                     controller.addJavaScriptHandler( | ||||
|                       handlerName: 'tokenHandler', | ||||
|                       callback: (args) { | ||||
|                         // args[0] will be the token string | ||||
|                         if (args.isNotEmpty && args[0] is String) { | ||||
|                           setState(() { | ||||
|                             authToken = args[0]; | ||||
|                           }); | ||||
|  | ||||
|                           // Return the token and close the webview | ||||
|                           Navigator.of(context).pop(authToken); | ||||
|                         } | ||||
|                       }, | ||||
|                     ); | ||||
|                   }, | ||||
|                   shouldOverrideUrlLoading: ( | ||||
|                     controller, | ||||
|                     navigationAction, | ||||
|                   ) async { | ||||
|                     final url = navigationAction.request.url; | ||||
|                     if (url != null) { | ||||
|                       setState(() { | ||||
|                         currentUrl = url.toString(); | ||||
|                         _urlController.text = currentUrl ?? ''; | ||||
|                         _isLoading = true; | ||||
|                       }); | ||||
|  | ||||
|                       final path = url.path; | ||||
|                       final queryParams = url.queryParameters; | ||||
|  | ||||
|                       // Check if we're on the token page | ||||
|                       if (path.endsWith('/auth/callback')) { | ||||
|                         // Extract token from URL | ||||
|                         final challenge = queryParams['challenge']; | ||||
|                         // Return the token and close the webview | ||||
|                         Navigator.of(context).pop(challenge); | ||||
|                         return NavigationActionPolicy.CANCEL; | ||||
|                       } | ||||
|                     } | ||||
|                     return NavigationActionPolicy.ALLOW; | ||||
|                   }, | ||||
|                   onUpdateVisitedHistory: (controller, url, androidIsReload) { | ||||
|                     if (url != null) { | ||||
|                       setState(() { | ||||
|                         currentUrl = url.toString(); | ||||
|                         _urlController.text = currentUrl ?? ''; | ||||
|                       }); | ||||
|                     } | ||||
|                   }, | ||||
|                   onLoadStop: (controller, url) { | ||||
|                     setState(() { | ||||
|                       _isLoading = false; | ||||
|                     }); | ||||
|                   }, | ||||
|                   onLoadStart: (controller, url) { | ||||
|                     setState(() { | ||||
|                       _isLoading = true; | ||||
|                     }); | ||||
|                   }, | ||||
|                   onLoadError: (controller, url, code, message) { | ||||
|                     setState(() { | ||||
|                       _isLoading = false; | ||||
|                     }); | ||||
|                   }, | ||||
|                 ), | ||||
|               ), | ||||
|               // Loading progress indicator | ||||
|               if (_isLoading) | ||||
|                 LinearProgressIndicator( | ||||
|                   color: Theme.of(context).colorScheme.primary, | ||||
|                   backgroundColor: Theme.of(context).colorScheme.surfaceVariant, | ||||
|                   borderRadius: BorderRadius.zero, | ||||
|                   stopIndicatorRadius: 0, | ||||
|                   minHeight: 2, | ||||
|                 ) | ||||
|               else | ||||
|                 ColoredBox( | ||||
|                   color: Theme.of(context).colorScheme.surfaceVariant, | ||||
|                 ).height(2), | ||||
|               // Debug location bar (only visible in debug mode) | ||||
|               Container( | ||||
|                 padding: EdgeInsets.only( | ||||
|                   left: 16, | ||||
|                   right: 0, | ||||
|                   bottom: MediaQuery.of(context).padding.bottom + 8, | ||||
|                   top: 8, | ||||
|                 ), | ||||
|                 color: Theme.of(context).colorScheme.surface, | ||||
|                 child: Row( | ||||
|                   children: [ | ||||
|                     Expanded( | ||||
|                       child: TextField( | ||||
|                         controller: _urlController, | ||||
|                         decoration: InputDecoration( | ||||
|                           isDense: true, | ||||
|                           contentPadding: const EdgeInsets.symmetric( | ||||
|                             horizontal: 8, | ||||
|                             vertical: 8, | ||||
|                           ), | ||||
|                           border: OutlineInputBorder( | ||||
|                             borderRadius: BorderRadius.circular(4), | ||||
|                           ), | ||||
|                           hintText: 'URL', | ||||
|                         ), | ||||
|                         style: const TextStyle(fontSize: 12), | ||||
|                         readOnly: true, | ||||
|                       ), | ||||
|                     ), | ||||
|                     const Gap(4), | ||||
|                     IconButton( | ||||
|                       icon: const Icon(Icons.copy, size: 20), | ||||
|                       padding: const EdgeInsets.all(4), | ||||
|                       constraints: const BoxConstraints(), | ||||
|                       onPressed: () { | ||||
|                         if (currentUrl != null) { | ||||
|                           Clipboard.setData(ClipboardData(text: currentUrl!)); | ||||
|                           ScaffoldMessenger.of(context).showSnackBar( | ||||
|                             SnackBar( | ||||
|                               content: Text('copyToClipboard').tr(), | ||||
|                               duration: const Duration(seconds: 1), | ||||
|                             ), | ||||
|                           ); | ||||
|                         } | ||||
|                       }, | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ); | ||||
|         }, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										86
									
								
								lib/screens/auth/oidc.web.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								lib/screens/auth/oidc.web.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,86 @@ | ||||
| // ignore_for_file: invalid_runtime_check_with_js_interop_types | ||||
|  | ||||
| import 'dart:ui_web' as ui; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:web/web.dart' as web; | ||||
| import 'package:flutter/material.dart'; | ||||
|  | ||||
| class OidcScreen extends ConsumerStatefulWidget { | ||||
|   final String provider; | ||||
|   final String? title; | ||||
|  | ||||
|   const OidcScreen({super.key, required this.provider, this.title}); | ||||
|  | ||||
|   @override | ||||
|   ConsumerState<OidcScreen> createState() => _OidcScreenState(); | ||||
| } | ||||
|  | ||||
| class _OidcScreenState extends ConsumerState<OidcScreen> { | ||||
|   bool _isInitialized = false; | ||||
|   final String _viewType = 'oidc-iframe'; | ||||
|  | ||||
|   void _setupWebListener(String serverUrl) { | ||||
|     // Listen for messages from the iframe | ||||
|     web.window.onMessage.listen((event) { | ||||
|       if (event.data != null && event.data is String) { | ||||
|         final message = event.data as String; | ||||
|         if (message.startsWith("token=")) { | ||||
|           String token = message.replaceFirst("token=", ""); | ||||
|           // Return the token and close the screen | ||||
|           if (mounted) Navigator.pop(context, token); | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     // Create the iframe for the OIDC login | ||||
|     final token = ref.watch(tokenProvider); | ||||
|     final iframe = | ||||
|         web.HTMLIFrameElement() | ||||
|           ..src = | ||||
|               (token?.token.isNotEmpty ?? false) | ||||
|                   ? '$serverUrl/auth/login/${widget.provider}?tk=${token!.token}' | ||||
|                   : '$serverUrl/auth/login/${widget.provider}' | ||||
|           ..style.border = 'none' | ||||
|           ..width = '100%' | ||||
|           ..height = '100%'; | ||||
|  | ||||
|     // Add the iframe to the document body | ||||
|     web.document.body!.append(iframe); | ||||
|  | ||||
|     // Register the iframe as a platform view | ||||
|     ui.platformViewRegistry.registerViewFactory( | ||||
|       _viewType, | ||||
|       (int viewId) => iframe, | ||||
|     ); | ||||
|  | ||||
|     setState(() { | ||||
|       _isInitialized = true; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     Future.delayed(Duration.zero, () { | ||||
|       final serverUrl = ref.watch(serverUrlProvider); | ||||
|       _setupWebListener(serverUrl); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar( | ||||
|         title: widget.title != null ? Text(widget.title!) : Text('login').tr(), | ||||
|       ), | ||||
|       body: | ||||
|           _isInitialized | ||||
|               ? HtmlElementView(viewType: _viewType) | ||||
|               : Center(child: CircularProgressIndicator()), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -337,7 +337,7 @@ class ChatRoomScreen extends HookConsumerWidget { | ||||
|         ), | ||||
|       ); | ||||
|  | ||||
|       typingDebouncer.value = Timer(const Duration(milliseconds: 1000), () { | ||||
|       typingDebouncer.value = Timer(const Duration(milliseconds: 850), () { | ||||
|         typingDebouncer.value = null; | ||||
|       }); | ||||
|     } | ||||
| @@ -384,9 +384,12 @@ class ChatRoomScreen extends HookConsumerWidget { | ||||
|         if (!pkt.type.startsWith('messages')) return; | ||||
|         if (['messages.read'].contains(pkt.type)) return; | ||||
|  | ||||
|         if (pkt.type == 'messages.typing') { | ||||
|         if (pkt.type == 'messages.typing' && pkt.data?['sender'] != null) { | ||||
|           if (pkt.data?['room_id'] != chatRoom.value?.id) return; | ||||
|           if (pkt.data?['sender_id'] == chatIdentity.value?.id) return; | ||||
|  | ||||
|           final sender = SnChatMember.fromJson( | ||||
|             pkt.data!['sender'], | ||||
|             pkt.data?['sender'], | ||||
|           ).copyWith(lastTyped: DateTime.now()); | ||||
|  | ||||
|           // Check if the sender is already in the typing list | ||||
| @@ -733,7 +736,7 @@ class ChatRoomScreen extends HookConsumerWidget { | ||||
|                       mainAxisSize: MainAxisSize.min, | ||||
|                       children: [ | ||||
|                         AnimatedSwitcher( | ||||
|                           duration: const Duration(milliseconds: 300), | ||||
|                           duration: const Duration(milliseconds: 150), | ||||
|                           switchInCurve: Curves.fastEaseInToSlowEaseOut, | ||||
|                           switchOutCurve: Curves.fastEaseInToSlowEaseOut, | ||||
|                           transitionBuilder: ( | ||||
| @@ -800,7 +803,7 @@ class ChatRoomScreen extends HookConsumerWidget { | ||||
|                                     ), | ||||
|                                   ) | ||||
|                                   : const SizedBox.shrink( | ||||
|                                     key: ValueKey('no_typing'), | ||||
|                                     key: ValueKey('typing-indicator-none'), | ||||
|                                   ), | ||||
|                         ), | ||||
|                         _ChatInput( | ||||
|   | ||||
| @@ -14,7 +14,7 @@ import 'package:island/widgets/account/account_picker.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/content/paging_helper_ext.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'; | ||||
| @@ -31,6 +31,206 @@ class ChatDetailScreen extends HookConsumerWidget { | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final roomState = ref.watch(chatroomProvider(id)); | ||||
|     final roomIdentity = ref.watch(chatroomIdentityProvider(id)); | ||||
|  | ||||
|     const kNotifyLevelText = [ | ||||
|       'chatNotifyLevelAll', | ||||
|       'chatNotifyLevelMention', | ||||
|       'chatNotifyLevelNone', | ||||
|     ]; | ||||
|  | ||||
|     void setNotifyLevel(int level) async { | ||||
|       try { | ||||
|         final client = ref.watch(apiClientProvider); | ||||
|         await client.patch( | ||||
|           '/chat/$id/members/me/notify', | ||||
|           data: {'notify_level': level}, | ||||
|         ); | ||||
|         ref.invalidate(chatroomIdentityProvider(id)); | ||||
|         if (context.mounted) { | ||||
|           showSnackBar( | ||||
|             context, | ||||
|             'chatNotifyLevelUpdated'.tr(args: [kNotifyLevelText[level].tr()]), | ||||
|           ); | ||||
|         } | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     void setChatBreak(DateTime until) async { | ||||
|       try { | ||||
|         final client = ref.watch(apiClientProvider); | ||||
|         await client.patch( | ||||
|           '/chat/$id/members/me/notify', | ||||
|           data: {'break_until': until.toUtc().toIso8601String()}, | ||||
|         ); | ||||
|         ref.invalidate(chatroomProvider(id)); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     void showNotifyLevelBottomSheet(SnChatMember identity) { | ||||
|       showModalBottomSheet( | ||||
|         isScrollControlled: true, | ||||
|         context: context, | ||||
|         builder: | ||||
|             (context) => SheetScaffold( | ||||
|               height: 320, | ||||
|               titleText: 'chatNotifyLevel'.tr(), | ||||
|               child: Column( | ||||
|                 mainAxisSize: MainAxisSize.min, | ||||
|                 children: [ | ||||
|                   ListTile( | ||||
|                     title: const Text('chatNotifyLevelAll').tr(), | ||||
|                     subtitle: const Text('chatNotifyLevelDescription').tr(), | ||||
|                     leading: const Icon(Icons.notifications_active), | ||||
|                     selected: identity.notify == 0, | ||||
|                     onTap: () { | ||||
|                       setNotifyLevel(0); | ||||
|                       Navigator.pop(context); | ||||
|                     }, | ||||
|                   ), | ||||
|                   ListTile( | ||||
|                     title: const Text('chatNotifyLevelMention').tr(), | ||||
|                     subtitle: const Text('chatNotifyLevelDescription').tr(), | ||||
|                     leading: const Icon(Icons.alternate_email), | ||||
|                     selected: identity.notify == 1, | ||||
|                     onTap: () { | ||||
|                       setNotifyLevel(1); | ||||
|                       Navigator.pop(context); | ||||
|                     }, | ||||
|                   ), | ||||
|                   ListTile( | ||||
|                     title: const Text('chatNotifyLevelNone').tr(), | ||||
|                     subtitle: const Text('chatNotifyLevelDescription').tr(), | ||||
|                     leading: const Icon(Icons.notifications_off), | ||||
|                     selected: identity.notify == 2, | ||||
|                     onTap: () { | ||||
|                       setNotifyLevel(2); | ||||
|                       Navigator.pop(context); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     void showChatBreakDialog() { | ||||
|       final now = DateTime.now(); | ||||
|       final durationController = TextEditingController(); | ||||
|  | ||||
|       showDialog( | ||||
|         context: context, | ||||
|         builder: | ||||
|             (context) => AlertDialog( | ||||
|               title: const Text('chatBreak').tr(), | ||||
|               content: Column( | ||||
|                 mainAxisSize: MainAxisSize.min, | ||||
|                 children: [ | ||||
|                   const Text('chatBreakDescription').tr(), | ||||
|                   const Gap(16), | ||||
|                   ListTile( | ||||
|                     title: const Text('Clear').tr(), | ||||
|                     subtitle: const Text('chatBreakClear').tr(), | ||||
|                     leading: const Icon(Icons.notifications_active), | ||||
|                     onTap: () { | ||||
|                       setChatBreak(now); | ||||
|                       Navigator.pop(context); | ||||
|                       if (context.mounted) { | ||||
|                         showSnackBar(context, 'chatBreakCleared'.tr()); | ||||
|                       } | ||||
|                     }, | ||||
|                   ), | ||||
|                   ListTile( | ||||
|                     title: const Text('5m'), | ||||
|                     subtitle: const Text('chatBreakHour').tr(args: ['5m']), | ||||
|                     leading: const Icon(Symbols.circle), | ||||
|                     onTap: () { | ||||
|                       setChatBreak(now.add(const Duration(minutes: 5))); | ||||
|                       Navigator.pop(context); | ||||
|                       if (context.mounted) { | ||||
|                         showSnackBar(context, 'chatBreakSet'.tr(args: ['5m'])); | ||||
|                       } | ||||
|                     }, | ||||
|                   ), | ||||
|                   ListTile( | ||||
|                     title: const Text('10m'), | ||||
|                     subtitle: const Text('chatBreakHour').tr(args: ['10m']), | ||||
|                     leading: const Icon(Symbols.circle), | ||||
|                     onTap: () { | ||||
|                       setChatBreak(now.add(const Duration(minutes: 10))); | ||||
|                       Navigator.pop(context); | ||||
|                       if (context.mounted) { | ||||
|                         showSnackBar(context, 'chatBreakSet'.tr(args: ['10m'])); | ||||
|                       } | ||||
|                     }, | ||||
|                   ), | ||||
|                   ListTile( | ||||
|                     title: const Text('15m'), | ||||
|                     subtitle: const Text('chatBreakHour').tr(args: ['15m']), | ||||
|                     leading: const Icon(Symbols.timer_3), | ||||
|                     onTap: () { | ||||
|                       setChatBreak(now.add(const Duration(minutes: 15))); | ||||
|                       Navigator.pop(context); | ||||
|                       if (context.mounted) { | ||||
|                         showSnackBar(context, 'chatBreakSet'.tr(args: ['15m'])); | ||||
|                       } | ||||
|                     }, | ||||
|                   ), | ||||
|                   ListTile( | ||||
|                     title: const Text('30m'), | ||||
|                     subtitle: const Text('chatBreakHour').tr(args: ['30m']), | ||||
|                     leading: const Icon(Symbols.timer), | ||||
|                     onTap: () { | ||||
|                       setChatBreak(now.add(const Duration(minutes: 30))); | ||||
|                       Navigator.pop(context); | ||||
|                       if (context.mounted) { | ||||
|                         showSnackBar(context, 'chatBreakSet'.tr(args: ['30m'])); | ||||
|                       } | ||||
|                     }, | ||||
|                   ), | ||||
|                   const Gap(8), | ||||
|                   TextField( | ||||
|                     controller: durationController, | ||||
|                     decoration: InputDecoration( | ||||
|                       labelText: 'Custom (minutes)'.tr(), | ||||
|                       hintText: 'Enter minutes'.tr(), | ||||
|                       border: const OutlineInputBorder(), | ||||
|                       suffixIcon: IconButton( | ||||
|                         icon: const Icon(Icons.check), | ||||
|                         onPressed: () { | ||||
|                           final minutes = int.tryParse(durationController.text); | ||||
|                           if (minutes != null && minutes > 0) { | ||||
|                             setChatBreak(now.add(Duration(minutes: minutes))); | ||||
|                             Navigator.pop(context); | ||||
|                             if (context.mounted) { | ||||
|                               showSnackBar( | ||||
|                                 context, | ||||
|                                 'chatBreakSet'.tr(args: ['${minutes}m']), | ||||
|                               ); | ||||
|                             } | ||||
|                           } | ||||
|                         }, | ||||
|                       ), | ||||
|                     ), | ||||
|                     keyboardType: TextInputType.number, | ||||
|                     onTapOutside: | ||||
|                         (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|               actions: [ | ||||
|                 TextButton( | ||||
|                   onPressed: () => Navigator.pop(context), | ||||
|                   child: const Text('cancel').tr(), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     const iconShadow = Shadow( | ||||
|       color: Colors.black54, | ||||
| @@ -114,17 +314,59 @@ class ChatDetailScreen extends HookConsumerWidget { | ||||
|                   ], | ||||
|                 ), | ||||
|                 SliverToBoxAdapter( | ||||
|                   child: Padding( | ||||
|                     padding: const EdgeInsets.all(16.0), | ||||
|                     child: Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         Text( | ||||
|                           currentRoom.description ?? 'descriptionNone'.tr(), | ||||
|                           style: const TextStyle(fontSize: 16), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   child: Column( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                     children: [ | ||||
|                       Text( | ||||
|                         currentRoom.description ?? 'descriptionNone'.tr(), | ||||
|                         style: const TextStyle(fontSize: 16), | ||||
|                       ).padding(all: 24), | ||||
|                       const Divider(height: 1), | ||||
|                       roomIdentity.when( | ||||
|                         data: | ||||
|                             (identity) => Column( | ||||
|                               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                               children: [ | ||||
|                                 ListTile( | ||||
|                                   contentPadding: EdgeInsets.symmetric( | ||||
|                                     horizontal: 24, | ||||
|                                   ), | ||||
|                                   leading: const Icon(Symbols.notifications), | ||||
|                                   trailing: const Icon(Symbols.chevron_right), | ||||
|                                   title: const Text('chatNotifyLevel').tr(), | ||||
|                                   subtitle: Text( | ||||
|                                     kNotifyLevelText[identity!.notify].tr(), | ||||
|                                   ), | ||||
|                                   onTap: | ||||
|                                       () => | ||||
|                                           showNotifyLevelBottomSheet(identity), | ||||
|                                 ), | ||||
|                                 ListTile( | ||||
|                                   contentPadding: EdgeInsets.symmetric( | ||||
|                                     horizontal: 24, | ||||
|                                   ), | ||||
|                                   leading: const Icon(Icons.timer), | ||||
|                                   trailing: const Icon(Symbols.chevron_right), | ||||
|                                   title: const Text('chatBreak').tr(), | ||||
|                                   subtitle: | ||||
|                                       identity.breakUntil != null && | ||||
|                                               identity.breakUntil!.isAfter( | ||||
|                                                 DateTime.now(), | ||||
|                                               ) | ||||
|                                           ? Text( | ||||
|                                             DateFormat( | ||||
|                                               'yyyy-MM-dd HH:mm', | ||||
|                                             ).format(identity.breakUntil!), | ||||
|                                           ) | ||||
|                                           : const Text('chatBreakNone').tr(), | ||||
|                                   onTap: () => showChatBreakDialog(), | ||||
|                                 ), | ||||
|                               ], | ||||
|                             ), | ||||
|                         error: (_, _) => const SizedBox.shrink(), | ||||
|                         loading: () => const SizedBox.shrink(), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|   | ||||
| @@ -263,6 +263,13 @@ class CreatorHubScreen extends HookConsumerWidget { | ||||
|                             contentPadding: EdgeInsets.symmetric( | ||||
|                               horizontal: 24, | ||||
|                             ), | ||||
|                             onTap: () { | ||||
|                               context.router.push( | ||||
|                                 CreatorPostListRoute( | ||||
|                                   pubName: currentPublisher.value!.name, | ||||
|                                 ), | ||||
|                               ); | ||||
|                             }, | ||||
|                           ), | ||||
|                           Divider(height: 1).padding(vertical: 8), | ||||
|                           ListTile( | ||||
|   | ||||
							
								
								
									
										79
									
								
								lib/screens/creators/posts/list.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								lib/screens/creators/posts/list.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,79 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:island/widgets/post/post_list.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
|  | ||||
| @RoutePage() | ||||
| class CreatorPostListScreen extends HookConsumerWidget { | ||||
|   final String pubName; | ||||
|   const CreatorPostListScreen({ | ||||
|     super.key, | ||||
|     @PathParam('name') required this.pubName, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final refreshKey = useState(0); | ||||
|  | ||||
|     void showCreatePostSheet() { | ||||
|       showModalBottomSheet( | ||||
|         context: context, | ||||
|         builder: | ||||
|             (context) => SheetScaffold( | ||||
|               titleText: 'create'.tr(), | ||||
|               child: Column( | ||||
|                 children: [ | ||||
|                   ListTile( | ||||
|                     leading: const Icon(Symbols.edit), | ||||
|                     title: Text('postContent'.tr()), | ||||
|                     subtitle: Text('Create a regular post'), | ||||
|                     onTap: () async { | ||||
|                       Navigator.pop(context); | ||||
|                       final result = await context.router.pushPath( | ||||
|                         '/posts/compose?type=0', | ||||
|                       ); | ||||
|                       if (result == true) { | ||||
|                         refreshKey.value++; | ||||
|                       } | ||||
|                     }, | ||||
|                   ), | ||||
|                   ListTile( | ||||
|                     leading: const Icon(Symbols.article), | ||||
|                     title: Text('Article'), | ||||
|                     subtitle: Text('Create a detailed article'), | ||||
|                     onTap: () async { | ||||
|                       Navigator.pop(context); | ||||
|                       final result = await context.router.pushPath( | ||||
|                         '/posts/compose?type=1', | ||||
|                       ); | ||||
|                       if (result == true) { | ||||
|                         refreshKey.value++; | ||||
|                       } | ||||
|                     }, | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar(title: Text('posts').tr()), | ||||
|       body: CustomScrollView( | ||||
|         key: ValueKey(refreshKey.value), | ||||
|         slivers: [ | ||||
|           SliverPostList(pubName: pubName, itemType: PostItemType.creator), | ||||
|         ], | ||||
|       ), | ||||
|       floatingActionButton: FloatingActionButton( | ||||
|         onPressed: showCreatePostSheet, | ||||
|         child: const Icon(Symbols.add), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -7,7 +7,6 @@ import 'package:island/models/activity.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/widgets/account/status.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
| import 'package:island/widgets/check_in.dart'; | ||||
| @@ -194,10 +193,7 @@ class ActivityListNotifier extends _$ActivityListNotifier | ||||
|  | ||||
|     final response = await client.get( | ||||
|       '/activities', | ||||
|       queryParameters: { | ||||
|         if (cursor != null) 'reading_cursor': cursor, | ||||
|         'take': take, | ||||
|       }, | ||||
|       queryParameters: {if (cursor != null) 'cursor': cursor, 'take': take}, | ||||
|     ); | ||||
|  | ||||
|     final List<SnActivity> items = | ||||
| @@ -207,7 +203,12 @@ class ActivityListNotifier extends _$ActivityListNotifier | ||||
|  | ||||
|     final hasMore = (items.firstOrNull?.type ?? 'empty') != 'empty'; | ||||
|     final nextCursor = | ||||
|         items.map((x) => x.createdAt).lastOrNull?.toIso8601String().toString(); | ||||
|         items | ||||
|             .map((x) => x.createdAt) | ||||
|             .lastOrNull | ||||
|             ?.toUtc() | ||||
|             .toIso8601String() | ||||
|             .toString(); | ||||
|  | ||||
|     return CursorPagingData( | ||||
|       items: items, | ||||
|   | ||||
| @@ -7,7 +7,7 @@ part of 'explore.dart'; | ||||
| // ************************************************************************** | ||||
|  | ||||
| String _$activityListNotifierHash() => | ||||
|     r'1baf0bb961bc02bfc8a5b5f515981072c6ce1750'; | ||||
|     r'c9683035f7a66a2f331689e274642b60064fbb2e'; | ||||
|  | ||||
| /// See also [ActivityListNotifier]. | ||||
| @ProviderFor(ActivityListNotifier) | ||||
|   | ||||
| @@ -1,28 +1,22 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:image_picker/image_picker.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/screens/creators/publishers.dart'; | ||||
| import 'package:island/screens/posts/detail.dart'; | ||||
| import 'package:island/services/file.dart'; | ||||
| import 'package:island/screens/posts/compose_article.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/content/attachment_preview.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/post/compose_shared.dart'; | ||||
| import 'package:island/widgets/post/post_item.dart'; | ||||
| import 'package:island/widgets/post/publishers_modal.dart'; | ||||
| import 'package:island/screens/posts/detail.dart'; | ||||
| import 'package:island/widgets/post/compose_settings_sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:pasteboard/pasteboard.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @RoutePage() | ||||
| @@ -54,282 +48,150 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|   final SnPost? originalPost; | ||||
|   final SnPost? repliedPost; | ||||
|   final SnPost? forwardedPost; | ||||
|   final int? type; | ||||
|   const PostComposeScreen({ | ||||
|     super.key, | ||||
|     this.originalPost, | ||||
|     this.repliedPost, | ||||
|     this.forwardedPost, | ||||
|     @QueryParam('type') this.type, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     // Determine the compose type: auto-detect from edited post or use query parameter | ||||
|     final composeType = originalPost?.type ?? type ?? 0; | ||||
|  | ||||
|     // If type is 1 (article), return ArticleComposeScreen | ||||
|     if (composeType == 1) { | ||||
|       return ArticleComposeScreen(originalPost: originalPost); | ||||
|     } | ||||
|  | ||||
|     // Otherwise, continue with regular post compose | ||||
|     final theme = Theme.of(context); | ||||
|     final colorScheme = theme.colorScheme; | ||||
|  | ||||
|     final publishers = ref.watch(publishersManagedProvider); | ||||
|     final state = useMemoized( | ||||
|       () => ComposeLogic.createState( | ||||
|         originalPost: originalPost, | ||||
|         forwardedPost: forwardedPost, | ||||
|       ), | ||||
|       [originalPost, forwardedPost], | ||||
|     ); | ||||
|  | ||||
|     final currentPublisher = useState<SnPublisher?>(null); | ||||
|  | ||||
|     // Initialize publisher once when data is available | ||||
|     useEffect(() { | ||||
|       if (publishers.value?.isNotEmpty ?? false) { | ||||
|         currentPublisher.value = publishers.value!.first; | ||||
|         state.currentPublisher.value = publishers.value!.first; | ||||
|       } | ||||
|       return null; | ||||
|     }, [publishers]); | ||||
|  | ||||
|     // Contains the XFile, ByteData, or SnCloudFile | ||||
|     final attachments = useState<List<UniversalFile>>( | ||||
|       originalPost?.attachments | ||||
|               .map( | ||||
|                 (e) => UniversalFile( | ||||
|                   data: e, | ||||
|                   type: switch (e.mimeType?.split('/').firstOrNull) { | ||||
|                     'image' => UniversalFileType.image, | ||||
|                     'video' => UniversalFileType.video, | ||||
|                     'audio' => UniversalFileType.audio, | ||||
|                     _ => UniversalFileType.file, | ||||
|                   }, | ||||
|                 ), | ||||
|               ) | ||||
|               .toList() ?? | ||||
|           [], | ||||
|     ); | ||||
|     final titleController = useTextEditingController(text: originalPost?.title); | ||||
|     final descriptionController = useTextEditingController( | ||||
|       text: originalPost?.description, | ||||
|     ); | ||||
|     final contentController = useTextEditingController( | ||||
|       text: | ||||
|           originalPost?.content ?? | ||||
|           (forwardedPost != null ? '> ${forwardedPost!.content}\n\n' : null), | ||||
|     ); | ||||
|     // Dispose state when widget is disposed | ||||
|     useEffect(() { | ||||
|       return () => ComposeLogic.dispose(state); | ||||
|     }, []); | ||||
|  | ||||
|     // Add visibility state with default value from original post or 0 (public) | ||||
|     final visibility = useState<int>(originalPost?.visibility ?? 0); | ||||
|     // Helper methods | ||||
|  | ||||
|     final submitting = useState(false); | ||||
|  | ||||
|     Future<void> pickPhotoMedia() async { | ||||
|       final result = await ref | ||||
|           .watch(imagePickerProvider) | ||||
|           .pickMultiImage(requestFullMetadata: true); | ||||
|       if (result.isEmpty) return; | ||||
|       attachments.value = [ | ||||
|         ...attachments.value, | ||||
|         ...result.map( | ||||
|           (e) => UniversalFile(data: e, type: UniversalFileType.image), | ||||
|         ), | ||||
|       ]; | ||||
|     } | ||||
|  | ||||
|     Future<void> pickVideoMedia() async { | ||||
|       final result = await ref | ||||
|           .watch(imagePickerProvider) | ||||
|           .pickVideo(source: ImageSource.gallery); | ||||
|       if (result == null) return; | ||||
|       attachments.value = [ | ||||
|         ...attachments.value, | ||||
|         UniversalFile(data: result, type: UniversalFileType.video), | ||||
|       ]; | ||||
|     } | ||||
|  | ||||
|     final attachmentProgress = useState<Map<int, double>>({}); | ||||
|  | ||||
|     Future<void> uploadAttachment(int index) async { | ||||
|       final attachment = attachments.value[index]; | ||||
|       if (attachment is SnCloudFile) return; | ||||
|       final baseUrl = ref.watch(serverUrlProvider); | ||||
|       final token = await getToken(ref.watch(tokenProvider)); | ||||
|       if (token == null) throw ArgumentError('Token is null'); | ||||
|       try { | ||||
|         attachmentProgress.value = {...attachmentProgress.value, index: 0}; | ||||
|         final cloudFile = | ||||
|             await putMediaToCloud( | ||||
|               fileData: attachment, | ||||
|               atk: token, | ||||
|               baseUrl: baseUrl, | ||||
|               filename: attachment.data.name ?? 'Post media', | ||||
|               mimetype: | ||||
|                   attachment.data.mimeType ?? | ||||
|                   switch (attachment.type) { | ||||
|                     UniversalFileType.image => 'image/unknown', | ||||
|                     UniversalFileType.video => 'video/unknown', | ||||
|                     UniversalFileType.audio => 'audio/unknown', | ||||
|                     UniversalFileType.file => 'application/octet-stream', | ||||
|                   }, | ||||
|               onProgress: (progress, estimate) { | ||||
|                 attachmentProgress.value = { | ||||
|                   ...attachmentProgress.value, | ||||
|                   index: progress, | ||||
|                 }; | ||||
|               }, | ||||
|             ).future; | ||||
|         if (cloudFile == null) { | ||||
|           throw ArgumentError('Failed to upload the file...'); | ||||
|         } | ||||
|         final clone = List.of(attachments.value); | ||||
|         clone[index] = UniversalFile(data: cloudFile, type: attachment.type); | ||||
|         attachments.value = clone; | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         attachmentProgress.value = attachmentProgress.value..remove(index); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Future<void> deleteAttachment(int index) async { | ||||
|       final attachment = attachments.value[index]; | ||||
|       if (attachment.isOnCloud) { | ||||
|         final client = ref.watch(apiClientProvider); | ||||
|         await client.delete('/files/${attachment.data.id}'); | ||||
|       } | ||||
|       final clone = List.of(attachments.value); | ||||
|       clone.removeAt(index); | ||||
|       attachments.value = clone; | ||||
|     } | ||||
|  | ||||
|     Future<void> performAction() async { | ||||
|       try { | ||||
|         submitting.value = true; | ||||
|  | ||||
|         await Future.wait( | ||||
|           attachments.value | ||||
|               .where((e) => e.isOnDevice) | ||||
|               .mapIndexed((idx, e) => uploadAttachment(idx)), | ||||
|         ); | ||||
|  | ||||
|         final client = ref.watch(apiClientProvider); | ||||
|         await client.request( | ||||
|           originalPost == null ? '/posts' : '/posts/${originalPost!.id}', | ||||
|           data: { | ||||
|             'title': titleController.text, | ||||
|             'description': descriptionController.text, | ||||
|             'content': contentController.text, | ||||
|             'visibility': | ||||
|                 visibility.value, // Add visibility field to API request | ||||
|             'attachments': | ||||
|                 attachments.value | ||||
|                     .where((e) => e.isOnCloud) | ||||
|                     .map((e) => e.data.id) | ||||
|                     .toList(), | ||||
|             if (repliedPost != null) 'replied_post_id': repliedPost!.id, | ||||
|             if (forwardedPost != null) 'forwarded_post_id': forwardedPost!.id, | ||||
|           }, | ||||
|           options: Options( | ||||
|             headers: {'X-Pub': currentPublisher.value?.name}, | ||||
|             method: originalPost == null ? 'POST' : 'PATCH', | ||||
|           ), | ||||
|         ); | ||||
|         if (context.mounted) { | ||||
|           context.maybePop(true); | ||||
|         } | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         submitting.value = false; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Future<void> handlePaste() async { | ||||
|       final clipboard = await Pasteboard.image; | ||||
|       if (clipboard == null) return; | ||||
|  | ||||
|       attachments.value = [ | ||||
|         ...attachments.value, | ||||
|         UniversalFile( | ||||
|           data: XFile.fromData(clipboard, mimeType: "image/jpeg"), | ||||
|           type: UniversalFileType.image, | ||||
|         ), | ||||
|       ]; | ||||
|     } | ||||
|  | ||||
|     void handleKeyPress(RawKeyEvent event) { | ||||
|       if (event is! RawKeyDownEvent) return; | ||||
|  | ||||
|       final isPaste = event.logicalKey == LogicalKeyboardKey.keyV; | ||||
|       final isModifierPressed = event.isMetaPressed || event.isControlPressed; | ||||
|  | ||||
|       if (isPaste && isModifierPressed) { | ||||
|         handlePaste(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     void showVisibilityModal() { | ||||
|       showDialog( | ||||
|     void showSettingsSheet() { | ||||
|       showModalBottomSheet( | ||||
|         context: context, | ||||
|         isScrollControlled: true, | ||||
|         builder: | ||||
|             (context) => AlertDialog( | ||||
|               title: Text('postVisibility'.tr()), | ||||
|               content: Column( | ||||
|                 mainAxisSize: MainAxisSize.min, | ||||
|                 children: [ | ||||
|                   ListTile( | ||||
|                     leading: Icon(Symbols.public), | ||||
|                     title: Text('postVisibilityPublic'.tr()), | ||||
|                     onTap: () { | ||||
|                       visibility.value = 0; | ||||
|                       Navigator.pop(context); | ||||
|                     }, | ||||
|                     selected: visibility.value == 0, | ||||
|                   ), | ||||
|                   ListTile( | ||||
|                     leading: Icon(Symbols.group), | ||||
|                     title: Text('postVisibilityFriends'.tr()), | ||||
|                     onTap: () { | ||||
|                       visibility.value = 1; | ||||
|                       Navigator.pop(context); | ||||
|                     }, | ||||
|                     selected: visibility.value == 1, | ||||
|                   ), | ||||
|                   ListTile( | ||||
|                     leading: Icon(Symbols.link_off), | ||||
|                     title: Text('postVisibilityUnlisted'.tr()), | ||||
|                     onTap: () { | ||||
|                       visibility.value = 2; | ||||
|                       Navigator.pop(context); | ||||
|                     }, | ||||
|                     selected: visibility.value == 2, | ||||
|                   ), | ||||
|                   ListTile( | ||||
|                     leading: Icon(Symbols.lock), | ||||
|                     title: Text('postVisibilityPrivate'.tr()), | ||||
|                     onTap: () { | ||||
|                       visibility.value = 3; | ||||
|                       Navigator.pop(context); | ||||
|                     }, | ||||
|                     selected: visibility.value == 3, | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             (context) => ComposeSettingsSheet( | ||||
|               titleController: state.titleController, | ||||
|               descriptionController: state.descriptionController, | ||||
|               visibility: state.visibility, | ||||
|               onVisibilityChanged: () { | ||||
|                 // Trigger rebuild if needed | ||||
|               }, | ||||
|             ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     // Helper method to get the appropriate icon for each visibility status | ||||
|     IconData getVisibilityIcon(int visibilityValue) { | ||||
|       switch (visibilityValue) { | ||||
|         case 1: // Friends | ||||
|           return Symbols.group; | ||||
|         case 2: // Unlisted | ||||
|           return Symbols.link_off; | ||||
|         case 3: // Private | ||||
|           return Symbols.lock; | ||||
|         default: // Public (0) or unknown | ||||
|           return Symbols.public; | ||||
|       } | ||||
|     void showKeyboardShortcutsDialog() { | ||||
|       showDialog( | ||||
|         context: context, | ||||
|         builder: | ||||
|             (context) => AlertDialog( | ||||
|               title: Text('keyboard_shortcuts'.tr()), | ||||
|               content: Column( | ||||
|                 mainAxisSize: MainAxisSize.min, | ||||
|                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                 children: [ | ||||
|                   Text('Ctrl/Cmd + Enter: ${'submit'.tr()}'), | ||||
|                   Text('Ctrl/Cmd + V: ${'paste'.tr()}'), | ||||
|                   Text('Ctrl/Cmd + I: ${'add_image'.tr()}'), | ||||
|                   Text('Ctrl/Cmd + Shift + V: ${'add_video'.tr()}'), | ||||
|                 ], | ||||
|               ), | ||||
|               actions: [ | ||||
|                 TextButton( | ||||
|                   onPressed: () => Navigator.of(context).pop(), | ||||
|                   child: Text('close'.tr()), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     // Helper method to get the translation key for each visibility status | ||||
|     String getVisibilityText(int visibilityValue) { | ||||
|       switch (visibilityValue) { | ||||
|         case 1: // Friends | ||||
|           return 'postVisibilityFriends'; | ||||
|         case 2: // Unlisted | ||||
|           return 'postVisibilityUnlisted'; | ||||
|         case 3: // Private | ||||
|           return 'postVisibilityPrivate'; | ||||
|         default: // Public (0) or unknown | ||||
|           return 'postVisibilityPublic'; | ||||
|       } | ||||
|     Widget buildWideAttachmentGrid() { | ||||
|       return GridView.builder( | ||||
|         shrinkWrap: true, | ||||
|         physics: const NeverScrollableScrollPhysics(), | ||||
|         gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( | ||||
|           crossAxisCount: 3, | ||||
|           crossAxisSpacing: 8, | ||||
|           mainAxisSpacing: 8, | ||||
|         ), | ||||
|         itemCount: state.attachments.value.length, | ||||
|         itemBuilder: (context, idx) { | ||||
|           return AttachmentPreview( | ||||
|             item: state.attachments.value[idx], | ||||
|             progress: state.attachmentProgress.value[idx], | ||||
|             onRequestUpload: | ||||
|                 () => ComposeLogic.uploadAttachment(ref, state, idx), | ||||
|             onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx), | ||||
|             onMove: (delta) { | ||||
|               state.attachments.value = ComposeLogic.moveAttachment( | ||||
|                 state.attachments.value, | ||||
|                 idx, | ||||
|                 delta, | ||||
|               ); | ||||
|             }, | ||||
|           ); | ||||
|         }, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     Widget buildNarrowAttachmentList() { | ||||
|       return Column( | ||||
|         children: [ | ||||
|           for (var idx = 0; idx < state.attachments.value.length; idx++) | ||||
|             Container( | ||||
|               margin: const EdgeInsets.only(bottom: 8), | ||||
|               child: AttachmentPreview( | ||||
|                 item: state.attachments.value[idx], | ||||
|                 progress: state.attachmentProgress.value[idx], | ||||
|                 onRequestUpload: | ||||
|                     () => ComposeLogic.uploadAttachment(ref, state, idx), | ||||
|                 onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx), | ||||
|                 onMove: (delta) { | ||||
|                   state.attachments.value = ComposeLogic.moveAttachment( | ||||
|                     state.attachments.value, | ||||
|                     idx, | ||||
|                     delta, | ||||
|                   ); | ||||
|                 }, | ||||
|               ), | ||||
|             ), | ||||
|         ], | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     // Build UI | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar( | ||||
|         leading: const PageBackButton(), | ||||
| @@ -338,53 +200,50 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|                 ? Text(originalPost != null ? 'editPost'.tr() : 'newPost'.tr()) | ||||
|                 : null, | ||||
|         actions: [ | ||||
|           IconButton( | ||||
|             icon: const Icon(Symbols.settings), | ||||
|             onPressed: showSettingsSheet, | ||||
|             tooltip: 'postSettings'.tr(), | ||||
|           ), | ||||
|           if (isWideScreen(context)) | ||||
|             Tooltip( | ||||
|               message: 'keyboard_shortcuts'.tr(), | ||||
|               child: IconButton( | ||||
|                 icon: const Icon(Symbols.keyboard), | ||||
|                 onPressed: () { | ||||
|                   showDialog( | ||||
|                     context: context, | ||||
|                     builder: | ||||
|                         (context) => AlertDialog( | ||||
|                           title: Text('keyboard_shortcuts'.tr()), | ||||
|                           content: Column( | ||||
|                             mainAxisSize: MainAxisSize.min, | ||||
|                             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                             children: [ | ||||
|                               Text('Ctrl/Cmd + Enter: ${'submit'.tr()}'), | ||||
|                               Text('Ctrl/Cmd + V: ${'paste'.tr()}'), | ||||
|                               Text('Ctrl/Cmd + I: ${'add_image'.tr()}'), | ||||
|                               Text('Ctrl/Cmd + Shift + V: ${'add_video'.tr()}'), | ||||
|                             ], | ||||
|                           ), | ||||
|                           actions: [ | ||||
|                             TextButton( | ||||
|                               onPressed: () => Navigator.of(context).pop(), | ||||
|                               child: Text('close'.tr()), | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                   ); | ||||
|                 }, | ||||
|                 onPressed: showKeyboardShortcutsDialog, | ||||
|               ), | ||||
|             ), | ||||
|           IconButton( | ||||
|             onPressed: submitting.value ? null : performAction, | ||||
|             icon: | ||||
|                 submitting.value | ||||
|                     ? SizedBox( | ||||
|                       width: 28, | ||||
|                       height: 28, | ||||
|                       child: const CircularProgressIndicator( | ||||
|                         color: Colors.white, | ||||
|                         strokeWidth: 2.5, | ||||
|                       ), | ||||
|                     ).center() | ||||
|                     : originalPost != null | ||||
|                     ? const Icon(Symbols.edit) | ||||
|                     : const Icon(Symbols.upload), | ||||
|           ValueListenableBuilder<bool>( | ||||
|             valueListenable: state.submitting, | ||||
|             builder: (context, submitting, _) { | ||||
|               return IconButton( | ||||
|                 onPressed: | ||||
|                     submitting | ||||
|                         ? null | ||||
|                         : () => ComposeLogic.performAction( | ||||
|                           ref, | ||||
|                           state, | ||||
|                           context, | ||||
|                           originalPost: originalPost, | ||||
|                           repliedPost: repliedPost, | ||||
|                           forwardedPost: forwardedPost, | ||||
|                           postType: 0, // Regular post type | ||||
|                         ), | ||||
|                 icon: | ||||
|                     submitting | ||||
|                         ? SizedBox( | ||||
|                           width: 28, | ||||
|                           height: 28, | ||||
|                           child: const CircularProgressIndicator( | ||||
|                             color: Colors.white, | ||||
|                             strokeWidth: 2.5, | ||||
|                           ), | ||||
|                         ).center() | ||||
|                         : Icon( | ||||
|                           originalPost != null ? Symbols.edit : Symbols.upload, | ||||
|                         ), | ||||
|               ); | ||||
|             }, | ||||
|           ), | ||||
|           const Gap(8), | ||||
|         ], | ||||
| @@ -392,59 +251,22 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|       body: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           if (repliedPost != null) | ||||
|             Container( | ||||
|               padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), | ||||
|               color: Theme.of( | ||||
|                 context, | ||||
|               ).colorScheme.surfaceVariant.withOpacity(0.5), | ||||
|               child: Row( | ||||
|                 children: [ | ||||
|                   const Icon(Symbols.reply, size: 16), | ||||
|                   const Gap(8), | ||||
|                   Expanded( | ||||
|                     child: Text( | ||||
|                       '${'reply'.tr()}: ${repliedPost!.publisher.nick}', | ||||
|                       style: Theme.of(context).textTheme.bodySmall, | ||||
|                       maxLines: 1, | ||||
|                       overflow: TextOverflow.ellipsis, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|           if (forwardedPost != null) | ||||
|             Container( | ||||
|               padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), | ||||
|               color: Theme.of( | ||||
|                 context, | ||||
|               ).colorScheme.surfaceVariant.withOpacity(0.5), | ||||
|               child: Row( | ||||
|                 children: [ | ||||
|                   const Icon(Symbols.forward, size: 16), | ||||
|                   const Gap(8), | ||||
|                   Expanded( | ||||
|                     child: Text( | ||||
|                       '${'forward'.tr()}: ${forwardedPost!.publisher.nick}', | ||||
|                       style: Theme.of(context).textTheme.bodySmall, | ||||
|                       maxLines: 1, | ||||
|                       overflow: TextOverflow.ellipsis, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|           // Reply/Forward info section | ||||
|           _buildInfoBanner(context), | ||||
|  | ||||
|           // Main content area | ||||
|           Expanded( | ||||
|             child: Row( | ||||
|               spacing: 12, | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 // Publisher profile picture | ||||
|                 GestureDetector( | ||||
|                   child: ProfilePictureWidget( | ||||
|                     fileId: currentPublisher.value?.picture?.id, | ||||
|                     fileId: state.currentPublisher.value?.picture?.id, | ||||
|                     radius: 20, | ||||
|                     fallbackIcon: | ||||
|                         currentPublisher.value == null | ||||
|                         state.currentPublisher.value == null | ||||
|                             ? Symbols.question_mark | ||||
|                             : null, | ||||
|                   ), | ||||
| @@ -452,93 +274,43 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|                     showModalBottomSheet( | ||||
|                       isScrollControlled: true, | ||||
|                       context: context, | ||||
|                       builder: (context) => PublisherModal(), | ||||
|                       builder: (context) => const PublisherModal(), | ||||
|                     ).then((value) { | ||||
|                       if (value is SnPublisher) currentPublisher.value = value; | ||||
|                       if (value != null) { | ||||
|                         state.currentPublisher.value = value; | ||||
|                       } | ||||
|                     }); | ||||
|                   }, | ||||
|                 ).padding(top: 16), | ||||
|  | ||||
|                 // Post content form | ||||
|                 Expanded( | ||||
|                   child: SingleChildScrollView( | ||||
|                     padding: EdgeInsets.symmetric(vertical: 16), | ||||
|                     padding: const EdgeInsets.symmetric(vertical: 12), | ||||
|                     child: Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         Row( | ||||
|                           children: [ | ||||
|                             OutlinedButton( | ||||
|                               onPressed: () { | ||||
|                                 showVisibilityModal(); | ||||
|                               }, | ||||
|                               style: OutlinedButton.styleFrom( | ||||
|                                 shape: RoundedRectangleBorder( | ||||
|                                   borderRadius: BorderRadius.circular(20), | ||||
|                                 ), | ||||
|                                 side: BorderSide( | ||||
|                                   color: Theme.of( | ||||
|                                     context, | ||||
|                                   ).colorScheme.primary.withOpacity(0.5), | ||||
|                                 ), | ||||
|                                 padding: EdgeInsets.symmetric(horizontal: 16), | ||||
|                                 visualDensity: const VisualDensity( | ||||
|                                   vertical: -2, | ||||
|                                   horizontal: -4, | ||||
|                                 ), | ||||
|                               ), | ||||
|                               child: Row( | ||||
|                                 mainAxisSize: MainAxisSize.min, | ||||
|                                 children: [ | ||||
|                                   Icon( | ||||
|                                     getVisibilityIcon(visibility.value), | ||||
|                                     size: 16, | ||||
|                                     color: | ||||
|                                         Theme.of(context).colorScheme.primary, | ||||
|                                   ), | ||||
|                                   const SizedBox(width: 6), | ||||
|                                   Text( | ||||
|                                     getVisibilityText(visibility.value).tr(), | ||||
|                                     style: TextStyle( | ||||
|                                       fontSize: 14, | ||||
|                                       color: | ||||
|                                           Theme.of(context).colorScheme.primary, | ||||
|                                     ), | ||||
|                                   ), | ||||
|                                 ], | ||||
|                               ), | ||||
|                             ), | ||||
|                           ], | ||||
|                         ).padding(bottom: 6), | ||||
|                         TextField( | ||||
|                           controller: titleController, | ||||
|                           decoration: InputDecoration.collapsed( | ||||
|                             hintText: 'postTitle'.tr(), | ||||
|                           ), | ||||
|                           style: TextStyle(fontSize: 16), | ||||
|                           onTapOutside: | ||||
|                               (_) => | ||||
|                                   FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                         ), | ||||
|                         TextField( | ||||
|                           controller: descriptionController, | ||||
|                           decoration: InputDecoration.collapsed( | ||||
|                             hintText: 'postDescription'.tr(), | ||||
|                           ), | ||||
|                           style: TextStyle(fontSize: 16), | ||||
|                           onTapOutside: | ||||
|                               (_) => | ||||
|                                   FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                         ), | ||||
|                         const Gap(8), | ||||
|                         // Content field with borderless design | ||||
|                         RawKeyboardListener( | ||||
|                           focusNode: FocusNode(), | ||||
|                           onKey: handleKeyPress, | ||||
|                           onKey: | ||||
|                               (event) => ComposeLogic.handleKeyPress( | ||||
|                                 event, | ||||
|                                 state, | ||||
|                                 ref, | ||||
|                                 context, | ||||
|                                 originalPost: originalPost, | ||||
|                                 repliedPost: repliedPost, | ||||
|                                 forwardedPost: forwardedPost, | ||||
|                                 postType: 0, // Regular post type | ||||
|                               ), | ||||
|                           child: TextField( | ||||
|                             controller: contentController, | ||||
|                             style: TextStyle(fontSize: 14), | ||||
|                             controller: state.contentController, | ||||
|                             style: theme.textTheme.bodyMedium, | ||||
|                             decoration: InputDecoration( | ||||
|                               border: InputBorder.none, | ||||
|                               hintText: 'postPlaceholder'.tr(), | ||||
|                               isDense: true, | ||||
|                               hintText: 'postContent'.tr(), | ||||
|                               contentPadding: const EdgeInsets.all(8), | ||||
|                             ), | ||||
|                             maxLines: null, | ||||
|                             onTapOutside: | ||||
| @@ -547,81 +319,16 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|                                         ?.unfocus(), | ||||
|                           ), | ||||
|                         ), | ||||
|  | ||||
|                         const Gap(8), | ||||
|  | ||||
|                         // Attachments preview | ||||
|                         LayoutBuilder( | ||||
|                           builder: (context, constraints) { | ||||
|                             final isWide = isWideScreen(context); | ||||
|                             return isWide | ||||
|                                 ? Wrap( | ||||
|                                   spacing: 8, | ||||
|                                   runSpacing: 8, | ||||
|                                   children: [ | ||||
|                                     for ( | ||||
|                                       var idx = 0; | ||||
|                                       idx < attachments.value.length; | ||||
|                                       idx++ | ||||
|                                     ) | ||||
|                                       SizedBox( | ||||
|                                         width: constraints.maxWidth / 2 - 4, | ||||
|                                         child: AttachmentPreview( | ||||
|                                           item: attachments.value[idx], | ||||
|                                           progress: | ||||
|                                               attachmentProgress.value[idx], | ||||
|                                           onRequestUpload: | ||||
|                                               () => uploadAttachment(idx), | ||||
|                                           onDelete: () => deleteAttachment(idx), | ||||
|                                           onMove: (delta) { | ||||
|                                             if (idx + delta < 0 || | ||||
|                                                 idx + delta >= | ||||
|                                                     attachments.value.length) { | ||||
|                                               return; | ||||
|                                             } | ||||
|                                             final clone = List.of( | ||||
|                                               attachments.value, | ||||
|                                             ); | ||||
|                                             clone.insert( | ||||
|                                               idx + delta, | ||||
|                                               clone.removeAt(idx), | ||||
|                                             ); | ||||
|                                             attachments.value = clone; | ||||
|                                           }, | ||||
|                                         ), | ||||
|                                       ), | ||||
|                                   ], | ||||
|                                 ) | ||||
|                                 : Column( | ||||
|                                   crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                                   spacing: 8, | ||||
|                                   children: [ | ||||
|                                     for ( | ||||
|                                       var idx = 0; | ||||
|                                       idx < attachments.value.length; | ||||
|                                       idx++ | ||||
|                                     ) | ||||
|                                       AttachmentPreview( | ||||
|                                         item: attachments.value[idx], | ||||
|                                         progress: attachmentProgress.value[idx], | ||||
|                                         onRequestUpload: | ||||
|                                             () => uploadAttachment(idx), | ||||
|                                         onDelete: () => deleteAttachment(idx), | ||||
|                                         onMove: (delta) { | ||||
|                                           if (idx + delta < 0 || | ||||
|                                               idx + delta >= | ||||
|                                                   attachments.value.length) { | ||||
|                                             return; | ||||
|                                           } | ||||
|                                           final clone = List.of( | ||||
|                                             attachments.value, | ||||
|                                           ); | ||||
|                                           clone.insert( | ||||
|                                             idx + delta, | ||||
|                                             clone.removeAt(idx), | ||||
|                                           ); | ||||
|                                           attachments.value = clone; | ||||
|                                         }, | ||||
|                                       ), | ||||
|                                   ], | ||||
|                                 ); | ||||
|                                 ? buildWideAttachmentGrid() | ||||
|                                 : buildNarrowAttachmentList(); | ||||
|                           }, | ||||
|                         ), | ||||
|                       ], | ||||
| @@ -631,19 +338,21 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|               ], | ||||
|             ).padding(horizontal: 16), | ||||
|           ), | ||||
|  | ||||
|           // Bottom toolbar | ||||
|           Material( | ||||
|             elevation: 4, | ||||
|             child: Row( | ||||
|               children: [ | ||||
|                 IconButton( | ||||
|                   onPressed: pickPhotoMedia, | ||||
|                   onPressed: () => ComposeLogic.pickPhotoMedia(ref, state), | ||||
|                   icon: const Icon(Symbols.add_a_photo), | ||||
|                   color: Theme.of(context).colorScheme.primary, | ||||
|                   color: colorScheme.primary, | ||||
|                 ), | ||||
|                 IconButton( | ||||
|                   onPressed: pickVideoMedia, | ||||
|                   onPressed: () => ComposeLogic.pickVideoMedia(ref, state), | ||||
|                   icon: const Icon(Symbols.videocam), | ||||
|                   color: Theme.of(context).colorScheme.primary, | ||||
|                   color: colorScheme.primary, | ||||
|                 ), | ||||
|               ], | ||||
|             ).padding( | ||||
| @@ -656,4 +365,37 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildInfoBanner(BuildContext context) { | ||||
|     if (originalPost != null) { | ||||
|       return Container( | ||||
|         width: double.infinity, | ||||
|         color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||
|         child: Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           children: [ | ||||
|             Row( | ||||
|               children: [ | ||||
|                 Icon( | ||||
|                   repliedPost != null ? Symbols.reply : Symbols.forward, | ||||
|                   size: 16, | ||||
|                 ), | ||||
|                 const Gap(4), | ||||
|                 Text( | ||||
|                   repliedPost != null | ||||
|                       ? 'postReplyingTo'.tr() | ||||
|                       : 'postForwardingTo'.tr(), | ||||
|                   style: Theme.of(context).textTheme.labelMedium, | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|             const Gap(8), | ||||
|             PostItem(item: originalPost!, isOpenable: false), | ||||
|           ], | ||||
|         ).padding(all: 16), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return const SizedBox.shrink(); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										401
									
								
								lib/screens/posts/compose_article.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										401
									
								
								lib/screens/posts/compose_article.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,401 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| 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/post.dart'; | ||||
| import 'package:island/screens/creators/publishers.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
|  | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/screens/posts/detail.dart'; | ||||
| import 'package:island/widgets/content/attachment_preview.dart'; | ||||
| import 'package:island/widgets/post/compose_shared.dart'; | ||||
| import 'package:island/widgets/post/publishers_modal.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/post/compose_settings_sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @RoutePage() | ||||
| class ArticleEditScreen extends HookConsumerWidget { | ||||
|   final String id; | ||||
|   const ArticleEditScreen({super.key, @PathParam('id') required this.id}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final post = ref.watch(postProvider(id)); | ||||
|     return post.when( | ||||
|       data: (post) => ArticleComposeScreen(originalPost: post), | ||||
|       loading: | ||||
|           () => AppScaffold( | ||||
|             appBar: AppBar(leading: const PageBackButton()), | ||||
|             body: const Center(child: CircularProgressIndicator()), | ||||
|           ), | ||||
|       error: | ||||
|           (e, _) => AppScaffold( | ||||
|             appBar: AppBar(leading: const PageBackButton()), | ||||
|             body: Text('Error: $e', textAlign: TextAlign.center), | ||||
|           ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class ArticleComposeScreen extends HookConsumerWidget { | ||||
|   final SnPost? originalPost; | ||||
|  | ||||
|   const ArticleComposeScreen({super.key, this.originalPost}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final theme = Theme.of(context); | ||||
|     final colorScheme = theme.colorScheme; | ||||
|  | ||||
|     final publishers = ref.watch(publishersManagedProvider); | ||||
|     final state = useMemoized( | ||||
|       () => ComposeLogic.createState(originalPost: originalPost), | ||||
|       [originalPost], | ||||
|     ); | ||||
|  | ||||
|     final showPreview = useState(false); | ||||
|  | ||||
|     // Initialize publisher once when data is available | ||||
|     useEffect(() { | ||||
|       if (publishers.value?.isNotEmpty ?? false) { | ||||
|         state.currentPublisher.value = publishers.value!.first; | ||||
|       } | ||||
|       return null; | ||||
|     }, [publishers]); | ||||
|  | ||||
|     // Dispose state when widget is disposed | ||||
|     useEffect(() { | ||||
|       return () => ComposeLogic.dispose(state); | ||||
|     }, []); | ||||
|  | ||||
|     // Helper methods | ||||
|     void showSettingsSheet() { | ||||
|       showModalBottomSheet( | ||||
|         context: context, | ||||
|         isScrollControlled: true, | ||||
|         builder: | ||||
|             (context) => ComposeSettingsSheet( | ||||
|               titleController: state.titleController, | ||||
|               descriptionController: state.descriptionController, | ||||
|               visibility: state.visibility, | ||||
|               onVisibilityChanged: () { | ||||
|                 // Trigger rebuild if needed | ||||
|               }, | ||||
|             ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     void showKeyboardShortcutsDialog() { | ||||
|       showDialog( | ||||
|         context: context, | ||||
|         builder: | ||||
|             (context) => AlertDialog( | ||||
|               title: Text('keyboard_shortcuts'.tr()), | ||||
|               content: Column( | ||||
|                 mainAxisSize: MainAxisSize.min, | ||||
|                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                 children: [ | ||||
|                   Text('Ctrl/Cmd + Enter: ${'submit'.tr()}'), | ||||
|                   Text('Ctrl/Cmd + V: ${'paste'.tr()}'), | ||||
|                   Text('Ctrl/Cmd + I: ${'add_image'.tr()}'), | ||||
|                   Text('Ctrl/Cmd + Shift + V: ${'add_video'.tr()}'), | ||||
|                   Text('Ctrl/Cmd + P: ${'toggle_preview'.tr()}'), | ||||
|                 ], | ||||
|               ), | ||||
|               actions: [ | ||||
|                 TextButton( | ||||
|                   onPressed: () => Navigator.of(context).pop(), | ||||
|                   child: Text('close'.tr()), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     Widget buildPreviewPane() { | ||||
|       return Container( | ||||
|         decoration: BoxDecoration( | ||||
|           border: Border.all(color: colorScheme.outline.withOpacity(0.3)), | ||||
|           borderRadius: BorderRadius.circular(8), | ||||
|         ), | ||||
|         child: Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           children: [ | ||||
|             Container( | ||||
|               padding: const EdgeInsets.all(16), | ||||
|               decoration: BoxDecoration( | ||||
|                 color: colorScheme.surfaceVariant.withOpacity(0.3), | ||||
|                 borderRadius: const BorderRadius.only( | ||||
|                   topLeft: Radius.circular(8), | ||||
|                   topRight: Radius.circular(8), | ||||
|                 ), | ||||
|               ), | ||||
|               child: Row( | ||||
|                 children: [ | ||||
|                   Icon(Symbols.preview, size: 20), | ||||
|                   const Gap(8), | ||||
|                   Text('preview'.tr(), style: theme.textTheme.titleMedium), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|             Expanded( | ||||
|               child: SingleChildScrollView( | ||||
|                 padding: const EdgeInsets.all(16), | ||||
|                 child: Column( | ||||
|                   crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                   children: [ | ||||
|                     if (state.titleController.text.isNotEmpty) ...[ | ||||
|                       Text( | ||||
|                         state.titleController.text, | ||||
|                         style: theme.textTheme.headlineSmall?.copyWith( | ||||
|                           fontWeight: FontWeight.bold, | ||||
|                         ), | ||||
|                       ), | ||||
|                       const Gap(16), | ||||
|                     ], | ||||
|                     if (state.descriptionController.text.isNotEmpty) ...[ | ||||
|                       Text( | ||||
|                         state.descriptionController.text, | ||||
|                         style: theme.textTheme.bodyLarge?.copyWith( | ||||
|                           color: colorScheme.onSurface.withOpacity(0.7), | ||||
|                         ), | ||||
|                       ), | ||||
|                       const Gap(16), | ||||
|                     ], | ||||
|                     if (state.contentController.text.isNotEmpty) | ||||
|                       Text( | ||||
|                         state.contentController.text, | ||||
|                         style: theme.textTheme.bodyMedium, | ||||
|                       ), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     Widget buildEditorPane() { | ||||
|       return Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           // Publisher row | ||||
|           Card( | ||||
|             elevation: 1, | ||||
|             child: Padding( | ||||
|               padding: const EdgeInsets.all(12), | ||||
|               child: Row( | ||||
|                 children: [ | ||||
|                   GestureDetector( | ||||
|                     child: ProfilePictureWidget( | ||||
|                       fileId: state.currentPublisher.value?.picture?.id, | ||||
|                       radius: 20, | ||||
|                       fallbackIcon: | ||||
|                           state.currentPublisher.value == null | ||||
|                               ? Symbols.question_mark | ||||
|                               : null, | ||||
|                     ), | ||||
|                     onTap: () { | ||||
|                       showModalBottomSheet( | ||||
|                         isScrollControlled: true, | ||||
|                         context: context, | ||||
|                         builder: (context) => const PublisherModal(), | ||||
|                       ).then((value) { | ||||
|                         if (value != null) { | ||||
|                           state.currentPublisher.value = value; | ||||
|                         } | ||||
|                       }); | ||||
|                     }, | ||||
|                   ), | ||||
|                   const Gap(12), | ||||
|                   Text( | ||||
|                     state.currentPublisher.value?.name ?? | ||||
|                         'postPublisherUnselected'.tr(), | ||||
|                     style: theme.textTheme.bodyMedium, | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|  | ||||
|           // Content field with keyboard listener | ||||
|           Expanded( | ||||
|             child: RawKeyboardListener( | ||||
|               focusNode: FocusNode(), | ||||
|               onKey: | ||||
|                   (event) => ComposeLogic.handleKeyPress( | ||||
|                     event, | ||||
|                     state, | ||||
|                     ref, | ||||
|                     context, | ||||
|                     originalPost: originalPost, | ||||
|                     postType: 1, // Article type | ||||
|                   ), | ||||
|               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 | ||||
|           if (state.attachments.value.isNotEmpty) ...[ | ||||
|             const Gap(16), | ||||
|             Wrap( | ||||
|               spacing: 8, | ||||
|               runSpacing: 8, | ||||
|               children: [ | ||||
|                 for (var idx = 0; idx < state.attachments.value.length; idx++) | ||||
|                   SizedBox( | ||||
|                     width: 120, | ||||
|                     height: 120, | ||||
|                     child: AttachmentPreview( | ||||
|                       item: state.attachments.value[idx], | ||||
|                       progress: state.attachmentProgress.value[idx], | ||||
|                       onRequestUpload: | ||||
|                           () => ComposeLogic.uploadAttachment(ref, state, idx), | ||||
|                       onDelete: | ||||
|                           () => ComposeLogic.deleteAttachment(ref, state, idx), | ||||
|                       onMove: (delta) { | ||||
|                         state.attachments.value = ComposeLogic.moveAttachment( | ||||
|                           state.attachments.value, | ||||
|                           idx, | ||||
|                           delta, | ||||
|                         ); | ||||
|                       }, | ||||
|                     ), | ||||
|                   ), | ||||
|               ], | ||||
|             ), | ||||
|           ], | ||||
|         ], | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar( | ||||
|         leading: const PageBackButton(), | ||||
|         actions: [ | ||||
|           IconButton( | ||||
|             icon: const Icon(Symbols.settings), | ||||
|             onPressed: showSettingsSheet, | ||||
|             tooltip: 'postSettings'.tr(), | ||||
|           ), | ||||
|           Tooltip( | ||||
|             message: 'togglePreview'.tr(), | ||||
|             child: IconButton( | ||||
|               icon: Icon(showPreview.value ? Symbols.edit : Symbols.preview), | ||||
|               onPressed: () => showPreview.value = !showPreview.value, | ||||
|             ), | ||||
|           ), | ||||
|           if (isWideScreen(context)) | ||||
|             Tooltip( | ||||
|               message: 'keyboard_shortcuts'.tr(), | ||||
|               child: IconButton( | ||||
|                 icon: const Icon(Symbols.keyboard), | ||||
|                 onPressed: showKeyboardShortcutsDialog, | ||||
|               ), | ||||
|             ), | ||||
|           ValueListenableBuilder<bool>( | ||||
|             valueListenable: state.submitting, | ||||
|             builder: (context, submitting, _) { | ||||
|               return IconButton( | ||||
|                 onPressed: | ||||
|                     submitting | ||||
|                         ? null | ||||
|                         : () => ComposeLogic.performAction( | ||||
|                           ref, | ||||
|                           state, | ||||
|                           context, | ||||
|                           originalPost: originalPost, | ||||
|                           postType: 1, // Article type | ||||
|                         ), | ||||
|                 icon: | ||||
|                     submitting | ||||
|                         ? SizedBox( | ||||
|                           width: 28, | ||||
|                           height: 28, | ||||
|                           child: const CircularProgressIndicator( | ||||
|                             color: Colors.white, | ||||
|                             strokeWidth: 2.5, | ||||
|                           ), | ||||
|                         ).center() | ||||
|                         : Icon( | ||||
|                           originalPost != null ? Symbols.edit : Symbols.upload, | ||||
|                         ), | ||||
|               ); | ||||
|             }, | ||||
|           ), | ||||
|           const Gap(8), | ||||
|         ], | ||||
|       ), | ||||
|       body: Column( | ||||
|         children: [ | ||||
|           Expanded( | ||||
|             child: Padding( | ||||
|               padding: const EdgeInsets.all(16), | ||||
|               child: | ||||
|                   isWideScreen(context) | ||||
|                       ? Row( | ||||
|                         spacing: 16, | ||||
|                         children: [ | ||||
|                           Expanded( | ||||
|                             flex: showPreview.value ? 1 : 2, | ||||
|                             child: buildEditorPane(), | ||||
|                           ), | ||||
|                           if (showPreview.value) | ||||
|                             Expanded(child: buildPreviewPane()), | ||||
|                         ], | ||||
|                       ) | ||||
|                       : showPreview.value | ||||
|                       ? buildPreviewPane() | ||||
|                       : buildEditorPane(), | ||||
|             ), | ||||
|           ), | ||||
|  | ||||
|           // Bottom toolbar | ||||
|           Material( | ||||
|             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,6 +4,7 @@ import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/post/post_item.dart'; | ||||
| @@ -29,6 +30,7 @@ class PostDetailScreen extends HookConsumerWidget { | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final post = ref.watch(postProvider(id)); | ||||
|     final user = ref.watch(userInfoProvider); | ||||
|  | ||||
|     final isWide = isWideScreen(context); | ||||
|  | ||||
| @@ -47,6 +49,7 @@ class PostDetailScreen extends HookConsumerWidget { | ||||
|                         PostItem( | ||||
|                           item: post!, | ||||
|                           isOpenable: false, | ||||
|                           isFullPost: true, | ||||
|                           backgroundColor: isWide ? Colors.transparent : null, | ||||
|                         ), | ||||
|                         const Divider(height: 1), | ||||
| @@ -57,25 +60,25 @@ class PostDetailScreen extends HookConsumerWidget { | ||||
|                   SliverGap(MediaQuery.of(context).padding.bottom + 80), | ||||
|                 ], | ||||
|               ), | ||||
|               Positioned( | ||||
|                 bottom: 0, | ||||
|                 left: 0, | ||||
|                 right: 0, | ||||
|                 child: Material( | ||||
|                   elevation: 2, | ||||
|                   color: Colors.transparent, | ||||
|                   child: PostQuickReply( | ||||
|                     parent: post, | ||||
|                     onPosted: () { | ||||
|                       ref.invalidate(postRepliesNotifierProvider(id)); | ||||
|                     }, | ||||
|                   ).padding( | ||||
|                     bottom: MediaQuery.of(context).padding.bottom + 16, | ||||
|                     top: 16, | ||||
|                     horizontal: 16, | ||||
|               if (user.value != null) | ||||
|                 Positioned( | ||||
|                   bottom: 0, | ||||
|                   left: 0, | ||||
|                   right: 0, | ||||
|                   child: Material( | ||||
|                     elevation: 2, | ||||
|                     child: PostQuickReply( | ||||
|                       parent: post, | ||||
|                       onPosted: () { | ||||
|                         ref.invalidate(postRepliesNotifierProvider(id)); | ||||
|                       }, | ||||
|                     ).padding( | ||||
|                       bottom: MediaQuery.of(context).padding.bottom + 16, | ||||
|                       top: 16, | ||||
|                       horizontal: 16, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ); | ||||
|         }, | ||||
|   | ||||
| @@ -7,13 +7,18 @@ import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/services/color.dart'; | ||||
| import 'package:island/widgets/account/account_name.dart'; | ||||
| import 'package:island/widgets/account/badge.dart'; | ||||
| import 'package:island/widgets/account/status.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/post/post_list.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:palette_generator/palette_generator.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @@ -47,6 +52,21 @@ Future<SnSubscriptionStatus> publisherSubscriptionStatus( | ||||
|   return SnSubscriptionStatus.fromJson(resp.data); | ||||
| } | ||||
|  | ||||
| @riverpod | ||||
| Future<Color?> publisherAppbarForcegroundColor(Ref ref, String pubName) async { | ||||
|   final publisher = await ref.watch(publisherProvider(pubName).future); | ||||
|   if (publisher.background == null) return null; | ||||
|   final palette = await PaletteGenerator.fromImageProvider( | ||||
|     CloudImageWidget.provider( | ||||
|       fileId: publisher.background!.id, | ||||
|       serverUrl: ref.watch(serverUrlProvider), | ||||
|     ), | ||||
|   ); | ||||
|   final dominantColor = palette.dominantColor?.color; | ||||
|   if (dominantColor == null) return null; | ||||
|   return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white; | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class PublisherProfileScreen extends HookConsumerWidget { | ||||
|   final String name; | ||||
| @@ -60,6 +80,9 @@ class PublisherProfileScreen extends HookConsumerWidget { | ||||
|     final publisher = ref.watch(publisherProvider(name)); | ||||
|     final badges = ref.watch(publisherBadgesProvider(name)); | ||||
|     final subStatus = ref.watch(publisherSubscriptionStatusProvider(name)); | ||||
|     final appbarColor = ref.watch( | ||||
|       publisherAppbarForcegroundColorProvider(name), | ||||
|     ); | ||||
|  | ||||
|     final subscribing = useState(false); | ||||
|  | ||||
| @@ -91,8 +114,8 @@ class PublisherProfileScreen extends HookConsumerWidget { | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     final iconShadow = Shadow( | ||||
|       color: Colors.black54, | ||||
|     final appbarShadow = Shadow( | ||||
|       color: appbarColor.value?.invert ?? Colors.transparent, | ||||
|       blurRadius: 5.0, | ||||
|       offset: Offset(1.0, 1.0), | ||||
|     ); | ||||
| @@ -103,24 +126,40 @@ class PublisherProfileScreen extends HookConsumerWidget { | ||||
|             body: CustomScrollView( | ||||
|               slivers: [ | ||||
|                 SliverAppBar( | ||||
|                   foregroundColor: appbarColor.value, | ||||
|                   expandedHeight: 180, | ||||
|                   pinned: true, | ||||
|                   leading: PageBackButton(shadows: [iconShadow]), | ||||
|                   flexibleSpace: FlexibleSpaceBar( | ||||
|                     background: | ||||
|                         data.background?.id != null | ||||
|                             ? CloudImageWidget(fileId: data.background!.id) | ||||
|                             : Container( | ||||
|                               color: | ||||
|                                   Theme.of(context).appBarTheme.backgroundColor, | ||||
|                             ), | ||||
|                     title: Text( | ||||
|                       data.nick, | ||||
|                       style: TextStyle( | ||||
|                         color: Theme.of(context).appBarTheme.foregroundColor, | ||||
|                         shadows: [iconShadow], | ||||
|                   leading: PageBackButton( | ||||
|                     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( | ||||
|                         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 | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                   actions: [ | ||||
|                     subStatus.when( | ||||
| @@ -136,7 +175,7 @@ class PublisherProfileScreen extends HookConsumerWidget { | ||||
|                               status.isSubscribed | ||||
|                                   ? Icons.remove_circle | ||||
|                                   : Icons.add_circle, | ||||
|                               shadows: [iconShadow], | ||||
|                               shadows: [appbarShadow], | ||||
|                             ), | ||||
|                           ), | ||||
|                       error: (_, _) => const SizedBox(), | ||||
| @@ -163,10 +202,7 @@ class PublisherProfileScreen extends HookConsumerWidget { | ||||
|                     crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                     spacing: 20, | ||||
|                     children: [ | ||||
|                       ProfilePictureWidget( | ||||
|                         fileId: data.picture!.id, | ||||
|                         radius: 32, | ||||
|                       ), | ||||
|                       ProfilePictureWidget(file: data.picture, radius: 32), | ||||
|                       Expanded( | ||||
|                         child: Column( | ||||
|                           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
| @@ -175,62 +211,85 @@ class PublisherProfileScreen extends HookConsumerWidget { | ||||
|                               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) | ||||
|                               InkWell( | ||||
|                                 onTap: () { | ||||
|                                   context.router.pushPath( | ||||
|                                     '/account/${data.account!.name}', | ||||
|                                   ); | ||||
|                                 }, | ||||
|                                 child: Row( | ||||
|                                   crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                                   spacing: 4, | ||||
|                                   children: [ | ||||
|                                     Text( | ||||
|                                       'publisherVisitAccountPage'.tr( | ||||
|                                         args: ['@${data.account!.name}'], | ||||
|                                       ), | ||||
|                                     ).fontSize(14), | ||||
|                                     Icon(Icons.launch, size: 14), | ||||
|                                   ], | ||||
|                                 ).opacity(0.85), | ||||
|                               ).padding(bottom: 6), | ||||
|                               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, | ||||
|                               ), | ||||
|                             OutlinedButton.icon( | ||||
|                               onPressed: () { | ||||
|                                 Navigator.pop(context); | ||||
|                                 context.router.pushPath( | ||||
|                                   '/account/${data.name}', | ||||
|                                 ); | ||||
|                               }, | ||||
|                               icon: const Icon(Symbols.launch), | ||||
|                               label: Text('accountProfileView').tr(), | ||||
|                               style: ButtonStyle( | ||||
|                                 visualDensity: VisualDensity(vertical: -2), | ||||
|                               ), | ||||
|                             ).padding(top: 8), | ||||
|                           ], | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ).padding(horizontal: 24, top: 24, bottom: 24), | ||||
|                   ).padding(horizontal: 24, top: 24), | ||||
|                 ), | ||||
|                 SliverToBoxAdapter( | ||||
|                   child: Column( | ||||
|                     children: [ | ||||
|                       if (badges.value?.isNotEmpty ?? false) | ||||
|                         BadgeList(badges: badges.value!).padding(top: 16), | ||||
|                       if (data.verification != null) | ||||
|                         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), | ||||
|                 ), | ||||
|                 if (badges.value?.isNotEmpty ?? false) | ||||
|                   SliverToBoxAdapter( | ||||
|                     child: BadgeList( | ||||
|                       badges: badges.value!, | ||||
|                     ).padding(horizontal: 24, bottom: 24), | ||||
|                   ) | ||||
|                 else | ||||
|                   const SliverGap(16), | ||||
|                 SliverToBoxAdapter(child: const Divider(height: 1)), | ||||
|                 if (data.bio.isNotEmpty) | ||||
|                   SliverToBoxAdapter( | ||||
|                     child: Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                       children: [Text('bio').tr().bold(), Text(data.bio)], | ||||
|                     ).padding(horizontal: 24, top: 24), | ||||
|                   ), | ||||
|                 if (data.bio.isNotEmpty) | ||||
|                   SliverToBoxAdapter( | ||||
|                     child: const Divider(height: 1).padding(top: 24), | ||||
|                   ), | ||||
|                 SliverPostList(pubName: name), | ||||
|                 SliverGap(MediaQuery.of(context).padding.bottom + 16), | ||||
|               ], | ||||
|   | ||||
| @@ -399,5 +399,136 @@ class _PublisherSubscriptionStatusProviderElement | ||||
|   String get pubName => (origin as PublisherSubscriptionStatusProvider).pubName; | ||||
| } | ||||
|  | ||||
| String _$publisherAppbarForcegroundColorHash() => | ||||
|     r'3ff2eebb48d3f3af1907052f471e648f5b14b13c'; | ||||
|  | ||||
| /// See also [publisherAppbarForcegroundColor]. | ||||
| @ProviderFor(publisherAppbarForcegroundColor) | ||||
| const publisherAppbarForcegroundColorProvider = | ||||
|     PublisherAppbarForcegroundColorFamily(); | ||||
|  | ||||
| /// See also [publisherAppbarForcegroundColor]. | ||||
| class PublisherAppbarForcegroundColorFamily extends Family<AsyncValue<Color?>> { | ||||
|   /// See also [publisherAppbarForcegroundColor]. | ||||
|   const PublisherAppbarForcegroundColorFamily(); | ||||
|  | ||||
|   /// See also [publisherAppbarForcegroundColor]. | ||||
|   PublisherAppbarForcegroundColorProvider call(String pubName) { | ||||
|     return PublisherAppbarForcegroundColorProvider(pubName); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   PublisherAppbarForcegroundColorProvider getProviderOverride( | ||||
|     covariant PublisherAppbarForcegroundColorProvider 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'publisherAppbarForcegroundColorProvider'; | ||||
| } | ||||
|  | ||||
| /// See also [publisherAppbarForcegroundColor]. | ||||
| class PublisherAppbarForcegroundColorProvider | ||||
|     extends AutoDisposeFutureProvider<Color?> { | ||||
|   /// See also [publisherAppbarForcegroundColor]. | ||||
|   PublisherAppbarForcegroundColorProvider(String pubName) | ||||
|     : this._internal( | ||||
|         (ref) => publisherAppbarForcegroundColor( | ||||
|           ref as PublisherAppbarForcegroundColorRef, | ||||
|           pubName, | ||||
|         ), | ||||
|         from: publisherAppbarForcegroundColorProvider, | ||||
|         name: r'publisherAppbarForcegroundColorProvider', | ||||
|         debugGetCreateSourceHash: | ||||
|             const bool.fromEnvironment('dart.vm.product') | ||||
|                 ? null | ||||
|                 : _$publisherAppbarForcegroundColorHash, | ||||
|         dependencies: PublisherAppbarForcegroundColorFamily._dependencies, | ||||
|         allTransitiveDependencies: | ||||
|             PublisherAppbarForcegroundColorFamily._allTransitiveDependencies, | ||||
|         pubName: pubName, | ||||
|       ); | ||||
|  | ||||
|   PublisherAppbarForcegroundColorProvider._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 | ||||
|   Override overrideWith( | ||||
|     FutureOr<Color?> Function(PublisherAppbarForcegroundColorRef provider) | ||||
|     create, | ||||
|   ) { | ||||
|     return ProviderOverride( | ||||
|       origin: this, | ||||
|       override: PublisherAppbarForcegroundColorProvider._internal( | ||||
|         (ref) => create(ref as PublisherAppbarForcegroundColorRef), | ||||
|         from: from, | ||||
|         name: null, | ||||
|         dependencies: null, | ||||
|         allTransitiveDependencies: null, | ||||
|         debugGetCreateSourceHash: null, | ||||
|         pubName: pubName, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   AutoDisposeFutureProviderElement<Color?> createElement() { | ||||
|     return _PublisherAppbarForcegroundColorProviderElement(this); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     return other is PublisherAppbarForcegroundColorProvider && | ||||
|         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 PublisherAppbarForcegroundColorRef | ||||
|     on AutoDisposeFutureProviderRef<Color?> { | ||||
|   /// The parameter `pubName` of this provider. | ||||
|   String get pubName; | ||||
| } | ||||
|  | ||||
| class _PublisherAppbarForcegroundColorProviderElement | ||||
|     extends AutoDisposeFutureProviderElement<Color?> | ||||
|     with PublisherAppbarForcegroundColorRef { | ||||
|   _PublisherAppbarForcegroundColorProviderElement(super.provider); | ||||
|  | ||||
|   @override | ||||
|   String get pubName => | ||||
|       (origin as PublisherAppbarForcegroundColorProvider).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 | ||||
|   | ||||
| @@ -16,6 +16,7 @@ import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:palette_generator/palette_generator.dart'; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| @@ -232,7 +233,7 @@ class SettingsScreen extends HookConsumerWidget { | ||||
|             return ListTile( | ||||
|               minLeadingWidth: 48, | ||||
|               title: Text('settingsBackgroundImageClear').tr(), | ||||
|               contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|               contentPadding: const EdgeInsets.only(left: 24, right: 17), | ||||
|               leading: const Icon(Symbols.texture), | ||||
|               trailing: const Icon(Symbols.chevron_right), | ||||
|               onTap: () { | ||||
| @@ -248,6 +249,53 @@ class SettingsScreen extends HookConsumerWidget { | ||||
|             ); | ||||
|           }, | ||||
|         ), | ||||
|  | ||||
|       if (!kIsWeb && docBasepath.value != null) | ||||
|         FutureBuilder( | ||||
|           future: | ||||
|               File('${docBasepath.value}/$kAppBackgroundImagePath').exists(), | ||||
|           builder: (context, snapshot) { | ||||
|             if (!snapshot.hasData || !snapshot.data!) { | ||||
|               return const SizedBox.shrink(); | ||||
|             } | ||||
|  | ||||
|             return ListTile( | ||||
|               minLeadingWidth: 48, | ||||
|               title: Text('settingsBackgroundGenerateColor').tr(), | ||||
|               contentPadding: const EdgeInsets.only(left: 24, right: 17), | ||||
|               leading: const Icon(Symbols.format_color_fill), | ||||
|               trailing: const Icon(Symbols.chevron_right), | ||||
|               onTap: () async { | ||||
|                 showLoadingModal(context); | ||||
|                 final palette = await PaletteGenerator.fromImageProvider( | ||||
|                   FileImage( | ||||
|                     File('${docBasepath.value}/$kAppBackgroundImagePath'), | ||||
|                   ), | ||||
|                 ); | ||||
|                 if (palette.darkVibrantColor == null || | ||||
|                     palette.lightVibrantColor == null) { | ||||
|                   if (context.mounted) hideLoadingModal(context); | ||||
|                   showErrorAlert( | ||||
|                     'Unable to calculate the domiant color of the background image.', | ||||
|                   ); | ||||
|                   return; | ||||
|                 } | ||||
|                 if (!context.mounted) return; | ||||
|                 final color = | ||||
|                     MediaQuery.of(context).platformBrightness == Brightness.dark | ||||
|                         ? palette.darkVibrantColor!.color | ||||
|                         : palette.lightVibrantColor!.color; | ||||
|                 ref | ||||
|                     .read(appSettingsNotifierProvider.notifier) | ||||
|                     .setAppColorScheme(color.value); | ||||
|                 if (context.mounted) { | ||||
|                   hideLoadingModal(context); | ||||
|                   showSnackBar(context, 'settingsApplied'.tr()); | ||||
|                 } | ||||
|               }, | ||||
|             ); | ||||
|           }, | ||||
|         ), | ||||
|     ]; | ||||
|  | ||||
|     final serverSettings = [ | ||||
| @@ -402,19 +450,24 @@ class SettingsScreen extends HookConsumerWidget { | ||||
|                               children: [ | ||||
|                                 _ShortcutRow( | ||||
|                                   shortcut: 'Ctrl+F', | ||||
|                                   description: 'settingsKeyboardShortcutSearch'.tr(), | ||||
|                                   description: | ||||
|                                       'settingsKeyboardShortcutSearch'.tr(), | ||||
|                                 ), | ||||
|                                 _ShortcutRow( | ||||
|                                   shortcut: 'Ctrl+,', | ||||
|                                   description: 'settingsKeyboardShortcutSettings'.tr(), | ||||
|                                   description: | ||||
|                                       'settingsKeyboardShortcutSettings'.tr(), | ||||
|                                 ), | ||||
|                                 _ShortcutRow( | ||||
|                                   shortcut: 'Ctrl+N', | ||||
|                                   description: 'settingsKeyboardShortcutNewMessage'.tr(), | ||||
|                                   description: | ||||
|                                       'settingsKeyboardShortcutNewMessage'.tr(), | ||||
|                                 ), | ||||
|                                 _ShortcutRow( | ||||
|                                   shortcut: 'Esc', | ||||
|                                   description: 'settingsKeyboardShortcutCloseDialog'.tr(), | ||||
|                                   description: | ||||
|                                       'settingsKeyboardShortcutCloseDialog' | ||||
|                                           .tr(), | ||||
|                                 ), | ||||
|                                 // Add more shortcuts as needed | ||||
|                               ], | ||||
| @@ -448,7 +501,10 @@ class SettingsScreen extends HookConsumerWidget { | ||||
|                     title: 'settingsAppearance'.tr(), | ||||
|                     children: appearanceSettings, | ||||
|                   ), | ||||
|                   _SettingsSection(title: 'settingsServer'.tr(), children: serverSettings), | ||||
|                   _SettingsSection( | ||||
|                     title: 'settingsServer'.tr(), | ||||
|                     children: serverSettings, | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
| @@ -475,11 +531,23 @@ class SettingsScreen extends HookConsumerWidget { | ||||
|         return Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           children: [ | ||||
|             _SettingsSection(title: 'settingsAppearance'.tr(), children: appearanceSettings), | ||||
|             _SettingsSection(title: 'settingsServer'.tr(), children: serverSettings), | ||||
|             _SettingsSection(title: 'settingsBehavior'.tr(), children: behaviorSettings), | ||||
|             _SettingsSection( | ||||
|               title: 'settingsAppearance'.tr(), | ||||
|               children: appearanceSettings, | ||||
|             ), | ||||
|             _SettingsSection( | ||||
|               title: 'settingsServer'.tr(), | ||||
|               children: serverSettings, | ||||
|             ), | ||||
|             _SettingsSection( | ||||
|               title: 'settingsBehavior'.tr(), | ||||
|               children: behaviorSettings, | ||||
|             ), | ||||
|             if (desktopSettings.isNotEmpty) | ||||
|               _SettingsSection(title: 'settingsDesktop'.tr(), children: desktopSettings), | ||||
|               _SettingsSection( | ||||
|                 title: 'settingsDesktop'.tr(), | ||||
|                 children: desktopSettings, | ||||
|               ), | ||||
|           ], | ||||
|         ); | ||||
|       } | ||||
|   | ||||
							
								
								
									
										7
									
								
								lib/services/color.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								lib/services/color.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| import 'package:flutter/widgets.dart'; | ||||
|  | ||||
| extension ColorInversion on Color { | ||||
|   Color get invert { | ||||
|     return Color.fromARGB(alpha, 255 - red, 255 - green, 255 - blue); | ||||
|   } | ||||
| } | ||||
| @@ -8,6 +8,7 @@ import 'package:cross_file/cross_file.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/widgets.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:native_exif/native_exif.dart'; | ||||
| import 'package:tus_client_dart/tus_client_dart.dart'; | ||||
|  | ||||
| Future<XFile?> cropImage( | ||||
| @@ -46,7 +47,91 @@ Completer<SnCloudFile?> putMediaToCloud({ | ||||
|   String? mimetype, | ||||
|   Function(double progress, Duration estimate)? onProgress, | ||||
| }) { | ||||
|   XFile file; | ||||
|   final completer = Completer<SnCloudFile?>(); | ||||
|  | ||||
|   // Process the image to remove GPS EXIF data if needed | ||||
|   if (fileData.isOnDevice && fileData.type == UniversalFileType.image) { | ||||
|     final data = fileData.data; | ||||
|     if (data is XFile && !kIsWeb && (Platform.isIOS || Platform.isAndroid)) { | ||||
|       // Use native_exif to selectively remove GPS data | ||||
|       Exif.fromPath(data.path) | ||||
|           .then((exif) { | ||||
|             // Remove GPS-related attributes | ||||
|             final gpsAttributes = [ | ||||
|               'GPSLatitude', | ||||
|               'GPSLatitudeRef', | ||||
|               'GPSLongitude', | ||||
|               'GPSLongitudeRef', | ||||
|               'GPSAltitude', | ||||
|               'GPSAltitudeRef', | ||||
|               'GPSTimeStamp', | ||||
|               'GPSProcessingMethod', | ||||
|               'GPSDateStamp', | ||||
|             ]; | ||||
|  | ||||
|             // Create a map of attributes to clear | ||||
|             final clearAttributes = <String, String>{}; | ||||
|             for (final attr in gpsAttributes) { | ||||
|               clearAttributes[attr] = ''; | ||||
|             } | ||||
|  | ||||
|             // Write empty values to remove GPS data | ||||
|             return exif.writeAttributes(clearAttributes); | ||||
|           }) | ||||
|           .then((_) { | ||||
|             // Continue with upload after GPS data is removed | ||||
|             _processUpload( | ||||
|               fileData, | ||||
|               atk, | ||||
|               baseUrl, | ||||
|               filename, | ||||
|               mimetype, | ||||
|               onProgress, | ||||
|               completer, | ||||
|             ); | ||||
|           }) | ||||
|           .catchError((e) { | ||||
|             // If there's an error, continue with the original file | ||||
|             debugPrint('Error removing GPS EXIF data: $e'); | ||||
|             _processUpload( | ||||
|               fileData, | ||||
|               atk, | ||||
|               baseUrl, | ||||
|               filename, | ||||
|               mimetype, | ||||
|               onProgress, | ||||
|               completer, | ||||
|             ); | ||||
|           }); | ||||
|  | ||||
|       return completer; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // If not an image or on web, continue with normal upload | ||||
|   _processUpload( | ||||
|     fileData, | ||||
|     atk, | ||||
|     baseUrl, | ||||
|     filename, | ||||
|     mimetype, | ||||
|     onProgress, | ||||
|     completer, | ||||
|   ); | ||||
|   return completer; | ||||
| } | ||||
|  | ||||
| // Helper method to process the upload after any EXIF processing | ||||
| Completer<SnCloudFile?> _processUpload( | ||||
|   UniversalFile fileData, | ||||
|   String atk, | ||||
|   String baseUrl, | ||||
|   String? filename, | ||||
|   String? mimetype, | ||||
|   Function(double progress, Duration estimate)? onProgress, | ||||
|   Completer<SnCloudFile?> completer, | ||||
| ) { | ||||
|   late XFile file; | ||||
|   String actualFilename = filename ?? 'randomly_file'; | ||||
|   String actualMimetype = mimetype ?? ''; | ||||
|   Uint8List? byteData; | ||||
| @@ -63,16 +148,23 @@ Completer<SnCloudFile?> putMediaToCloud({ | ||||
|     actualFilename = filename ?? 'uploaded_file'; | ||||
|     actualMimetype = mimetype ?? 'application/octet-stream'; | ||||
|     if (mimetype == null) { | ||||
|       throw ArgumentError('Mimetype is required when providing raw bytes.'); | ||||
|       completer.completeError( | ||||
|         ArgumentError('Mimetype is required when providing raw bytes.'), | ||||
|       ); | ||||
|       return completer; | ||||
|     } | ||||
|     file = XFile.fromData(byteData!, mimeType: actualMimetype); | ||||
|   } else if (data is SnCloudFile) { | ||||
|     // If the file is already on the cloud, just return it | ||||
|     return Completer<SnCloudFile?>()..complete(data); | ||||
|     completer.complete(data); | ||||
|     return completer; | ||||
|   } else { | ||||
|     throw ArgumentError( | ||||
|       'Invalid fileData type. Expected data to be XFile, List<int>, Uint8List, or SnCloudFile.', | ||||
|     completer.completeError( | ||||
|       ArgumentError( | ||||
|         'Invalid fileData type. Expected data to be XFile, List<int>, Uint8List, or SnCloudFile.', | ||||
|       ), | ||||
|     ); | ||||
|     return completer; | ||||
|   } | ||||
|  | ||||
|   final Map<String, String> metadata = { | ||||
| @@ -80,8 +172,6 @@ Completer<SnCloudFile?> putMediaToCloud({ | ||||
|     'content-type': actualMimetype, | ||||
|   }; | ||||
|  | ||||
|   final completer = Completer<SnCloudFile?>(); | ||||
|  | ||||
|   final client = TusClient(file); | ||||
|   client | ||||
|       .upload( | ||||
|   | ||||
							
								
								
									
										14
									
								
								lib/services/text.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								lib/services/text.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| extension StringExtension on String { | ||||
|   String capitalizeEachWord() { | ||||
|     if (isEmpty) return this; | ||||
|  | ||||
|     return split(' ') | ||||
|         .map( | ||||
|           (word) => | ||||
|               word.isNotEmpty | ||||
|                   ? '${word[0].toUpperCase()}${word.substring(1).toLowerCase()}' | ||||
|                   : '', | ||||
|         ) | ||||
|         .join(' '); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										76
									
								
								lib/services/time.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								lib/services/time.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,76 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/widgets.dart'; | ||||
| import 'package:relative_time/relative_time.dart'; | ||||
|  | ||||
| extension DurationFormatter on Duration { | ||||
|   String formatDuration() { | ||||
|     final isNegative = inMicroseconds < 0; | ||||
|     final positiveDuration = isNegative ? -this : this; | ||||
|  | ||||
|     final hours = positiveDuration.inHours.toString().padLeft(2, '0'); | ||||
|     final minutes = (positiveDuration.inMinutes % 60).toString().padLeft( | ||||
|       2, | ||||
|       '0', | ||||
|     ); | ||||
|     final seconds = (positiveDuration.inSeconds % 60).toString().padLeft( | ||||
|       2, | ||||
|       '0', | ||||
|     ); | ||||
|  | ||||
|     return '${isNegative ? '-' : ''}$hours:$minutes:$seconds'; | ||||
|   } | ||||
|  | ||||
|   String formatOffset() { | ||||
|     final isNegative = inMicroseconds < 0; | ||||
|     final positiveDuration = isNegative ? -this : this; | ||||
|  | ||||
|     final hours = positiveDuration.inHours.toString().padLeft(2, '0'); | ||||
|     final minutes = (positiveDuration.inMinutes % 60).toString().padLeft( | ||||
|       2, | ||||
|       '0', | ||||
|     ); | ||||
|  | ||||
|     return '${isNegative ? '-' : '+'}$hours:$minutes'; | ||||
|   } | ||||
|  | ||||
|   String formatOffsetLocal() { | ||||
|     // Get the local timezone offset | ||||
|     final localOffset = DateTime.now().timeZoneOffset; | ||||
|  | ||||
|     // Add the local offset to the input duration | ||||
|     final totalOffset = this - localOffset; | ||||
|  | ||||
|     final isNegative = totalOffset.inMicroseconds < 0; | ||||
|     final positiveDuration = isNegative ? -totalOffset : totalOffset; | ||||
|  | ||||
|     final hours = positiveDuration.inHours.toString().padLeft(2, '0'); | ||||
|     final minutes = (positiveDuration.inMinutes % 60).toString().padLeft( | ||||
|       2, | ||||
|       '0', | ||||
|     ); | ||||
|  | ||||
|     return '${isNegative ? '-' : '+'}$hours:$minutes'; | ||||
|   } | ||||
| } | ||||
|  | ||||
| extension DateTimeFormatter on DateTime { | ||||
|   String formatSystem() { | ||||
|     return DateFormat.yMd().add_jm().format(toLocal()); | ||||
|   } | ||||
|  | ||||
|   String formatCustom(String pattern) { | ||||
|     return DateFormat(pattern).format(toLocal()); | ||||
|   } | ||||
|  | ||||
|   String formatCustomGlobal(String pattern) { | ||||
|     return DateFormat(pattern).format(this); | ||||
|   } | ||||
|  | ||||
|   String formatWithLocale(String locale) { | ||||
|     return DateFormat.yMd().add_jm().format(toLocal()).toString(); | ||||
|   } | ||||
|  | ||||
|   String formatRelative(BuildContext context) { | ||||
|     return RelativeTime(context).format(toLocal()); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										1
									
								
								lib/services/timezone.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								lib/services/timezone.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| export 'timezone/native.dart' if (dart.library.html) 'timezone/web.dart'; | ||||
							
								
								
									
										22
									
								
								lib/services/timezone/native.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								lib/services/timezone/native.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| import 'package:flutter_timezone/flutter_timezone.dart'; | ||||
| import 'package:timezone/standalone.dart' as tz; | ||||
| import 'package:timezone/data/latest_all.dart' as tzdb; | ||||
|  | ||||
| Future<void> initializeTzdb() async { | ||||
|   tzdb.initializeTimeZones(); | ||||
| } | ||||
|  | ||||
| (Duration offset, DateTime now) getTzInfo(String name) { | ||||
|   final location = tz.getLocation(name); | ||||
|   final now = tz.TZDateTime.now(location); | ||||
|   final offset = now.timeZoneOffset; | ||||
|   return (offset, now); | ||||
| } | ||||
|  | ||||
| Future<String> getMachineTz() async { | ||||
|   return await FlutterTimezone.getLocalTimezone(); | ||||
| } | ||||
|  | ||||
| List<String> getAvailableTz() { | ||||
|   return tz.timeZoneDatabase.locations.keys.toList(); | ||||
| } | ||||
							
								
								
									
										21
									
								
								lib/services/timezone/web.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								lib/services/timezone/web.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| import 'package:flutter_timezone/flutter_timezone.dart'; | ||||
| import 'package:timezone/browser.dart' as tz; | ||||
|  | ||||
| Future<void> initializeTzdb() async { | ||||
|   await tz.initializeTimeZone(); | ||||
| } | ||||
|  | ||||
| (Duration offset, DateTime now) getTzInfo(String name) { | ||||
|   final location = tz.getLocation(name); | ||||
|   final now = tz.TZDateTime.now(location); | ||||
|   final offset = now.timeZoneOffset; | ||||
|   return (offset, now); | ||||
| } | ||||
|  | ||||
| Future<String> getMachineTz() async { | ||||
|   return await FlutterTimezone.getLocalTimezone(); | ||||
| } | ||||
|  | ||||
| List<String> getAvailableTz() { | ||||
|   return tz.timeZoneDatabase.locations.keys.toList(); | ||||
| } | ||||
							
								
								
									
										99
									
								
								lib/widgets/account/account_name.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								lib/widgets/account/account_name.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| const kVerificationMarkColors = [ | ||||
|   Colors.teal, | ||||
|   Colors.blue, | ||||
|   Colors.amber, | ||||
|   Colors.blueGrey, | ||||
|   Colors.lightBlue, | ||||
| ]; | ||||
|  | ||||
| class AccountName extends StatelessWidget { | ||||
|   final SnAccount account; | ||||
|   final TextStyle? style; | ||||
|   const AccountName({super.key, required this.account, this.style}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Row( | ||||
|       mainAxisSize: MainAxisSize.min, | ||||
|       spacing: 4, | ||||
|       children: [ | ||||
|         Flexible(child: Text(account.nick, style: style)), | ||||
|         if (account.profile.verification != null) | ||||
|           VerificationMark(mark: account.profile.verification!), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class VerificationMark extends StatelessWidget { | ||||
|   final SnVerificationMark mark; | ||||
|   const VerificationMark({super.key, required this.mark}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Tooltip( | ||||
|       richMessage: TextSpan( | ||||
|         text: mark.title ?? 'No title', | ||||
|         children: [ | ||||
|           TextSpan(text: '\n'), | ||||
|           TextSpan( | ||||
|             text: mark.description ?? 'descriptionNone'.tr(), | ||||
|             style: TextStyle(fontWeight: FontWeight.normal), | ||||
|           ), | ||||
|         ], | ||||
|         style: TextStyle(fontWeight: FontWeight.bold), | ||||
|       ), | ||||
|       child: Icon( | ||||
|         mark.type == 4 | ||||
|             ? Symbols.play_circle | ||||
|             : mark.type == 0 | ||||
|             ? Symbols.build_circle | ||||
|             : Symbols.verified, | ||||
|         size: 16, | ||||
|         color: kVerificationMarkColors[mark.type], | ||||
|         fill: 1, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class VerificationStatusCard extends StatelessWidget { | ||||
|   final SnVerificationMark mark; | ||||
|   const VerificationStatusCard({super.key, required this.mark}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Card( | ||||
|       margin: EdgeInsets.zero, | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           Icon( | ||||
|             mark.type == 4 | ||||
|                 ? Symbols.play_circle | ||||
|                 : mark.type == 0 | ||||
|                 ? Symbols.build_circle | ||||
|                 : Symbols.verified, | ||||
|             size: 32, | ||||
|             color: kVerificationMarkColors[mark.type], | ||||
|             fill: 1, | ||||
|           ), | ||||
|           const Gap(8), | ||||
|           Text(mark.title ?? 'No title').bold(), | ||||
|           Text(mark.description ?? 'descriptionNone'.tr()), | ||||
|           const Gap(6), | ||||
|           Text( | ||||
|             'Verified by\n${mark.verifiedBy ?? 'No one verified it'}', | ||||
|           ).fontSize(11).opacity(0.8), | ||||
|         ], | ||||
|       ).padding(horizontal: 24, vertical: 16), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										218
									
								
								lib/widgets/account/account_nameplate.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										218
									
								
								lib/widgets/account/account_nameplate.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,218 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:island/widgets/account/account_name.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/screens/account/profile.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
|  | ||||
| class AccountNameplate extends HookConsumerWidget { | ||||
|   final String name; | ||||
|   final bool isOutlined; | ||||
|   final EdgeInsetsGeometry padding; | ||||
|  | ||||
|   const AccountNameplate({ | ||||
|     super.key, | ||||
|     required this.name, | ||||
|     this.isOutlined = true, | ||||
|     this.padding = const EdgeInsets.all(16), | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final user = ref.watch(accountProvider(name)); | ||||
|  | ||||
|     return Container( | ||||
|       decoration: | ||||
|           isOutlined | ||||
|               ? BoxDecoration( | ||||
|                 border: Border.all( | ||||
|                   width: 1 / MediaQuery.of(context).devicePixelRatio, | ||||
|                   color: Theme.of(context).dividerColor, | ||||
|                 ), | ||||
|                 borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|               ) | ||||
|               : null, | ||||
|       margin: padding, | ||||
|       child: Card( | ||||
|         margin: EdgeInsets.zero, | ||||
|         elevation: 0, | ||||
|         color: Colors.transparent, | ||||
|         child: user.when( | ||||
|           data: | ||||
|               (account) => | ||||
|                   account.profile.background != null | ||||
|                       ? AspectRatio( | ||||
|                         aspectRatio: 16 / 9, | ||||
|                         child: Stack( | ||||
|                           children: [ | ||||
|                             // Background image | ||||
|                             Positioned.fill( | ||||
|                               child: ClipRRect( | ||||
|                                 borderRadius: BorderRadius.circular(8), | ||||
|                                 child: CloudFileWidget( | ||||
|                                   item: account.profile.background!, | ||||
|                                   fit: BoxFit.cover, | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ), | ||||
|                             // Gradient overlay for text readability | ||||
|                             Positioned.fill( | ||||
|                               child: Container( | ||||
|                                 decoration: BoxDecoration( | ||||
|                                   borderRadius: BorderRadius.circular(8), | ||||
|                                   gradient: LinearGradient( | ||||
|                                     begin: Alignment.bottomCenter, | ||||
|                                     end: Alignment.topCenter, | ||||
|                                     colors: [ | ||||
|                                       Colors.black.withOpacity(0.8), | ||||
|                                       Colors.black.withOpacity(0.1), | ||||
|                                       Colors.transparent, | ||||
|                                     ], | ||||
|                                   ), | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ), | ||||
|                             // Content positioned at the bottom | ||||
|                             Positioned( | ||||
|                               left: 0, | ||||
|                               right: 0, | ||||
|                               bottom: 0, | ||||
|                               child: Padding( | ||||
|                                 padding: const EdgeInsets.symmetric( | ||||
|                                   horizontal: 16.0, | ||||
|                                   vertical: 8.0, | ||||
|                                 ), | ||||
|                                 child: Row( | ||||
|                                   children: [ | ||||
|                                     // Profile picture (equivalent to leading) | ||||
|                                     ProfilePictureWidget( | ||||
|                                       fileId: account.profile.picture?.id, | ||||
|                                     ), | ||||
|                                     const SizedBox(width: 16), | ||||
|                                     // Text content (equivalent to title and subtitle) | ||||
|                                     Expanded( | ||||
|                                       child: Column( | ||||
|                                         crossAxisAlignment: | ||||
|                                             CrossAxisAlignment.start, | ||||
|                                         mainAxisSize: MainAxisSize.min, | ||||
|                                         children: [ | ||||
|                                           AccountName( | ||||
|                                             account: account, | ||||
|                                             style: TextStyle( | ||||
|                                               fontWeight: FontWeight.bold, | ||||
|                                               color: Colors.white, | ||||
|                                             ), | ||||
|                                           ), | ||||
|                                           Text( | ||||
|                                             '@${account.name}', | ||||
|                                           ).textColor(Colors.white70), | ||||
|                                         ], | ||||
|                                       ), | ||||
|                                     ), | ||||
|                                   ], | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                       ) | ||||
|                       : Container( | ||||
|                         padding: const EdgeInsets.symmetric( | ||||
|                           horizontal: 16.0, | ||||
|                           vertical: 8.0, | ||||
|                         ), | ||||
|                         decoration: | ||||
|                             isOutlined | ||||
|                                 ? BoxDecoration( | ||||
|                                   border: Border.all( | ||||
|                                     color: | ||||
|                                         Theme.of(context).colorScheme.outline, | ||||
|                                   ), | ||||
|                                   borderRadius: BorderRadius.circular(12), | ||||
|                                 ) | ||||
|                                 : null, | ||||
|                         child: Row( | ||||
|                           children: [ | ||||
|                             // Profile picture (equivalent to leading) | ||||
|                             ProfilePictureWidget( | ||||
|                               fileId: account.profile.picture?.id, | ||||
|                             ), | ||||
|                             const SizedBox(width: 16), | ||||
|                             // Text content (equivalent to title and subtitle) | ||||
|                             Expanded( | ||||
|                               child: Column( | ||||
|                                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                                 mainAxisSize: MainAxisSize.min, | ||||
|                                 children: [ | ||||
|                                   AccountName( | ||||
|                                     account: account, | ||||
|                                     style: TextStyle( | ||||
|                                       fontWeight: FontWeight.bold, | ||||
|                                     ), | ||||
|                                   ), | ||||
|                                   Text('@${account.name}'), | ||||
|                                 ], | ||||
|                               ), | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                       ), | ||||
|           loading: | ||||
|               () => Padding( | ||||
|                 padding: const EdgeInsets.symmetric( | ||||
|                   horizontal: 16.0, | ||||
|                   vertical: 8.0, | ||||
|                 ), | ||||
|                 child: Row( | ||||
|                   children: [ | ||||
|                     // Loading indicator (equivalent to leading) | ||||
|                     const CircularProgressIndicator(), | ||||
|                     const SizedBox(width: 16), | ||||
|                     // Loading text content (equivalent to title and subtitle) | ||||
|                     Expanded( | ||||
|                       child: Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         mainAxisSize: MainAxisSize.min, | ||||
|                         children: [ | ||||
|                           const Text('loading').bold().tr(), | ||||
|                           const SizedBox(height: 4), | ||||
|                           const Text('...'), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|           error: | ||||
|               (error, stackTrace) => Padding( | ||||
|                 padding: const EdgeInsets.symmetric( | ||||
|                   horizontal: 16.0, | ||||
|                   vertical: 8.0, | ||||
|                 ), | ||||
|                 child: Row( | ||||
|                   children: [ | ||||
|                     // Error icon (equivalent to leading) | ||||
|                     const Icon(Symbols.error), | ||||
|                     const SizedBox(width: 16), | ||||
|                     // Error text content (equivalent to title and subtitle) | ||||
|                     Expanded( | ||||
|                       child: Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         mainAxisSize: MainAxisSize.min, | ||||
|                         children: [ | ||||
|                           Text('somethingWentWrong').tr().bold(), | ||||
|                           const SizedBox(height: 4), | ||||
|                           Text(error.toString()), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										169
									
								
								lib/widgets/account/account_pfc.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										169
									
								
								lib/widgets/account/account_pfc.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,169 @@ | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.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/screens/account/profile.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
| import 'package:island/services/timezone/native.dart'; | ||||
| import 'package:island/widgets/account/account_name.dart'; | ||||
| import 'package:island/widgets/account/badge.dart'; | ||||
| import 'package:island/widgets/account/leveling_progress.dart'; | ||||
| import 'package:island/widgets/account/status.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/response.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| class AccountProfileCard extends HookConsumerWidget { | ||||
|   final String uname; | ||||
|   const AccountProfileCard({super.key, required this.uname}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final account = ref.watch(accountProvider(uname)); | ||||
|     final width = | ||||
|         math.max(MediaQuery.of(context).size.width - 80, 360).toDouble(); | ||||
|     return PopupCard( | ||||
|       elevation: 8, | ||||
|       shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), | ||||
|       child: SizedBox( | ||||
|         width: width, | ||||
|         child: account.when( | ||||
|           data: | ||||
|               (data) => Column( | ||||
|                 mainAxisSize: MainAxisSize.min, | ||||
|                 crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                 children: [ | ||||
|                   if (data.profile.background != null) | ||||
|                     ClipRRect( | ||||
|                       borderRadius: const BorderRadius.vertical( | ||||
|                         top: Radius.circular(12), | ||||
|                       ), | ||||
|                       child: AspectRatio( | ||||
|                         aspectRatio: 16 / 9, | ||||
|                         child: CloudImageWidget(file: data.profile.background), | ||||
|                       ), | ||||
|                     ), | ||||
|                   Column( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                     children: [ | ||||
|                       Row( | ||||
|                         children: [ | ||||
|                           ProfilePictureWidget(file: data.profile.picture), | ||||
|                           const Gap(12), | ||||
|                           Expanded( | ||||
|                             child: Column( | ||||
|                               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                               children: [ | ||||
|                                 AccountName( | ||||
|                                   account: data, | ||||
|                                   style: TextStyle(fontWeight: FontWeight.bold), | ||||
|                                 ), | ||||
|                                 Text('@${data.name}').fontSize(12), | ||||
|                               ], | ||||
|                             ), | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|                       const Gap(12), | ||||
|                       AccountStatusWidget( | ||||
|                         uname: data.name, | ||||
|                         padding: EdgeInsets.zero, | ||||
|                       ), | ||||
|                       if (data.profile.timeZone.isNotEmpty) | ||||
|                         Row( | ||||
|                           spacing: 6, | ||||
|                           children: [ | ||||
|                             Icon( | ||||
|                               Symbols.alarm, | ||||
|                               size: 17, | ||||
|                               fill: 1, | ||||
|                             ).padding(right: 2), | ||||
|                             Text( | ||||
|                               getTzInfo( | ||||
|                                 data.profile.timeZone, | ||||
|                               ).$2.formatCustomGlobal('HH:mm'), | ||||
|                             ).fontSize(12), | ||||
|                             Text( | ||||
|                               getTzInfo( | ||||
|                                 data.profile.timeZone, | ||||
|                               ).$1.formatOffsetLocal(), | ||||
|                             ).fontSize(12), | ||||
|                           ], | ||||
|                         ).padding(top: 2), | ||||
|                       if (data.badges.isNotEmpty) | ||||
|                         BadgeList(badges: data.badges).padding(top: 12), | ||||
|                       LevelingProgressCard( | ||||
|                         level: data.profile.level, | ||||
|                         experience: data.profile.experience, | ||||
|                         progress: data.profile.levelingProgress, | ||||
|                       ).padding(top: 12), | ||||
|                       FilledButton.tonalIcon( | ||||
|                         onPressed: () { | ||||
|                           Navigator.pop(context); | ||||
|                           context.router.pushPath('/account/${data.name}'); | ||||
|                         }, | ||||
|                         icon: const Icon(Symbols.launch), | ||||
|                         label: Text('accountProfileView').tr(), | ||||
|                       ).padding(top: 12, horizontal: 2), | ||||
|                     ], | ||||
|                   ).padding(horizontal: 24, vertical: 16), | ||||
|                 ], | ||||
|               ), | ||||
|           error: | ||||
|               (err, _) => ResponseErrorWidget( | ||||
|                 error: err, | ||||
|                 onRetry: () => ref.invalidate(accountProvider(uname)), | ||||
|               ), | ||||
|           loading: | ||||
|               () => SizedBox( | ||||
|                 width: width, | ||||
|                 height: width, | ||||
|                 child: | ||||
|                     Padding( | ||||
|                       padding: const EdgeInsets.all(24), | ||||
|                       child: CircularProgressIndicator(), | ||||
|                     ).center(), | ||||
|               ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class AccountPfcGestureDetector extends StatelessWidget { | ||||
|   final String uname; | ||||
|   final Widget child; | ||||
|   const AccountPfcGestureDetector({ | ||||
|     super.key, | ||||
|     required this.uname, | ||||
|     required this.child, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return GestureDetector( | ||||
|       child: child, | ||||
|       onTapDown: (details) { | ||||
|         showAccountProfileCard(context, uname, offset: details.localPosition); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| Future<void> showAccountProfileCard( | ||||
|   BuildContext context, | ||||
|   String uname, { | ||||
|   Offset? offset, | ||||
| }) async { | ||||
|   await showPopupCard<void>( | ||||
|     offset: offset ?? Offset.zero, | ||||
|     context: context, | ||||
|     builder: (context) => AccountProfileCard(uname: uname), | ||||
|     dimBackground: true, | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										193
									
								
								lib/widgets/account/event_calendar.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										193
									
								
								lib/widgets/account/event_calendar.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,193 @@ | ||||
| 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/activity.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:table_calendar/table_calendar.dart'; | ||||
|  | ||||
| /// A reusable widget for displaying an event calendar with event details | ||||
| /// This can be used in various places throughout the app | ||||
| class EventCalendarWidget extends HookConsumerWidget { | ||||
|   /// The list of calendar entries to display | ||||
|   final AsyncValue<List<SnEventCalendarEntry>> events; | ||||
|  | ||||
|   /// Initial date to focus on | ||||
|   final DateTime? initialDate; | ||||
|  | ||||
|   /// Whether to show the event details below the calendar | ||||
|   final bool showEventDetails; | ||||
|  | ||||
|   /// Whether to constrain the width of the calendar | ||||
|   final bool constrainWidth; | ||||
|  | ||||
|   /// Maximum width constraint when constrainWidth is true | ||||
|   final double maxWidth; | ||||
|  | ||||
|   /// Callback when a day is selected | ||||
|   final void Function(DateTime)? onDaySelected; | ||||
|  | ||||
|   /// Callback when the focused month changes | ||||
|   final void Function(int year, int month)? onMonthChanged; | ||||
|  | ||||
|   const EventCalendarWidget({ | ||||
|     super.key, | ||||
|     required this.events, | ||||
|     this.initialDate, | ||||
|     this.showEventDetails = true, | ||||
|     this.constrainWidth = false, | ||||
|     this.maxWidth = 480, | ||||
|     this.onDaySelected, | ||||
|     this.onMonthChanged, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final selectedMonth = useState(initialDate?.month ?? DateTime.now().month); | ||||
|     final selectedYear = useState(initialDate?.year ?? DateTime.now().year); | ||||
|     final selectedDay = useState(initialDate ?? DateTime.now()); | ||||
|  | ||||
|     final content = Column( | ||||
|       children: [ | ||||
|         TableCalendar( | ||||
|           locale: EasyLocalization.of(context)!.locale.toString(), | ||||
|           firstDay: DateTime.now().add(Duration(days: -3650)), | ||||
|           lastDay: DateTime.now().add(Duration(days: 3650)), | ||||
|           focusedDay: DateTime.utc( | ||||
|             selectedYear.value, | ||||
|             selectedMonth.value, | ||||
|             selectedDay.value.day, | ||||
|           ), | ||||
|           weekNumbersVisible: false, | ||||
|           calendarFormat: CalendarFormat.month, | ||||
|           selectedDayPredicate: (day) { | ||||
|             return isSameDay(selectedDay.value, day); | ||||
|           }, | ||||
|           onDaySelected: (value, _) { | ||||
|             selectedDay.value = value; | ||||
|             onDaySelected?.call(value); | ||||
|           }, | ||||
|           onPageChanged: (focusedDay) { | ||||
|             selectedMonth.value = focusedDay.month; | ||||
|             selectedYear.value = focusedDay.year; | ||||
|             onMonthChanged?.call(focusedDay.year, focusedDay.month); | ||||
|           }, | ||||
|           eventLoader: (day) { | ||||
|             return events.value | ||||
|                     ?.where((e) => isSameDay(e.date, day)) | ||||
|                     .expand((e) => [...e.statuses, e.checkInResult]) | ||||
|                     .where((e) => e != null) | ||||
|                     .toList() ?? | ||||
|                 []; | ||||
|           }, | ||||
|           calendarBuilders: CalendarBuilders( | ||||
|             dowBuilder: (context, day) { | ||||
|               final text = DateFormat.EEEEE().format(day); | ||||
|               return Center(child: Text(text)); | ||||
|             }, | ||||
|             markerBuilder: (context, day, events) { | ||||
|               var checkInResult = | ||||
|                   events.whereType<SnCheckInResult>().firstOrNull; | ||||
|               if (checkInResult != null) { | ||||
|                 return Positioned( | ||||
|                   top: 32, | ||||
|                   child: Text( | ||||
|                     'checkInResultT${checkInResult.level}'.tr(), | ||||
|                     style: TextStyle( | ||||
|                       fontSize: 9, | ||||
|                       color: | ||||
|                           isSameDay(selectedDay.value, day) | ||||
|                               ? Theme.of(context).colorScheme.onPrimaryContainer | ||||
|                               : isSameDay(DateTime.now(), day) | ||||
|                               ? Theme.of( | ||||
|                                 context, | ||||
|                               ).colorScheme.onSecondaryContainer | ||||
|                               : Theme.of(context).colorScheme.onSurface, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ); | ||||
|               } | ||||
|               return null; | ||||
|             }, | ||||
|           ), | ||||
|         ), | ||||
|         if (showEventDetails) ...[ | ||||
|           const Divider(height: 1).padding(top: 8), | ||||
|           AnimatedSwitcher( | ||||
|             duration: const Duration(milliseconds: 300), | ||||
|             child: Builder( | ||||
|               builder: (context) { | ||||
|                 final event = | ||||
|                     events.value | ||||
|                         ?.where((e) => isSameDay(e.date, selectedDay.value)) | ||||
|                         .firstOrNull; | ||||
|                 return Column( | ||||
|                   crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                   children: [ | ||||
|                     Text(DateFormat.EEEE().format(selectedDay.value)) | ||||
|                         .fontSize(16) | ||||
|                         .bold() | ||||
|                         .textColor( | ||||
|                           Theme.of(context).colorScheme.onSecondaryContainer, | ||||
|                         ), | ||||
|                     Text(DateFormat.yMd().format(selectedDay.value)) | ||||
|                         .fontSize(12) | ||||
|                         .textColor( | ||||
|                           Theme.of(context).colorScheme.onSecondaryContainer, | ||||
|                         ), | ||||
|                     const Gap(16), | ||||
|                     if (event?.checkInResult != null) | ||||
|                       Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                         children: [ | ||||
|                           Text( | ||||
|                             'checkInResultLevel${event!.checkInResult!.level}', | ||||
|                           ).tr().fontSize(16).bold(), | ||||
|                           for (final tip in event.checkInResult!.tips) | ||||
|                             Row( | ||||
|                               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                               spacing: 8, | ||||
|                               children: [ | ||||
|                                 Icon( | ||||
|                                   Symbols.circle, | ||||
|                                   size: 12, | ||||
|                                   fill: 1, | ||||
|                                 ).padding(top: 4, right: 4), | ||||
|                                 Expanded( | ||||
|                                   child: Column( | ||||
|                                     crossAxisAlignment: | ||||
|                                         CrossAxisAlignment.start, | ||||
|                                     children: [ | ||||
|                                       Text(tip.title).bold(), | ||||
|                                       Text(tip.content), | ||||
|                                     ], | ||||
|                                   ), | ||||
|                                 ), | ||||
|                               ], | ||||
|                             ).padding(top: 8), | ||||
|                         ], | ||||
|                       ), | ||||
|                     if (event?.checkInResult == null && | ||||
|                         (event?.statuses.isEmpty ?? true)) | ||||
|                       Text('eventCalanderEmpty').tr(), | ||||
|                   ], | ||||
|                 ).padding(vertical: 24, horizontal: 24); | ||||
|               }, | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ], | ||||
|     ); | ||||
|  | ||||
|     if (constrainWidth) { | ||||
|       return ConstrainedBox( | ||||
|         constraints: BoxConstraints(maxWidth: maxWidth), | ||||
|         child: Card(margin: EdgeInsets.all(16), child: content), | ||||
|       ).center(); | ||||
|     } | ||||
|  | ||||
|     return content; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										280
									
								
								lib/widgets/account/fortune_graph.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										280
									
								
								lib/widgets/account/fortune_graph.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,280 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:fl_chart/fl_chart.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/activity.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| /// A widget that displays a graph of fortune levels over time | ||||
| /// This can be used alongside the EventCalendarWidget to provide a different visualization | ||||
| class FortuneGraphWidget extends HookConsumerWidget { | ||||
|   /// The list of calendar entries to display | ||||
|   final AsyncValue<List<SnEventCalendarEntry>> events; | ||||
|  | ||||
|   /// Whether to constrain the width of the graph | ||||
|   final bool constrainWidth; | ||||
|  | ||||
|   /// Maximum width constraint when constrainWidth is true | ||||
|   final double maxWidth; | ||||
|  | ||||
|   /// Height of the graph | ||||
|   final double height; | ||||
|  | ||||
|   /// Callback when a point is selected | ||||
|   final void Function(DateTime)? onPointSelected; | ||||
|  | ||||
|   final String? eventCalanderUser; | ||||
|  | ||||
|   const FortuneGraphWidget({ | ||||
|     super.key, | ||||
|     required this.events, | ||||
|     this.constrainWidth = false, | ||||
|     this.maxWidth = double.infinity, | ||||
|     this.height = 180, | ||||
|     this.onPointSelected, | ||||
|     this.eventCalanderUser, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     // Filter events to only include those with check-in results | ||||
|     final filteredEvents = events.whenData( | ||||
|       (data) => | ||||
|           data | ||||
|               .where((event) => event.checkInResult != null) | ||||
|               .toList() | ||||
|               .cast<SnEventCalendarEntry>() | ||||
|             // Sort by date | ||||
|             ..sort((a, b) => a.date.compareTo(b.date)), | ||||
|     ); | ||||
|  | ||||
|     final content = Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|       children: [ | ||||
|         Row( | ||||
|           mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|           children: [ | ||||
|             Text('fortuneGraph').tr().fontSize(18).bold(), | ||||
|             if (eventCalanderUser != null) | ||||
|               IconButton( | ||||
|                 icon: const Icon(Icons.calendar_month, size: 20), | ||||
|                 visualDensity: const VisualDensity( | ||||
|                   horizontal: -4, | ||||
|                   vertical: -4, | ||||
|                 ), | ||||
|                 padding: EdgeInsets.zero, | ||||
|                 constraints: const BoxConstraints(), | ||||
|                 onPressed: () { | ||||
|                   context.router.pushNamed( | ||||
|                     '/account/$eventCalanderUser/calendar', | ||||
|                   ); | ||||
|                 }, | ||||
|               ), | ||||
|           ], | ||||
|         ).padding(all: 16, bottom: 24), | ||||
|         SizedBox( | ||||
|           height: height, | ||||
|           child: filteredEvents.when( | ||||
|             data: (data) { | ||||
|               if (data.isEmpty) { | ||||
|                 return Center(child: Text('noFortuneData').tr()); | ||||
|               } | ||||
|  | ||||
|               // Create spots for the line chart | ||||
|               final spots = | ||||
|                   data | ||||
|                       .map( | ||||
|                         (e) => FlSpot( | ||||
|                           e.date.millisecondsSinceEpoch.toDouble(), | ||||
|                           e.checkInResult!.level.toDouble(), | ||||
|                         ), | ||||
|                       ) | ||||
|                       .toList(); | ||||
|  | ||||
|               // Get min and max dates for the x-axis | ||||
|               final minDate = data.first.date; | ||||
|               final maxDate = data.last.date; | ||||
|  | ||||
|               return Padding( | ||||
|                 padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), | ||||
|                 child: LineChart( | ||||
|                   LineChartData( | ||||
|                     gridData: FlGridData( | ||||
|                       show: true, | ||||
|                       horizontalInterval: 1, | ||||
|                       drawVerticalLine: false, | ||||
|                     ), | ||||
|                     titlesData: FlTitlesData( | ||||
|                       bottomTitles: AxisTitles( | ||||
|                         sideTitles: SideTitles( | ||||
|                           showTitles: true, | ||||
|                           reservedSize: 30, | ||||
|                           interval: _calculateDateInterval(minDate, maxDate), | ||||
|                           getTitlesWidget: (value, meta) { | ||||
|                             final date = DateTime.fromMillisecondsSinceEpoch( | ||||
|                               value.toInt(), | ||||
|                             ); | ||||
|                             return Padding( | ||||
|                               padding: const EdgeInsets.only(top: 8.0), | ||||
|                               child: Text( | ||||
|                                 DateFormat.MMMd().format(date), | ||||
|                                 style: TextStyle(fontSize: 10), | ||||
|                               ), | ||||
|                             ); | ||||
|                           }, | ||||
|                         ), | ||||
|                       ), | ||||
|                       leftTitles: AxisTitles( | ||||
|                         sideTitles: SideTitles( | ||||
|                           showTitles: true, | ||||
|                           interval: 1, | ||||
|                           reservedSize: 40, | ||||
|                           getTitlesWidget: (value, meta) { | ||||
|                             final level = value.toInt(); | ||||
|                             if (level < 0 || level > 4) return const SizedBox(); | ||||
|                             return Padding( | ||||
|                               padding: const EdgeInsets.only(right: 8.0), | ||||
|                               child: Text( | ||||
|                                 'checkInResultT$level'.tr(), | ||||
|                                 style: TextStyle(fontSize: 10), | ||||
|                               ), | ||||
|                             ); | ||||
|                           }, | ||||
|                         ), | ||||
|                       ), | ||||
|                       topTitles: AxisTitles( | ||||
|                         sideTitles: SideTitles(showTitles: false), | ||||
|                       ), | ||||
|                       rightTitles: AxisTitles( | ||||
|                         sideTitles: SideTitles(showTitles: false), | ||||
|                       ), | ||||
|                     ), | ||||
|                     borderData: FlBorderData( | ||||
|                       show: true, | ||||
|                       border: Border( | ||||
|                         bottom: BorderSide( | ||||
|                           color: Theme.of(context).dividerColor, | ||||
|                         ), | ||||
|                         left: BorderSide(color: Theme.of(context).dividerColor), | ||||
|                       ), | ||||
|                     ), | ||||
|                     minX: minDate.millisecondsSinceEpoch.toDouble(), | ||||
|                     maxX: maxDate.millisecondsSinceEpoch.toDouble(), | ||||
|                     minY: 0, | ||||
|                     maxY: 4, | ||||
|                     lineTouchData: LineTouchData( | ||||
|                       touchTooltipData: LineTouchTooltipData( | ||||
|                         getTooltipItems: (touchedSpots) { | ||||
|                           return touchedSpots.map((spot) { | ||||
|                             final date = DateTime.fromMillisecondsSinceEpoch( | ||||
|                               spot.x.toInt(), | ||||
|                             ); | ||||
|                             final level = spot.y.toInt(); | ||||
|                             return LineTooltipItem( | ||||
|                               '${DateFormat.yMMMd().format(date)}\n', | ||||
|                               TextStyle( | ||||
|                                 color: Theme.of(context).colorScheme.onSurface, | ||||
|                                 fontWeight: FontWeight.bold, | ||||
|                               ), | ||||
|                               children: [ | ||||
|                                 TextSpan( | ||||
|                                   text: 'checkInResultLevel$level'.tr(), | ||||
|                                   style: TextStyle( | ||||
|                                     color: | ||||
|                                         Theme.of(context).colorScheme.onSurface, | ||||
|                                     fontWeight: FontWeight.normal, | ||||
|                                   ), | ||||
|                                 ), | ||||
|                               ], | ||||
|                             ); | ||||
|                           }).toList(); | ||||
|                         }, | ||||
|                       ), | ||||
|                       touchCallback: ( | ||||
|                         FlTouchEvent event, | ||||
|                         LineTouchResponse? response, | ||||
|                       ) { | ||||
|                         if (event is FlTapUpEvent && | ||||
|                             response != null && | ||||
|                             response.lineBarSpots != null && | ||||
|                             response.lineBarSpots!.isNotEmpty) { | ||||
|                           final spot = response.lineBarSpots!.first; | ||||
|                           final date = DateTime.fromMillisecondsSinceEpoch( | ||||
|                             spot.x.toInt(), | ||||
|                           ); | ||||
|                           onPointSelected?.call(date); | ||||
|                         } | ||||
|                       }, | ||||
|                     ), | ||||
|                     lineBarsData: [ | ||||
|                       LineChartBarData( | ||||
|                         spots: spots, | ||||
|                         isCurved: true, | ||||
|                         color: Theme.of(context).colorScheme.primary, | ||||
|                         barWidth: 3, | ||||
|                         isStrokeCapRound: true, | ||||
|                         dotData: FlDotData( | ||||
|                           show: true, | ||||
|                           getDotPainter: (spot, percent, barData, index) { | ||||
|                             return FlDotCirclePainter( | ||||
|                               radius: 4, | ||||
|                               color: Theme.of(context).colorScheme.primary, | ||||
|                               strokeWidth: 2, | ||||
|                               strokeColor: | ||||
|                                   Theme.of(context).colorScheme.surface, | ||||
|                             ); | ||||
|                           }, | ||||
|                         ), | ||||
|                         belowBarData: BarAreaData( | ||||
|                           show: true, | ||||
|                           color: Theme.of( | ||||
|                             context, | ||||
|                           ).colorScheme.primary.withOpacity(0.2), | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|               ); | ||||
|             }, | ||||
|             loading: () => const Center(child: CircularProgressIndicator()), | ||||
|             error: (error, stack) => Center(child: Text('Error: $error')), | ||||
|           ), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|  | ||||
|     if (constrainWidth) { | ||||
|       return ConstrainedBox( | ||||
|         constraints: BoxConstraints(maxWidth: maxWidth), | ||||
|         child: Card(margin: EdgeInsets.all(16), child: content), | ||||
|       ).center(); | ||||
|     } | ||||
|  | ||||
|     return content; | ||||
|   } | ||||
|  | ||||
|   /// Calculate an appropriate interval for date labels based on the date range | ||||
|   double _calculateDateInterval(DateTime minDate, DateTime maxDate) { | ||||
|     final difference = maxDate.difference(minDate).inDays; | ||||
|  | ||||
|     // If less than 7 days, show all days | ||||
|     if (difference <= 7) { | ||||
|       return 24 * 60 * 60 * 1000; // One day in milliseconds | ||||
|     } | ||||
|  | ||||
|     // If less than a month, show every 3 days | ||||
|     if (difference <= 30) { | ||||
|       return 3 * 24 * 60 * 60 * 1000; // Three days in milliseconds | ||||
|     } | ||||
|  | ||||
|     // If less than 3 months, show weekly | ||||
|     if (difference <= 90) { | ||||
|       return 7 * 24 * 60 * 60 * 1000; // One week in milliseconds | ||||
|     } | ||||
|  | ||||
|     // Otherwise show every 2 weeks | ||||
|     return 14 * 24 * 60 * 60 * 1000; // Two weeks in milliseconds | ||||
|   } | ||||
| } | ||||
| @@ -19,6 +19,7 @@ class LevelingProgressCard extends StatelessWidget { | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Card( | ||||
|       margin: EdgeInsets.zero, | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|   | ||||
| @@ -1,15 +1,13 @@ | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/activity.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/screens/account/profile.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
| import 'package:island/widgets/account/status_creation.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:relative_time/relative_time.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @@ -108,14 +106,15 @@ class AccountStatusWidget extends HookConsumerWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final userStatus = ref.watch(accountStatusProvider(uname)); | ||||
|     final status = ref.watch(accountStatusProvider(uname)); | ||||
|     final account = ref.watch(accountProvider(uname)); | ||||
|  | ||||
|     return Padding( | ||||
|       padding: padding ?? EdgeInsets.symmetric(horizontal: 27, vertical: 4), | ||||
|       child: Row( | ||||
|         spacing: 4, | ||||
|         children: [ | ||||
|           if (userStatus.value?.isOnline ?? false) | ||||
|           if (status.value?.isOnline ?? false) | ||||
|             Icon( | ||||
|               Symbols.circle, | ||||
|               fill: 1, | ||||
| @@ -123,13 +122,24 @@ class AccountStatusWidget extends HookConsumerWidget { | ||||
|               size: 16, | ||||
|             ).padding(right: 4) | ||||
|           else | ||||
|             Icon(Symbols.circle, color: Colors.grey, size: 16).padding(all: 4), | ||||
|           if (userStatus.value?.isCustomized ?? false) | ||||
|             Text(userStatus.value?.label ?? 'unknown'.tr()) | ||||
|             Icon( | ||||
|               Symbols.circle, | ||||
|               color: Colors.grey, | ||||
|               size: 16, | ||||
|             ).padding(right: 4), | ||||
|           if (status.value?.isCustomized ?? false) | ||||
|             Text(status.value?.label ?? 'unknown'.tr()) | ||||
|           else | ||||
|             Text((userStatus.value?.label ?? 'offline').toLowerCase()).tr(), | ||||
|             Text((status.value?.label ?? 'offline').toLowerCase()).tr(), | ||||
|           if (!(status.value?.isOnline ?? false) && | ||||
|               account.value?.profile.lastSeenAt != null) | ||||
|             Flexible( | ||||
|               child: Text( | ||||
|                 account.value!.profile.lastSeenAt!.formatRelative(context), | ||||
|               ).opacity(0.75), | ||||
|             ), | ||||
|         ], | ||||
|       ), | ||||
|     ).opacity((userStatus.value?.isCustomized ?? false) ? 1 : 0.85); | ||||
|     ).opacity((status.value?.isCustomized ?? false) ? 1 : 0.85); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										462
									
								
								lib/widgets/app_notification.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										462
									
								
								lib/widgets/app_notification.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,462 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:developer'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:collection/collection.dart'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/main.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/pods/websocket.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:material_symbols_icons/material_symbols_icons.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| part 'app_notification.freezed.dart'; | ||||
| part 'app_notification.g.dart'; | ||||
|  | ||||
| class AppNotificationToast extends HookConsumerWidget { | ||||
|   const AppNotificationToast({super.key}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final notifications = ref.watch(appNotificationsProvider); | ||||
|  | ||||
|     // Create a global key for AnimatedList | ||||
|     final listKey = useMemoized(() => GlobalKey<AnimatedListState>()); | ||||
|  | ||||
|     // Track visual notification count (including those being animated out) | ||||
|     final visualCount = useState(notifications.length); | ||||
|  | ||||
|     // Track notifications being removed to manage visual count | ||||
|     final animatingOutIds = useState<Set<String>>({}); | ||||
|  | ||||
|     // Track previous notifications to detect changes | ||||
|     final previousNotifications = usePrevious(notifications) ?? []; | ||||
|  | ||||
|     // Handle notification changes | ||||
|     useEffect(() { | ||||
|       final currentIds = notifications.map((n) => n.data.id).toSet(); | ||||
|       final previousIds = previousNotifications.map((n) => n.data.id).toSet(); | ||||
|  | ||||
|       // Find new notifications (added) | ||||
|       final newIds = currentIds.difference(previousIds); | ||||
|  | ||||
|       // Update visual count for new notifications | ||||
|       if (newIds.isNotEmpty) { | ||||
|         visualCount.value += newIds.length; | ||||
|       } | ||||
|  | ||||
|       // Insert new notifications with animation | ||||
|       for (final id in newIds) { | ||||
|         final index = notifications.indexWhere((n) => n.data.id == id); | ||||
|         if (index != -1 && | ||||
|             listKey.currentState != null && | ||||
|             index >= 0 && | ||||
|             index <= notifications.length) { | ||||
|           try { | ||||
|             listKey.currentState!.insertItem( | ||||
|               index, | ||||
|               duration: const Duration(milliseconds: 150), | ||||
|             ); | ||||
|           } catch (e) { | ||||
|             // Log error but don't crash the app | ||||
|             debugPrint('Error inserting notification: $e'); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       return null; | ||||
|     }, [notifications]); | ||||
|  | ||||
|     return Positioned( | ||||
|       top: MediaQuery.of(context).padding.top + 50, | ||||
|       left: 16, | ||||
|       right: 16, | ||||
|       child: SizedBox( | ||||
|         // Use visualCount instead of notifications.length for height calculation | ||||
|         height: visualCount.value * 80, | ||||
|         child: AnimatedList( | ||||
|           physics: NeverScrollableScrollPhysics(), | ||||
|           padding: EdgeInsets.zero, | ||||
|           key: listKey, | ||||
|           initialItemCount: notifications.length, | ||||
|           itemBuilder: (context, index, animation) { | ||||
|             // Safely access notifications with bounds check | ||||
|             if (index >= notifications.length) { | ||||
|               return const SizedBox.shrink(); // Return empty widget if out of bounds | ||||
|             } | ||||
|  | ||||
|             final notification = notifications[index]; | ||||
|             final now = DateTime.now(); | ||||
|             final createdAt = notification.createdAt ?? now; | ||||
|             final duration = | ||||
|                 notification.duration ?? const Duration(seconds: 5); | ||||
|             final elapsedTime = now.difference(createdAt); | ||||
|             final remainingTime = duration - elapsedTime; | ||||
|             final progress = | ||||
|                 1.0 - | ||||
|                 (remainingTime.inMilliseconds / duration.inMilliseconds).clamp( | ||||
|                   0.0, | ||||
|                   1.0, | ||||
|                 ); // Ensure progress is clamped | ||||
|  | ||||
|             return SizeTransition( | ||||
|               sizeFactor: animation.drive( | ||||
|                 CurveTween(curve: Curves.fastLinearToSlowEaseIn), | ||||
|               ), | ||||
|               child: _NotificationCard( | ||||
|                 notification: notification, | ||||
|                 progress: progress.clamp(0.0, 1.0), | ||||
|                 onDismiss: () { | ||||
|                   // Find the current index before removal | ||||
|                   final currentIndex = notifications.indexWhere( | ||||
|                     (n) => n.data.id == notification.data.id, | ||||
|                   ); | ||||
|  | ||||
|                   // Add to animating out set | ||||
|                   final notificationId = notification.data.id; | ||||
|                   if (!animatingOutIds.value.contains(notificationId)) { | ||||
|                     animatingOutIds.value = { | ||||
|                       ...animatingOutIds.value, | ||||
|                       notificationId, | ||||
|                     }; | ||||
|                   } | ||||
|  | ||||
|                   if (currentIndex != -1 && | ||||
|                       listKey.currentState != null && | ||||
|                       currentIndex >= 0 && | ||||
|                       currentIndex < notifications.length) { | ||||
|                     try { | ||||
|                       // Remove the item with animation | ||||
|                       listKey.currentState!.removeItem( | ||||
|                         currentIndex, | ||||
|                         (context, animation) => SizeTransition( | ||||
|                           sizeFactor: animation.drive( | ||||
|                             CurveTween(curve: Curves.fastLinearToSlowEaseIn), | ||||
|                           ), | ||||
|                           child: _NotificationCard( | ||||
|                             notification: notification, | ||||
|                             progress: progress.clamp(0.0, 1.0), | ||||
|                             onDismiss: | ||||
|                                 () {}, // Empty because it's being removed | ||||
|                           ), | ||||
|                         ), | ||||
|                         duration: const Duration(milliseconds: 150), | ||||
|                         // When animation completes, update the visual count | ||||
|                       ); | ||||
|  | ||||
|                       // Schedule decrementing the visual count after animation completes | ||||
|                       Future.delayed(const Duration(milliseconds: 150), () { | ||||
|                         if (animatingOutIds.value.contains(notificationId)) { | ||||
|                           visualCount.value = | ||||
|                               visualCount.value > 0 ? visualCount.value - 1 : 0; | ||||
|                           animatingOutIds.value = | ||||
|                               animatingOutIds.value | ||||
|                                   .where((id) => id != notificationId) | ||||
|                                   .toSet(); | ||||
|                         } | ||||
|                       }); | ||||
|                     } catch (e) { | ||||
|                       // Log error but don't crash the app | ||||
|                       log('[Notification] Error removing notification: $e'); | ||||
|                       // Still update visual count in case of error | ||||
|                       visualCount.value = | ||||
|                           visualCount.value > 0 ? visualCount.value - 1 : 0; | ||||
|                       animatingOutIds.value = | ||||
|                           animatingOutIds.value | ||||
|                               .where((id) => id != notificationId) | ||||
|                               .toSet(); | ||||
|                     } | ||||
|                   } | ||||
|  | ||||
|                   // Actually remove from state | ||||
|                   ref | ||||
|                       .read(appNotificationsProvider.notifier) | ||||
|                       .removeNotification(notification); | ||||
|                 }, | ||||
|               ), | ||||
|             ); | ||||
|           }, | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _NotificationCard extends HookConsumerWidget { | ||||
|   final AppNotification notification; | ||||
|   final double progress; | ||||
|   final VoidCallback onDismiss; | ||||
|  | ||||
|   const _NotificationCard({ | ||||
|     required this.notification, | ||||
|     required this.progress, | ||||
|     required this.onDismiss, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     // Use state to track the current progress for smooth animation | ||||
|     final progressState = useState(progress); | ||||
|  | ||||
|     // Use effect to update progress smoothly | ||||
|     useEffect(() { | ||||
|       if (progress < 1.0) { | ||||
|         // Update progress every 16ms (roughly 60fps) for smooth animation | ||||
|         final timer = Timer.periodic(const Duration(milliseconds: 16), (_) { | ||||
|           final now = DateTime.now(); | ||||
|           final createdAt = notification.createdAt ?? now; | ||||
|           final duration = notification.duration ?? const Duration(seconds: 5); | ||||
|           final elapsedTime = now.difference(createdAt); | ||||
|           final remainingTime = duration - elapsedTime; | ||||
|           final newProgress = (1.0 - | ||||
|                   (remainingTime.inMilliseconds / duration.inMilliseconds)) | ||||
|               .clamp(0.0, 1.0); | ||||
|  | ||||
|           progressState.value = newProgress; | ||||
|  | ||||
|           // Auto-dismiss when complete | ||||
|           if (newProgress >= 1.0) { | ||||
|             onDismiss(); | ||||
|           } | ||||
|         }); | ||||
|  | ||||
|         return timer.cancel; | ||||
|       } | ||||
|       return null; | ||||
|     }, [notification.createdAt, notification.duration]); | ||||
|  | ||||
|     return Card( | ||||
|       elevation: 4, | ||||
|       margin: const EdgeInsets.only(bottom: 8), | ||||
|       shape: RoundedRectangleBorder( | ||||
|         borderRadius: BorderRadius.vertical(bottom: Radius.circular(8)), | ||||
|       ), | ||||
|       child: InkWell( | ||||
|         borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|         onTap: () { | ||||
|           if (notification.data.meta['action_uri'] != null) { | ||||
|             var uri = notification.data.meta['action_uri'] as String; | ||||
|             if (uri.startsWith('/')) { | ||||
|               // In-app routes | ||||
|               appRouter.pushPath(notification.data.meta['action_uri']); | ||||
|             } else { | ||||
|               // External URLs | ||||
|               launchUrlString(uri); | ||||
|             } | ||||
|             onDismiss(); | ||||
|           } | ||||
|         }, | ||||
|         child: Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           mainAxisSize: MainAxisSize.min, | ||||
|           children: [ | ||||
|             // Progress indicator | ||||
|             if (progressState.value > 0 && progressState.value < 1.0) | ||||
|               AnimatedBuilder( | ||||
|                 animation: progressState, | ||||
|                 builder: (context, _) { | ||||
|                   return LinearProgressIndicator( | ||||
|                     borderRadius: BorderRadius.vertical( | ||||
|                       top: Radius.circular(16), | ||||
|                     ), | ||||
|                     value: 1.0 - progressState.value, | ||||
|                     backgroundColor: Colors.transparent, | ||||
|                     color: Theme.of(context).colorScheme.tertiary, | ||||
|                     minHeight: 3, | ||||
|                     stopIndicatorColor: Colors.transparent, | ||||
|                     stopIndicatorRadius: 0, | ||||
|                   ); | ||||
|                 }, | ||||
|               ), | ||||
|             Padding( | ||||
|               padding: const EdgeInsets.all(12), | ||||
|               child: Row( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                 children: [ | ||||
|                   if (notification.data.meta['avatar'] != null) | ||||
|                     ProfilePictureWidget( | ||||
|                       fileId: notification.data.meta['avatar'], | ||||
|                       radius: 12, | ||||
|                     ).padding(right: 12, top: 2) | ||||
|                   else if (notification.icon != null) | ||||
|                     Icon( | ||||
|                       notification.icon, | ||||
|                       color: Theme.of(context).colorScheme.primary, | ||||
|                       size: 24, | ||||
|                     ).padding(right: 12), | ||||
|                   Expanded( | ||||
|                     child: Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         Text( | ||||
|                           notification.data.title, | ||||
|                           style: Theme.of(context).textTheme.titleMedium | ||||
|                               ?.copyWith(fontWeight: FontWeight.bold), | ||||
|                         ), | ||||
|                         if (notification.data.content.isNotEmpty) | ||||
|                           Text( | ||||
|                             notification.data.content, | ||||
|                             style: Theme.of(context).textTheme.bodyMedium, | ||||
|                           ), | ||||
|                         if (notification.data.subtitle.isNotEmpty) | ||||
|                           Text( | ||||
|                             notification.data.subtitle, | ||||
|                             style: Theme.of(context).textTheme.bodySmall, | ||||
|                           ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|                   IconButton( | ||||
|                     icon: const Icon(Symbols.close, size: 18), | ||||
|                     onPressed: onDismiss, | ||||
|                     padding: EdgeInsets.zero, | ||||
|                     constraints: const BoxConstraints(), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| sealed class AppNotification with _$AppNotification { | ||||
|   const factory AppNotification({ | ||||
|     required SnNotification data, | ||||
|     @JsonKey(ignore: true) IconData? icon, | ||||
|     @JsonKey(ignore: true) Duration? duration, | ||||
|     @Default(null) DateTime? createdAt, | ||||
|     @Default(false) @JsonKey(ignore: true) bool isAnimatingOut, | ||||
|   }) = _AppNotification; | ||||
|  | ||||
|   factory AppNotification.fromJson(Map<String, dynamic> json) => | ||||
|       _$AppNotificationFromJson(json); | ||||
| } | ||||
|  | ||||
| // Using riverpod_generator for cleaner provider code | ||||
| @riverpod | ||||
| class AppNotifications extends _$AppNotifications { | ||||
|   StreamSubscription? _subscription; | ||||
|  | ||||
|   @override | ||||
|   List<AppNotification> build() { | ||||
|     ref.onDispose(() { | ||||
|       _subscription?.cancel(); | ||||
|     }); | ||||
|  | ||||
|     _initWebSocketListener(); | ||||
|     return []; | ||||
|   } | ||||
|  | ||||
|   void _initWebSocketListener() { | ||||
|     final service = ref.read(websocketProvider); | ||||
|     _subscription = service.dataStream.listen((packet) { | ||||
|       // Handle notification packets | ||||
|       if (packet.type == 'notifications.new') { | ||||
|         try { | ||||
|           final data = SnNotification.fromJson(packet.data!); | ||||
|  | ||||
|           IconData? icon; | ||||
|           switch (data.topic) { | ||||
|             case 'general': | ||||
|             default: | ||||
|               icon = Symbols.info; | ||||
|               break; | ||||
|           } | ||||
|  | ||||
|           addNotification( | ||||
|             AppNotification( | ||||
|               data: data, | ||||
|               icon: icon, | ||||
|               createdAt: data.createdAt.toLocal(), | ||||
|               duration: const Duration(seconds: 5), | ||||
|             ), | ||||
|           ); | ||||
|         } catch (e) { | ||||
|           log('[Notification] Error processing notification: $e'); | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   void addNotification(AppNotification notification) { | ||||
|     // Create a new notification with createdAt if not provided | ||||
|     final newNotification = | ||||
|         notification.createdAt == null | ||||
|             ? notification.copyWith(createdAt: DateTime.now()) | ||||
|             : notification; | ||||
|  | ||||
|     // Add to state | ||||
|     state = [...state, newNotification]; | ||||
|  | ||||
|     // Auto-remove notification after duration | ||||
|     final duration = newNotification.duration ?? const Duration(seconds: 5); | ||||
|     Future.delayed(duration, () { | ||||
|       // Find the notification in the current state | ||||
|       final notificationToRemove = state.firstWhereOrNull( | ||||
|         (n) => n.data.id == newNotification.data.id, | ||||
|       ); | ||||
|  | ||||
|       // Only proceed if the notification still exists in state | ||||
|       if (notificationToRemove != null) { | ||||
|         // Call removeNotification which will handle the animation | ||||
|         removeNotification(notificationToRemove); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   // Map to track notifications that are being animated out | ||||
|   final Map<String, bool> _animatingNotifications = {}; | ||||
|  | ||||
|   // Map to track which notifications should animate out | ||||
|   final Map<String, bool> _animatingOutNotifications = {}; | ||||
|  | ||||
|   void removeNotification(AppNotification notification) { | ||||
|     final notificationId = notification.data.id; | ||||
|  | ||||
|     // If this notification is already being removed, don't do anything | ||||
|     if (_animatingNotifications[notificationId] == true) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Mark this notification as being removed | ||||
|     _animatingNotifications[notificationId] = true; | ||||
|  | ||||
|     // Remove from state immediately - AnimatedList handles the animation | ||||
|     state = state.where((n) => n.data.id != notificationId).toList(); | ||||
|  | ||||
|     // Clean up tracking | ||||
|     _animatingNotifications.remove(notificationId); | ||||
|     _animatingOutNotifications.remove(notificationId); | ||||
|   } | ||||
|  | ||||
|   // Helper method to check if a notification should animate out | ||||
|   bool isAnimatingOut(String notificationId) { | ||||
|     return _animatingOutNotifications[notificationId] == true; | ||||
|   } | ||||
|  | ||||
|   // Helper method to manually add a notification for testing | ||||
|   void showNotification({ | ||||
|     required SnNotification data, | ||||
|     IconData? icon, | ||||
|     Duration? duration, | ||||
|   }) { | ||||
|     addNotification( | ||||
|       AppNotification( | ||||
|         data: data, | ||||
|         icon: icon, | ||||
|         duration: duration, | ||||
|         createdAt: data.createdAt, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										190
									
								
								lib/widgets/app_notification.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								lib/widgets/app_notification.freezed.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,190 @@ | ||||
| // dart format width=80 | ||||
| // coverage:ignore-file | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| // 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 'app_notification.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // FreezedGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| // dart format off | ||||
| T _$identity<T>(T value) => value; | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$AppNotification implements DiagnosticableTreeMixin { | ||||
|  | ||||
|  SnNotification get data;@JsonKey(ignore: true) IconData? get icon;@JsonKey(ignore: true) Duration? get duration; DateTime? get createdAt;@JsonKey(ignore: true) bool get isAnimatingOut; | ||||
| /// Create a copy of AppNotification | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| $AppNotificationCopyWith<AppNotification> get copyWith => _$AppNotificationCopyWithImpl<AppNotification>(this as AppNotification, _$identity); | ||||
|  | ||||
|   /// Serializes this AppNotification to a JSON map. | ||||
|   Map<String, dynamic> toJson(); | ||||
|  | ||||
| @override | ||||
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { | ||||
|   properties | ||||
|     ..add(DiagnosticsProperty('type', 'AppNotification')) | ||||
|     ..add(DiagnosticsProperty('data', data))..add(DiagnosticsProperty('icon', icon))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('createdAt', createdAt))..add(DiagnosticsProperty('isAnimatingOut', isAnimatingOut)); | ||||
| } | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is AppNotification&&(identical(other.data, data) || other.data == data)&&(identical(other.icon, icon) || other.icon == icon)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.isAnimatingOut, isAnimatingOut) || other.isAnimatingOut == isAnimatingOut)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,data,icon,duration,createdAt,isAnimatingOut); | ||||
|  | ||||
| @override | ||||
| String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { | ||||
|   return 'AppNotification(data: $data, icon: $icon, duration: $duration, createdAt: $createdAt, isAnimatingOut: $isAnimatingOut)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class $AppNotificationCopyWith<$Res>  { | ||||
|   factory $AppNotificationCopyWith(AppNotification value, $Res Function(AppNotification) _then) = _$AppNotificationCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  SnNotification data,@JsonKey(ignore: true) IconData? icon,@JsonKey(ignore: true) Duration? duration, DateTime? createdAt,@JsonKey(ignore: true) bool isAnimatingOut | ||||
| }); | ||||
|  | ||||
|  | ||||
| $SnNotificationCopyWith<$Res> get data; | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class _$AppNotificationCopyWithImpl<$Res> | ||||
|     implements $AppNotificationCopyWith<$Res> { | ||||
|   _$AppNotificationCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final AppNotification _self; | ||||
|   final $Res Function(AppNotification) _then; | ||||
|  | ||||
| /// Create a copy of AppNotification | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? data = null,Object? icon = freezed,Object? duration = freezed,Object? createdAt = freezed,Object? isAnimatingOut = null,}) { | ||||
|   return _then(_self.copyWith( | ||||
| data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable | ||||
| as SnNotification,icon: freezed == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable | ||||
| as IconData?,duration: freezed == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable | ||||
| as Duration?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,isAnimatingOut: null == isAnimatingOut ? _self.isAnimatingOut : isAnimatingOut // ignore: cast_nullable_to_non_nullable | ||||
| as bool, | ||||
|   )); | ||||
| } | ||||
| /// Create a copy of AppNotification | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnNotificationCopyWith<$Res> get data { | ||||
|    | ||||
|   return $SnNotificationCopyWith<$Res>(_self.data, (value) { | ||||
|     return _then(_self.copyWith(data: value)); | ||||
|   }); | ||||
| } | ||||
| } | ||||
|  | ||||
|  | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _AppNotification with DiagnosticableTreeMixin implements AppNotification { | ||||
|   const _AppNotification({required this.data, @JsonKey(ignore: true) this.icon, @JsonKey(ignore: true) this.duration, this.createdAt = null, @JsonKey(ignore: true) this.isAnimatingOut = false}); | ||||
|   factory _AppNotification.fromJson(Map<String, dynamic> json) => _$AppNotificationFromJson(json); | ||||
|  | ||||
| @override final  SnNotification data; | ||||
| @override@JsonKey(ignore: true) final  IconData? icon; | ||||
| @override@JsonKey(ignore: true) final  Duration? duration; | ||||
| @override@JsonKey() final  DateTime? createdAt; | ||||
| @override@JsonKey(ignore: true) final  bool isAnimatingOut; | ||||
|  | ||||
| /// Create a copy of AppNotification | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| _$AppNotificationCopyWith<_AppNotification> get copyWith => __$AppNotificationCopyWithImpl<_AppNotification>(this, _$identity); | ||||
|  | ||||
| @override | ||||
| Map<String, dynamic> toJson() { | ||||
|   return _$AppNotificationToJson(this, ); | ||||
| } | ||||
| @override | ||||
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { | ||||
|   properties | ||||
|     ..add(DiagnosticsProperty('type', 'AppNotification')) | ||||
|     ..add(DiagnosticsProperty('data', data))..add(DiagnosticsProperty('icon', icon))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('createdAt', createdAt))..add(DiagnosticsProperty('isAnimatingOut', isAnimatingOut)); | ||||
| } | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppNotification&&(identical(other.data, data) || other.data == data)&&(identical(other.icon, icon) || other.icon == icon)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.isAnimatingOut, isAnimatingOut) || other.isAnimatingOut == isAnimatingOut)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,data,icon,duration,createdAt,isAnimatingOut); | ||||
|  | ||||
| @override | ||||
| String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { | ||||
|   return 'AppNotification(data: $data, icon: $icon, duration: $duration, createdAt: $createdAt, isAnimatingOut: $isAnimatingOut)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class _$AppNotificationCopyWith<$Res> implements $AppNotificationCopyWith<$Res> { | ||||
|   factory _$AppNotificationCopyWith(_AppNotification value, $Res Function(_AppNotification) _then) = __$AppNotificationCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  SnNotification data,@JsonKey(ignore: true) IconData? icon,@JsonKey(ignore: true) Duration? duration, DateTime? createdAt,@JsonKey(ignore: true) bool isAnimatingOut | ||||
| }); | ||||
|  | ||||
|  | ||||
| @override $SnNotificationCopyWith<$Res> get data; | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class __$AppNotificationCopyWithImpl<$Res> | ||||
|     implements _$AppNotificationCopyWith<$Res> { | ||||
|   __$AppNotificationCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final _AppNotification _self; | ||||
|   final $Res Function(_AppNotification) _then; | ||||
|  | ||||
| /// Create a copy of AppNotification | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? data = null,Object? icon = freezed,Object? duration = freezed,Object? createdAt = freezed,Object? isAnimatingOut = null,}) { | ||||
|   return _then(_AppNotification( | ||||
| data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable | ||||
| as SnNotification,icon: freezed == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable | ||||
| as IconData?,duration: freezed == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable | ||||
| as Duration?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,isAnimatingOut: null == isAnimatingOut ? _self.isAnimatingOut : isAnimatingOut // ignore: cast_nullable_to_non_nullable | ||||
| as bool, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| /// Create a copy of AppNotification | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnNotificationCopyWith<$Res> get data { | ||||
|    | ||||
|   return $SnNotificationCopyWith<$Res>(_self.data, (value) { | ||||
|     return _then(_self.copyWith(data: value)); | ||||
|   }); | ||||
| } | ||||
| } | ||||
|  | ||||
| // dart format on | ||||
							
								
								
									
										48
									
								
								lib/widgets/app_notification.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								lib/widgets/app_notification.g.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
|  | ||||
| part of 'app_notification.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // JsonSerializableGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| _AppNotification _$AppNotificationFromJson(Map<String, dynamic> json) => | ||||
|     _AppNotification( | ||||
|       data: SnNotification.fromJson(json['data'] as Map<String, dynamic>), | ||||
|       createdAt: | ||||
|           json['created_at'] == null | ||||
|               ? null | ||||
|               : DateTime.parse(json['created_at'] as String), | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$AppNotificationToJson(_AppNotification instance) => | ||||
|     <String, dynamic>{ | ||||
|       'data': instance.data.toJson(), | ||||
|       'created_at': instance.createdAt?.toIso8601String(), | ||||
|     }; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // RiverpodGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| String _$appNotificationsHash() => r'a7e7e1d1533e329b000d4b294e455b8420ec3c4d'; | ||||
|  | ||||
| /// See also [AppNotifications]. | ||||
| @ProviderFor(AppNotifications) | ||||
| final appNotificationsProvider = AutoDisposeNotifierProvider< | ||||
|   AppNotifications, | ||||
|   List<AppNotification> | ||||
| >.internal( | ||||
|   AppNotifications.new, | ||||
|   name: r'appNotificationsProvider', | ||||
|   debugGetCreateSourceHash: | ||||
|       const bool.fromEnvironment('dart.vm.product') | ||||
|           ? null | ||||
|           : _$appNotificationsHash, | ||||
|   dependencies: null, | ||||
|   allTransitiveDependencies: null, | ||||
| ); | ||||
|  | ||||
| typedef _$AppNotifications = AutoDisposeNotifier<List<AppNotification>>; | ||||
| // 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 | ||||
| @@ -10,6 +10,7 @@ import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/pods/websocket.dart'; | ||||
| import 'package:island/route.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/widgets/app_notification.dart'; | ||||
| import 'package:material_symbols_icons/material_symbols_icons.dart'; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| @@ -83,6 +84,7 @@ class WindowScaffold extends HookConsumerWidget { | ||||
|               ], | ||||
|             ), | ||||
|             _WebSocketIndicator(), | ||||
|             AppNotificationToast(), | ||||
|           ], | ||||
|         ), | ||||
|       ); | ||||
| @@ -90,7 +92,7 @@ class WindowScaffold extends HookConsumerWidget { | ||||
|  | ||||
|     return Stack( | ||||
|       fit: StackFit.expand, | ||||
|       children: [child, _WebSocketIndicator()], | ||||
|       children: [child, _WebSocketIndicator(), AppNotificationToast()], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -167,9 +169,10 @@ class AppScaffold extends StatelessWidget { | ||||
| } | ||||
|  | ||||
| class PageBackButton extends StatelessWidget { | ||||
|   final Color? color; | ||||
|   final List<Shadow>? shadows; | ||||
|   final VoidCallback? onWillPop; | ||||
|   const PageBackButton({super.key, this.shadows, this.onWillPop}); | ||||
|   const PageBackButton({super.key, this.shadows, this.onWillPop, this.color}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
| @@ -179,6 +182,7 @@ class PageBackButton extends StatelessWidget { | ||||
|         context.router.maybePop(); | ||||
|       }, | ||||
|       icon: Icon( | ||||
|         color: color, | ||||
|         (!kIsWeb && (Platform.isMacOS || Platform.isIOS)) | ||||
|             ? Symbols.arrow_back_ios_new | ||||
|             : Symbols.arrow_back, | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import 'package:island/database/message.dart'; | ||||
| import 'package:island/models/chat.dart'; | ||||
| import 'package:island/pods/call.dart'; | ||||
| import 'package:island/screens/chat/room.dart'; | ||||
| import 'package:island/widgets/account/account_pfc.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/content/cloud_file_collection.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| @@ -129,9 +130,12 @@ class MessageItem extends HookConsumerWidget { | ||||
|                   spacing: 8, | ||||
|                   mainAxisSize: MainAxisSize.min, | ||||
|                   children: [ | ||||
|                     ProfilePictureWidget( | ||||
|                       fileId: sender.account.profile.picture?.id, | ||||
|                       radius: 16, | ||||
|                     AccountPfcGestureDetector( | ||||
|                       uname: sender.account.name, | ||||
|                       child: ProfilePictureWidget( | ||||
|                         fileId: sender.account.profile.picture?.id, | ||||
|                         radius: 16, | ||||
|                       ), | ||||
|                     ), | ||||
|                     Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|   | ||||
| @@ -10,13 +10,13 @@ import 'package:gal/gal.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:path/path.dart' show extension; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
| import 'package:photo_view/photo_view.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:uuid/uuid.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
|  | ||||
| class CloudFileList extends HookConsumerWidget { | ||||
|   final List<SnCloudFile> files; | ||||
| @@ -194,14 +194,18 @@ class CloudFileZoomIn extends HookConsumerWidget { | ||||
|         ); | ||||
|  | ||||
|         // Get the image URL | ||||
|         final imageUrl = '$serverUrl/files/${item.id}?original=true'; | ||||
|         final client = ref.watch(apiClientProvider); | ||||
|  | ||||
|         // Create a temporary file to save the image | ||||
|         final tempDir = await getTemporaryDirectory(); | ||||
|         final filePath = '${tempDir.path}/${item.id}.${extension(item.name)}'; | ||||
|  | ||||
|         await Dio().download(imageUrl, filePath); | ||||
|         await Gal.putImage(filePath); | ||||
|         await client.download( | ||||
|           '/files/${item.id}', | ||||
|           filePath, | ||||
|           queryParameters: {'original': true}, | ||||
|         ); | ||||
|         await Gal.putImage(filePath, album: 'Solar Network'); | ||||
|  | ||||
|         // Show success message | ||||
|         scaffold.showSnackBar( | ||||
|   | ||||
| @@ -54,13 +54,15 @@ class CloudFileWidget extends ConsumerWidget { | ||||
| } | ||||
|  | ||||
| class CloudImageWidget extends ConsumerWidget { | ||||
|   final String fileId; | ||||
|   final String? fileId; | ||||
|   final SnCloudFile? file; | ||||
|   final BoxFit fit; | ||||
|   final double aspectRatio; | ||||
|   final String? blurHash; | ||||
|   const CloudImageWidget({ | ||||
|     super.key, | ||||
|     required this.fileId, | ||||
|     this.fileId, | ||||
|     this.file, | ||||
|     this.aspectRatio = 1, | ||||
|     this.fit = BoxFit.cover, | ||||
|     this.blurHash, | ||||
| @@ -69,10 +71,14 @@ class CloudImageWidget extends ConsumerWidget { | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final serverUrl = ref.watch(serverUrlProvider); | ||||
|     final uri = '$serverUrl/files/$fileId'; | ||||
|     final uri = '$serverUrl/files/${file?.id ?? fileId}'; | ||||
|  | ||||
|     return AspectRatio( | ||||
|       aspectRatio: aspectRatio, | ||||
|       child: UniversalImage(uri: uri, blurHash: blurHash), | ||||
|       child: | ||||
|           file != null | ||||
|               ? CloudFileWidget(item: file!, fit: fit) | ||||
|               : UniversalImage(uri: uri, blurHash: blurHash, fit: fit), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @@ -88,12 +94,14 @@ class CloudImageWidget extends ConsumerWidget { | ||||
|  | ||||
| class ProfilePictureWidget extends ConsumerWidget { | ||||
|   final String? fileId; | ||||
|   final SnCloudFile? file; | ||||
|   final double radius; | ||||
|   final IconData? fallbackIcon; | ||||
|   final Color? fallbackColor; | ||||
|   const ProfilePictureWidget({ | ||||
|     super.key, | ||||
|     required this.fileId, | ||||
|     this.fileId, | ||||
|     this.file, | ||||
|     this.radius = 20, | ||||
|     this.fallbackIcon, | ||||
|     this.fallbackColor, | ||||
| @@ -102,7 +110,7 @@ class ProfilePictureWidget extends ConsumerWidget { | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final serverUrl = ref.watch(serverUrlProvider); | ||||
|     final uri = '$serverUrl/files/$fileId'; | ||||
|     final uri = '$serverUrl/files/${file?.id ?? fileId}'; | ||||
|  | ||||
|     return ClipRRect( | ||||
|       borderRadius: BorderRadius.all(Radius.circular(radius)), | ||||
| @@ -111,7 +119,9 @@ class ProfilePictureWidget extends ConsumerWidget { | ||||
|         height: radius * 2, | ||||
|         color: Theme.of(context).colorScheme.primaryContainer, | ||||
|         child: | ||||
|             fileId == null | ||||
|             file != null | ||||
|                 ? CloudFileWidget(item: file!, fit: BoxFit.cover) | ||||
|                 : fileId == null | ||||
|                 ? Icon( | ||||
|                   fallbackIcon ?? Symbols.account_circle, | ||||
|                   size: radius, | ||||
|   | ||||
| @@ -7,6 +7,7 @@ class SheetScaffold extends StatelessWidget { | ||||
|   final List<Widget> actions; | ||||
|   final Widget child; | ||||
|   final double heightFactor; | ||||
|   final double? height; | ||||
|   const SheetScaffold({ | ||||
|     super.key, | ||||
|     this.title, | ||||
| @@ -14,6 +15,7 @@ class SheetScaffold extends StatelessWidget { | ||||
|     required this.child, | ||||
|     this.actions = const [], | ||||
|     this.heightFactor = 0.8, | ||||
|     this.height, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
| @@ -32,7 +34,7 @@ class SheetScaffold extends StatelessWidget { | ||||
|  | ||||
|     return Container( | ||||
|       constraints: BoxConstraints( | ||||
|         maxHeight: MediaQuery.of(context).size.height * heightFactor, | ||||
|         maxHeight: height ?? MediaQuery.of(context).size.height * heightFactor, | ||||
|       ), | ||||
|       child: Column( | ||||
|         children: [ | ||||
|   | ||||
							
								
								
									
										178
									
								
								lib/widgets/post/compose_settings_sheet.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								lib/widgets/post/compose_settings_sheet.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,178 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
|  | ||||
| class ComposeSettingsSheet extends HookWidget { | ||||
|   final TextEditingController titleController; | ||||
|   final TextEditingController descriptionController; | ||||
|   final ValueNotifier<int> visibility; | ||||
|   final VoidCallback? onVisibilityChanged; | ||||
|  | ||||
|   const ComposeSettingsSheet({ | ||||
|     super.key, | ||||
|     required this.titleController, | ||||
|     required this.descriptionController, | ||||
|     required this.visibility, | ||||
|     this.onVisibilityChanged, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final theme = Theme.of(context); | ||||
|     final colorScheme = theme.colorScheme; | ||||
|  | ||||
|     IconData getVisibilityIcon(int visibilityValue) { | ||||
|       switch (visibilityValue) { | ||||
|         case 1: | ||||
|           return Symbols.group; | ||||
|         case 2: | ||||
|           return Symbols.link_off; | ||||
|         case 3: | ||||
|           return Symbols.lock; | ||||
|         default: | ||||
|           return Symbols.public; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     String getVisibilityText(int visibilityValue) { | ||||
|       switch (visibilityValue) { | ||||
|         case 1: | ||||
|           return 'postVisibilityFriends'; | ||||
|         case 2: | ||||
|           return 'postVisibilityUnlisted'; | ||||
|         case 3: | ||||
|           return 'postVisibilityPrivate'; | ||||
|         default: | ||||
|           return 'postVisibilityPublic'; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Widget buildVisibilityOption( | ||||
|       BuildContext context, | ||||
|       int value, | ||||
|       IconData icon, | ||||
|       String textKey, | ||||
|     ) { | ||||
|       return ListTile( | ||||
|         leading: Icon(icon), | ||||
|         title: Text(textKey.tr()), | ||||
|         onTap: () { | ||||
|           visibility.value = value; | ||||
|           onVisibilityChanged?.call(); | ||||
|           Navigator.pop(context); | ||||
|         }, | ||||
|         selected: visibility.value == value, | ||||
|         contentPadding: const EdgeInsets.symmetric(horizontal: 20), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     void showVisibilitySheet() { | ||||
|       showModalBottomSheet( | ||||
|         context: context, | ||||
|         builder: (context) => SheetScaffold( | ||||
|           titleText: 'postVisibility'.tr(), | ||||
|           child: Column( | ||||
|             mainAxisSize: MainAxisSize.min, | ||||
|             children: [ | ||||
|               buildVisibilityOption( | ||||
|                 context, | ||||
|                 0, | ||||
|                 Symbols.public, | ||||
|                 'postVisibilityPublic', | ||||
|               ), | ||||
|               buildVisibilityOption( | ||||
|                 context, | ||||
|                 1, | ||||
|                 Symbols.group, | ||||
|                 'postVisibilityFriends', | ||||
|               ), | ||||
|               buildVisibilityOption( | ||||
|                 context, | ||||
|                 2, | ||||
|                 Symbols.link_off, | ||||
|                 'postVisibilityUnlisted', | ||||
|               ), | ||||
|               buildVisibilityOption( | ||||
|                 context, | ||||
|                 3, | ||||
|                 Symbols.lock, | ||||
|                 'postVisibilityPrivate', | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: 'postSettings'.tr(), | ||||
|       child: SingleChildScrollView( | ||||
|         padding: const EdgeInsets.all(16), | ||||
|         child: Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           children: [ | ||||
|             // Title field | ||||
|             TextField( | ||||
|               controller: titleController, | ||||
|               decoration: InputDecoration( | ||||
|                 labelText: 'postTitle'.tr(), | ||||
|                 hintText: 'postTitle'.tr(), | ||||
|                 border: OutlineInputBorder( | ||||
|                   borderRadius: BorderRadius.circular(12), | ||||
|                 ), | ||||
|                 contentPadding: const EdgeInsets.all(16), | ||||
|               ), | ||||
|               style: theme.textTheme.titleLarge, | ||||
|               onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|             ), | ||||
|             const SizedBox(height: 16), | ||||
|  | ||||
|             // Description field | ||||
|             TextField( | ||||
|               controller: descriptionController, | ||||
|               decoration: InputDecoration( | ||||
|                 labelText: 'postDescription'.tr(), | ||||
|                 hintText: 'postDescription'.tr(), | ||||
|                 border: OutlineInputBorder( | ||||
|                   borderRadius: BorderRadius.circular(12), | ||||
|                 ), | ||||
|                 contentPadding: const EdgeInsets.all(16), | ||||
|               ), | ||||
|               style: theme.textTheme.bodyLarge, | ||||
|               maxLines: 3, | ||||
|               onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|             ), | ||||
|             const SizedBox(height: 24), | ||||
|  | ||||
|             // Visibility setting | ||||
|             Container( | ||||
|               decoration: BoxDecoration( | ||||
|                 border: Border.all( | ||||
|                   color: colorScheme.outline, | ||||
|                   width: 1, | ||||
|                 ), | ||||
|                 borderRadius: BorderRadius.circular(12), | ||||
|               ), | ||||
|               child: ListTile( | ||||
|                 leading: Icon(getVisibilityIcon(visibility.value)), | ||||
|                 title: Text('postVisibility'.tr()), | ||||
|                 subtitle: Text(getVisibilityText(visibility.value).tr()), | ||||
|                 trailing: const Icon(Symbols.chevron_right), | ||||
|                 onTap: showVisibilitySheet, | ||||
|                 shape: RoundedRectangleBorder( | ||||
|                   borderRadius: BorderRadius.circular(12), | ||||
|                 ), | ||||
|                 contentPadding: const EdgeInsets.symmetric( | ||||
|                   horizontal: 16, | ||||
|                   vertical: 8, | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										308
									
								
								lib/widgets/post/compose_shared.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										308
									
								
								lib/widgets/post/compose_shared.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,308 @@ | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:image_picker/image_picker.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/services/file.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:pasteboard/pasteboard.dart'; | ||||
|  | ||||
| class ComposeState { | ||||
|   final ValueNotifier<List<UniversalFile>> attachments; | ||||
|   final TextEditingController titleController; | ||||
|   final TextEditingController descriptionController; | ||||
|   final TextEditingController contentController; | ||||
|   final ValueNotifier<int> visibility; | ||||
|   final ValueNotifier<bool> submitting; | ||||
|   final ValueNotifier<Map<int, double>> attachmentProgress; | ||||
|   final ValueNotifier<SnPublisher?> currentPublisher; | ||||
|  | ||||
|   ComposeState({ | ||||
|     required this.attachments, | ||||
|     required this.titleController, | ||||
|     required this.descriptionController, | ||||
|     required this.contentController, | ||||
|     required this.visibility, | ||||
|     required this.submitting, | ||||
|     required this.attachmentProgress, | ||||
|     required this.currentPublisher, | ||||
|   }); | ||||
| } | ||||
|  | ||||
| class ComposeLogic { | ||||
|   static ComposeState createState({ | ||||
|     SnPost? originalPost, | ||||
|     SnPost? forwardedPost, | ||||
|   }) { | ||||
|     return ComposeState( | ||||
|       attachments: ValueNotifier<List<UniversalFile>>( | ||||
|         originalPost?.attachments | ||||
|                 .map( | ||||
|                   (e) => UniversalFile( | ||||
|                     data: e, | ||||
|                     type: switch (e.mimeType?.split('/').firstOrNull) { | ||||
|                       'image' => UniversalFileType.image, | ||||
|                       'video' => UniversalFileType.video, | ||||
|                       'audio' => UniversalFileType.audio, | ||||
|                       _ => UniversalFileType.file, | ||||
|                     }, | ||||
|                   ), | ||||
|                 ) | ||||
|                 .toList() ?? | ||||
|             [], | ||||
|       ), | ||||
|       titleController: TextEditingController(text: originalPost?.title), | ||||
|       descriptionController: TextEditingController( | ||||
|         text: originalPost?.description, | ||||
|       ), | ||||
|       contentController: TextEditingController( | ||||
|         text: | ||||
|             originalPost?.content ?? | ||||
|             (forwardedPost != null ? '> ${forwardedPost.content}\n\n' : null), | ||||
|       ), | ||||
|       visibility: ValueNotifier<int>(originalPost?.visibility ?? 0), | ||||
|       submitting: ValueNotifier<bool>(false), | ||||
|       attachmentProgress: ValueNotifier<Map<int, double>>({}), | ||||
|       currentPublisher: ValueNotifier<SnPublisher?>(null), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   static String getMimeTypeFromFileType(UniversalFileType type) { | ||||
|     return switch (type) { | ||||
|       UniversalFileType.image => 'image/unknown', | ||||
|       UniversalFileType.video => 'video/unknown', | ||||
|       UniversalFileType.audio => 'audio/unknown', | ||||
|       UniversalFileType.file => 'application/octet-stream', | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   static Future<void> pickPhotoMedia(WidgetRef ref, ComposeState state) async { | ||||
|     final result = await ref | ||||
|         .watch(imagePickerProvider) | ||||
|         .pickMultiImage(requestFullMetadata: true); | ||||
|     if (result.isEmpty) return; | ||||
|     state.attachments.value = [ | ||||
|       ...state.attachments.value, | ||||
|       ...result.map( | ||||
|         (e) => UniversalFile(data: e, type: UniversalFileType.image), | ||||
|       ), | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   static Future<void> pickVideoMedia(WidgetRef ref, ComposeState state) async { | ||||
|     final result = await ref | ||||
|         .watch(imagePickerProvider) | ||||
|         .pickVideo(source: ImageSource.gallery); | ||||
|     if (result == null) return; | ||||
|     state.attachments.value = [ | ||||
|       ...state.attachments.value, | ||||
|       UniversalFile(data: result, type: UniversalFileType.video), | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   static Future<void> uploadAttachment( | ||||
|     WidgetRef ref, | ||||
|     ComposeState state, | ||||
|     int index, | ||||
|   ) async { | ||||
|     final attachment = state.attachments.value[index]; | ||||
|     if (attachment.isOnCloud) return; | ||||
|  | ||||
|     final baseUrl = ref.watch(serverUrlProvider); | ||||
|     final token = await getToken(ref.watch(tokenProvider)); | ||||
|     if (token == null) throw ArgumentError('Token is null'); | ||||
|  | ||||
|     try { | ||||
|       // Update progress state | ||||
|       state.attachmentProgress.value = { | ||||
|         ...state.attachmentProgress.value, | ||||
|         index: 0, | ||||
|       }; | ||||
|  | ||||
|       // Upload file to cloud | ||||
|       final cloudFile = | ||||
|           await putMediaToCloud( | ||||
|             fileData: attachment, | ||||
|             atk: token, | ||||
|             baseUrl: baseUrl, | ||||
|             filename: attachment.data.name ?? 'Post media', | ||||
|             mimetype: | ||||
|                 attachment.data.mimeType ?? | ||||
|                 getMimeTypeFromFileType(attachment.type), | ||||
|             onProgress: (progress, _) { | ||||
|               state.attachmentProgress.value = { | ||||
|                 ...state.attachmentProgress.value, | ||||
|                 index: progress, | ||||
|               }; | ||||
|             }, | ||||
|           ).future; | ||||
|  | ||||
|       if (cloudFile == null) { | ||||
|         throw ArgumentError('Failed to upload the file...'); | ||||
|       } | ||||
|  | ||||
|       // Update attachments list with cloud file | ||||
|       final clone = List.of(state.attachments.value); | ||||
|       clone[index] = UniversalFile(data: cloudFile, type: attachment.type); | ||||
|       state.attachments.value = clone; | ||||
|     } catch (err) { | ||||
|       showErrorAlert(err); | ||||
|     } finally { | ||||
|       // Clean up progress state | ||||
|       state.attachmentProgress.value = {...state.attachmentProgress.value} | ||||
|         ..remove(index); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   static List<UniversalFile> moveAttachment( | ||||
|     List<UniversalFile> attachments, | ||||
|     int idx, | ||||
|     int delta, | ||||
|   ) { | ||||
|     if (idx + delta < 0 || idx + delta >= attachments.length) { | ||||
|       return attachments; | ||||
|     } | ||||
|     final clone = List.of(attachments); | ||||
|     clone.insert(idx + delta, clone.removeAt(idx)); | ||||
|     return clone; | ||||
|   } | ||||
|  | ||||
|   static Future<void> deleteAttachment( | ||||
|     WidgetRef ref, | ||||
|     ComposeState state, | ||||
|     int index, | ||||
|   ) async { | ||||
|     final attachment = state.attachments.value[index]; | ||||
|     if (attachment.isOnCloud) { | ||||
|       final client = ref.watch(apiClientProvider); | ||||
|       await client.delete('/files/${attachment.data.id}'); | ||||
|     } | ||||
|     final clone = List.of(state.attachments.value); | ||||
|     clone.removeAt(index); | ||||
|     state.attachments.value = clone; | ||||
|   } | ||||
|  | ||||
|   static Future<void> performAction( | ||||
|     WidgetRef ref, | ||||
|     ComposeState state, | ||||
|     BuildContext context, { | ||||
|     SnPost? originalPost, | ||||
|     SnPost? repliedPost, | ||||
|     SnPost? forwardedPost, | ||||
|     int? postType, // 0 for regular post, 1 for article | ||||
|   }) async { | ||||
|     if (state.submitting.value) return; | ||||
|  | ||||
|     try { | ||||
|       state.submitting.value = true; | ||||
|  | ||||
|       // Upload any local attachments first | ||||
|       await Future.wait( | ||||
|         state.attachments.value | ||||
|             .asMap() | ||||
|             .entries | ||||
|             .where((entry) => entry.value.isOnDevice) | ||||
|             .map((entry) => uploadAttachment(ref, state, entry.key)), | ||||
|       ); | ||||
|  | ||||
|       // Prepare API request | ||||
|       final client = ref.watch(apiClientProvider); | ||||
|       final isNewPost = originalPost == null; | ||||
|       final endpoint = isNewPost ? '/posts' : '/posts/${originalPost!.id}'; | ||||
|  | ||||
|       // Create request payload | ||||
|       final payload = { | ||||
|         'title': state.titleController.text, | ||||
|         'description': state.descriptionController.text, | ||||
|         'content': state.contentController.text, | ||||
|         'visibility': state.visibility.value, | ||||
|         'attachments': | ||||
|             state.attachments.value | ||||
|                 .where((e) => e.isOnCloud) | ||||
|                 .map((e) => e.data.id) | ||||
|                 .toList(), | ||||
|         if (postType != null) 'type': postType, | ||||
|         if (repliedPost != null) 'replied_post_id': repliedPost.id, | ||||
|         if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id, | ||||
|       }; | ||||
|  | ||||
|       // Send request | ||||
|       await client.request( | ||||
|         endpoint, | ||||
|         data: payload, | ||||
|         options: Options( | ||||
|           headers: {'X-Pub': state.currentPublisher.value?.name}, | ||||
|           method: isNewPost ? 'POST' : 'PATCH', | ||||
|         ), | ||||
|       ); | ||||
|  | ||||
|       if (context.mounted) { | ||||
|         Navigator.of(context).maybePop(true); | ||||
|       } | ||||
|     } catch (err) { | ||||
|       showErrorAlert(err); | ||||
|     } finally { | ||||
|       state.submitting.value = false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   static Future<void> handlePaste(ComposeState state) async { | ||||
|     final clipboard = await Pasteboard.image; | ||||
|     if (clipboard == null) return; | ||||
|  | ||||
|     state.attachments.value = [ | ||||
|       ...state.attachments.value, | ||||
|       UniversalFile( | ||||
|         data: XFile.fromData(clipboard, mimeType: "image/jpeg"), | ||||
|         type: UniversalFileType.image, | ||||
|       ), | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   static void handleKeyPress( | ||||
|     RawKeyEvent event, | ||||
|     ComposeState state, | ||||
|     WidgetRef ref, | ||||
|     BuildContext context, { | ||||
|     SnPost? originalPost, | ||||
|     SnPost? repliedPost, | ||||
|     SnPost? forwardedPost, | ||||
|     int? postType, | ||||
|   }) { | ||||
|     if (event is! RawKeyDownEvent) return; | ||||
|  | ||||
|     final isPaste = event.logicalKey == LogicalKeyboardKey.keyV; | ||||
|     final isModifierPressed = event.isMetaPressed || event.isControlPressed; | ||||
|     final isSubmit = event.logicalKey == LogicalKeyboardKey.enter; | ||||
|  | ||||
|     if (isPaste && isModifierPressed) { | ||||
|       handlePaste(state); | ||||
|     } else if (isSubmit && isModifierPressed && !state.submitting.value) { | ||||
|       performAction( | ||||
|         ref, | ||||
|         state, | ||||
|         context, | ||||
|         originalPost: originalPost, | ||||
|         repliedPost: repliedPost, | ||||
|         forwardedPost: forwardedPost, | ||||
|         postType: postType, | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   static void dispose(ComposeState state) { | ||||
|     state.titleController.dispose(); | ||||
|     state.descriptionController.dispose(); | ||||
|     state.contentController.dispose(); | ||||
|     state.attachments.dispose(); | ||||
|     state.visibility.dispose(); | ||||
|     state.submitting.dispose(); | ||||
|     state.attachmentProgress.dispose(); | ||||
|     state.currentPublisher.dispose(); | ||||
|   } | ||||
| } | ||||
| @@ -5,12 +5,15 @@ import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
| import 'package:island/widgets/account/account_name.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/content/cloud_file_collection.dart'; | ||||
| @@ -25,6 +28,7 @@ class PostItem extends HookConsumerWidget { | ||||
|   final SnPost item; | ||||
|   final EdgeInsets? padding; | ||||
|   final bool isOpenable; | ||||
|   final bool isFullPost; | ||||
|   final bool showReferencePost; | ||||
|   final Function? onRefresh; | ||||
|   final Function(SnPost)? onUpdate; | ||||
| @@ -34,6 +38,7 @@ class PostItem extends HookConsumerWidget { | ||||
|     this.backgroundColor, | ||||
|     this.padding, | ||||
|     this.isOpenable = true, | ||||
|     this.isFullPost = false, | ||||
|     this.showReferencePost = true, | ||||
|     this.onRefresh, | ||||
|     this.onUpdate, | ||||
| @@ -133,9 +138,7 @@ class PostItem extends HookConsumerWidget { | ||||
|                 spacing: 12, | ||||
|                 children: [ | ||||
|                   GestureDetector( | ||||
|                     child: ProfilePictureWidget( | ||||
|                       fileId: item.publisher.picture?.id, | ||||
|                     ), | ||||
|                     child: ProfilePictureWidget(file: item.publisher.picture), | ||||
|                     onTap: () { | ||||
|                       context.router.push( | ||||
|                         PublisherProfileRoute(name: item.publisher.name), | ||||
| @@ -147,7 +150,22 @@ class PostItem extends HookConsumerWidget { | ||||
|                       child: Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         children: [ | ||||
|                           Text(item.publisher.nick).bold(), | ||||
|                           Row( | ||||
|                             children: [ | ||||
|                               Text(item.publisher.nick).bold(), | ||||
|                               if (item.publisher.verification != null) | ||||
|                                 VerificationMark( | ||||
|                                   mark: item.publisher.verification!, | ||||
|                                 ).padding(left: 4), | ||||
|                               Spacer(), | ||||
|                               Text( | ||||
|                                 isFullPost | ||||
|                                     ? item.publishedAt.formatSystem() | ||||
|                                     : item.publishedAt.formatRelative(context), | ||||
|                               ).fontSize(11).alignment(Alignment.bottomRight), | ||||
|                               const Gap(4), | ||||
|                             ], | ||||
|                           ), | ||||
|                           // Add visibility indicator if not public (visibility != 0) | ||||
|                           if (item.visibility != 0) | ||||
|                             Row( | ||||
|   | ||||
							
								
								
									
										457
									
								
								lib/widgets/post/post_item_creator.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										457
									
								
								lib/widgets/post/post_item_creator.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,457 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/content/cloud_file_collection.dart'; | ||||
| import 'package:island/widgets/content/markdown.dart'; | ||||
| import 'package:island/widgets/post/post_item.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:super_context_menu/super_context_menu.dart'; | ||||
|  | ||||
| class PostItemCreator extends HookConsumerWidget { | ||||
|   final Color? backgroundColor; | ||||
|   final SnPost item; | ||||
|   final EdgeInsets? padding; | ||||
|   final bool isOpenable; | ||||
|   final Function? onRefresh; | ||||
|   final Function(SnPost)? onUpdate; | ||||
|  | ||||
|   const PostItemCreator({ | ||||
|     super.key, | ||||
|     required this.item, | ||||
|     this.backgroundColor, | ||||
|     this.padding, | ||||
|     this.isOpenable = true, | ||||
|     this.onRefresh, | ||||
|     this.onUpdate, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final renderingPadding = | ||||
|         padding ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 16); | ||||
|  | ||||
|     return ContextMenuWidget( | ||||
|       menuProvider: (_) { | ||||
|         return Menu( | ||||
|           children: [ | ||||
|             MenuAction( | ||||
|               title: 'edit'.tr(), | ||||
|               image: MenuImage.icon(Symbols.edit), | ||||
|               callback: () { | ||||
|                 context.router.push(PostEditRoute(id: item.id)).then((value) { | ||||
|                   if (value != null) { | ||||
|                     onRefresh?.call(); | ||||
|                   } | ||||
|                 }); | ||||
|               }, | ||||
|             ), | ||||
|             MenuAction( | ||||
|               title: 'delete'.tr(), | ||||
|               image: MenuImage.icon(Symbols.delete), | ||||
|               callback: () { | ||||
|                 showConfirmAlert('deletePostHint'.tr(), 'deletePost'.tr()).then( | ||||
|                   (confirm) { | ||||
|                     if (confirm) { | ||||
|                       final client = ref.watch(apiClientProvider); | ||||
|                       client | ||||
|                           .delete('/posts/${item.id}') | ||||
|                           .catchError((err) { | ||||
|                             showErrorAlert(err); | ||||
|                             return err; | ||||
|                           }) | ||||
|                           .then((_) { | ||||
|                             onRefresh?.call(); | ||||
|                           }); | ||||
|                     } | ||||
|                   }, | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|             MenuSeparator(), | ||||
|             MenuAction( | ||||
|               title: 'copyLink'.tr(), | ||||
|               image: MenuImage.icon(Symbols.link), | ||||
|               callback: () { | ||||
|                 // Copy post link to clipboard | ||||
|                 context.router.push(PostDetailRoute(id: item.id)); | ||||
|               }, | ||||
|             ), | ||||
|           ], | ||||
|         ); | ||||
|       }, | ||||
|       child: Material( | ||||
|         color: backgroundColor, | ||||
|         borderRadius: BorderRadius.circular(12), | ||||
|         elevation: 1, | ||||
|         child: InkWell( | ||||
|           borderRadius: BorderRadius.circular(12), | ||||
|           onTap: () { | ||||
|             if (isOpenable) { | ||||
|               context.router.push(PostDetailRoute(id: item.id)); | ||||
|             } | ||||
|           }, | ||||
|           child: Padding( | ||||
|             padding: renderingPadding, | ||||
|             child: Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 _buildPostHeader(context), | ||||
|                 _buildPostContent(context), | ||||
|                 const Gap(16), | ||||
|                 _buildAnalyticsSection(context), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildPostHeader(BuildContext context) { | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         // Post ID and timestamp row | ||||
|         Row( | ||||
|           children: [ | ||||
|             Container( | ||||
|               padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), | ||||
|               decoration: BoxDecoration( | ||||
|                 color: Theme.of(context).colorScheme.primaryContainer, | ||||
|                 borderRadius: BorderRadius.circular(4), | ||||
|               ), | ||||
|               child: Text( | ||||
|                 'ID: ${item.id.substring(0, 6)}', | ||||
|                 style: TextStyle( | ||||
|                   fontSize: 12, | ||||
|                   fontWeight: FontWeight.bold, | ||||
|                   color: Theme.of(context).colorScheme.onPrimaryContainer, | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|             const Spacer(), | ||||
|             Icon( | ||||
|               _getVisibilityIcon(item.visibility), | ||||
|               size: 16, | ||||
|               color: Theme.of(context).colorScheme.secondary, | ||||
|             ), | ||||
|             const SizedBox(width: 4), | ||||
|             Text( | ||||
|               _getVisibilityText(item.visibility).tr(), | ||||
|               style: TextStyle( | ||||
|                 fontSize: 12, | ||||
|                 color: Theme.of(context).colorScheme.secondary, | ||||
|               ), | ||||
|             ), | ||||
|             const Gap(8), | ||||
|             Text( | ||||
|               item.publishedAt.formatSystem(), | ||||
|               style: TextStyle( | ||||
|                 fontSize: 12, | ||||
|                 color: Theme.of(context).colorScheme.secondary, | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|         const Gap(8), | ||||
|  | ||||
|         // Title and description | ||||
|         if (item.title?.isNotEmpty ?? false) | ||||
|           Text( | ||||
|             item.title!, | ||||
|             style: Theme.of( | ||||
|               context, | ||||
|             ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), | ||||
|           ), | ||||
|         if (item.description?.isNotEmpty ?? false) | ||||
|           Text( | ||||
|             item.description!, | ||||
|             style: Theme.of(context).textTheme.bodyMedium?.copyWith( | ||||
|               color: Theme.of(context).colorScheme.onSurfaceVariant, | ||||
|             ), | ||||
|           ).padding(top: 4), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildPostContent(BuildContext context) { | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         // Content preview | ||||
|         if (item.content?.isNotEmpty ?? false) | ||||
|           Container( | ||||
|             margin: const EdgeInsets.only(top: 12), | ||||
|             child: MarkdownTextContent(content: item.content!), | ||||
|           ), | ||||
|  | ||||
|         // Attachments | ||||
|         if (item.attachments.isNotEmpty) | ||||
|           CloudFileList( | ||||
|             files: item.attachments, | ||||
|             maxWidth: MediaQuery.of(context).size.width * 0.85, | ||||
|             minWidth: MediaQuery.of(context).size.width * 0.9, | ||||
|           ).padding(top: 8), | ||||
|  | ||||
|         // Reference post indicator | ||||
|         if (item.repliedPost != null || item.forwardedPost != null) | ||||
|           Container( | ||||
|             margin: const EdgeInsets.only(top: 8), | ||||
|             child: Row( | ||||
|               children: [ | ||||
|                 Icon( | ||||
|                   item.repliedPost != null ? Symbols.reply : Symbols.forward, | ||||
|                   size: 16, | ||||
|                   color: Theme.of(context).colorScheme.secondary, | ||||
|                 ), | ||||
|                 const SizedBox(width: 4), | ||||
|                 Text( | ||||
|                   item.repliedPost != null | ||||
|                       ? 'repliedTo'.tr() | ||||
|                       : 'forwarded'.tr(), | ||||
|                   style: TextStyle( | ||||
|                     fontSize: 12, | ||||
|                     color: Theme.of(context).colorScheme.secondary, | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildAnalyticsSection(BuildContext context) { | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         Text('Analytics', style: Theme.of(context).textTheme.titleSmall), | ||||
|         const Gap(8), | ||||
|  | ||||
|         // Engagement metrics in a card | ||||
|         Card( | ||||
|           elevation: 1, | ||||
|           margin: EdgeInsets.zero, | ||||
|           shape: RoundedRectangleBorder( | ||||
|             borderRadius: BorderRadius.circular(12), | ||||
|             side: BorderSide( | ||||
|               color: Theme.of(context).colorScheme.outline.withOpacity(0.3), | ||||
|             ), | ||||
|           ), | ||||
|           child: Padding( | ||||
|             padding: const EdgeInsets.all(16), | ||||
|             child: Row( | ||||
|               mainAxisAlignment: MainAxisAlignment.spaceAround, | ||||
|               children: [ | ||||
|                 _buildMetricItem( | ||||
|                   context, | ||||
|                   Symbols.visibility, | ||||
|                   'Views', | ||||
|                   '${item.viewsUnique} / ${item.viewsTotal}', | ||||
|                   'Unique / Total', | ||||
|                 ), | ||||
|                 _buildMetricItem( | ||||
|                   context, | ||||
|                   Symbols.thumb_up, | ||||
|                   'Upvotes', | ||||
|                   '${item.upvotes}', | ||||
|                   null, | ||||
|                 ), | ||||
|                 _buildMetricItem( | ||||
|                   context, | ||||
|                   Symbols.thumb_down, | ||||
|                   'Downvotes', | ||||
|                   '${item.downvotes}', | ||||
|                   null, | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|         const Gap(16), | ||||
|  | ||||
|         // Reactions summary | ||||
|         if (item.reactionsCount.isNotEmpty) _buildReactionsSection(context), | ||||
|  | ||||
|         // Metadata section | ||||
|         if (item.meta != null && item.meta!.isNotEmpty) | ||||
|           _buildMetadataSection(context), | ||||
|  | ||||
|         // Creation and modification timestamps | ||||
|         const Gap(16), | ||||
|         Row( | ||||
|           mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|           children: [ | ||||
|             Text( | ||||
|               'Created: ${item.createdAt.formatSystem()}', | ||||
|               style: TextStyle( | ||||
|                 fontSize: 12, | ||||
|                 color: Theme.of(context).colorScheme.secondary, | ||||
|               ), | ||||
|             ), | ||||
|             if (item.editedAt != null) | ||||
|               Text( | ||||
|                 'Edited: ${item.editedAt!.formatSystem()}', | ||||
|                 style: TextStyle( | ||||
|                   fontSize: 12, | ||||
|                   color: Theme.of(context).colorScheme.secondary, | ||||
|                 ), | ||||
|               ), | ||||
|           ], | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildMetricItem( | ||||
|     BuildContext context, | ||||
|     IconData icon, | ||||
|     String label, | ||||
|     String value, | ||||
|     String? subtitle, | ||||
|   ) { | ||||
|     return Column( | ||||
|       children: [ | ||||
|         Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), | ||||
|         const Gap(4), | ||||
|         Text( | ||||
|           label, | ||||
|           style: TextStyle( | ||||
|             fontSize: 12, | ||||
|             color: Theme.of(context).colorScheme.secondary, | ||||
|           ), | ||||
|         ), | ||||
|         Text( | ||||
|           value, | ||||
|           style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), | ||||
|         ), | ||||
|         if (subtitle != null) | ||||
|           Text( | ||||
|             subtitle, | ||||
|             style: TextStyle( | ||||
|               fontSize: 10, | ||||
|               color: Theme.of(context).colorScheme.secondary, | ||||
|             ), | ||||
|           ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildReactionsSection(BuildContext context) { | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         Text( | ||||
|           'reactions'.plural( | ||||
|             item.reactionsCount.isNotEmpty | ||||
|                 ? item.reactionsCount.values.reduce((a, b) => a + b) | ||||
|                 : 0, | ||||
|           ), | ||||
|           style: TextStyle( | ||||
|             fontSize: 14, | ||||
|             fontWeight: FontWeight.w500, | ||||
|             color: Theme.of(context).colorScheme.secondary, | ||||
|           ), | ||||
|         ), | ||||
|         const Gap(8), | ||||
|         PostReactionList( | ||||
|           parentId: item.id, | ||||
|           reactions: item.reactionsCount, | ||||
|           padding: EdgeInsets.zero, | ||||
|           onReact: (symbol, attitude, delta) { | ||||
|             final reactionsCount = Map<String, int>.from(item.reactionsCount); | ||||
|             reactionsCount[symbol] = (reactionsCount[symbol] ?? 0) + delta; | ||||
|             onUpdate?.call(item.copyWith(reactionsCount: reactionsCount)); | ||||
|           }, | ||||
|         ), | ||||
|         const Gap(16), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildMetadataSection(BuildContext context) { | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         const Gap(16), | ||||
|         Text('Metadata', style: Theme.of(context).textTheme.titleSmall), | ||||
|         const Gap(8), | ||||
|         Container( | ||||
|           padding: const EdgeInsets.all(12), | ||||
|           decoration: BoxDecoration( | ||||
|             color: Theme.of( | ||||
|               context, | ||||
|             ).colorScheme.surfaceVariant.withOpacity(0.5), | ||||
|             borderRadius: BorderRadius.circular(8), | ||||
|             border: Border.all( | ||||
|               color: Theme.of(context).colorScheme.outline.withOpacity(0.3), | ||||
|             ), | ||||
|           ), | ||||
|           child: Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             children: [ | ||||
|               for (final entry in item.meta!.entries) | ||||
|                 Padding( | ||||
|                   padding: const EdgeInsets.only(bottom: 8), | ||||
|                   child: Row( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                     children: [ | ||||
|                       Text( | ||||
|                         '${entry.key}: ', | ||||
|                         style: const TextStyle( | ||||
|                           fontWeight: FontWeight.bold, | ||||
|                           fontSize: 12, | ||||
|                         ), | ||||
|                       ), | ||||
|                       Expanded( | ||||
|                         child: Text( | ||||
|                           '${entry.value}', | ||||
|                           style: const TextStyle(fontSize: 12), | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Helper method to get the appropriate icon for each visibility status | ||||
| IconData _getVisibilityIcon(int visibility) { | ||||
|   switch (visibility) { | ||||
|     case 1: // Friends | ||||
|       return Symbols.group; | ||||
|     case 2: // Unlisted | ||||
|       return Symbols.link_off; | ||||
|     case 3: // Private | ||||
|       return Symbols.lock; | ||||
|     default: // Public (0) or unknown | ||||
|       return Symbols.public; | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Helper method to get the translation key for each visibility status | ||||
| String _getVisibilityText(int visibility) { | ||||
|   switch (visibility) { | ||||
|     case 1: // Friends | ||||
|       return 'postVisibilityFriends'; | ||||
|     case 2: // Unlisted | ||||
|       return 'postVisibilityUnlisted'; | ||||
|     case 3: // Private | ||||
|       return 'postVisibilityPrivate'; | ||||
|     default: // Public (0) or unknown | ||||
|       return 'postVisibilityPublic'; | ||||
|   } | ||||
| } | ||||
| @@ -4,6 +4,7 @@ import 'package:island/models/post.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/widgets/content/paging_helper_ext.dart'; | ||||
| import 'package:island/widgets/post/post_item.dart'; | ||||
| import 'package:island/widgets/post/post_item_creator.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||
|  | ||||
| @@ -46,9 +47,34 @@ class PostListNotifier extends _$PostListNotifier | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// Defines which post item widget to use in the list | ||||
| enum PostItemType { | ||||
|   /// Regular post item with user information | ||||
|   regular, | ||||
|  | ||||
|   /// Creator view with analytics and metadata | ||||
|   creator, | ||||
| } | ||||
|  | ||||
| class SliverPostList extends HookConsumerWidget { | ||||
|   final String? pubName; | ||||
|   const SliverPostList({super.key, this.pubName}); | ||||
|   final PostItemType itemType; | ||||
|   final Color? backgroundColor; | ||||
|   final EdgeInsets? padding; | ||||
|   final bool isOpenable; | ||||
|   final Function? onRefresh; | ||||
|   final Function(SnPost)? onUpdate; | ||||
|  | ||||
|   const SliverPostList({ | ||||
|     super.key, | ||||
|     this.pubName, | ||||
|     this.itemType = PostItemType.regular, | ||||
|     this.backgroundColor, | ||||
|     this.padding, | ||||
|     this.isOpenable = true, | ||||
|     this.onRefresh, | ||||
|     this.onUpdate, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
| @@ -64,14 +90,29 @@ class SliverPostList extends HookConsumerWidget { | ||||
|                 return endItemView; | ||||
|               } | ||||
|  | ||||
|               final post = data.items[index]; | ||||
|  | ||||
|               return Column( | ||||
|                 children: [ | ||||
|                   PostItem(item: data.items[index]), | ||||
|                   const Divider(height: 1), | ||||
|                 ], | ||||
|                 children: [_buildPostItem(post), const Divider(height: 1)], | ||||
|               ); | ||||
|             }, | ||||
|           ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildPostItem(SnPost post) { | ||||
|     switch (itemType) { | ||||
|       case PostItemType.creator: | ||||
|         return PostItemCreator( | ||||
|           item: post, | ||||
|           backgroundColor: backgroundColor, | ||||
|           padding: padding, | ||||
|           isOpenable: isOpenable, | ||||
|           onRefresh: onRefresh, | ||||
|           onUpdate: onUpdate, | ||||
|         ); | ||||
|       case PostItemType.regular: | ||||
|         return PostItem(item: post); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -9,6 +9,7 @@ | ||||
| #include <bitsdojo_window_linux/bitsdojo_window_plugin.h> | ||||
| #include <file_selector_linux/file_selector_plugin.h> | ||||
| #include <flutter_platform_alert/flutter_platform_alert_plugin.h> | ||||
| #include <flutter_timezone/flutter_timezone_plugin.h> | ||||
| #include <flutter_udid/flutter_udid_plugin.h> | ||||
| #include <flutter_webrtc/flutter_web_r_t_c_plugin.h> | ||||
| #include <irondash_engine_context/irondash_engine_context_plugin.h> | ||||
| @@ -31,6 +32,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { | ||||
|   g_autoptr(FlPluginRegistrar) flutter_platform_alert_registrar = | ||||
|       fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterPlatformAlertPlugin"); | ||||
|   flutter_platform_alert_plugin_register_with_registrar(flutter_platform_alert_registrar); | ||||
|   g_autoptr(FlPluginRegistrar) flutter_timezone_registrar = | ||||
|       fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterTimezonePlugin"); | ||||
|   flutter_timezone_plugin_register_with_registrar(flutter_timezone_registrar); | ||||
|   g_autoptr(FlPluginRegistrar) flutter_udid_registrar = | ||||
|       fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterUdidPlugin"); | ||||
|   flutter_udid_plugin_register_with_registrar(flutter_udid_registrar); | ||||
|   | ||||
| @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST | ||||
|   bitsdojo_window_linux | ||||
|   file_selector_linux | ||||
|   flutter_platform_alert | ||||
|   flutter_timezone | ||||
|   flutter_udid | ||||
|   flutter_webrtc | ||||
|   irondash_engine_context | ||||
|   | ||||
| @@ -14,8 +14,10 @@ import firebase_core | ||||
| import firebase_messaging | ||||
| import flutter_inappwebview_macos | ||||
| import flutter_platform_alert | ||||
| import flutter_timezone | ||||
| import flutter_udid | ||||
| import flutter_webrtc | ||||
| import gal | ||||
| import irondash_engine_context | ||||
| import livekit_client | ||||
| import media_kit_libs_macos_video | ||||
| @@ -25,6 +27,7 @@ import pasteboard | ||||
| import path_provider_foundation | ||||
| import record_macos | ||||
| import shared_preferences_foundation | ||||
| import sign_in_with_apple | ||||
| import sqflite_darwin | ||||
| import sqlite3_flutter_libs | ||||
| import super_native_extensions | ||||
| @@ -42,8 +45,10 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { | ||||
|   FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) | ||||
|   InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) | ||||
|   FlutterPlatformAlertPlugin.register(with: registry.registrar(forPlugin: "FlutterPlatformAlertPlugin")) | ||||
|   FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin")) | ||||
|   FlutterUdidPlugin.register(with: registry.registrar(forPlugin: "FlutterUdidPlugin")) | ||||
|   FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) | ||||
|   GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin")) | ||||
|   IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) | ||||
|   LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin")) | ||||
|   MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) | ||||
| @@ -53,6 +58,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { | ||||
|   PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) | ||||
|   RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) | ||||
|   SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) | ||||
|   SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin")) | ||||
|   SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) | ||||
|   Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) | ||||
|   SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| platform :osx, '10.15' | ||||
| platform :osx, '11.0' | ||||
|  | ||||
| # CocoaPods analytics sends network stats synchronously affecting flutter build latency. | ||||
| ENV['COCOAPODS_DISABLE_STATS'] = 'true' | ||||
|   | ||||
| @@ -1,4 +1,14 @@ | ||||
| PODS: | ||||
|   - AppAuth (1.7.6): | ||||
|     - AppAuth/Core (= 1.7.6) | ||||
|     - AppAuth/ExternalUserAgent (= 1.7.6) | ||||
|   - AppAuth/Core (1.7.6) | ||||
|   - AppAuth/ExternalUserAgent (1.7.6): | ||||
|     - AppAuth/Core | ||||
|   - AppCheckCore (11.2.0): | ||||
|     - GoogleUtilities/Environment (~> 8.0) | ||||
|     - GoogleUtilities/UserDefaults (~> 8.0) | ||||
|     - PromisesObjC (~> 2.4) | ||||
|   - bitsdojo_window_macos (0.0.1): | ||||
|     - FlutterMacOS | ||||
|   - connectivity_plus (0.0.1): | ||||
| @@ -11,44 +21,46 @@ PODS: | ||||
|     - FlutterMacOS | ||||
|   - file_selector_macos (0.0.1): | ||||
|     - FlutterMacOS | ||||
|   - Firebase/CoreOnly (11.10.0): | ||||
|     - FirebaseCore (~> 11.10.0) | ||||
|   - Firebase/Messaging (11.10.0): | ||||
|   - Firebase/CoreOnly (11.13.0): | ||||
|     - FirebaseCore (~> 11.13.0) | ||||
|   - Firebase/Messaging (11.13.0): | ||||
|     - Firebase/CoreOnly | ||||
|     - FirebaseMessaging (~> 11.10.0) | ||||
|   - firebase_core (3.13.1): | ||||
|     - Firebase/CoreOnly (~> 11.10.0) | ||||
|     - FirebaseMessaging (~> 11.13.0) | ||||
|   - firebase_core (3.14.0): | ||||
|     - Firebase/CoreOnly (~> 11.13.0) | ||||
|     - FlutterMacOS | ||||
|   - firebase_messaging (15.2.6): | ||||
|     - Firebase/CoreOnly (~> 11.10.0) | ||||
|     - Firebase/Messaging (~> 11.10.0) | ||||
|   - firebase_messaging (15.2.7): | ||||
|     - Firebase/CoreOnly (~> 11.13.0) | ||||
|     - Firebase/Messaging (~> 11.13.0) | ||||
|     - firebase_core | ||||
|     - FlutterMacOS | ||||
|   - FirebaseCore (11.10.0): | ||||
|     - FirebaseCoreInternal (~> 11.10.0) | ||||
|     - GoogleUtilities/Environment (~> 8.0) | ||||
|     - GoogleUtilities/Logger (~> 8.0) | ||||
|   - FirebaseCoreInternal (11.10.0): | ||||
|     - "GoogleUtilities/NSData+zlib (~> 8.0)" | ||||
|   - FirebaseInstallations (11.10.0): | ||||
|     - FirebaseCore (~> 11.10.0) | ||||
|     - GoogleUtilities/Environment (~> 8.0) | ||||
|     - GoogleUtilities/UserDefaults (~> 8.0) | ||||
|   - FirebaseCore (11.13.0): | ||||
|     - FirebaseCoreInternal (~> 11.13.0) | ||||
|     - GoogleUtilities/Environment (~> 8.1) | ||||
|     - GoogleUtilities/Logger (~> 8.1) | ||||
|   - FirebaseCoreInternal (11.13.0): | ||||
|     - "GoogleUtilities/NSData+zlib (~> 8.1)" | ||||
|   - FirebaseInstallations (11.13.0): | ||||
|     - FirebaseCore (~> 11.13.0) | ||||
|     - GoogleUtilities/Environment (~> 8.1) | ||||
|     - GoogleUtilities/UserDefaults (~> 8.1) | ||||
|     - PromisesObjC (~> 2.4) | ||||
|   - FirebaseMessaging (11.10.0): | ||||
|     - FirebaseCore (~> 11.10.0) | ||||
|   - FirebaseMessaging (11.13.0): | ||||
|     - FirebaseCore (~> 11.13.0) | ||||
|     - FirebaseInstallations (~> 11.0) | ||||
|     - GoogleDataTransport (~> 10.0) | ||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.0) | ||||
|     - GoogleUtilities/Environment (~> 8.0) | ||||
|     - GoogleUtilities/Reachability (~> 8.0) | ||||
|     - GoogleUtilities/UserDefaults (~> 8.0) | ||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.1) | ||||
|     - GoogleUtilities/Environment (~> 8.1) | ||||
|     - GoogleUtilities/Reachability (~> 8.1) | ||||
|     - GoogleUtilities/UserDefaults (~> 8.1) | ||||
|     - nanopb (~> 3.30910.0) | ||||
|   - flutter_inappwebview_macos (0.0.1): | ||||
|     - FlutterMacOS | ||||
|     - OrderedSet (~> 6.0.3) | ||||
|   - flutter_platform_alert (0.0.1): | ||||
|     - FlutterMacOS | ||||
|   - flutter_timezone (0.1.0): | ||||
|     - FlutterMacOS | ||||
|   - flutter_udid (0.0.1): | ||||
|     - FlutterMacOS | ||||
|     - SAMKeychain | ||||
| @@ -56,9 +68,23 @@ PODS: | ||||
|     - FlutterMacOS | ||||
|     - WebRTC-SDK (= 125.6422.07) | ||||
|   - FlutterMacOS (1.0.0) | ||||
|   - gal (1.0.0): | ||||
|     - Flutter | ||||
|     - FlutterMacOS | ||||
|   - google_sign_in_ios (0.0.1): | ||||
|     - AppAuth (>= 1.7.4) | ||||
|     - Flutter | ||||
|     - FlutterMacOS | ||||
|     - GoogleSignIn (~> 8.0) | ||||
|     - GTMSessionFetcher (>= 3.4.0) | ||||
|   - GoogleDataTransport (10.1.0): | ||||
|     - nanopb (~> 3.30910.0) | ||||
|     - PromisesObjC (~> 2.4) | ||||
|   - GoogleSignIn (8.0.0): | ||||
|     - AppAuth (< 2.0, >= 1.7.3) | ||||
|     - AppCheckCore (~> 11.0) | ||||
|     - GTMAppAuth (< 5.0, >= 4.1.1) | ||||
|     - GTMSessionFetcher/Core (~> 3.3) | ||||
|   - GoogleUtilities/AppDelegateSwizzler (8.1.0): | ||||
|     - GoogleUtilities/Environment | ||||
|     - GoogleUtilities/Logger | ||||
| @@ -83,9 +109,17 @@ PODS: | ||||
|   - GoogleUtilities/UserDefaults (8.1.0): | ||||
|     - GoogleUtilities/Logger | ||||
|     - GoogleUtilities/Privacy | ||||
|   - GTMAppAuth (4.1.1): | ||||
|     - AppAuth/Core (~> 1.7) | ||||
|     - GTMSessionFetcher/Core (< 4.0, >= 3.3) | ||||
|   - GTMSessionFetcher (3.5.0): | ||||
|     - GTMSessionFetcher/Full (= 3.5.0) | ||||
|   - GTMSessionFetcher/Core (3.5.0) | ||||
|   - GTMSessionFetcher/Full (3.5.0): | ||||
|     - GTMSessionFetcher/Core | ||||
|   - irondash_engine_context (0.0.1): | ||||
|     - FlutterMacOS | ||||
|   - livekit_client (2.4.7): | ||||
|   - livekit_client (2.4.8): | ||||
|     - flutter_webrtc | ||||
|     - FlutterMacOS | ||||
|     - WebRTC-SDK (= 125.6422.07) | ||||
| @@ -113,26 +147,28 @@ PODS: | ||||
|   - shared_preferences_foundation (0.0.1): | ||||
|     - Flutter | ||||
|     - FlutterMacOS | ||||
|   - sign_in_with_apple (0.0.1): | ||||
|     - FlutterMacOS | ||||
|   - sqflite_darwin (0.0.4): | ||||
|     - Flutter | ||||
|     - FlutterMacOS | ||||
|   - sqlite3 (3.49.2): | ||||
|     - sqlite3/common (= 3.49.2) | ||||
|   - sqlite3/common (3.49.2) | ||||
|   - sqlite3/dbstatvtab (3.49.2): | ||||
|   - sqlite3 (3.50.1): | ||||
|     - sqlite3/common (= 3.50.1) | ||||
|   - sqlite3/common (3.50.1) | ||||
|   - sqlite3/dbstatvtab (3.50.1): | ||||
|     - sqlite3/common | ||||
|   - sqlite3/fts5 (3.49.2): | ||||
|   - sqlite3/fts5 (3.50.1): | ||||
|     - sqlite3/common | ||||
|   - sqlite3/math (3.49.2): | ||||
|   - sqlite3/math (3.50.1): | ||||
|     - sqlite3/common | ||||
|   - sqlite3/perf-threadsafe (3.49.2): | ||||
|   - sqlite3/perf-threadsafe (3.50.1): | ||||
|     - sqlite3/common | ||||
|   - sqlite3/rtree (3.49.2): | ||||
|   - sqlite3/rtree (3.50.1): | ||||
|     - sqlite3/common | ||||
|   - sqlite3_flutter_libs (0.0.1): | ||||
|     - Flutter | ||||
|     - FlutterMacOS | ||||
|     - sqlite3 (~> 3.49.2) | ||||
|     - sqlite3 (~> 3.50.1) | ||||
|     - sqlite3/dbstatvtab | ||||
|     - sqlite3/fts5 | ||||
|     - sqlite3/math | ||||
| @@ -159,9 +195,12 @@ DEPENDENCIES: | ||||
|   - firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`) | ||||
|   - flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`) | ||||
|   - flutter_platform_alert (from `Flutter/ephemeral/.symlinks/plugins/flutter_platform_alert/macos`) | ||||
|   - flutter_timezone (from `Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos`) | ||||
|   - flutter_udid (from `Flutter/ephemeral/.symlinks/plugins/flutter_udid/macos`) | ||||
|   - flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`) | ||||
|   - FlutterMacOS (from `Flutter/ephemeral`) | ||||
|   - gal (from `Flutter/ephemeral/.symlinks/plugins/gal/darwin`) | ||||
|   - google_sign_in_ios (from `Flutter/ephemeral/.symlinks/plugins/google_sign_in_ios/darwin`) | ||||
|   - irondash_engine_context (from `Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos`) | ||||
|   - livekit_client (from `Flutter/ephemeral/.symlinks/plugins/livekit_client/macos`) | ||||
|   - media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`) | ||||
| @@ -171,6 +210,7 @@ DEPENDENCIES: | ||||
|   - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) | ||||
|   - record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`) | ||||
|   - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) | ||||
|   - sign_in_with_apple (from `Flutter/ephemeral/.symlinks/plugins/sign_in_with_apple/macos`) | ||||
|   - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) | ||||
|   - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`) | ||||
|   - super_native_extensions (from `Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos`) | ||||
| @@ -180,13 +220,18 @@ DEPENDENCIES: | ||||
|  | ||||
| SPEC REPOS: | ||||
|   trunk: | ||||
|     - AppAuth | ||||
|     - AppCheckCore | ||||
|     - Firebase | ||||
|     - FirebaseCore | ||||
|     - FirebaseCoreInternal | ||||
|     - FirebaseInstallations | ||||
|     - FirebaseMessaging | ||||
|     - GoogleDataTransport | ||||
|     - GoogleSignIn | ||||
|     - GoogleUtilities | ||||
|     - GTMAppAuth | ||||
|     - GTMSessionFetcher | ||||
|     - nanopb | ||||
|     - OrderedSet | ||||
|     - PromisesObjC | ||||
| @@ -215,12 +260,18 @@ EXTERNAL SOURCES: | ||||
|     :path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos | ||||
|   flutter_platform_alert: | ||||
|     :path: Flutter/ephemeral/.symlinks/plugins/flutter_platform_alert/macos | ||||
|   flutter_timezone: | ||||
|     :path: Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos | ||||
|   flutter_udid: | ||||
|     :path: Flutter/ephemeral/.symlinks/plugins/flutter_udid/macos | ||||
|   flutter_webrtc: | ||||
|     :path: Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos | ||||
|   FlutterMacOS: | ||||
|     :path: Flutter/ephemeral | ||||
|   gal: | ||||
|     :path: Flutter/ephemeral/.symlinks/plugins/gal/darwin | ||||
|   google_sign_in_ios: | ||||
|     :path: Flutter/ephemeral/.symlinks/plugins/google_sign_in_ios/darwin | ||||
|   irondash_engine_context: | ||||
|     :path: Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos | ||||
|   livekit_client: | ||||
| @@ -239,6 +290,8 @@ EXTERNAL SOURCES: | ||||
|     :path: Flutter/ephemeral/.symlinks/plugins/record_macos/macos | ||||
|   shared_preferences_foundation: | ||||
|     :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin | ||||
|   sign_in_with_apple: | ||||
|     :path: Flutter/ephemeral/.symlinks/plugins/sign_in_with_apple/macos | ||||
|   sqflite_darwin: | ||||
|     :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin | ||||
|   sqlite3_flutter_libs: | ||||
| @@ -253,28 +306,36 @@ EXTERNAL SOURCES: | ||||
|     :path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos | ||||
|  | ||||
| SPEC CHECKSUMS: | ||||
|   AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 | ||||
|   AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f | ||||
|   bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9 | ||||
|   connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e | ||||
|   croppy: d9bfc8c02f3cd1851f669a421df298a474b78f43 | ||||
|   device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 | ||||
|   file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a | ||||
|   file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 | ||||
|   Firebase: 1fe1c0a7d9aaea32efe01fbea5f0ebd8d70e53a2 | ||||
|   firebase_core: 25e649fdb52de4f20314f9e54b914e7298875acc | ||||
|   firebase_messaging: cfe5129bd3b65e4da0b2ac98b9af0735ef2fd564 | ||||
|   FirebaseCore: 8344daef5e2661eb004b177488d6f9f0f24251b7 | ||||
|   FirebaseCoreInternal: ef4505d2afb1d0ebbc33162cb3795382904b5679 | ||||
|   FirebaseInstallations: 9980995bdd06ec8081dfb6ab364162bdd64245c3 | ||||
|   FirebaseMessaging: 2b9f56aa4ed286e1f0ce2ee1d413aabb8f9f5cb9 | ||||
|   Firebase: 3435bc66b4d494c2f22c79fd3aae4c1db6662327 | ||||
|   firebase_core: 1095fcf33161d99bc34aa10f7c0d89414a208d15 | ||||
|   firebase_messaging: 6417056ffb85141607618ddfef9fec9f3caab3ea | ||||
|   FirebaseCore: c692c7f1c75305ab6aff2b367f25e11d73aa8bd0 | ||||
|   FirebaseCoreInternal: 29d7b3af4aaf0b8f3ed20b568c13df399b06f68c | ||||
|   FirebaseInstallations: 0ee9074f2c1e86561ace168ee1470dc67aabaf02 | ||||
|   FirebaseMessaging: 195bbdb73e6ca1dbc76cd46e73f3552c084ef6e4 | ||||
|   flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d | ||||
|   flutter_platform_alert: 8fa7a7c21f95b26d08b4a3891936ca27e375f284 | ||||
|   flutter_timezone: d59eea86178cbd7943cd2431cc2eaa9850f935d8 | ||||
|   flutter_udid: d26e455e8c06174e6aff476e147defc6cae38495 | ||||
|   flutter_webrtc: a7eeb54859e672228c28f4b48b1fb61561976ea3 | ||||
|   FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 | ||||
|   gal: baecd024ebfd13c441269ca7404792a7152fde89 | ||||
|   google_sign_in_ios: b48bb9af78576358a168361173155596c845f0b9 | ||||
|   GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 | ||||
|   GoogleSignIn: ce8c89bb9b37fb624b92e7514cc67335d1e277e4 | ||||
|   GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 | ||||
|   GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de | ||||
|   GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 | ||||
|   irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba | ||||
|   livekit_client: c5fc13c397f17de76534280744ddd8c232915057 | ||||
|   livekit_client: 6a35243df3da61750c98e266e02dedcf5d25c888 | ||||
|   media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65 | ||||
|   media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758 | ||||
|   nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 | ||||
| @@ -286,15 +347,16 @@ SPEC CHECKSUMS: | ||||
|   record_macos: 295d70bd5fb47145df78df7b80e6697cd18403c0 | ||||
|   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c | ||||
|   shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 | ||||
|   sign_in_with_apple: 6673c03c9e3643f6c8d33601943fbfa9ae99f94e | ||||
|   sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 | ||||
|   sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 | ||||
|   sqlite3_flutter_libs: 74334e3ef2dbdb7d37e50859bb45da43935779c4 | ||||
|   sqlite3: 1d85290c3321153511f6e900ede7a1608718bbd5 | ||||
|   sqlite3_flutter_libs: e7fc8c9ea2200ff3271f08f127842131746b70e2 | ||||
|   super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189 | ||||
|   url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 | ||||
|   volume_controller: 5c068e6d085c80dadd33fc2c918d2114b775b3dd | ||||
|   wakelock_plus: 21ddc249ac4b8d018838dbdabd65c5976c308497 | ||||
|   WebRTC-SDK: dff00a3892bc570b6014e046297782084071657e | ||||
|  | ||||
| PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 | ||||
| PODFILE CHECKSUM: 346bfb2deb41d4a6ebd6f6799f92188bde2d246f | ||||
|  | ||||
| COCOAPODS: 1.16.2 | ||||
|   | ||||
| @@ -57,7 +57,7 @@ | ||||
| 		331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; }; | ||||
| 		333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; }; | ||||
| 		335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; }; | ||||
| 		33CC10ED2044A3C60003C045 /* island.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = island.app; sourceTree = BUILT_PRODUCTS_DIR; }; | ||||
| 		33CC10ED2044A3C60003C045 /* Solian.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Solian.app; sourceTree = BUILT_PRODUCTS_DIR; }; | ||||
| 		33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; | ||||
| 		33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; }; | ||||
| 		33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; }; | ||||
| @@ -150,7 +150,7 @@ | ||||
| 		33CC10EE2044A3C60003C045 /* Products */ = { | ||||
| 			isa = PBXGroup; | ||||
| 			children = ( | ||||
| 				33CC10ED2044A3C60003C045 /* island.app */, | ||||
| 				33CC10ED2044A3C60003C045 /* Solian.app */, | ||||
| 				331C80D5294CF71000263BE5 /* RunnerTests.xctest */, | ||||
| 			); | ||||
| 			name = Products; | ||||
| @@ -242,7 +242,7 @@ | ||||
| 			); | ||||
| 			name = Runner; | ||||
| 			productName = Runner; | ||||
| 			productReference = 33CC10ED2044A3C60003C045 /* island.app */; | ||||
| 			productReference = 33CC10ED2044A3C60003C045 /* Solian.app */; | ||||
| 			productType = "com.apple.product-type.application"; | ||||
| 		}; | ||||
| /* End PBXNativeTarget section */ | ||||
| @@ -583,6 +583,7 @@ | ||||
| 				CLANG_ENABLE_MODULES = YES; | ||||
| 				CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; | ||||
| 				CODE_SIGN_IDENTITY = "Apple Development"; | ||||
| 				"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; | ||||
| 				CODE_SIGN_STYLE = Automatic; | ||||
| 				COMBINE_HIDPI_IMAGES = YES; | ||||
| 				DEVELOPMENT_TEAM = W7HPZ53V6B; | ||||
| @@ -593,7 +594,7 @@ | ||||
| 					"$(inherited)", | ||||
| 					"@executable_path/../Frameworks", | ||||
| 				); | ||||
| 				MACOSX_DEPLOYMENT_TARGET = 10.15; | ||||
| 				MACOSX_DEPLOYMENT_TARGET = 11.0; | ||||
| 				PROVISIONING_PROFILE_SPECIFIER = ""; | ||||
| 				SWIFT_VERSION = 5.0; | ||||
| 			}; | ||||
| @@ -720,6 +721,7 @@ | ||||
| 				CLANG_ENABLE_MODULES = YES; | ||||
| 				CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; | ||||
| 				CODE_SIGN_IDENTITY = "Apple Development"; | ||||
| 				"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; | ||||
| 				CODE_SIGN_STYLE = Automatic; | ||||
| 				COMBINE_HIDPI_IMAGES = YES; | ||||
| 				DEVELOPMENT_TEAM = W7HPZ53V6B; | ||||
| @@ -730,7 +732,7 @@ | ||||
| 					"$(inherited)", | ||||
| 					"@executable_path/../Frameworks", | ||||
| 				); | ||||
| 				MACOSX_DEPLOYMENT_TARGET = 10.15; | ||||
| 				MACOSX_DEPLOYMENT_TARGET = 11.0; | ||||
| 				PROVISIONING_PROFILE_SPECIFIER = ""; | ||||
| 				SWIFT_OPTIMIZATION_LEVEL = "-Onone"; | ||||
| 				SWIFT_VERSION = 5.0; | ||||
| @@ -745,6 +747,7 @@ | ||||
| 				CLANG_ENABLE_MODULES = YES; | ||||
| 				CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; | ||||
| 				CODE_SIGN_IDENTITY = "Apple Development"; | ||||
| 				"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; | ||||
| 				CODE_SIGN_STYLE = Automatic; | ||||
| 				COMBINE_HIDPI_IMAGES = YES; | ||||
| 				DEVELOPMENT_TEAM = W7HPZ53V6B; | ||||
| @@ -755,7 +758,7 @@ | ||||
| 					"$(inherited)", | ||||
| 					"@executable_path/../Frameworks", | ||||
| 				); | ||||
| 				MACOSX_DEPLOYMENT_TARGET = 10.15; | ||||
| 				MACOSX_DEPLOYMENT_TARGET = 11.0; | ||||
| 				PROVISIONING_PROFILE_SPECIFIER = ""; | ||||
| 				SWIFT_VERSION = 5.0; | ||||
| 			}; | ||||
|   | ||||
| @@ -5,10 +5,10 @@ | ||||
| // 'flutter create' template. | ||||
|  | ||||
| // The application's name. By default this is also the title of the Flutter window. | ||||
| PRODUCT_NAME = island | ||||
| PRODUCT_NAME = Solian | ||||
|  | ||||
| // The application's bundle identifier | ||||
| PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian | ||||
|  | ||||
| // The copyright displayed in application information | ||||
| PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. | ||||
| PRODUCT_COPYRIGHT = Copyright © 2025 Solsynth LLC. All rights reserved. | ||||
|   | ||||
| @@ -2,6 +2,12 @@ | ||||
| <!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.developer.applesignin</key> | ||||
| 	<array> | ||||
| 		<string>Default</string> | ||||
| 	</array> | ||||
| 	<key>com.apple.developer.device-information.user-assigned-device-name</key> | ||||
| 	<true/> | ||||
| 	<key>com.apple.security.app-sandbox</key> | ||||
| 	<true/> | ||||
| 	<key>com.apple.security.cs.allow-jit</key> | ||||
|   | ||||
| @@ -2,6 +2,12 @@ | ||||
| <!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.developer.applesignin</key> | ||||
| 	<array> | ||||
| 		<string>Default</string> | ||||
| 	</array> | ||||
| 	<key>com.apple.developer.device-information.user-assigned-device-name</key> | ||||
| 	<true/> | ||||
| 	<key>com.apple.security.app-sandbox</key> | ||||
| 	<true/> | ||||
| 	<key>com.apple.security.device.audio-input</key> | ||||
|   | ||||
							
								
								
									
										300
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										300
									
								
								pubspec.lock
									
									
									
									
									
								
							| @@ -5,34 +5,34 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: _fe_analyzer_shared | ||||
|       sha256: dc27559385e905ad30838356c5f5d574014ba39872d732111cd07ac0beff4c57 | ||||
|       sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "80.0.0" | ||||
|     version: "82.0.0" | ||||
|   _flutterfire_internals: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: _flutterfire_internals | ||||
|       sha256: "214e6f07e2a44f45972e0365c7b537eaeaddb4598db0778dd4ac64b4acd3f5b1" | ||||
|       sha256: dda4fd7909a732a014239009aa52537b136f8ce568de23c212587097887e2307 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.3.55" | ||||
|     version: "1.3.56" | ||||
|   analyzer: | ||||
|     dependency: "direct overridden" | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: analyzer | ||||
|       sha256: "192d1c5b944e7e53b24b5586db760db934b177d4147c42fbca8c8c5f1eb8d11e" | ||||
|       sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "7.3.0" | ||||
|     version: "7.4.5" | ||||
|   analyzer_plugin: | ||||
|     dependency: "direct overridden" | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: analyzer_plugin | ||||
|       sha256: "1d460d14e3c2ae36dc2b32cef847c4479198cf87704f63c3c3c8150ee50c3916" | ||||
|       sha256: ee188b6df6c85f1441497c7171c84f1392affadc0384f71089cb10a3bc508cef | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.12.0" | ||||
|     version: "0.13.1" | ||||
|   animations: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -85,10 +85,10 @@ packages: | ||||
|     dependency: "direct dev" | ||||
|     description: | ||||
|       name: auto_route_generator | ||||
|       sha256: "9e3846fcbeacba5c362557328dd8c8fbc953b6a0cbc3395365e8d8f92eea29c4" | ||||
|       sha256: "2a5b5bf9c55d4a2098931037dac90921a4663808aed494bb4f134d82d46cb8ec" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "10.1.0" | ||||
|     version: "10.2.3" | ||||
|   avatar_stack: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -269,10 +269,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: checked_yaml | ||||
|       sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff | ||||
|       sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.0.3" | ||||
|     version: "2.0.4" | ||||
|   ci: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -281,6 +281,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.1.0" | ||||
|   cli_config: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: cli_config | ||||
|       sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.2.0" | ||||
|   cli_util: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -337,6 +345,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.1.2" | ||||
|   coverage: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: coverage | ||||
|       sha256: aa07dbe5f2294c827b7edb9a87bba44a9c15a3cc81bc8da2ca19b37322d30080 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.14.1" | ||||
|   croppy: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -402,13 +418,13 @@ packages: | ||||
|     source: hosted | ||||
|     version: "0.7.5" | ||||
|   custom_lint_visitor: | ||||
|     dependency: "direct overridden" | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: custom_lint_visitor | ||||
|       sha256: "36282d85714af494ee2d7da8c8913630aa6694da99f104fb2ed4afcf8fc857d8" | ||||
|       sha256: cba5b6d7a6217312472bf4468cdf68c949488aed7ffb0eab792cd0b6c435054d | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.0.0+7.3.0" | ||||
|     version: "1.0.0+7.4.5" | ||||
|   dart_style: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -477,18 +493,18 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: drift | ||||
|       sha256: b584ddeb2b74436735dd2cf746d2d021e19a9a6770f409212fd5cbc2814ada85 | ||||
|       sha256: e60c715f045dd33624fc533efb0075e057debec9f39e83843e518f488a0e21fb | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.26.1" | ||||
|     version: "2.27.0" | ||||
|   drift_dev: | ||||
|     dependency: "direct dev" | ||||
|     description: | ||||
|       name: drift_dev | ||||
|       sha256: "54dc207c6e4662741f60e5752678df183957ab907754ffab0372a7082f6d2816" | ||||
|       sha256: "7ad88b8982e753eadcdbc0ea7c7d30500598af733601428b5c9d264baf5106d6" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.26.1" | ||||
|     version: "2.27.0" | ||||
|   drift_flutter: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -573,10 +589,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: file_picker | ||||
|       sha256: "77f8e81d22d2a07d0dee2c62e1dda71dc1da73bf43bb2d45af09727406167964" | ||||
|       sha256: ef9908739bdd9c476353d6adff72e88fd00c625f5b959ae23f7567bd5137db0a | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "10.1.9" | ||||
|     version: "10.2.0" | ||||
|   file_selector_linux: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -613,10 +629,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: firebase_core | ||||
|       sha256: "8cfe3c900512399ce8d50fcc817e5758ff8615eeb6fa5c846a4cc47bbf6353b6" | ||||
|       sha256: "420d9111dcf095341f1ea8fdce926eef750cf7b9745d21f38000de780c94f608" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.13.1" | ||||
|     version: "3.14.0" | ||||
|   firebase_core_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -637,26 +653,26 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: firebase_messaging | ||||
|       sha256: "38111089e511f03daa2c66b4c3614c16421b7d78c84ee04331a0a65b47df4542" | ||||
|       sha256: "758461f67b96aa5ad27625aaae39882fd6d1961b1c7e005301f9a74b6336100b" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "15.2.6" | ||||
|     version: "15.2.7" | ||||
|   firebase_messaging_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_messaging_platform_interface | ||||
|       sha256: ba254769982e5f439e534eed68856181c74e2b3f417c8188afad2bb440807cc7 | ||||
|       sha256: "614db1b0df0f53e541e41cc182b6d7ede5763c400f6ba232a5f8d0e1b5e5de32" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "4.6.6" | ||||
|     version: "4.6.7" | ||||
|   firebase_messaging_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_messaging_web | ||||
|       sha256: dba89137272aac39e95f71408ba25c33fb8ed903cd5fa8d1e49b5cd0d96069e0 | ||||
|       sha256: b5fbbcdd3e0e7f3fde72b0c119410f22737638fed5fc428b54bba06bc1455d81 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.10.6" | ||||
|     version: "3.10.7" | ||||
|   fixnum: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -665,6 +681,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.1.1" | ||||
|   fl_chart: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: fl_chart | ||||
|       sha256: "577aeac8ca414c25333334d7c4bb246775234c0e44b38b10a82b559dd4d764e7" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.0.0" | ||||
|   flutter: | ||||
|     dependency: "direct main" | ||||
|     description: flutter | ||||
| @@ -778,10 +802,10 @@ packages: | ||||
|     dependency: "direct dev" | ||||
|     description: | ||||
|       name: flutter_launcher_icons | ||||
|       sha256: bfa04787c85d80ecb3f8777bde5fc10c3de809240c48fa061a2c2bf15ea5211c | ||||
|       sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.14.3" | ||||
|     version: "0.14.4" | ||||
|   flutter_lints: | ||||
|     dependency: "direct dev" | ||||
|     description: | ||||
| @@ -851,6 +875,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.0.28" | ||||
|   flutter_popup_card: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: flutter_popup_card | ||||
|       sha256: "71bca1521e3bae790162183d4dbfb77b84e11a9604e4b5f3b0edad6c5a1495cd" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.0.6" | ||||
|   flutter_riverpod: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -860,7 +892,7 @@ packages: | ||||
|     source: hosted | ||||
|     version: "2.6.1" | ||||
|   flutter_svg: | ||||
|     dependency: transitive | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: flutter_svg | ||||
|       sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1 | ||||
| @@ -872,6 +904,14 @@ packages: | ||||
|     description: flutter | ||||
|     source: sdk | ||||
|     version: "0.0.0" | ||||
|   flutter_timezone: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: flutter_timezone | ||||
|       sha256: "13b2109ad75651faced4831bf262e32559e44aa549426eab8a597610d385d934" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "4.1.1" | ||||
|   flutter_udid: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -929,10 +969,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: gal | ||||
|       sha256: "1bdef5879e4569910cfd8c77f460f98fcb7a1f910026af1daa80869856c67d66" | ||||
|       sha256: "2771519c8b29f784d5e27f4efc2667667eef51c6c47cccaa0435a8fe8aa208e4" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.9.1" | ||||
|     version: "2.3.1" | ||||
|   gap: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -1110,14 +1150,13 @@ packages: | ||||
|     source: hosted | ||||
|     version: "1.0.5" | ||||
|   irondash_engine_context: | ||||
|     dependency: "direct overridden" | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       path: "engine_context/dart" | ||||
|       ref: "refs/pull/66/head" | ||||
|       resolved-ref: e2551d9a3b0272a723b3627c5ef70e01a9e26961 | ||||
|       url: "https://github.com/irondash/irondash.git" | ||||
|     source: git | ||||
|     version: "0.5.4" | ||||
|       name: irondash_engine_context | ||||
|       sha256: "2bb0bc13dfda9f5aaef8dde06ecc5feb1379f5bb387d59716d799554f3f305d7" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.5.5" | ||||
|   irondash_message_channel: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1174,6 +1213,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.0.1" | ||||
|   lean_builder: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: lean_builder | ||||
|       sha256: ac129cd2173aa4e53e1327bcee2233d738d68ee446f3c797135633deafe6ca8a | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.1.0-alpha.12" | ||||
|   lint: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1194,10 +1241,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: livekit_client | ||||
|       sha256: dc8fe8295353a6f4c96915a49faece4ede2334d2b2bdf5ed4b985e07507bdf09 | ||||
|       sha256: c270720a49b935591960c6f3296fd8f00c09b45a70cd64aef78cd0a8f8257913 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.4.7" | ||||
|     version: "2.4.8" | ||||
|   logging: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1350,6 +1397,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.0.0" | ||||
|   native_exif: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: native_exif | ||||
|       sha256: "0d37444c1ed00cbcada69b7510aba1d505fed75d3b6ef3ea3c8c2c970040e4f1" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.6.2" | ||||
|   nested: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1366,6 +1421,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.5.0" | ||||
|   node_preamble: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: node_preamble | ||||
|       sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.0.2" | ||||
|   octo_image: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1398,6 +1461,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.2.0" | ||||
|   palette_generator: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: palette_generator | ||||
|       sha256: "4420f7ccc3f0a4a906144e73f8b6267cd940b64f57a7262e95cb8cec3a8ae0ed" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.3.3+7" | ||||
|   pasteboard: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -1530,10 +1601,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: protobuf | ||||
|       sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" | ||||
|       sha256: "579fe5557eae58e3adca2e999e38f02441d8aa908703854a9e0a0f47fa857731" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.1.0" | ||||
|     version: "4.1.0" | ||||
|   provider: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1634,10 +1705,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: record_web | ||||
|       sha256: f8e536a9c927e52f95326d7540898457eaeefbe0b21a84d3cb3d2d7d4645e8cb | ||||
|       sha256: "024c81eb7f51468b1833a3eca8b461c7ca25c04899dba37abe580bb57afd32e4" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.1.7" | ||||
|     version: "1.1.8" | ||||
|   record_windows: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1814,6 +1885,22 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.4.2" | ||||
|   shelf_packages_handler: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: shelf_packages_handler | ||||
|       sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.0.2" | ||||
|   shelf_static: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: shelf_static | ||||
|       sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.1.3" | ||||
|   shelf_web_socket: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1822,6 +1909,30 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.0.0" | ||||
|   sign_in_with_apple: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: sign_in_with_apple | ||||
|       sha256: "8bd875c8e8748272749eb6d25b896f768e7e9d60988446d543fe85a37a2392b8" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "7.0.1" | ||||
|   sign_in_with_apple_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: sign_in_with_apple_platform_interface | ||||
|       sha256: "981bca52cf3bb9c3ad7ef44aace2d543e5c468bb713fd8dda4275ff76dfa6659" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.0.0" | ||||
|   sign_in_with_apple_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: sign_in_with_apple_web | ||||
|       sha256: f316400827f52cafcf50d00e1a2e8a0abc534ca1264e856a81c5f06bd5b10fed | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.0.0" | ||||
|   simple_gesture_detector: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1851,6 +1962,22 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.3.5" | ||||
|   source_map_stack_trace: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: source_map_stack_trace | ||||
|       sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.1.2" | ||||
|   source_maps: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: source_maps | ||||
|       sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.10.13" | ||||
|   source_span: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1927,10 +2054,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: sqlite3_flutter_libs | ||||
|       sha256: "7986c26234c0a5cf4fd83ff4ee39d4195b1f47cdb50a949ec7987ede4dcbdc2a" | ||||
|       sha256: e07232b998755fe795655c56d1f5426e0190c9c435e1752d39e7b1cd33699c71 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.5.33" | ||||
|     version: "0.5.34" | ||||
|   sqlparser: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1991,19 +2118,18 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: super_context_menu | ||||
|       sha256: "29f5a7090a35d3af627a80dc77d56b4a748b723461f2e5a5e592c61607b17e87" | ||||
|       sha256: "44570d342bea2381c57520f181016207c0ffde401f05f641e6dfec495fa728fe" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.9.0-dev.6" | ||||
|     version: "0.9.1" | ||||
|   super_native_extensions: | ||||
|     dependency: "direct overridden" | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       path: super_native_extensions | ||||
|       ref: "refs/pull/525/head" | ||||
|       resolved-ref: d3020a8c5acd8555707b3b6477fd744d09f3e22f | ||||
|       url: "https://github.com/superlistapp/super_native_extensions.git" | ||||
|     source: git | ||||
|     version: "0.9.0-dev.6" | ||||
|       name: super_native_extensions | ||||
|       sha256: b9611dcb68f1047d6f3ef11af25e4e68a21b1a705bbcc3eb8cb4e9f5c3148569 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.9.1" | ||||
|   super_sliver_list: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -2044,6 +2170,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.2.2" | ||||
|   test: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: test | ||||
|       sha256: "301b213cd241ca982e9ba50266bd3f5bd1ea33f1455554c5abb85d1be0e2d87e" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.25.15" | ||||
|   test_api: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -2052,6 +2186,22 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.7.4" | ||||
|   test_core: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: test_core | ||||
|       sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.6.8" | ||||
|   timezone: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: timezone | ||||
|       sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.10.1" | ||||
|   timing: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -2185,10 +2335,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: vector_graphics | ||||
|       sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de" | ||||
|       sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.1.18" | ||||
|     version: "1.1.19" | ||||
|   vector_graphics_codec: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -2265,10 +2415,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: watcher | ||||
|       sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" | ||||
|       sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.1.1" | ||||
|     version: "1.1.2" | ||||
|   web: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -2293,6 +2443,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.0.3" | ||||
|   webkit_inspection_protocol: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: webkit_inspection_protocol | ||||
|       sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.2.1" | ||||
|   webrtc_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -2305,10 +2463,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: win32 | ||||
|       sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" | ||||
|       sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "5.13.0" | ||||
|     version: "5.14.0" | ||||
|   win32_registry: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -2333,6 +2491,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "6.5.0" | ||||
|   xxh3: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: xxh3 | ||||
|       sha256: "399a0438f5d426785723c99da6b16e136f4953fb1e9db0bf270bd41dd4619916" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.2.0" | ||||
|   yaml: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -2343,4 +2509,4 @@ packages: | ||||
|     version: "3.1.3" | ||||
| sdks: | ||||
|   dart: ">=3.8.0 <4.0.0" | ||||
|   flutter: ">=3.27.0" | ||||
|   flutter: ">=3.27.4" | ||||
|   | ||||
							
								
								
									
										31
									
								
								pubspec.yaml
									
									
									
									
									
								
							
							
						
						
									
										31
									
								
								pubspec.yaml
									
									
									
									
									
								
							| @@ -98,7 +98,7 @@ dependencies: | ||||
|   visibility_detector: ^0.4.0+2 | ||||
|   flutter_native_splash: ^2.4.6 | ||||
|   photo_view: ^0.15.0 | ||||
|   gal: ^1.9.1 | ||||
|   gal: ^2.3.1 | ||||
|   dismissible_page: ^1.0.2 | ||||
|   super_sliver_list: ^0.4.1 | ||||
|   flutter_webrtc: ^0.14.1 | ||||
| @@ -108,6 +108,14 @@ dependencies: | ||||
|   record: ^6.0.0 | ||||
|   qr_flutter: ^4.1.0 | ||||
|   flutter_otp_text_field: ^1.5.1+1 | ||||
|   palette_generator: ^0.3.3+7 | ||||
|   flutter_popup_card: ^0.0.6 | ||||
|   timezone: ^0.10.1 | ||||
|   flutter_timezone: ^4.1.1 | ||||
|   fl_chart: ^1.0.0 | ||||
|   sign_in_with_apple: ^7.0.1 | ||||
|   flutter_svg: ^2.1.0 | ||||
|   native_exif: ^0.6.2 | ||||
|  | ||||
| dev_dependencies: | ||||
|   flutter_test: | ||||
| @@ -129,24 +137,6 @@ dev_dependencies: | ||||
|   drift_dev: ^2.26.0 | ||||
|   flutter_launcher_icons: ^0.14.3 | ||||
|  | ||||
| dependency_overrides: | ||||
|   # https://github.com/dart-lang/sdk/issues/60784#issuecomment-2904784415 | ||||
|   analyzer: 7.3.0 | ||||
|   analyzer_plugin: 0.12.0 | ||||
|   # https://github.com/dart-lang/sdk/issues/60784#issuecomment-2906872272 | ||||
|   custom_lint_visitor: 1.0.0+7.3.0 | ||||
|   # https://github.com/superlistapp/super_native_extensions/issues/524#issuecomment-2918531753 | ||||
|   super_native_extensions: | ||||
|     git: | ||||
|       url: https://github.com/superlistapp/super_native_extensions.git | ||||
|       ref: refs/pull/525/head | ||||
|       path: super_native_extensions | ||||
|   irondash_engine_context: | ||||
|     git: | ||||
|       url: https://github.com/irondash/irondash.git | ||||
|       ref: refs/pull/66/head | ||||
|       path: engine_context/dart | ||||
|  | ||||
| # For information on the generic Dart part of this file, see the | ||||
| # following page: https://dart.dev/tools/pub/pubspec | ||||
|  | ||||
| @@ -160,6 +150,9 @@ flutter: | ||||
|   # To add assets to your application, add an assets section, like this: | ||||
|   assets: | ||||
|     - assets/i18n/ | ||||
|     - assets/images/ | ||||
|     - assets/images/oidc/ | ||||
|     - assets/icons/ | ||||
|  | ||||
|   # An image asset can refer to one or more resolution-specific "variants", see | ||||
|   # https://flutter.dev/to/resolution-aware-images | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user