Compare commits
	
		
			147 Commits
		
	
	
		
			2.3.2+75
			...
			a1c4e5eca0
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| a1c4e5eca0 | |||
| 595050f89f | |||
| 0722c99f21 | |||
| 12d03836f9 | |||
|  | f78d3f4fd5 | ||
|  | e798a8ba76 | ||
| c28a664373 | |||
| 4589722c3b | |||
| 38e1c51b45 | |||
| 610ddec05c | |||
| d0276f9ac6 | |||
| c1e89a2ee6 | |||
| ecc79368a1 | |||
| e6d732c86a | |||
| dd055fb077 | |||
| 280840c6d8 | |||
| bde62a7b2c | |||
| 5445c570a2 | |||
| b2302f5b3c | |||
| d7359cfd0d | |||
| 9cc577adbe | |||
| dd196b7754 | |||
| 16c07c2133 | |||
| 6bcb658d44 | |||
| 9311bfc3b5 | |||
| 8dd6435a30 | |||
| 21a1d4a2ad | |||
| 603875b1af | |||
| 4209a13c84 | |||
| 55b79bfd8f | |||
| 6e6c3f42f6 | |||
| dc38b46b2c | |||
| b4990308e9 | |||
| 237abe564d | |||
| 71b41d470a | |||
| 7052b5b635 | |||
| f356e08f79 | |||
| 152872db65 | |||
| dfe117d04f | |||
| caf63f0cbe | |||
| b8f5cc82f9 | |||
| 360bc50f21 | |||
| 2de93a0486 | |||
| 02227852f8 | |||
| ad16de595b | |||
| 9f8c8923d9 | |||
| 060bfa4887 | |||
| e68ada2d04 | |||
| d6013078bd | |||
| 5976d61997 | |||
| b492db90ca | |||
| c9f69fed2c | |||
| d2f4e7a969 | |||
| aecd04e0b9 | |||
| e5212419ae | |||
| ec7650a920 | |||
| 7b96013406 | |||
| fc5a79b29b | |||
| 4146820be5 | |||
| 9ec0f1ff19 | |||
| ac2aec48aa | |||
| 58421e5d5e | |||
| 172d0d24fb | |||
| 71899dd4f2 | |||
| 02ffe9866d | |||
| 1b7e668b3f | |||
| f03d80ba88 | |||
| 14ee6845ed | |||
| 8fe6c2be46 | |||
| 78e765f69d | |||
| ddd6ff7eee | |||
| b8f379796f | |||
| 3a10e9280c | |||
| 65fe06de22 | |||
| e44320e0fe | |||
| f2d913ffec | |||
| e88dea8858 | |||
| 813679b161 | |||
| 9d4ce6ca8c | |||
| 88396647f3 | |||
| 335318ae3f | |||
| da25fb9c29 | |||
| c1aef89b84 | |||
| 0241c5f804 | |||
| f6939d7c23 | |||
| d654c162e3 | |||
| 25550ba197 | |||
| 3defd3a593 | |||
| d62ed4c375 | |||
| 857f3cc832 | |||
| e16bc80eea | |||
| a4f6e8af56 | |||
| 060a97f5ec | |||
| 92f7e92018 | |||
| 5c483bd3b8 | |||
| 1c510d63fe | |||
| 115cb4adc1 | |||
| 54c098c274 | |||
| 29731728cd | |||
| 9e8882c580 | |||
| 6042e57e7a | |||
| 6235e736b9 | |||
| e075804782 | |||
| d40a6ca1c4 | |||
| 5ac657e526 | |||
| 97ddc18b8e | |||
| b835c8edea | |||
| 288c0399f9 | |||
| 1478933cf1 | |||
| 93c6fa6e53 | |||
| ce6e9c185a | |||
| cdaa8cfe58 | |||
| 76d8cd943d | |||
| d6f3ffc655 | |||
| 5a6b841253 | |||
| cb2de52bee | |||
| 64e2644745 | |||
| 56711889ab | |||
| 4f47cd2c0c | |||
| 2b61c372f5 | |||
| 73777fe74e | |||
| 33a4bd7e71 | |||
| 17e6b81f76 | |||
| 22fde6b400 | |||
| 6e03a00280 | |||
| 72e6a6a1f6 | |||
| 66aef44281 | |||
| 7bb73c80b0 | |||
| d043ef2410 | |||
| 1d0e2f7591 | |||
| e9ef28d764 | |||
| 289aa17a7a | |||
| 93f41bb523 | |||
| 09ec9d4a0c | |||
| 1153fbdeee | |||
| e933058338 | |||
| ae9743c84f | |||
| 32bf834108 | |||
| 1b41c847a6 | |||
| b1af6c2c97 | |||
| 8e76ff3f84 | |||
| bd26602299 | |||
| 52ab1d0d10 | |||
| f746e06f65 | |||
| d11069a2be | |||
| d6dc487d9e | |||
| a07c7cdede | 
							
								
								
									
										10
									
								
								.github/workflows/nightly.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/workflows/nightly.yml
									
									
									
									
										vendored
									
									
								
							| @@ -52,10 +52,12 @@ jobs: | |||||||
|       - run: | |       - run: | | ||||||
|           sudo apt-get update -y |           sudo apt-get update -y | ||||||
|           sudo apt-get install -y ninja-build libgtk-3-dev |           sudo apt-get install -y ninja-build libgtk-3-dev | ||||||
|           sudo apt-get install libmpv-dev mpv |           sudo apt-get install -y libmpv-dev mpv | ||||||
|           sudo apt-get install libayatana-appindicator3-dev |           sudo apt-get install -y libayatana-appindicator3-dev | ||||||
|           sudo apt-get install keybinder-3.0 |           sudo apt-get install -y keybinder-3.0 | ||||||
|           sudo apt-get install libnotify-dev |           sudo apt-get install -y libnotify-dev | ||||||
|  |           sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev | ||||||
|  |           sudo apt-get install -y gstreamer-1.0 | ||||||
|       - run: flutter pub get |       - run: flutter pub get | ||||||
|       - run: flutter build linux |       - run: flutter build linux | ||||||
|       - name: Archive production artifacts |       - name: Archive production artifacts | ||||||
|   | |||||||
							
								
								
									
										11
									
								
								api/Interactive/Trigger Fediverse Scan.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								api/Interactive/Trigger Fediverse Scan.bru
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | meta { | ||||||
|  |   name: Trigger Fediverse Scan | ||||||
|  |   type: http | ||||||
|  |   seq: 1 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | post { | ||||||
|  |   url: {{endpoint}}/cgi/co/admin/fediverse | ||||||
|  |   body: none | ||||||
|  |   auth: inherit | ||||||
|  | } | ||||||
							
								
								
									
										11
									
								
								api/Nexus/Check Status.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								api/Nexus/Check Status.bru
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | meta { | ||||||
|  |   name: Check Status | ||||||
|  |   type: http | ||||||
|  |   seq: 1 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | get { | ||||||
|  |   url: {{endpoint}}/directory/status | ||||||
|  |   body: none | ||||||
|  |   auth: none | ||||||
|  | } | ||||||
							
								
								
									
										11
									
								
								api/Nexus/List Services.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								api/Nexus/List Services.bru
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | meta { | ||||||
|  |   name: List Services | ||||||
|  |   type: http | ||||||
|  |   seq: 2 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | get { | ||||||
|  |   url: {{endpoint}}/directory/services | ||||||
|  |   body: none | ||||||
|  |   auth: none | ||||||
|  | } | ||||||
							
								
								
									
										18
									
								
								api/Passport/Deal Abuse Report.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								api/Passport/Deal Abuse Report.bru
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | meta { | ||||||
|  |   name: Deal Abuse Report | ||||||
|  |   type: http | ||||||
|  |   seq: 3 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | put { | ||||||
|  |   url: {{endpoint}}/cgi/id/reports/abuse/6/status | ||||||
|  |   body: json | ||||||
|  |   auth: inherit | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body:json { | ||||||
|  |   { | ||||||
|  |     "status": "rejected", | ||||||
|  |     "message": "Not a good reason" | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -15,12 +15,10 @@ body:json { | |||||||
|     "client_id": "{{third_client_id}}", |     "client_id": "{{third_client_id}}", | ||||||
|     "client_secret":"{{third_client_tk}}", |     "client_secret":"{{third_client_tk}}", | ||||||
|     "type": "general", |     "type": "general", | ||||||
|     "subject": "新年快乐!", |     "subject": "关于迁移服务器完成的提示", | ||||||
|     "subtitle": "一条来自 Solar Network 团队的信息", |     "subtitle": "一条来自 Solar Network 团队的运营信息", | ||||||
|     "content": "今天是农历正月初一,小羊祝您新年快乐 🎉", |     "content": "我们已经将所有用户数据迁移到新版服务器,刚刚发布新的 DNS,因为部分 DNS 缓存的影响。可能更改不会生效,可以使用 nslookup / ping 检查解析地址是否未 8. 开头,您可以主动刷新 DNS。谢谢!", | ||||||
|     "metadata": { |     "metadata": {}, | ||||||
|       "image": "D2EDbcrsTugs3xk5" |  | ||||||
|     }, |  | ||||||
|     "priority": 10 |     "priority": 10 | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -7,5 +7,5 @@ meta { | |||||||
| get { | get { | ||||||
|   url: {{endpoint}}/cgi/re/well-known/sources |   url: {{endpoint}}/cgi/re/well-known/sources | ||||||
|   body: none |   body: none | ||||||
|   auth: none |   auth: inherit | ||||||
| } | } | ||||||
|   | |||||||
| @@ -12,7 +12,7 @@ post { | |||||||
|  |  | ||||||
| body:json { | body:json { | ||||||
|   { |   { | ||||||
|     "sources": ["taiwan-ltn"], |     "sources": ["taiwan-pts"], | ||||||
|     "eager": true |     "eager": true | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										
											BIN
										
									
								
								assets/audio/notify/metal-pipe.mp3
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/audio/notify/metal-pipe.mp3
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								assets/audio/sfx/launch-intro.mp3
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/audio/sfx/launch-intro.mp3
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Bold.ttf
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Bold.ttf
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Italic.ttf
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Italic.ttf
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Regular.ttf
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Regular.ttf
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								assets/icon/kanban-1st.jpg
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/icon/kanban-1st.jpg
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 509 KiB | 
| @@ -130,7 +130,7 @@ | |||||||
|   "accountPublishersSubtitle": "Manage your publish identities.", |   "accountPublishersSubtitle": "Manage your publish identities.", | ||||||
|   "accountSettings": "Account Settings", |   "accountSettings": "Account Settings", | ||||||
|   "accountSettingsSubtitle": "Manage your account and make it yours.", |   "accountSettingsSubtitle": "Manage your account and make it yours.", | ||||||
|   "accountProfileEdit": "Edit your profile", |   "accountProfileEdit": "Edit Profile", | ||||||
|   "accountProfileEditSubtitle": "Make your Solarpass account more looks like you.", |   "accountProfileEditSubtitle": "Make your Solarpass account more looks like you.", | ||||||
|   "accountWallet": "Wallet", |   "accountWallet": "Wallet", | ||||||
|   "accountWalletSubtitle": "View your balance and transactions.", |   "accountWalletSubtitle": "View your balance and transactions.", | ||||||
| @@ -153,6 +153,11 @@ | |||||||
|   "publisherRunBy": "Run by {}", |   "publisherRunBy": "Run by {}", | ||||||
|   "fieldPublisherBelongToRealm": "Belongs to", |   "fieldPublisherBelongToRealm": "Belongs to", | ||||||
|   "fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm", |   "fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm", | ||||||
|  |   "writePost": "Compose", | ||||||
|  |   "postTypeStory": "Story", | ||||||
|  |   "postTypeArticle": "Article", | ||||||
|  |   "postTypeQuestion": "Question", | ||||||
|  |   "postTypeVideo": "Video", | ||||||
|   "writePostTypeStory": "Post a story", |   "writePostTypeStory": "Post a story", | ||||||
|   "writePostTypeArticle": "Write an article", |   "writePostTypeArticle": "Write an article", | ||||||
|   "writePostTypeQuestion": "Ask a question", |   "writePostTypeQuestion": "Ask a question", | ||||||
| @@ -202,7 +207,13 @@ | |||||||
|     "one": "{} comment", |     "one": "{} comment", | ||||||
|     "other": "{} comments" |     "other": "{} comments" | ||||||
|   }, |   }, | ||||||
|  |   "postCommentExpand": "Show comments", | ||||||
|   "settingsAppearance": "Appearance", |   "settingsAppearance": "Appearance", | ||||||
|  |   "settingsCustomFonts": "Custom Fonts", | ||||||
|  |   "settingsCustomFontsDescription": "Set custom fonts for the application.", | ||||||
|  |   "settingsCustomFontFamily": "Custom Font Family", | ||||||
|  |   "settingsCustomFontFamilyHint": "Use comma to separate fonts, higher priority comes first", | ||||||
|  |   "settingsCustomFontApplied": "Custom font has been applied.", | ||||||
|   "settingsDisplayLanguage": "Display Language", |   "settingsDisplayLanguage": "Display Language", | ||||||
|   "settingsDisplayLanguageDescription": "Set the application language.", |   "settingsDisplayLanguageDescription": "Set the application language.", | ||||||
|   "settingsDisplayLanguageSystem": "Follow System", |   "settingsDisplayLanguageSystem": "Follow System", | ||||||
| @@ -327,6 +338,7 @@ | |||||||
|   "fieldAttachmentRandomId": "Random ID", |   "fieldAttachmentRandomId": "Random ID", | ||||||
|   "fieldAttachmentAlt": "Alternative text", |   "fieldAttachmentAlt": "Alternative text", | ||||||
|   "addAttachmentFromAlbum": "Add from album", |   "addAttachmentFromAlbum": "Add from album", | ||||||
|  |   "addAttachmentFromFiles": "Add from files", | ||||||
|   "addAttachmentFromClipboard": "Paste file", |   "addAttachmentFromClipboard": "Paste file", | ||||||
|   "addAttachmentFromCameraPhoto": "Take photo", |   "addAttachmentFromCameraPhoto": "Take photo", | ||||||
|   "addAttachmentFromCameraVideo": "Take video", |   "addAttachmentFromCameraVideo": "Take video", | ||||||
| @@ -512,8 +524,13 @@ | |||||||
|   "accountBirthday": "Born on {}", |   "accountBirthday": "Born on {}", | ||||||
|   "accountBadge": "Badge", |   "accountBadge": "Badge", | ||||||
|   "accountCheckInNoRecords": "No check-in records", |   "accountCheckInNoRecords": "No check-in records", | ||||||
|   "badgeCompanyStaff": "Solsynth Staff", |   "badgeCompanyStaff": "Staff", | ||||||
|   "badgeSiteMigration": "Solar Network Native", |   "badgeSiteMigration": "Solar Network Native", | ||||||
|  |   "badgeCommunitySurvey": "Survey Participant", | ||||||
|  |   "badgeCommunityVerified": "Verified User", | ||||||
|  |   "badgeCommunityContributor": "Great Contributor", | ||||||
|  |   "badgeSiteAnniversary": "Anniversary", | ||||||
|  |   "badgeUserBirthday": "Birthday", | ||||||
|   "accountStatus": "Status", |   "accountStatus": "Status", | ||||||
|   "accountStatusOnline": "Online", |   "accountStatusOnline": "Online", | ||||||
|   "accountStatusOffline": "Offline", |   "accountStatusOffline": "Offline", | ||||||
| @@ -622,6 +639,7 @@ | |||||||
|   "postQuestionUnansweredWithReward": "Unanswered Question, reward source points {}", |   "postQuestionUnansweredWithReward": "Unanswered Question, reward source points {}", | ||||||
|   "postQuestionAnswered": "Answered Question", |   "postQuestionAnswered": "Answered Question", | ||||||
|   "postQuestionAnswerSelect": "Select as Answer", |   "postQuestionAnswerSelect": "Select as Answer", | ||||||
|  |   "postQuestionAnswerTitle": "Selected Question", | ||||||
|   "postQuestionAnswerSelected": "Answer has been selected, reward has been applied.", |   "postQuestionAnswerSelected": "Answer has been selected, reward has been applied.", | ||||||
|   "postVideoUpload": "Upload Video", |   "postVideoUpload": "Upload Video", | ||||||
|   "realmJoin": "Join Realm", |   "realmJoin": "Join Realm", | ||||||
| @@ -722,5 +740,204 @@ | |||||||
|   "trayMenuMuteNotification": "Do Not Disturb", |   "trayMenuMuteNotification": "Do Not Disturb", | ||||||
|   "update": "Update", |   "update": "Update", | ||||||
|   "forceUpdate": "Force Update", |   "forceUpdate": "Force Update", | ||||||
|   "forceUpdateDescription": "Force to show the application update popup, even the new version is not available." |   "forceUpdateDescription": "Force to show the application update popup, even the new version is not available.", | ||||||
|  |   "debugLogging": "Runtime Logs", | ||||||
|  |   "runtimeLogsOpen": "Open Logs", | ||||||
|  |   "runtimeLogsDescription": "Show the runtime logs to help debugging.", | ||||||
|  |   "signinResetPasswordHint": "Please enter the username / email address to help us to find your account and reset your password.", | ||||||
|  |   "cacheSize": "Cache Size", | ||||||
|  |   "cacheDelete": "Clean Cache", | ||||||
|  |   "cacheDeleteDescription": "Remove the cached images and other resources from your disk, the content will be downloaded from server again.", | ||||||
|  |   "cacheDeleted": "All cache has been cleaned up.", | ||||||
|  |   "userNoDescription": "No description.", | ||||||
|  |   "fieldTimeZone": "Time Zone", | ||||||
|  |   "fieldGender": "Gender", | ||||||
|  |   "fieldPronouns": "Pronouns", | ||||||
|  |   "fieldLocation": "Location", | ||||||
|  |   "fieldLinks": "Links", | ||||||
|  |   "fieldLinkName": "Name", | ||||||
|  |   "fieldLinkUrl": "URL", | ||||||
|  |   "screenAccountBadges": "Badges", | ||||||
|  |   "accountBadges": "Badges", | ||||||
|  |   "accountBadgesDescription": "View and manage your badges.", | ||||||
|  |   "badgeActivated": "Activated badge {}.", | ||||||
|  |   "viewDetailedAttachment": "Details", | ||||||
|  |   "screenKeyPairs": "Key Pairs", | ||||||
|  |   "accountKeyPairs": "Key Pairs", | ||||||
|  |   "accountKeyPairsDescription": "Manage the key pairs which used to encrypt messages.", | ||||||
|  |   "enrollNewKeyPair": "Enroll New One", | ||||||
|  |   "enrollNewKeyPairDescription": "Generate a new key pair.", | ||||||
|  |   "keyPairHasPrivateKey": "With private key", | ||||||
|  |   "decrypting": "Decrypting……", | ||||||
|  |   "decryptingKeyNotFound": "Key not found or exchange failed, the other party may not be online", | ||||||
|  |   "messageUnablePreview": "Unable preview", | ||||||
|  |   "messageUnablePreviewEncrypted": "Unable preview encrypted message", | ||||||
|  |   "postViewInGlobalDescription": "Do not view the post in the specific realm.", | ||||||
|  |   "postDraftSaved": "The draft has been saved.", | ||||||
|  |   "postDraftBox": "Draft Box", | ||||||
|  |   "postShuffle": "Read Randomly", | ||||||
|  |   "checkInStreak": { | ||||||
|  |     "zero": "No streak", | ||||||
|  |     "one": "{} day streak", | ||||||
|  |     "other": "{} days streak" | ||||||
|  |   }, | ||||||
|  |   "accountChangeStatus": "Change Status", | ||||||
|  |   "accountStatusSilent": "Do not Disturb", | ||||||
|  |   "accountStatusSilentDesc": "The notification will stop popping up", | ||||||
|  |   "accountStatusInvisible": "Invisible", | ||||||
|  |   "accountStatusInvisibleDesc": "Will show as offline, but all features still remain normal", | ||||||
|  |   "accountCustomStatus": "Custom Status", | ||||||
|  |   "accountCustomStatusDescription": "Customize your status.", | ||||||
|  |   "accountClearStatus": "Clear Status", | ||||||
|  |   "accountClearStatusDescription": "Clear your status, and let server decide which status you are for you.", | ||||||
|  |   "fieldAccountStatusLabel": "Status Text", | ||||||
|  |   "fieldAccountStatusClearAt": "Clear At", | ||||||
|  |   "accountStatusNegative": "Negative", | ||||||
|  |   "accountStatusNeutral": "Neutral", | ||||||
|  |   "accountStatusPositive": "Positive", | ||||||
|  |   "mixedFeed": "Mixed Feed", | ||||||
|  |   "mixedFeedDescription": "The Explore screen may not only display the user's posts, but may also contain other content. However, this mode does not apply to classification and filtering.", | ||||||
|  |   "filterFeed": "Exploring Adjust", | ||||||
|  |   "feedUnknownItem": "Unable to display this content, the current version of the client does not support the type of content, please try to update the application afterwards.", | ||||||
|  |   "serviceStatusOperational": "All services operational", | ||||||
|  |   "serviceStatusDowngraded": "Some services downgraded", | ||||||
|  |   "serviceStatusFailed": "All services unavailable", | ||||||
|  |   "serviceStatusFailedDescription": "The server is down or the maintenance is just finished.", | ||||||
|  |   "serviceNameInsights": "Summarize and Insights", | ||||||
|  |   "serviceNameInteractive": "Posts, Reactions and Explore", | ||||||
|  |   "serviceNameReader": "News and Link Previews", | ||||||
|  |   "serviceNameMessaging": "Chat", | ||||||
|  |   "serviceNameMatrix": "Matrix Software and Game Marketplace", | ||||||
|  |   "serviceNamePaperclip": "Attachments, Images and Files", | ||||||
|  |   "serviceNameWallet": "Source Points Wallet", | ||||||
|  |   "serviceNamePassport": "Authorization and Authentication", | ||||||
|  |   "accountActionEvent": "Action Events", | ||||||
|  |   "accountActionEventDescription": "View your action event logs.", | ||||||
|  |   "eventMetadata": "Metadata", | ||||||
|  |   "accountAuthTickets": "Auth Sessions", | ||||||
|  |   "accountAuthTicketsDescription": "View and manage your auth sessions.", | ||||||
|  |   "authTicketCreatedAt": "Issued at {}", | ||||||
|  |   "authTicketExpiredAt": "Expired at {}", | ||||||
|  |   "authTicketLastGrantAt": "Last granted at {}", | ||||||
|  |   "authTicketCurrent": "Current", | ||||||
|  |   "accountUnconfirmedTitle": "Unconfirmed Account", | ||||||
|  |   "accountUnconfirmedSubtitle": "Your account is unconfirmed, which will make most features unavailable and your account will be destroyed in 24 hours. You should receive an email in your inbox with a confirmation link.", | ||||||
|  |   "accountUnconfirmedUnreceived": "Didn't receive the email?", | ||||||
|  |   "accountUnconfirmedResend": "Resend one", | ||||||
|  |   "accountUnconfirmedResendSuccessful": "Email has been resent, you can resend it again in 60 minutes.", | ||||||
|  |   "stickerPickerEmpty": "Sticker list is empty", | ||||||
|  |   "stickerPickerEmptyHint": "To start using stickers, you need to add a sticker pack first.", | ||||||
|  |   "goto": "Go to {}", | ||||||
|  |   "accountContactMethods": "Contact Methods", | ||||||
|  |   "accountContactMethodsDescription": "Manage your contact methods.", | ||||||
|  |   "accountContactMethodsNameEmail": "Email address", | ||||||
|  |   "accountContactMethodsNamePhone": "Phone number", | ||||||
|  |   "accountContactMethodsNameAddress": "Address", | ||||||
|  |   "accountContactMethodsPrimary": "Primary", | ||||||
|  |   "accountContactMethodsVerified": "Verified", | ||||||
|  |   "accountContactMethodsPublic": "Public", | ||||||
|  |   "accountContactMethodsAdd": "Add Contact Method", | ||||||
|  |   "accountContactMethodsEdit": "Edit Contact Method", | ||||||
|  |   "accountContactMethodsAddDescription": "Add a new contact method.", | ||||||
|  |   "fieldContactContent": "Contact method", | ||||||
|  |   "accountContactMethodsPublicHint": "This contact method will be displayed publicly on your profile.", | ||||||
|  |   "accountContactMethodsDelete": "Delete Contact Method", | ||||||
|  |   "accountContactMethodsDeleteDescription": "Are you sure you want to delete contact method {}? This operation is irreversible.", | ||||||
|  |   "postCommentAdd": "Write a comment", | ||||||
|  |   "translate": "Translate", | ||||||
|  |   "translating": "Translating…", | ||||||
|  |   "translated": "Translated", | ||||||
|  |   "settingsAutoTranslate": "Auto Translate", | ||||||
|  |   "settingsAutoTranslateDescription": "Automatically translate text when viewing posts and messages.", | ||||||
|  |   "trayMenuHide": "Hide", | ||||||
|  |   "accountSettingsNotify": "Notify Settings", | ||||||
|  |   "accountSettingsNotifyDescription": "Adjust the types of notifications you receive.", | ||||||
|  |   "accountSettingsSecurity": "Security Settings", | ||||||
|  |   "accountSettingsSecurityDescription": "Adjust your account security settings.", | ||||||
|  |   "save": "Save", | ||||||
|  |   "notificationTopicPostFeedback": "Post Feedback", | ||||||
|  |   "notificationTopicPostReply": "Post Replies", | ||||||
|  |   "notificationTopicPostSubscription": "Post Subscriptions", | ||||||
|  |   "notificationTopicMessaging": "New Messages", | ||||||
|  |   "notificationTopicMessagingCall": "Incoming Calls", | ||||||
|  |   "notificationTopicGeneral": "General", | ||||||
|  |   "authMaximumAuthSteps": "Maximum Authenticate Steps", | ||||||
|  |   "authMaximumAuthStepsDescription": { | ||||||
|  |     "one": "Maximum ask for {} step authenticate", | ||||||
|  |     "other": "Maximum ask for {} steps authenticate" | ||||||
|  |   }, | ||||||
|  |   "authAlwaysRisky": "Always Risky", | ||||||
|  |   "authAlwaysRiskyDescription": "Always ask for the highest steps count of authentication when logging in.", | ||||||
|  |   "chatUnjoined": "Unjoined Channel", | ||||||
|  |   "chatUnjoinedDescription": "You haven't joined this channel, so you can't send messages either view messages in it.", | ||||||
|  |   "chatUnjoinedPublicDescription": "Fortunately, this is a public channel, so you can join it as you want.", | ||||||
|  |   "chatJoin": "Join the Channel", | ||||||
|  |   "appInitStarting": "Starting", | ||||||
|  |   "appInitNetwork": "Initializing Network", | ||||||
|  |   "appInitUserdata": "Initializing User Data", | ||||||
|  |   "appInitWebsocket": "Establishing Solar Link", | ||||||
|  |   "appInitNotification": "Initializing Push Notifications",  | ||||||
|  |   "appInitKeyPair": "Initializing Key Pairs", | ||||||
|  |   "appInitStickers": "Initializing Stickers", | ||||||
|  |   "appInitUserDirectory": "Initializing User Directory", | ||||||
|  |   "appInitRealm": "Initializing Realms", | ||||||
|  |   "appInitChat": "Initializing Chat", | ||||||
|  |   "appInitDone": "Completed", | ||||||
|  |   "community": "Community", | ||||||
|  |   "realmCommunity": "{}'s Community", | ||||||
|  |   "postTotalCount": { | ||||||
|  |     "one": "Total {} post", | ||||||
|  |     "other": "Total {} posts" | ||||||
|  |   }, | ||||||
|  |   "settingsHideBottomNav": "Hide Bottom Navigation", | ||||||
|  |   "settingsHideBottomNavDescription": "Hide the bottom navigation bar, and show the navigation buttons in the drawer.", | ||||||
|  |   "reCaptcha": "reCaptcha", | ||||||
|  |   "friends": "Friends", | ||||||
|  |   "friendsDescription": "Manage your friendships.", | ||||||
|  |   "album": "Album", | ||||||
|  |   "albumDescription": "View albums and manage attachments.", | ||||||
|  |   "stickers": "Stickers", | ||||||
|  |   "stickersDescription": "View sticker packs and manage stickers.", | ||||||
|  |   "navBottomUnauthorizedCaption": "Or create an account", | ||||||
|  |   "walletCurrencyGoldenShort": "GDP", | ||||||
|  |   "walletCurrencyGolden": { | ||||||
|  |     "one": "{} Golden Point", | ||||||
|  |     "other": "{} Golden Points" | ||||||
|  |   }, | ||||||
|  |   "walletTransactionTypeNormal": "Source Point", | ||||||
|  |   "walletTransactionTypeGolden": "Golden Point", | ||||||
|  |   "accountProgram": "Programs", | ||||||
|  |   "accountProgramDescription": "Explore the available member programs.", | ||||||
|  |   "accountProgramJoin": "Join Program", | ||||||
|  |   "accountProgramJoinRequirements": "Requirements", | ||||||
|  |   "accountProgramJoinPricing": "Pricing", | ||||||
|  |   "accountProgramJoinPricingHint": "Billed every (30 days) month.", | ||||||
|  |   "accountProgramLeaveHint": "After leaving the program, the source points will not be refunded.", | ||||||
|  |   "accountProgramJoined": "Joined Program.", | ||||||
|  |   "accountProgramAlreadyJoined": "Joined", | ||||||
|  |   "accountProgramLeft": "Left Program.", | ||||||
|  |   "leave": "Leave", | ||||||
|  |   "attachmentFailedToLoadMedia": "Unable to load media file, please try again later. If this error occurs repeatedly, the source file may not exist or the network connection may be abnormal.", | ||||||
|  |   "accountPunishments": "Punishments", | ||||||
|  |   "accountPunishmentsDescription": "View your account's reputation status.", | ||||||
|  |   "punishmentType0": "Strike", | ||||||
|  |   "punishmentType1": "Limited", | ||||||
|  |   "punishmentType2": "Banned", | ||||||
|  |   "punishmentOverall": "Overall Status", | ||||||
|  |   "punishmentStatusNormal": "All abilities normal", | ||||||
|  |   "punishmentStatusWarned": "All abilities normal, but at least one strike is in effect", | ||||||
|  |   "punishmentStatusLimited": "Some abilities limited, at least one limited punishment is in effect", | ||||||
|  |   "punishmentStatusLimitedFully": "All abilities limited, at least one completely limited punishment is in effect", | ||||||
|  |   "punishmentStatusBanned": "All services are terminated, banned", | ||||||
|  |   "punishmentCreatedAt": "Applied since {}", | ||||||
|  |   "punishmentExpiredAt": "Expired at {}", | ||||||
|  |   "punishmentExpiredNever": "Never expired", | ||||||
|  |   "punishmentModerator": "Moderator who made this punishment", | ||||||
|  |   "punishmentMadeBySystem": "Made by auto-mod system", | ||||||
|  |   "settingsAprilFoolFeatures": "April Fool Features", | ||||||
|  |   "settingsAprilFoolFeaturesDescription": "Enable April Fool features during April Fool, this option will only be visible during April Fool.", | ||||||
|  |   "settingsSoundEffects": "Sound Effects", | ||||||
|  |   "settingsSoundEffectsDescription": "Enable the sound effects around the app.", | ||||||
|  |   "settingsResetMemorizedWindowSize": "Reset Window Size", | ||||||
|  |   "settingsResetMemorizedWindowSizeDescription": "Reset the memorized window size, and set it to the default size." | ||||||
| } | } | ||||||
|   | |||||||
| @@ -137,6 +137,11 @@ | |||||||
|   "publisherRunBy": "由 {} 管理", |   "publisherRunBy": "由 {} 管理", | ||||||
|   "fieldPublisherBelongToRealm": "所属领域", |   "fieldPublisherBelongToRealm": "所属领域", | ||||||
|   "fieldPublisherBelongToRealmUnset": "未设置发布者所属领域", |   "fieldPublisherBelongToRealmUnset": "未设置发布者所属领域", | ||||||
|  |   "writePost": "撰写", | ||||||
|  |   "postTypeStory": "动态", | ||||||
|  |   "postTypeArticle": "文章", | ||||||
|  |   "postTypeQuestion": "问题", | ||||||
|  |   "postTypeVideo": "视频", | ||||||
|   "writePostTypeStory": "发动态", |   "writePostTypeStory": "发动态", | ||||||
|   "writePostTypeArticle": "写文章", |   "writePostTypeArticle": "写文章", | ||||||
|   "writePostTypeQuestion": "提问题", |   "writePostTypeQuestion": "提问题", | ||||||
| @@ -200,7 +205,13 @@ | |||||||
|     "one": "{} 条评论", |     "one": "{} 条评论", | ||||||
|     "other": "{} 条评论" |     "other": "{} 条评论" | ||||||
|   }, |   }, | ||||||
|  |   "postCommentExpand": "展开评论", | ||||||
|   "settingsAppearance": "外观", |   "settingsAppearance": "外观", | ||||||
|  |   "settingsCustomFonts": "自定义字体", | ||||||
|  |   "settingsCustomFontsDescription": "设置应用程序使用的字体。", | ||||||
|  |   "settingsCustomFontFamily": "应用字体", | ||||||
|  |   "settingsCustomFontFamilyHint": "使用英文逗号分割每一种字体,越前优先级越高", | ||||||
|  |   "settingsCustomFontApplied": "自定义字体已经应用。", | ||||||
|   "settingsDisplayLanguage": "显示语言", |   "settingsDisplayLanguage": "显示语言", | ||||||
|   "settingsDisplayLanguageDescription": "设置应用程序使用的语言", |   "settingsDisplayLanguageDescription": "设置应用程序使用的语言", | ||||||
|   "settingsDisplayLanguageSystem": "跟随系统", |   "settingsDisplayLanguageSystem": "跟随系统", | ||||||
| @@ -325,6 +336,7 @@ | |||||||
|   "fieldAttachmentRandomId": "访问 ID", |   "fieldAttachmentRandomId": "访问 ID", | ||||||
|   "fieldAttachmentAlt": "概述文字", |   "fieldAttachmentAlt": "概述文字", | ||||||
|   "addAttachmentFromAlbum": "从相册中添加附件", |   "addAttachmentFromAlbum": "从相册中添加附件", | ||||||
|  |   "addAttachmentFromFiles": "从文件中添加附件", | ||||||
|   "addAttachmentFromClipboard": "粘贴附件", |   "addAttachmentFromClipboard": "粘贴附件", | ||||||
|   "addAttachmentFromCameraPhoto": "拍摄照片", |   "addAttachmentFromCameraPhoto": "拍摄照片", | ||||||
|   "addAttachmentFromCameraVideo": "拍摄视频", |   "addAttachmentFromCameraVideo": "拍摄视频", | ||||||
| @@ -510,8 +522,13 @@ | |||||||
|   "accountBirthday": "出生于 {}", |   "accountBirthday": "出生于 {}", | ||||||
|   "accountBadge": "徽章", |   "accountBadge": "徽章", | ||||||
|   "accountCheckInNoRecords": "暂无运势记录", |   "accountCheckInNoRecords": "暂无运势记录", | ||||||
|   "badgeCompanyStaff": "索尔辛茨士大夫 · 员工", |   "badgeCompanyStaff": "工作人员", | ||||||
|   "badgeSiteMigration": "Solar Network 原住民", |   "badgeSiteMigration": "Solar Network 原住民", | ||||||
|  |   "badgeCommunitySurvey": "调研参与者", | ||||||
|  |   "badgeCommunityVerified": "认证用户", | ||||||
|  |   "badgeCommunityContributor": "优秀社区贡献者", | ||||||
|  |   "badgeSiteAnniversary": "周年纪念", | ||||||
|  |   "badgeUserBirthday": "生日纪念", | ||||||
|   "accountStatus": "状态", |   "accountStatus": "状态", | ||||||
|   "accountStatusOnline": "在线", |   "accountStatusOnline": "在线", | ||||||
|   "accountStatusOffline": "离线", |   "accountStatusOffline": "离线", | ||||||
| @@ -720,5 +737,204 @@ | |||||||
|   "trayMenuMuteNotification": "静音通知", |   "trayMenuMuteNotification": "静音通知", | ||||||
|   "update": "更新", |   "update": "更新", | ||||||
|   "forceUpdate": "强制更新", |   "forceUpdate": "强制更新", | ||||||
|   "forceUpdateDescription": "强制更新应用程序,即使有更新的版本可能不可用。" |   "forceUpdateDescription": "强制更新应用程序,即使有更新的版本可能不可用。", | ||||||
|  |   "runtimeLogs": "运行时日志", | ||||||
|  |   "runtimeLogsOpen": "打开日志文件", | ||||||
|  |   "runtimeLogsDescription": "显示运行时的日志记录。", | ||||||
|  |   "signinResetPasswordHint": "请输入用户名/电子邮箱地址以帮助我们找到您的帐户并重置密码。", | ||||||
|  |   "cacheSize": "缓存资源大小", | ||||||
|  |   "cacheDelete": "清除缓存", | ||||||
|  |   "cacheDeleteDescription": "从磁盘中移除缓存的图片和其他资源,内容将从服务器重新下载。", | ||||||
|  |   "cacheDeleted": "所有缓存已被清除。", | ||||||
|  |   "userNoDescription": "这个人很懒,没有留下什么……", | ||||||
|  |   "fieldTimeZone": "时区", | ||||||
|  |   "fieldGender": "性别", | ||||||
|  |   "fieldPronouns": "人称代词", | ||||||
|  |   "fieldLocation": "位置", | ||||||
|  |   "fieldLinks": "链接", | ||||||
|  |   "fieldLinkName": "名称", | ||||||
|  |   "fieldLinkUrl": "链接", | ||||||
|  |   "screenAccountBadges": "徽章", | ||||||
|  |   "accountBadges": "徽章", | ||||||
|  |   "accountBadgesDescription": "查看并管理你的徽章。", | ||||||
|  |   "badgeActivated": "已佩戴徽章 {}。", | ||||||
|  |   "viewDetailedAttachment": "查看附件详情", | ||||||
|  |   "screenKeyPairs": "密钥对", | ||||||
|  |   "accountKeyPairs": "密钥对", | ||||||
|  |   "accountKeyPairsDescription": "管理用于加密信息的密钥对。", | ||||||
|  |   "enrollNewKeyPair": "新建密钥对", | ||||||
|  |   "enrollNewKeyPairDescription": "生成一对新密钥对。", | ||||||
|  |   "keyPairHasPrivateKey": "有私钥", | ||||||
|  |   "decrypting": "解密中……", | ||||||
|  |   "decryptingKeyNotFound": "未找到密钥对或交换失败,对方可能不在线", | ||||||
|  |   "messageUnablePreview": "无法预览消息", | ||||||
|  |   "messageUnablePreviewEncrypted": "无法预览加密消息", | ||||||
|  |   "postViewInGlobalDescription": "不查看特定领域的帖子。", | ||||||
|  |   "postDraftSaved": "已保存为草稿。", | ||||||
|  |   "postDraftBox": "草稿箱", | ||||||
|  |   "postShuffle": "随便看看", | ||||||
|  |   "checkInStreak": { | ||||||
|  |     "zero": "无连击", | ||||||
|  |     "one": "连续签到 {} 天", | ||||||
|  |     "other": "连续签到 {} 天" | ||||||
|  |   }, | ||||||
|  |   "accountChangeStatus": "修改状态", | ||||||
|  |   "accountStatusSilent": "请勿打扰", | ||||||
|  |   "accountStatusSilentDesc": "将会暂停所有通知推送", | ||||||
|  |   "accountStatusInvisible": "隐身", | ||||||
|  |   "accountStatusInvisibleDesc": "将会在他人界面显示离线,但不影响功能使用", | ||||||
|  |   "accountCustomStatus": "自定义状态", | ||||||
|  |   "accountCustomStatusDescription": "客制化你的状态。", | ||||||
|  |   "accountClearStatus": "清除状态", | ||||||
|  |   "accountClearStatusDescription": "清除你的状态,并让服务器决定你的状态。", | ||||||
|  |   "fieldAccountStatusLabel": "状态文字", | ||||||
|  |   "fieldAccountStatusClearAt": "清除时间", | ||||||
|  |   "accountStatusNegative": "负面", | ||||||
|  |   "accountStatusNeutral": "中性", | ||||||
|  |   "accountStatusPositive": "正面", | ||||||
|  |   "mixedFeed": "混合推荐流", | ||||||
|  |   "mixedFeedDescription": "探索页面可能不只会展示用户的帖子,更可能包含其他的内容。但该模式不适用分类和过滤。", | ||||||
|  |   "filterFeed": "探索队列调整", | ||||||
|  |   "feedUnknownItem": "无法显示该内容,当前版本客户端不支持该类型的内容,请尝试更新应用程序后再试。", | ||||||
|  |   "serviceStatusOperational": "所有服务正常", | ||||||
|  |   "serviceStatusDowngraded": "部分服务异常", | ||||||
|  |   "serviceStatusFailed": "服务状态异常", | ||||||
|  |   "serviceStatusFailedDescription": "服务器炸了或者刚刚执行完维护程序。", | ||||||
|  |   "serviceNameInsights": "总结、见解与洞察", | ||||||
|  |   "serviceNameInteractive": "帖子与互动", | ||||||
|  |   "serviceNameReader": "新闻与链接展开", | ||||||
|  |   "serviceNameMessaging": "即使聊天", | ||||||
|  |   "serviceNameMatrix": "矩阵市场", | ||||||
|  |   "serviceNamePaperclip": "附件", | ||||||
|  |   "serviceNameWallet": "源点钱包", | ||||||
|  |   "serviceNamePassport": "身份验证与授权", | ||||||
|  |   "accountActionEvent": "操作日志", | ||||||
|  |   "accountActionEventDescription": "查看你的操作日志。", | ||||||
|  |   "eventMetadata": "元数据", | ||||||
|  |   "accountAuthTickets": "授权会话", | ||||||
|  |   "accountAuthTicketsDescription": "查看和管理你的授权会话。", | ||||||
|  |   "authTicketCreatedAt": "签发于 {}", | ||||||
|  |   "authTicketExpiredAt": "到期于 {}", | ||||||
|  |   "authTicketLastGrantAt": "上次刷新于 {}", | ||||||
|  |   "authTicketCurrent": "当前会话", | ||||||
|  |   "accountUnconfirmedTitle": "尚未未确认账户", | ||||||
|  |   "accountUnconfirmedSubtitle": "您的账户尚未确认,这会导致大部分功能不可用,并且您的帐户将在 24 小时后自毁。您绑定的邮箱的收件箱应该会有一封邮件指引您确认您的帐户。", | ||||||
|  |   "accountUnconfirmedUnreceived": "未收到邮件?", | ||||||
|  |   "accountUnconfirmedResend": "重新发送一封", | ||||||
|  |   "accountUnconfirmedResendSuccessful": "邮件已重新发送,你可以在 60 分钟后再重发一封。", | ||||||
|  |   "stickerPickerEmpty": "贴图列表为空", | ||||||
|  |   "stickerPickerEmptyHint": "想要开始使用贴图,请先添加贴图包。", | ||||||
|  |   "goto": "跳转到 {}", | ||||||
|  |   "accountContactMethods": "联系方式", | ||||||
|  |   "accountContactMethodsDescription": "管理你的联系方式。", | ||||||
|  |   "accountContactMethodsNameEmail": "电子邮箱", | ||||||
|  |   "accountContactMethodsNamePhone": "电话", | ||||||
|  |   "accountContactMethodsNameAddress": "地址", | ||||||
|  |   "accountContactMethodsPrimary": "主要的", | ||||||
|  |   "accountContactMethodsVerified": "已验证", | ||||||
|  |   "accountContactMethodsPublic": "公开的", | ||||||
|  |   "accountContactMethodsAdd": "添加联系方式", | ||||||
|  |   "accountContactMethodsEdit": "编辑联系方式", | ||||||
|  |   "accountContactMethodsAddDescription": "添加新的联系方式。", | ||||||
|  |   "fieldContactContent": "联系方式", | ||||||
|  |   "accountContactMethodsPublicHint": "这个联系方式公开地显示在个人资料中。", | ||||||
|  |   "accountContactMethodsDelete": "删除联系方式", | ||||||
|  |   "accountContactMethodsDeleteDescription": "你确定要删除联系方式 {} 吗?这个操作不可撤销。", | ||||||
|  |   "postCommentAdd": "撰写一条评论", | ||||||
|  |   "translate": "翻译", | ||||||
|  |   "translating": "正在翻译……", | ||||||
|  |   "translated": "已翻译", | ||||||
|  |   "settingsAutoTranslate": "自动翻译", | ||||||
|  |   "settingsAutoTranslateDescription": "在查看帖子、消息时自动翻译文本。", | ||||||
|  |   "trayMenuHide": "隐藏", | ||||||
|  |   "accountSettingsNotify": "通知设置", | ||||||
|  |   "accountSettingsNotifyDescription": "调整你所收到的通知种类。", | ||||||
|  |   "accountSettingsSecurity": "安全设置", | ||||||
|  |   "accountSettingsSecurityDescription": "调整你的帐户安全设置。", | ||||||
|  |   "save": "保存", | ||||||
|  |   "notificationTopicPostFeedback": "帖子数据反馈", | ||||||
|  |   "notificationTopicPostReply": "帖子回复", | ||||||
|  |   "notificationTopicPostSubscription": "帖子订阅", | ||||||
|  |   "notificationTopicMessaging": "消息", | ||||||
|  |   "notificationTopicMessagingCall": "通话", | ||||||
|  |   "notificationTopicGeneral": "杂项", | ||||||
|  |   "authMaximumAuthSteps": "最大验证步骤", | ||||||
|  |   "authMaximumAuthStepsDescription": { | ||||||
|  |     "one": "登入时最多要求 {} 步验证", | ||||||
|  |     "other": "登入时最多要求 {} 步验证" | ||||||
|  |   }, | ||||||
|  |   "authAlwaysRisky": "总是风险", | ||||||
|  |   "authAlwaysRiskyDescription": "在登入时始终按最高标准要求验证。", | ||||||
|  |   "chatUnjoined": "未加入频道", | ||||||
|  |   "chatUnjoinedDescription": "你没有加入这个频道,所以你也无法发送消息或者查看这个频道中的消息。", | ||||||
|  |   "chatUnjoinedPublicDescription": "但幸运的是,这是一个公开频道,所以你可以主动加入。", | ||||||
|  |   "chatJoin": "加入频道", | ||||||
|  |   "appInitStarting": "启动中", | ||||||
|  |   "appInitNetwork": "正在初始化网络", | ||||||
|  |   "appInitUserdata": "正在初始化用户数据", | ||||||
|  |   "appInitWebsocket": "正在建立 Solar Link", | ||||||
|  |   "appInitNotification": "正在初始化推送通知",  | ||||||
|  |   "appInitKeyPair": "正在初始化密钥对", | ||||||
|  |   "appInitStickers": "正在初始化贴图包", | ||||||
|  |   "appInitUserDirectory": "正在初始化用户目录", | ||||||
|  |   "appInitRealm": "正在初始化领域信息", | ||||||
|  |   "appInitChat": "正在初始化聊天", | ||||||
|  |   "appInitDone": "完成", | ||||||
|  |   "community": "社区", | ||||||
|  |   "realmCommunity": "{}的社区", | ||||||
|  |   "postTotalCount": { | ||||||
|  |     "zero": "没有帖子", | ||||||
|  |     "one": "共 {} 条帖子" | ||||||
|  |   }, | ||||||
|  |   "settingsHideBottomNav": "隐藏底部导航栏", | ||||||
|  |   "settingsHideBottomNavDescription": "隐藏底部导航栏,在侧边栏抽屉显示导航按钮。", | ||||||
|  |   "reCaptcha": "人机验证", | ||||||
|  |   "friends": "好友", | ||||||
|  |   "friendsDescription": "管理好友关系。", | ||||||
|  |   "album": "相册", | ||||||
|  |   "albumDescription": "查看相册与管理上传附件。", | ||||||
|  |   "stickers": "贴图", | ||||||
|  |   "stickersDescription": "查看贴图包与管理贴图。", | ||||||
|  |   "navBottomUnauthorizedCaption": "或者注册一个账号", | ||||||
|  |   "walletCurrencyGoldenShort": "金点", | ||||||
|  |   "walletCurrencyGolden": { | ||||||
|  |     "one": "{} 金点", | ||||||
|  |     "other": "{} 金点" | ||||||
|  |   }, | ||||||
|  |   "walletTransactionTypeNormal": "源点", | ||||||
|  |   "walletTransactionTypeGolden": "金点", | ||||||
|  |   "accountProgram": "计划", | ||||||
|  |   "accountProgramDescription": "了解可用的成员计划。", | ||||||
|  |   "accountProgramJoin": "加入计划", | ||||||
|  |   "accountProgramJoinRequirements": "要求", | ||||||
|  |   "accountProgramJoinPricing": "价格", | ||||||
|  |   "accountProgramJoinPricingHint": "按月(30 天)收费", | ||||||
|  |   "accountProgramLeaveHint": "离开计划后,之前花费的源点不会退款。", | ||||||
|  |   "accountProgramJoined": "已加入计划。", | ||||||
|  |   "accountProgramLeft": "已离开计划。", | ||||||
|  |   "accountProgramAlreadyJoined": "已加入", | ||||||
|  |   "leave": "离开", | ||||||
|  |   "attachmentFailedToLoadMedia": "无法加载媒体文件,请稍后重试。若此错误重复出现,可能源文件不存在或者网络连接异常。", | ||||||
|  |   "accountPunishments": "处分", | ||||||
|  |   "accountPunishmentsDescription": "查看你帐号的信誉状态。", | ||||||
|  |   "punishmentType0": "警告", | ||||||
|  |   "punishmentType1": "停权", | ||||||
|  |   "punishmentType2": "封禁", | ||||||
|  |   "punishmentOverall": "总体状态", | ||||||
|  |   "punishmentStatusNormal": "所有功能正常", | ||||||
|  |   "punishmentStatusWarned": "所有功能正常,但有警告生效", | ||||||
|  |   "punishmentStatusLimited": "部份功能暂时受限,有至少一个停权生效", | ||||||
|  |   "punishmentStatusLimitedFully": "所有功能暂时受限,有至少一个完全停权生效", | ||||||
|  |   "punishmentStatusBanned": "所有服务终止,已被封禁", | ||||||
|  |   "punishmentCreatedAt": "宣布于 {}", | ||||||
|  |   "punishmentExpiredAt": "到期于 {}", | ||||||
|  |   "punishmentExpiredNever": "永久生效", | ||||||
|  |   "punishmentModerator": "责任管理员", | ||||||
|  |   "punishmentMadeBySystem": "由系统自动裁决", | ||||||
|  |   "settingsAprilFoolFeatures": "愚人节特性", | ||||||
|  |   "settingsAprilFoolFeaturesDescription": "在愚人节期间启用愚人节特性,该选项只会在愚人节期间显示。", | ||||||
|  |   "settingsSoundEffects": "声音效果", | ||||||
|  |   "settingsSoundEffectsDescription": "在一些场合下启用声音特效。", | ||||||
|  |   "settingsResetMemorizedWindowSize": "重置窗口大小", | ||||||
|  |   "settingsResetMemorizedWindowSizeDescription": "重置记忆的窗口大小,以重新设置为默认大小。" | ||||||
| } | } | ||||||
|   | |||||||
| @@ -137,6 +137,11 @@ | |||||||
|   "publisherRunBy": "由 {} 管理", |   "publisherRunBy": "由 {} 管理", | ||||||
|   "fieldPublisherBelongToRealm": "所屬領域", |   "fieldPublisherBelongToRealm": "所屬領域", | ||||||
|   "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域", |   "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域", | ||||||
|  |   "writePost": "撰寫", | ||||||
|  |   "postTypeStory": "動態", | ||||||
|  |   "postTypeArticle": "文章", | ||||||
|  |   "postTypeQuestion": "問題", | ||||||
|  |   "postTypeVideo": "視頻", | ||||||
|   "writePostTypeStory": "發動態", |   "writePostTypeStory": "發動態", | ||||||
|   "writePostTypeArticle": "寫文章", |   "writePostTypeArticle": "寫文章", | ||||||
|   "writePostTypeQuestion": "提問題", |   "writePostTypeQuestion": "提問題", | ||||||
| @@ -200,7 +205,13 @@ | |||||||
|     "one": "{} 條評論", |     "one": "{} 條評論", | ||||||
|     "other": "{} 條評論" |     "other": "{} 條評論" | ||||||
|   }, |   }, | ||||||
|  |   "postCommentExpand": "展開評論", | ||||||
|   "settingsAppearance": "外觀", |   "settingsAppearance": "外觀", | ||||||
|  |   "settingsCustomFonts": "自定義字體", | ||||||
|  |   "settingsCustomFontsDescription": "設置應用程序使用的字體。", | ||||||
|  |   "settingsCustomFontFamily": "應用字體", | ||||||
|  |   "settingsCustomFontFamilyHint": "使用英文逗號分割每一種字體,越前優先級越高", | ||||||
|  |   "settingsCustomFontApplied": "自定義字體已經應用。", | ||||||
|   "settingsDisplayLanguage": "顯示語言", |   "settingsDisplayLanguage": "顯示語言", | ||||||
|   "settingsDisplayLanguageDescription": "設置應用程序使用的語言", |   "settingsDisplayLanguageDescription": "設置應用程序使用的語言", | ||||||
|   "settingsDisplayLanguageSystem": "跟隨系統", |   "settingsDisplayLanguageSystem": "跟隨系統", | ||||||
| @@ -325,6 +336,7 @@ | |||||||
|   "fieldAttachmentRandomId": "訪問 ID", |   "fieldAttachmentRandomId": "訪問 ID", | ||||||
|   "fieldAttachmentAlt": "概述文字", |   "fieldAttachmentAlt": "概述文字", | ||||||
|   "addAttachmentFromAlbum": "從相冊中添加附件", |   "addAttachmentFromAlbum": "從相冊中添加附件", | ||||||
|  |   "addAttachmentFromFiles": "從文件中添加附件", | ||||||
|   "addAttachmentFromClipboard": "粘貼附件", |   "addAttachmentFromClipboard": "粘貼附件", | ||||||
|   "addAttachmentFromCameraPhoto": "拍攝照片", |   "addAttachmentFromCameraPhoto": "拍攝照片", | ||||||
|   "addAttachmentFromCameraVideo": "拍攝視頻", |   "addAttachmentFromCameraVideo": "拍攝視頻", | ||||||
| @@ -510,8 +522,13 @@ | |||||||
|   "accountBirthday": "出生於 {}", |   "accountBirthday": "出生於 {}", | ||||||
|   "accountBadge": "徽章", |   "accountBadge": "徽章", | ||||||
|   "accountCheckInNoRecords": "暫無運勢記錄", |   "accountCheckInNoRecords": "暫無運勢記錄", | ||||||
|   "badgeCompanyStaff": "索爾辛茨士大夫 · 員工", |   "badgeCompanyStaff": "工作人員", | ||||||
|   "badgeSiteMigration": "Solar Network 原住民", |   "badgeSiteMigration": "Solar Network 原住民", | ||||||
|  |   "badgeCommunitySurvey": "調研參與者", | ||||||
|  |   "badgeCommunityVerified": "認證用户", | ||||||
|  |   "badgeCommunityContributor": "優秀社區貢獻者", | ||||||
|  |   "badgeSiteAnniversary": "週年紀念", | ||||||
|  |   "badgeUserBirthday": "生日紀念", | ||||||
|   "accountStatus": "狀態", |   "accountStatus": "狀態", | ||||||
|   "accountStatusOnline": "在線", |   "accountStatusOnline": "在線", | ||||||
|   "accountStatusOffline": "離線", |   "accountStatusOffline": "離線", | ||||||
| @@ -720,5 +737,163 @@ | |||||||
|   "trayMenuMuteNotification": "靜音通知", |   "trayMenuMuteNotification": "靜音通知", | ||||||
|   "update": "更新", |   "update": "更新", | ||||||
|   "forceUpdate": "強制更新", |   "forceUpdate": "強制更新", | ||||||
|   "forceUpdateDescription": "強制更新應用程序,即使有更新的版本可能不可用。" |   "forceUpdateDescription": "強制更新應用程序,即使有更新的版本可能不可用。", | ||||||
|  |   "runtimeLogs": "運行時日誌", | ||||||
|  |   "runtimeLogsOpen": "打開日誌文件", | ||||||
|  |   "runtimeLogsDescription": "顯示運行時的日誌記錄。", | ||||||
|  |   "signinResetPasswordHint": "請輸入用户名/電子郵箱地址以幫助我們找到您的帳户並重置密碼。", | ||||||
|  |   "cacheSize": "緩存資源大小", | ||||||
|  |   "cacheDelete": "清除緩存", | ||||||
|  |   "cacheDeleteDescription": "從磁盤中移除緩存的圖片和其他資源,內容將從服務器重新下載。", | ||||||
|  |   "cacheDeleted": "所有緩存已被清除。", | ||||||
|  |   "userNoDescription": "這個人很懶,沒有留下什麼……", | ||||||
|  |   "fieldTimeZone": "時區", | ||||||
|  |   "fieldGender": "性別", | ||||||
|  |   "fieldPronouns": "人稱代詞", | ||||||
|  |   "fieldLocation": "位置", | ||||||
|  |   "fieldLinks": "鏈接", | ||||||
|  |   "fieldLinkName": "名稱", | ||||||
|  |   "fieldLinkUrl": "鏈接", | ||||||
|  |   "screenAccountBadges": "徽章", | ||||||
|  |   "accountBadges": "徽章", | ||||||
|  |   "accountBadgesDescription": "查看並管理你的徽章。", | ||||||
|  |   "badgeActivated": "已佩戴徽章 {}。", | ||||||
|  |   "viewDetailedAttachment": "查看附件詳情", | ||||||
|  |   "screenKeyPairs": "密鑰對", | ||||||
|  |   "accountKeyPairs": "密鑰對", | ||||||
|  |   "accountKeyPairsDescription": "管理用於加密信息的密鑰對。", | ||||||
|  |   "enrollNewKeyPair": "新建密鑰對", | ||||||
|  |   "enrollNewKeyPairDescription": "生成一對新密鑰對。", | ||||||
|  |   "keyPairHasPrivateKey": "有私鑰", | ||||||
|  |   "decrypting": "解密中……", | ||||||
|  |   "decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線", | ||||||
|  |   "messageUnablePreview": "無法預覽消息", | ||||||
|  |   "messageUnablePreviewEncrypted": "無法預覽加密消息", | ||||||
|  |   "postViewInGlobalDescription": "不查看特定領域的帖子。", | ||||||
|  |   "postDraftSaved": "已保存為草稿。", | ||||||
|  |   "postDraftBox": "草稿箱", | ||||||
|  |   "postShuffle": "隨便看看", | ||||||
|  |   "checkInStreak": { | ||||||
|  |     "zero": "無連擊", | ||||||
|  |     "one": "連續簽到 {} 天", | ||||||
|  |     "other": "連續簽到 {} 天" | ||||||
|  |   }, | ||||||
|  |   "accountChangeStatus": "修改狀態", | ||||||
|  |   "accountStatusSilent": "請勿打擾", | ||||||
|  |   "accountStatusSilentDesc": "將會暫停所有通知推送", | ||||||
|  |   "accountStatusInvisible": "隱身", | ||||||
|  |   "accountStatusInvisibleDesc": "將會在他人界面顯示離線,但不影響功能使用", | ||||||
|  |   "accountCustomStatus": "自定義狀態", | ||||||
|  |   "accountCustomStatusDescription": "客製化你的狀態。", | ||||||
|  |   "accountClearStatus": "清除狀態", | ||||||
|  |   "accountClearStatusDescription": "清除你的狀態,並讓服務器決定你的狀態。", | ||||||
|  |   "fieldAccountStatusLabel": "狀態文字", | ||||||
|  |   "fieldAccountStatusClearAt": "清除時間", | ||||||
|  |   "accountStatusNegative": "負面", | ||||||
|  |   "accountStatusNeutral": "中性", | ||||||
|  |   "accountStatusPositive": "正面", | ||||||
|  |   "mixedFeed": "混合推薦流", | ||||||
|  |   "mixedFeedDescription": "探索頁面可能不只會展示用户的帖子,更可能包含其他的內容。但該模式不適用分類和過濾。", | ||||||
|  |   "filterFeed": "探索隊列調整", | ||||||
|  |   "feedUnknownItem": "無法顯示該內容,當前版本客户端不支持該類型的內容,請嘗試更新應用程序後再試。", | ||||||
|  |   "serviceStatusOperational": "所有服務正常", | ||||||
|  |   "serviceStatusDowngraded": "部分服務異常", | ||||||
|  |   "serviceStatusFailed": "服務狀態異常", | ||||||
|  |   "serviceStatusFailedDescription": "服務器炸了或者剛剛執行完維護程序。", | ||||||
|  |   "serviceNameInsights": "總結、見解與洞察", | ||||||
|  |   "serviceNameInteractive": "帖子與互動", | ||||||
|  |   "serviceNameReader": "新聞與鏈接展開", | ||||||
|  |   "serviceNameMessaging": "即使聊天", | ||||||
|  |   "serviceNameMatrix": "矩陣市場", | ||||||
|  |   "serviceNamePaperclip": "附件", | ||||||
|  |   "serviceNameWallet": "源點錢包", | ||||||
|  |   "serviceNamePassport": "身份驗證與授權", | ||||||
|  |   "accountActionEvent": "操作日誌", | ||||||
|  |   "accountActionEventDescription": "查看你的操作日誌。", | ||||||
|  |   "eventMetadata": "元數據", | ||||||
|  |   "accountAuthTickets": "授權會話", | ||||||
|  |   "accountAuthTicketsDescription": "查看和管理你的授權會話。", | ||||||
|  |   "authTicketCreatedAt": "簽發於 {}", | ||||||
|  |   "authTicketExpiredAt": "到期於 {}", | ||||||
|  |   "authTicketLastGrantAt": "上次刷新於 {}", | ||||||
|  |   "authTicketCurrent": "當前會話", | ||||||
|  |   "accountUnconfirmedTitle": "尚未未確認賬户", | ||||||
|  |   "accountUnconfirmedSubtitle": "您的賬户尚未確認,這會導致大部分功能不可用,並且您的帳户將在 24 小時後自毀。您綁定的郵箱的收件箱應該會有一封郵件指引您確認您的帳户。", | ||||||
|  |   "accountUnconfirmedUnreceived": "未收到郵件?", | ||||||
|  |   "accountUnconfirmedResend": "重新發送一封", | ||||||
|  |   "accountUnconfirmedResendSuccessful": "郵件已重新發送,你可以在 60 分鐘後再重發一封。", | ||||||
|  |   "stickerPickerEmpty": "貼圖列表為空", | ||||||
|  |   "stickerPickerEmptyHint": "想要開始使用貼圖,請先添加貼圖包。", | ||||||
|  |   "goto": "跳轉到 {}", | ||||||
|  |   "accountContactMethods": "聯繫方式", | ||||||
|  |   "accountContactMethodsDescription": "管理你的聯繫方式。", | ||||||
|  |   "accountContactMethodsNameEmail": "電子郵箱", | ||||||
|  |   "accountContactMethodsNamePhone": "電話", | ||||||
|  |   "accountContactMethodsNameAddress": "地址", | ||||||
|  |   "accountContactMethodsPrimary": "主要的", | ||||||
|  |   "accountContactMethodsVerified": "已驗證", | ||||||
|  |   "accountContactMethodsPublic": "公開的", | ||||||
|  |   "accountContactMethodsAdd": "添加聯繫方式", | ||||||
|  |   "accountContactMethodsEdit": "編輯聯繫方式", | ||||||
|  |   "accountContactMethodsAddDescription": "添加新的聯繫方式。", | ||||||
|  |   "fieldContactContent": "聯繫方式", | ||||||
|  |   "accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。", | ||||||
|  |   "accountContactMethodsDelete": "刪除聯繫方式", | ||||||
|  |   "accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。", | ||||||
|  |   "postCommentAdd": "撰寫一條評論", | ||||||
|  |   "translate": "翻譯", | ||||||
|  |   "translating": "正在翻譯……", | ||||||
|  |   "translated": "已翻譯", | ||||||
|  |   "settingsAutoTranslate": "自動翻譯", | ||||||
|  |   "settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。", | ||||||
|  |   "trayMenuHide": "隱藏", | ||||||
|  |   "accountSettingsNotify": "通知設置", | ||||||
|  |   "accountSettingsNotifyDescription": "調整你所收到的通知種類。", | ||||||
|  |   "accountSettingsSecurity": "安全設置", | ||||||
|  |   "accountSettingsSecurityDescription": "調整你的帳户安全設置。", | ||||||
|  |   "save": "保存", | ||||||
|  |   "notificationTopicPostFeedback": "帖子數據反饋", | ||||||
|  |   "notificationTopicPostReply": "帖子回覆", | ||||||
|  |   "notificationTopicPostSubscription": "帖子訂閲", | ||||||
|  |   "notificationTopicMessaging": "消息", | ||||||
|  |   "notificationTopicMessagingCall": "通話", | ||||||
|  |   "notificationTopicGeneral": "雜項", | ||||||
|  |   "authMaximumAuthSteps": "最大驗證步驟", | ||||||
|  |   "authMaximumAuthStepsDescription": { | ||||||
|  |     "one": "登入時最多要求 {} 步驗證", | ||||||
|  |     "other": "登入時最多要求 {} 步驗證" | ||||||
|  |   }, | ||||||
|  |   "authAlwaysRisky": "總是風險", | ||||||
|  |   "authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。", | ||||||
|  |   "chatUnjoined": "未加入頻道", | ||||||
|  |   "chatUnjoinedDescription": "你沒有加入這個頻道,所以你也無法發送消息或者查看這個頻道中的消息。", | ||||||
|  |   "chatUnjoinedPublicDescription": "但幸運的是,這是一個公開頻道,所以你可以主動加入。", | ||||||
|  |   "chatJoin": "加入頻道", | ||||||
|  |   "appInitStarting": "啓動中", | ||||||
|  |   "appInitNetwork": "正在初始化網絡", | ||||||
|  |   "appInitUserdata": "正在初始化用户數據", | ||||||
|  |   "appInitWebsocket": "正在建立 Solar Link", | ||||||
|  |   "appInitNotification": "正在初始化推送通知",  | ||||||
|  |   "appInitKeyPair": "正在初始化密鑰對", | ||||||
|  |   "appInitStickers": "正在初始化貼圖包", | ||||||
|  |   "appInitUserDirectory": "正在初始化用户目錄", | ||||||
|  |   "appInitRealm": "正在初始化領域信息", | ||||||
|  |   "appInitChat": "正在初始化聊天", | ||||||
|  |   "appInitDone": "完成", | ||||||
|  |   "community": "社區", | ||||||
|  |   "realmCommunity": "{}的社區", | ||||||
|  |   "postTotalCount": { | ||||||
|  |     "zero": "沒有帖子", | ||||||
|  |     "one": "共 {} 條帖子" | ||||||
|  |   }, | ||||||
|  |   "settingsHideBottomNav": "隱藏底部導航欄", | ||||||
|  |   "settingsHideBottomNavDescription": "隱藏底部導航欄,在側邊欄抽屜顯示導航按鈕。", | ||||||
|  |   "reCaptcha": "人機驗證", | ||||||
|  |   "friends": "好友", | ||||||
|  |   "friendsDescription": "管理好友關係。", | ||||||
|  |   "album": "相冊", | ||||||
|  |   "albumDescription": "查看相冊與管理上傳附件。", | ||||||
|  |   "stickers": "貼圖", | ||||||
|  |   "stickersDescription": "查看貼圖包與管理貼圖。", | ||||||
|  |   "navBottomUnauthorizedCaption": "或者註冊一個賬號" | ||||||
| } | } | ||||||
|   | |||||||
| @@ -137,6 +137,11 @@ | |||||||
|   "publisherRunBy": "由 {} 管理", |   "publisherRunBy": "由 {} 管理", | ||||||
|   "fieldPublisherBelongToRealm": "所屬領域", |   "fieldPublisherBelongToRealm": "所屬領域", | ||||||
|   "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域", |   "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域", | ||||||
|  |   "writePost": "撰寫", | ||||||
|  |   "postTypeStory": "動態", | ||||||
|  |   "postTypeArticle": "文章", | ||||||
|  |   "postTypeQuestion": "問題", | ||||||
|  |   "postTypeVideo": "視頻", | ||||||
|   "writePostTypeStory": "發動態", |   "writePostTypeStory": "發動態", | ||||||
|   "writePostTypeArticle": "寫文章", |   "writePostTypeArticle": "寫文章", | ||||||
|   "writePostTypeQuestion": "提問題", |   "writePostTypeQuestion": "提問題", | ||||||
| @@ -200,7 +205,13 @@ | |||||||
|     "one": "{} 條評論", |     "one": "{} 條評論", | ||||||
|     "other": "{} 條評論" |     "other": "{} 條評論" | ||||||
|   }, |   }, | ||||||
|  |   "postCommentExpand": "展開評論", | ||||||
|   "settingsAppearance": "外觀", |   "settingsAppearance": "外觀", | ||||||
|  |   "settingsCustomFonts": "自定義字體", | ||||||
|  |   "settingsCustomFontsDescription": "設置應用程序使用的字體。", | ||||||
|  |   "settingsCustomFontFamily": "應用字體", | ||||||
|  |   "settingsCustomFontFamilyHint": "使用英文逗號分割每一種字體,越前優先級越高", | ||||||
|  |   "settingsCustomFontApplied": "自定義字體已經應用。", | ||||||
|   "settingsDisplayLanguage": "顯示語言", |   "settingsDisplayLanguage": "顯示語言", | ||||||
|   "settingsDisplayLanguageDescription": "設置應用程序使用的語言", |   "settingsDisplayLanguageDescription": "設置應用程序使用的語言", | ||||||
|   "settingsDisplayLanguageSystem": "跟隨系統", |   "settingsDisplayLanguageSystem": "跟隨系統", | ||||||
| @@ -325,6 +336,7 @@ | |||||||
|   "fieldAttachmentRandomId": "訪問 ID", |   "fieldAttachmentRandomId": "訪問 ID", | ||||||
|   "fieldAttachmentAlt": "概述文字", |   "fieldAttachmentAlt": "概述文字", | ||||||
|   "addAttachmentFromAlbum": "從相冊中添加附件", |   "addAttachmentFromAlbum": "從相冊中添加附件", | ||||||
|  |   "addAttachmentFromFiles": "從文件中添加附件", | ||||||
|   "addAttachmentFromClipboard": "粘貼附件", |   "addAttachmentFromClipboard": "粘貼附件", | ||||||
|   "addAttachmentFromCameraPhoto": "拍攝照片", |   "addAttachmentFromCameraPhoto": "拍攝照片", | ||||||
|   "addAttachmentFromCameraVideo": "拍攝視頻", |   "addAttachmentFromCameraVideo": "拍攝視頻", | ||||||
| @@ -510,8 +522,13 @@ | |||||||
|   "accountBirthday": "出生於 {}", |   "accountBirthday": "出生於 {}", | ||||||
|   "accountBadge": "徽章", |   "accountBadge": "徽章", | ||||||
|   "accountCheckInNoRecords": "暫無運勢記錄", |   "accountCheckInNoRecords": "暫無運勢記錄", | ||||||
|   "badgeCompanyStaff": "索爾辛茨士大夫 · 員工", |   "badgeCompanyStaff": "工作人員", | ||||||
|   "badgeSiteMigration": "Solar Network 原住民", |   "badgeSiteMigration": "Solar Network 原住民", | ||||||
|  |   "badgeCommunitySurvey": "調研參與者", | ||||||
|  |   "badgeCommunityVerified": "認證用戶", | ||||||
|  |   "badgeCommunityContributor": "優秀社區貢獻者", | ||||||
|  |   "badgeSiteAnniversary": "週年紀念", | ||||||
|  |   "badgeUserBirthday": "生日紀念", | ||||||
|   "accountStatus": "狀態", |   "accountStatus": "狀態", | ||||||
|   "accountStatusOnline": "在線", |   "accountStatusOnline": "在線", | ||||||
|   "accountStatusOffline": "離線", |   "accountStatusOffline": "離線", | ||||||
| @@ -720,5 +737,163 @@ | |||||||
|   "trayMenuMuteNotification": "靜音通知", |   "trayMenuMuteNotification": "靜音通知", | ||||||
|   "update": "更新", |   "update": "更新", | ||||||
|   "forceUpdate": "強制更新", |   "forceUpdate": "強制更新", | ||||||
|   "forceUpdateDescription": "強制更新應用程序,即使有更新的版本可能不可用。" |   "forceUpdateDescription": "強制更新應用程序,即使有更新的版本可能不可用。", | ||||||
|  |   "runtimeLogs": "運行時日誌", | ||||||
|  |   "runtimeLogsOpen": "打開日誌文件", | ||||||
|  |   "runtimeLogsDescription": "顯示運行時的日誌記錄。", | ||||||
|  |   "signinResetPasswordHint": "請輸入用戶名/電子郵箱地址以幫助我們找到您的帳戶並重置密碼。", | ||||||
|  |   "cacheSize": "緩存資源大小", | ||||||
|  |   "cacheDelete": "清除緩存", | ||||||
|  |   "cacheDeleteDescription": "從磁盤中移除緩存的圖片和其他資源,內容將從服務器重新下載。", | ||||||
|  |   "cacheDeleted": "所有緩存已被清除。", | ||||||
|  |   "userNoDescription": "這個人很懶,沒有留下什麼……", | ||||||
|  |   "fieldTimeZone": "時區", | ||||||
|  |   "fieldGender": "性別", | ||||||
|  |   "fieldPronouns": "人稱代詞", | ||||||
|  |   "fieldLocation": "位置", | ||||||
|  |   "fieldLinks": "鏈接", | ||||||
|  |   "fieldLinkName": "名稱", | ||||||
|  |   "fieldLinkUrl": "鏈接", | ||||||
|  |   "screenAccountBadges": "徽章", | ||||||
|  |   "accountBadges": "徽章", | ||||||
|  |   "accountBadgesDescription": "查看並管理你的徽章。", | ||||||
|  |   "badgeActivated": "已佩戴徽章 {}。", | ||||||
|  |   "viewDetailedAttachment": "查看附件詳情", | ||||||
|  |   "screenKeyPairs": "密鑰對", | ||||||
|  |   "accountKeyPairs": "密鑰對", | ||||||
|  |   "accountKeyPairsDescription": "管理用於加密信息的密鑰對。", | ||||||
|  |   "enrollNewKeyPair": "新建密鑰對", | ||||||
|  |   "enrollNewKeyPairDescription": "生成一對新密鑰對。", | ||||||
|  |   "keyPairHasPrivateKey": "有私鑰", | ||||||
|  |   "decrypting": "解密中……", | ||||||
|  |   "decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線", | ||||||
|  |   "messageUnablePreview": "無法預覽消息", | ||||||
|  |   "messageUnablePreviewEncrypted": "無法預覽加密消息", | ||||||
|  |   "postViewInGlobalDescription": "不查看特定領域的帖子。", | ||||||
|  |   "postDraftSaved": "已保存為草稿。", | ||||||
|  |   "postDraftBox": "草稿箱", | ||||||
|  |   "postShuffle": "隨便看看", | ||||||
|  |   "checkInStreak": { | ||||||
|  |     "zero": "無連擊", | ||||||
|  |     "one": "連續簽到 {} 天", | ||||||
|  |     "other": "連續簽到 {} 天" | ||||||
|  |   }, | ||||||
|  |   "accountChangeStatus": "修改狀態", | ||||||
|  |   "accountStatusSilent": "請勿打擾", | ||||||
|  |   "accountStatusSilentDesc": "將會暫停所有通知推送", | ||||||
|  |   "accountStatusInvisible": "隱身", | ||||||
|  |   "accountStatusInvisibleDesc": "將會在他人界面顯示離線,但不影響功能使用", | ||||||
|  |   "accountCustomStatus": "自定義狀態", | ||||||
|  |   "accountCustomStatusDescription": "客製化你的狀態。", | ||||||
|  |   "accountClearStatus": "清除狀態", | ||||||
|  |   "accountClearStatusDescription": "清除你的狀態,並讓服務器決定你的狀態。", | ||||||
|  |   "fieldAccountStatusLabel": "狀態文字", | ||||||
|  |   "fieldAccountStatusClearAt": "清除時間", | ||||||
|  |   "accountStatusNegative": "負面", | ||||||
|  |   "accountStatusNeutral": "中性", | ||||||
|  |   "accountStatusPositive": "正面", | ||||||
|  |   "mixedFeed": "混合推薦流", | ||||||
|  |   "mixedFeedDescription": "探索頁面可能不只會展示用戶的帖子,更可能包含其他的內容。但該模式不適用分類和過濾。", | ||||||
|  |   "filterFeed": "探索隊列調整", | ||||||
|  |   "feedUnknownItem": "無法顯示該內容,當前版本客戶端不支持該類型的內容,請嘗試更新應用程序後再試。", | ||||||
|  |   "serviceStatusOperational": "所有服務正常", | ||||||
|  |   "serviceStatusDowngraded": "部分服務異常", | ||||||
|  |   "serviceStatusFailed": "服務狀態異常", | ||||||
|  |   "serviceStatusFailedDescription": "服務器炸了或者剛剛執行完維護程序。", | ||||||
|  |   "serviceNameInsights": "總結、見解與洞察", | ||||||
|  |   "serviceNameInteractive": "帖子與互動", | ||||||
|  |   "serviceNameReader": "新聞與鏈接展開", | ||||||
|  |   "serviceNameMessaging": "即使聊天", | ||||||
|  |   "serviceNameMatrix": "矩陣市場", | ||||||
|  |   "serviceNamePaperclip": "附件", | ||||||
|  |   "serviceNameWallet": "源點錢包", | ||||||
|  |   "serviceNamePassport": "身份驗證與授權", | ||||||
|  |   "accountActionEvent": "操作日誌", | ||||||
|  |   "accountActionEventDescription": "查看你的操作日誌。", | ||||||
|  |   "eventMetadata": "元數據", | ||||||
|  |   "accountAuthTickets": "授權會話", | ||||||
|  |   "accountAuthTicketsDescription": "查看和管理你的授權會話。", | ||||||
|  |   "authTicketCreatedAt": "簽發於 {}", | ||||||
|  |   "authTicketExpiredAt": "到期於 {}", | ||||||
|  |   "authTicketLastGrantAt": "上次刷新於 {}", | ||||||
|  |   "authTicketCurrent": "當前會話", | ||||||
|  |   "accountUnconfirmedTitle": "尚未未確認賬戶", | ||||||
|  |   "accountUnconfirmedSubtitle": "您的賬戶尚未確認,這會導致大部分功能不可用,並且您的帳戶將在 24 小時後自毀。您綁定的郵箱的收件箱應該會有一封郵件指引您確認您的帳戶。", | ||||||
|  |   "accountUnconfirmedUnreceived": "未收到郵件?", | ||||||
|  |   "accountUnconfirmedResend": "重新發送一封", | ||||||
|  |   "accountUnconfirmedResendSuccessful": "郵件已重新發送,你可以在 60 分鐘後再重發一封。", | ||||||
|  |   "stickerPickerEmpty": "貼圖列表為空", | ||||||
|  |   "stickerPickerEmptyHint": "想要開始使用貼圖,請先添加貼圖包。", | ||||||
|  |   "goto": "跳轉到 {}", | ||||||
|  |   "accountContactMethods": "聯繫方式", | ||||||
|  |   "accountContactMethodsDescription": "管理你的聯繫方式。", | ||||||
|  |   "accountContactMethodsNameEmail": "電子郵箱", | ||||||
|  |   "accountContactMethodsNamePhone": "電話", | ||||||
|  |   "accountContactMethodsNameAddress": "地址", | ||||||
|  |   "accountContactMethodsPrimary": "主要的", | ||||||
|  |   "accountContactMethodsVerified": "已驗證", | ||||||
|  |   "accountContactMethodsPublic": "公開的", | ||||||
|  |   "accountContactMethodsAdd": "添加聯繫方式", | ||||||
|  |   "accountContactMethodsEdit": "編輯聯繫方式", | ||||||
|  |   "accountContactMethodsAddDescription": "添加新的聯繫方式。", | ||||||
|  |   "fieldContactContent": "聯繫方式", | ||||||
|  |   "accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。", | ||||||
|  |   "accountContactMethodsDelete": "刪除聯繫方式", | ||||||
|  |   "accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。", | ||||||
|  |   "postCommentAdd": "撰寫一條評論", | ||||||
|  |   "translate": "翻譯", | ||||||
|  |   "translating": "正在翻譯……", | ||||||
|  |   "translated": "已翻譯", | ||||||
|  |   "settingsAutoTranslate": "自動翻譯", | ||||||
|  |   "settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。", | ||||||
|  |   "trayMenuHide": "隱藏", | ||||||
|  |   "accountSettingsNotify": "通知設置", | ||||||
|  |   "accountSettingsNotifyDescription": "調整你所收到的通知種類。", | ||||||
|  |   "accountSettingsSecurity": "安全設置", | ||||||
|  |   "accountSettingsSecurityDescription": "調整你的帳戶安全設置。", | ||||||
|  |   "save": "保存", | ||||||
|  |   "notificationTopicPostFeedback": "帖子數據反饋", | ||||||
|  |   "notificationTopicPostReply": "帖子回覆", | ||||||
|  |   "notificationTopicPostSubscription": "帖子訂閱", | ||||||
|  |   "notificationTopicMessaging": "消息", | ||||||
|  |   "notificationTopicMessagingCall": "通話", | ||||||
|  |   "notificationTopicGeneral": "雜項", | ||||||
|  |   "authMaximumAuthSteps": "最大驗證步驟", | ||||||
|  |   "authMaximumAuthStepsDescription": { | ||||||
|  |     "one": "登入時最多要求 {} 步驗證", | ||||||
|  |     "other": "登入時最多要求 {} 步驗證" | ||||||
|  |   }, | ||||||
|  |   "authAlwaysRisky": "總是風險", | ||||||
|  |   "authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。", | ||||||
|  |   "chatUnjoined": "未加入頻道", | ||||||
|  |   "chatUnjoinedDescription": "你沒有加入這個頻道,所以你也無法發送消息或者查看這個頻道中的消息。", | ||||||
|  |   "chatUnjoinedPublicDescription": "但幸運的是,這是一個公開頻道,所以你可以主動加入。", | ||||||
|  |   "chatJoin": "加入頻道", | ||||||
|  |   "appInitStarting": "啟動中", | ||||||
|  |   "appInitNetwork": "正在初始化網絡", | ||||||
|  |   "appInitUserdata": "正在初始化用戶數據", | ||||||
|  |   "appInitWebsocket": "正在建立 Solar Link", | ||||||
|  |   "appInitNotification": "正在初始化推送通知",  | ||||||
|  |   "appInitKeyPair": "正在初始化密鑰對", | ||||||
|  |   "appInitStickers": "正在初始化貼圖包", | ||||||
|  |   "appInitUserDirectory": "正在初始化用戶目錄", | ||||||
|  |   "appInitRealm": "正在初始化領域信息", | ||||||
|  |   "appInitChat": "正在初始化聊天", | ||||||
|  |   "appInitDone": "完成", | ||||||
|  |   "community": "社區", | ||||||
|  |   "realmCommunity": "{}的社區", | ||||||
|  |   "postTotalCount": { | ||||||
|  |     "zero": "沒有帖子", | ||||||
|  |     "one": "共 {} 條帖子" | ||||||
|  |   }, | ||||||
|  |   "settingsHideBottomNav": "隱藏底部導航欄", | ||||||
|  |   "settingsHideBottomNavDescription": "隱藏底部導航欄,在側邊欄抽屜顯示導航按鈕。", | ||||||
|  |   "reCaptcha": "人機驗證", | ||||||
|  |   "friends": "好友", | ||||||
|  |   "friendsDescription": "管理好友關係。", | ||||||
|  |   "album": "相冊", | ||||||
|  |   "albumDescription": "查看相冊與管理上傳附件。", | ||||||
|  |   "stickers": "貼圖", | ||||||
|  |   "stickersDescription": "查看貼圖包與管理貼圖。", | ||||||
|  |   "navBottomUnauthorizedCaption": "或者註冊一個賬號" | ||||||
| } | } | ||||||
|   | |||||||
| @@ -5,3 +5,7 @@ targets: | |||||||
|         options: |         options: | ||||||
|           explicit_to_json: true |           explicit_to_json: true | ||||||
|           field_rename: snake |           field_rename: snake | ||||||
|  |       drift_dev: | ||||||
|  |         options: | ||||||
|  |           databases: | ||||||
|  |             my_database: lib/database/database.dart | ||||||
							
								
								
									
										1
									
								
								drift_schemas/my_database/drift_schema_v1.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								drift_schemas/my_database/drift_schema_v1.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | {"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"sn_local_chat_channel","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"alias","getter_name":"alias","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnChannelConverter()","dart_type_name":"SnChannel"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"sn_local_chat_message","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"channel_id","getter_name":"channelId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnMessageConverter()","dart_type_name":"SnChatMessage"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}}]} | ||||||
							
								
								
									
										1
									
								
								drift_schemas/my_database/drift_schema_v2.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								drift_schemas/my_database/drift_schema_v2.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | {"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"sn_local_chat_channel","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"alias","getter_name":"alias","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnChannelConverter()","dart_type_name":"SnChannel"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"sn_local_chat_message","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"channel_id","getter_name":"channelId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnMessageConverter()","dart_type_name":"SnChatMessage"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":2,"references":[],"type":"table","data":{"name":"sn_local_key_pair","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"account_id","getter_name":"accountId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"public_key","getter_name":"publicKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"private_key","getter_name":"privateKey","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_active","getter_name":"isActive","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_active\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_active\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["id"]}}]} | ||||||
							
								
								
									
										1
									
								
								drift_schemas/my_database/drift_schema_v3.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								drift_schemas/my_database/drift_schema_v3.json
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										1
									
								
								drift_schemas/my_database/drift_schema_v4.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								drift_schemas/my_database/drift_schema_v4.json
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										111
									
								
								ios/Podfile.lock
									
									
									
									
									
								
							
							
						
						
									
										111
									
								
								ios/Podfile.lock
									
									
									
									
									
								
							| @@ -1,5 +1,7 @@ | |||||||
| PODS: | PODS: | ||||||
|   - Alamofire (5.10.2) |   - Alamofire (5.10.2) | ||||||
|  |   - audioplayers_darwin (0.0.1): | ||||||
|  |     - Flutter | ||||||
|   - connectivity_plus (0.0.1): |   - connectivity_plus (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - croppy (0.0.1): |   - croppy (0.0.1): | ||||||
| @@ -37,6 +39,8 @@ PODS: | |||||||
|   - DKPhotoGallery/Resource (0.0.19): |   - DKPhotoGallery/Resource (0.0.19): | ||||||
|     - SDWebImage |     - SDWebImage | ||||||
|     - SwiftyGif |     - SwiftyGif | ||||||
|  |   - fast_rsa (0.6.0): | ||||||
|  |     - Flutter | ||||||
|   - file_picker (0.0.1): |   - file_picker (0.0.1): | ||||||
|     - DKImagePickerController/PhotoGallery |     - DKImagePickerController/PhotoGallery | ||||||
|     - Flutter |     - Flutter | ||||||
| @@ -52,14 +56,14 @@ PODS: | |||||||
|   - Firebase/Messaging (11.8.0): |   - Firebase/Messaging (11.8.0): | ||||||
|     - Firebase/CoreOnly |     - Firebase/CoreOnly | ||||||
|     - FirebaseMessaging (~> 11.8.0) |     - FirebaseMessaging (~> 11.8.0) | ||||||
|   - firebase_analytics (11.4.3): |   - firebase_analytics (11.4.4): | ||||||
|     - Firebase/Analytics (= 11.8.0) |     - Firebase/Analytics (= 11.8.0) | ||||||
|     - firebase_core |     - firebase_core | ||||||
|     - Flutter |     - Flutter | ||||||
|   - firebase_core (3.12.0): |   - firebase_core (3.12.1): | ||||||
|     - Firebase/CoreOnly (= 11.8.0) |     - Firebase/CoreOnly (= 11.8.0) | ||||||
|     - Flutter |     - Flutter | ||||||
|   - firebase_messaging (15.2.3): |   - firebase_messaging (15.2.4): | ||||||
|     - Firebase/Messaging (= 11.8.0) |     - Firebase/Messaging (= 11.8.0) | ||||||
|     - firebase_core |     - firebase_core | ||||||
|     - Flutter |     - Flutter | ||||||
| @@ -113,6 +117,8 @@ PODS: | |||||||
|     - OrderedSet (~> 6.0.3) |     - OrderedSet (~> 6.0.3) | ||||||
|   - flutter_native_splash (2.4.3): |   - flutter_native_splash (2.4.3): | ||||||
|     - Flutter |     - Flutter | ||||||
|  |   - flutter_timezone (0.0.1): | ||||||
|  |     - Flutter | ||||||
|   - flutter_udid (0.0.1): |   - flutter_udid (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|     - SAMKeychain |     - SAMKeychain | ||||||
| @@ -179,14 +185,12 @@ PODS: | |||||||
|   - in_app_review (2.0.0): |   - in_app_review (2.0.0): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - Kingfisher (8.2.0) |   - Kingfisher (8.2.0) | ||||||
|   - livekit_client (2.4.0): |   - livekit_client (2.4.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|     - flutter_webrtc |     - flutter_webrtc | ||||||
|     - WebRTC-SDK (= 125.6422.06) |     - WebRTC-SDK (= 125.6422.06) | ||||||
|   - media_kit_libs_ios_video (1.0.4): |   - media_kit_libs_ios_video (1.0.4): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - media_kit_native_event_loop (1.0.0): |  | ||||||
|     - Flutter |  | ||||||
|   - media_kit_video (0.0.1): |   - media_kit_video (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - nanopb (3.30910.0): |   - nanopb (3.30910.0): | ||||||
| @@ -208,8 +212,6 @@ PODS: | |||||||
|   - receive_sharing_intent (1.8.1): |   - receive_sharing_intent (1.8.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - SAMKeychain (1.5.3) |   - SAMKeychain (1.5.3) | ||||||
|   - screen_brightness_ios (0.1.0): |  | ||||||
|     - Flutter |  | ||||||
|   - SDWebImage (5.20.1): |   - SDWebImage (5.20.1): | ||||||
|     - SDWebImage/Core (= 5.20.1) |     - SDWebImage/Core (= 5.20.1) | ||||||
|   - SDWebImage/Core (5.20.1) |   - SDWebImage/Core (5.20.1) | ||||||
| @@ -228,6 +230,8 @@ PODS: | |||||||
|     - sqlite3/common |     - sqlite3/common | ||||||
|   - sqlite3/fts5 (3.49.1): |   - sqlite3/fts5 (3.49.1): | ||||||
|     - sqlite3/common |     - sqlite3/common | ||||||
|  |   - sqlite3/math (3.49.1): | ||||||
|  |     - sqlite3/common | ||||||
|   - sqlite3/perf-threadsafe (3.49.1): |   - sqlite3/perf-threadsafe (3.49.1): | ||||||
|     - sqlite3/common |     - sqlite3/common | ||||||
|   - sqlite3/rtree (3.49.1): |   - sqlite3/rtree (3.49.1): | ||||||
| @@ -235,9 +239,10 @@ PODS: | |||||||
|   - sqlite3_flutter_libs (0.0.1): |   - sqlite3_flutter_libs (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|     - FlutterMacOS |     - FlutterMacOS | ||||||
|     - sqlite3 (~> 3.49.0) |     - sqlite3 (~> 3.49.1) | ||||||
|     - sqlite3/dbstatvtab |     - sqlite3/dbstatvtab | ||||||
|     - sqlite3/fts5 |     - sqlite3/fts5 | ||||||
|  |     - sqlite3/math | ||||||
|     - sqlite3/perf-threadsafe |     - sqlite3/perf-threadsafe | ||||||
|     - sqlite3/rtree |     - sqlite3/rtree | ||||||
|   - SwiftyGif (5.4.5) |   - SwiftyGif (5.4.5) | ||||||
| @@ -255,9 +260,11 @@ PODS: | |||||||
|  |  | ||||||
| DEPENDENCIES: | DEPENDENCIES: | ||||||
|   - Alamofire |   - Alamofire | ||||||
|  |   - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/ios`) | ||||||
|   - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) |   - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) | ||||||
|   - croppy (from `.symlinks/plugins/croppy/ios`) |   - croppy (from `.symlinks/plugins/croppy/ios`) | ||||||
|   - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) |   - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) | ||||||
|  |   - fast_rsa (from `.symlinks/plugins/fast_rsa/ios`) | ||||||
|   - file_picker (from `.symlinks/plugins/file_picker/ios`) |   - file_picker (from `.symlinks/plugins/file_picker/ios`) | ||||||
|   - file_saver (from `.symlinks/plugins/file_saver/ios`) |   - file_saver (from `.symlinks/plugins/file_saver/ios`) | ||||||
|   - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) |   - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) | ||||||
| @@ -267,6 +274,7 @@ DEPENDENCIES: | |||||||
|   - flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`) |   - flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`) | ||||||
|   - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) |   - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) | ||||||
|   - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) |   - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) | ||||||
|  |   - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`) | ||||||
|   - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) |   - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) | ||||||
|   - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) |   - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) | ||||||
|   - gal (from `.symlinks/plugins/gal/darwin`) |   - gal (from `.symlinks/plugins/gal/darwin`) | ||||||
| @@ -276,14 +284,12 @@ DEPENDENCIES: | |||||||
|   - Kingfisher (~> 8.0) |   - Kingfisher (~> 8.0) | ||||||
|   - livekit_client (from `.symlinks/plugins/livekit_client/ios`) |   - livekit_client (from `.symlinks/plugins/livekit_client/ios`) | ||||||
|   - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) |   - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) | ||||||
|   - media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`) |  | ||||||
|   - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) |   - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) | ||||||
|   - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) |   - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) | ||||||
|   - pasteboard (from `.symlinks/plugins/pasteboard/ios`) |   - pasteboard (from `.symlinks/plugins/pasteboard/ios`) | ||||||
|   - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) |   - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) | ||||||
|   - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) |   - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) | ||||||
|   - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) |   - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) | ||||||
|   - screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`) |  | ||||||
|   - share_plus (from `.symlinks/plugins/share_plus/ios`) |   - share_plus (from `.symlinks/plugins/share_plus/ios`) | ||||||
|   - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) |   - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) | ||||||
|   - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) |   - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) | ||||||
| @@ -319,12 +325,16 @@ SPEC REPOS: | |||||||
|     - WebRTC-SDK |     - WebRTC-SDK | ||||||
|  |  | ||||||
| EXTERNAL SOURCES: | EXTERNAL SOURCES: | ||||||
|  |   audioplayers_darwin: | ||||||
|  |     :path: ".symlinks/plugins/audioplayers_darwin/ios" | ||||||
|   connectivity_plus: |   connectivity_plus: | ||||||
|     :path: ".symlinks/plugins/connectivity_plus/ios" |     :path: ".symlinks/plugins/connectivity_plus/ios" | ||||||
|   croppy: |   croppy: | ||||||
|     :path: ".symlinks/plugins/croppy/ios" |     :path: ".symlinks/plugins/croppy/ios" | ||||||
|   device_info_plus: |   device_info_plus: | ||||||
|     :path: ".symlinks/plugins/device_info_plus/ios" |     :path: ".symlinks/plugins/device_info_plus/ios" | ||||||
|  |   fast_rsa: | ||||||
|  |     :path: ".symlinks/plugins/fast_rsa/ios" | ||||||
|   file_picker: |   file_picker: | ||||||
|     :path: ".symlinks/plugins/file_picker/ios" |     :path: ".symlinks/plugins/file_picker/ios" | ||||||
|   file_saver: |   file_saver: | ||||||
| @@ -343,6 +353,8 @@ EXTERNAL SOURCES: | |||||||
|     :path: ".symlinks/plugins/flutter_inappwebview_ios/ios" |     :path: ".symlinks/plugins/flutter_inappwebview_ios/ios" | ||||||
|   flutter_native_splash: |   flutter_native_splash: | ||||||
|     :path: ".symlinks/plugins/flutter_native_splash/ios" |     :path: ".symlinks/plugins/flutter_native_splash/ios" | ||||||
|  |   flutter_timezone: | ||||||
|  |     :path: ".symlinks/plugins/flutter_timezone/ios" | ||||||
|   flutter_udid: |   flutter_udid: | ||||||
|     :path: ".symlinks/plugins/flutter_udid/ios" |     :path: ".symlinks/plugins/flutter_udid/ios" | ||||||
|   flutter_webrtc: |   flutter_webrtc: | ||||||
| @@ -359,8 +371,6 @@ EXTERNAL SOURCES: | |||||||
|     :path: ".symlinks/plugins/livekit_client/ios" |     :path: ".symlinks/plugins/livekit_client/ios" | ||||||
|   media_kit_libs_ios_video: |   media_kit_libs_ios_video: | ||||||
|     :path: ".symlinks/plugins/media_kit_libs_ios_video/ios" |     :path: ".symlinks/plugins/media_kit_libs_ios_video/ios" | ||||||
|   media_kit_native_event_loop: |  | ||||||
|     :path: ".symlinks/plugins/media_kit_native_event_loop/ios" |  | ||||||
|   media_kit_video: |   media_kit_video: | ||||||
|     :path: ".symlinks/plugins/media_kit_video/ios" |     :path: ".symlinks/plugins/media_kit_video/ios" | ||||||
|   package_info_plus: |   package_info_plus: | ||||||
| @@ -373,8 +383,6 @@ EXTERNAL SOURCES: | |||||||
|     :path: ".symlinks/plugins/permission_handler_apple/ios" |     :path: ".symlinks/plugins/permission_handler_apple/ios" | ||||||
|   receive_sharing_intent: |   receive_sharing_intent: | ||||||
|     :path: ".symlinks/plugins/receive_sharing_intent/ios" |     :path: ".symlinks/plugins/receive_sharing_intent/ios" | ||||||
|   screen_brightness_ios: |  | ||||||
|     :path: ".symlinks/plugins/screen_brightness_ios/ios" |  | ||||||
|   share_plus: |   share_plus: | ||||||
|     :path: ".symlinks/plugins/share_plus/ios" |     :path: ".symlinks/plugins/share_plus/ios" | ||||||
|   shared_preferences_foundation: |   shared_preferences_foundation: | ||||||
| @@ -396,63 +404,64 @@ EXTERNAL SOURCES: | |||||||
|  |  | ||||||
| SPEC CHECKSUMS: | SPEC CHECKSUMS: | ||||||
|   Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496 |   Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496 | ||||||
|   connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d |   audioplayers_darwin: ccf9c770ee768abb07e26d90af093f7bab1c12ab | ||||||
|   croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321 |   connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd | ||||||
|   device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 |   croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30 | ||||||
|  |   device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe | ||||||
|   DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c |   DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c | ||||||
|   DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 |   DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 | ||||||
|   file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 |   fast_rsa: d99f8e1809a4a312fa9216d830186869b2e9eb65 | ||||||
|   file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808 |   file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be | ||||||
|  |   file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6 | ||||||
|   Firebase: d80354ed7f6df5f9aca55e9eb47cc4b634735eaf |   Firebase: d80354ed7f6df5f9aca55e9eb47cc4b634735eaf | ||||||
|   firebase_analytics: 7ec1166af61987fa968766eb11587c562a5650ee |   firebase_analytics: 4e93dbe66872104d28ae9750fec1800e1fd66858 | ||||||
|   firebase_core: 6e223dfa350b2edceb729cea505eaaef59330682 |   firebase_core: 8d552814f6c01ccde5d88939fced4ec26f2f5510 | ||||||
|   firebase_messaging: 07fde77ae28c08616a1d4d870450efc2b38cf40d |   firebase_messaging: 8b96a4f09841c15a16b96973ef5c3dcfc1a064e4 | ||||||
|   FirebaseAnalytics: 4fd42def128146e24e480e89f310e3d8534ea42b |   FirebaseAnalytics: 4fd42def128146e24e480e89f310e3d8534ea42b | ||||||
|   FirebaseCore: 99fe0c4b44a39f37d99e6404e02009d2db5d718d |   FirebaseCore: 99fe0c4b44a39f37d99e6404e02009d2db5d718d | ||||||
|   FirebaseCoreInternal: df24ce5af28864660ecbd13596fc8dd3a8c34629 |   FirebaseCoreInternal: df24ce5af28864660ecbd13596fc8dd3a8c34629 | ||||||
|   FirebaseInstallations: 6c963bd2a86aca0481eef4f48f5a4df783ae5917 |   FirebaseInstallations: 6c963bd2a86aca0481eef4f48f5a4df783ae5917 | ||||||
|   FirebaseMessaging: 487b634ccdf6f7b7ff180fdcb2a9935490f764e8 |   FirebaseMessaging: 487b634ccdf6f7b7ff180fdcb2a9935490f764e8 | ||||||
|   Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 |   Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 | ||||||
|   flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc |   flutter_app_update: 816fdb2e30e4832a7c45e3f108d391c42ef040a9 | ||||||
|   flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4 |   flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 | ||||||
|   flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29 |   flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf | ||||||
|   flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab |   flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 | ||||||
|   flutter_webrtc: 90260f83024b1b96d239a575ea4e3708e79344d1 |   flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9 | ||||||
|   gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5 |   flutter_webrtc: 57f32415b8744e806f9c2a96ccdb60c6a627ba33 | ||||||
|  |   gal: baecd024ebfd13c441269ca7404792a7152fde89 | ||||||
|   GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896 |   GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896 | ||||||
|   GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 |   GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 | ||||||
|   GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d |   GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d | ||||||
|   home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57 |   home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f | ||||||
|   image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 |   image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a | ||||||
|   in_app_review: a31b5257259646ea78e0e35fc914979b0031d011 |   in_app_review: 5596fe56fab799e8edb3561c03d053363ab13457 | ||||||
|   Kingfisher: 323e5c4ec7983aaace12af655a7b51a7f88a599d |   Kingfisher: 323e5c4ec7983aaace12af655a7b51a7f88a599d | ||||||
|   livekit_client: 9819ebc8be8ef00ed0fae7d806bf8938ec689573 |   livekit_client: 08755cabfa4da4ed455642f460cfbb39bc518070 | ||||||
|   media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1 |   media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 | ||||||
|   media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a |   media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 | ||||||
|   media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e |  | ||||||
|   nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 |   nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 | ||||||
|   OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 |   OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 | ||||||
|   package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 |   package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 | ||||||
|   pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0 |   pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c | ||||||
|   path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 |   path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 | ||||||
|   permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 |   permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d | ||||||
|   PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 |   PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 | ||||||
|   receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1 |   receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00 | ||||||
|   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c |   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c | ||||||
|   screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625 |  | ||||||
|   SDWebImage: 33d0f23bddeb5d209ae959153883247be6703713 |   SDWebImage: 33d0f23bddeb5d209ae959153883247be6703713 | ||||||
|   share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f |   share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a | ||||||
|   shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 |   shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 | ||||||
|   sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d |   sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 | ||||||
|   sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983 |   sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983 | ||||||
|   sqlite3_flutter_libs: 069c435986dd4b63461aecd68f4b30be4a9e9daa |   sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 | ||||||
|   SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 |   SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 | ||||||
|   url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe |   url_launcher_ios: 694010445543906933d732453a59da0a173ae33d | ||||||
|   video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe |   video_compress: f2133a07762889d67f0711ac831faa26f956980e | ||||||
|   volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9 |   volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12 | ||||||
|   wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56 |   wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49 | ||||||
|   WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db |   WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db | ||||||
|   workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6 |   workmanager: 01be2de7f184bd15de93a1812936a2b7f42ef07e | ||||||
|  |  | ||||||
| PODFILE CHECKSUM: 9b244e02f87527430136c8d21cbdcf1cd586b6bc | PODFILE CHECKSUM: 9b244e02f87527430136c8d21cbdcf1cd586b6bc | ||||||
|  |  | ||||||
|   | |||||||
| @@ -79,6 +79,8 @@ | |||||||
| 		<string>UIInterfaceOrientationLandscapeLeft</string> | 		<string>UIInterfaceOrientationLandscapeLeft</string> | ||||||
| 		<string>UIInterfaceOrientationLandscapeRight</string> | 		<string>UIInterfaceOrientationLandscapeRight</string> | ||||||
| 	</array> | 	</array> | ||||||
|  | 	<key>LSSupportsOpeningDocumentsInPlace</key> | ||||||
|  | 	<true/> | ||||||
| 	<key>UISupportedInterfaceOrientations~ipad</key> | 	<key>UISupportedInterfaceOrientations~ipad</key> | ||||||
| 	<array> | 	<array> | ||||||
| 		<string>UIInterfaceOrientationPortrait</string> | 		<string>UIInterfaceOrientationPortrait</string> | ||||||
|   | |||||||
| @@ -1,6 +1,5 @@ | |||||||
| import 'dart:async'; | import 'dart:async'; | ||||||
| import 'dart:convert'; | import 'dart:convert'; | ||||||
| import 'dart:developer'; |  | ||||||
| import 'dart:math' as math; | import 'dart:math' as math; | ||||||
|  |  | ||||||
| import 'package:dio/dio.dart'; | import 'package:dio/dio.dart'; | ||||||
| @@ -8,7 +7,10 @@ import 'package:drift/drift.dart'; | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:surface/database/database.dart'; | import 'package:surface/database/database.dart'; | ||||||
|  | import 'package:surface/logger.dart'; | ||||||
|  | import 'package:surface/providers/channel.dart'; | ||||||
| import 'package:surface/providers/database.dart'; | import 'package:surface/providers/database.dart'; | ||||||
|  | import 'package:surface/providers/keypair.dart'; | ||||||
| import 'package:surface/providers/sn_attachment.dart'; | import 'package:surface/providers/sn_attachment.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/providers/user_directory.dart'; | import 'package:surface/providers/user_directory.dart'; | ||||||
| @@ -25,6 +27,8 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|   late final WebSocketProvider _ws; |   late final WebSocketProvider _ws; | ||||||
|   late final SnAttachmentProvider _attach; |   late final SnAttachmentProvider _attach; | ||||||
|   late final DatabaseProvider _dt; |   late final DatabaseProvider _dt; | ||||||
|  |   late final ChatChannelProvider _ct; | ||||||
|  |   late final KeyPairProvider _kp; | ||||||
|  |  | ||||||
|   StreamSubscription? _wsSubscription; |   StreamSubscription? _wsSubscription; | ||||||
|  |  | ||||||
| @@ -33,11 +37,14 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|     _ud = context.read<UserDirectoryProvider>(); |     _ud = context.read<UserDirectoryProvider>(); | ||||||
|     _ws = context.read<WebSocketProvider>(); |     _ws = context.read<WebSocketProvider>(); | ||||||
|     _attach = context.read<SnAttachmentProvider>(); |     _attach = context.read<SnAttachmentProvider>(); | ||||||
|  |     _ct = context.read<ChatChannelProvider>(); | ||||||
|     _dt = context.read<DatabaseProvider>(); |     _dt = context.read<DatabaseProvider>(); | ||||||
|  |     _kp = context.read<KeyPairProvider>(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   bool isPending = true; |   bool isPending = true; | ||||||
|   bool isLoading = false; |   bool isLoading = false; | ||||||
|  |   bool isAggressiveLoading = false; | ||||||
|  |  | ||||||
|   int? messageTotal; |   int? messageTotal; | ||||||
|  |  | ||||||
| @@ -61,10 +68,7 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|     channel = chan; |     channel = chan; | ||||||
|  |  | ||||||
|     // Fetch channel profile |     // Fetch channel profile | ||||||
|     final resp = await _sn.client.get( |     profile = await _ct.getChannelProfile(channel!); | ||||||
|       '/cgi/im/channels/${chan.keyPath}/me', |  | ||||||
|     ); |  | ||||||
|     profile = SnChannelMember.fromJson(resp.data); |  | ||||||
|  |  | ||||||
|     _wsSubscription = _ws.pk.stream.listen((event) { |     _wsSubscription = _ws.pk.stream.listen((event) { | ||||||
|       switch (event.method) { |       switch (event.method) { | ||||||
| @@ -183,6 +187,7 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|     } else { |     } else { | ||||||
|       messages.insert(0, message); |       messages.insert(0, message); | ||||||
|     } |     } | ||||||
|  |     notifyListeners(); | ||||||
|     await _applyMessage(message); |     await _applyMessage(message); | ||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|  |  | ||||||
| @@ -243,6 +248,24 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   Future<Map<String, dynamic>> _encodeMessageBody( | ||||||
|  |     String text, | ||||||
|  |     bool isEncrypted, | ||||||
|  |   ) async { | ||||||
|  |     if (!isEncrypted || _kp.activeKp == null) { | ||||||
|  |       return { | ||||||
|  |         'text': text, | ||||||
|  |         'algorithm': 'plain', | ||||||
|  |       }; | ||||||
|  |     } else { | ||||||
|  |       return { | ||||||
|  |         'text': await _kp.encryptText(text), | ||||||
|  |         'algorithm': 'rsa', | ||||||
|  |         'keypair_id': _kp.activeKp!.id, | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   Future<void> sendMessage( |   Future<void> sendMessage( | ||||||
|     String type, |     String type, | ||||||
|     String content, { |     String content, { | ||||||
| @@ -250,13 +273,13 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|     int? relatedId, |     int? relatedId, | ||||||
|     List<String>? attachments, |     List<String>? attachments, | ||||||
|     SnChatMessage? editingMessage, |     SnChatMessage? editingMessage, | ||||||
|  |     bool isEncrypted = false, | ||||||
|   }) async { |   }) async { | ||||||
|     if (channel == null) return; |     if (channel == null) return; | ||||||
|     const uuid = Uuid(); |     const uuid = Uuid(); | ||||||
|     final nonce = uuid.v4(); |     final nonce = uuid.v4(); | ||||||
|     final body = { |     final body = { | ||||||
|       'text': content, |       ...(await _encodeMessageBody(content, isEncrypted)), | ||||||
|       'algorithm': 'plain', |  | ||||||
|       if (quoteId != null) 'quote_event': quoteId, |       if (quoteId != null) 'quote_event': quoteId, | ||||||
|       if (relatedId != null) 'related_event': relatedId, |       if (relatedId != null) 'related_event': relatedId, | ||||||
|       if (attachments != null && attachments.isNotEmpty) |       if (attachments != null && attachments.isNotEmpty) | ||||||
| @@ -264,6 +287,8 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Mock the message locally |     // Mock the message locally | ||||||
|  |     // Do not mock the editing message | ||||||
|  |     if (editingMessage == null) { | ||||||
|       final createdAt = DateTime.now(); |       final createdAt = DateTime.now(); | ||||||
|       final message = SnChatMessage( |       final message = SnChatMessage( | ||||||
|         id: 0, |         id: 0, | ||||||
| @@ -281,6 +306,7 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|         relatedEventId: relatedId, |         relatedEventId: relatedId, | ||||||
|       ); |       ); | ||||||
|       _addUnconfirmedMessage(message); |       _addUnconfirmedMessage(message); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     // Send to server |     // Send to server | ||||||
|     try { |     try { | ||||||
| @@ -320,7 +346,7 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|   /// Check the local storage is up to date with the server. |   /// Check the local storage is up to date with the server. | ||||||
|   /// If the local storage is not up to date, it will be updated. |   /// If the local storage is not up to date, it will be updated. | ||||||
|   Future<void> checkUpdate() async { |   Future<void> checkUpdate() async { | ||||||
|     isLoading = true; |     isAggressiveLoading = true; | ||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|  |  | ||||||
|     final mostRecentMessage = await (_dt.db.snLocalChatMessage.select() |     final mostRecentMessage = await (_dt.db.snLocalChatMessage.select() | ||||||
| @@ -334,6 +360,7 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|     if (mostRecentMessage == null) { |     if (mostRecentMessage == null) { | ||||||
|       // Initial load |       // Initial load | ||||||
|       await loadMessages(take: 20); |       await loadMessages(take: 20); | ||||||
|  |       isAggressiveLoading = false; | ||||||
|       isCheckedUpdate = true; |       isCheckedUpdate = true; | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
| @@ -351,13 +378,19 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|       final countToFetch = math.min(resp.data['count'] as int, 100); |       final countToFetch = math.min(resp.data['count'] as int, 100); | ||||||
|  |  | ||||||
|       for (int idx = 0; idx < countToFetch; idx += kSingleBatchLoadLimit) { |       for (int idx = 0; idx < countToFetch; idx += kSingleBatchLoadLimit) { | ||||||
|         await getMessages(kSingleBatchLoadLimit, idx, forceRemote: true); |         final out = await getMessages( | ||||||
|  |           kSingleBatchLoadLimit, | ||||||
|  |           idx, | ||||||
|  |           forceRemote: true, | ||||||
|  |         ); | ||||||
|  |         messages.insertAll(0, out); | ||||||
|  |         notifyListeners(); | ||||||
|       } |       } | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       rethrow; |       rethrow; | ||||||
|     } finally { |     } finally { | ||||||
|       await loadMessages(); |       await loadMessages(); | ||||||
|       isLoading = false; |       isAggressiveLoading = false; | ||||||
|  |  | ||||||
|       isCheckedUpdate = true; |       isCheckedUpdate = true; | ||||||
|       _saveMessageToLocal(incomeStrandedQueue).then((_) { |       _saveMessageToLocal(incomeStrandedQueue).then((_) { | ||||||
| @@ -532,7 +565,7 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|         }, |         }, | ||||||
|       ).toJson(), |       ).toJson(), | ||||||
|     )); |     )); | ||||||
|     log('[Messaging] Send read event request: $_readEventAnchor'); |     logging.debug('[Messaging] Send read event request: $_readEventAnchor'); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   | |||||||
| @@ -71,7 +71,8 @@ class PostWriteMedia { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   PostWriteMedia.fromBytes(this.raw, this.name, this.type, {this.attachment, this.file}); |   PostWriteMedia.fromBytes(this.raw, this.name, this.type, | ||||||
|  |       {this.attachment, this.file}); | ||||||
|  |  | ||||||
|   bool get isEmpty => attachment == null && file == null && raw == null; |   bool get isEmpty => attachment == null && file == null && raw == null; | ||||||
|  |  | ||||||
| @@ -105,7 +106,8 @@ class PostWriteMedia { | |||||||
|   }) { |   }) { | ||||||
|     if (attachment != null) { |     if (attachment != null) { | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final ImageProvider provider = UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid)); |       final ImageProvider provider = | ||||||
|  |           UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid)); | ||||||
|       if (width != null && height != null && !kIsWeb) { |       if (width != null && height != null && !kIsWeb) { | ||||||
|         return ResizeImage( |         return ResizeImage( | ||||||
|           provider, |           provider, | ||||||
| @@ -116,7 +118,8 @@ class PostWriteMedia { | |||||||
|       } |       } | ||||||
|       return provider; |       return provider; | ||||||
|     } else if (file != null) { |     } else if (file != null) { | ||||||
|       final ImageProvider provider = kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path)); |       final ImageProvider provider = | ||||||
|  |           kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path)); | ||||||
|       if (width != null && height != null) { |       if (width != null && height != null) { | ||||||
|         return ResizeImage( |         return ResizeImage( | ||||||
|           provider, |           provider, | ||||||
| @@ -159,11 +162,14 @@ class PostWriteController extends ChangeNotifier { | |||||||
|   final TextEditingController aliasController = TextEditingController(); |   final TextEditingController aliasController = TextEditingController(); | ||||||
|   final TextEditingController rewardController = TextEditingController(); |   final TextEditingController rewardController = TextEditingController(); | ||||||
|  |  | ||||||
|   ContentInsertionConfiguration get contentInsertionConfiguration => ContentInsertionConfiguration( |   ContentInsertionConfiguration get contentInsertionConfiguration => | ||||||
|  |       ContentInsertionConfiguration( | ||||||
|         onContentInserted: (KeyboardInsertedContent content) { |         onContentInserted: (KeyboardInsertedContent content) { | ||||||
|           if (content.hasData) { |           if (content.hasData) { | ||||||
|             addAttachments( |             addAttachments([ | ||||||
|                 [PostWriteMedia.fromBytes(content.data!, 'attachmentInsertedImage'.tr(), SnMediaType.image)]); |               PostWriteMedia.fromBytes(content.data!, | ||||||
|  |                   'attachmentInsertedImage'.tr(), SnMediaType.image) | ||||||
|  |             ]); | ||||||
|           } |           } | ||||||
|         }, |         }, | ||||||
|       ); |       ); | ||||||
| @@ -193,7 +199,8 @@ class PostWriteController extends ChangeNotifier { | |||||||
|  |  | ||||||
|   String get description => descriptionController.text; |   String get description => descriptionController.text; | ||||||
|  |  | ||||||
|   bool get isRelatedNull => ![editingPost, repostingPost, replyingPost].any((ele) => ele != null); |   bool get isRelatedNull => | ||||||
|  |       ![editingPost, repostingPost, replyingPost].any((ele) => ele != null); | ||||||
|  |  | ||||||
|   bool isLoading = false, isBusy = false; |   bool isLoading = false, isBusy = false; | ||||||
|   double? progress; |   double? progress; | ||||||
| @@ -201,6 +208,7 @@ class PostWriteController extends ChangeNotifier { | |||||||
|   SnRealm? realm; |   SnRealm? realm; | ||||||
|   SnPublisher? publisher; |   SnPublisher? publisher; | ||||||
|   SnPost? editingPost, repostingPost, replyingPost; |   SnPost? editingPost, repostingPost, replyingPost; | ||||||
|  |   bool editingDraft = false; | ||||||
|  |  | ||||||
|   int visibility = 0; |   int visibility = 0; | ||||||
|   List<int> visibleUsers = List.empty(); |   List<int> visibleUsers = List.empty(); | ||||||
| @@ -237,14 +245,20 @@ class PostWriteController extends ChangeNotifier { | |||||||
|         publishedAt = post.publishedAt; |         publishedAt = post.publishedAt; | ||||||
|         publishedUntil = post.publishedUntil; |         publishedUntil = post.publishedUntil; | ||||||
|         visibleUsers = List.from(post.visibleUsersList ?? [], growable: true); |         visibleUsers = List.from(post.visibleUsersList ?? [], growable: true); | ||||||
|         invisibleUsers = List.from(post.invisibleUsersList ?? [], growable: true); |         invisibleUsers = | ||||||
|  |             List.from(post.invisibleUsersList ?? [], growable: true); | ||||||
|         visibility = post.visibility; |         visibility = post.visibility; | ||||||
|         tags = List.from(post.tags.map((ele) => ele.alias), growable: true); |         tags = List.from(post.tags.map((ele) => ele.alias), growable: true); | ||||||
|         categories = List.from(post.categories.map((ele) => ele.alias), growable: true); |         categories = | ||||||
|         attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []); |             List.from(post.categories.map((ele) => ele.alias), growable: true); | ||||||
|  |         attachments.addAll( | ||||||
|  |             post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []); | ||||||
|         poll = post.preload?.poll; |         poll = post.preload?.poll; | ||||||
|  |  | ||||||
|         if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) { |         editingDraft = post.isDraft; | ||||||
|  |  | ||||||
|  |         if (post.preload?.thumbnail != null && | ||||||
|  |             (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) { | ||||||
|           thumbnail = PostWriteMedia(post.preload!.thumbnail); |           thumbnail = PostWriteMedia(post.preload!.thumbnail); | ||||||
|         } |         } | ||||||
|         if (post.preload?.realm != null) { |         if (post.preload?.realm != null) { | ||||||
| @@ -272,7 +286,8 @@ class PostWriteController extends ChangeNotifier { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<SnAttachment> _uploadAttachment(BuildContext context, PostWriteMedia media, |   Future<SnAttachment> _uploadAttachment( | ||||||
|  |       BuildContext context, PostWriteMedia media, | ||||||
|       {bool isCompressed = false}) async { |       {bool isCompressed = false}) async { | ||||||
|     final attach = context.read<SnAttachmentProvider>(); |     final attach = context.read<SnAttachmentProvider>(); | ||||||
|  |  | ||||||
| @@ -281,7 +296,9 @@ class PostWriteController extends ChangeNotifier { | |||||||
|       media.name, |       media.name, | ||||||
|       'interactive', |       'interactive', | ||||||
|       null, |       null, | ||||||
|       mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null, |       mimetype: media.raw != null && media.type == SnMediaType.image | ||||||
|  |           ? 'image/png' | ||||||
|  |           : null, | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     var item = await attach.chunkedUploadParts( |     var item = await attach.chunkedUploadParts( | ||||||
| @@ -297,9 +314,11 @@ class PostWriteController extends ChangeNotifier { | |||||||
|  |  | ||||||
|     if (media.type == SnMediaType.video && !isCompressed && context.mounted) { |     if (media.type == SnMediaType.video && !isCompressed && context.mounted) { | ||||||
|       try { |       try { | ||||||
|         final compressedAttachment = await _tryCompressVideoCopy(context, media); |         final compressedAttachment = | ||||||
|  |             await _tryCompressVideoCopy(context, media); | ||||||
|         if (compressedAttachment != null) { |         if (compressedAttachment != null) { | ||||||
|           item = await attach.updateOne(item, compressedId: compressedAttachment.id); |           item = await attach.updateOne(item, | ||||||
|  |               compressedId: compressedAttachment.id); | ||||||
|         } |         } | ||||||
|       } catch (err) { |       } catch (err) { | ||||||
|         if (context.mounted) context.showErrorDialog(err); |         if (context.mounted) context.showErrorDialog(err); | ||||||
| @@ -309,8 +328,10 @@ class PostWriteController extends ChangeNotifier { | |||||||
|     return item; |     return item; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<SnAttachment?> _tryCompressVideoCopy(BuildContext context, PostWriteMedia media) async { |   Future<SnAttachment?> _tryCompressVideoCopy( | ||||||
|     if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) return null; |       BuildContext context, PostWriteMedia media) async { | ||||||
|  |     if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) | ||||||
|  |       return null; | ||||||
|     if (media.type != SnMediaType.video) return null; |     if (media.type != SnMediaType.video) return null; | ||||||
|     if (media.file == null) return null; |     if (media.file == null) return null; | ||||||
|     if (VideoCompress.isCompressing) return null; |     if (VideoCompress.isCompressing) return null; | ||||||
| @@ -334,7 +355,8 @@ class PostWriteController extends ChangeNotifier { | |||||||
|     if (!context.mounted) return null; |     if (!context.mounted) return null; | ||||||
|  |  | ||||||
|     final compressedMedia = PostWriteMedia.fromFile(XFile(mediaInfo.path!)); |     final compressedMedia = PostWriteMedia.fromFile(XFile(mediaInfo.path!)); | ||||||
|     final compressedAttachment = await _uploadAttachment(context, compressedMedia, isCompressed: true); |     final compressedAttachment = | ||||||
|  |         await _uploadAttachment(context, compressedMedia, isCompressed: true); | ||||||
|  |  | ||||||
|     return compressedAttachment; |     return compressedAttachment; | ||||||
|   } |   } | ||||||
| @@ -370,18 +392,25 @@ class PostWriteController extends ChangeNotifier { | |||||||
|           'content': contentController.text, |           'content': contentController.text, | ||||||
|           if (aliasController.text.isNotEmpty) 'alias': aliasController.text, |           if (aliasController.text.isNotEmpty) 'alias': aliasController.text, | ||||||
|           if (titleController.text.isNotEmpty) 'title': titleController.text, |           if (titleController.text.isNotEmpty) 'title': titleController.text, | ||||||
|           if (descriptionController.text.isNotEmpty) 'description': descriptionController.text, |           if (descriptionController.text.isNotEmpty) | ||||||
|  |             'description': descriptionController.text, | ||||||
|           if (rewardController.text.isNotEmpty) 'reward': rewardController.text, |           if (rewardController.text.isNotEmpty) 'reward': rewardController.text, | ||||||
|           if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.toJson(), |           if (thumbnail != null && thumbnail!.attachment != null) | ||||||
|           'attachments': |             'thumbnail': thumbnail!.attachment!.toJson(), | ||||||
|               attachments.where((e) => e.attachment != null).map((e) => e.attachment!.toJson()).toList(growable: true), |           'attachments': attachments | ||||||
|  |               .where((e) => e.attachment != null) | ||||||
|  |               .map((e) => e.attachment!.toJson()) | ||||||
|  |               .toList(growable: true), | ||||||
|           'tags': tags.map((ele) => {'alias': ele}).toList(growable: true), |           'tags': tags.map((ele) => {'alias': ele}).toList(growable: true), | ||||||
|           'categories': categories.map((ele) => {'alias': ele}).toList(growable: true), |           'categories': | ||||||
|  |               categories.map((ele) => {'alias': ele}).toList(growable: true), | ||||||
|           'visibility': visibility, |           'visibility': visibility, | ||||||
|           'visible_users_list': visibleUsers, |           'visible_users_list': visibleUsers, | ||||||
|           'invisible_users_list': invisibleUsers, |           'invisible_users_list': invisibleUsers, | ||||||
|           if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(), |           if (publishedAt != null) | ||||||
|           if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(), |             'published_at': publishedAt!.toUtc().toIso8601String(), | ||||||
|  |           if (publishedUntil != null) | ||||||
|  |             'published_until': publishedAt!.toUtc().toIso8601String(), | ||||||
|           if (replyingPost != null) 'reply_to': replyingPost!.toJson(), |           if (replyingPost != null) 'reply_to': replyingPost!.toJson(), | ||||||
|           if (repostingPost != null) 'repost_to': repostingPost!.toJson(), |           if (repostingPost != null) 'repost_to': repostingPost!.toJson(), | ||||||
|           if (poll != null) 'poll': poll!.toJson(), |           if (poll != null) 'poll': poll!.toJson(), | ||||||
| @@ -391,6 +420,12 @@ class PostWriteController extends ChangeNotifier { | |||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   bool get isNotEmpty => | ||||||
|  |       title.isNotEmpty || | ||||||
|  |       description.isNotEmpty || | ||||||
|  |       contentController.text.isNotEmpty || | ||||||
|  |       attachments.isNotEmpty; | ||||||
|  |  | ||||||
|   bool temporaryRestored = false; |   bool temporaryRestored = false; | ||||||
|  |  | ||||||
|   void _temporaryLoad() { |   void _temporaryLoad() { | ||||||
| @@ -403,18 +438,24 @@ class PostWriteController extends ChangeNotifier { | |||||||
|       titleController.text = data['title'] ?? ''; |       titleController.text = data['title'] ?? ''; | ||||||
|       descriptionController.text = data['description'] ?? ''; |       descriptionController.text = data['description'] ?? ''; | ||||||
|       rewardController.text = data['reward']?.toString() ?? ''; |       rewardController.text = data['reward']?.toString() ?? ''; | ||||||
|       if (data['thumbnail'] != null) thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail'])); |       if (data['thumbnail'] != null) | ||||||
|       attachments |         thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail'])); | ||||||
|           .addAll(data['attachments'].map((ele) => PostWriteMedia(SnAttachment.fromJson(ele))).cast<PostWriteMedia>()); |       attachments.addAll(data['attachments'] | ||||||
|  |           .map((ele) => PostWriteMedia(SnAttachment.fromJson(ele))) | ||||||
|  |           .cast<PostWriteMedia>()); | ||||||
|       tags = List.from(data['tags'].map((ele) => ele['alias'])); |       tags = List.from(data['tags'].map((ele) => ele['alias'])); | ||||||
|       categories = List.from(data['categories'].map((ele) => ele['alias'])); |       categories = List.from(data['categories'].map((ele) => ele['alias'])); | ||||||
|       visibility = data['visibility']; |       visibility = data['visibility']; | ||||||
|       visibleUsers = List.from(data['visible_users_list'] ?? []); |       visibleUsers = List.from(data['visible_users_list'] ?? []); | ||||||
|       invisibleUsers = List.from(data['invisible_users_list'] ?? []); |       invisibleUsers = List.from(data['invisible_users_list'] ?? []); | ||||||
|       if (data['published_at'] != null) publishedAt = DateTime.tryParse(data['published_at'])?.toLocal(); |       if (data['published_at'] != null) | ||||||
|       if (data['published_until'] != null) publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal(); |         publishedAt = DateTime.tryParse(data['published_at'])?.toLocal(); | ||||||
|       replyingPost = data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null; |       if (data['published_until'] != null) | ||||||
|       repostingPost = data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null; |         publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal(); | ||||||
|  |       replyingPost = | ||||||
|  |           data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null; | ||||||
|  |       repostingPost = | ||||||
|  |           data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null; | ||||||
|       poll = data['poll'] != null ? SnPoll.fromJson(data['poll']) : null; |       poll = data['poll'] != null ? SnPoll.fromJson(data['poll']) : null; | ||||||
|       realm = data['realm'] != null ? SnRealm.fromJson(data['realm']) : null; |       realm = data['realm'] != null ? SnRealm.fromJson(data['realm']) : null; | ||||||
|       temporaryRestored = true; |       temporaryRestored = true; | ||||||
| @@ -436,7 +477,10 @@ class PostWriteController extends ChangeNotifier { | |||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> sendPost(BuildContext context) async { |   Future<void> sendPost( | ||||||
|  |     BuildContext context, { | ||||||
|  |     bool saveAsDraft = false, | ||||||
|  |   }) async { | ||||||
|     if (isBusy || publisher == null) return; |     if (isBusy || publisher == null) return; | ||||||
|  |  | ||||||
|     final sn = context.read<SnNetworkProvider>(); |     final sn = context.read<SnNetworkProvider>(); | ||||||
| @@ -463,7 +507,9 @@ class PostWriteController extends ChangeNotifier { | |||||||
|           media.name, |           media.name, | ||||||
|           'interactive', |           'interactive', | ||||||
|           null, |           null, | ||||||
|           mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null, |           mimetype: media.raw != null && media.type == SnMediaType.image | ||||||
|  |               ? 'image/png' | ||||||
|  |               : null, | ||||||
|         ); |         ); | ||||||
|  |  | ||||||
|         var item = await attach.chunkedUploadParts( |         var item = await attach.chunkedUploadParts( | ||||||
| @@ -472,16 +518,20 @@ class PostWriteController extends ChangeNotifier { | |||||||
|           place.$2, |           place.$2, | ||||||
|           onProgress: (value) { |           onProgress: (value) { | ||||||
|             // Calculate overall progress for attachments |             // Calculate overall progress for attachments | ||||||
|             progress = math.max(((i + value) / attachments.length) * kAttachmentProgressWeight, value); |             progress = math.max( | ||||||
|  |                 ((i + value) / attachments.length) * kAttachmentProgressWeight, | ||||||
|  |                 value); | ||||||
|             notifyListeners(); |             notifyListeners(); | ||||||
|           }, |           }, | ||||||
|         ); |         ); | ||||||
|  |  | ||||||
|         try { |         try { | ||||||
|           if (context.mounted) { |           if (context.mounted) { | ||||||
|             final compressedAttachment = await _tryCompressVideoCopy(context, media); |             final compressedAttachment = | ||||||
|  |                 await _tryCompressVideoCopy(context, media); | ||||||
|             if (compressedAttachment != null) { |             if (compressedAttachment != null) { | ||||||
|               item = await attach.updateOne(item, compressedId: compressedAttachment.id); |               item = await attach.updateOne(item, | ||||||
|  |                   compressedId: compressedAttachment.id); | ||||||
|             } |             } | ||||||
|           } |           } | ||||||
|         } catch (err) { |         } catch (err) { | ||||||
| @@ -508,7 +558,7 @@ class PostWriteController extends ChangeNotifier { | |||||||
|     // Posting the content |     // Posting the content | ||||||
|     try { |     try { | ||||||
|       final baseProgressVal = progress!; |       final baseProgressVal = progress!; | ||||||
|       await sn.client.request( |       final resp = await sn.client.request( | ||||||
|         [ |         [ | ||||||
|           '/cgi/co/$mode', |           '/cgi/co/$mode', | ||||||
|           if (editingPost != null) '${editingPost!.id}', |           if (editingPost != null) '${editingPost!.id}', | ||||||
| @@ -518,36 +568,56 @@ class PostWriteController extends ChangeNotifier { | |||||||
|           'content': contentController.text, |           'content': contentController.text, | ||||||
|           if (aliasController.text.isNotEmpty) 'alias': aliasController.text, |           if (aliasController.text.isNotEmpty) 'alias': aliasController.text, | ||||||
|           if (titleController.text.isNotEmpty) 'title': titleController.text, |           if (titleController.text.isNotEmpty) 'title': titleController.text, | ||||||
|           if (descriptionController.text.isNotEmpty) 'description': descriptionController.text, |           if (descriptionController.text.isNotEmpty) | ||||||
|           if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid, |             'description': descriptionController.text, | ||||||
|           'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(), |           if (thumbnail != null && thumbnail!.attachment != null) | ||||||
|  |             'thumbnail': thumbnail!.attachment!.rid, | ||||||
|  |           'attachments': attachments | ||||||
|  |               .where((e) => e.attachment != null) | ||||||
|  |               .map((e) => e.attachment!.rid) | ||||||
|  |               .toList(), | ||||||
|           'tags': tags.map((ele) => {'alias': ele}).toList(), |           'tags': tags.map((ele) => {'alias': ele}).toList(), | ||||||
|           'categories': categories.map((ele) => {'alias': ele}).toList(), |           'categories': categories.map((ele) => {'alias': ele}).toList(), | ||||||
|           'visibility': visibility, |           'visibility': visibility, | ||||||
|           'visible_users_list': visibleUsers, |           'visible_users_list': visibleUsers, | ||||||
|           'invisible_users_list': invisibleUsers, |           'invisible_users_list': invisibleUsers, | ||||||
|           if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(), |           if (publishedAt != null) | ||||||
|           if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(), |             'published_at': publishedAt!.toUtc().toIso8601String(), | ||||||
|  |           if (publishedUntil != null) | ||||||
|  |             'published_until': publishedAt!.toUtc().toIso8601String(), | ||||||
|           if (replyingPost != null) 'reply_to': replyingPost!.id, |           if (replyingPost != null) 'reply_to': replyingPost!.id, | ||||||
|           if (repostingPost != null) 'repost_to': repostingPost!.id, |           if (repostingPost != null) 'repost_to': repostingPost!.id, | ||||||
|           if (reward != null) 'reward': reward, |           if (reward != null) 'reward': reward, | ||||||
|           if (videoAttachment != null) 'video': videoAttachment!.rid, |           if (videoAttachment != null) 'video': videoAttachment!.rid, | ||||||
|           if (poll != null) 'poll': poll!.id, |           if (poll != null) 'poll': poll!.id, | ||||||
|           if (realm != null) 'realm': realm!.id, |           if (realm != null) 'realm': realm!.id, | ||||||
|  |           'is_draft': saveAsDraft, | ||||||
|         }, |         }, | ||||||
|         onSendProgress: (count, total) { |         onSendProgress: (count, total) { | ||||||
|           progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2); |           progress = | ||||||
|  |               baseProgressVal + (count / total) * (kPostingProgressWeight / 2); | ||||||
|           notifyListeners(); |           notifyListeners(); | ||||||
|         }, |         }, | ||||||
|         onReceiveProgress: (count, total) { |         onReceiveProgress: (count, total) { | ||||||
|           progress = baseProgressVal + (kPostingProgressWeight / 2) + (count / total) * (kPostingProgressWeight / 2); |           progress = baseProgressVal + | ||||||
|  |               (kPostingProgressWeight / 2) + | ||||||
|  |               (count / total) * (kPostingProgressWeight / 2); | ||||||
|           notifyListeners(); |           notifyListeners(); | ||||||
|         }, |         }, | ||||||
|         options: Options( |         options: Options( | ||||||
|           method: editingPost != null ? 'PUT' : 'POST', |           method: editingPost != null ? 'PUT' : 'POST', | ||||||
|         ), |         ), | ||||||
|       ); |       ); | ||||||
|  |       if (saveAsDraft) { | ||||||
|  |         if (!context.mounted) return; | ||||||
|  |         editingDraft = true; | ||||||
|  |         final out = SnPost.fromJson(resp.data); | ||||||
|  |         final pt = context.read<SnPostContentProvider>(); | ||||||
|  |         editingPost = await pt.completePostData(out); | ||||||
|  |         notifyListeners(); | ||||||
|  |       } else { | ||||||
|         reset(); |         reset(); | ||||||
|  |       } | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!context.mounted) return; |       if (!context.mounted) return; | ||||||
|       context.showErrorDialog(err); |       context.showErrorDialog(err); | ||||||
| @@ -683,7 +753,8 @@ class PostWriteController extends ChangeNotifier { | |||||||
|     repostingPost = null; |     repostingPost = null; | ||||||
|     mode = kTitleMap.keys.first; |     mode = kTitleMap.keys.first; | ||||||
|     temporaryRestored = false; |     temporaryRestored = false; | ||||||
|     SharedPreferences.getInstance().then((prefs) => prefs.remove(kTemporaryStorageKey)); |     SharedPreferences.getInstance() | ||||||
|  |         .then((prefs) => prefs.remove(kTemporaryStorageKey)); | ||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										42
									
								
								lib/database/account.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								lib/database/account.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  |  | ||||||
|  | import 'package:drift/drift.dart'; | ||||||
|  | import 'package:surface/types/account.dart'; | ||||||
|  |  | ||||||
|  | class SnAccountConverter extends TypeConverter<SnAccount, String> | ||||||
|  |     with JsonTypeConverter2<SnAccount, String, Map<String, Object?>> { | ||||||
|  |   const SnAccountConverter(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnAccount fromSql(String fromDb) { | ||||||
|  |     return fromJson(jsonDecode(fromDb) as Map<String, dynamic>); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String toSql(SnAccount value) { | ||||||
|  |     return jsonEncode(toJson(value)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnAccount fromJson(Map<String, Object?> json) { | ||||||
|  |     return SnAccount.fromJson(json); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Map<String, Object?> toJson(SnAccount value) { | ||||||
|  |     return value.toJson(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @TableIndex(name: 'idx_account_name', columns: {#name}) | ||||||
|  | class SnLocalAccount extends Table { | ||||||
|  |   IntColumn get id => integer().autoIncrement()(); | ||||||
|  |  | ||||||
|  |   TextColumn get name => text()(); | ||||||
|  |  | ||||||
|  |   TextColumn get content => text().map(const SnAccountConverter())(); | ||||||
|  |  | ||||||
|  |   DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); | ||||||
|  |  | ||||||
|  |   DateTimeColumn get cacheExpiredAt => dateTime()(); | ||||||
|  | } | ||||||
							
								
								
									
										47
									
								
								lib/database/attachment.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								lib/database/attachment.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  |  | ||||||
|  | import 'package:drift/drift.dart'; | ||||||
|  | import 'package:surface/types/attachment.dart'; | ||||||
|  |  | ||||||
|  | class SnAttachmentConverter extends TypeConverter<SnAttachment, String> | ||||||
|  |     with JsonTypeConverter2<SnAttachment, String, Map<String, Object?>> { | ||||||
|  |   const SnAttachmentConverter(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnAttachment fromSql(String fromDb) { | ||||||
|  |     return fromJson(jsonDecode(fromDb) as Map<String, dynamic>); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String toSql(SnAttachment value) { | ||||||
|  |     return jsonEncode(toJson(value)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnAttachment fromJson(Map<String, Object?> json) { | ||||||
|  |     return SnAttachment.fromJson(json); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Map<String, Object?> toJson(SnAttachment value) { | ||||||
|  |     return value.toJson(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @TableIndex(name: 'idx_attachment_rid', columns: {#rid}) | ||||||
|  | @TableIndex(name: 'idx_attachment_account', columns: {#accountId}) | ||||||
|  | class SnLocalAttachment extends Table { | ||||||
|  |   IntColumn get id => integer().autoIncrement()(); | ||||||
|  |  | ||||||
|  |   TextColumn get rid => text().unique()(); | ||||||
|  |  | ||||||
|  |   TextColumn get uuid => text().unique()(); | ||||||
|  |  | ||||||
|  |   TextColumn get content => text().map(const SnAttachmentConverter())(); | ||||||
|  |  | ||||||
|  |   IntColumn get accountId => integer()(); | ||||||
|  |  | ||||||
|  |   DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); | ||||||
|  |  | ||||||
|  |   DateTimeColumn get cacheExpiredAt => dateTime()(); | ||||||
|  | } | ||||||
| @@ -28,6 +28,7 @@ class SnChannelConverter extends TypeConverter<SnChannel, String> | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @TableIndex(name: 'idx_channel_alias', columns: {#alias}) | ||||||
| class SnLocalChatChannel extends Table { | class SnLocalChatChannel extends Table { | ||||||
|   IntColumn get id => integer().autoIncrement()(); |   IntColumn get id => integer().autoIncrement()(); | ||||||
|  |  | ||||||
| @@ -63,12 +64,54 @@ class SnMessageConverter extends TypeConverter<SnChatMessage, String> | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @TableIndex(name: 'idx_chat_channel', columns: {#channelId}) | ||||||
| class SnLocalChatMessage extends Table { | class SnLocalChatMessage extends Table { | ||||||
|   IntColumn get id => integer().autoIncrement()(); |   IntColumn get id => integer().autoIncrement()(); | ||||||
|  |  | ||||||
|   IntColumn get channelId => integer()(); |   IntColumn get channelId => integer()(); | ||||||
|  |  | ||||||
|  |   IntColumn get senderId => integer().nullable()(); | ||||||
|  |  | ||||||
|   TextColumn get content => text().map(const SnMessageConverter())(); |   TextColumn get content => text().map(const SnMessageConverter())(); | ||||||
|  |  | ||||||
|   DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); |   DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | class SnChannelMemberConverter extends TypeConverter<SnChannelMember, String> | ||||||
|  |     with JsonTypeConverter2<SnChannelMember, String, Map<String, Object?>> { | ||||||
|  |   const SnChannelMemberConverter(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnChannelMember fromSql(String fromDb) { | ||||||
|  |     return fromJson(jsonDecode(fromDb) as Map<String, dynamic>); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String toSql(SnChannelMember value) { | ||||||
|  |     return jsonEncode(toJson(value)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnChannelMember fromJson(Map<String, Object?> json) { | ||||||
|  |     return SnChannelMember.fromJson(json); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Map<String, Object?> toJson(SnChannelMember value) { | ||||||
|  |     return value.toJson(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class SnLocalChannelMember extends Table { | ||||||
|  |   IntColumn get id => integer().autoIncrement()(); | ||||||
|  |  | ||||||
|  |   IntColumn get channelId => integer()(); | ||||||
|  |  | ||||||
|  |   IntColumn get accountId => integer()(); | ||||||
|  |  | ||||||
|  |   TextColumn get content => text().map(SnChannelMemberConverter())(); | ||||||
|  |  | ||||||
|  |   DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); | ||||||
|  |  | ||||||
|  |   DateTimeColumn get cacheExpiredAt => dateTime()(); | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,17 +1,36 @@ | |||||||
| import 'package:drift/drift.dart'; | import 'package:drift/drift.dart'; | ||||||
| import 'package:drift_flutter/drift_flutter.dart'; | import 'package:drift_flutter/drift_flutter.dart'; | ||||||
| import 'package:path_provider/path_provider.dart'; | import 'package:path_provider/path_provider.dart'; | ||||||
|  | import 'package:surface/database/account.dart'; | ||||||
|  | import 'package:surface/database/attachment.dart'; | ||||||
| import 'package:surface/database/chat.dart'; | import 'package:surface/database/chat.dart'; | ||||||
|  | import 'package:surface/database/database.steps.dart'; | ||||||
|  | import 'package:surface/database/keypair.dart'; | ||||||
|  | import 'package:surface/database/realm.dart'; | ||||||
|  | import 'package:surface/database/sticker.dart'; | ||||||
| import 'package:surface/types/chat.dart'; | import 'package:surface/types/chat.dart'; | ||||||
|  | import 'package:surface/types/attachment.dart'; | ||||||
|  | import 'package:surface/types/account.dart'; | ||||||
|  | import 'package:surface/types/realm.dart'; | ||||||
|  |  | ||||||
| part 'database.g.dart'; | part 'database.g.dart'; | ||||||
|  |  | ||||||
| @DriftDatabase(tables: [SnLocalChatChannel, SnLocalChatMessage]) | @DriftDatabase(tables: [ | ||||||
|  |   SnLocalChatChannel, | ||||||
|  |   SnLocalChatMessage, | ||||||
|  |   SnLocalChannelMember, | ||||||
|  |   SnLocalKeyPair, | ||||||
|  |   SnLocalAccount, | ||||||
|  |   SnLocalAttachment, | ||||||
|  |   SnLocalSticker, | ||||||
|  |   SnLocalStickerPack, | ||||||
|  |   SnLocalRealm, | ||||||
|  | ]) | ||||||
| class AppDatabase extends _$AppDatabase { | class AppDatabase extends _$AppDatabase { | ||||||
|   AppDatabase() : super(_openConnection()); |   AppDatabase([QueryExecutor? e]) : super(e ?? _openConnection()); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   int get schemaVersion => 1; |   int get schemaVersion => 4; | ||||||
|  |  | ||||||
|   static QueryExecutor _openConnection() { |   static QueryExecutor _openConnection() { | ||||||
|     return driftDatabase( |     return driftDatabase( | ||||||
| @@ -25,4 +44,19 @@ class AppDatabase extends _$AppDatabase { | |||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   MigrationStrategy get migration { | ||||||
|  |     return MigrationStrategy( | ||||||
|  |       onUpgrade: stepByStep(from1To2: (m, schema) async { | ||||||
|  |         // Nothing else to do here | ||||||
|  |       }, from2To3: (m, schema) async { | ||||||
|  |         // Nothing else to do here, too | ||||||
|  |       }, from3To4: (m, schema) async { | ||||||
|  |         m.createTable(schema.snLocalRealm); | ||||||
|  |         m.createIndex(schema.idxRealmAccount); | ||||||
|  |         m.createIndex(schema.idxRealmAlias); | ||||||
|  |       }), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										657
									
								
								lib/database/database.steps.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										657
									
								
								lib/database/database.steps.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,657 @@ | |||||||
|  | // dart format width=80 | ||||||
|  | import 'package:drift/internal/versioned_schema.dart' as i0; | ||||||
|  | import 'package:drift/drift.dart' as i1; | ||||||
|  | import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import | ||||||
|  |  | ||||||
|  | // GENERATED BY drift_dev, DO NOT MODIFY. | ||||||
|  | final class Schema2 extends i0.VersionedSchema { | ||||||
|  |   Schema2({required super.database}) : super(version: 2); | ||||||
|  |   @override | ||||||
|  |   late final List<i1.DatabaseSchemaEntity> entities = [ | ||||||
|  |     snLocalChatChannel, | ||||||
|  |     snLocalChatMessage, | ||||||
|  |     snLocalKeyPair, | ||||||
|  |   ]; | ||||||
|  |   late final Shape0 snLocalChatChannel = Shape0( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_chat_channel', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_1, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape1 snLocalChatMessage = Shape1( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_chat_message', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_4, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape2 snLocalKeyPair = Shape2( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_key_pair', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [ | ||||||
|  |           'PRIMARY KEY(id)', | ||||||
|  |         ], | ||||||
|  |         columns: [ | ||||||
|  |           _column_5, | ||||||
|  |           _column_6, | ||||||
|  |           _column_7, | ||||||
|  |           _column_8, | ||||||
|  |           _column_9, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class Shape0 extends i0.VersionedTable { | ||||||
|  |   Shape0({required super.source, required super.alias}) : super.aliased(); | ||||||
|  |   i1.GeneratedColumn<int> get id => | ||||||
|  |       columnsByName['id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<String> get alias => | ||||||
|  |       columnsByName['alias']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<String> get content => | ||||||
|  |       columnsByName['content']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get createdAt => | ||||||
|  |       columnsByName['created_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | i1.GeneratedColumn<int> _column_0(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<int>('id', aliasedName, false, | ||||||
|  |         hasAutoIncrement: true, | ||||||
|  |         type: i1.DriftSqlType.int, | ||||||
|  |         defaultConstraints: | ||||||
|  |             i1.GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); | ||||||
|  | i1.GeneratedColumn<String> _column_1(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<String>('alias', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.string); | ||||||
|  | i1.GeneratedColumn<String> _column_2(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<String>('content', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.string); | ||||||
|  | i1.GeneratedColumn<DateTime> _column_3(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<DateTime>('created_at', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.dateTime, | ||||||
|  |         defaultValue: const CustomExpression( | ||||||
|  |             'CAST(strftime(\'%s\', CURRENT_TIMESTAMP) AS INTEGER)')); | ||||||
|  |  | ||||||
|  | class Shape1 extends i0.VersionedTable { | ||||||
|  |   Shape1({required super.source, required super.alias}) : super.aliased(); | ||||||
|  |   i1.GeneratedColumn<int> get id => | ||||||
|  |       columnsByName['id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<int> get channelId => | ||||||
|  |       columnsByName['channel_id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<String> get content => | ||||||
|  |       columnsByName['content']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get createdAt => | ||||||
|  |       columnsByName['created_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | i1.GeneratedColumn<int> _column_4(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<int>('channel_id', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.int); | ||||||
|  |  | ||||||
|  | class Shape2 extends i0.VersionedTable { | ||||||
|  |   Shape2({required super.source, required super.alias}) : super.aliased(); | ||||||
|  |   i1.GeneratedColumn<String> get id => | ||||||
|  |       columnsByName['id']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<int> get accountId => | ||||||
|  |       columnsByName['account_id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<String> get publicKey => | ||||||
|  |       columnsByName['public_key']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<String> get privateKey => | ||||||
|  |       columnsByName['private_key']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<bool> get isActive => | ||||||
|  |       columnsByName['is_active']! as i1.GeneratedColumn<bool>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | i1.GeneratedColumn<String> _column_5(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<String>('id', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.string); | ||||||
|  | i1.GeneratedColumn<int> _column_6(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<int>('account_id', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.int); | ||||||
|  | i1.GeneratedColumn<String> _column_7(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<String>('public_key', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.string); | ||||||
|  | i1.GeneratedColumn<String> _column_8(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<String>('private_key', aliasedName, true, | ||||||
|  |         type: i1.DriftSqlType.string); | ||||||
|  | i1.GeneratedColumn<bool> _column_9(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<bool>('is_active', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.bool, | ||||||
|  |         defaultConstraints: i1.GeneratedColumn.constraintIsAlways( | ||||||
|  |             'CHECK ("is_active" IN (0, 1))'), | ||||||
|  |         defaultValue: const CustomExpression('0')); | ||||||
|  |  | ||||||
|  | final class Schema3 extends i0.VersionedSchema { | ||||||
|  |   Schema3({required super.database}) : super(version: 3); | ||||||
|  |   @override | ||||||
|  |   late final List<i1.DatabaseSchemaEntity> entities = [ | ||||||
|  |     snLocalChatChannel, | ||||||
|  |     snLocalChatMessage, | ||||||
|  |     snLocalChannelMember, | ||||||
|  |     snLocalKeyPair, | ||||||
|  |     snLocalAccount, | ||||||
|  |     snLocalAttachment, | ||||||
|  |     snLocalSticker, | ||||||
|  |     snLocalStickerPack, | ||||||
|  |     idxChannelAlias, | ||||||
|  |     idxChatChannel, | ||||||
|  |     idxAccountName, | ||||||
|  |     idxAttachmentRid, | ||||||
|  |     idxAttachmentAccount, | ||||||
|  |   ]; | ||||||
|  |   late final Shape0 snLocalChatChannel = Shape0( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_chat_channel', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_1, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape3 snLocalChatMessage = Shape3( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_chat_message', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_4, | ||||||
|  |           _column_10, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape4 snLocalChannelMember = Shape4( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_channel_member', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_4, | ||||||
|  |           _column_6, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |           _column_11, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape2 snLocalKeyPair = Shape2( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_key_pair', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [ | ||||||
|  |           'PRIMARY KEY(id)', | ||||||
|  |         ], | ||||||
|  |         columns: [ | ||||||
|  |           _column_5, | ||||||
|  |           _column_6, | ||||||
|  |           _column_7, | ||||||
|  |           _column_8, | ||||||
|  |           _column_9, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape5 snLocalAccount = Shape5( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_account', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_12, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |           _column_11, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape6 snLocalAttachment = Shape6( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_attachment', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_13, | ||||||
|  |           _column_14, | ||||||
|  |           _column_2, | ||||||
|  |           _column_6, | ||||||
|  |           _column_3, | ||||||
|  |           _column_11, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape7 snLocalSticker = Shape7( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_sticker', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_1, | ||||||
|  |           _column_15, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape8 snLocalStickerPack = Shape8( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_sticker_pack', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   final i1.Index idxChannelAlias = i1.Index('idx_channel_alias', | ||||||
|  |       'CREATE INDEX idx_channel_alias ON sn_local_chat_channel (alias)'); | ||||||
|  |   final i1.Index idxChatChannel = i1.Index('idx_chat_channel', | ||||||
|  |       'CREATE INDEX idx_chat_channel ON sn_local_chat_message (channel_id)'); | ||||||
|  |   final i1.Index idxAccountName = i1.Index('idx_account_name', | ||||||
|  |       'CREATE INDEX idx_account_name ON sn_local_account (name)'); | ||||||
|  |   final i1.Index idxAttachmentRid = i1.Index('idx_attachment_rid', | ||||||
|  |       'CREATE INDEX idx_attachment_rid ON sn_local_attachment (rid)'); | ||||||
|  |   final i1.Index idxAttachmentAccount = i1.Index('idx_attachment_account', | ||||||
|  |       'CREATE INDEX idx_attachment_account ON sn_local_attachment (account_id)'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class Shape3 extends i0.VersionedTable { | ||||||
|  |   Shape3({required super.source, required super.alias}) : super.aliased(); | ||||||
|  |   i1.GeneratedColumn<int> get id => | ||||||
|  |       columnsByName['id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<int> get channelId => | ||||||
|  |       columnsByName['channel_id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<int> get senderId => | ||||||
|  |       columnsByName['sender_id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<String> get content => | ||||||
|  |       columnsByName['content']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get createdAt => | ||||||
|  |       columnsByName['created_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | i1.GeneratedColumn<int> _column_10(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<int>('sender_id', aliasedName, true, | ||||||
|  |         type: i1.DriftSqlType.int); | ||||||
|  |  | ||||||
|  | class Shape4 extends i0.VersionedTable { | ||||||
|  |   Shape4({required super.source, required super.alias}) : super.aliased(); | ||||||
|  |   i1.GeneratedColumn<int> get id => | ||||||
|  |       columnsByName['id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<int> get channelId => | ||||||
|  |       columnsByName['channel_id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<int> get accountId => | ||||||
|  |       columnsByName['account_id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<String> get content => | ||||||
|  |       columnsByName['content']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get createdAt => | ||||||
|  |       columnsByName['created_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get cacheExpiredAt => | ||||||
|  |       columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | i1.GeneratedColumn<DateTime> _column_11(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<DateTime>('cache_expired_at', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.dateTime); | ||||||
|  |  | ||||||
|  | class Shape5 extends i0.VersionedTable { | ||||||
|  |   Shape5({required super.source, required super.alias}) : super.aliased(); | ||||||
|  |   i1.GeneratedColumn<int> get id => | ||||||
|  |       columnsByName['id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<String> get name => | ||||||
|  |       columnsByName['name']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<String> get content => | ||||||
|  |       columnsByName['content']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get createdAt => | ||||||
|  |       columnsByName['created_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get cacheExpiredAt => | ||||||
|  |       columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | i1.GeneratedColumn<String> _column_12(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<String>('name', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.string); | ||||||
|  |  | ||||||
|  | class Shape6 extends i0.VersionedTable { | ||||||
|  |   Shape6({required super.source, required super.alias}) : super.aliased(); | ||||||
|  |   i1.GeneratedColumn<int> get id => | ||||||
|  |       columnsByName['id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<String> get rid => | ||||||
|  |       columnsByName['rid']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<String> get uuid => | ||||||
|  |       columnsByName['uuid']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<String> get content => | ||||||
|  |       columnsByName['content']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<int> get accountId => | ||||||
|  |       columnsByName['account_id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get createdAt => | ||||||
|  |       columnsByName['created_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get cacheExpiredAt => | ||||||
|  |       columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | i1.GeneratedColumn<String> _column_13(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<String>('rid', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.string, | ||||||
|  |         defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE')); | ||||||
|  | i1.GeneratedColumn<String> _column_14(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<String>('uuid', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.string, | ||||||
|  |         defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE')); | ||||||
|  |  | ||||||
|  | class Shape7 extends i0.VersionedTable { | ||||||
|  |   Shape7({required super.source, required super.alias}) : super.aliased(); | ||||||
|  |   i1.GeneratedColumn<int> get id => | ||||||
|  |       columnsByName['id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<String> get alias => | ||||||
|  |       columnsByName['alias']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<String> get fullAlias => | ||||||
|  |       columnsByName['full_alias']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<String> get content => | ||||||
|  |       columnsByName['content']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get createdAt => | ||||||
|  |       columnsByName['created_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | i1.GeneratedColumn<String> _column_15(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<String>('full_alias', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.string); | ||||||
|  |  | ||||||
|  | class Shape8 extends i0.VersionedTable { | ||||||
|  |   Shape8({required super.source, required super.alias}) : super.aliased(); | ||||||
|  |   i1.GeneratedColumn<int> get id => | ||||||
|  |       columnsByName['id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<String> get content => | ||||||
|  |       columnsByName['content']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get createdAt => | ||||||
|  |       columnsByName['created_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | final class Schema4 extends i0.VersionedSchema { | ||||||
|  |   Schema4({required super.database}) : super(version: 4); | ||||||
|  |   @override | ||||||
|  |   late final List<i1.DatabaseSchemaEntity> entities = [ | ||||||
|  |     snLocalChatChannel, | ||||||
|  |     snLocalChatMessage, | ||||||
|  |     snLocalChannelMember, | ||||||
|  |     snLocalKeyPair, | ||||||
|  |     snLocalAccount, | ||||||
|  |     snLocalAttachment, | ||||||
|  |     snLocalSticker, | ||||||
|  |     snLocalStickerPack, | ||||||
|  |     snLocalRealm, | ||||||
|  |     idxChannelAlias, | ||||||
|  |     idxChatChannel, | ||||||
|  |     idxAccountName, | ||||||
|  |     idxAttachmentRid, | ||||||
|  |     idxAttachmentAccount, | ||||||
|  |     idxRealmAlias, | ||||||
|  |     idxRealmAccount, | ||||||
|  |   ]; | ||||||
|  |   late final Shape0 snLocalChatChannel = Shape0( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_chat_channel', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_1, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape3 snLocalChatMessage = Shape3( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_chat_message', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_4, | ||||||
|  |           _column_10, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape4 snLocalChannelMember = Shape4( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_channel_member', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_4, | ||||||
|  |           _column_6, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |           _column_11, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape2 snLocalKeyPair = Shape2( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_key_pair', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [ | ||||||
|  |           'PRIMARY KEY(id)', | ||||||
|  |         ], | ||||||
|  |         columns: [ | ||||||
|  |           _column_5, | ||||||
|  |           _column_6, | ||||||
|  |           _column_7, | ||||||
|  |           _column_8, | ||||||
|  |           _column_9, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape5 snLocalAccount = Shape5( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_account', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_12, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |           _column_11, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape6 snLocalAttachment = Shape6( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_attachment', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_13, | ||||||
|  |           _column_14, | ||||||
|  |           _column_2, | ||||||
|  |           _column_6, | ||||||
|  |           _column_3, | ||||||
|  |           _column_11, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape7 snLocalSticker = Shape7( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_sticker', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_1, | ||||||
|  |           _column_15, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape8 snLocalStickerPack = Shape8( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_sticker_pack', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape9 snLocalRealm = Shape9( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_realm', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_16, | ||||||
|  |           _column_2, | ||||||
|  |           _column_6, | ||||||
|  |           _column_3, | ||||||
|  |           _column_11, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   final i1.Index idxChannelAlias = i1.Index('idx_channel_alias', | ||||||
|  |       'CREATE INDEX idx_channel_alias ON sn_local_chat_channel (alias)'); | ||||||
|  |   final i1.Index idxChatChannel = i1.Index('idx_chat_channel', | ||||||
|  |       'CREATE INDEX idx_chat_channel ON sn_local_chat_message (channel_id)'); | ||||||
|  |   final i1.Index idxAccountName = i1.Index('idx_account_name', | ||||||
|  |       'CREATE INDEX idx_account_name ON sn_local_account (name)'); | ||||||
|  |   final i1.Index idxAttachmentRid = i1.Index('idx_attachment_rid', | ||||||
|  |       'CREATE INDEX idx_attachment_rid ON sn_local_attachment (rid)'); | ||||||
|  |   final i1.Index idxAttachmentAccount = i1.Index('idx_attachment_account', | ||||||
|  |       'CREATE INDEX idx_attachment_account ON sn_local_attachment (account_id)'); | ||||||
|  |   final i1.Index idxRealmAlias = i1.Index('idx_realm_alias', | ||||||
|  |       'CREATE INDEX idx_realm_alias ON sn_local_realm (alias)'); | ||||||
|  |   final i1.Index idxRealmAccount = i1.Index('idx_realm_account', | ||||||
|  |       'CREATE INDEX idx_realm_account ON sn_local_realm (account_id)'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class Shape9 extends i0.VersionedTable { | ||||||
|  |   Shape9({required super.source, required super.alias}) : super.aliased(); | ||||||
|  |   i1.GeneratedColumn<int> get id => | ||||||
|  |       columnsByName['id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<String> get alias => | ||||||
|  |       columnsByName['alias']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<String> get content => | ||||||
|  |       columnsByName['content']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<int> get accountId => | ||||||
|  |       columnsByName['account_id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get createdAt => | ||||||
|  |       columnsByName['created_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get cacheExpiredAt => | ||||||
|  |       columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | i1.GeneratedColumn<String> _column_16(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<String>('alias', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.string, | ||||||
|  |         defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE')); | ||||||
|  | i0.MigrationStepWithVersion migrationSteps({ | ||||||
|  |   required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, | ||||||
|  |   required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, | ||||||
|  |   required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4, | ||||||
|  | }) { | ||||||
|  |   return (currentVersion, database) async { | ||||||
|  |     switch (currentVersion) { | ||||||
|  |       case 1: | ||||||
|  |         final schema = Schema2(database: database); | ||||||
|  |         final migrator = i1.Migrator(database, schema); | ||||||
|  |         await from1To2(migrator, schema); | ||||||
|  |         return 2; | ||||||
|  |       case 2: | ||||||
|  |         final schema = Schema3(database: database); | ||||||
|  |         final migrator = i1.Migrator(database, schema); | ||||||
|  |         await from2To3(migrator, schema); | ||||||
|  |         return 3; | ||||||
|  |       case 3: | ||||||
|  |         final schema = Schema4(database: database); | ||||||
|  |         final migrator = i1.Migrator(database, schema); | ||||||
|  |         await from3To4(migrator, schema); | ||||||
|  |         return 4; | ||||||
|  |       default: | ||||||
|  |         throw ArgumentError.value('Unknown migration from $currentVersion'); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | i1.OnUpgrade stepByStep({ | ||||||
|  |   required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, | ||||||
|  |   required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, | ||||||
|  |   required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4, | ||||||
|  | }) => | ||||||
|  |     i0.VersionedSchema.stepByStepHelper( | ||||||
|  |         step: migrationSteps( | ||||||
|  |       from1To2: from1To2, | ||||||
|  |       from2To3: from2To3, | ||||||
|  |       from3To4: from3To4, | ||||||
|  |     )); | ||||||
							
								
								
									
										16
									
								
								lib/database/keypair.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								lib/database/keypair.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | import 'package:drift/drift.dart'; | ||||||
|  |  | ||||||
|  | class SnLocalKeyPair extends Table { | ||||||
|  |   TextColumn get id => text()(); | ||||||
|  |  | ||||||
|  |   IntColumn get accountId => integer()(); | ||||||
|  |  | ||||||
|  |   TextColumn get publicKey => text()(); | ||||||
|  |  | ||||||
|  |   TextColumn get privateKey => text().nullable()(); | ||||||
|  |  | ||||||
|  |   BoolColumn get isActive => boolean().withDefault(Constant(false))(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Set<Column<Object>> get primaryKey => {id}; | ||||||
|  | } | ||||||
							
								
								
									
										45
									
								
								lib/database/realm.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								lib/database/realm.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  |  | ||||||
|  | import 'package:drift/drift.dart'; | ||||||
|  | import 'package:surface/types/realm.dart'; | ||||||
|  |  | ||||||
|  | class SnRealmConverter extends TypeConverter<SnRealm, String> | ||||||
|  |     with JsonTypeConverter2<SnRealm, String, Map<String, Object?>> { | ||||||
|  |   const SnRealmConverter(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnRealm fromSql(String fromDb) { | ||||||
|  |     return fromJson(jsonDecode(fromDb) as Map<String, dynamic>); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String toSql(SnRealm value) { | ||||||
|  |     return jsonEncode(toJson(value)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnRealm fromJson(Map<String, Object?> json) { | ||||||
|  |     return SnRealm.fromJson(json); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Map<String, Object?> toJson(SnRealm value) { | ||||||
|  |     return value.toJson(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @TableIndex(name: 'idx_realm_alias', columns: {#alias}) | ||||||
|  | @TableIndex(name: 'idx_realm_account', columns: {#accountId}) | ||||||
|  | class SnLocalRealm extends Table { | ||||||
|  |   IntColumn get id => integer().autoIncrement()(); | ||||||
|  |  | ||||||
|  |   TextColumn get alias => text().unique()(); | ||||||
|  |  | ||||||
|  |   TextColumn get content => text().map(const SnRealmConverter())(); | ||||||
|  |  | ||||||
|  |   IntColumn get accountId => integer()(); | ||||||
|  |  | ||||||
|  |   DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); | ||||||
|  |  | ||||||
|  |   DateTimeColumn get cacheExpiredAt => dateTime()(); | ||||||
|  | } | ||||||
							
								
								
									
										74
									
								
								lib/database/sticker.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								lib/database/sticker.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,74 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  |  | ||||||
|  | import 'package:drift/drift.dart'; | ||||||
|  | import 'package:surface/types/attachment.dart'; | ||||||
|  |  | ||||||
|  | class SnStickerConverter extends TypeConverter<SnSticker, String> | ||||||
|  |     with JsonTypeConverter2<SnSticker, String, Map<String, Object?>> { | ||||||
|  |   const SnStickerConverter(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnSticker fromSql(String fromDb) { | ||||||
|  |     return fromJson(jsonDecode(fromDb) as Map<String, dynamic>); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String toSql(SnSticker value) { | ||||||
|  |     return jsonEncode(toJson(value)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnSticker fromJson(Map<String, Object?> json) { | ||||||
|  |     return SnSticker.fromJson(json); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Map<String, Object?> toJson(SnSticker value) { | ||||||
|  |     return value.toJson(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class SnLocalSticker extends Table { | ||||||
|  |   IntColumn get id => integer().autoIncrement()(); | ||||||
|  |  | ||||||
|  |   TextColumn get alias => text()(); | ||||||
|  |  | ||||||
|  |   TextColumn get fullAlias => text()(); | ||||||
|  |  | ||||||
|  |   TextColumn get content => text().map(const SnStickerConverter())(); | ||||||
|  |  | ||||||
|  |   DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class SnStickerPackConverter extends TypeConverter<SnStickerPack, String> | ||||||
|  |     with JsonTypeConverter2<SnStickerPack, String, Map<String, Object?>> { | ||||||
|  |   const SnStickerPackConverter(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnStickerPack fromSql(String fromDb) { | ||||||
|  |     return fromJson(jsonDecode(fromDb) as Map<String, dynamic>); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String toSql(SnStickerPack value) { | ||||||
|  |     return jsonEncode(toJson(value)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnStickerPack fromJson(Map<String, Object?> json) { | ||||||
|  |     return SnStickerPack.fromJson(json); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Map<String, Object?> toJson(SnStickerPack value) { | ||||||
|  |     return value.toJson(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class SnLocalStickerPack extends Table { | ||||||
|  |   IntColumn get id => integer().autoIncrement()(); | ||||||
|  |  | ||||||
|  |   TextColumn get content => text().map(const SnStickerPackConverter())(); | ||||||
|  |  | ||||||
|  |   DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); | ||||||
|  | } | ||||||
							
								
								
									
										10
									
								
								lib/logger.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								lib/logger.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | |||||||
|  | import 'package:talker/talker.dart'; | ||||||
|  |  | ||||||
|  | final logging = Talker( | ||||||
|  |   settings: TalkerSettings( | ||||||
|  |     enabled: true, | ||||||
|  |     useHistory: true, | ||||||
|  |     maxHistoryItems: 1000, | ||||||
|  |     useConsoleLogs: true, | ||||||
|  |   ), | ||||||
|  | ); | ||||||
							
								
								
									
										231
									
								
								lib/main.dart
									
									
									
									
									
								
							
							
						
						
									
										231
									
								
								lib/main.dart
									
									
									
									
									
								
							| @@ -3,6 +3,7 @@ import 'dart:developer'; | |||||||
| import 'dart:io'; | import 'dart:io'; | ||||||
| import 'dart:ui'; | import 'dart:ui'; | ||||||
|  |  | ||||||
|  | import 'package:audioplayers/audioplayers.dart'; | ||||||
| import 'package:bitsdojo_window/bitsdojo_window.dart'; | import 'package:bitsdojo_window/bitsdojo_window.dart'; | ||||||
| import 'package:croppy/croppy.dart'; | import 'package:croppy/croppy.dart'; | ||||||
| import 'package:dio/dio.dart'; | import 'package:dio/dio.dart'; | ||||||
| @@ -12,6 +13,7 @@ import 'package:firebase_core/firebase_core.dart'; | |||||||
| import 'package:flutter/foundation.dart'; | import 'package:flutter/foundation.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter/services.dart'; | import 'package:flutter/services.dart'; | ||||||
|  | import 'package:gap/gap.dart'; | ||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
| import 'package:hotkey_manager/hotkey_manager.dart'; | import 'package:hotkey_manager/hotkey_manager.dart'; | ||||||
| import 'package:package_info_plus/package_info_plus.dart'; | import 'package:package_info_plus/package_info_plus.dart'; | ||||||
| @@ -19,11 +21,14 @@ import 'package:provider/provider.dart'; | |||||||
| import 'package:relative_time/relative_time.dart'; | import 'package:relative_time/relative_time.dart'; | ||||||
| import 'package:responsive_framework/responsive_framework.dart'; | import 'package:responsive_framework/responsive_framework.dart'; | ||||||
| import 'package:shared_preferences/shared_preferences.dart'; | import 'package:shared_preferences/shared_preferences.dart'; | ||||||
|  | import 'package:styled_widget/styled_widget.dart'; | ||||||
| import 'package:surface/firebase_options.dart'; | import 'package:surface/firebase_options.dart'; | ||||||
|  | import 'package:surface/logger.dart'; | ||||||
| import 'package:surface/providers/channel.dart'; | import 'package:surface/providers/channel.dart'; | ||||||
| import 'package:surface/providers/chat_call.dart'; | import 'package:surface/providers/chat_call.dart'; | ||||||
| import 'package:surface/providers/config.dart'; | import 'package:surface/providers/config.dart'; | ||||||
| import 'package:surface/providers/database.dart'; | import 'package:surface/providers/database.dart'; | ||||||
|  | import 'package:surface/providers/keypair.dart'; | ||||||
| import 'package:surface/providers/link_preview.dart'; | import 'package:surface/providers/link_preview.dart'; | ||||||
| import 'package:surface/providers/navigation.dart'; | import 'package:surface/providers/navigation.dart'; | ||||||
| import 'package:surface/providers/notification.dart'; | import 'package:surface/providers/notification.dart'; | ||||||
| @@ -35,6 +40,7 @@ import 'package:surface/providers/sn_realm.dart'; | |||||||
| import 'package:surface/providers/sn_sticker.dart'; | import 'package:surface/providers/sn_sticker.dart'; | ||||||
| import 'package:surface/providers/special_day.dart'; | import 'package:surface/providers/special_day.dart'; | ||||||
| import 'package:surface/providers/theme.dart'; | import 'package:surface/providers/theme.dart'; | ||||||
|  | import 'package:surface/providers/translation.dart'; | ||||||
| import 'package:surface/providers/user_directory.dart'; | import 'package:surface/providers/user_directory.dart'; | ||||||
| import 'package:surface/providers/userinfo.dart'; | import 'package:surface/providers/userinfo.dart'; | ||||||
| import 'package:surface/providers/websocket.dart'; | import 'package:surface/providers/websocket.dart'; | ||||||
| @@ -42,6 +48,8 @@ import 'package:surface/providers/widget.dart'; | |||||||
| import 'package:surface/router.dart'; | import 'package:surface/router.dart'; | ||||||
| import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy; | import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy; | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
|  | import 'package:surface/widgets/menu_bar.dart'; | ||||||
|  | import 'package:surface/widgets/version_label.dart'; | ||||||
| import 'package:tray_manager/tray_manager.dart'; | import 'package:tray_manager/tray_manager.dart'; | ||||||
| import 'package:version/version.dart'; | import 'package:version/version.dart'; | ||||||
| import 'package:workmanager/workmanager.dart'; | import 'package:workmanager/workmanager.dart'; | ||||||
| @@ -67,13 +75,40 @@ void appBackgroundDispatcher() { | |||||||
|   }); |   }); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // Desktop size tools | ||||||
|  |  | ||||||
|  | Future<Size> _getSavedWindowSize() async { | ||||||
|  |   final prefs = await SharedPreferences.getInstance(); | ||||||
|  |   String? sizeString = prefs.getString(kAppWindowSize); | ||||||
|  |  | ||||||
|  |   if (sizeString != null) { | ||||||
|  |     List<String> parts = sizeString.split('x'); | ||||||
|  |     if (parts.length == 2) { | ||||||
|  |       double? width = double.tryParse(parts[0]); | ||||||
|  |       double? height = double.tryParse(parts[1]); | ||||||
|  |       if (width != null && height != null) { | ||||||
|  |         return Size(width, height); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return const Size(1280, 720); // Default size | ||||||
|  | } | ||||||
|  |  | ||||||
|  | Future<void> _saveWindowSize() async { | ||||||
|  |   final prefs = await SharedPreferences.getInstance(); | ||||||
|  |   final size = appWindow.size; | ||||||
|  |   await prefs.setString(kAppWindowSize, '${size.width}x${size.height}'); | ||||||
|  | } | ||||||
|  |  | ||||||
| void main() async { | void main() async { | ||||||
|   WidgetsFlutterBinding.ensureInitialized(); |   WidgetsFlutterBinding.ensureInitialized(); | ||||||
|  |  | ||||||
|   if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { |   if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { | ||||||
|  |     final Size savedSize = await _getSavedWindowSize(); | ||||||
|     doWhenWindowReady(() { |     doWhenWindowReady(() { | ||||||
|       appWindow.minSize = Size(480, 640); |       appWindow.minSize = Size(480, 640); | ||||||
|       appWindow.size = Size(1280, 720); |       appWindow.size = savedSize; | ||||||
|       appWindow.alignment = Alignment.center; |       appWindow.alignment = Alignment.center; | ||||||
|       appWindow.show(); |       appWindow.show(); | ||||||
|     }); |     }); | ||||||
| @@ -83,18 +118,15 @@ void main() async { | |||||||
|  |  | ||||||
|   if (!kIsWeb && !Platform.isLinux) { |   if (!kIsWeb && !Platform.isLinux) { | ||||||
|     await Firebase.initializeApp( |     await Firebase.initializeApp( | ||||||
|       options: DefaultFirebaseOptions.currentPlatform, |         options: DefaultFirebaseOptions.currentPlatform); | ||||||
|     ); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   GoRouter.optionURLReflectsImperativeAPIs = true; |   GoRouter.optionURLReflectsImperativeAPIs = true; | ||||||
|   usePathUrlStrategy(); |   usePathUrlStrategy(); | ||||||
|  |  | ||||||
|   if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { |   if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { | ||||||
|     Workmanager().initialize( |     Workmanager() | ||||||
|       appBackgroundDispatcher, |         .initialize(appBackgroundDispatcher, isInDebugMode: kDebugMode); | ||||||
|       isInDebugMode: kDebugMode, |  | ||||||
|     ); |  | ||||||
|     if (Platform.isAndroid) { |     if (Platform.isAndroid) { | ||||||
|       Workmanager().registerPeriodicTask( |       Workmanager().registerPeriodicTask( | ||||||
|         "widget-update-random-post", |         "widget-update-random-post", | ||||||
| @@ -129,7 +161,7 @@ class SolianApp extends StatelessWidget { | |||||||
|           Locale('en', 'US'), |           Locale('en', 'US'), | ||||||
|           Locale('zh', 'CN'), |           Locale('zh', 'CN'), | ||||||
|           Locale('zh', 'TW'), |           Locale('zh', 'TW'), | ||||||
|           Locale('zh', 'HK'), |           Locale('zh', 'HK') | ||||||
|         ], |         ], | ||||||
|         fallbackLocale: Locale('en', 'US'), |         fallbackLocale: Locale('en', 'US'), | ||||||
|         useFallbackTranslations: true, |         useFallbackTranslations: true, | ||||||
| @@ -153,16 +185,18 @@ class SolianApp extends StatelessWidget { | |||||||
|             Provider(create: (ctx) => SnNetworkProvider(ctx)), |             Provider(create: (ctx) => SnNetworkProvider(ctx)), | ||||||
|             Provider(create: (ctx) => UserDirectoryProvider(ctx)), |             Provider(create: (ctx) => UserDirectoryProvider(ctx)), | ||||||
|             Provider(create: (ctx) => SnAttachmentProvider(ctx)), |             Provider(create: (ctx) => SnAttachmentProvider(ctx)), | ||||||
|             Provider(create: (ctx) => SnRealmProvider(ctx)), |             ChangeNotifierProvider(create: (ctx) => SnRealmProvider(ctx)), | ||||||
|             Provider(create: (ctx) => SnPostContentProvider(ctx)), |             Provider(create: (ctx) => SnPostContentProvider(ctx)), | ||||||
|             Provider(create: (ctx) => SnRelationshipProvider(ctx)), |             Provider(create: (ctx) => SnRelationshipProvider(ctx)), | ||||||
|             Provider(create: (ctx) => SnLinkPreviewProvider(ctx)), |             Provider(create: (ctx) => SnLinkPreviewProvider(ctx)), | ||||||
|             Provider(create: (ctx) => SnStickerProvider(ctx)), |             Provider(create: (ctx) => SnStickerProvider(ctx)), | ||||||
|             ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)), |             ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)), | ||||||
|             ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)), |             ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)), | ||||||
|  |             Provider(create: (ctx) => KeyPairProvider(ctx)), | ||||||
|             ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)), |             ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)), | ||||||
|             ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)), |             ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)), | ||||||
|             ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)), |             ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)), | ||||||
|  |             Provider(create: (ctx) => SnTranslator()), | ||||||
|  |  | ||||||
|             // Additional helper layer |             // Additional helper layer | ||||||
|             Provider(create: (ctx) => SpecialDayProvider(ctx)), |             Provider(create: (ctx) => SpecialDayProvider(ctx)), | ||||||
| @@ -222,6 +256,9 @@ class _AppSplashScreen extends StatefulWidget { | |||||||
| } | } | ||||||
|  |  | ||||||
| class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | ||||||
|  |   bool _isBusy = false; | ||||||
|  |   String _phaseText = 'appInitStarting'; | ||||||
|  |  | ||||||
|   void _tryRequestRating() async { |   void _tryRequestRating() async { | ||||||
|     final prefs = await SharedPreferences.getInstance(); |     final prefs = await SharedPreferences.getInstance(); | ||||||
|     if (prefs.containsKey('first_boot_time')) { |     if (prefs.containsKey('first_boot_time')) { | ||||||
| @@ -235,7 +272,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | |||||||
|           await inAppReview.requestReview(); |           await inAppReview.requestReview(); | ||||||
|           prefs.setBool('rating_requested', true); |           prefs.setBool('rating_requested', true); | ||||||
|         } else { |         } else { | ||||||
|           log('Unable request app review, unavailable'); |           logging.error('Unable request app review, unavailable'); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } else { |     } else { | ||||||
| @@ -251,11 +288,9 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | |||||||
|       final resp = await Dio( |       final resp = await Dio( | ||||||
|         BaseOptions( |         BaseOptions( | ||||||
|             sendTimeout: const Duration(seconds: 60), |             sendTimeout: const Duration(seconds: 60), | ||||||
|           receiveTimeout: const Duration(seconds: 60), |             receiveTimeout: const Duration(seconds: 60)), | ||||||
|         ), |  | ||||||
|       ).get( |       ).get( | ||||||
|         'https://api.github.com/repos/Solsynth/HyperNet.Surface/releases/latest', |           'https://api.github.com/repos/Solsynth/HyperNet.Surface/releases/latest'); | ||||||
|       ); |  | ||||||
|       final remoteVersionString = resp.data?['tag_name'] ?? '0.0.0+0'; |       final remoteVersionString = resp.data?['tag_name'] ?? '0.0.0+0'; | ||||||
|       final remoteVersion = Version.parse(remoteVersionString.split('+').first); |       final remoteVersion = Version.parse(remoteVersionString.split('+').first); | ||||||
|       final localVersion = Version.parse(localVersionString.split('+').first); |       final localVersion = Version.parse(localVersionString.split('+').first); | ||||||
| @@ -263,21 +298,27 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | |||||||
|           int.tryParse(remoteVersionString.split('+').last) ?? 0; |           int.tryParse(remoteVersionString.split('+').last) ?? 0; | ||||||
|       final localBuildNumber = |       final localBuildNumber = | ||||||
|           int.tryParse(localVersionString.split('+').last) ?? 0; |           int.tryParse(localVersionString.split('+').last) ?? 0; | ||||||
|       log("[Update] Local: $localVersionString, Remote: $remoteVersionString"); |       logging.info( | ||||||
|  |           "[Update] Local: $localVersionString, Remote: $remoteVersionString"); | ||||||
|       if ((remoteVersion > localVersion || |       if ((remoteVersion > localVersion || | ||||||
|               remoteBuildNumber > localBuildNumber) && |               remoteBuildNumber > localBuildNumber) && | ||||||
|           mounted) { |           mounted) { | ||||||
|         final config = context.read<ConfigProvider>(); |         final config = context.read<ConfigProvider>(); | ||||||
|         config.setUpdate( |         config.setUpdate( | ||||||
|             remoteVersionString, resp.data?['body'] ?? 'No changelog'); |             remoteVersionString, resp.data?['body'] ?? 'No changelog'); | ||||||
|         log("[Update] Update available: $remoteVersionString"); |         logging.info("[Update] Update available: $remoteVersionString"); | ||||||
|       } |       } | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       log('[Error] Unable to check update: $e'); |       logging.error('[Error] Unable to check update...', e); | ||||||
|       if (mounted) context.showErrorDialog('Unable to check update: $e'); |       if (mounted) context.showErrorDialog('Unable to check update: $e'); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   void _setPhaseText(String text) { | ||||||
|  |     _phaseText = 'appInit${text.capitalize()}'.tr(); | ||||||
|  |     if (mounted) setState(() {}); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   Future<void> _initialize() async { |   Future<void> _initialize() async { | ||||||
|     try { |     try { | ||||||
|       final cfg = context.read<ConfigProvider>(); |       final cfg = context.read<ConfigProvider>(); | ||||||
| @@ -290,23 +331,52 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | |||||||
|       // The Network initialization must be done after the HomeWidget initialization |       // The Network initialization must be done after the HomeWidget initialization | ||||||
|       // The Network initialization will save the server url to the HomeWidget |       // The Network initialization will save the server url to the HomeWidget | ||||||
|       // The Network initialization will also save initialize the Config, so it not need to be initialized again |       // The Network initialization will also save initialize the Config, so it not need to be initialized again | ||||||
|  |       _setPhaseText('network'); | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       await sn.initializeUserAgent(); |       await sn.initializeUserAgent(); | ||||||
|       await sn.setConfigWithNative(); |       await sn.setConfigWithNative(); | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|  |       _setPhaseText('userdata'); | ||||||
|       final ua = context.read<UserProvider>(); |       final ua = context.read<UserProvider>(); | ||||||
|       await ua.initialize(); |       await ua.initialize(); | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|  |       _setPhaseText('websocket'); | ||||||
|       final ws = context.read<WebSocketProvider>(); |       final ws = context.read<WebSocketProvider>(); | ||||||
|       await ws.tryConnect(); |       await ws.tryConnect(); | ||||||
|  |       try { | ||||||
|         if (!mounted) return; |         if (!mounted) return; | ||||||
|  |         _setPhaseText('keyPair'); | ||||||
|  |         final kp = context.read<KeyPairProvider>(); | ||||||
|  |         await kp.reloadActive(); | ||||||
|  |         kp.listen(); | ||||||
|  |       } catch (_) {} | ||||||
|  |       if (ua.isAuthorized) { | ||||||
|  |         if (!mounted) return; | ||||||
|  |         _setPhaseText('notification'); | ||||||
|         final notify = context.read<NotificationProvider>(); |         final notify = context.read<NotificationProvider>(); | ||||||
|         notify.listen(); |         notify.listen(); | ||||||
|       await notify.registerPushNotifications(); |         try { | ||||||
|  |           notify.registerPushNotifications(); | ||||||
|  |         } catch (_) {} | ||||||
|         if (!mounted) return; |         if (!mounted) return; | ||||||
|  |         _setPhaseText('stickers'); | ||||||
|         final sticker = context.read<SnStickerProvider>(); |         final sticker = context.read<SnStickerProvider>(); | ||||||
|         await sticker.listSticker(); |         await sticker.listSticker(); | ||||||
|       log('[Bootstrap] Everything initialized!'); |         if (!mounted) return; | ||||||
|  |         _setPhaseText('userDirectory'); | ||||||
|  |         final ud = context.read<UserDirectoryProvider>(); | ||||||
|  |         await ud.loadAccountCache(); | ||||||
|  |         if (!mounted) return; | ||||||
|  |         _setPhaseText('realm'); | ||||||
|  |         final rm = context.read<SnRealmProvider>(); | ||||||
|  |         await rm.refreshAvailableRealms(); | ||||||
|  |         if (!mounted) return; | ||||||
|  |         _setPhaseText('chat'); | ||||||
|  |         final ct = context.read<ChatChannelProvider>(); | ||||||
|  |         await ct.refreshAvailableChannels(); | ||||||
|  |         _setPhaseText('done'); | ||||||
|  |         _playIntro(); | ||||||
|  |       } | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       await context.showErrorDialog(err); |       await context.showErrorDialog(err); | ||||||
| @@ -319,42 +389,31 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | |||||||
|  |  | ||||||
|   Future<void> _hotkeyInitialization() async { |   Future<void> _hotkeyInitialization() async { | ||||||
|     if (kIsWeb) return; |     if (kIsWeb) return; | ||||||
|  |     // The quit key has been removed, and the logic of the quit key is moved to system menu bar activator. | ||||||
|     if (Platform.isMacOS) { |  | ||||||
|       HotKey quitHotKey = HotKey( |  | ||||||
|         key: PhysicalKeyboardKey.keyQ, |  | ||||||
|         modifiers: [HotKeyModifier.meta], |  | ||||||
|         scope: HotKeyScope.inapp, |  | ||||||
|       ); |  | ||||||
|       await hotKeyManager.register(quitHotKey, keyUpHandler: (_) { |  | ||||||
|         _appLifecycleListener?.dispose(); |  | ||||||
|         SystemChannels.platform.invokeMethod('SystemNavigator.pop'); |  | ||||||
|       }); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   void _playIntro() async { | ||||||
|  |     final cfg = context.read<ConfigProvider>(); | ||||||
|  |     if (!cfg.soundEffects) return; | ||||||
|  |  | ||||||
|  |     final player = AudioPlayer(playerId: 'launch-intro-player'); | ||||||
|  |     await player.play(AssetSource('audio/sfx/launch-intro.mp3'), volume: 0.5); | ||||||
|  |     player.onPlayerComplete.listen((_) { | ||||||
|  |       player.dispose(); | ||||||
|  |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   final Menu _appTrayMenu = Menu( |   final Menu _appTrayMenu = Menu( | ||||||
|     items: [ |     items: [ | ||||||
|       MenuItem( |       MenuItem(key: 'version_label', label: 'Solian', disabled: true), | ||||||
|         key: 'version_label', |  | ||||||
|         label: 'Solian', |  | ||||||
|         disabled: true, |  | ||||||
|       ), |  | ||||||
|       MenuItem.separator(), |       MenuItem.separator(), | ||||||
|       MenuItem.checkbox( |       MenuItem.checkbox( | ||||||
|           checked: false, |           checked: false, | ||||||
|           key: 'mute_notification', |           key: 'mute_notification', | ||||||
|         label: 'trayMenuMuteNotification'.tr(), |           label: 'trayMenuMuteNotification'.tr()), | ||||||
|       ), |  | ||||||
|       MenuItem.separator(), |       MenuItem.separator(), | ||||||
|       MenuItem( |       MenuItem(key: 'window_show', label: 'trayMenuShow'.tr()), | ||||||
|         key: 'window_show', |       MenuItem(key: 'exit', label: 'trayMenuExit'.tr()), | ||||||
|         label: 'trayMenuShow'.tr(), |  | ||||||
|       ), |  | ||||||
|       MenuItem( |  | ||||||
|         key: 'exit', |  | ||||||
|         label: 'trayMenuExit'.tr(), |  | ||||||
|       ), |  | ||||||
|     ], |     ], | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
| @@ -382,9 +441,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | |||||||
|     if (kIsWeb || Platform.isAndroid || Platform.isIOS) return; |     if (kIsWeb || Platform.isAndroid || Platform.isIOS) return; | ||||||
|  |  | ||||||
|     await localNotifier.setup( |     await localNotifier.setup( | ||||||
|       appName: 'Solian', |         appName: 'Solian', shortcutPolicy: ShortcutPolicy.requireCreate); | ||||||
|       shortcutPolicy: ShortcutPolicy.requireCreate, |  | ||||||
|     ); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   AppLifecycleListener? _appLifecycleListener; |   AppLifecycleListener? _appLifecycleListener; | ||||||
| @@ -393,10 +450,10 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | |||||||
|   void initState() { |   void initState() { | ||||||
|     super.initState(); |     super.initState(); | ||||||
|  |  | ||||||
|  |     _isBusy = true; | ||||||
|     if (!kIsWeb && !(Platform.isIOS || Platform.isAndroid)) { |     if (!kIsWeb && !(Platform.isIOS || Platform.isAndroid)) { | ||||||
|       _appLifecycleListener = AppLifecycleListener( |       _appLifecycleListener = | ||||||
|         onExitRequested: _onExitRequested, |           AppLifecycleListener(onExitRequested: _onExitRequested); | ||||||
|       ); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     _trayInitialization(); |     _trayInitialization(); | ||||||
| @@ -406,6 +463,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | |||||||
|       _postInitialization(); |       _postInitialization(); | ||||||
|       _tryRequestRating(); |       _tryRequestRating(); | ||||||
|       _checkForUpdate(); |       _checkForUpdate(); | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -414,6 +472,16 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | |||||||
|     return AppExitResponse.cancel; |     return AppExitResponse.cancel; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   void _quitApp() { | ||||||
|  |     _saveWindowSize(); | ||||||
|  |     _appLifecycleListener?.dispose(); | ||||||
|  |     if (Platform.isWindows) { | ||||||
|  |       appWindow.close(); | ||||||
|  |     } else { | ||||||
|  |       SystemChannels.platform.invokeMethod('SystemNavigator.pop'); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void onTrayIconMouseDown() { |   void onTrayIconMouseDown() { | ||||||
|     if (Platform.isWindows) { |     if (Platform.isWindows) { | ||||||
| @@ -448,12 +516,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | |||||||
|         Timer(const Duration(milliseconds: 100), () => appWindow.show()); |         Timer(const Duration(milliseconds: 100), () => appWindow.show()); | ||||||
|         break; |         break; | ||||||
|       case 'exit': |       case 'exit': | ||||||
|         _appLifecycleListener?.dispose(); |         _quitApp(); | ||||||
|         if (Platform.isWindows) { |  | ||||||
|           appWindow.close(); |  | ||||||
|         } else { |  | ||||||
|           SystemChannels.platform.invokeMethod('SystemNavigator.pop'); |  | ||||||
|         } |  | ||||||
|         break; |         break; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| @@ -470,7 +533,9 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     final cfg = context.read<ConfigProvider>(); |     final cfg = context.read<ConfigProvider>(); | ||||||
|     return NotificationListener<SizeChangedLayoutNotification>( |     return AppSystemMenuBar( | ||||||
|  |       onQuit: _quitApp, | ||||||
|  |       child: NotificationListener<SizeChangedLayoutNotification>( | ||||||
|         onNotification: (notification) { |         onNotification: (notification) { | ||||||
|           WidgetsBinding.instance.addPostFrameCallback((_) { |           WidgetsBinding.instance.addPostFrameCallback((_) { | ||||||
|             cfg.calcDrawerSize(context); |             cfg.calcDrawerSize(context); | ||||||
| @@ -483,11 +548,59 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | |||||||
|             WidgetsBinding.instance.addPostFrameCallback((_) { |             WidgetsBinding.instance.addPostFrameCallback((_) { | ||||||
|               cfg.calcDrawerSize(context); |               cfg.calcDrawerSize(context); | ||||||
|             }); |             }); | ||||||
|  |             Future.delayed(const Duration(milliseconds: 300), () { | ||||||
|  |               if (context.mounted) { | ||||||
|  |                 cfg.calcDrawerSize(context); | ||||||
|  |               } | ||||||
|  |             }); | ||||||
|             return SizeChangedLayoutNotifier( |             return SizeChangedLayoutNotifier( | ||||||
|             child: widget.child, |               child: _isBusy | ||||||
|  |                   ? Material( | ||||||
|  |                       key: Key('app-splash-screen-$_isBusy'), | ||||||
|  |                       child: Stack( | ||||||
|  |                         children: [ | ||||||
|  |                           Container( | ||||||
|  |                             decoration: BoxDecoration( | ||||||
|  |                               image: DecorationImage( | ||||||
|  |                                 image: AssetImage('assets/icon/kanban-1st.jpg'), | ||||||
|  |                                 fit: BoxFit.cover, | ||||||
|  |                                 opacity: 0.1, | ||||||
|  |                               ), | ||||||
|  |                               color: Theme.of(context).colorScheme.surface, | ||||||
|  |                               backgroundBlendMode: BlendMode.darken, | ||||||
|  |                             ), | ||||||
|  |                           ), | ||||||
|  |                           Center( | ||||||
|  |                             child: Container( | ||||||
|  |                               constraints: const BoxConstraints(maxWidth: 240), | ||||||
|  |                               child: Column( | ||||||
|  |                                 mainAxisSize: MainAxisSize.min, | ||||||
|  |                                 children: [ | ||||||
|  |                                   Image.asset( | ||||||
|  |                                     'assets/icon/icon.png', | ||||||
|  |                                     width: 64, | ||||||
|  |                                     height: 64, | ||||||
|  |                                     color: | ||||||
|  |                                         Theme.of(context).colorScheme.onSurface, | ||||||
|  |                                   ), | ||||||
|  |                                   Text('Solar Network').bold(), | ||||||
|  |                                   AppVersionLabel(), | ||||||
|  |                                   Gap(8), | ||||||
|  |                                   Text(_phaseText, textAlign: TextAlign.center), | ||||||
|  |                                   Gap(16), | ||||||
|  |                                   const LinearProgressIndicator(), | ||||||
|  |                                 ], | ||||||
|  |                               ), | ||||||
|  |                             ), | ||||||
|  |                           ), | ||||||
|  |                         ], | ||||||
|  |                       ), | ||||||
|  |                     ) | ||||||
|  |                   : widget.child, | ||||||
|             ); |             ); | ||||||
|           }, |           }, | ||||||
|         ), |         ), | ||||||
|  |       ), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -8,6 +8,7 @@ import 'package:surface/providers/database.dart'; | |||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/providers/sn_realm.dart'; | import 'package:surface/providers/sn_realm.dart'; | ||||||
| import 'package:surface/providers/user_directory.dart'; | import 'package:surface/providers/user_directory.dart'; | ||||||
|  | import 'package:surface/providers/userinfo.dart'; | ||||||
| import 'package:surface/types/chat.dart'; | import 'package:surface/types/chat.dart'; | ||||||
|  |  | ||||||
| class ChatChannelProvider extends ChangeNotifier { | class ChatChannelProvider extends ChangeNotifier { | ||||||
| @@ -15,16 +16,36 @@ class ChatChannelProvider extends ChangeNotifier { | |||||||
|  |  | ||||||
|   late final SnNetworkProvider _sn; |   late final SnNetworkProvider _sn; | ||||||
|   late final UserDirectoryProvider _ud; |   late final UserDirectoryProvider _ud; | ||||||
|  |   late final UserProvider _ua; | ||||||
|   late final DatabaseProvider _dt; |   late final DatabaseProvider _dt; | ||||||
|   late final SnRealmProvider _rels; |   late final SnRealmProvider _rels; | ||||||
|  |  | ||||||
|   ChatChannelProvider(BuildContext context) { |   ChatChannelProvider(BuildContext context) { | ||||||
|     _sn = context.read<SnNetworkProvider>(); |     _sn = context.read<SnNetworkProvider>(); | ||||||
|     _ud = context.read<UserDirectoryProvider>(); |     _ud = context.read<UserDirectoryProvider>(); | ||||||
|  |     _ua = context.read<UserProvider>(); | ||||||
|     _dt = context.read<DatabaseProvider>(); |     _dt = context.read<DatabaseProvider>(); | ||||||
|     _rels = context.read<SnRealmProvider>(); |     _rels = context.read<SnRealmProvider>(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   final List<SnChannel> _availableChannels = List.empty(growable: true); | ||||||
|  |  | ||||||
|  |   List<SnChannel> get availableChannels => _availableChannels; | ||||||
|  |  | ||||||
|  |   Future<void> refreshAvailableChannels() async { | ||||||
|  |     final stream = fetchChannels(); | ||||||
|  |     stream.listen((ele) { | ||||||
|  |       _availableChannels.clear(); | ||||||
|  |       _availableChannels.addAll(ele); | ||||||
|  |       notifyListeners(); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void addAvailableChannel(SnChannel channel) { | ||||||
|  |     _availableChannels.add(channel); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async { |   Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async { | ||||||
|     await Future.wait( |     await Future.wait( | ||||||
|       channels.map( |       channels.map( | ||||||
| @@ -149,4 +170,60 @@ class ChatChannelProvider extends ChangeNotifier { | |||||||
|     await _ud.listAccount(out.map((ele) => ele.sender.accountId).toSet()); |     await _ud.listAccount(out.map((ele) => ele.sender.accountId).toSet()); | ||||||
|     return out; |     return out; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   Future<void> _saveMemberToLocal(Iterable<SnChannelMember> members) async { | ||||||
|  |     final queries = members.map((ele) { | ||||||
|  |       return _dt.db.snLocalChannelMember.insertOne( | ||||||
|  |         SnLocalChannelMemberCompanion.insert( | ||||||
|  |           id: Value(ele.id), | ||||||
|  |           channelId: ele.channelId, | ||||||
|  |           accountId: ele.accountId, | ||||||
|  |           content: ele, | ||||||
|  |           cacheExpiredAt: DateTime.now().add(const Duration(days: 7)), | ||||||
|  |         ), | ||||||
|  |         onConflict: DoUpdate( | ||||||
|  |           (_) => SnLocalChannelMemberCompanion.custom( | ||||||
|  |             content: Constant(jsonEncode(ele.toJson())), | ||||||
|  |             cacheExpiredAt: | ||||||
|  |                 Constant(DateTime.now().add(const Duration(days: 7))), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |     await Future.wait(queries); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> removeLocalChannel(SnChannel channel) async { | ||||||
|  |     await _dt.db.transaction(() async { | ||||||
|  |       await (_dt.db.snLocalChannelMember.delete() | ||||||
|  |             ..where((e) => e.channelId.equals(channel.id))) | ||||||
|  |           .go(); | ||||||
|  |       await (_dt.db.snLocalChatChannel.delete() | ||||||
|  |             ..where((e) => e.id.equals(channel.id))) | ||||||
|  |           .go(); | ||||||
|  |       await (_dt.db.snLocalChatMessage.delete() | ||||||
|  |             ..where((e) => e.channelId.equals(channel.id))) | ||||||
|  |           .go(); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> updateChannelProfile(SnChannelMember member) { | ||||||
|  |     return _saveMemberToLocal([member]); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<SnChannelMember> getChannelProfile(SnChannel channel) async { | ||||||
|  |     if (_ua.user == null) throw Exception('User not logged in'); | ||||||
|  |     final local = await (_dt.db.snLocalChannelMember.select() | ||||||
|  |           ..where((e) => e.channelId.equals(channel.id)) | ||||||
|  |           ..where((e) => e.accountId.equals(_ua.user!.id))) | ||||||
|  |         .getSingleOrNull(); | ||||||
|  |     if (local != null) { | ||||||
|  |       return local.content; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     final resp = await _sn.client.get('/cgi/im/channels/${channel.keyPath}/me'); | ||||||
|  |     final out = SnChannelMember.fromJson(resp.data); | ||||||
|  |     _saveMemberToLocal([out]); | ||||||
|  |     return out; | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -18,6 +18,13 @@ const kAppNotifyWithHaptic = 'app_notify_with_haptic'; | |||||||
| const kAppExpandPostLink = 'app_expand_post_link'; | const kAppExpandPostLink = 'app_expand_post_link'; | ||||||
| const kAppExpandChatLink = 'app_expand_chat_link'; | const kAppExpandChatLink = 'app_expand_chat_link'; | ||||||
| const kAppRealmCompactView = 'app_realm_compact_view'; | const kAppRealmCompactView = 'app_realm_compact_view'; | ||||||
|  | const kAppCustomFonts = 'app_custom_fonts'; | ||||||
|  | const kAppMixedFeed = 'app_mixed_feed'; | ||||||
|  | const kAppAutoTranslate = 'app_auto_translate'; | ||||||
|  | const kAppHideBottomNav = 'app_hide_bottom_nav'; | ||||||
|  | const kAppSoundEffects = 'app_sound_effects'; | ||||||
|  | const kAppAprilFoolFeatures = 'app_april_fool_features'; | ||||||
|  | const kAppWindowSize = 'app_window_size'; | ||||||
|  |  | ||||||
| const Map<String, FilterQuality> kImageQualityLevel = { | const Map<String, FilterQuality> kImageQualityLevel = { | ||||||
|   'settingsImageQualityLowest': FilterQuality.none, |   'settingsImageQualityLowest': FilterQuality.none, | ||||||
| @@ -80,8 +87,54 @@ class ConfigProvider extends ChangeNotifier { | |||||||
|     return prefs.getBool(kAppRealmCompactView) ?? false; |     return prefs.getBool(kAppRealmCompactView) ?? false; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   bool get mixedFeed { | ||||||
|  |     return prefs.getBool(kAppMixedFeed) ?? true; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   bool get autoTranslate { | ||||||
|  |     return prefs.getBool(kAppAutoTranslate) ?? false; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   bool get hideBottomNav { | ||||||
|  |     return prefs.getBool(kAppHideBottomNav) ?? false; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   bool get aprilFoolFeatures { | ||||||
|  |     return prefs.getBool(kAppAprilFoolFeatures) ?? true; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   bool get soundEffects { | ||||||
|  |     return prefs.getBool(kAppSoundEffects) ?? true; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   set soundEffects(bool value) { | ||||||
|  |     prefs.setBool(kAppSoundEffects, value); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   set aprilFoolFeatures(bool value) { | ||||||
|  |     prefs.setBool(kAppAprilFoolFeatures, value); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   set hideBottomNav(bool value) { | ||||||
|  |     prefs.setBool(kAppHideBottomNav, value); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   set autoTranslate(bool value) { | ||||||
|  |     prefs.setBool(kAppAutoTranslate, value); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   set mixedFeed(bool value) { | ||||||
|  |     prefs.setBool(kAppMixedFeed, value); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   set realmCompactView(bool value) { |   set realmCompactView(bool value) { | ||||||
|     prefs.setBool(kAppRealmCompactView, value); |     prefs.setBool(kAppRealmCompactView, value); | ||||||
|  |     notifyListeners(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   set serverUrl(String url) { |   set serverUrl(String url) { | ||||||
|   | |||||||
							
								
								
									
										245
									
								
								lib/providers/keypair.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										245
									
								
								lib/providers/keypair.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,245 @@ | |||||||
|  | import 'dart:async'; | ||||||
|  | import 'dart:convert'; | ||||||
|  |  | ||||||
|  | import 'package:drift/drift.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/database/database.dart'; | ||||||
|  | import 'package:surface/logger.dart'; | ||||||
|  | import 'package:surface/providers/database.dart'; | ||||||
|  | import 'package:surface/providers/userinfo.dart'; | ||||||
|  | import 'package:surface/providers/websocket.dart'; | ||||||
|  | import 'package:surface/types/keypair.dart'; | ||||||
|  | import 'package:fast_rsa/fast_rsa.dart'; | ||||||
|  | import 'package:surface/types/websocket.dart'; | ||||||
|  | import 'package:uuid/uuid.dart'; | ||||||
|  |  | ||||||
|  | // Currently the keypair only provide RSA encryption | ||||||
|  | // Supported by the `fast_rsa` package | ||||||
|  | class KeyPairProvider { | ||||||
|  |   late final DatabaseProvider _dt; | ||||||
|  |   late final UserProvider _ua; | ||||||
|  |   late final WebSocketProvider _ws; | ||||||
|  |  | ||||||
|  |   SnKeyPair? activeKp; | ||||||
|  |  | ||||||
|  |   KeyPairProvider(BuildContext context) { | ||||||
|  |     _dt = context.read<DatabaseProvider>(); | ||||||
|  |     _ua = context.read<UserProvider>(); | ||||||
|  |     _ws = context.read<WebSocketProvider>(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void listen() { | ||||||
|  |     _ws.pk.stream.listen((event) { | ||||||
|  |       switch (event.method) { | ||||||
|  |         case 'kex.ack': | ||||||
|  |           ackKeyExchange(event); | ||||||
|  |           break; | ||||||
|  |         case 'kex.ask': | ||||||
|  |           replyAskKeyExchange(event); | ||||||
|  |           break; | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<String> decryptText(String text, String kpId, {int? kpOwner}) async { | ||||||
|  |     String? publicKey; | ||||||
|  |     final kp = await (_dt.db.snLocalKeyPair.select() | ||||||
|  |           ..where((e) => e.id.equals(kpId))) | ||||||
|  |         .getSingleOrNull(); | ||||||
|  |     if (kp == null) { | ||||||
|  |       if (kpOwner != null) { | ||||||
|  |         final out = await askKeyExchange(kpOwner, kpId); | ||||||
|  |         publicKey = out.publicKey; | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       publicKey = kp.publicKey; | ||||||
|  |     } | ||||||
|  |     if (publicKey == null) { | ||||||
|  |       throw Exception('Key pair not found'); | ||||||
|  |     } | ||||||
|  |     return await RSA.decryptPKCS1v15(text, publicKey); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<String> encryptText(String text) async { | ||||||
|  |     if (activeKp == null) throw Exception('No active key pair'); | ||||||
|  |     return await RSA.encryptPKCS1v15(text, activeKp!.privateKey!); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   final Map<String, Completer<SnKeyPair>> _requests = {}; | ||||||
|  |  | ||||||
|  |   Future<SnKeyPair> askKeyExchange(int kpOwner, String kpId) async { | ||||||
|  |     if (_requests.containsKey(kpId)) return await _requests[kpId]!.future; | ||||||
|  |  | ||||||
|  |     final completer = Completer<SnKeyPair>(); | ||||||
|  |     _requests[kpId] = completer; | ||||||
|  |  | ||||||
|  |     _ws.conn?.sink.add( | ||||||
|  |       jsonEncode(WebSocketPackage( | ||||||
|  |         method: 'kex.ask', | ||||||
|  |         endpoint: 'id', | ||||||
|  |         payload: { | ||||||
|  |           'keypair_id': kpId, | ||||||
|  |           'user_id': kpOwner, | ||||||
|  |         }, | ||||||
|  |       )), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     return Future.any([ | ||||||
|  |       _requests[kpId]!.future, | ||||||
|  |       Future.delayed(const Duration(seconds: 60), () { | ||||||
|  |         _requests.remove(kpId); | ||||||
|  |         throw TimeoutException("Key exchange timed out"); | ||||||
|  |       }), | ||||||
|  |     ]); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> ackKeyExchange(WebSocketPackage pkt) async { | ||||||
|  |     if (pkt.payload == null) return; | ||||||
|  |     final kpMeta = SnKeyPair( | ||||||
|  |       id: pkt.payload!['keypair_id'] as String, | ||||||
|  |       accountId: pkt.payload!['user_id'] as int, | ||||||
|  |       publicKey: pkt.payload!['public_key'] as String, | ||||||
|  |       privateKey: pkt.payload?['private_key'] as String?, | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (_requests.containsKey(kpMeta.id)) { | ||||||
|  |       _requests[kpMeta.id]!.complete(kpMeta); | ||||||
|  |       _requests.remove(kpMeta.id); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Save the keypair to the local database | ||||||
|  |     await _dt.db.snLocalKeyPair.insertOne( | ||||||
|  |       SnLocalKeyPairCompanion.insert( | ||||||
|  |         id: kpMeta.id, | ||||||
|  |         accountId: kpMeta.accountId, | ||||||
|  |         publicKey: kpMeta.publicKey, | ||||||
|  |         privateKey: Value(kpMeta.privateKey), | ||||||
|  |       ), | ||||||
|  |       onConflict: DoNothing(), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> replyAskKeyExchange(WebSocketPackage pkt) async { | ||||||
|  |     final kpId = pkt.payload!['keypair_id'] as String; | ||||||
|  |     final userId = pkt.payload!['user_id'] as int; | ||||||
|  |     final clientId = pkt.payload!['client_id'] as String; | ||||||
|  |  | ||||||
|  |     final localKp = await (_dt.db.snLocalKeyPair.select() | ||||||
|  |           ..where((e) => e.id.equals(kpId)) | ||||||
|  |           ..limit(1)) | ||||||
|  |         .getSingleOrNull(); | ||||||
|  |     if (localKp == null) return; | ||||||
|  |  | ||||||
|  |     logging.info( | ||||||
|  |       '[Kex] Reply to key exchange request of $kpId from user $userId', | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     // We do not give the private key to the client | ||||||
|  |     _ws.conn?.sink.add(jsonEncode( | ||||||
|  |       WebSocketPackage( | ||||||
|  |         method: 'kex.ack', | ||||||
|  |         endpoint: 'id', | ||||||
|  |         payload: { | ||||||
|  |           'keypair_id': localKp.id, | ||||||
|  |           'user_id': localKp.accountId, | ||||||
|  |           'public_key': localKp.publicKey, | ||||||
|  |           'client_id': clientId, | ||||||
|  |         }, | ||||||
|  |       ).toJson(), | ||||||
|  |     )); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<SnKeyPair?> reloadActive({bool autoEnroll = true}) async { | ||||||
|  |     final kp = await (_dt.db.snLocalKeyPair.select() | ||||||
|  |           ..where((e) => e.accountId.equals(_ua.user!.id)) | ||||||
|  |           ..where((e) => e.privateKey.isNotNull()) | ||||||
|  |           ..where((e) => e.isActive.equals(true)) | ||||||
|  |           ..limit(1)) | ||||||
|  |         .getSingleOrNull(); | ||||||
|  |  | ||||||
|  |     if (kp != null) { | ||||||
|  |       activeKp = SnKeyPair( | ||||||
|  |         id: kp.id, | ||||||
|  |         accountId: kp.accountId, | ||||||
|  |         publicKey: kp.publicKey, | ||||||
|  |         privateKey: kp.privateKey, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (kp == null && autoEnroll) { | ||||||
|  |       return await enrollNew(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return activeKp; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<List<SnKeyPair>> listKeyPair() async { | ||||||
|  |     final kps = await (_dt.db.snLocalKeyPair.select()).get(); | ||||||
|  |     return kps | ||||||
|  |         .map((e) => SnKeyPair( | ||||||
|  |               id: e.id, | ||||||
|  |               accountId: e.accountId, | ||||||
|  |               publicKey: e.publicKey, | ||||||
|  |               privateKey: e.privateKey, | ||||||
|  |               isActive: e.isActive, | ||||||
|  |             )) | ||||||
|  |         .toList(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> activeKeyPair(String kpId) async { | ||||||
|  |     final kp = await (_dt.db.snLocalKeyPair.select() | ||||||
|  |           ..where((e) => e.id.equals(kpId)) | ||||||
|  |           ..where((e) => e.privateKey.isNotNull()) | ||||||
|  |           ..limit(1)) | ||||||
|  |         .getSingleOrNull(); | ||||||
|  |     if (kp == null) return; | ||||||
|  |  | ||||||
|  |     await _dt.db.transaction(() async { | ||||||
|  |       await (_dt.db.update(_dt.db.snLocalKeyPair) | ||||||
|  |             ..where((e) => e.isActive.equals(true))) | ||||||
|  |           .write(SnLocalKeyPairCompanion(isActive: Value(false))); | ||||||
|  |  | ||||||
|  |       await (_dt.db.update(_dt.db.snLocalKeyPair) | ||||||
|  |             ..where((e) => e.id.equals(kp.id))) | ||||||
|  |           .write(SnLocalKeyPairCompanion(isActive: Value(true))); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<SnKeyPair> enrollNew() async { | ||||||
|  |     if (!_ua.isAuthorized) throw Exception('Unauthorized'); | ||||||
|  |  | ||||||
|  |     final id = const Uuid().v4(); | ||||||
|  |     final kp = await RSA.generate(2048); | ||||||
|  |     final kpMeta = SnKeyPair( | ||||||
|  |       id: id, | ||||||
|  |       accountId: _ua.user!.id, | ||||||
|  |       // This is work as expected | ||||||
|  |       // We need to share private key to let everyone can decode the message | ||||||
|  |       publicKey: kp.privateKey, | ||||||
|  |       privateKey: kp.publicKey, | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     // Save the keypair to the local database | ||||||
|  |     // If there is already one with private key, it will be overwritten | ||||||
|  |     await _dt.db.transaction(() async { | ||||||
|  |       await (_dt.db.update(_dt.db.snLocalKeyPair) | ||||||
|  |             ..where((e) => e.isActive.equals(true))) | ||||||
|  |           .write(SnLocalKeyPairCompanion(isActive: Value(false))); | ||||||
|  |  | ||||||
|  |       await _dt.db.snLocalKeyPair.insertOne( | ||||||
|  |         SnLocalKeyPairCompanion.insert( | ||||||
|  |           id: kpMeta.id, | ||||||
|  |           accountId: kpMeta.accountId, | ||||||
|  |           publicKey: kpMeta.publicKey, | ||||||
|  |           privateKey: Value(kpMeta.privateKey), | ||||||
|  |           isActive: Value(true), | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     await reloadActive(autoEnroll: false); | ||||||
|  |  | ||||||
|  |     return kpMeta; | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,8 +1,8 @@ | |||||||
| import 'dart:convert'; | import 'dart:convert'; | ||||||
| import 'dart:developer'; |  | ||||||
|  |  | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/logger.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/types/link.dart'; | import 'package:surface/types/link.dart'; | ||||||
|  |  | ||||||
| @@ -20,7 +20,7 @@ class SnLinkPreviewProvider { | |||||||
|     final target = b64.encode(url); |     final target = b64.encode(url); | ||||||
|     if (_cache.containsKey(target)) return _cache[target]; |     if (_cache.containsKey(target)) return _cache[target]; | ||||||
|  |  | ||||||
|     log('[LinkPreview] Fetching $url ($target)'); |     logging.debug('[LinkPreview] Fetching $url ($target)'); | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       final resp = await _sn.client.get('/cgi/re/link/$target'); |       final resp = await _sn.client.get('/cgi/re/link/$target'); | ||||||
| @@ -28,7 +28,7 @@ class SnLinkPreviewProvider { | |||||||
|       _cache[url] = meta; |       _cache[url] = meta; | ||||||
|       return meta; |       return meta; | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       log('[LinkPreview] Failed to fetch $url ($target)...'); |       logging.warning('[LinkPreview] Failed to fetch $url ($target)...', err); | ||||||
|       return null; |       return null; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -4,6 +4,21 @@ import 'package:flutter/material.dart'; | |||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:shared_preferences/shared_preferences.dart'; | import 'package:shared_preferences/shared_preferences.dart'; | ||||||
|  | import 'package:surface/types/realm.dart'; | ||||||
|  |  | ||||||
|  | class AppNavListItem { | ||||||
|  |   final String title; | ||||||
|  |   final String subtitle; | ||||||
|  |   final String screen; | ||||||
|  |   final IconData icon; | ||||||
|  |  | ||||||
|  |   const AppNavListItem({ | ||||||
|  |     required this.title, | ||||||
|  |     required this.subtitle, | ||||||
|  |     required this.screen, | ||||||
|  |     required this.icon, | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  |  | ||||||
| class AppNavDestination { | class AppNavDestination { | ||||||
|   final String label; |   final String label; | ||||||
| @@ -24,13 +39,10 @@ class NavigationProvider extends ChangeNotifier { | |||||||
|  |  | ||||||
|   int? get currentIndex => _currentIndex; |   int? get currentIndex => _currentIndex; | ||||||
|  |  | ||||||
|   static const List<String> kShowBottomNavScreen = [ |   List<String> get showBottomNavScreen => destinations | ||||||
|     'home', |       .where((ele) => ele.isPinned) | ||||||
|     'explore', |       .map((ele) => ele.screen) | ||||||
|     'account', |       .toList(); | ||||||
|     'album', |  | ||||||
|     'chat', |  | ||||||
|   ]; |  | ||||||
|  |  | ||||||
|   static const List<AppNavDestination> kAllDestination = [ |   static const List<AppNavDestination> kAllDestination = [ | ||||||
|     AppNavDestination( |     AppNavDestination( | ||||||
| @@ -48,11 +60,6 @@ class NavigationProvider extends ChangeNotifier { | |||||||
|       screen: 'chat', |       screen: 'chat', | ||||||
|       label: 'screenChat', |       label: 'screenChat', | ||||||
|     ), |     ), | ||||||
|     AppNavDestination( |  | ||||||
|       icon: Icon(Symbols.account_circle, weight: 400, opticalSize: 20), |  | ||||||
|       screen: 'account', |  | ||||||
|       label: 'screenAccount', |  | ||||||
|     ), |  | ||||||
|     AppNavDestination( |     AppNavDestination( | ||||||
|       icon: Icon(Symbols.group, weight: 400, opticalSize: 20), |       icon: Icon(Symbols.group, weight: 400, opticalSize: 20), | ||||||
|       screen: 'realm', |       screen: 'realm', | ||||||
| @@ -64,31 +71,16 @@ class NavigationProvider extends ChangeNotifier { | |||||||
|       label: 'screenNews', |       label: 'screenNews', | ||||||
|     ), |     ), | ||||||
|     AppNavDestination( |     AppNavDestination( | ||||||
|       icon: Icon(Symbols.emoji_emotions, weight: 400, opticalSize: 20), |       icon: Icon(Symbols.settings, weight: 400, opticalSize: 20), | ||||||
|       screen: 'stickers', |       screen: 'settings', | ||||||
|       label: 'screenStickers', |       label: 'screenSettings', | ||||||
|     ), |  | ||||||
|     AppNavDestination( |  | ||||||
|       icon: Icon(Symbols.photo_library, weight: 400, opticalSize: 20), |  | ||||||
|       screen: 'album', |  | ||||||
|       label: 'screenAlbum', |  | ||||||
|     ), |  | ||||||
|     AppNavDestination( |  | ||||||
|       icon: Icon(Symbols.diversity_4, weight: 400, opticalSize: 20), |  | ||||||
|       screen: 'friend', |  | ||||||
|       label: 'screenFriend', |  | ||||||
|     ), |  | ||||||
|     AppNavDestination( |  | ||||||
|       icon: Icon(Symbols.notifications, weight: 400, opticalSize: 20), |  | ||||||
|       screen: 'notification', |  | ||||||
|       label: 'screenNotification', |  | ||||||
|     ), |     ), | ||||||
|   ]; |   ]; | ||||||
|   static const List<String> kDefaultPinnedDestination = [ |   static const List<String> kDefaultPinnedDestination = [ | ||||||
|     'home', |     'home', | ||||||
|     'explore', |     'explore', | ||||||
|     'chat', |     'chat', | ||||||
|     'account', |     'realm', | ||||||
|   ]; |   ]; | ||||||
|  |  | ||||||
|   List<AppNavDestination> destinations = []; |   List<AppNavDestination> destinations = []; | ||||||
| @@ -143,4 +135,11 @@ class NavigationProvider extends ChangeNotifier { | |||||||
|     _currentIndex = idx; |     _currentIndex = idx; | ||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   SnRealm? focusedRealm; | ||||||
|  |  | ||||||
|  |   void setFocusedRealm(SnRealm? realm) { | ||||||
|  |     focusedRealm = realm; | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| import 'dart:developer'; |  | ||||||
| import 'dart:io'; | import 'dart:io'; | ||||||
|  |  | ||||||
|  | import 'package:audioplayers/audioplayers.dart'; | ||||||
| import 'package:bitsdojo_window/bitsdojo_window.dart'; | import 'package:bitsdojo_window/bitsdojo_window.dart'; | ||||||
| import 'package:firebase_messaging/firebase_messaging.dart'; | import 'package:firebase_messaging/firebase_messaging.dart'; | ||||||
| import 'package:flutter/foundation.dart'; | import 'package:flutter/foundation.dart'; | ||||||
| @@ -9,6 +9,7 @@ import 'package:flutter/services.dart'; | |||||||
| import 'package:flutter_udid/flutter_udid.dart'; | import 'package:flutter_udid/flutter_udid.dart'; | ||||||
| import 'package:local_notifier/local_notifier.dart'; | import 'package:local_notifier/local_notifier.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/logger.dart'; | ||||||
| import 'package:surface/providers/config.dart'; | import 'package:surface/providers/config.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/providers/userinfo.dart'; | import 'package:surface/providers/userinfo.dart'; | ||||||
| @@ -22,6 +23,8 @@ class NotificationProvider extends ChangeNotifier { | |||||||
|   late final WebSocketProvider _ws; |   late final WebSocketProvider _ws; | ||||||
|   late final ConfigProvider _cfg; |   late final ConfigProvider _cfg; | ||||||
|  |  | ||||||
|  |   final AudioPlayer _notifySoundPlayer = AudioPlayer(playerId: 'notify-sound'); | ||||||
|  |  | ||||||
|   NotificationProvider(BuildContext context) { |   NotificationProvider(BuildContext context) { | ||||||
|     _sn = context.read<SnNetworkProvider>(); |     _sn = context.read<SnNetworkProvider>(); | ||||||
|     _ua = context.read<UserProvider>(); |     _ua = context.read<UserProvider>(); | ||||||
| @@ -48,11 +51,13 @@ class NotificationProvider extends ChangeNotifier { | |||||||
|     var deviceUuid = await FlutterUdid.consistentUdid; |     var deviceUuid = await FlutterUdid.consistentUdid; | ||||||
|  |  | ||||||
|     if (deviceUuid.isEmpty) { |     if (deviceUuid.isEmpty) { | ||||||
|       log("Unable to active push notifications, couldn't get device uuid"); |       logging.warning( | ||||||
|  |           '[Push Notification] Unable to active push notifications, couldn\'t get device uuid'); | ||||||
|       return; |       return; | ||||||
|     } else { |     } else { | ||||||
|       log('Device UUID is $deviceUuid'); |       logging.info('[Push Notification] Device UUID is $deviceUuid'); | ||||||
|       log('Registering device push notifications...'); |       logging | ||||||
|  |           .info('[Push Notification] Registering device push notifications...'); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (Platform.isIOS || Platform.isMacOS) { |     if (Platform.isIOS || Platform.isMacOS) { | ||||||
| @@ -62,16 +67,21 @@ class NotificationProvider extends ChangeNotifier { | |||||||
|       provider = 'fcm'; |       provider = 'fcm'; | ||||||
|       token = await FirebaseMessaging.instance.getToken(); |       token = await FirebaseMessaging.instance.getToken(); | ||||||
|     } |     } | ||||||
|     log('Device Push Token is $token'); |     logging.info('[Push Notification] Device Push Token is $token'); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|       await _sn.client.post( |       await _sn.client.post( | ||||||
|         '/cgi/id/notifications/subscription', |         '/cgi/id/notifications/subscription', | ||||||
|         data: { |         data: { | ||||||
|           'provider': provider, |           'provider': provider, | ||||||
|           'device_token': token, |           'device_token': token, | ||||||
|         'device_id': deviceUuid, |           'device_id': deviceUuid | ||||||
|         }, |         }, | ||||||
|       ); |       ); | ||||||
|  |     } catch (err) { | ||||||
|  |       logging.error( | ||||||
|  |           '[Push Notification] Unable to register push notifications: $err'); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   int showingCount = 0; |   int showingCount = 0; | ||||||
| @@ -89,6 +99,16 @@ class NotificationProvider extends ChangeNotifier { | |||||||
|         final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true; |         final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true; | ||||||
|         if (doHaptic) HapticFeedback.mediumImpact(); |         if (doHaptic) HapticFeedback.mediumImpact(); | ||||||
|  |  | ||||||
|  |         // April fool notification sfx | ||||||
|  |         if (_cfg.prefs.getBool(kAppAprilFoolFeatures) ?? true) { | ||||||
|  |           final now = DateTime.now(); | ||||||
|  |           if (now.day == 1 && now.month == 4) { | ||||||
|  |             _notifySoundPlayer.play( | ||||||
|  |               AssetSource('audio/notify/metal-pipe.mp3'), | ||||||
|  |             ); | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |  | ||||||
|         if (notification.topic == 'messaging.message' && |         if (notification.topic == 'messaging.message' && | ||||||
|             skippableNotifyChannel != null) { |             skippableNotifyChannel != null) { | ||||||
|           if (notification.metadata['channel_id'] != null && |           if (notification.metadata['channel_id'] != null && | ||||||
|   | |||||||
| @@ -28,6 +28,7 @@ class SnPostContentProvider { | |||||||
|  |  | ||||||
|   Future<List<SnPost>> _preloadRelatedDataInBatch(List<SnPost> out) async { |   Future<List<SnPost>> _preloadRelatedDataInBatch(List<SnPost> out) async { | ||||||
|     Set<String> rids = {}; |     Set<String> rids = {}; | ||||||
|  |     Set<int> uids = {}; | ||||||
|     for (var i = 0; i < out.length; i++) { |     for (var i = 0; i < out.length; i++) { | ||||||
|       rids.addAll(out[i].body['attachments']?.cast<String>() ?? []); |       rids.addAll(out[i].body['attachments']?.cast<String>() ?? []); | ||||||
|       if (out[i].body['thumbnail'] != null) { |       if (out[i].body['thumbnail'] != null) { | ||||||
| @@ -41,6 +42,9 @@ class SnPostContentProvider { | |||||||
|           repostTo: await _preloadRelatedDataSingle(out[i].repostTo!), |           repostTo: await _preloadRelatedDataSingle(out[i].repostTo!), | ||||||
|         ); |         ); | ||||||
|       } |       } | ||||||
|  |       if (out[i].publisher.type == 0) { | ||||||
|  |         uids.add(out[i].publisher.accountId); | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     final attachments = await _attach.getMultiple(rids.toList()); |     final attachments = await _attach.getMultiple(rids.toList()); | ||||||
| @@ -56,24 +60,32 @@ class SnPostContentProvider { | |||||||
|  |  | ||||||
|       out[i] = out[i].copyWith( |       out[i] = out[i].copyWith( | ||||||
|         preload: SnPostPreload( |         preload: SnPostPreload( | ||||||
|           thumbnail: attachments.where((ele) => ele?.rid == out[i].body['thumbnail']).firstOrNull, |           thumbnail: attachments | ||||||
|           attachments: attachments.where((ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false).toList(), |               .where((ele) => ele?.rid == out[i].body['thumbnail']) | ||||||
|           video: attachments.where((ele) => ele?.rid == out[i].body['video']).firstOrNull, |               .firstOrNull, | ||||||
|  |           attachments: attachments | ||||||
|  |               .where((ele) => | ||||||
|  |                   out[i].body['attachments']?.contains(ele?.rid) ?? false) | ||||||
|  |               .toList(), | ||||||
|  |           video: attachments | ||||||
|  |               .where((ele) => ele?.rid == out[i].body['video']) | ||||||
|  |               .firstOrNull, | ||||||
|           poll: poll, |           poll: poll, | ||||||
|           realm: realm, |           realm: realm, | ||||||
|         ), |         ), | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     await _ud.listAccount( |     uids.addAll( | ||||||
|       attachments.where((ele) => ele != null).map((ele) => ele!.accountId).toSet(), |         attachments.where((ele) => ele != null).map((ele) => ele!.accountId)); | ||||||
|     ); |     await _ud.listAccount(uids); | ||||||
|  |  | ||||||
|     return out; |     return out; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<SnPost> _preloadRelatedDataSingle(SnPost out) async { |   Future<SnPost> _preloadRelatedDataSingle(SnPost out) async { | ||||||
|     Set<String> rids = {}; |     Set<String> rids = {}; | ||||||
|  |     Set<int> uids = {}; | ||||||
|     rids.addAll(out.body['attachments']?.cast<String>() ?? []); |     rids.addAll(out.body['attachments']?.cast<String>() ?? []); | ||||||
|     if (out.body['thumbnail'] != null) { |     if (out.body['thumbnail'] != null) { | ||||||
|       rids.add(out.body['thumbnail']); |       rids.add(out.body['thumbnail']); | ||||||
| @@ -86,6 +98,9 @@ class SnPostContentProvider { | |||||||
|         repostTo: await _preloadRelatedDataSingle(out.repostTo!), |         repostTo: await _preloadRelatedDataSingle(out.repostTo!), | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |     if (out.publisher.type == 0) { | ||||||
|  |       uids.add(out.publisher.accountId); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     final attachments = await _attach.getMultiple(rids.toList()); |     final attachments = await _attach.getMultiple(rids.toList()); | ||||||
|  |  | ||||||
| @@ -100,14 +115,25 @@ class SnPostContentProvider { | |||||||
|  |  | ||||||
|     out = out.copyWith( |     out = out.copyWith( | ||||||
|       preload: SnPostPreload( |       preload: SnPostPreload( | ||||||
|         thumbnail: attachments.where((ele) => ele?.rid == out.body['thumbnail']).firstOrNull, |         thumbnail: attachments | ||||||
|         attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(), |             .where((ele) => ele?.rid == out.body['thumbnail']) | ||||||
|         video: attachments.where((ele) => ele?.rid == out.body['video']).firstOrNull, |             .firstOrNull, | ||||||
|  |         attachments: attachments | ||||||
|  |             .where( | ||||||
|  |                 (ele) => out.body['attachments']?.contains(ele?.rid) ?? false) | ||||||
|  |             .toList(), | ||||||
|  |         video: attachments | ||||||
|  |             .where((ele) => ele?.rid == out.body['video']) | ||||||
|  |             .firstOrNull, | ||||||
|         poll: poll, |         poll: poll, | ||||||
|         realm: realm, |         realm: realm, | ||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|  |     uids.addAll( | ||||||
|  |         attachments.where((ele) => ele != null).map((ele) => ele!.accountId)); | ||||||
|  |     await _ud.listAccount(uids); | ||||||
|  |  | ||||||
|     return out; |     return out; | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -119,6 +145,36 @@ class SnPostContentProvider { | |||||||
|     return out; |     return out; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   Future<List<SnFeedEntry>> getFeed({int take = 20, DateTime? cursor}) async { | ||||||
|  |     final resp = | ||||||
|  |         await _sn.client.get('/cgi/co/recommendations/feed', queryParameters: { | ||||||
|  |       'take': take, | ||||||
|  |       if (cursor != null) 'cursor': cursor.toUtc().millisecondsSinceEpoch, | ||||||
|  |     }); | ||||||
|  |     final List<SnFeedEntry> out = | ||||||
|  |         List.from(resp.data.map((ele) => SnFeedEntry.fromJson(ele))); | ||||||
|  |  | ||||||
|  |     List<SnPost> posts = List.empty(growable: true); | ||||||
|  |     for (var idx = 0; idx < out.length; idx++) { | ||||||
|  |       final ele = out[idx]; | ||||||
|  |       if (ele.type == 'interactive.post') { | ||||||
|  |         posts.add(SnPost.fromJson(ele.data)); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     posts = await _preloadRelatedDataInBatch(posts); | ||||||
|  |  | ||||||
|  |     var postsIdx = 0; | ||||||
|  |     for (var idx = 0; idx < out.length; idx++) { | ||||||
|  |       final ele = out[idx]; | ||||||
|  |       if (ele.type == 'interactive.post') { | ||||||
|  |         out[idx] = ele.copyWith(data: posts[postsIdx].toJson()); | ||||||
|  |         postsIdx++; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return out; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   Future<(List<SnPost>, int)> listPosts({ |   Future<(List<SnPost>, int)> listPosts({ | ||||||
|     int take = 10, |     int take = 10, | ||||||
|     int offset = 0, |     int offset = 0, | ||||||
| @@ -128,17 +184,25 @@ class SnPostContentProvider { | |||||||
|     Iterable<String>? tags, |     Iterable<String>? tags, | ||||||
|     String? realm, |     String? realm, | ||||||
|     String? channel, |     String? channel, | ||||||
|  |     bool isDraft = false, | ||||||
|  |     bool isShuffle = false, | ||||||
|   }) async { |   }) async { | ||||||
|     final resp = await _sn.client.get('/cgi/co/posts', queryParameters: { |     final resp = await _sn.client.get( | ||||||
|  |       isShuffle | ||||||
|  |           ? '/cgi/co/recommendations/shuffle' | ||||||
|  |           : '/cgi/co/posts${isDraft ? '/drafts' : ''}', | ||||||
|  |       queryParameters: { | ||||||
|         'take': take, |         'take': take, | ||||||
|         'offset': offset, |         'offset': offset, | ||||||
|         if (type != null) 'type': type, |         if (type != null) 'type': type, | ||||||
|         if (author != null) 'author': author, |         if (author != null) 'author': author, | ||||||
|         if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','), |         if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','), | ||||||
|       if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','), |         if (categories?.isNotEmpty ?? false) | ||||||
|  |           'categories': categories!.join(','), | ||||||
|         if (realm != null) 'realm': realm, |         if (realm != null) 'realm': realm, | ||||||
|         if (channel != null) 'channel': channel, |         if (channel != null) 'channel': channel, | ||||||
|     }); |       }, | ||||||
|  |     ); | ||||||
|     final List<SnPost> out = await _preloadRelatedDataInBatch( |     final List<SnPost> out = await _preloadRelatedDataInBatch( | ||||||
|       List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []), |       List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []), | ||||||
|     ); |     ); | ||||||
| @@ -151,7 +215,8 @@ class SnPostContentProvider { | |||||||
|     int take = 10, |     int take = 10, | ||||||
|     int offset = 0, |     int offset = 0, | ||||||
|   }) async { |   }) async { | ||||||
|     final resp = await _sn.client.get('/cgi/co/posts/$parentId/replies', queryParameters: { |     final resp = await _sn.client | ||||||
|  |         .get('/cgi/co/posts/$parentId/replies', queryParameters: { | ||||||
|       'take': take, |       'take': take, | ||||||
|       'offset': offset, |       'offset': offset, | ||||||
|     }); |     }); | ||||||
| @@ -190,4 +255,9 @@ class SnPostContentProvider { | |||||||
|     ); |     ); | ||||||
|     return out; |     return out; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   Future<SnPost> completePostData(SnPost post) async { | ||||||
|  |     final out = await _preloadRelatedDataSingle(post); | ||||||
|  |     return out; | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,11 +1,14 @@ | |||||||
| import 'dart:collection'; | import 'dart:collection'; | ||||||
|  | import 'dart:convert'; | ||||||
| import 'dart:math' as math; | import 'dart:math' as math; | ||||||
| import 'dart:typed_data'; |  | ||||||
|  |  | ||||||
| import 'package:dio/dio.dart'; | import 'package:dio/dio.dart'; | ||||||
|  | import 'package:drift/drift.dart'; | ||||||
| import 'package:flutter/widgets.dart'; | import 'package:flutter/widgets.dart'; | ||||||
| import 'package:cross_file/cross_file.dart'; | import 'package:cross_file/cross_file.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/database/database.dart'; | ||||||
|  | import 'package:surface/providers/database.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/types/attachment.dart'; | import 'package:surface/types/attachment.dart'; | ||||||
|  |  | ||||||
| @@ -13,10 +16,12 @@ const kConcurrentUploadChunks = 5; | |||||||
|  |  | ||||||
| class SnAttachmentProvider { | class SnAttachmentProvider { | ||||||
|   late final SnNetworkProvider _sn; |   late final SnNetworkProvider _sn; | ||||||
|  |   late final DatabaseProvider _dt; | ||||||
|   final Map<String, SnAttachment> _cache = {}; |   final Map<String, SnAttachment> _cache = {}; | ||||||
|  |  | ||||||
|   SnAttachmentProvider(BuildContext context) { |   SnAttachmentProvider(BuildContext context) { | ||||||
|     _sn = context.read<SnNetworkProvider>(); |     _sn = context.read<SnNetworkProvider>(); | ||||||
|  |     _dt = context.read<DatabaseProvider>(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) { |   void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) { | ||||||
| @@ -28,21 +33,33 @@ class SnAttachmentProvider { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<SnAttachment> getOne(String rid, {noCache = false}) async { |   Future<SnAttachment> getOne(String rid, {noCache = false}) async { | ||||||
|  |     // In-memory cache | ||||||
|     if (!noCache && _cache.containsKey(rid)) { |     if (!noCache && _cache.containsKey(rid)) { | ||||||
|       return _cache[rid]!; |       return _cache[rid]!; | ||||||
|     } |     } | ||||||
|  |     // On-disk cache | ||||||
|  |     final dbResp = await (_dt.db.snLocalAttachment.select() | ||||||
|  |           ..where((e) => e.rid.equals(rid)) | ||||||
|  |           ..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now()))) | ||||||
|  |         .getSingleOrNull(); | ||||||
|  |     if (dbResp != null) { | ||||||
|  |       _cache[rid] = dbResp.content; | ||||||
|  |       return dbResp.content; | ||||||
|  |     } | ||||||
|  |     // Remote server | ||||||
|     final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta'); |     final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta'); | ||||||
|     final out = SnAttachment.fromJson(resp.data); |     final out = SnAttachment.fromJson(resp.data); | ||||||
|     if (out.isAnalyzed) { |     if (out.isAnalyzed) { | ||||||
|       _cache[rid] = out; |       _cache[rid] = out; | ||||||
|     } |     } | ||||||
|  |     _saveToLocal([out]); | ||||||
|  |  | ||||||
|     return out; |     return out; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<List<SnAttachment?>> getMultiple(List<String> rids, |   Future<List<SnAttachment?>> getMultiple(List<String> rids, | ||||||
|       {noCache = false}) async { |       {bool noCache = false}) async { | ||||||
|  |     // In-memory cache | ||||||
|     final result = List<SnAttachment?>.filled(rids.length, null); |     final result = List<SnAttachment?>.filled(rids.length, null); | ||||||
|     final Map<String, int> randomMapping = {}; |     final Map<String, int> randomMapping = {}; | ||||||
|     for (int i = 0; i < rids.length; i++) { |     for (int i = 0; i < rids.length; i++) { | ||||||
| @@ -53,9 +70,25 @@ class SnAttachmentProvider { | |||||||
|         result[i] = _cache[rid]!; |         result[i] = _cache[rid]!; | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     final pendingFetch = randomMapping.keys; |     var pendingFetch = randomMapping.keys; | ||||||
|  |     // On-disk cache | ||||||
|     if (pendingFetch.isNotEmpty) { |     if (pendingFetch.isEmpty) return result; | ||||||
|  |     if (!noCache) { | ||||||
|  |       final dbResp = await (_dt.db.snLocalAttachment.select() | ||||||
|  |             ..where((e) => e.rid.isIn(pendingFetch)) | ||||||
|  |             ..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now()))) | ||||||
|  |           .get(); | ||||||
|  |       for (final item in dbResp) { | ||||||
|  |         if (item.content.isAnalyzed) { | ||||||
|  |           _cache[item.rid] = item.content; | ||||||
|  |         } | ||||||
|  |         result[randomMapping[item.rid]!] = item.content; | ||||||
|  |         randomMapping.remove(item.rid); | ||||||
|  |       } | ||||||
|  |       pendingFetch = randomMapping.keys; | ||||||
|  |     } | ||||||
|  |     // Remote server | ||||||
|  |     if (pendingFetch.isEmpty) return result; | ||||||
|     final resp = await _sn.client.get( |     final resp = await _sn.client.get( | ||||||
|       '/cgi/uc/attachments', |       '/cgi/uc/attachments', | ||||||
|       queryParameters: { |       queryParameters: { | ||||||
| @@ -67,7 +100,6 @@ class SnAttachmentProvider { | |||||||
|         .map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e)) |         .map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e)) | ||||||
|         .cast<SnAttachment?>() |         .cast<SnAttachment?>() | ||||||
|         .toList(); |         .toList(); | ||||||
|  |  | ||||||
|     for (final item in out) { |     for (final item in out) { | ||||||
|       if (item == null) continue; |       if (item == null) continue; | ||||||
|       if (item.isAnalyzed) { |       if (item.isAnalyzed) { | ||||||
| @@ -75,7 +107,7 @@ class SnAttachmentProvider { | |||||||
|       } |       } | ||||||
|       result[randomMapping[item.rid]!] = item; |       result[randomMapping[item.rid]!] = item; | ||||||
|     } |     } | ||||||
|     } |     _saveToLocal(out.where((ele) => ele != null).cast()); | ||||||
|  |  | ||||||
|     return result; |     return result; | ||||||
|   } |   } | ||||||
| @@ -274,6 +306,31 @@ class SnAttachmentProvider { | |||||||
|       'metadata': metadata ?? item.usermeta, |       'metadata': metadata ?? item.usermeta, | ||||||
|       'is_indexable': isIndexable ?? item.isIndexable, |       'is_indexable': isIndexable ?? item.isIndexable, | ||||||
|     }); |     }); | ||||||
|     return SnAttachment.fromJson(resp.data); |     final out = SnAttachment.fromJson(resp.data); | ||||||
|  |     _saveToLocal([out]); | ||||||
|  |     return out; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> _saveToLocal(Iterable<SnAttachment> out) async { | ||||||
|  |     for (final ele in out) { | ||||||
|  |       if (!ele.isAnalyzed || ele.destination == 0) continue; | ||||||
|  |       await _dt.db.snLocalAttachment.insertOne( | ||||||
|  |         SnLocalAttachmentCompanion.insert( | ||||||
|  |           id: Value(ele.id), | ||||||
|  |           rid: ele.rid, | ||||||
|  |           uuid: ele.uuid, | ||||||
|  |           content: ele, | ||||||
|  |           accountId: ele.accountId, | ||||||
|  |           cacheExpiredAt: DateTime.now().add(const Duration(hours: 1)), | ||||||
|  |         ), | ||||||
|  |         onConflict: DoUpdate( | ||||||
|  |           (_) => SnLocalAttachmentCompanion.custom( | ||||||
|  |             content: Constant(jsonEncode(ele.toJson())), | ||||||
|  |             cacheExpiredAt: | ||||||
|  |                 Constant(DateTime.now().add(const Duration(hours: 1))), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,6 +1,5 @@ | |||||||
| import 'dart:async'; | import 'dart:async'; | ||||||
| import 'dart:convert'; | import 'dart:convert'; | ||||||
| import 'dart:developer'; |  | ||||||
| import 'dart:io'; | import 'dart:io'; | ||||||
|  |  | ||||||
| import 'package:dio/dio.dart'; | import 'package:dio/dio.dart'; | ||||||
| @@ -11,9 +10,26 @@ import 'package:package_info_plus/package_info_plus.dart'; | |||||||
| import 'package:device_info_plus/device_info_plus.dart'; | import 'package:device_info_plus/device_info_plus.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:shared_preferences/shared_preferences.dart'; | import 'package:shared_preferences/shared_preferences.dart'; | ||||||
|  | import 'package:surface/logger.dart'; | ||||||
| import 'package:surface/providers/config.dart'; | import 'package:surface/providers/config.dart'; | ||||||
| import 'package:surface/providers/widget.dart'; | import 'package:surface/providers/widget.dart'; | ||||||
| import 'package:synchronized/synchronized.dart'; | import 'package:synchronized/synchronized.dart'; | ||||||
|  | import 'package:talker_dio_logger/talker_dio_logger_interceptor.dart'; | ||||||
|  | import 'package:talker_dio_logger/talker_dio_logger_settings.dart'; | ||||||
|  |  | ||||||
|  | enum ServiceStatus { operational, downgraded, failed } | ||||||
|  |  | ||||||
|  | const Map<String, String> kServicesName = { | ||||||
|  |   'ai': 'Insights', | ||||||
|  |   'co': 'Interactive', | ||||||
|  |   're': 'Reader', | ||||||
|  |   'im': 'Messaging', | ||||||
|  |   'ma': 'Matrix', | ||||||
|  |   'uc': 'Paperclip', | ||||||
|  |   'wa': 'Wallet', | ||||||
|  |   'id': 'Passport', | ||||||
|  |   'pusher': 'Pusher', | ||||||
|  | }; | ||||||
|  |  | ||||||
| const kNetworkServerDirectory = [ | const kNetworkServerDirectory = [ | ||||||
|   ('Solar Network', 'https://api.sn.solsynth.dev'), |   ('Solar Network', 'https://api.sn.solsynth.dev'), | ||||||
| @@ -36,6 +52,19 @@ class SnNetworkProvider { | |||||||
|  |  | ||||||
|     client = Dio(); |     client = Dio(); | ||||||
|  |  | ||||||
|  |     client.interceptors.add( | ||||||
|  |       TalkerDioLogger( | ||||||
|  |         talker: logging, | ||||||
|  |         settings: const TalkerDioLoggerSettings( | ||||||
|  |           printRequestHeaders: false, | ||||||
|  |           printResponseHeaders: false, | ||||||
|  |           printResponseMessage: false, | ||||||
|  |           printResponseData: false, | ||||||
|  |           printRequestData: false, | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     client.interceptors.add(RetryInterceptor( |     client.interceptors.add(RetryInterceptor( | ||||||
|       dio: client, |       dio: client, | ||||||
|       retries: 3, |       retries: 3, | ||||||
| @@ -69,7 +98,6 @@ class SnNetworkProvider { | |||||||
|       _prefs = _config.prefs; |       _prefs = _config.prefs; | ||||||
|       client.options.baseUrl = _config.serverUrl; |       client.options.baseUrl = _config.serverUrl; | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   static Future<Dio> createOffContextClient() async { |   static Future<Dio> createOffContextClient() async { | ||||||
| @@ -91,7 +119,8 @@ class SnNetworkProvider { | |||||||
|           RequestOptions options, |           RequestOptions options, | ||||||
|           RequestInterceptorHandler handler, |           RequestInterceptorHandler handler, | ||||||
|         ) async { |         ) async { | ||||||
|           final atk = await _getFreshAtk(client, prefs.getString(kAtkStoreKey), prefs.getString(kRtkStoreKey), (atk, rtk) { |           final atk = await _getFreshAtk(client, prefs.getString(kAtkStoreKey), | ||||||
|  |               prefs.getString(kRtkStoreKey), (atk, rtk) { | ||||||
|             prefs.setString(kAtkStoreKey, atk); |             prefs.setString(kAtkStoreKey, atk); | ||||||
|             prefs.setString(kRtkStoreKey, rtk); |             prefs.setString(kRtkStoreKey, rtk); | ||||||
|           }); |           }); | ||||||
| @@ -103,7 +132,8 @@ class SnNetworkProvider { | |||||||
|         }, |         }, | ||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
|     client.options.baseUrl = prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault; |     client.options.baseUrl = | ||||||
|  |         prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault; | ||||||
|  |  | ||||||
|     return client; |     return client; | ||||||
|   } |   } | ||||||
| @@ -119,7 +149,8 @@ class SnNetworkProvider { | |||||||
|       platformInfo = 'Web; ${deviceInfo.vendor}'; |       platformInfo = 'Web; ${deviceInfo.vendor}'; | ||||||
|     } else if (Platform.isAndroid) { |     } else if (Platform.isAndroid) { | ||||||
|       final deviceInfo = await DeviceInfoPlugin().androidInfo; |       final deviceInfo = await DeviceInfoPlugin().androidInfo; | ||||||
|       platformInfo = 'Android; ${deviceInfo.brand} ${deviceInfo.model}; ${deviceInfo.id}'; |       platformInfo = | ||||||
|  |           'Android; ${deviceInfo.brand} ${deviceInfo.model}; ${deviceInfo.id}'; | ||||||
|     } else if (Platform.isIOS) { |     } else if (Platform.isIOS) { | ||||||
|       final deviceInfo = await DeviceInfoPlugin().iosInfo; |       final deviceInfo = await DeviceInfoPlugin().iosInfo; | ||||||
|       platformInfo = 'iOS; ${deviceInfo.model}; ${deviceInfo.name}'; |       platformInfo = 'iOS; ${deviceInfo.model}; ${deviceInfo.name}'; | ||||||
| @@ -128,7 +159,8 @@ class SnNetworkProvider { | |||||||
|       platformInfo = 'MacOS; ${deviceInfo.model}; ${deviceInfo.hostName}'; |       platformInfo = 'MacOS; ${deviceInfo.model}; ${deviceInfo.hostName}'; | ||||||
|     } else if (Platform.isWindows) { |     } else if (Platform.isWindows) { | ||||||
|       final deviceInfo = await DeviceInfoPlugin().windowsInfo; |       final deviceInfo = await DeviceInfoPlugin().windowsInfo; | ||||||
|       platformInfo = 'Windows NT; ${deviceInfo.productName}; ${deviceInfo.computerName}'; |       platformInfo = | ||||||
|  |           'Windows NT; ${deviceInfo.productName}; ${deviceInfo.computerName}'; | ||||||
|     } else if (Platform.isLinux) { |     } else if (Platform.isLinux) { | ||||||
|       final deviceInfo = await DeviceInfoPlugin().linuxInfo; |       final deviceInfo = await DeviceInfoPlugin().linuxInfo; | ||||||
|       platformInfo = 'Linux; ${deviceInfo.prettyName}'; |       platformInfo = 'Linux; ${deviceInfo.prettyName}'; | ||||||
| @@ -148,12 +180,15 @@ class SnNetworkProvider { | |||||||
|   final tkLock = Lock(); |   final tkLock = Lock(); | ||||||
|  |  | ||||||
|   Future<String?> getFreshAtk() async { |   Future<String?> getFreshAtk() async { | ||||||
|     return await _getFreshAtk(client, _prefs.getString(kAtkStoreKey), _prefs.getString(kRtkStoreKey), (atk, rtk) { |     return await _getFreshAtk( | ||||||
|  |         client, _prefs.getString(kAtkStoreKey), _prefs.getString(kRtkStoreKey), | ||||||
|  |         (atk, rtk) { | ||||||
|       setTokenPair(atk, rtk); |       setTokenPair(atk, rtk); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   static Future<String?> _getFreshAtk(Dio client, String? atk, String? rtk, Function(String atk, String rtk)? onRefresh) async { |   static Future<String?> _getFreshAtk(Dio client, String? atk, String? rtk, | ||||||
|  |       Function(String atk, String rtk)? onRefresh) async { | ||||||
|     if (_refreshCompleter != null) { |     if (_refreshCompleter != null) { | ||||||
|       return await _refreshCompleter!.future; |       return await _refreshCompleter!.future; | ||||||
|     } else { |     } else { | ||||||
| @@ -185,7 +220,8 @@ class SnNetworkProvider { | |||||||
|         final payload = b64.decode(rawPayload); |         final payload = b64.decode(rawPayload); | ||||||
|         final exp = jsonDecode(payload)['exp']; |         final exp = jsonDecode(payload)['exp']; | ||||||
|         if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) { |         if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) { | ||||||
|           log('Access token need refresh, doing it at ${DateTime.now()}'); |           logging.debug( | ||||||
|  |               '[Auth] Access token need refresh, doing it at ${DateTime.now()}'); | ||||||
|           final result = await _refreshToken(client.options.baseUrl, rtk); |           final result = await _refreshToken(client.options.baseUrl, rtk); | ||||||
|           if (result == null) { |           if (result == null) { | ||||||
|             atk = null; |             atk = null; | ||||||
| @@ -199,12 +235,12 @@ class SnNetworkProvider { | |||||||
|           _refreshCompleter!.complete(atk); |           _refreshCompleter!.complete(atk); | ||||||
|           return atk; |           return atk; | ||||||
|         } else { |         } else { | ||||||
|           log('Access token refresh failed...'); |           logging.error('[Auth] Access token refresh failed...'); | ||||||
|           _refreshCompleter!.complete(null); |           _refreshCompleter!.complete(null); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       log('Failed to authenticate user: $err'); |       logging.error('[Auth] Failed to authenticate user...', err); | ||||||
|       _refreshCompleter!.completeError(err); |       _refreshCompleter!.completeError(err); | ||||||
|     } finally { |     } finally { | ||||||
|       _refreshCompleter = null; |       _refreshCompleter = null; | ||||||
| @@ -237,7 +273,8 @@ class SnNetworkProvider { | |||||||
|     return result.$1; |     return result.$1; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   static Future<(String, String)?> _refreshToken(String baseUrl, String? rtk) async { |   static Future<(String, String)?> _refreshToken( | ||||||
|  |       String baseUrl, String? rtk) async { | ||||||
|     if (rtk == null) return null; |     if (rtk == null) return null; | ||||||
|  |  | ||||||
|     final dio = Dio(); |     final dio = Dio(); | ||||||
|   | |||||||
| @@ -1,16 +1,30 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  |  | ||||||
|  | import 'package:drift/drift.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/database/database.dart'; | ||||||
|  | import 'package:surface/providers/database.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/types/realm.dart'; | import 'package:surface/types/realm.dart'; | ||||||
|  |  | ||||||
| class SnRealmProvider { | class SnRealmProvider extends ChangeNotifier { | ||||||
|   late final SnNetworkProvider _sn; |   late final SnNetworkProvider _sn; | ||||||
|  |   late final DatabaseProvider _dt; | ||||||
|  |  | ||||||
|   SnRealmProvider(BuildContext context) { |   SnRealmProvider(BuildContext context) { | ||||||
|     _sn = context.read<SnNetworkProvider>(); |     _sn = context.read<SnNetworkProvider>(); | ||||||
|  |     _dt = context.read<DatabaseProvider>(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   final Map<String, SnRealm> _cache = {}; |   final Map<String, SnRealm> _cache = {}; | ||||||
|  |   List<SnRealm> _availableRealms = List.empty(growable: true); | ||||||
|  |  | ||||||
|  |   Future<void> refreshAvailableRealms() async { | ||||||
|  |     _availableRealms = await listAvailableRealms(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   List<SnRealm> get availableRealms => _availableRealms; | ||||||
|  |  | ||||||
|   Future<List<SnRealm>> listAvailableRealms() async { |   Future<List<SnRealm>> listAvailableRealms() async { | ||||||
|     final resp = await _sn.client.get('/cgi/id/realms/me/available'); |     final resp = await _sn.client.get('/cgi/id/realms/me/available'); | ||||||
| @@ -21,17 +35,56 @@ class SnRealmProvider { | |||||||
|       _cache[realm.alias] = realm; |       _cache[realm.alias] = realm; | ||||||
|       _cache[realm.id.toString()] = realm; |       _cache[realm.id.toString()] = realm; | ||||||
|     } |     } | ||||||
|  |     _saveToLocal(out); | ||||||
|     return out; |     return out; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   void addAvailableRealm(SnRealm realm) { | ||||||
|  |     _availableRealms.add(realm); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   Future<SnRealm> getRealm(dynamic aliasOrId) async { |   Future<SnRealm> getRealm(dynamic aliasOrId) async { | ||||||
|     if (_cache.containsKey(aliasOrId.toString())) { |     if (_cache.containsKey(aliasOrId.toString())) { | ||||||
|       return _cache[aliasOrId.toString()]!; |       return _cache[aliasOrId.toString()]!; | ||||||
|     } |     } | ||||||
|  |     final localResp = await (_dt.db.snLocalRealm.select() | ||||||
|  |           ..where((e) => | ||||||
|  |               e.id.equals(aliasOrId is int ? aliasOrId : 0) | | ||||||
|  |               e.alias.equals(aliasOrId.toString())) | ||||||
|  |           ..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now()))) | ||||||
|  |         .getSingleOrNull(); | ||||||
|  |     if (localResp != null) { | ||||||
|  |       _cache[localResp.content.id.toString()] = localResp.content; | ||||||
|  |       _cache[localResp.content.alias] = localResp.content; | ||||||
|  |       return localResp.content; | ||||||
|  |     } | ||||||
|     final resp = await _sn.client.get('/cgi/id/realms/$aliasOrId'); |     final resp = await _sn.client.get('/cgi/id/realms/$aliasOrId'); | ||||||
|     final out = SnRealm.fromJson(resp.data); |     final out = SnRealm.fromJson(resp.data); | ||||||
|     _cache[out.alias] = out; |     _cache[out.alias] = out; | ||||||
|     _cache[out.id.toString()] = out; |     _cache[out.id.toString()] = out; | ||||||
|  |     _saveToLocal([out]); | ||||||
|     return out; |     return out; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   Future<void> _saveToLocal(Iterable<SnRealm> out) async { | ||||||
|  |     for (final ele in out) { | ||||||
|  |       await _dt.db.snLocalRealm.insertOne( | ||||||
|  |         SnLocalRealmCompanion.insert( | ||||||
|  |           id: Value(ele.id), | ||||||
|  |           alias: ele.alias, | ||||||
|  |           content: ele, | ||||||
|  |           accountId: ele.accountId, | ||||||
|  |           cacheExpiredAt: DateTime.now().add(const Duration(hours: 1)), | ||||||
|  |         ), | ||||||
|  |         onConflict: DoUpdate( | ||||||
|  |           (_) => SnLocalRealmCompanion.custom( | ||||||
|  |             content: Constant(jsonEncode(ele.toJson())), | ||||||
|  |             cacheExpiredAt: | ||||||
|  |                 Constant(DateTime.now().add(const Duration(hours: 1))), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,12 +1,17 @@ | |||||||
| import 'dart:developer'; | import 'dart:convert'; | ||||||
|  |  | ||||||
|  | import 'package:drift/drift.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/database/database.dart'; | ||||||
|  | import 'package:surface/logger.dart'; | ||||||
|  | import 'package:surface/providers/database.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/types/attachment.dart'; | import 'package:surface/types/attachment.dart'; | ||||||
|  |  | ||||||
| class SnStickerProvider { | class SnStickerProvider { | ||||||
|   late final SnNetworkProvider _sn; |   late final SnNetworkProvider _sn; | ||||||
|  |   late final DatabaseProvider _dt; | ||||||
|   final Map<String, SnSticker?> _cache = {}; |   final Map<String, SnSticker?> _cache = {}; | ||||||
|  |  | ||||||
|   final Map<int, List<SnSticker>> stickersByPack = {}; |   final Map<int, List<SnSticker>> stickersByPack = {}; | ||||||
| @@ -16,6 +21,7 @@ class SnStickerProvider { | |||||||
|  |  | ||||||
|   SnStickerProvider(BuildContext context) { |   SnStickerProvider(BuildContext context) { | ||||||
|     _sn = context.read<SnNetworkProvider>(); |     _sn = context.read<SnNetworkProvider>(); | ||||||
|  |     _dt = context.read<DatabaseProvider>(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   bool hasNotSticker(String alias) { |   bool hasNotSticker(String alias) { | ||||||
| @@ -32,32 +38,54 @@ class SnStickerProvider { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void putSticker(Iterable<SnSticker> sticker) { |   void putSticker(Iterable<SnSticker> stickers) { | ||||||
|     for (final ele in sticker) { |     for (final ele in stickers) { | ||||||
|       _cacheSticker(ele); |       _cacheSticker(ele); | ||||||
|     } |     } | ||||||
|  |     _saveStickerToLocal(stickers); | ||||||
|  |     _saveStickerPackToLocal(stickers.map((ele) => ele.pack).toSet()); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<SnSticker?> lookupSticker(String alias) async { |   Future<SnSticker?> lookupSticker(String alias) async { | ||||||
|  |     // In-memory cache | ||||||
|     if (_cache.containsKey(alias)) { |     if (_cache.containsKey(alias)) { | ||||||
|       return _cache[alias]; |       return _cache[alias]; | ||||||
|     } |     } | ||||||
|  |     // On-disk cache | ||||||
|  |     final localStickers = await (_dt.db.snLocalSticker.select() | ||||||
|  |           ..where((e) => e.fullAlias.equals(alias))) | ||||||
|  |         .getSingleOrNull(); | ||||||
|  |     if (localStickers != null) { | ||||||
|  |       _cache[alias] = localStickers.content; | ||||||
|  |       return localStickers.content; | ||||||
|  |     } | ||||||
|  |     // Remote server | ||||||
|     try { |     try { | ||||||
|       final resp = await _sn.client.get('/cgi/uc/stickers/lookup/$alias'); |       final resp = await _sn.client.get('/cgi/uc/stickers/lookup/$alias'); | ||||||
|       final sticker = SnSticker.fromJson(resp.data); |       final sticker = SnSticker.fromJson(resp.data); | ||||||
|       _cacheSticker(sticker); |       putSticker([sticker]); | ||||||
|  |  | ||||||
|       return sticker; |       return sticker; | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       _cache[alias] = null; |       _cache[alias] = null; | ||||||
|       log('[Sticker] Failed to lookup sticker $alias: $err'); |       logging.warning('[Sticker] Failed to lookup sticker $alias', err); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return null; |     return null; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> listSticker() async { |   Future<void> listSticker() async { | ||||||
|  |     final localPacks = await _dt.db.snLocalStickerPack.select().get(); | ||||||
|  |     final localStickers = await _dt.db.snLocalSticker.select().get(); | ||||||
|  |     final local = localStickers.map((ele) { | ||||||
|  |       return ele.content.copyWith( | ||||||
|  |         pack: localPacks | ||||||
|  |             .firstWhere((pk) => pk.content.id == ele.content.packId) | ||||||
|  |             .content, | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |     for (final sticker in local) { | ||||||
|  |       _cacheSticker(sticker); | ||||||
|  |     } | ||||||
|     try { |     try { | ||||||
|       final resp = await _sn.client.get('/cgi/uc/stickers'); |       final resp = await _sn.client.get('/cgi/uc/stickers'); | ||||||
|       final data = resp.data; |       final data = resp.data; | ||||||
| @@ -66,8 +94,39 @@ class SnStickerProvider { | |||||||
|         _cacheSticker(sticker); |         _cacheSticker(sticker); | ||||||
|       } |       } | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       log('[Sticker] Failed to list stickers: $err'); |       logging.error('[Sticker] Failed to list stickers...', err); | ||||||
|       rethrow; |       rethrow; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   Future<void> _saveStickerToLocal(Iterable<SnSticker> stickers) async { | ||||||
|  |     await _dt.db.snLocalSticker.insertAll( | ||||||
|  |       stickers.map( | ||||||
|  |         (ele) => SnLocalStickerCompanion.insert( | ||||||
|  |           id: Value(ele.id), | ||||||
|  |           alias: ele.alias, | ||||||
|  |           fullAlias: '${ele.pack.prefix}${ele.alias}', | ||||||
|  |           content: ele, | ||||||
|  |           createdAt: Value(ele.createdAt), | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |       onConflict: DoNothing(), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> _saveStickerPackToLocal(Iterable<SnStickerPack> packs) async { | ||||||
|  |     final queries = packs | ||||||
|  |         .map( | ||||||
|  |           (ele) => _dt.db.snLocalStickerPack.insertOne( | ||||||
|  |               SnLocalStickerPackCompanion.insert( | ||||||
|  |                 id: Value(ele.id), | ||||||
|  |                 content: ele, | ||||||
|  |                 createdAt: Value(ele.createdAt), | ||||||
|  |               ), | ||||||
|  |               onConflict: DoUpdate((_) => SnLocalStickerPackCompanion.custom( | ||||||
|  |                   content: Constant(jsonEncode(ele.toJson()))))), | ||||||
|  |         ) | ||||||
|  |         .toList(); | ||||||
|  |     await Future.wait(queries); | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -13,8 +13,16 @@ class ThemeProvider extends ChangeNotifier { | |||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void reloadTheme({Color? seedColorOverride, bool? useMaterial3}) { |   void reloadTheme({ | ||||||
|     createAppThemeSet(seedColorOverride: seedColorOverride, useMaterial3: useMaterial3).then((value) { |     Color? seedColorOverride, | ||||||
|  |     bool? useMaterial3, | ||||||
|  |     String? customFonts, | ||||||
|  |   }) { | ||||||
|  |     createAppThemeSet( | ||||||
|  |       seedColorOverride: seedColorOverride, | ||||||
|  |       useMaterial3: useMaterial3, | ||||||
|  |       customFonts: customFonts, | ||||||
|  |     ).then((value) { | ||||||
|       theme = value; |       theme = value; | ||||||
|       notifyListeners(); |       notifyListeners(); | ||||||
|     }); |     }); | ||||||
|   | |||||||
							
								
								
									
										55
									
								
								lib/providers/translation.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								lib/providers/translation.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  |  | ||||||
|  | import 'package:crypto/crypto.dart'; | ||||||
|  | import 'package:dio/dio.dart'; | ||||||
|  | import 'package:surface/logger.dart'; | ||||||
|  |  | ||||||
|  | const kTranslateApiBaseUrl = 'https://translate.solsynth.dev'; | ||||||
|  |  | ||||||
|  | class SnTranslator { | ||||||
|  |   final Dio client = Dio( | ||||||
|  |     BaseOptions( | ||||||
|  |       baseUrl: kTranslateApiBaseUrl, | ||||||
|  |       connectTimeout: Duration(seconds: 3), | ||||||
|  |       sendTimeout: Duration(seconds: 3), | ||||||
|  |       receiveTimeout: Duration(seconds: 3), | ||||||
|  |     ), | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   final Map<String, String> _cache = {}; | ||||||
|  |  | ||||||
|  |   Future<String> translate( | ||||||
|  |     String text, { | ||||||
|  |     required String to, | ||||||
|  |     String from = 'auto', | ||||||
|  |     bool skipCache = false, | ||||||
|  |   }) async { | ||||||
|  |     if (text.isEmpty) return text; | ||||||
|  |  | ||||||
|  |     final cacheKey = md5.convert(utf8.encode('$text$from$to')).toString(); | ||||||
|  |     if (!skipCache && _cache.containsKey(cacheKey)) { | ||||||
|  |       return _cache[cacheKey]!; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     logging.info('[Translator] Translate $text from $from to $to'); | ||||||
|  |  | ||||||
|  |     final resp = await client.post( | ||||||
|  |       '/translate', | ||||||
|  |       data: { | ||||||
|  |         'q': text, | ||||||
|  |         'source': from, | ||||||
|  |         'target': to, | ||||||
|  |         'format': 'text', | ||||||
|  |       }, | ||||||
|  |     ); | ||||||
|  |     if (resp.statusCode == 200) { | ||||||
|  |       final out = resp.data['translatedText']; | ||||||
|  |       if (out.isNotEmpty) { | ||||||
|  |         logging.info('[Translator] Translated $text from $from to $to'); | ||||||
|  |         _cache[cacheKey] = out; | ||||||
|  |         return out; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     throw Exception('translate failed: $resp'); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,19 +1,44 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  |  | ||||||
|  | import 'package:drift/drift.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/database/database.dart'; | ||||||
|  | import 'package:surface/providers/database.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/types/account.dart'; | import 'package:surface/types/account.dart'; | ||||||
|  |  | ||||||
| class UserDirectoryProvider { | class UserDirectoryProvider { | ||||||
|   late final SnNetworkProvider _sn; |   late final SnNetworkProvider _sn; | ||||||
|  |   late final DatabaseProvider _dt; | ||||||
|  |  | ||||||
|   UserDirectoryProvider(BuildContext context) { |   UserDirectoryProvider(BuildContext context) { | ||||||
|     _sn = context.read<SnNetworkProvider>(); |     _sn = context.read<SnNetworkProvider>(); | ||||||
|  |     _dt = context.read<DatabaseProvider>(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   final Map<String, int> _idCache = {}; |   final Map<String, int> _idCache = {}; | ||||||
|   final Map<int, SnAccount> _cache = {}; |   final Map<int, SnAccount> _cache = {}; | ||||||
|  |   DateTime? _cacheExpiredAt; | ||||||
|  |  | ||||||
|  |   Future<int> loadAccountCache({int max = 100}) async { | ||||||
|  |     final out = await (_dt.db.snLocalAccount.select()..limit(max)).get(); | ||||||
|  |     for (final ele in out) { | ||||||
|  |       _cache[ele.id] = ele.content; | ||||||
|  |       _idCache[ele.name] = ele.id; | ||||||
|  |     } | ||||||
|  |     _cacheExpiredAt = DateTime.now().add(const Duration(hours: 1)); | ||||||
|  |     return out.length; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async { |   Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async { | ||||||
|  |     // In-memory cache | ||||||
|  |     if (_cacheExpiredAt != null && _cacheExpiredAt!.isBefore(DateTime.now())) { | ||||||
|  |       _cache.clear(); | ||||||
|  |       _cacheExpiredAt = DateTime.now().add(const Duration(hours: 1)); | ||||||
|  |     } else { | ||||||
|  |       _cacheExpiredAt ??= DateTime.now().add(const Duration(hours: 1)); | ||||||
|  |     } | ||||||
|     final out = List<SnAccount?>.generate(id.length, (e) => null); |     final out = List<SnAccount?>.generate(id.length, (e) => null); | ||||||
|     final plannedQuery = <int>{}; |     final plannedQuery = <int>{}; | ||||||
|     for (var idx = 0; idx < out.length; idx++) { |     for (var idx = 0; idx < out.length; idx++) { | ||||||
| @@ -27,8 +52,30 @@ class UserDirectoryProvider { | |||||||
|         plannedQuery.add(item); |         plannedQuery.add(item); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     final resp = await _sn.client.get('/cgi/id/users', queryParameters: {'id': plannedQuery.join(',')}); |     // On-disk cache | ||||||
|     final respDecoded = resp.data.map((e) => SnAccount.fromJson(e)).cast<SnAccount>().toList(); |     if (plannedQuery.isEmpty) return out; | ||||||
|  |     final dbResp = await (_dt.db.snLocalAccount.select() | ||||||
|  |           ..where((e) => e.id.isIn(plannedQuery)) | ||||||
|  |           ..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())) | ||||||
|  |           ..limit(plannedQuery.length)) | ||||||
|  |         .get(); | ||||||
|  |     for (var idx = 0; idx < out.length; idx++) { | ||||||
|  |       if (out[idx] != null) continue; | ||||||
|  |       if (dbResp.length <= idx) { | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |       out[idx] = dbResp[idx].content; | ||||||
|  |       _cache[dbResp[idx].id] = dbResp[idx].content; | ||||||
|  |       _idCache[dbResp[idx].name] = dbResp[idx].id; | ||||||
|  |       plannedQuery.remove(dbResp[idx].id); | ||||||
|  |     } | ||||||
|  |     // Remote server | ||||||
|  |     _saveToLocal(out.where((ele) => ele != null).cast()); | ||||||
|  |     if (plannedQuery.isEmpty) return out; | ||||||
|  |     final resp = await _sn.client | ||||||
|  |         .get('/cgi/id/users', queryParameters: {'id': plannedQuery.join(',')}); | ||||||
|  |     final respDecoded = | ||||||
|  |         resp.data.map((e) => SnAccount.fromJson(e)).cast<SnAccount>().toList(); | ||||||
|     var sideIdx = 0; |     var sideIdx = 0; | ||||||
|     for (var idx = 0; idx < out.length; idx++) { |     for (var idx = 0; idx < out.length; idx++) { | ||||||
|       if (out[idx] != null) continue; |       if (out[idx] != null) continue; | ||||||
| @@ -40,17 +87,29 @@ class UserDirectoryProvider { | |||||||
|       _idCache[respDecoded[sideIdx].name] = respDecoded[sideIdx].id; |       _idCache[respDecoded[sideIdx].name] = respDecoded[sideIdx].id; | ||||||
|       sideIdx++; |       sideIdx++; | ||||||
|     } |     } | ||||||
|  |     if (respDecoded.isNotEmpty) _saveToLocal(respDecoded); | ||||||
|     return out; |     return out; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<SnAccount?> getAccount(dynamic id) async { |   Future<SnAccount?> getAccount(dynamic id) async { | ||||||
|  |     // In-memory cache | ||||||
|     if (id is String && _idCache.containsKey(id)) { |     if (id is String && _idCache.containsKey(id)) { | ||||||
|       id = _idCache[id]; |       id = _idCache[id]; | ||||||
|     } |     } | ||||||
|     if (_cache.containsKey(id)) { |     if (_cache.containsKey(id)) { | ||||||
|       return _cache[id]; |       return _cache[id]; | ||||||
|     } |     } | ||||||
|  |     // On-disk cache | ||||||
|  |     final dbResp = await (_dt.db.snLocalAccount.select() | ||||||
|  |           ..where((e) => e.id.equals(id)) | ||||||
|  |           ..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now()))) | ||||||
|  |         .getSingleOrNull(); | ||||||
|  |     if (dbResp != null) { | ||||||
|  |       _cache[dbResp.id] = dbResp.content; | ||||||
|  |       _idCache[dbResp.name] = dbResp.id; | ||||||
|  |       return dbResp.content; | ||||||
|  |     } | ||||||
|  |     // Remote server | ||||||
|     try { |     try { | ||||||
|       final resp = await _sn.client.get('/cgi/id/users/$id'); |       final resp = await _sn.client.get('/cgi/id/users/$id'); | ||||||
|       final account = SnAccount.fromJson( |       final account = SnAccount.fromJson( | ||||||
| @@ -58,16 +117,42 @@ class UserDirectoryProvider { | |||||||
|       ); |       ); | ||||||
|       _cache[account.id] = account; |       _cache[account.id] = account; | ||||||
|       if (id is String) _idCache[id] = account.id; |       if (id is String) _idCache[id] = account.id; | ||||||
|  |       _saveToLocal([account]); | ||||||
|       return account; |       return account; | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       return null; |       return null; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   SnAccount? getAccountFromCache(dynamic id) { |   SnAccount? getFromCache(dynamic id) { | ||||||
|     if (id is String && _idCache.containsKey(id)) { |     if (id is String && _idCache.containsKey(id)) { | ||||||
|       id = _idCache[id]; |       id = _idCache[id]; | ||||||
|     } |     } | ||||||
|     return _cache[id]; |     return _cache[id]; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   Future<void> _saveToLocal(Iterable<SnAccount> out) async { | ||||||
|  |     // For better on conflict resolution | ||||||
|  |     // And consider the method usually called with usually small amount of data | ||||||
|  |     // Use for to insert each record instead of bulk insert | ||||||
|  |     List<Future<int>> queries = out.map((ele) { | ||||||
|  |       return _dt.db.snLocalAccount.insertOne( | ||||||
|  |         SnLocalAccountCompanion.insert( | ||||||
|  |           id: Value(ele.id), | ||||||
|  |           name: ele.name, | ||||||
|  |           content: ele, | ||||||
|  |           cacheExpiredAt: DateTime.now().add(const Duration(hours: 1)), | ||||||
|  |         ), | ||||||
|  |         onConflict: DoUpdate( | ||||||
|  |           (_) => SnLocalAccountCompanion.custom( | ||||||
|  |             name: Constant(ele.name), | ||||||
|  |             content: Constant(jsonEncode(ele.toJson())), | ||||||
|  |             cacheExpiredAt: | ||||||
|  |                 Constant(DateTime.now().add(const Duration(hours: 1))), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |     }).toList(); | ||||||
|  |     await Future.wait(queries); | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,8 +1,9 @@ | |||||||
| import 'dart:developer'; | import 'dart:convert'; | ||||||
|  |  | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:shared_preferences/shared_preferences.dart'; | import 'package:shared_preferences/shared_preferences.dart'; | ||||||
|  | import 'package:surface/logger.dart'; | ||||||
| import 'package:surface/providers/config.dart'; | import 'package:surface/providers/config.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/types/account.dart'; | import 'package:surface/types/account.dart'; | ||||||
| @@ -30,13 +31,40 @@ class UserProvider extends ChangeNotifier { | |||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|     refreshUser().then((value) async { |     refreshUser().then((value) async { | ||||||
|       if (value != null) { |       if (value != null) { | ||||||
|         log('Logged in as @${value.name}'); |         logging.info('[Auth] Logged in as @${value.name}'); | ||||||
|         log('Atk: ${await atk}'); |         logging.debug('[Auth] Access token: ${await atk}'); | ||||||
|       } |       } | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   Future<Map<String, dynamic>?> get atkClaims async { | ||||||
|  |     final tk = (await atk); | ||||||
|  |     if (tk == null) return null; | ||||||
|  |     final atkParts = tk.split('.'); | ||||||
|  |     if (atkParts.length != 3) { | ||||||
|  |       throw Exception('invalid format of access token'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     var rawPayload = atkParts[1].replaceAll('-', '+').replaceAll('_', '/'); | ||||||
|  |     switch (rawPayload.length % 4) { | ||||||
|  |       case 0: | ||||||
|  |         break; | ||||||
|  |       case 2: | ||||||
|  |         rawPayload += '=='; | ||||||
|  |         break; | ||||||
|  |       case 3: | ||||||
|  |         rawPayload += '='; | ||||||
|  |         break; | ||||||
|  |       default: | ||||||
|  |         throw Exception('illegal format of access token payload'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     final b64 = utf8.fuse(base64Url); | ||||||
|  |     return jsonDecode(b64.decode(rawPayload)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   Future<SnAccount?> refreshUser() async { |   Future<SnAccount?> refreshUser() async { | ||||||
|  |     if (!isAuthorized) return null; | ||||||
|     final resp = await _sn.client.get('/cgi/id/users/me'); |     final resp = await _sn.client.get('/cgi/id/users/me'); | ||||||
|     final out = SnAccount.fromJson(resp.data); |     final out = SnAccount.fromJson(resp.data); | ||||||
|  |  | ||||||
| @@ -48,7 +76,13 @@ class UserProvider extends ChangeNotifier { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   void logoutUser() async { |   void logoutUser() async { | ||||||
|  |     atkClaims.then((value) async { | ||||||
|  |       if (value != null) { | ||||||
|  |         await _sn.client.delete('/cgi/id/users/me/tickets/${value['sed']}'); | ||||||
|  |         logging.info('[Auth] Current session has been destroyed.'); | ||||||
|  |       } | ||||||
|       _sn.clearTokenPair(); |       _sn.clearTokenPair(); | ||||||
|  |     }); | ||||||
|     isAuthorized = false; |     isAuthorized = false; | ||||||
|     user = null; |     user = null; | ||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|   | |||||||
| @@ -1,12 +1,15 @@ | |||||||
| import 'dart:async'; | import 'dart:async'; | ||||||
| import 'dart:convert'; | import 'dart:convert'; | ||||||
| import 'dart:developer'; |  | ||||||
|  |  | ||||||
|  | import 'package:flutter/foundation.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_udid/flutter_udid.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/logger.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/providers/userinfo.dart'; | import 'package:surface/providers/userinfo.dart'; | ||||||
| import 'package:surface/types/websocket.dart'; | import 'package:surface/types/websocket.dart'; | ||||||
|  | import 'package:web_socket_channel/io.dart'; | ||||||
| import 'package:web_socket_channel/web_socket_channel.dart'; | import 'package:web_socket_channel/web_socket_channel.dart'; | ||||||
|  |  | ||||||
| class WebSocketProvider extends ChangeNotifier { | class WebSocketProvider extends ChangeNotifier { | ||||||
| @@ -30,7 +33,7 @@ class WebSocketProvider extends ChangeNotifier { | |||||||
|     if (isConnected) return; |     if (isConnected) return; | ||||||
|     if (!_ua.isAuthorized) return; |     if (!_ua.isAuthorized) return; | ||||||
|  |  | ||||||
|     log('[WebSocket] Connecting to the server...'); |     logging.debug('[WebSocket] Connecting to the server...'); | ||||||
|     await connect(); |     await connect(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -39,7 +42,7 @@ class WebSocketProvider extends ChangeNotifier { | |||||||
|   Future<void> connect({noRetry = false}) async { |   Future<void> connect({noRetry = false}) async { | ||||||
|     if (_connectCompleter != null) { |     if (_connectCompleter != null) { | ||||||
|       await _connectCompleter!.future; |       await _connectCompleter!.future; | ||||||
|       _connectCompleter = null; |       return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (!_ua.isAuthorized) return; |     if (!_ua.isAuthorized) return; | ||||||
| @@ -52,27 +55,39 @@ class WebSocketProvider extends ChangeNotifier { | |||||||
|  |  | ||||||
|       final atk = await _sn.getFreshAtk(); |       final atk = await _sn.getFreshAtk(); | ||||||
|       final uri = Uri.parse( |       final uri = Uri.parse( | ||||||
|         '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk', |         kIsWeb | ||||||
|  |             ? '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk' | ||||||
|  |             : '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?clientId=${await FlutterUdid.consistentUdid}tk=$atk', | ||||||
|       ); |       ); | ||||||
|  |  | ||||||
|       isBusy = true; |       isBusy = true; | ||||||
|       notifyListeners(); |       notifyListeners(); | ||||||
|  |  | ||||||
|       conn = WebSocketChannel.connect(uri); |       conn = kIsWeb | ||||||
|  |           ? WebSocketChannel.connect(uri) | ||||||
|  |           : IOWebSocketChannel.connect( | ||||||
|  |               uri, | ||||||
|  |               headers: {'Authorization': 'Bearer $atk'}, | ||||||
|  |             ); | ||||||
|       await conn!.ready; |       await conn!.ready; | ||||||
|       _wsStream = conn!.stream.asBroadcastStream(); |       _wsStream = conn!.stream.asBroadcastStream(); | ||||||
|       listen(); |       listen(); | ||||||
|       log('[WebSocket] Connected to server!'); |       logging.info('[WebSocket] Connected to server!'); | ||||||
|       isConnected = true; |       isConnected = true; | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (err is WebSocketChannelException) { |       if (err is WebSocketChannelException) { | ||||||
|         log('Failed to connect to websocket: ${(err.inner as dynamic).message}'); |         logging.error( | ||||||
|  |           '[WebSocket] Failed to connect to websocket...', | ||||||
|  |           err.inner, | ||||||
|  |         ); | ||||||
|       } else { |       } else { | ||||||
|         log('Failed to connect to websocket: $err'); |         logging.error('[WebSocket] Failed to connect to websocket...', err); | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       if (!noRetry) { |       if (!noRetry) { | ||||||
|         log('Retry connecting to websocket in 3 seconds...'); |         logging.warning( | ||||||
|  |           '[WebSocket] Retry connecting to websocket in 3 seconds...', | ||||||
|  |         ); | ||||||
|         return Future.delayed( |         return Future.delayed( | ||||||
|           const Duration(seconds: 3), |           const Duration(seconds: 3), | ||||||
|           () => connect(noRetry: true), |           () => connect(noRetry: true), | ||||||
| @@ -100,7 +115,9 @@ class WebSocketProvider extends ChangeNotifier { | |||||||
|     _wsStream!.listen( |     _wsStream!.listen( | ||||||
|       (event) { |       (event) { | ||||||
|         final packet = WebSocketPackage.fromJson(jsonDecode(event)); |         final packet = WebSocketPackage.fromJson(jsonDecode(event)); | ||||||
|         log('Websocket incoming message: ${packet.method} ${packet.message}'); |         logging.debug( | ||||||
|  |           '[Websocket] Incoming message: ${packet.method} ${packet.message}', | ||||||
|  |         ); | ||||||
|         pk.sink.add(packet); |         pk.sink.add(packet); | ||||||
|       }, |       }, | ||||||
|       onDone: () { |       onDone: () { | ||||||
|   | |||||||
							
								
								
									
										155
									
								
								lib/router.dart
									
									
									
									
									
								
							
							
						
						
									
										155
									
								
								lib/router.dart
									
									
									
									
									
								
							| @@ -3,13 +3,22 @@ import 'package:flutter/material.dart'; | |||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
| import 'package:surface/screens/abuse_report.dart'; | import 'package:surface/screens/abuse_report.dart'; | ||||||
| import 'package:surface/screens/account.dart'; | import 'package:surface/screens/account.dart'; | ||||||
| import 'package:surface/screens/account/account_settings.dart'; | import 'package:surface/screens/account/punishments.dart'; | ||||||
|  | import 'package:surface/screens/account/settings.dart'; | ||||||
|  | import 'package:surface/screens/account/action_events.dart'; | ||||||
|  | import 'package:surface/screens/account/badges.dart'; | ||||||
|  | import 'package:surface/screens/account/contact_methods.dart'; | ||||||
| import 'package:surface/screens/account/factor_settings.dart'; | import 'package:surface/screens/account/factor_settings.dart'; | ||||||
|  | import 'package:surface/screens/account/keypairs.dart'; | ||||||
|  | import 'package:surface/screens/account/prefs/notify.dart'; | ||||||
|  | import 'package:surface/screens/account/prefs/security.dart'; | ||||||
| import 'package:surface/screens/account/profile_page.dart'; | import 'package:surface/screens/account/profile_page.dart'; | ||||||
| import 'package:surface/screens/account/profile_edit.dart'; | import 'package:surface/screens/account/profile_edit.dart'; | ||||||
|  | import 'package:surface/screens/account/programs.dart'; | ||||||
| import 'package:surface/screens/account/publishers/publisher_edit.dart'; | import 'package:surface/screens/account/publishers/publisher_edit.dart'; | ||||||
| import 'package:surface/screens/account/publishers/publisher_new.dart'; | import 'package:surface/screens/account/publishers/publisher_new.dart'; | ||||||
| import 'package:surface/screens/account/publishers/publishers.dart'; | import 'package:surface/screens/account/publishers/publishers.dart'; | ||||||
|  | import 'package:surface/screens/account/auth_tickets.dart'; | ||||||
| import 'package:surface/screens/album.dart'; | import 'package:surface/screens/album.dart'; | ||||||
| import 'package:surface/screens/auth/login.dart'; | import 'package:surface/screens/auth/login.dart'; | ||||||
| import 'package:surface/screens/auth/register.dart'; | import 'package:surface/screens/auth/register.dart'; | ||||||
| @@ -21,14 +30,18 @@ import 'package:surface/screens/chat/room.dart'; | |||||||
| import 'package:surface/screens/explore.dart'; | import 'package:surface/screens/explore.dart'; | ||||||
| import 'package:surface/screens/friend.dart'; | import 'package:surface/screens/friend.dart'; | ||||||
| import 'package:surface/screens/home.dart'; | import 'package:surface/screens/home.dart'; | ||||||
|  | import 'package:surface/screens/logging.dart'; | ||||||
| import 'package:surface/screens/news/news_detail.dart'; | import 'package:surface/screens/news/news_detail.dart'; | ||||||
| import 'package:surface/screens/news/news_list.dart'; | import 'package:surface/screens/news/news_list.dart'; | ||||||
| import 'package:surface/screens/notification.dart'; | import 'package:surface/screens/notification.dart'; | ||||||
| import 'package:surface/screens/post/post_detail.dart'; | import 'package:surface/screens/post/post_detail.dart'; | ||||||
|  | import 'package:surface/screens/post/post_draft.dart'; | ||||||
| import 'package:surface/screens/post/post_editor.dart'; | import 'package:surface/screens/post/post_editor.dart'; | ||||||
|  | import 'package:surface/screens/post/post_shuffle.dart'; | ||||||
| import 'package:surface/screens/post/publisher_page.dart'; | import 'package:surface/screens/post/publisher_page.dart'; | ||||||
| import 'package:surface/screens/post/post_search.dart'; | import 'package:surface/screens/post/post_search.dart'; | ||||||
| import 'package:surface/screens/realm.dart'; | import 'package:surface/screens/realm.dart'; | ||||||
|  | import 'package:surface/screens/realm/community.dart'; | ||||||
| import 'package:surface/screens/realm/manage.dart'; | import 'package:surface/screens/realm/manage.dart'; | ||||||
| import 'package:surface/screens/realm/realm_detail.dart'; | import 'package:surface/screens/realm/realm_detail.dart'; | ||||||
| import 'package:surface/screens/realm/realm_discovery.dart'; | import 'package:surface/screens/realm/realm_discovery.dart'; | ||||||
| @@ -59,14 +72,19 @@ final _appRoutes = [ | |||||||
|   ), |   ), | ||||||
|   GoRoute( |   GoRoute( | ||||||
|     path: '/posts', |     path: '/posts', | ||||||
|     name: 'explore', |     name: 'posts', | ||||||
|     builder: (context, state) => const ExploreScreen(), |     builder: (_, __) => const SizedBox.shrink(), | ||||||
|     routes: [ |     routes: [ | ||||||
|       GoRoute( |       GoRoute( | ||||||
|         path: '/write/:mode', |         path: '/draft', | ||||||
|  |         name: 'postDraftBox', | ||||||
|  |         builder: (context, state) => const PostDraftBox(), | ||||||
|  |       ), | ||||||
|  |       GoRoute( | ||||||
|  |         path: '/write', | ||||||
|         name: 'postEditor', |         name: 'postEditor', | ||||||
|         builder: (context, state) => PostEditorScreen( |         builder: (context, state) => PostEditorScreen( | ||||||
|           mode: state.pathParameters['mode']!, |           mode: state.uri.queryParameters['mode'], | ||||||
|           postEditId: int.tryParse( |           postEditId: int.tryParse( | ||||||
|             state.uri.queryParameters['editing'] ?? '', |             state.uri.queryParameters['editing'] ?? '', | ||||||
|           ), |           ), | ||||||
| @@ -79,6 +97,11 @@ final _appRoutes = [ | |||||||
|           extraProps: state.extra as PostEditorExtra?, |           extraProps: state.extra as PostEditorExtra?, | ||||||
|         ), |         ), | ||||||
|       ), |       ), | ||||||
|  |       GoRoute( | ||||||
|  |         path: '/shuffle', | ||||||
|  |         name: 'postShuffle', | ||||||
|  |         builder: (context, state) => const PostShuffleScreen(), | ||||||
|  |       ), | ||||||
|       GoRoute( |       GoRoute( | ||||||
|         path: '/search', |         path: '/search', | ||||||
|         name: 'postSearch', |         name: 'postSearch', | ||||||
| @@ -88,36 +111,108 @@ final _appRoutes = [ | |||||||
|               state.uri.queryParameters['categories']?.split(','), |               state.uri.queryParameters['categories']?.split(','), | ||||||
|         ), |         ), | ||||||
|       ), |       ), | ||||||
|  |     ], | ||||||
|  |   ), | ||||||
|  |   ShellRoute( | ||||||
|  |     builder: (context, state, child) => ResponsiveScaffold( | ||||||
|  |       asideFlex: 2, | ||||||
|  |       contentFlex: 3, | ||||||
|  |       aside: const ExploreScreen(), | ||||||
|  |       child: child, | ||||||
|  |     ), | ||||||
|  |     routes: [ | ||||||
|  |       GoRoute( | ||||||
|  |         path: '/explore', | ||||||
|  |         name: 'explore', | ||||||
|  |         builder: (context, state) => const ResponsiveScaffoldLanding( | ||||||
|  |           child: ExploreScreen(), | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |       GoRoute( | ||||||
|  |         path: '/posts/:slug', | ||||||
|  |         name: 'postDetail', | ||||||
|  |         builder: (context, state) => PostDetailScreen( | ||||||
|  |           key: ValueKey(state.pathParameters['slug']!), | ||||||
|  |           slug: state.pathParameters['slug']!, | ||||||
|  |           preload: state.extra as SnPost?, | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|       GoRoute( |       GoRoute( | ||||||
|         path: '/publishers/:name', |         path: '/publishers/:name', | ||||||
|         name: 'postPublisher', |         name: 'postPublisher', | ||||||
|         builder: (context, state) => |         builder: (context, state) => | ||||||
|             PostPublisherScreen(name: state.pathParameters['name']!), |             PostPublisherScreen(name: state.pathParameters['name']!), | ||||||
|       ), |       ), | ||||||
|       GoRoute( |  | ||||||
|         path: '/:slug', |  | ||||||
|         name: 'postDetail', |  | ||||||
|         builder: (context, state) => PostDetailScreen( |  | ||||||
|           slug: state.pathParameters['slug']!, |  | ||||||
|           preload: state.extra as SnPost?, |  | ||||||
|         ), |  | ||||||
|       ), |  | ||||||
|     ], |     ], | ||||||
|   ), |   ), | ||||||
|  |   ShellRoute( | ||||||
|  |     builder: (context, state, child) => ResponsiveScaffold( | ||||||
|  |       aside: const AccountScreen(), | ||||||
|  |       child: child, | ||||||
|  |     ), | ||||||
|  |     routes: [ | ||||||
|       GoRoute( |       GoRoute( | ||||||
|         path: '/account', |         path: '/account', | ||||||
|         name: 'account', |         name: 'account', | ||||||
|       builder: (context, state) => const AccountScreen(), |         builder: (context, state) => | ||||||
|  |             const ResponsiveScaffoldLanding(child: AccountScreen()), | ||||||
|         routes: [ |         routes: [ | ||||||
|  |           GoRoute( | ||||||
|  |             path: '/punishments', | ||||||
|  |             name: 'accountPunishments', | ||||||
|  |             builder: (context, state) => const PunishmentsScreen(), | ||||||
|  |           ), | ||||||
|  |           GoRoute( | ||||||
|  |             path: '/programs', | ||||||
|  |             name: 'accountProgram', | ||||||
|  |             builder: (context, state) => const AccountProgramScreen(), | ||||||
|  |           ), | ||||||
|  |           GoRoute( | ||||||
|  |             path: '/contacts', | ||||||
|  |             name: 'accountContactMethods', | ||||||
|  |             builder: (context, state) => const AccountContactMethod(), | ||||||
|  |           ), | ||||||
|  |           GoRoute( | ||||||
|  |             path: '/events', | ||||||
|  |             name: 'accountActionEvents', | ||||||
|  |             builder: (context, state) => const ActionEventScreen(), | ||||||
|  |           ), | ||||||
|  |           GoRoute( | ||||||
|  |             path: '/tickets', | ||||||
|  |             name: 'accountAuthTickets', | ||||||
|  |             builder: (context, state) => const AccountAuthTicket(), | ||||||
|  |           ), | ||||||
|  |           GoRoute( | ||||||
|  |             path: '/badges', | ||||||
|  |             name: 'accountBadges', | ||||||
|  |             builder: (context, state) => const AccountBadgesScreen(), | ||||||
|  |           ), | ||||||
|           GoRoute( |           GoRoute( | ||||||
|             path: '/wallet', |             path: '/wallet', | ||||||
|             name: 'accountWallet', |             name: 'accountWallet', | ||||||
|             builder: (context, state) => const WalletScreen(), |             builder: (context, state) => const WalletScreen(), | ||||||
|           ), |           ), | ||||||
|  |           GoRoute( | ||||||
|  |             path: '/keypairs', | ||||||
|  |             name: 'accountKeyPairs', | ||||||
|  |             builder: (context, state) => const KeyPairScreen(), | ||||||
|  |           ), | ||||||
|           GoRoute( |           GoRoute( | ||||||
|             path: '/settings', |             path: '/settings', | ||||||
|             name: 'accountSettings', |             name: 'accountSettings', | ||||||
|             builder: (context, state) => AccountSettingsScreen(), |             builder: (context, state) => AccountSettingsScreen(), | ||||||
|  |             routes: [ | ||||||
|  |               GoRoute( | ||||||
|  |                 path: '/notify', | ||||||
|  |                 name: 'accountSettingsNotify', | ||||||
|  |                 builder: (context, state) => const AccountNotifyPrefsScreen(), | ||||||
|  |               ), | ||||||
|  |               GoRoute( | ||||||
|  |                 path: '/auth', | ||||||
|  |                 name: 'accountSettingsSecurity', | ||||||
|  |                 builder: (context, state) => const AccountSecurityPrefsScreen(), | ||||||
|  |               ), | ||||||
|  |             ], | ||||||
|           ), |           ), | ||||||
|           GoRoute( |           GoRoute( | ||||||
|             path: '/settings/factors', |             path: '/settings/factors', | ||||||
| @@ -146,23 +241,35 @@ final _appRoutes = [ | |||||||
|               name: state.pathParameters['name']!, |               name: state.pathParameters['name']!, | ||||||
|             ), |             ), | ||||||
|           ), |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ], | ||||||
|  |   ), | ||||||
|   GoRoute( |   GoRoute( | ||||||
|           path: '/:name', |     path: '/accounts/:name', | ||||||
|     name: 'accountProfilePage', |     name: 'accountProfilePage', | ||||||
|     pageBuilder: (context, state) => NoTransitionPage( |     pageBuilder: (context, state) => NoTransitionPage( | ||||||
|       child: UserScreen(name: state.pathParameters['name']!), |       child: UserScreen(name: state.pathParameters['name']!), | ||||||
|     ), |     ), | ||||||
|   ), |   ), | ||||||
|       ]), |   ShellRoute( | ||||||
|  |     builder: (context, state, child) => | ||||||
|  |         ResponsiveScaffold(aside: const ChatScreen(), child: child), | ||||||
|  |     routes: [ | ||||||
|       GoRoute( |       GoRoute( | ||||||
|         path: '/chat', |         path: '/chat', | ||||||
|         name: 'chat', |         name: 'chat', | ||||||
|     builder: (context, state) => const ChatScreen(), |         builder: (context, state) => const ResponsiveScaffoldLanding( | ||||||
|  |           child: ChatScreen(), | ||||||
|  |         ), | ||||||
|         routes: [ |         routes: [ | ||||||
|           GoRoute( |           GoRoute( | ||||||
|             path: '/:scope/:alias', |             path: '/:scope/:alias', | ||||||
|             name: 'chatRoom', |             name: 'chatRoom', | ||||||
|             builder: (context, state) => ChatRoomScreen( |             builder: (context, state) => ChatRoomScreen( | ||||||
|  |               key: ValueKey( | ||||||
|  |                 '${state.pathParameters['scope']!}:${state.pathParameters['alias']!}', | ||||||
|  |               ), | ||||||
|               scope: state.pathParameters['scope']!, |               scope: state.pathParameters['scope']!, | ||||||
|               alias: state.pathParameters['alias']!, |               alias: state.pathParameters['alias']!, | ||||||
|               extra: state.extra as ChatRoomScreenExtra?, |               extra: state.extra as ChatRoomScreenExtra?, | ||||||
| @@ -193,6 +300,8 @@ final _appRoutes = [ | |||||||
|           ), |           ), | ||||||
|         ], |         ], | ||||||
|       ), |       ), | ||||||
|  |     ], | ||||||
|  |   ), | ||||||
|   GoRoute( |   GoRoute( | ||||||
|     path: '/realm', |     path: '/realm', | ||||||
|     name: 'realm', |     name: 'realm', | ||||||
| @@ -201,6 +310,13 @@ final _appRoutes = [ | |||||||
|       child: const RealmScreen(), |       child: const RealmScreen(), | ||||||
|     ), |     ), | ||||||
|     routes: [ |     routes: [ | ||||||
|  |       GoRoute( | ||||||
|  |         path: '/:alias/community', | ||||||
|  |         name: 'realmCommunity', | ||||||
|  |         builder: (context, state) => RealmCommunityScreen( | ||||||
|  |           alias: state.pathParameters['alias']!, | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|       GoRoute( |       GoRoute( | ||||||
|         path: '/manage', |         path: '/manage', | ||||||
|         name: 'realmManage', |         name: 'realmManage', | ||||||
| @@ -249,6 +365,11 @@ final _appRoutes = [ | |||||||
|       ), |       ), | ||||||
|     ], |     ], | ||||||
|   ), |   ), | ||||||
|  |   GoRoute( | ||||||
|  |     path: '/debug/logging', | ||||||
|  |     name: 'debugLogging', | ||||||
|  |     builder: (context, state) => const DebugLoggingScreen(), | ||||||
|  |   ), | ||||||
|   GoRoute( |   GoRoute( | ||||||
|     path: '/album', |     path: '/album', | ||||||
|     name: 'album', |     name: 'album', | ||||||
|   | |||||||
| @@ -8,10 +8,13 @@ import 'package:material_symbols_icons/symbols.dart'; | |||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
| import 'package:surface/providers/database.dart'; | import 'package:surface/providers/database.dart'; | ||||||
|  | import 'package:surface/providers/navigation.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/providers/userinfo.dart'; | import 'package:surface/providers/userinfo.dart'; | ||||||
| import 'package:surface/providers/websocket.dart'; | import 'package:surface/providers/websocket.dart'; | ||||||
|  | import 'package:surface/types/account.dart'; | ||||||
| import 'package:surface/widgets/account/account_image.dart'; | import 'package:surface/widgets/account/account_image.dart'; | ||||||
|  | import 'package:surface/widgets/account/account_status.dart'; | ||||||
| import 'package:surface/widgets/app_bar_leading.dart'; | import 'package:surface/widgets/app_bar_leading.dart'; | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
| @@ -20,27 +23,97 @@ import 'package:surface/widgets/universal_image.dart'; | |||||||
| class AccountScreen extends StatelessWidget { | class AccountScreen extends StatelessWidget { | ||||||
|   const AccountScreen({super.key}); |   const AccountScreen({super.key}); | ||||||
|  |  | ||||||
|  |   static const List<AppNavListItem> kNavList = [ | ||||||
|  |     AppNavListItem( | ||||||
|  |       title: "accountPublishers", | ||||||
|  |       subtitle: "accountPublishersSubtitle", | ||||||
|  |       screen: "accountPublishers", | ||||||
|  |       icon: Symbols.face, | ||||||
|  |     ), | ||||||
|  |     AppNavListItem( | ||||||
|  |       title: "accountProgram", | ||||||
|  |       subtitle: "accountProgramDescription", | ||||||
|  |       screen: "accountProgram", | ||||||
|  |       icon: Symbols.communities, | ||||||
|  |     ), | ||||||
|  |     AppNavListItem( | ||||||
|  |       title: "friends", | ||||||
|  |       subtitle: "friendsDescription", | ||||||
|  |       screen: "friend", | ||||||
|  |       icon: Symbols.person, | ||||||
|  |     ), | ||||||
|  |     AppNavListItem( | ||||||
|  |       title: "album", | ||||||
|  |       subtitle: "albumDescription", | ||||||
|  |       screen: "album", | ||||||
|  |       icon: Symbols.photo_library, | ||||||
|  |     ), | ||||||
|  |     AppNavListItem( | ||||||
|  |       title: "stickers", | ||||||
|  |       subtitle: "stickersDescription", | ||||||
|  |       screen: "stickers", | ||||||
|  |       icon: Symbols.emoji_emotions, | ||||||
|  |     ), | ||||||
|  |     AppNavListItem( | ||||||
|  |       title: "accountWallet", | ||||||
|  |       subtitle: "accountWalletSubtitle", | ||||||
|  |       screen: "accountWallet", | ||||||
|  |       icon: Symbols.wallet, | ||||||
|  |     ), | ||||||
|  |     AppNavListItem( | ||||||
|  |       title: "accountBadges", | ||||||
|  |       subtitle: "accountBadgesDescription", | ||||||
|  |       screen: "accountBadges", | ||||||
|  |       icon: Symbols.award_star, | ||||||
|  |     ), | ||||||
|  |     AppNavListItem( | ||||||
|  |       title: "accountKeyPairs", | ||||||
|  |       subtitle: "accountKeyPairsDescription", | ||||||
|  |       screen: "accountKeyPairs", | ||||||
|  |       icon: Symbols.key, | ||||||
|  |     ), | ||||||
|  |     AppNavListItem( | ||||||
|  |       title: "accountPunishments", | ||||||
|  |       subtitle: "accountPunishmentsDescription", | ||||||
|  |       screen: "accountPunishments", | ||||||
|  |       icon: Symbols.credit_score, | ||||||
|  |     ), | ||||||
|  |     AppNavListItem( | ||||||
|  |       title: "accountActionEvent", | ||||||
|  |       subtitle: "accountActionEventDescription", | ||||||
|  |       screen: "accountActionEvents", | ||||||
|  |       icon: Symbols.history, | ||||||
|  |     ), | ||||||
|  |     AppNavListItem( | ||||||
|  |       title: "accountAuthTickets", | ||||||
|  |       subtitle: "accountAuthTicketsDescription", | ||||||
|  |       screen: "accountAuthTickets", | ||||||
|  |       icon: Symbols.confirmation_number, | ||||||
|  |     ), | ||||||
|  |     AppNavListItem( | ||||||
|  |       title: "accountSettings", | ||||||
|  |       subtitle: "accountSettingsSubtitle", | ||||||
|  |       screen: "accountSettings", | ||||||
|  |       icon: Symbols.manage_accounts, | ||||||
|  |     ), | ||||||
|  |     AppNavListItem( | ||||||
|  |       title: "abuseReport", | ||||||
|  |       subtitle: "abuseReportActionDescription", | ||||||
|  |       screen: "abuseReport", | ||||||
|  |       icon: Symbols.flag, | ||||||
|  |     ), | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     final ua = context.watch<UserProvider>(); |     final ua = context.watch<UserProvider>(); | ||||||
|     final sn = context.read<SnNetworkProvider>(); |     final sn = context.read<SnNetworkProvider>(); | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         leading: AutoAppBarLeading(), |         leading: AutoAppBarLeading(), | ||||||
|         title: Text( |         title: Text("screenAccount").tr(), | ||||||
|           "screenAccount", |  | ||||||
|           style: TextStyle( |  | ||||||
|             color: Colors.white, |  | ||||||
|             shadows: [ |  | ||||||
|               Shadow( |  | ||||||
|                 offset: Offset(1, 1), |  | ||||||
|                 blurRadius: 5.0, |  | ||||||
|                 color: Color.fromARGB(255, 0, 0, 0), |  | ||||||
|               ), |  | ||||||
|             ], |  | ||||||
|           ), |  | ||||||
|         ).tr(), |  | ||||||
|         flexibleSpace: ua.user != null && ua.user!.banner.isNotEmpty |         flexibleSpace: ua.user != null && ua.user!.banner.isNotEmpty | ||||||
|             ? Stack( |             ? Stack( | ||||||
|                 fit: StackFit.expand, |                 fit: StackFit.expand, | ||||||
| @@ -69,15 +142,6 @@ class AccountScreen extends StatelessWidget { | |||||||
|                 ], |                 ], | ||||||
|               ) |               ) | ||||||
|             : null, |             : null, | ||||||
|         actions: [ |  | ||||||
|           IconButton( |  | ||||||
|             icon: const Icon(Symbols.settings, fill: 1), |  | ||||||
|             onPressed: () { |  | ||||||
|               GoRouter.of(context).pushNamed('settings'); |  | ||||||
|             }, |  | ||||||
|           ), |  | ||||||
|           const Gap(8), |  | ||||||
|         ], |  | ||||||
|       ), |       ), | ||||||
|       body: SingleChildScrollView( |       body: SingleChildScrollView( | ||||||
|         child: ua.isAuthorized |         child: ua.isAuthorized | ||||||
| @@ -112,7 +176,25 @@ class _AuthorizedAccountScreen extends StatelessWidget { | |||||||
|               child: Column( |               child: Column( | ||||||
|                 crossAxisAlignment: CrossAxisAlignment.start, |                 crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|                 children: [ |                 children: [ | ||||||
|                   AccountImage(content: ua.user!.avatar, radius: 28), |                   Row( | ||||||
|  |                     mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||||
|  |                     crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                     children: [ | ||||||
|  |                       GestureDetector( | ||||||
|  |                         child: AccountImage( | ||||||
|  |                           content: ua.user!.avatar, | ||||||
|  |                           radius: 28, | ||||||
|  |                         ), | ||||||
|  |                         onTap: () { | ||||||
|  |                           GoRouter.of(context) | ||||||
|  |                               .pushNamed('accountProfilePage', pathParameters: { | ||||||
|  |                             'name': ua.user!.name, | ||||||
|  |                           }); | ||||||
|  |                         }, | ||||||
|  |                       ), | ||||||
|  |                       _AccountStatusWidget(account: ua.user!), | ||||||
|  |                     ], | ||||||
|  |                   ), | ||||||
|                   const Gap(8), |                   const Gap(8), | ||||||
|                   Row( |                   Row( | ||||||
|                     crossAxisAlignment: CrossAxisAlignment.baseline, |                     crossAxisAlignment: CrossAxisAlignment.baseline, | ||||||
| @@ -125,66 +207,38 @@ class _AuthorizedAccountScreen extends StatelessWidget { | |||||||
|                           .textStyle(Theme.of(context).textTheme.bodySmall!), |                           .textStyle(Theme.of(context).textTheme.bodySmall!), | ||||||
|                     ], |                     ], | ||||||
|                   ), |                   ), | ||||||
|                   Text(ua.user!.description) |                   Text( | ||||||
|                       .textStyle(Theme.of(context).textTheme.bodyMedium!), |                     (ua.user!.profile?.description.isNotEmpty ?? false) | ||||||
|  |                         ? ua.user!.profile!.description | ||||||
|  |                         : 'userNoDescription'.tr(), | ||||||
|  |                     style: (ua.user!.profile?.description.isEmpty ?? true) | ||||||
|  |                         ? TextStyle(fontStyle: FontStyle.italic) | ||||||
|  |                         : null, | ||||||
|  |                   ).textStyle(Theme.of(context).textTheme.bodyMedium!), | ||||||
|                 ], |                 ], | ||||||
|               ), |               ), | ||||||
|             ); |             ); | ||||||
|           }).padding(all: 20), |           }).padding(all: 20), | ||||||
|         ).padding(horizontal: 8, top: 16, bottom: 4), |         ).padding(horizontal: 8, top: 16, bottom: 4), | ||||||
|         ListTile( |         for (final item in AccountScreen.kNavList) | ||||||
|           title: Text('accountPublishers').tr(), |           Tooltip( | ||||||
|           subtitle: Text('accountPublishersSubtitle').tr(), |             message: item.subtitle.tr(), | ||||||
|  |             child: ListTile( | ||||||
|  |               minTileHeight: 48, | ||||||
|  |               title: Text(item.title).tr(), | ||||||
|               contentPadding: const EdgeInsets.symmetric(horizontal: 24), |               contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|           leading: const Icon(Symbols.face), |               leading: Icon(item.icon), | ||||||
|               trailing: const Icon(Symbols.chevron_right), |               trailing: const Icon(Symbols.chevron_right), | ||||||
|               onTap: () { |               onTap: () { | ||||||
|             GoRouter.of(context).pushNamed('accountPublishers'); |                 GoRouter.of(context).pushNamed(item.screen); | ||||||
|               }, |               }, | ||||||
|             ), |             ), | ||||||
|         ListTile( |  | ||||||
|           title: Text('abuseReport').tr(), |  | ||||||
|           subtitle: Text('abuseReportActionDescription').tr(), |  | ||||||
|           contentPadding: const EdgeInsets.symmetric(horizontal: 24), |  | ||||||
|           leading: const Icon(Symbols.flag), |  | ||||||
|           trailing: const Icon(Symbols.chevron_right), |  | ||||||
|           onTap: () { |  | ||||||
|             GoRouter.of(context).pushNamed('abuseReport'); |  | ||||||
|           }, |  | ||||||
|           ), |           ), | ||||||
|         ListTile( |         Tooltip( | ||||||
|           title: Text('factorSettings').tr(), |           message: 'accountLogoutSubtitle'.tr(), | ||||||
|           subtitle: Text('factorSettingsSubtitle').tr(), |           child: ListTile( | ||||||
|           contentPadding: const EdgeInsets.symmetric(horizontal: 24), |  | ||||||
|           leading: const Icon(Symbols.lock), |  | ||||||
|           trailing: const Icon(Symbols.chevron_right), |  | ||||||
|           onTap: () { |  | ||||||
|             GoRouter.of(context).pushNamed('factorSettings'); |  | ||||||
|           }, |  | ||||||
|         ), |  | ||||||
|         ListTile( |  | ||||||
|           title: Text('accountWallet').tr(), |  | ||||||
|           subtitle: Text('accountWalletSubtitle').tr(), |  | ||||||
|           contentPadding: const EdgeInsets.symmetric(horizontal: 24), |  | ||||||
|           leading: const Icon(Symbols.wallet), |  | ||||||
|           trailing: const Icon(Symbols.chevron_right), |  | ||||||
|           onTap: () { |  | ||||||
|             GoRouter.of(context).pushNamed('accountWallet'); |  | ||||||
|           }, |  | ||||||
|         ), |  | ||||||
|         ListTile( |  | ||||||
|           title: Text('accountSettings').tr(), |  | ||||||
|           subtitle: Text('accountSettingsSubtitle').tr(), |  | ||||||
|           contentPadding: const EdgeInsets.symmetric(horizontal: 24), |  | ||||||
|           leading: const Icon(Symbols.manage_accounts), |  | ||||||
|           trailing: const Icon(Symbols.chevron_right), |  | ||||||
|           onTap: () { |  | ||||||
|             GoRouter.of(context).pushNamed('accountSettings'); |  | ||||||
|           }, |  | ||||||
|         ), |  | ||||||
|         ListTile( |  | ||||||
|             title: Text('accountLogout').tr(), |             title: Text('accountLogout').tr(), | ||||||
|           subtitle: Text('accountLogoutSubtitle').tr(), |             minTileHeight: 48, | ||||||
|             contentPadding: const EdgeInsets.symmetric(horizontal: 24), |             contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|             leading: const Icon(Symbols.logout), |             leading: const Icon(Symbols.logout), | ||||||
|             trailing: const Icon(Symbols.chevron_right), |             trailing: const Icon(Symbols.chevron_right), | ||||||
| @@ -202,6 +256,7 @@ class _AuthorizedAccountScreen extends StatelessWidget { | |||||||
|               context.read<DatabaseProvider>().removeDatabase(); |               context.read<DatabaseProvider>().removeDatabase(); | ||||||
|             }, |             }, | ||||||
|           ), |           ), | ||||||
|  |         ), | ||||||
|       ], |       ], | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| @@ -243,9 +298,7 @@ class _UnauthorizedAccountScreen extends StatelessWidget { | |||||||
|             GoRouter.of(context).pushNamed('authLogin').then((value) { |             GoRouter.of(context).pushNamed('authLogin').then((value) { | ||||||
|               if (value == true && context.mounted) { |               if (value == true && context.mounted) { | ||||||
|                 final ua = context.read<UserProvider>(); |                 final ua = context.read<UserProvider>(); | ||||||
|                 context.showSnackbar('loginSuccess'.tr(args: [ |                 ua.refreshUser(); | ||||||
|                   '@${ua.user?.name} (${ua.user?.nick})', |  | ||||||
|                 ])); |  | ||||||
|               } |               } | ||||||
|             }); |             }); | ||||||
|           }, |           }, | ||||||
| @@ -264,3 +317,81 @@ class _UnauthorizedAccountScreen extends StatelessWidget { | |||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | class _AccountStatusWidget extends StatefulWidget { | ||||||
|  |   final SnAccount account; | ||||||
|  |   const _AccountStatusWidget({required this.account}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<_AccountStatusWidget> createState() => _AccountStatusWidgetState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _AccountStatusWidgetState extends State<_AccountStatusWidget> { | ||||||
|  |   SnAccountStatusInfo? _status; | ||||||
|  |  | ||||||
|  |   Future<void> _fetchStatus() async { | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       final resp = | ||||||
|  |           await sn.client.get('/cgi/id/users/${widget.account.name}/status'); | ||||||
|  |       setState(() { | ||||||
|  |         _status = SnAccountStatusInfo.fromJson(resp.data); | ||||||
|  |       }); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() {}); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _fetchStatus(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return InkWell( | ||||||
|  |       child: Row( | ||||||
|  |         children: [ | ||||||
|  |           Text( | ||||||
|  |             _status != null | ||||||
|  |                 ? (_status!.status?.label.isNotEmpty ?? false) | ||||||
|  |                     ? _status!.status!.label | ||||||
|  |                     : _status!.isOnline | ||||||
|  |                         ? 'accountStatusOnline'.tr() | ||||||
|  |                         : 'accountStatusOffline'.tr() | ||||||
|  |                 : 'loading'.tr(), | ||||||
|  |           ), | ||||||
|  |           const Gap(4), | ||||||
|  |           Icon( | ||||||
|  |             (_status?.isDisturbable ?? true) | ||||||
|  |                 ? Symbols.circle | ||||||
|  |                 : Symbols.do_not_disturb_on, | ||||||
|  |             fill: (_status?.isOnline ?? false) ? 1 : 0, | ||||||
|  |             size: 16, | ||||||
|  |             color: (_status?.isOnline ?? false) | ||||||
|  |                 ? (_status?.isDisturbable ?? true) | ||||||
|  |                     ? Colors.green | ||||||
|  |                     : Colors.red | ||||||
|  |                 : Colors.grey, | ||||||
|  |           ).padding(all: 4), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |       onTap: () { | ||||||
|  |         showModalBottomSheet( | ||||||
|  |           context: context, | ||||||
|  |           builder: (context) => AccountStatusActionPopup( | ||||||
|  |             currentStatus: _status, | ||||||
|  |           ), | ||||||
|  |         ).then((value) { | ||||||
|  |           if (value == true && mounted) { | ||||||
|  |             _fetchStatus(); | ||||||
|  |           } | ||||||
|  |         }); | ||||||
|  |       }, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|   | |||||||
							
								
								
									
										161
									
								
								lib/screens/account/action_events.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										161
									
								
								lib/screens/account/action_events.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,161 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  |  | ||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:google_fonts/google_fonts.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:relative_time/relative_time.dart'; | ||||||
|  | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:surface/providers/sn_network.dart'; | ||||||
|  | import 'package:surface/types/account.dart'; | ||||||
|  | import 'package:surface/widgets/dialog.dart'; | ||||||
|  | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  | import 'package:timelines_plus/timelines_plus.dart'; | ||||||
|  | import 'package:very_good_infinite_list/very_good_infinite_list.dart'; | ||||||
|  |  | ||||||
|  | class ActionEventScreen extends StatefulWidget { | ||||||
|  |   const ActionEventScreen({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<ActionEventScreen> createState() => _ActionEventScreenState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _ActionEventScreenState extends State<ActionEventScreen> { | ||||||
|  |   bool _isBusy = false; | ||||||
|  |   int? _totalCount; | ||||||
|  |   final List<SnActionEvent> _actionEvents = List.empty(growable: true); | ||||||
|  |  | ||||||
|  |   Future<void> _fetchActionEvents() async { | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       final resp = await sn.client.get( | ||||||
|  |         '/cgi/id/users/me/events', | ||||||
|  |         queryParameters: { | ||||||
|  |           'take': 10, | ||||||
|  |           'offset': _actionEvents.length, | ||||||
|  |         }, | ||||||
|  |       ); | ||||||
|  |       _totalCount = resp.data['count']; | ||||||
|  |       _actionEvents.addAll( | ||||||
|  |         (resp.data['data'] as List<dynamic>) | ||||||
|  |             .map((e) => SnActionEvent.fromJson(e)), | ||||||
|  |       ); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _fetchActionEvents(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|  |       appBar: AppBar( | ||||||
|  |         leading: const PageBackButton(), | ||||||
|  |         title: Text('accountActionEvent').tr(), | ||||||
|  |       ), | ||||||
|  |       body: Column( | ||||||
|  |         children: [ | ||||||
|  |           LoadingIndicator(isActive: _isBusy), | ||||||
|  |           Expanded( | ||||||
|  |             child: RefreshIndicator( | ||||||
|  |               onRefresh: () { | ||||||
|  |                 _totalCount = null; | ||||||
|  |                 return _fetchActionEvents(); | ||||||
|  |               }, | ||||||
|  |               child: InfiniteList( | ||||||
|  |                 padding: EdgeInsets.only(left: 20, right: 8), | ||||||
|  |                 itemCount: _actionEvents.length, | ||||||
|  |                 isLoading: _isBusy, | ||||||
|  |                 hasReachedMax: | ||||||
|  |                     _totalCount != null && _actionEvents.length >= _totalCount!, | ||||||
|  |                 onFetchData: _fetchActionEvents, | ||||||
|  |                 itemBuilder: (context, idx) { | ||||||
|  |                   final event = _actionEvents[idx]; | ||||||
|  |                   return TimelineTile( | ||||||
|  |                     nodeAlign: TimelineNodeAlign.start, | ||||||
|  |                     contents: Card( | ||||||
|  |                       margin: EdgeInsets.symmetric(horizontal: 8, vertical: 12), | ||||||
|  |                       child: Column( | ||||||
|  |                         crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                         children: [ | ||||||
|  |                           Container( | ||||||
|  |                             padding: EdgeInsets.symmetric( | ||||||
|  |                               horizontal: 16, | ||||||
|  |                               vertical: 12, | ||||||
|  |                             ), | ||||||
|  |                             child: Column( | ||||||
|  |                               crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                               children: [ | ||||||
|  |                                 Text( | ||||||
|  |                                   event.type, | ||||||
|  |                                   maxLines: 1, | ||||||
|  |                                   style: GoogleFonts.robotoMono(), | ||||||
|  |                                 ), | ||||||
|  |                                 if (event.ipAddress.isNotEmpty) | ||||||
|  |                                   Text( | ||||||
|  |                                     event.ipAddress, | ||||||
|  |                                     style: TextStyle(fontSize: 13), | ||||||
|  |                                   ), | ||||||
|  |                                 if (event.location?.isNotEmpty ?? false) | ||||||
|  |                                   Text(event.location!), | ||||||
|  |                                 Row( | ||||||
|  |                                   children: [ | ||||||
|  |                                     Text(DateFormat() | ||||||
|  |                                             .format(event.createdAt.toLocal())) | ||||||
|  |                                         .fontSize(12), | ||||||
|  |                                     Text(' · ') | ||||||
|  |                                         .fontSize(12) | ||||||
|  |                                         .padding(horizontal: 4), | ||||||
|  |                                     Text(RelativeTime(context) | ||||||
|  |                                             .format(event.createdAt.toLocal())) | ||||||
|  |                                         .fontSize(12), | ||||||
|  |                                   ], | ||||||
|  |                                 ).opacity(0.75).padding(top: 4), | ||||||
|  |                               ], | ||||||
|  |                             ), | ||||||
|  |                           ), | ||||||
|  |                           if (event.metadata != null) | ||||||
|  |                             ExpansionTile( | ||||||
|  |                               minTileHeight: 40, | ||||||
|  |                               tilePadding: EdgeInsets.symmetric(horizontal: 16), | ||||||
|  |                               title: Text('eventMetadata').tr(), | ||||||
|  |                               expandedAlignment: Alignment.topLeft, | ||||||
|  |                               expandedCrossAxisAlignment: | ||||||
|  |                                   CrossAxisAlignment.start, | ||||||
|  |                               children: [ | ||||||
|  |                                 Text( | ||||||
|  |                                   JsonEncoder.withIndent('\t') | ||||||
|  |                                       .convert(event.metadata), | ||||||
|  |                                   style: GoogleFonts.robotoMono(), | ||||||
|  |                                 ).padding(vertical: 8, horizontal: 16), | ||||||
|  |                               ], | ||||||
|  |                             ).padding(bottom: 6), | ||||||
|  |                         ], | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                     node: TimelineNode( | ||||||
|  |                       indicator: DotIndicator(), | ||||||
|  |                       startConnector: SolidLineConnector(), | ||||||
|  |                       endConnector: SolidLineConnector(), | ||||||
|  |                     ), | ||||||
|  |                   ); | ||||||
|  |                 }, | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										187
									
								
								lib/screens/account/auth_tickets.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										187
									
								
								lib/screens/account/auth_tickets.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,187 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:gap/gap.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:surface/providers/sn_network.dart'; | ||||||
|  | import 'package:surface/providers/userinfo.dart'; | ||||||
|  | import 'package:surface/types/auth.dart'; | ||||||
|  | import 'package:surface/widgets/dialog.dart'; | ||||||
|  | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  | import 'package:very_good_infinite_list/very_good_infinite_list.dart'; | ||||||
|  |  | ||||||
|  | const Map<String, IconData> kAuthTicketIcon = { | ||||||
|  |   'ios': Symbols.ios, | ||||||
|  |   'android': Symbols.android, | ||||||
|  |   'macos': Symbols.computer, | ||||||
|  |   'windows nt': Symbols.laptop_windows, | ||||||
|  |   'linux': Symbols.laptop, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | class AccountAuthTicket extends StatefulWidget { | ||||||
|  |   const AccountAuthTicket({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<AccountAuthTicket> createState() => _AccountAuthTicketState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _AccountAuthTicketState extends State<AccountAuthTicket> { | ||||||
|  |   bool _isBusy = false; | ||||||
|  |   int? _totalCount; | ||||||
|  |   final List<SnAuthTicket> _authTickets = List.empty(growable: true); | ||||||
|  |  | ||||||
|  |   Future<void> _fetchAuthTickets() async { | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       final resp = await sn.client.get( | ||||||
|  |         '/cgi/id/users/me/tickets', | ||||||
|  |         queryParameters: { | ||||||
|  |           'take': 10, | ||||||
|  |           'offset': _authTickets.length, | ||||||
|  |         }, | ||||||
|  |       ); | ||||||
|  |       _totalCount = resp.data['count']; | ||||||
|  |       _authTickets.addAll( | ||||||
|  |         (resp.data['data'] as List<dynamic>) | ||||||
|  |             .map((e) => SnAuthTicket.fromJson(e)), | ||||||
|  |       ); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> _deleteAuthTicket(SnAuthTicket ticket) async { | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       await sn.client.delete( | ||||||
|  |         '/cgi/id/users/me/tickets/${ticket.id}', | ||||||
|  |       ); | ||||||
|  |       setState(() { | ||||||
|  |         _authTickets.remove(ticket); | ||||||
|  |       }); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   int? _currentTicketId; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _fetchAuthTickets(); | ||||||
|  |  | ||||||
|  |     final ua = context.read<UserProvider>(); | ||||||
|  |     ua.atkClaims.then((value) { | ||||||
|  |       if (value == null) return; | ||||||
|  |       _currentTicketId = int.parse(value['sed']); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|  |       appBar: AppBar( | ||||||
|  |         leading: const PageBackButton(), | ||||||
|  |         title: Text('accountAuthTickets').tr(), | ||||||
|  |       ), | ||||||
|  |       body: Column( | ||||||
|  |         children: [ | ||||||
|  |           LoadingIndicator(isActive: _isBusy), | ||||||
|  |           Expanded( | ||||||
|  |             child: RefreshIndicator( | ||||||
|  |               onRefresh: () { | ||||||
|  |                 _totalCount = null; | ||||||
|  |                 return _fetchAuthTickets(); | ||||||
|  |               }, | ||||||
|  |               child: InfiniteList( | ||||||
|  |                 padding: EdgeInsets.zero, | ||||||
|  |                 onFetchData: _fetchAuthTickets, | ||||||
|  |                 isLoading: _isBusy, | ||||||
|  |                 hasReachedMax: | ||||||
|  |                     _totalCount != null && _authTickets.length >= _totalCount!, | ||||||
|  |                 itemCount: _authTickets.length, | ||||||
|  |                 itemBuilder: (context, idx) { | ||||||
|  |                   final ticket = _authTickets[idx]; | ||||||
|  |                   final platform = RegExp(r'\(([^;]+);') | ||||||
|  |                       .firstMatch(ticket.userAgent) | ||||||
|  |                       ?.group(1); | ||||||
|  |                   return Row( | ||||||
|  |                     crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                     children: [ | ||||||
|  |                       Icon( | ||||||
|  |                         kAuthTicketIcon[platform!.toLowerCase()] ?? Symbols.web, | ||||||
|  |                       ), | ||||||
|  |                       const Gap(12), | ||||||
|  |                       Expanded( | ||||||
|  |                         child: Column( | ||||||
|  |                           crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                           children: [ | ||||||
|  |                             Text( | ||||||
|  |                               ticket.ipAddress, | ||||||
|  |                               style: TextStyle(fontSize: 15), | ||||||
|  |                             ), | ||||||
|  |                             Text(ticket.userAgent).opacity(0.8), | ||||||
|  |                             if (ticket.location?.isNotEmpty ?? false) | ||||||
|  |                               const Gap(4), | ||||||
|  |                             if (ticket.location?.isNotEmpty ?? false) | ||||||
|  |                               Text(ticket.location!).opacity(0.8), | ||||||
|  |                             const Gap(4), | ||||||
|  |                             Text('authTicketCreatedAt'.tr(args: [ | ||||||
|  |                               (DateFormat().format(ticket.createdAt.toLocal())) | ||||||
|  |                             ])).fontSize(12).opacity(0.75), | ||||||
|  |                             if (ticket.expiredAt != null) | ||||||
|  |                               Text('authTicketExpiredAt'.tr(args: [ | ||||||
|  |                                 (DateFormat() | ||||||
|  |                                     .format(ticket.expiredAt!.toLocal())) | ||||||
|  |                               ])).fontSize(12).opacity(0.75), | ||||||
|  |                             if (ticket.lastGrantAt != null) | ||||||
|  |                               Text('authTicketLastGrantAt'.tr(args: [ | ||||||
|  |                                 (DateFormat() | ||||||
|  |                                     .format(ticket.lastGrantAt!.toLocal())) | ||||||
|  |                               ])).fontSize(12).opacity(0.75), | ||||||
|  |                             const Gap(4), | ||||||
|  |                             if (_currentTicketId == ticket.id) | ||||||
|  |                               Text('authTicketCurrent'.tr()) | ||||||
|  |                                   .fontSize(11) | ||||||
|  |                                   .bold() | ||||||
|  |                                   .opacity(0.75), | ||||||
|  |                             Text('#${ticket.id}').fontSize(11).opacity(0.75), | ||||||
|  |                           ], | ||||||
|  |                         ), | ||||||
|  |                       ), | ||||||
|  |                       IconButton( | ||||||
|  |                         iconSize: 20, | ||||||
|  |                         visualDensity: | ||||||
|  |                             VisualDensity(horizontal: -4, vertical: -4), | ||||||
|  |                         constraints: const BoxConstraints(), | ||||||
|  |                         padding: EdgeInsets.zero, | ||||||
|  |                         icon: const Icon(Symbols.logout), | ||||||
|  |                         onPressed: _currentTicketId == ticket.id | ||||||
|  |                             ? null | ||||||
|  |                             : () { | ||||||
|  |                                 _deleteAuthTicket(ticket); | ||||||
|  |                               }, | ||||||
|  |                       ), | ||||||
|  |                     ], | ||||||
|  |                   ).padding(horizontal: 16, vertical: 12); | ||||||
|  |                 }, | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										141
									
								
								lib/screens/account/badges.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								lib/screens/account/badges.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,141 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:google_fonts/google_fonts.dart'; | ||||||
|  | import 'package:material_symbols_icons/material_symbols_icons.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:surface/providers/sn_network.dart'; | ||||||
|  | import 'package:surface/screens/account/profile_page.dart' show kBadgesMeta; | ||||||
|  | import 'package:surface/theme.dart'; | ||||||
|  | import 'package:surface/types/account.dart'; | ||||||
|  | import 'package:surface/widgets/dialog.dart'; | ||||||
|  | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  |  | ||||||
|  | class AccountBadgesScreen extends StatefulWidget { | ||||||
|  |   const AccountBadgesScreen({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<AccountBadgesScreen> createState() => _AccountBadgesScreenState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _AccountBadgesScreenState extends State<AccountBadgesScreen> { | ||||||
|  |   bool _isBusy = false; | ||||||
|  |   List<SnAccountBadge>? _badges; | ||||||
|  |  | ||||||
|  |   Future<void> _fetchBadges() async { | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       final resp = await sn.client.get('/cgi/id/badges/me'); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       setState( | ||||||
|  |         () => _badges = List<SnAccountBadge>.from( | ||||||
|  |           resp.data?.map((e) => SnAccountBadge.fromJson(e)) ?? [], | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   bool _isActivating = false; | ||||||
|  |  | ||||||
|  |   Future<void> _activateBadge(SnAccountBadge badge) async { | ||||||
|  |     try { | ||||||
|  |       setState(() => _isActivating = true); | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       await sn.client.post('/cgi/id/badges/${badge.id}/active'); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showSnackbar('badgeActivated' | ||||||
|  |           .tr(args: [(kBadgesMeta[badge.type]?.$1 ?? 'unknown').tr()])); | ||||||
|  |       await _fetchBadges(); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isActivating = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _fetchBadges(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|  |       appBar: AppBar( | ||||||
|  |         title: Text('screenAccountBadges').tr(), | ||||||
|  |       ), | ||||||
|  |       body: Column( | ||||||
|  |         children: [ | ||||||
|  |           LoadingIndicator(isActive: _isBusy), | ||||||
|  |           if (_badges != null) | ||||||
|  |             Expanded( | ||||||
|  |               child: MediaQuery.removePadding( | ||||||
|  |                 context: context, | ||||||
|  |                 removeTop: true, | ||||||
|  |                 child: RefreshIndicator( | ||||||
|  |                   onRefresh: _fetchBadges, | ||||||
|  |                   child: ListView.builder( | ||||||
|  |                     itemCount: _badges!.length, | ||||||
|  |                     itemBuilder: (context, idx) { | ||||||
|  |                       final badge = _badges![idx]; | ||||||
|  |                       return ListTile( | ||||||
|  |                         title: Text( | ||||||
|  |                           kBadgesMeta[badge.type]?.$1 ?? 'unknown', | ||||||
|  |                         ).tr(), | ||||||
|  |                         contentPadding: const EdgeInsets.only( | ||||||
|  |                           left: 24, | ||||||
|  |                           right: 16, | ||||||
|  |                           top: 4, | ||||||
|  |                           bottom: 4, | ||||||
|  |                         ), | ||||||
|  |                         subtitle: Column( | ||||||
|  |                           crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                           children: [ | ||||||
|  |                             if (badge.metadata['title'] != null) | ||||||
|  |                               Text(badge.metadata['title']).fontSize(14).bold() | ||||||
|  |                             else | ||||||
|  |                               Text( | ||||||
|  |                                 '#${badge.id.toString().padLeft(8, '0')}', | ||||||
|  |                                 style: GoogleFonts.robotoMono(), | ||||||
|  |                               ).fontSize(14).bold(), | ||||||
|  |                             Text( | ||||||
|  |                               DateFormat('y/M/d').format(badge.createdAt), | ||||||
|  |                             ) | ||||||
|  |                           ], | ||||||
|  |                         ), | ||||||
|  |                         trailing: IconButton( | ||||||
|  |                           icon: const Icon(Symbols.check), | ||||||
|  |                           onPressed: (badge.isActive || _isActivating) | ||||||
|  |                               ? null | ||||||
|  |                               : () { | ||||||
|  |                                   _activateBadge(badge); | ||||||
|  |                                 }, | ||||||
|  |                         ), | ||||||
|  |                         leading: Icon( | ||||||
|  |                           kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark, | ||||||
|  |                           color: badge.metadata['color'] != null | ||||||
|  |                               ? HexColor.fromHex(badge.metadata['color']!) | ||||||
|  |                               : kBadgesMeta[badge.type]?.$3, | ||||||
|  |                           fill: 1, | ||||||
|  |                         ), | ||||||
|  |                       ); | ||||||
|  |                     }, | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										323
									
								
								lib/screens/account/contact_methods.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										323
									
								
								lib/screens/account/contact_methods.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,323 @@ | |||||||
|  | import 'package:collection/collection.dart'; | ||||||
|  | import 'package:dio/dio.dart'; | ||||||
|  | import 'package:dropdown_button2/dropdown_button2.dart'; | ||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:gap/gap.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:surface/providers/sn_network.dart'; | ||||||
|  | import 'package:surface/types/account.dart'; | ||||||
|  | import 'package:surface/widgets/dialog.dart'; | ||||||
|  | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  |  | ||||||
|  | const kContactMethodsIcons = [Symbols.email, Symbols.phone, Symbols.map]; | ||||||
|  | const kContactMethodsName = ['Email', 'Phone', 'Address']; | ||||||
|  |  | ||||||
|  | class AccountContactMethod extends StatefulWidget { | ||||||
|  |   const AccountContactMethod({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<AccountContactMethod> createState() => _AccountContactMethodState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _AccountContactMethodState extends State<AccountContactMethod> { | ||||||
|  |   bool _isBusy = false; | ||||||
|  |   List<SnAccountContact> _contactMethods = List.empty(growable: true); | ||||||
|  |  | ||||||
|  |   Future<void> _fetchContactMethods() async { | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       final resp = await sn.client.get('/cgi/id/users/me/contacts'); | ||||||
|  |       _contactMethods = List.from((resp.data as List<dynamic>) | ||||||
|  |           .map((e) => SnAccountContact.fromJson(e))); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> _deleteContactMethod(SnAccountContact contact) async { | ||||||
|  |     final confirm = await context.showConfirmDialog( | ||||||
|  |       'accountContactMethodsDelete'.tr(), | ||||||
|  |       'accountContactMethodsDeleteDescription'.tr(args: [contact.content]), | ||||||
|  |     ); | ||||||
|  |     if (!confirm || !mounted) return; | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       await sn.client.delete('/cgi/id/users/me/contacts/${contact.id}'); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       await _fetchContactMethods(); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _fetchContactMethods(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|  |       appBar: AppBar( | ||||||
|  |         leading: const PageBackButton(), | ||||||
|  |         title: Text('accountContactMethods').tr(), | ||||||
|  |       ), | ||||||
|  |       body: Column( | ||||||
|  |         children: [ | ||||||
|  |           LoadingIndicator(isActive: _isBusy), | ||||||
|  |           ListTile( | ||||||
|  |             title: Text('accountContactMethodsAdd').tr(), | ||||||
|  |             subtitle: Text('accountContactMethodsAddDescription').tr(), | ||||||
|  |             contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |             leading: const Icon(Symbols.add), | ||||||
|  |             trailing: const Icon(Symbols.chevron_right), | ||||||
|  |             onTap: () { | ||||||
|  |               showDialog( | ||||||
|  |                 context: context, | ||||||
|  |                 builder: (context) => _ContactMethodEditor(), | ||||||
|  |               ).then((value) { | ||||||
|  |                 if (value) { | ||||||
|  |                   _fetchContactMethods(); | ||||||
|  |                 } | ||||||
|  |               }); | ||||||
|  |             }, | ||||||
|  |           ), | ||||||
|  |           Divider(height: 1), | ||||||
|  |           Expanded( | ||||||
|  |             child: RefreshIndicator( | ||||||
|  |               onRefresh: _fetchContactMethods, | ||||||
|  |               child: ListView.builder( | ||||||
|  |                 padding: EdgeInsets.zero, | ||||||
|  |                 itemCount: _contactMethods.length, | ||||||
|  |                 itemBuilder: (context, index) { | ||||||
|  |                   final method = _contactMethods[index]; | ||||||
|  |                   return ListTile( | ||||||
|  |                     title: Text(method.content), | ||||||
|  |                     subtitle: Column( | ||||||
|  |                       crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                       children: [ | ||||||
|  |                         Text( | ||||||
|  |                           'accountContactMethodsName${kContactMethodsName[method.type]}', | ||||||
|  |                         ).tr().bold(), | ||||||
|  |                         if (method.isPrimary || | ||||||
|  |                             method.isPublic || | ||||||
|  |                             method.verifiedAt != null) | ||||||
|  |                           Row( | ||||||
|  |                             spacing: 4, | ||||||
|  |                             children: [ | ||||||
|  |                               if (method.isPrimary) | ||||||
|  |                                 Text('accountContactMethodsPrimary').tr(), | ||||||
|  |                               if (method.isPublic) | ||||||
|  |                                 Text('accountContactMethodsPublic').tr(), | ||||||
|  |                               if (method.verifiedAt != null) | ||||||
|  |                                 Text('accountContactMethodsVerified').tr(), | ||||||
|  |                             ], | ||||||
|  |                           ), | ||||||
|  |                       ], | ||||||
|  |                     ), | ||||||
|  |                     contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                     leading: Icon( | ||||||
|  |                       kContactMethodsIcons[method.type], | ||||||
|  |                     ), | ||||||
|  |                     trailing: PopupMenuButton( | ||||||
|  |                       itemBuilder: (_) => [ | ||||||
|  |                         PopupMenuItem( | ||||||
|  |                           child: Row( | ||||||
|  |                             children: [ | ||||||
|  |                               const Icon(Symbols.edit), | ||||||
|  |                               const Gap(16), | ||||||
|  |                               Text('edit').tr(), | ||||||
|  |                             ], | ||||||
|  |                           ), | ||||||
|  |                           onTap: () { | ||||||
|  |                             showDialog( | ||||||
|  |                               context: context, | ||||||
|  |                               builder: (context) => _ContactMethodEditor( | ||||||
|  |                                 contact: method, | ||||||
|  |                               ), | ||||||
|  |                             ).then((value) { | ||||||
|  |                               if (value) { | ||||||
|  |                                 _fetchContactMethods(); | ||||||
|  |                               } | ||||||
|  |                             }); | ||||||
|  |                           }, | ||||||
|  |                         ), | ||||||
|  |                         PopupMenuItem( | ||||||
|  |                           child: Row( | ||||||
|  |                             children: [ | ||||||
|  |                               const Icon(Symbols.delete), | ||||||
|  |                               const Gap(16), | ||||||
|  |                               Text('delete'.tr()), | ||||||
|  |                             ], | ||||||
|  |                           ), | ||||||
|  |                           onTap: () { | ||||||
|  |                             _deleteContactMethod(method); | ||||||
|  |                           }, | ||||||
|  |                         ), | ||||||
|  |                       ], | ||||||
|  |                     ), | ||||||
|  |                   ); | ||||||
|  |                 }, | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _ContactMethodEditor extends StatefulWidget { | ||||||
|  |   final SnAccountContact? contact; | ||||||
|  |   const _ContactMethodEditor({this.contact}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<_ContactMethodEditor> createState() => _ContactMethodEditorState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _ContactMethodEditorState extends State<_ContactMethodEditor> { | ||||||
|  |   int _type = 0; | ||||||
|  |   bool _isPublic = false; | ||||||
|  |   final TextEditingController _contentController = TextEditingController(); | ||||||
|  |  | ||||||
|  |   bool _isBusy = false; | ||||||
|  |  | ||||||
|  |   Future<void> _saveContactMethod() async { | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       await sn.client.request( | ||||||
|  |         widget.contact == null | ||||||
|  |             ? '/cgi/id/users/me/contacts' | ||||||
|  |             : '/cgi/id/users/me/contacts/${widget.contact!.id}', | ||||||
|  |         data: { | ||||||
|  |           'content': _contentController.text, | ||||||
|  |           'type': _type, | ||||||
|  |           'is_public': _isPublic, | ||||||
|  |         }, | ||||||
|  |         options: Options( | ||||||
|  |           method: widget.contact == null ? 'POST' : 'PUT', | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       Navigator.pop(context, true); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     if (widget.contact != null) { | ||||||
|  |       _type = widget.contact!.type; | ||||||
|  |       _isPublic = widget.contact!.isPublic; | ||||||
|  |       _contentController.text = widget.contact!.content; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AlertDialog( | ||||||
|  |       title: widget.contact == null | ||||||
|  |           ? Text('accountContactMethodsAdd').tr() | ||||||
|  |           : Text('accountContactMethodsEdit').tr(), | ||||||
|  |       content: Column( | ||||||
|  |         mainAxisSize: MainAxisSize.min, | ||||||
|  |         crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |         children: [ | ||||||
|  |           DropdownButtonHideUnderline( | ||||||
|  |             child: DropdownButton2<int>( | ||||||
|  |               value: _type, | ||||||
|  |               items: kContactMethodsName | ||||||
|  |                   .mapIndexed((idx, ele) => DropdownMenuItem<int>( | ||||||
|  |                         value: idx, | ||||||
|  |                         child: Text('accountContactMethodsName$ele').tr(), | ||||||
|  |                       )) | ||||||
|  |                   .toList(), | ||||||
|  |               buttonStyleData: ButtonStyleData( | ||||||
|  |                 height: 48, | ||||||
|  |                 width: double.infinity, | ||||||
|  |                 padding: const EdgeInsets.only(left: 14, right: 14), | ||||||
|  |                 decoration: BoxDecoration( | ||||||
|  |                   borderRadius: BorderRadius.circular(4), | ||||||
|  |                   border: Border.all( | ||||||
|  |                     color: Theme.of(context).dividerColor, | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |               menuItemStyleData: const MenuItemStyleData( | ||||||
|  |                 height: 48, | ||||||
|  |                 padding: EdgeInsets.only(left: 14, right: 14), | ||||||
|  |               ), | ||||||
|  |               onChanged: (value) { | ||||||
|  |                 setState(() => _type = value ?? 0); | ||||||
|  |               }, | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |           const Gap(8), | ||||||
|  |           TextField( | ||||||
|  |             controller: _contentController, | ||||||
|  |             decoration: InputDecoration( | ||||||
|  |               isDense: true, | ||||||
|  |               border: const OutlineInputBorder(), | ||||||
|  |               labelText: 'fieldContactContent'.tr(), | ||||||
|  |             ), | ||||||
|  |             onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |           ), | ||||||
|  |           const Gap(8), | ||||||
|  |           Card( | ||||||
|  |             margin: EdgeInsets.zero, | ||||||
|  |             child: CheckboxListTile( | ||||||
|  |               shape: RoundedRectangleBorder( | ||||||
|  |                 borderRadius: BorderRadius.all( | ||||||
|  |                   Radius.circular(8), | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |               title: Text('accountContactMethodsPublic').tr(), | ||||||
|  |               subtitle: Text('accountContactMethodsPublicHint').tr(), | ||||||
|  |               secondary: const Icon(Symbols.globe), | ||||||
|  |               value: _isPublic, | ||||||
|  |               onChanged: (value) { | ||||||
|  |                 setState(() => _isPublic = value ?? false); | ||||||
|  |               }, | ||||||
|  |             ), | ||||||
|  |           ) | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |       actions: [ | ||||||
|  |         TextButton( | ||||||
|  |           onPressed: _isBusy | ||||||
|  |               ? null | ||||||
|  |               : () { | ||||||
|  |                   Navigator.of(context).pop(); | ||||||
|  |                 }, | ||||||
|  |           child: Text('dialogDismiss').tr(), | ||||||
|  |         ), | ||||||
|  |         TextButton( | ||||||
|  |           onPressed: _isBusy | ||||||
|  |               ? null | ||||||
|  |               : () { | ||||||
|  |                   _saveContactMethod(); | ||||||
|  |                 }, | ||||||
|  |           child: Text('dialogConfirm').tr(), | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -16,7 +16,11 @@ final Map<int, (String, String, IconData)> kFactorTypes = { | |||||||
|   0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password), |   0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password), | ||||||
|   1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email), |   1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email), | ||||||
|   2: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer), |   2: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer), | ||||||
|   3: ('authFactorInAppNotify', 'authFactorInAppNotifyDescription', Symbols.notifications_active), |   3: ( | ||||||
|  |     'authFactorInAppNotify', | ||||||
|  |     'authFactorInAppNotifyDescription', | ||||||
|  |     Symbols.notifications_active | ||||||
|  |   ), | ||||||
| }; | }; | ||||||
|  |  | ||||||
| class FactorSettingsScreen extends StatefulWidget { | class FactorSettingsScreen extends StatefulWidget { | ||||||
| @@ -36,7 +40,10 @@ class _FactorSettingsScreenState extends State<FactorSettingsScreen> { | |||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final resp = await sn.client.get('/cgi/id/users/me/factors'); |       final resp = await sn.client.get('/cgi/id/users/me/factors'); | ||||||
|       _factors = List<SnAuthFactor>.from( |       _factors = List<SnAuthFactor>.from( | ||||||
|         resp.data?.map((e) => SnAuthFactor.fromJson(e as Map<String, dynamic>)).toList() ?? [], |         resp.data | ||||||
|  |                 ?.map((e) => SnAuthFactor.fromJson(e as Map<String, dynamic>)) | ||||||
|  |                 .toList() ?? | ||||||
|  |             [], | ||||||
|       ); |       ); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
| @@ -55,6 +62,7 @@ class _FactorSettingsScreenState extends State<FactorSettingsScreen> { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         leading: PageBackButton(), |         leading: PageBackButton(), | ||||||
|         title: Text('screenFactorSettings').tr(), |         title: Text('screenFactorSettings').tr(), | ||||||
| @@ -96,7 +104,8 @@ class _FactorSettingsScreenState extends State<FactorSettingsScreen> { | |||||||
|                     return ListTile( |                     return ListTile( | ||||||
|                       title: Text(kFactorTypes[ele.type]!.$1).tr(), |                       title: Text(kFactorTypes[ele.type]!.$1).tr(), | ||||||
|                       subtitle: Text(kFactorTypes[ele.type]!.$2).tr(), |                       subtitle: Text(kFactorTypes[ele.type]!.$2).tr(), | ||||||
|                       contentPadding: const EdgeInsets.only(left: 24, right: 12), |                       contentPadding: | ||||||
|  |                           const EdgeInsets.only(left: 24, right: 12), | ||||||
|                       leading: Icon(kFactorTypes[ele.type]!.$3), |                       leading: Icon(kFactorTypes[ele.type]!.$3), | ||||||
|                       trailing: IconButton( |                       trailing: IconButton( | ||||||
|                         icon: const Icon(Symbols.close), |                         icon: const Icon(Symbols.close), | ||||||
| @@ -105,14 +114,17 @@ class _FactorSettingsScreenState extends State<FactorSettingsScreen> { | |||||||
|                                 context |                                 context | ||||||
|                                     .showConfirmDialog( |                                     .showConfirmDialog( | ||||||
|                                   'authFactorDelete'.tr(), |                                   'authFactorDelete'.tr(), | ||||||
|                                   'authFactorDeleteDescription'.tr(args: [kFactorTypes[ele.type]!.$1.tr()]), |                                   'authFactorDeleteDescription'.tr( | ||||||
|  |                                       args: [kFactorTypes[ele.type]!.$1.tr()]), | ||||||
|                                 ) |                                 ) | ||||||
|                                     .then((val) async { |                                     .then((val) async { | ||||||
|                                   if (!val) return; |                                   if (!val) return; | ||||||
|                                   try { |                                   try { | ||||||
|                                     if (!context.mounted) return; |                                     if (!context.mounted) return; | ||||||
|                                     final sn = context.read<SnNetworkProvider>(); |                                     final sn = | ||||||
|                                     await sn.client.delete('/cgi/id/users/me/factors/${ele.id}'); |                                         context.read<SnNetworkProvider>(); | ||||||
|  |                                     await sn.client.delete( | ||||||
|  |                                         '/cgi/id/users/me/factors/${ele.id}'); | ||||||
|                                     _fetchFactors(); |                                     _fetchFactors(); | ||||||
|                                   } catch (err) { |                                   } catch (err) { | ||||||
|                                     if (!context.mounted) return; |                                     if (!context.mounted) return; | ||||||
| @@ -191,7 +203,9 @@ class _FactorNewDialogState extends State<_FactorNewDialog> { | |||||||
|               value: _factorType, |               value: _factorType, | ||||||
|               items: kFactorTypes.entries.map( |               items: kFactorTypes.entries.map( | ||||||
|                 (ele) { |                 (ele) { | ||||||
|                   final contains = widget.currentlyHave.map((ele) => ele.type).contains(ele.key); |                   final contains = widget.currentlyHave | ||||||
|  |                       .map((ele) => ele.type) | ||||||
|  |                       .contains(ele.key); | ||||||
|                   return DropdownMenuItem<int>( |                   return DropdownMenuItem<int>( | ||||||
|                     enabled: !contains, |                     enabled: !contains, | ||||||
|                     value: ele.key, |                     value: ele.key, | ||||||
|   | |||||||
							
								
								
									
										107
									
								
								lib/screens/account/keypairs.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								lib/screens/account/keypairs.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,107 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:google_fonts/google_fonts.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/providers/keypair.dart'; | ||||||
|  | import 'package:surface/types/keypair.dart'; | ||||||
|  | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  |  | ||||||
|  | class KeyPairScreen extends StatefulWidget { | ||||||
|  |   const KeyPairScreen({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<KeyPairScreen> createState() => _KeyPairScreenState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _KeyPairScreenState extends State<KeyPairScreen> { | ||||||
|  |   bool _isBusy = false; | ||||||
|  |   List<SnKeyPair>? _keyPairs; | ||||||
|  |  | ||||||
|  |   Future<void> _loadKeyPairs() async { | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |     final kps = await context.read<KeyPairProvider>().listKeyPair(); | ||||||
|  |     setState(() { | ||||||
|  |       _keyPairs = kps; | ||||||
|  |       _isBusy = false; | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _loadKeyPairs(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|  |       appBar: AppBar( | ||||||
|  |         title: Text('screenKeyPairs').tr(), | ||||||
|  |       ), | ||||||
|  |       body: Column( | ||||||
|  |         children: [ | ||||||
|  |           LoadingIndicator(isActive: _isBusy), | ||||||
|  |           ListTile( | ||||||
|  |             leading: const Icon(Symbols.add), | ||||||
|  |             title: Text('enrollNewKeyPair').tr(), | ||||||
|  |             subtitle: Text('enrollNewKeyPairDescription').tr(), | ||||||
|  |             onTap: () async { | ||||||
|  |               await context.read<KeyPairProvider>().enrollNew(); | ||||||
|  |               _loadKeyPairs(); | ||||||
|  |             }, | ||||||
|  |           ), | ||||||
|  |           const Divider(height: 1), | ||||||
|  |           if (_keyPairs != null) | ||||||
|  |             Expanded( | ||||||
|  |               child: MediaQuery.removePadding( | ||||||
|  |                 context: context, | ||||||
|  |                 removeTop: true, | ||||||
|  |                 child: RefreshIndicator( | ||||||
|  |                   onRefresh: _loadKeyPairs, | ||||||
|  |                   child: ListView.builder( | ||||||
|  |                     itemCount: _keyPairs!.length, | ||||||
|  |                     itemBuilder: (context, index) { | ||||||
|  |                       final kp = _keyPairs![index]; | ||||||
|  |                       return ListTile( | ||||||
|  |                         title: Text(kp.id.toUpperCase()), | ||||||
|  |                         subtitle: Row( | ||||||
|  |                           spacing: 8, | ||||||
|  |                           children: [ | ||||||
|  |                             if (kp.privateKey != null) | ||||||
|  |                               Text( | ||||||
|  |                                 'keyPairHasPrivateKey'.tr(), | ||||||
|  |                               ), | ||||||
|  |                             if (kp.privateKey != null) Text('·'), | ||||||
|  |                             Flexible( | ||||||
|  |                               flex: 1, | ||||||
|  |                               child: Text( | ||||||
|  |                                 'UID #${kp.accountId.toString().padLeft(8, '0')}', | ||||||
|  |                                 style: GoogleFonts.robotoMono(), | ||||||
|  |                               ), | ||||||
|  |                             ), | ||||||
|  |                           ], | ||||||
|  |                         ), | ||||||
|  |                         trailing: IconButton( | ||||||
|  |                           icon: const Icon(Symbols.check), | ||||||
|  |                           onPressed: kp.isActive == true | ||||||
|  |                               ? null | ||||||
|  |                               : () async { | ||||||
|  |                                   final k = context.read<KeyPairProvider>(); | ||||||
|  |                                   await k.activeKeyPair(kp.id); | ||||||
|  |                                   _loadKeyPairs(); | ||||||
|  |                                 }, | ||||||
|  |                         ), | ||||||
|  |                       ); | ||||||
|  |                     }, | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										123
									
								
								lib/screens/account/prefs/notify.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								lib/screens/account/prefs/notify.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,123 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:google_fonts/google_fonts.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/providers/sn_network.dart'; | ||||||
|  | import 'package:surface/widgets/dialog.dart'; | ||||||
|  | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  |  | ||||||
|  | final Map<String, String> kNotifyTopicMap = { | ||||||
|  |   'interactive.reply': 'notificationTopicPostReply'.tr(), | ||||||
|  |   'interactive.feedback': 'notificationTopicPostFeedback'.tr(), | ||||||
|  |   'interactive.subscription': 'notificationTopicPostSubscription'.tr(), | ||||||
|  |   'messaging.message': 'notificationTopicMessaging'.tr(), | ||||||
|  |   'messaging.call': 'notificationTopicMessagingCall'.tr(), | ||||||
|  |   'general': 'notificationTopicGeneral'.tr(), | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | class AccountNotifyPrefsScreen extends StatefulWidget { | ||||||
|  |   const AccountNotifyPrefsScreen({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<AccountNotifyPrefsScreen> createState() => | ||||||
|  |       _AccountNotifyPrefsScreenState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _AccountNotifyPrefsScreenState extends State<AccountNotifyPrefsScreen> { | ||||||
|  |   bool _isBusy = true; | ||||||
|  |  | ||||||
|  |   Map<String, bool> _config = {}; | ||||||
|  |  | ||||||
|  |   Future<void> _getPreferences() async { | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
|  |     final sn = context.read<SnNetworkProvider>(); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       final resp = await sn.client.get('/cgi/id/preferences/notifications'); | ||||||
|  |       _config = resp.data['config'] | ||||||
|  |           .map((k, v) => MapEntry(k, v as bool)) | ||||||
|  |           .cast<String, bool>(); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> _savePreferences() async { | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
|  |     final sn = context.read<SnNetworkProvider>(); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       await sn.client.put( | ||||||
|  |         '/cgi/id/preferences/notifications', | ||||||
|  |         data: { | ||||||
|  |           'config': _config, | ||||||
|  |         }, | ||||||
|  |       ); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showSnackbar('accountSettingsApplied'.tr()); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _getPreferences(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|  |       appBar: AppBar( | ||||||
|  |         leading: const PageBackButton(), | ||||||
|  |         title: Text('accountSettingsNotify').tr(), | ||||||
|  |       ), | ||||||
|  |       body: Column( | ||||||
|  |         children: [ | ||||||
|  |           LoadingIndicator(isActive: _isBusy), | ||||||
|  |           ListTile( | ||||||
|  |             tileColor: Theme.of(context).colorScheme.surfaceContainer, | ||||||
|  |             contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |             leading: const Icon(Icons.save), | ||||||
|  |             title: Text('save').tr(), | ||||||
|  |             enabled: !_isBusy, | ||||||
|  |             onTap: () { | ||||||
|  |               _savePreferences(); | ||||||
|  |             }, | ||||||
|  |           ), | ||||||
|  |           Expanded( | ||||||
|  |             child: ListView.builder( | ||||||
|  |               padding: EdgeInsets.zero, | ||||||
|  |               itemCount: kNotifyTopicMap.length, | ||||||
|  |               itemBuilder: (context, index) { | ||||||
|  |                 final element = kNotifyTopicMap.entries.elementAt(index); | ||||||
|  |                 return CheckboxListTile( | ||||||
|  |                   title: Text(element.value), | ||||||
|  |                   subtitle: Text( | ||||||
|  |                     element.key, | ||||||
|  |                     style: GoogleFonts.robotoMono(fontSize: 12), | ||||||
|  |                   ), | ||||||
|  |                   value: _config[element.key] ?? true, | ||||||
|  |                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                   onChanged: (value) { | ||||||
|  |                     setState(() { | ||||||
|  |                       _config[element.key] = value ?? false; | ||||||
|  |                     }); | ||||||
|  |                   }, | ||||||
|  |                 ); | ||||||
|  |               }, | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										148
									
								
								lib/screens/account/prefs/security.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										148
									
								
								lib/screens/account/prefs/security.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,148 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/providers/sn_network.dart'; | ||||||
|  | import 'package:surface/widgets/dialog.dart'; | ||||||
|  | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  |  | ||||||
|  | class AccountSecurityPrefsScreen extends StatefulWidget { | ||||||
|  |   const AccountSecurityPrefsScreen({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<AccountSecurityPrefsScreen> createState() => | ||||||
|  |       _AccountSecurityPrefsScreenState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _AccountSecurityPrefsScreenState | ||||||
|  |     extends State<AccountSecurityPrefsScreen> { | ||||||
|  |   bool _isBusy = true; | ||||||
|  |  | ||||||
|  |   Map<String, dynamic> _config = { | ||||||
|  |     'maximum_auth_steps': 2, | ||||||
|  |     'always_risky': false, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   Future<void> _getPreferences() async { | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
|  |     final sn = context.read<SnNetworkProvider>(); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       final resp = await sn.client.get('/cgi/id/preferences/auth'); | ||||||
|  |       _config = resp.data['config'] | ||||||
|  |           .map((k, v) => MapEntry(k, v as bool)) | ||||||
|  |           .cast<String, bool>(); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> _savePreferences() async { | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
|  |     final sn = context.read<SnNetworkProvider>(); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       await sn.client.put( | ||||||
|  |         '/cgi/id/preferences/auth', | ||||||
|  |         data: { | ||||||
|  |           'config': _config, | ||||||
|  |         }, | ||||||
|  |       ); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showSnackbar('accountSettingsApplied'.tr()); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _getPreferences(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|  |       appBar: AppBar( | ||||||
|  |         leading: const PageBackButton(), | ||||||
|  |         title: Text('accountSettingsSecurity').tr(), | ||||||
|  |       ), | ||||||
|  |       body: Column( | ||||||
|  |         children: [ | ||||||
|  |           LoadingIndicator(isActive: _isBusy), | ||||||
|  |           ListTile( | ||||||
|  |             tileColor: Theme.of(context).colorScheme.surfaceContainer, | ||||||
|  |             contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |             leading: const Icon(Icons.save), | ||||||
|  |             title: Text('save').tr(), | ||||||
|  |             enabled: !_isBusy, | ||||||
|  |             onTap: () { | ||||||
|  |               _savePreferences(); | ||||||
|  |             }, | ||||||
|  |           ), | ||||||
|  |           Expanded( | ||||||
|  |             child: ListView( | ||||||
|  |               padding: EdgeInsets.zero, | ||||||
|  |               children: [ | ||||||
|  |                 ListTile( | ||||||
|  |                   title: Text('authMaximumAuthSteps').tr(), | ||||||
|  |                   subtitle: Text('authMaximumAuthStepsDescription') | ||||||
|  |                       .plural(_config['maximum_auth_steps'] ?? 2), | ||||||
|  |                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                   trailing: Row( | ||||||
|  |                     mainAxisSize: MainAxisSize.min, | ||||||
|  |                     children: [ | ||||||
|  |                       IconButton( | ||||||
|  |                         padding: EdgeInsets.zero, | ||||||
|  |                         visualDensity: const VisualDensity( | ||||||
|  |                           horizontal: -4, | ||||||
|  |                           vertical: -4, | ||||||
|  |                         ), | ||||||
|  |                         icon: const Icon(Symbols.remove), | ||||||
|  |                         onPressed: () { | ||||||
|  |                           if (_config['maximum_auth_steps'] > 1) { | ||||||
|  |                             setState(() => _config['maximum_auth_steps']--); | ||||||
|  |                           } | ||||||
|  |                         }, | ||||||
|  |                       ), | ||||||
|  |                       IconButton( | ||||||
|  |                         padding: EdgeInsets.zero, | ||||||
|  |                         visualDensity: const VisualDensity( | ||||||
|  |                           horizontal: -4, | ||||||
|  |                           vertical: -4, | ||||||
|  |                         ), | ||||||
|  |                         icon: const Icon(Symbols.add), | ||||||
|  |                         onPressed: () { | ||||||
|  |                           if (_config['maximum_auth_steps'] < 99) { | ||||||
|  |                             setState(() => _config['maximum_auth_steps']++); | ||||||
|  |                           } | ||||||
|  |                         }, | ||||||
|  |                       ), | ||||||
|  |                     ], | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |                 CheckboxListTile( | ||||||
|  |                   title: Text('authAlwaysRisky').tr(), | ||||||
|  |                   subtitle: Text('authAlwaysRiskyDescription').tr(), | ||||||
|  |                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                   value: _config['always_risky'] ?? false, | ||||||
|  |                   onChanged: (value) { | ||||||
|  |                     setState(() => _config['always_risky'] = value); | ||||||
|  |                   }, | ||||||
|  |                 ), | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -6,6 +6,7 @@ import 'package:easy_localization/easy_localization.dart'; | |||||||
| import 'package:flutter/cupertino.dart'; | import 'package:flutter/cupertino.dart'; | ||||||
| import 'package:flutter/foundation.dart'; | import 'package:flutter/foundation.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_timezone/flutter_timezone.dart'; | ||||||
| import 'package:gap/gap.dart'; | import 'package:gap/gap.dart'; | ||||||
| import 'package:image_picker/image_picker.dart'; | import 'package:image_picker/image_picker.dart'; | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| @@ -36,11 +37,16 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|   final _firstNameController = TextEditingController(); |   final _firstNameController = TextEditingController(); | ||||||
|   final _lastNameController = TextEditingController(); |   final _lastNameController = TextEditingController(); | ||||||
|   final _descriptionController = TextEditingController(); |   final _descriptionController = TextEditingController(); | ||||||
|  |   final _timezoneController = TextEditingController(); | ||||||
|  |   final _genderController = TextEditingController(); | ||||||
|  |   final _pronounsController = TextEditingController(); | ||||||
|  |   final _locationController = TextEditingController(); | ||||||
|   final _birthdayController = TextEditingController(); |   final _birthdayController = TextEditingController(); | ||||||
|  |  | ||||||
|   String? _avatar; |   String? _avatar; | ||||||
|   String? _banner; |   String? _banner; | ||||||
|   DateTime? _birthday; |   DateTime? _birthday; | ||||||
|  |   List<(String, String)>? _links; | ||||||
|  |  | ||||||
|   bool _isBusy = false; |   bool _isBusy = false; | ||||||
|  |  | ||||||
| @@ -51,15 +57,21 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|     final prof = ua.user!; |     final prof = ua.user!; | ||||||
|     _usernameController.text = prof.name; |     _usernameController.text = prof.name; | ||||||
|     _nicknameController.text = prof.nick; |     _nicknameController.text = prof.nick; | ||||||
|     _descriptionController.text = prof.description; |     _descriptionController.text = prof.profile!.description; | ||||||
|     _firstNameController.text = prof.profile!.firstName; |     _firstNameController.text = prof.profile!.firstName; | ||||||
|     _lastNameController.text = prof.profile!.lastName; |     _lastNameController.text = prof.profile!.lastName; | ||||||
|  |     _timezoneController.text = prof.profile!.timeZone; | ||||||
|  |     _genderController.text = prof.profile!.gender; | ||||||
|  |     _pronounsController.text = prof.profile!.pronouns; | ||||||
|  |     _locationController.text = prof.profile!.location; | ||||||
|     _avatar = prof.avatar; |     _avatar = prof.avatar; | ||||||
|     _banner = prof.banner; |     _banner = prof.banner; | ||||||
|     if (prof.profile!.birthday != null) { |     _links = | ||||||
|       _birthdayController.text = DateFormat(_kDateFormat).format( |         prof.profile!.links.entries.map((ele) => (ele.key, ele.value)).toList(); | ||||||
|         prof.profile!.birthday!.toLocal(), |     _birthday = prof.profile!.birthday?.toLocal(); | ||||||
|       ); |     if (_birthday != null) { | ||||||
|  |       _birthdayController.text = | ||||||
|  |           DateFormat(_kDateFormat).format(prof.profile!.birthday!.toLocal()); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -69,9 +81,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|       builder: (BuildContext context) => Container( |       builder: (BuildContext context) => Container( | ||||||
|         height: 216, |         height: 216, | ||||||
|         padding: const EdgeInsets.only(top: 6.0), |         padding: const EdgeInsets.only(top: 6.0), | ||||||
|         margin: EdgeInsets.only( |         margin: | ||||||
|           bottom: MediaQuery.of(context).viewInsets.bottom, |             EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), | ||||||
|         ), |  | ||||||
|         color: Theme.of(context).colorScheme.surface, |         color: Theme.of(context).colorScheme.surface, | ||||||
|         child: SafeArea( |         child: SafeArea( | ||||||
|           top: false, |           top: false, | ||||||
| @@ -82,7 +93,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|             onDateTimeChanged: (DateTime newDate) { |             onDateTimeChanged: (DateTime newDate) { | ||||||
|               setState(() { |               setState(() { | ||||||
|                 _birthday = newDate; |                 _birthday = newDate; | ||||||
|                 _birthdayController.text = DateFormat(_kDateFormat).format(_birthday!); |                 _birthdayController.text = | ||||||
|  |                     DateFormat(_kDateFormat).format(_birthday!); | ||||||
|               }); |               }); | ||||||
|             }, |             }, | ||||||
|           ), |           ), | ||||||
| @@ -96,9 +108,15 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|     if (image == null) return; |     if (image == null) return; | ||||||
|     if (!mounted) return; |     if (!mounted) return; | ||||||
|  |  | ||||||
|     final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path)); |     final skipCrop = image.path.endsWith('.gif'); | ||||||
|     final aspectRatios = |  | ||||||
|         place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)]; |     Uint8List? rawBytes; | ||||||
|  |     if (!skipCrop) { | ||||||
|  |       final ImageProvider imageProvider = | ||||||
|  |           kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path)); | ||||||
|  |       final aspectRatios = place == 'banner' | ||||||
|  |           ? [CropAspectRatio(width: 16, height: 7)] | ||||||
|  |           : [CropAspectRatio(width: 1, height: 1)]; | ||||||
|       final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) |       final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) | ||||||
|           ? await showCupertinoImageCropper( |           ? await showCupertinoImageCropper( | ||||||
|               // ignore: use_build_context_synchronously |               // ignore: use_build_context_synchronously | ||||||
| @@ -116,11 +134,18 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|       if (result == null) return; |       if (result == null) return; | ||||||
|  |  | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|     final attach = context.read<SnAttachmentProvider>(); |  | ||||||
|  |  | ||||||
|       setState(() => _isBusy = true); |       setState(() => _isBusy = true); | ||||||
|  |       rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))! | ||||||
|  |           .buffer | ||||||
|  |           .asUint8List(); | ||||||
|  |     } else { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       setState(() => _isBusy = true); | ||||||
|  |       rawBytes = await image.readAsBytes(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List(); |     if (!mounted) return; | ||||||
|  |     final attach = context.read<SnAttachmentProvider>(); | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       final attachment = await attach.directUploadOne( |       final attachment = await attach.directUploadOne( | ||||||
| @@ -133,10 +158,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|  |  | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       await sn.client.put( |       await sn.client | ||||||
|         '/cgi/id/users/me/$place', |           .put('/cgi/id/users/me/$place', data: {'attachment': attachment.rid}); | ||||||
|         data: {'attachment': attachment.rid}, |  | ||||||
|       ); |  | ||||||
|  |  | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       final ua = context.read<UserProvider>(); |       final ua = context.read<UserProvider>(); | ||||||
| @@ -166,7 +189,16 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|           'description': _descriptionController.value.text, |           'description': _descriptionController.value.text, | ||||||
|           'first_name': _firstNameController.value.text, |           'first_name': _firstNameController.value.text, | ||||||
|           'last_name': _lastNameController.value.text, |           'last_name': _lastNameController.value.text, | ||||||
|  |           'time_zone': _timezoneController.value.text, | ||||||
|  |           'gender': _genderController.value.text, | ||||||
|  |           'pronouns': _pronounsController.value.text, | ||||||
|  |           'location': _locationController.value.text, | ||||||
|           'birthday': _birthday?.toUtc().toIso8601String(), |           'birthday': _birthday?.toUtc().toIso8601String(), | ||||||
|  |           'links': { | ||||||
|  |             for (final link in _links! | ||||||
|  |                 .where((ele) => ele.$1.isNotEmpty && ele.$2.isNotEmpty)) | ||||||
|  |               link.$1: link.$2, | ||||||
|  |           }, | ||||||
|         }, |         }, | ||||||
|       ); |       ); | ||||||
|  |  | ||||||
| @@ -197,6 +229,10 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|     _firstNameController.dispose(); |     _firstNameController.dispose(); | ||||||
|     _lastNameController.dispose(); |     _lastNameController.dispose(); | ||||||
|     _descriptionController.dispose(); |     _descriptionController.dispose(); | ||||||
|  |     _timezoneController.dispose(); | ||||||
|  |     _genderController.dispose(); | ||||||
|  |     _pronounsController.dispose(); | ||||||
|  |     _locationController.dispose(); | ||||||
|     _birthdayController.dispose(); |     _birthdayController.dispose(); | ||||||
|     super.dispose(); |     super.dispose(); | ||||||
|   } |   } | ||||||
| @@ -208,10 +244,10 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|     final sn = context.read<SnNetworkProvider>(); |     final sn = context.read<SnNetworkProvider>(); | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|           leading: const PageBackButton(), |           leading: const PageBackButton(), | ||||||
|         title: Text('screenAccountProfileEdit').tr(), |           title: Text('screenAccountProfileEdit').tr()), | ||||||
|       ), |  | ||||||
|       body: SingleChildScrollView( |       body: SingleChildScrollView( | ||||||
|         child: Column( |         child: Column( | ||||||
|           crossAxisAlignment: CrossAxisAlignment.start, |           crossAxisAlignment: CrossAxisAlignment.start, | ||||||
| @@ -229,12 +265,13 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|                       child: AspectRatio( |                       child: AspectRatio( | ||||||
|                         aspectRatio: 16 / 9, |                         aspectRatio: 16 / 9, | ||||||
|                         child: Container( |                         child: Container( | ||||||
|                           color: Theme.of(context).colorScheme.surfaceContainerHigh, |                           color: Theme.of(context) | ||||||
|  |                               .colorScheme | ||||||
|  |                               .surfaceContainerHigh, | ||||||
|                           child: _banner != null |                           child: _banner != null | ||||||
|                               ? AutoResizeUniversalImage( |                               ? AutoResizeUniversalImage( | ||||||
|                                   sn.getAttachmentUrl(_banner!), |                                   sn.getAttachmentUrl(_banner!), | ||||||
|                                   fit: BoxFit.cover, |                                   fit: BoxFit.cover) | ||||||
|                                 ) |  | ||||||
|                               : const SizedBox.shrink(), |                               : const SizedBox.shrink(), | ||||||
|                         ), |                         ), | ||||||
|                       ), |                       ), | ||||||
| @@ -262,6 +299,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|             ).padding(horizontal: padding), |             ).padding(horizontal: padding), | ||||||
|             const Gap(8 + 28), |             const Gap(8 + 28), | ||||||
|             Column( |             Column( | ||||||
|  |               spacing: 4, | ||||||
|               children: [ |               children: [ | ||||||
|                 TextField( |                 TextField( | ||||||
|                   readOnly: true, |                   readOnly: true, | ||||||
| @@ -271,16 +309,17 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|                     labelText: 'fieldUsername'.tr(), |                     labelText: 'fieldUsername'.tr(), | ||||||
|                     helperText: 'fieldUsernameCannotEditHint'.tr(), |                     helperText: 'fieldUsernameCannotEditHint'.tr(), | ||||||
|                   ), |                   ), | ||||||
|  |                   onTapOutside: (_) => | ||||||
|  |                       FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                 ), |                 ), | ||||||
|                 const Gap(4), |  | ||||||
|                 TextField( |                 TextField( | ||||||
|                   controller: _nicknameController, |                   controller: _nicknameController, | ||||||
|                   decoration: InputDecoration( |                   decoration: InputDecoration( | ||||||
|                       border: const UnderlineInputBorder(), |                       border: const UnderlineInputBorder(), | ||||||
|                     labelText: 'fieldNickname'.tr(), |                       labelText: 'fieldNickname'.tr()), | ||||||
|  |                   onTapOutside: (_) => | ||||||
|  |                       FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                 ), |                 ), | ||||||
|                 ), |  | ||||||
|                 const Gap(4), |  | ||||||
|                 Row( |                 Row( | ||||||
|                   children: [ |                   children: [ | ||||||
|                     Flexible( |                     Flexible( | ||||||
| @@ -291,6 +330,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|                           border: const UnderlineInputBorder(), |                           border: const UnderlineInputBorder(), | ||||||
|                           labelText: 'fieldFirstName'.tr(), |                           labelText: 'fieldFirstName'.tr(), | ||||||
|                         ), |                         ), | ||||||
|  |                         onTapOutside: (_) => | ||||||
|  |                             FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                       ), |                       ), | ||||||
|                     ), |                     ), | ||||||
|                     const Gap(8), |                     const Gap(8), | ||||||
| @@ -302,11 +343,41 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|                           border: const UnderlineInputBorder(), |                           border: const UnderlineInputBorder(), | ||||||
|                           labelText: 'fieldLastName'.tr(), |                           labelText: 'fieldLastName'.tr(), | ||||||
|                         ), |                         ), | ||||||
|  |                         onTapOutside: (_) => | ||||||
|  |                             FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                       ), |                       ), | ||||||
|                     ), |                     ), | ||||||
|                   ], |                   ], | ||||||
|                 ), |                 ), | ||||||
|  |                 Row( | ||||||
|  |                   children: [ | ||||||
|  |                     Flexible( | ||||||
|  |                       flex: 1, | ||||||
|  |                       child: TextField( | ||||||
|  |                         controller: _genderController, | ||||||
|  |                         decoration: InputDecoration( | ||||||
|  |                           border: const UnderlineInputBorder(), | ||||||
|  |                           labelText: 'fieldGender'.tr(), | ||||||
|  |                         ), | ||||||
|  |                         onTapOutside: (_) => | ||||||
|  |                             FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|                     const Gap(4), |                     const Gap(4), | ||||||
|  |                     Flexible( | ||||||
|  |                       flex: 1, | ||||||
|  |                       child: TextField( | ||||||
|  |                         controller: _pronounsController, | ||||||
|  |                         decoration: InputDecoration( | ||||||
|  |                           border: const UnderlineInputBorder(), | ||||||
|  |                           labelText: 'fieldPronouns'.tr(), | ||||||
|  |                         ), | ||||||
|  |                         onTapOutside: (_) => | ||||||
|  |                             FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                   ], | ||||||
|  |                 ), | ||||||
|                 TextField( |                 TextField( | ||||||
|                   controller: _descriptionController, |                   controller: _descriptionController, | ||||||
|                   keyboardType: TextInputType.multiline, |                   keyboardType: TextInputType.multiline, | ||||||
| @@ -314,19 +385,147 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|                   minLines: 3, |                   minLines: 3, | ||||||
|                   decoration: InputDecoration( |                   decoration: InputDecoration( | ||||||
|                       border: const UnderlineInputBorder(), |                       border: const UnderlineInputBorder(), | ||||||
|                     labelText: 'fieldDescription'.tr(), |                       labelText: 'fieldDescription'.tr()), | ||||||
|  |                   onTapOutside: (_) => | ||||||
|  |                       FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |                 ), | ||||||
|  |                 Row( | ||||||
|  |                   crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |                   children: [ | ||||||
|  |                     Expanded( | ||||||
|  |                       child: TextField( | ||||||
|  |                         controller: _timezoneController, | ||||||
|  |                         decoration: InputDecoration( | ||||||
|  |                           border: const UnderlineInputBorder(), | ||||||
|  |                           labelText: 'fieldTimeZone'.tr(), | ||||||
|  |                         ), | ||||||
|  |                         onTapOutside: (_) => | ||||||
|  |                             FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                       ), |                       ), | ||||||
|                     ), |                     ), | ||||||
|                     const Gap(4), |                     const Gap(4), | ||||||
|  |                     StyledWidget( | ||||||
|  |                       IconButton( | ||||||
|  |                         icon: const Icon(Symbols.calendar_month), | ||||||
|  |                         visualDensity: | ||||||
|  |                             VisualDensity(horizontal: -4, vertical: -4), | ||||||
|  |                         padding: EdgeInsets.zero, | ||||||
|  |                         constraints: const BoxConstraints(), | ||||||
|  |                         onPressed: () async { | ||||||
|  |                           _timezoneController.text = | ||||||
|  |                               await FlutterTimezone.getLocalTimezone(); | ||||||
|  |                         }, | ||||||
|  |                       ), | ||||||
|  |                     ).padding(top: 6), | ||||||
|  |                     const Gap(4), | ||||||
|  |                     StyledWidget( | ||||||
|  |                       IconButton( | ||||||
|  |                         icon: const Icon(Symbols.clear), | ||||||
|  |                         visualDensity: | ||||||
|  |                             VisualDensity(horizontal: -4, vertical: -4), | ||||||
|  |                         padding: EdgeInsets.zero, | ||||||
|  |                         constraints: const BoxConstraints(), | ||||||
|  |                         onPressed: () { | ||||||
|  |                           _timezoneController.clear(); | ||||||
|  |                         }, | ||||||
|  |                       ), | ||||||
|  |                     ).padding(top: 6), | ||||||
|  |                   ], | ||||||
|  |                 ), | ||||||
|  |                 TextField( | ||||||
|  |                   controller: _locationController, | ||||||
|  |                   decoration: InputDecoration( | ||||||
|  |                       border: const UnderlineInputBorder(), | ||||||
|  |                       labelText: 'fieldLocation'.tr()), | ||||||
|  |                   onTapOutside: (_) => | ||||||
|  |                       FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |                 ), | ||||||
|                 TextField( |                 TextField( | ||||||
|                   controller: _birthdayController, |                   controller: _birthdayController, | ||||||
|                   readOnly: true, |                   readOnly: true, | ||||||
|                   decoration: InputDecoration( |                   decoration: InputDecoration( | ||||||
|                       border: const UnderlineInputBorder(), |                       border: const UnderlineInputBorder(), | ||||||
|                     labelText: 'fieldBirthday'.tr(), |                       labelText: 'fieldBirthday'.tr()), | ||||||
|                   ), |  | ||||||
|                   onTap: () => _selectBirthday(), |                   onTap: () => _selectBirthday(), | ||||||
|                 ), |                 ), | ||||||
|  |                 if (_links != null) | ||||||
|  |                   Card( | ||||||
|  |                     margin: const EdgeInsets.only(top: 16, bottom: 4), | ||||||
|  |                     child: Container( | ||||||
|  |                       width: double.infinity, | ||||||
|  |                       padding: const EdgeInsets.symmetric( | ||||||
|  |                           horizontal: 16, vertical: 8), | ||||||
|  |                       child: Column( | ||||||
|  |                         crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                         children: [ | ||||||
|  |                           Row( | ||||||
|  |                             children: [ | ||||||
|  |                               Expanded( | ||||||
|  |                                 child: Text( | ||||||
|  |                                   'fieldLinks'.tr(), | ||||||
|  |                                   style: Theme.of(context) | ||||||
|  |                                       .textTheme | ||||||
|  |                                       .titleMedium! | ||||||
|  |                                       .copyWith(fontSize: 17), | ||||||
|  |                                 ), | ||||||
|  |                               ), | ||||||
|  |                               IconButton( | ||||||
|  |                                 padding: EdgeInsets.zero, | ||||||
|  |                                 constraints: const BoxConstraints(), | ||||||
|  |                                 visualDensity: | ||||||
|  |                                     VisualDensity(horizontal: -4, vertical: -4), | ||||||
|  |                                 icon: const Icon(Symbols.add), | ||||||
|  |                                 onPressed: () { | ||||||
|  |                                   setState(() => _links!.add(('', ''))); | ||||||
|  |                                 }, | ||||||
|  |                               ), | ||||||
|  |                             ], | ||||||
|  |                           ), | ||||||
|  |                           const Gap(8), | ||||||
|  |                           for (var idx = 0; idx < _links!.length; idx++) | ||||||
|  |                             Row( | ||||||
|  |                               children: [ | ||||||
|  |                                 Flexible( | ||||||
|  |                                   flex: 1, | ||||||
|  |                                   child: TextFormField( | ||||||
|  |                                     initialValue: _links![idx].$1, | ||||||
|  |                                     decoration: InputDecoration( | ||||||
|  |                                       isDense: true, | ||||||
|  |                                       border: const OutlineInputBorder(), | ||||||
|  |                                       labelText: 'fieldLinkName'.tr(), | ||||||
|  |                                     ), | ||||||
|  |                                     onChanged: (value) { | ||||||
|  |                                       _links![idx] = (value, _links![idx].$2); | ||||||
|  |                                     }, | ||||||
|  |                                     onTapOutside: (_) => FocusManager | ||||||
|  |                                         .instance.primaryFocus | ||||||
|  |                                         ?.unfocus(), | ||||||
|  |                                   ), | ||||||
|  |                                 ), | ||||||
|  |                                 const Gap(8), | ||||||
|  |                                 Flexible( | ||||||
|  |                                   flex: 1, | ||||||
|  |                                   child: TextFormField( | ||||||
|  |                                     initialValue: _links![idx].$2, | ||||||
|  |                                     decoration: InputDecoration( | ||||||
|  |                                       isDense: true, | ||||||
|  |                                       border: const OutlineInputBorder(), | ||||||
|  |                                       labelText: 'fieldLinkUrl'.tr(), | ||||||
|  |                                     ), | ||||||
|  |                                     onChanged: (value) { | ||||||
|  |                                       _links![idx] = (_links![idx].$1, value); | ||||||
|  |                                     }, | ||||||
|  |                                     onTapOutside: (_) => FocusManager | ||||||
|  |                                         .instance.primaryFocus | ||||||
|  |                                         ?.unfocus(), | ||||||
|  |                                   ), | ||||||
|  |                                 ), | ||||||
|  |                               ], | ||||||
|  |                             ), | ||||||
|  |                         ], | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|               ], |               ], | ||||||
|             ).padding(horizontal: padding + 8), |             ).padding(horizontal: padding + 8), | ||||||
|             const Gap(12), |             const Gap(12), | ||||||
| @@ -340,6 +539,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|                 ), |                 ), | ||||||
|               ], |               ], | ||||||
|             ).padding(horizontal: padding), |             ).padding(horizontal: padding), | ||||||
|  |             Gap(MediaQuery.of(context).padding.bottom), | ||||||
|           ], |           ], | ||||||
|         ), |         ), | ||||||
|       ), |       ), | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | import 'dart:math' as math; | ||||||
| import 'dart:ui'; | import 'dart:ui'; | ||||||
|  |  | ||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
| @@ -18,10 +19,13 @@ import 'package:surface/types/account.dart'; | |||||||
| import 'package:surface/types/check_in.dart'; | import 'package:surface/types/check_in.dart'; | ||||||
| import 'package:surface/types/post.dart'; | import 'package:surface/types/post.dart'; | ||||||
| import 'package:surface/widgets/account/account_image.dart'; | import 'package:surface/widgets/account/account_image.dart'; | ||||||
|  | import 'package:surface/widgets/account/badge.dart'; | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
| import 'package:surface/widgets/universal_image.dart'; | import 'package:surface/widgets/universal_image.dart'; | ||||||
|  | import 'package:surface/theme.dart'; | ||||||
|  | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
|  |  | ||||||
| const Map<String, (String, IconData, Color)> kBadgesMeta = { | final Map<String, (String, IconData, Color)> kBadgesMeta = { | ||||||
|   'company.staff': ( |   'company.staff': ( | ||||||
|     'badgeCompanyStaff', |     'badgeCompanyStaff', | ||||||
|     Symbols.tools_wrench, |     Symbols.tools_wrench, | ||||||
| @@ -32,6 +36,31 @@ const Map<String, (String, IconData, Color)> kBadgesMeta = { | |||||||
|     Symbols.flag, |     Symbols.flag, | ||||||
|     Colors.orange, |     Colors.orange, | ||||||
|   ), |   ), | ||||||
|  |   'site.anniversary': ( | ||||||
|  |     'badgeSiteAnniversary', | ||||||
|  |     Symbols.celebration, | ||||||
|  |     Colors.orangeAccent, | ||||||
|  |   ), | ||||||
|  |   'user.birthday': ( | ||||||
|  |     'badgeUserBirthday', | ||||||
|  |     Symbols.cake, | ||||||
|  |     Colors.red[400]!, | ||||||
|  |   ), | ||||||
|  |   'community.survey': ( | ||||||
|  |     'badgeCommunitySurvey', | ||||||
|  |     Symbols.star, | ||||||
|  |     Colors.yellow[700]!, | ||||||
|  |   ), | ||||||
|  |   'community.verified': ( | ||||||
|  |     'badgeCommunityVerified', | ||||||
|  |     Symbols.verified, | ||||||
|  |     Colors.blue, | ||||||
|  |   ), | ||||||
|  |   'community.contributor': ( | ||||||
|  |     'badgeCommunityContributor', | ||||||
|  |     Symbols.thumb_up, | ||||||
|  |     Colors.lightGreen, | ||||||
|  |   ), | ||||||
| }; | }; | ||||||
|  |  | ||||||
| class UserScreen extends StatefulWidget { | class UserScreen extends StatefulWidget { | ||||||
| @@ -43,7 +72,8 @@ class UserScreen extends StatefulWidget { | |||||||
|   State<UserScreen> createState() => _UserScreenState(); |   State<UserScreen> createState() => _UserScreenState(); | ||||||
| } | } | ||||||
|  |  | ||||||
| class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateMixin { | class _UserScreenState extends State<UserScreen> | ||||||
|  |     with SingleTickerProviderStateMixin { | ||||||
|   late final ScrollController _scrollController = ScrollController(); |   late final ScrollController _scrollController = ScrollController(); | ||||||
|  |  | ||||||
|   SnAccount? _account; |   SnAccount? _account; | ||||||
| @@ -64,13 +94,18 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<List<SnCheckInRecord>> _getCheckInRecords() async { |   List<SnCheckInRecord>? _records; | ||||||
|  |  | ||||||
|  |   Future<void> _getCheckInRecords() async { | ||||||
|     try { |     try { | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final resp = await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14'); |       final resp = | ||||||
|       return List.from( |           await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14'); | ||||||
|  |       setState(() { | ||||||
|  |         _records = List.from( | ||||||
|           resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [], |           resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [], | ||||||
|         ); |         ); | ||||||
|  |       }); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (mounted) context.showErrorDialog(err); |       if (mounted) context.showErrorDialog(err); | ||||||
|       rethrow; |       rethrow; | ||||||
| @@ -98,7 +133,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|   Future<void> _fetchPublishers() async { |   Future<void> _fetchPublishers() async { | ||||||
|     try { |     try { | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final resp = await sn.client.get('/cgi/co/publishers?user=${widget.name}'); |       final resp = | ||||||
|  |           await sn.client.get('/cgi/co/publishers?user=${widget.name}'); | ||||||
|       _publishers = List<SnPublisher>.from( |       _publishers = List<SnPublisher>.from( | ||||||
|         resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], |         resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], | ||||||
|       ); |       ); | ||||||
| @@ -144,7 +180,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|         'related': _account!.name, |         'related': _account!.name, | ||||||
|       }); |       }); | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showSnackbar('userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'}'])); |       context.showSnackbar( | ||||||
|  |           'userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'}'])); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showErrorDialog(err); |       context.showErrorDialog(err); | ||||||
| @@ -160,9 +197,11 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       final rel = context.read<SnRelationshipProvider>(); |       final rel = context.read<SnRelationshipProvider>(); | ||||||
|       await rel.updateRelationship(_account!.id, 1, _accountRelationship?.permNodes ?? {}); |       await rel.updateRelationship( | ||||||
|  |           _account!.id, 1, _accountRelationship?.permNodes ?? {}); | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showSnackbar('userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'}'])); |       context.showSnackbar( | ||||||
|  |           'userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'}'])); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showErrorDialog(err); |       context.showErrorDialog(err); | ||||||
| @@ -188,12 +227,14 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|   double _appBarBlur = 0.0; |   double _appBarBlur = 0.0; | ||||||
|  |  | ||||||
|   late final _appBarWidth = MediaQuery.of(context).size.width; |   late final _appBarWidth = MediaQuery.of(context).size.width; | ||||||
|   late final _appBarHeight = (_appBarWidth * kBannerAspectRatio).roundToDouble(); |   late final _appBarHeight = | ||||||
|  |       math.min((_appBarWidth * kBannerAspectRatio), 360).roundToDouble(); | ||||||
|  |  | ||||||
|   void _updateAppBarBlur() { |   void _updateAppBarBlur() { | ||||||
|     if (_scrollController.offset > _appBarHeight) return; |     if (_scrollController.offset > _appBarHeight) return; | ||||||
|     setState(() { |     setState(() { | ||||||
|       _appBarBlur = (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0); |       _appBarBlur = | ||||||
|  |           (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -205,6 +246,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|  |  | ||||||
|       _fetchStatus(); |       _fetchStatus(); | ||||||
|       _fetchPublishers(); |       _fetchPublishers(); | ||||||
|  |       _getCheckInRecords(); | ||||||
|  |  | ||||||
|       try { |       try { | ||||||
|         final rel = context.read<SnRelationshipProvider>(); |         final rel = context.read<SnRelationshipProvider>(); | ||||||
| @@ -260,7 +302,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                       text: TextSpan(children: [ |                       text: TextSpan(children: [ | ||||||
|                         TextSpan( |                         TextSpan( | ||||||
|                           text: _account!.nick, |                           text: _account!.nick, | ||||||
|                           style: Theme.of(context).textTheme.titleLarge!.copyWith( |                           style: | ||||||
|  |                               Theme.of(context).textTheme.titleLarge!.copyWith( | ||||||
|                                     color: Colors.white, |                                     color: Colors.white, | ||||||
|                                     shadows: labelShadows, |                                     shadows: labelShadows, | ||||||
|                                   ), |                                   ), | ||||||
| @@ -268,7 +311,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                         const TextSpan(text: '\n'), |                         const TextSpan(text: '\n'), | ||||||
|                         TextSpan( |                         TextSpan( | ||||||
|                           text: '@${_account!.name}', |                           text: '@${_account!.name}', | ||||||
|                           style: Theme.of(context).textTheme.bodySmall!.copyWith( |                           style: | ||||||
|  |                               Theme.of(context).textTheme.bodySmall!.copyWith( | ||||||
|                                     color: Colors.white, |                                     color: Colors.white, | ||||||
|                                     shadows: labelShadows, |                                     shadows: labelShadows, | ||||||
|                                   ), |                                   ), | ||||||
| @@ -280,6 +324,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                   ? Stack( |                   ? Stack( | ||||||
|                       fit: StackFit.expand, |                       fit: StackFit.expand, | ||||||
|                       children: [ |                       children: [ | ||||||
|  |                         if (_account!.banner.isNotEmpty) | ||||||
|                           UniversalImage( |                           UniversalImage( | ||||||
|                             sn.getAttachmentUrl(_account!.banner), |                             sn.getAttachmentUrl(_account!.banner), | ||||||
|                             fit: BoxFit.cover, |                             fit: BoxFit.cover, | ||||||
| @@ -287,6 +332,12 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                             width: _appBarWidth, |                             width: _appBarWidth, | ||||||
|                             cacheHeight: imageHeight, |                             cacheHeight: imageHeight, | ||||||
|                             cacheWidth: _appBarWidth, |                             cacheWidth: _appBarWidth, | ||||||
|  |                           ) | ||||||
|  |                         else | ||||||
|  |                           Container( | ||||||
|  |                             color: Theme.of(context) | ||||||
|  |                                 .colorScheme | ||||||
|  |                                 .surfaceContainerHigh, | ||||||
|                           ), |                           ), | ||||||
|                         Positioned( |                         Positioned( | ||||||
|                           top: 0, |                           top: 0, | ||||||
| @@ -339,7 +390,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                       PopupMenuButton( |                       PopupMenuButton( | ||||||
|                         padding: EdgeInsets.zero, |                         padding: EdgeInsets.zero, | ||||||
|                         style: ButtonStyle( |                         style: ButtonStyle( | ||||||
|                           visualDensity: VisualDensity(horizontal: -4, vertical: -4), |                           visualDensity: | ||||||
|  |                               VisualDensity(horizontal: -4, vertical: -4), | ||||||
|                         ), |                         ), | ||||||
|                         itemBuilder: (context) => [ |                         itemBuilder: (context) => [ | ||||||
|                           PopupMenuItem( |                           PopupMenuItem( | ||||||
| @@ -389,27 +441,41 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                       ), |                       ), | ||||||
|                     ], |                     ], | ||||||
|                   ).padding(right: 8), |                   ).padding(right: 8), | ||||||
|                   const Gap(12), |                   if (_account!.profile!.description.isNotEmpty) | ||||||
|                   Text(_account!.description).padding(horizontal: 8), |                     const Gap(12) | ||||||
|  |                   else | ||||||
|  |                     const Gap(8), | ||||||
|  |                   if (_account!.profile!.description.isNotEmpty) | ||||||
|  |                     Text(_account!.profile!.description).padding(horizontal: 8), | ||||||
|                   const Gap(4), |                   const Gap(4), | ||||||
|                   Card( |                   Card( | ||||||
|                     child: Row( |                     child: Row( | ||||||
|                       children: [ |                       children: [ | ||||||
|                         Icon( |                         Icon( | ||||||
|                           Symbols.circle, |                           (_status?.isDisturbable ?? true) | ||||||
|                           fill: 1, |                               ? Symbols.circle | ||||||
|  |                               : Symbols.do_not_disturb_on, | ||||||
|  |                           fill: (_status?.isOnline ?? false) ? 1 : 0, | ||||||
|                           size: 16, |                           size: 16, | ||||||
|                           color: (_status?.isOnline ?? false) ? Colors.green : Colors.grey, |                           color: (_status?.isOnline ?? false) | ||||||
|  |                               ? (_status?.isDisturbable ?? true) | ||||||
|  |                                   ? Colors.green | ||||||
|  |                                   : Colors.red | ||||||
|  |                               : Colors.grey, | ||||||
|                         ).padding(all: 4), |                         ).padding(all: 4), | ||||||
|                         const Gap(8), |                         const Gap(8), | ||||||
|                         Text( |                         Text( | ||||||
|                           _status != null |                           _status != null | ||||||
|                               ? _status!.isOnline |                               ? (_status!.status?.label.isNotEmpty ?? false) | ||||||
|  |                                   ? _status!.status!.label | ||||||
|  |                                   : _status!.isOnline | ||||||
|                                       ? 'accountStatusOnline'.tr() |                                       ? 'accountStatusOnline'.tr() | ||||||
|                                       : 'accountStatusOffline'.tr() |                                       : 'accountStatusOffline'.tr() | ||||||
|                               : 'loading'.tr(), |                               : 'loading'.tr(), | ||||||
|                         ), |                         ), | ||||||
|                         if (_status != null && !_status!.isOnline && _status!.lastSeenAt != null) |                         if (_status != null && | ||||||
|  |                             !_status!.isOnline && | ||||||
|  |                             _status!.lastSeenAt != null) | ||||||
|                           Text( |                           Text( | ||||||
|                             'accountStatusLastSeen'.tr(args: [ |                             'accountStatusLastSeen'.tr(args: [ | ||||||
|                               _status!.lastSeenAt != null |                               _status!.lastSeenAt != null | ||||||
| @@ -424,30 +490,10 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                   ), |                   ), | ||||||
|                   const Gap(8), |                   const Gap(8), | ||||||
|                   Wrap( |                   Wrap( | ||||||
|  |                     spacing: 4, | ||||||
|  |                     runSpacing: 4, | ||||||
|                     children: _account!.badges |                     children: _account!.badges | ||||||
|                         .map( |                         .map((ele) => AccountBadge(badge: ele)) | ||||||
|                           (ele) => Tooltip( |  | ||||||
|                             richMessage: TextSpan( |  | ||||||
|                               children: [ |  | ||||||
|                                 TextSpan(text: kBadgesMeta[ele.type]?.$1.tr() ?? 'unknown'.tr()), |  | ||||||
|                                 if (ele.metadata['title'] != null) |  | ||||||
|                                   TextSpan( |  | ||||||
|                                     text: '\n${ele.metadata['title']}', |  | ||||||
|                                     style: const TextStyle(fontWeight: FontWeight.bold), |  | ||||||
|                                   ), |  | ||||||
|                                 TextSpan(text: '\n'), |  | ||||||
|                                 TextSpan( |  | ||||||
|                                   text: DateFormat.yMEd().format(ele.createdAt), |  | ||||||
|                                 ), |  | ||||||
|                               ], |  | ||||||
|                             ), |  | ||||||
|                             child: Icon( |  | ||||||
|                               kBadgesMeta[ele.type]?.$2 ?? Symbols.question_mark, |  | ||||||
|                               color: kBadgesMeta[ele.type]?.$3, |  | ||||||
|                               fill: 1, |  | ||||||
|                             ), |  | ||||||
|                           ), |  | ||||||
|                         ) |  | ||||||
|                         .toList(), |                         .toList(), | ||||||
|                   ).padding(horizontal: 8), |                   ).padding(horizontal: 8), | ||||||
|                   const Gap(8), |                   const Gap(8), | ||||||
| @@ -458,7 +504,9 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                         children: [ |                         children: [ | ||||||
|                           const Icon(Symbols.calendar_add_on), |                           const Icon(Symbols.calendar_add_on), | ||||||
|                           const Gap(8), |                           const Gap(8), | ||||||
|                           Text('publisherJoinedAt').tr(args: [DateFormat('y/M/d').format(_account!.createdAt)]), |                           Text('publisherJoinedAt').tr(args: [ | ||||||
|  |                             DateFormat('y/M/d').format(_account!.createdAt) | ||||||
|  |                           ]), | ||||||
|                         ], |                         ], | ||||||
|                       ), |                       ), | ||||||
|                       Row( |                       Row( | ||||||
| @@ -475,6 +523,44 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                           ]), |                           ]), | ||||||
|                         ], |                         ], | ||||||
|                       ), |                       ), | ||||||
|  |                       if (_account!.profile!.gender.isNotEmpty || | ||||||
|  |                           _account!.profile!.pronouns.isNotEmpty) | ||||||
|  |                         Row( | ||||||
|  |                           crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |                           children: [ | ||||||
|  |                             const Icon(Symbols.wc), | ||||||
|  |                             const Gap(8), | ||||||
|  |                             Text( | ||||||
|  |                               _account!.profile!.gender.isNotEmpty | ||||||
|  |                                   ? _account!.profile!.gender | ||||||
|  |                                   : 'unknown'.tr(), | ||||||
|  |                             ), | ||||||
|  |                             Text(' · ').padding(horizontal: 4), | ||||||
|  |                             Text( | ||||||
|  |                               _account!.profile!.pronouns.isNotEmpty | ||||||
|  |                                   ? _account!.profile!.pronouns | ||||||
|  |                                   : 'unknown'.tr(), | ||||||
|  |                             ), | ||||||
|  |                           ], | ||||||
|  |                         ), | ||||||
|  |                       if (_account!.profile!.timeZone.isNotEmpty) | ||||||
|  |                         Row( | ||||||
|  |                           crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |                           children: [ | ||||||
|  |                             const Icon(Symbols.schedule), | ||||||
|  |                             const Gap(8), | ||||||
|  |                             Text(_account!.profile!.timeZone), | ||||||
|  |                           ], | ||||||
|  |                         ), | ||||||
|  |                       if (_account!.profile!.location.isNotEmpty) | ||||||
|  |                         Row( | ||||||
|  |                           crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |                           children: [ | ||||||
|  |                             const Icon(Symbols.location_on), | ||||||
|  |                             const Gap(8), | ||||||
|  |                             Text(_account!.profile!.location), | ||||||
|  |                           ], | ||||||
|  |                         ), | ||||||
|                       Row( |                       Row( | ||||||
|                         crossAxisAlignment: CrossAxisAlignment.center, |                         crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|                         children: [ |                         children: [ | ||||||
| @@ -491,17 +577,24 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                         children: [ |                         children: [ | ||||||
|                           const Icon(Symbols.star), |                           const Icon(Symbols.star), | ||||||
|                           const Gap(8), |                           const Gap(8), | ||||||
|                           Text('Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'), |                           Text( | ||||||
|  |                               'Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'), | ||||||
|                           const Gap(8), |                           const Gap(8), | ||||||
|                           Text(calcLevelUpProgressLevel(_account?.profile?.experience ?? 0)).fontSize(11).opacity(0.5), |                           Text(calcLevelUpProgressLevel( | ||||||
|  |                                   _account?.profile?.experience ?? 0)) | ||||||
|  |                               .fontSize(11) | ||||||
|  |                               .opacity(0.5), | ||||||
|                           const Gap(8), |                           const Gap(8), | ||||||
|                           Container( |                           Container( | ||||||
|                             width: double.infinity, |                             width: double.infinity, | ||||||
|                             constraints: const BoxConstraints(maxWidth: 160), |                             constraints: const BoxConstraints(maxWidth: 160), | ||||||
|                             child: LinearProgressIndicator( |                             child: LinearProgressIndicator( | ||||||
|                               value: calcLevelUpProgress(_account?.profile?.experience ?? 0), |                               value: calcLevelUpProgress( | ||||||
|  |                                   _account?.profile?.experience ?? 0), | ||||||
|                               borderRadius: BorderRadius.circular(8), |                               borderRadius: BorderRadius.circular(8), | ||||||
|                               backgroundColor: Theme.of(context).colorScheme.surfaceContainer, |                               backgroundColor: Theme.of(context) | ||||||
|  |                                   .colorScheme | ||||||
|  |                                   .surfaceContainer, | ||||||
|                             ).alignment(Alignment.centerLeft), |                             ).alignment(Alignment.centerLeft), | ||||||
|                           ), |                           ), | ||||||
|                         ], |                         ], | ||||||
| @@ -511,24 +604,46 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                 ], |                 ], | ||||||
|               ).padding(all: 16), |               ).padding(all: 16), | ||||||
|             ), |             ), | ||||||
|  |           if (_account?.profile?.links.isNotEmpty ?? false) | ||||||
|  |             SliverToBoxAdapter(child: const Divider()), | ||||||
|  |           if (_account?.profile?.links.isNotEmpty ?? false) | ||||||
|  |             SliverToBoxAdapter( | ||||||
|  |               child: Column( | ||||||
|  |                 mainAxisSize: MainAxisSize.min, | ||||||
|  |                 crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                 children: _account!.profile!.links.entries.map((ele) { | ||||||
|  |                   return ListTile( | ||||||
|  |                     leading: const Icon(Symbols.link), | ||||||
|  |                     title: Text(ele.key), | ||||||
|  |                     subtitle: Text(ele.value), | ||||||
|  |                     contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                     onTap: () { | ||||||
|  |                       launchUrlString(ele.value); | ||||||
|  |                     }, | ||||||
|  |                   ); | ||||||
|  |                 }).toList(), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|           SliverToBoxAdapter(child: const Divider()), |           SliverToBoxAdapter(child: const Divider()), | ||||||
|           const SliverGap(12), |           const SliverGap(12), | ||||||
|           SliverToBoxAdapter( |           SliverToBoxAdapter( | ||||||
|             child: FutureBuilder<List<SnCheckInRecord>>( |             child: Builder( | ||||||
|               future: _getCheckInRecords(), |               builder: (context) { | ||||||
|               builder: (context, snapshot) { |                 if (_records == null) return const SizedBox.shrink(); | ||||||
|                 if (!snapshot.hasData) return const SizedBox.shrink(); |                 if (_records!.length <= 1) { | ||||||
|                 if (snapshot.data!.length <= 1) { |  | ||||||
|                   return Text( |                   return Text( | ||||||
|                     'accountCheckInNoRecords', |                     'accountCheckInNoRecords', | ||||||
|                     textAlign: TextAlign.center, |                     textAlign: TextAlign.center, | ||||||
|                   ).tr().fontWeight(FontWeight.bold).center().padding(horizontal: 20, vertical: 8); |                   ) | ||||||
|  |                       .tr() | ||||||
|  |                       .fontWeight(FontWeight.bold) | ||||||
|  |                       .center() | ||||||
|  |                       .padding(horizontal: 20, vertical: 8); | ||||||
|                 } |                 } | ||||||
|                 final records = snapshot.data!; |  | ||||||
|                 return SizedBox( |                 return SizedBox( | ||||||
|                   width: double.infinity, |                   width: double.infinity, | ||||||
|                   height: 240, |                   height: 240, | ||||||
|                   child: CheckInRecordChart(records: records), |                   child: CheckInRecordChart(records: _records!), | ||||||
|                 ).padding( |                 ).padding( | ||||||
|                   right: 24, |                   right: 24, | ||||||
|                   left: 16, |                   left: 16, | ||||||
| @@ -540,11 +655,16 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|           const SliverGap(12), |           const SliverGap(12), | ||||||
|           SliverToBoxAdapter(child: const Divider()), |           SliverToBoxAdapter(child: const Divider()), | ||||||
|           const SliverGap(12), |           const SliverGap(12), | ||||||
|  |           if (_account?.badges.isNotEmpty ?? false) | ||||||
|             SliverToBoxAdapter( |             SliverToBoxAdapter( | ||||||
|               child: Column( |               child: Column( | ||||||
|                 crossAxisAlignment: CrossAxisAlignment.start, |                 crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|                 children: [ |                 children: [ | ||||||
|                 Text('accountBadge').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4), |                   Text('accountBadge') | ||||||
|  |                       .bold() | ||||||
|  |                       .fontSize(17) | ||||||
|  |                       .tr() | ||||||
|  |                       .padding(horizontal: 20, bottom: 4), | ||||||
|                   SizedBox( |                   SizedBox( | ||||||
|                     height: 80, |                     height: 80, | ||||||
|                     width: double.infinity, |                     width: double.infinity, | ||||||
| @@ -558,8 +678,12 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                             child: Card( |                             child: Card( | ||||||
|                               child: ListTile( |                               child: ListTile( | ||||||
|                                 leading: Icon( |                                 leading: Icon( | ||||||
|                                 kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark, |                                   kBadgesMeta[badge.type]?.$2 ?? | ||||||
|                                 color: kBadgesMeta[badge.type]?.$3, |                                       Symbols.question_mark, | ||||||
|  |                                   color: badge.metadata['color'] != null | ||||||
|  |                                       ? HexColor.fromHex( | ||||||
|  |                                           badge.metadata['color']!) | ||||||
|  |                                       : kBadgesMeta[badge.type]?.$3, | ||||||
|                                   fill: 1, |                                   fill: 1, | ||||||
|                                 ), |                                 ), | ||||||
|                                 title: Text( |                                 title: Text( | ||||||
| @@ -568,7 +692,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                                 subtitle: badge.metadata['title'] != null |                                 subtitle: badge.metadata['title'] != null | ||||||
|                                     ? Text(badge.metadata['title']) |                                     ? Text(badge.metadata['title']) | ||||||
|                                     : Text( |                                     : Text( | ||||||
|                                       DateFormat('y/M/d').format(badge.createdAt), |                                         DateFormat('y/M/d') | ||||||
|  |                                             .format(badge.createdAt), | ||||||
|                                       ), |                                       ), | ||||||
|                               ), |                               ), | ||||||
|                             ), |                             ), | ||||||
| @@ -664,7 +789,8 @@ class CheckInRecordChart extends StatelessWidget { | |||||||
|                   ), |                   ), | ||||||
|                 ) |                 ) | ||||||
|                 .toList(), |                 .toList(), | ||||||
|             getTooltipColor: (_) => Theme.of(context).colorScheme.surfaceContainerHigh, |             getTooltipColor: (_) => | ||||||
|  |                 Theme.of(context).colorScheme.surfaceContainerHigh, | ||||||
|           ), |           ), | ||||||
|         ), |         ), | ||||||
|         titlesData: FlTitlesData( |         titlesData: FlTitlesData( | ||||||
|   | |||||||
							
								
								
									
										291
									
								
								lib/screens/account/programs.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										291
									
								
								lib/screens/account/programs.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,291 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:gap/gap.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:surface/providers/experience.dart'; | ||||||
|  | import 'package:surface/providers/sn_network.dart'; | ||||||
|  | import 'package:surface/types/account.dart'; | ||||||
|  | import 'package:surface/widgets/dialog.dart'; | ||||||
|  | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
|  | import 'package:surface/widgets/markdown_content.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  |  | ||||||
|  | class AccountProgramScreen extends StatefulWidget { | ||||||
|  |   const AccountProgramScreen({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<AccountProgramScreen> createState() => _AccountProgramScreenState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _AccountProgramScreenState extends State<AccountProgramScreen> { | ||||||
|  |   bool _isBusy = false; | ||||||
|  |   final List<SnProgram> _programs = List.empty(growable: true); | ||||||
|  |   final List<SnProgramMember> _programMembers = List.empty(growable: true); | ||||||
|  |  | ||||||
|  |   Future<void> _fetchPrograms() async { | ||||||
|  |     _programs.clear(); | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       final resp = await sn.client.get('/cgi/id/programs'); | ||||||
|  |       _programs.addAll( | ||||||
|  |         resp.data.map((ele) => SnProgram.fromJson(ele)).cast<SnProgram>(), | ||||||
|  |       ); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> _fetchProgramMembers() async { | ||||||
|  |     _programMembers.clear(); | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       final resp = await sn.client.get('/cgi/id/programs/members'); | ||||||
|  |       _programMembers.addAll( | ||||||
|  |         resp.data | ||||||
|  |             .map((ele) => SnProgramMember.fromJson(ele)) | ||||||
|  |             .cast<SnProgramMember>(), | ||||||
|  |       ); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _fetchPrograms(); | ||||||
|  |     _fetchProgramMembers(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|  |       appBar: AppBar( | ||||||
|  |         title: Text('accountProgram').tr(), | ||||||
|  |       ), | ||||||
|  |       body: Column( | ||||||
|  |         children: [ | ||||||
|  |           LoadingIndicator(isActive: _isBusy), | ||||||
|  |           Expanded( | ||||||
|  |             child: ListView.builder( | ||||||
|  |               padding: EdgeInsets.zero, | ||||||
|  |               itemCount: _programs.length, | ||||||
|  |               itemBuilder: (context, idx) { | ||||||
|  |                 final ele = _programs[idx]; | ||||||
|  |                 return Card( | ||||||
|  |                   child: InkWell( | ||||||
|  |                     borderRadius: BorderRadius.all(Radius.circular(8)), | ||||||
|  |                     onTap: () { | ||||||
|  |                       showModalBottomSheet( | ||||||
|  |                         isScrollControlled: true, | ||||||
|  |                         context: context, | ||||||
|  |                         builder: (context) => _ProgramJoinPopup( | ||||||
|  |                           program: ele, | ||||||
|  |                           isJoined: | ||||||
|  |                               _programMembers.any((e) => e.programId == ele.id), | ||||||
|  |                         ), | ||||||
|  |                       ).then((value) { | ||||||
|  |                         if (value == true) { | ||||||
|  |                           _fetchProgramMembers(); | ||||||
|  |                         } | ||||||
|  |                       }); | ||||||
|  |                     }, | ||||||
|  |                     child: Column( | ||||||
|  |                       children: [ | ||||||
|  |                         if (ele.appearance['banner'] != null) | ||||||
|  |                           AspectRatio( | ||||||
|  |                             aspectRatio: 16 / 5, | ||||||
|  |                             child: ClipRRect( | ||||||
|  |                               borderRadius: BorderRadius.circular(8), | ||||||
|  |                               child: Container( | ||||||
|  |                                 color: Theme.of(context) | ||||||
|  |                                     .colorScheme | ||||||
|  |                                     .surfaceVariant, | ||||||
|  |                                 child: Image.network( | ||||||
|  |                                   ele.appearance['banner'], | ||||||
|  |                                   color: Theme.of(context) | ||||||
|  |                                       .colorScheme | ||||||
|  |                                       .onSurfaceVariant, | ||||||
|  |                                 ), | ||||||
|  |                               ), | ||||||
|  |                             ), | ||||||
|  |                           ), | ||||||
|  |                         Padding( | ||||||
|  |                           padding: const EdgeInsets.all(16), | ||||||
|  |                           child: Row( | ||||||
|  |                             crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                             children: [ | ||||||
|  |                               Expanded( | ||||||
|  |                                 child: Column( | ||||||
|  |                                   crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                                   children: [ | ||||||
|  |                                     Text( | ||||||
|  |                                       ele.name, | ||||||
|  |                                       style: Theme.of(context) | ||||||
|  |                                           .textTheme | ||||||
|  |                                           .titleMedium, | ||||||
|  |                                     ).bold(), | ||||||
|  |                                     Text( | ||||||
|  |                                       ele.description, | ||||||
|  |                                       maxLines: 3, | ||||||
|  |                                       overflow: TextOverflow.ellipsis, | ||||||
|  |                                     ), | ||||||
|  |                                     if (_programMembers | ||||||
|  |                                         .any((e) => e.programId == ele.id)) | ||||||
|  |                                       Text('accountProgramAlreadyJoined'.tr()) | ||||||
|  |                                           .opacity(0.75), | ||||||
|  |                                   ], | ||||||
|  |                                 ), | ||||||
|  |                               ), | ||||||
|  |                             ], | ||||||
|  |                           ), | ||||||
|  |                         ), | ||||||
|  |                       ], | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|  |                 ).padding(horizontal: 8); | ||||||
|  |               }, | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _ProgramJoinPopup extends StatefulWidget { | ||||||
|  |   final SnProgram program; | ||||||
|  |   final bool isJoined; | ||||||
|  |   const _ProgramJoinPopup({required this.program, required this.isJoined}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<_ProgramJoinPopup> createState() => _ProgramJoinPopupState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _ProgramJoinPopupState extends State<_ProgramJoinPopup> { | ||||||
|  |   bool _isBusy = false; | ||||||
|  |  | ||||||
|  |   Future<void> _joinProgram() async { | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       await sn.client.post('/cgi/id/programs/${widget.program.id}'); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       Navigator.pop(context, true); | ||||||
|  |       context.showSnackbar('accountProgramJoined'.tr()); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> _leaveProgram() async { | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       await sn.client.delete('/cgi/id/programs/${widget.program.id}'); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       Navigator.pop(context, true); | ||||||
|  |       context.showSnackbar('accountProgramLeft'.tr()); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return SizedBox( | ||||||
|  |       height: MediaQuery.of(context).size.height * 0.75, | ||||||
|  |       child: SingleChildScrollView( | ||||||
|  |         child: Column( | ||||||
|  |           crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |           children: [ | ||||||
|  |             Row( | ||||||
|  |               crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |               children: [ | ||||||
|  |                 const Icon(Symbols.add, size: 24), | ||||||
|  |                 const Gap(16), | ||||||
|  |                 Text( | ||||||
|  |                   'accountProgramJoin', | ||||||
|  |                   style: Theme.of(context).textTheme.titleLarge, | ||||||
|  |                 ).tr(), | ||||||
|  |               ], | ||||||
|  |             ).padding(horizontal: 20, top: 16, bottom: 12), | ||||||
|  |             Column( | ||||||
|  |               crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |               children: [ | ||||||
|  |                 if (widget.program.appearance['banner'] != null) | ||||||
|  |                   AspectRatio( | ||||||
|  |                     aspectRatio: 16 / 5, | ||||||
|  |                     child: ClipRRect( | ||||||
|  |                       borderRadius: BorderRadius.circular(8), | ||||||
|  |                       child: Container( | ||||||
|  |                         color: Theme.of(context).colorScheme.surfaceVariant, | ||||||
|  |                         child: Image.network( | ||||||
|  |                           widget.program.appearance['banner'], | ||||||
|  |                           color: Theme.of(context).colorScheme.onSurfaceVariant, | ||||||
|  |                         ), | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                   ).padding(bottom: 12), | ||||||
|  |                 Text( | ||||||
|  |                   widget.program.name, | ||||||
|  |                   style: Theme.of(context).textTheme.titleMedium, | ||||||
|  |                 ).bold(), | ||||||
|  |                 MarkdownTextContent(content: widget.program.description), | ||||||
|  |                 const Gap(8), | ||||||
|  |                 Text( | ||||||
|  |                   'accountProgramJoinRequirements', | ||||||
|  |                   style: Theme.of(context).textTheme.titleMedium, | ||||||
|  |                 ).tr().bold(), | ||||||
|  |                 Text('≥EXP ${widget.program.expRequirement}'), | ||||||
|  |                 Text('≥Lv${getLevelFromExp(widget.program.expRequirement)}'), | ||||||
|  |                 const Gap(8), | ||||||
|  |                 Text( | ||||||
|  |                   'accountProgramJoinPricing', | ||||||
|  |                   style: Theme.of(context).textTheme.titleMedium, | ||||||
|  |                 ).tr().bold(), | ||||||
|  |                 Text('walletCurrency${widget.program.price['currency'].toString().capitalize().replaceFirst('Normal', '')}') | ||||||
|  |                     .plural(widget.program.price['amount'].toDouble()), | ||||||
|  |                 Text('accountProgramJoinPricingHint').tr().opacity(0.75), | ||||||
|  |                 const Gap(8), | ||||||
|  |                 if (widget.isJoined) | ||||||
|  |                   Text('accountProgramLeaveHint') | ||||||
|  |                       .tr() | ||||||
|  |                       .opacity(0.75) | ||||||
|  |                       .padding(bottom: 8), | ||||||
|  |                 if (!widget.isJoined) | ||||||
|  |                   ElevatedButton( | ||||||
|  |                     onPressed: _isBusy ? null : _joinProgram, | ||||||
|  |                     child: Text('join').tr(), | ||||||
|  |                   ) | ||||||
|  |                 else | ||||||
|  |                   ElevatedButton( | ||||||
|  |                     onPressed: _isBusy ? null : _leaveProgram, | ||||||
|  |                     child: Text('leave').tr(), | ||||||
|  |                   ), | ||||||
|  |               ], | ||||||
|  |             ).padding(horizontal: 24), | ||||||
|  |             Gap(MediaQuery.of(context).padding.bottom), | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -27,10 +27,12 @@ class AccountPublisherEditScreen extends StatefulWidget { | |||||||
|   const AccountPublisherEditScreen({super.key, required this.name}); |   const AccountPublisherEditScreen({super.key, required this.name}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   State<AccountPublisherEditScreen> createState() => _AccountPublisherEditScreenState(); |   State<AccountPublisherEditScreen> createState() => | ||||||
|  |       _AccountPublisherEditScreenState(); | ||||||
| } | } | ||||||
|  |  | ||||||
| class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> { | class _AccountPublisherEditScreenState | ||||||
|  |     extends State<AccountPublisherEditScreen> { | ||||||
|   bool _isBusy = false; |   bool _isBusy = false; | ||||||
|  |  | ||||||
|   SnPublisher? _publisher; |   SnPublisher? _publisher; | ||||||
| @@ -68,13 +70,16 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> | |||||||
|     setState(() => _isBusy = true); |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       await sn.client.put('/cgi/co/publishers/${widget.name}', data: { |       await sn.client.put( | ||||||
|  |         '/cgi/co/publishers/${widget.name}', | ||||||
|  |         data: { | ||||||
|           'avatar': _avatar, |           'avatar': _avatar, | ||||||
|           'banner': _banner, |           'banner': _banner, | ||||||
|           'nick': _nickController.text, |           'nick': _nickController.text, | ||||||
|           'name': _nameController.text, |           'name': _nameController.text, | ||||||
|           'description': _descriptionController.text, |           'description': _descriptionController.text, | ||||||
|       }); |         }, | ||||||
|  |       ); | ||||||
|       if (mounted) Navigator.pop(context, true); |       if (mounted) Navigator.pop(context, true); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (mounted) context.showErrorDialog(err); |       if (mounted) context.showErrorDialog(err); | ||||||
| @@ -97,7 +102,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> | |||||||
|     _banner = ua.user!.banner; |     _banner = ua.user!.banner; | ||||||
|     _nickController.text = ua.user!.nick; |     _nickController.text = ua.user!.nick; | ||||||
|     _nameController.text = ua.user!.name; |     _nameController.text = ua.user!.name; | ||||||
|     _descriptionController.text = ua.user!.description; |     _descriptionController.text = ua.user!.profile!.description; | ||||||
|     setState(() {}); |     setState(() {}); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -108,9 +113,15 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> | |||||||
|     if (image == null) return; |     if (image == null) return; | ||||||
|     if (!mounted) return; |     if (!mounted) return; | ||||||
|  |  | ||||||
|     final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path)); |     final skipCrop = image.path.endsWith('.gif'); | ||||||
|     final aspectRatios = |  | ||||||
|         place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)]; |     Uint8List? rawBytes; | ||||||
|  |     if (!skipCrop) { | ||||||
|  |       final ImageProvider imageProvider = | ||||||
|  |           kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path)); | ||||||
|  |       final aspectRatios = place == 'banner' | ||||||
|  |           ? [CropAspectRatio(width: 16, height: 7)] | ||||||
|  |           : [CropAspectRatio(width: 1, height: 1)]; | ||||||
|       final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) |       final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) | ||||||
|           ? await showCupertinoImageCropper( |           ? await showCupertinoImageCropper( | ||||||
|               // ignore: use_build_context_synchronously |               // ignore: use_build_context_synchronously | ||||||
| @@ -128,11 +139,18 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> | |||||||
|       if (result == null) return; |       if (result == null) return; | ||||||
|  |  | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|     final attach = context.read<SnAttachmentProvider>(); |  | ||||||
|  |  | ||||||
|       setState(() => _isBusy = true); |       setState(() => _isBusy = true); | ||||||
|  |       rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))! | ||||||
|  |           .buffer | ||||||
|  |           .asUint8List(); | ||||||
|  |     } else { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       setState(() => _isBusy = true); | ||||||
|  |       rawBytes = await image.readAsBytes(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List(); |     if (!mounted) return; | ||||||
|  |     final attach = context.read<SnAttachmentProvider>(); | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       final attachment = await attach.directUploadOne( |       final attachment = await attach.directUploadOne( | ||||||
| @@ -178,10 +196,10 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> | |||||||
|     final sn = context.read<SnNetworkProvider>(); |     final sn = context.read<SnNetworkProvider>(); | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|           leading: PageBackButton(), |           leading: PageBackButton(), | ||||||
|         title: Text('screenAccountPublisherEdit').tr(), |           title: Text('screenAccountPublisherEdit').tr()), | ||||||
|       ), |  | ||||||
|       body: SingleChildScrollView( |       body: SingleChildScrollView( | ||||||
|         child: Column( |         child: Column( | ||||||
|           children: [ |           children: [ | ||||||
| @@ -198,12 +216,13 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> | |||||||
|                       child: AspectRatio( |                       child: AspectRatio( | ||||||
|                         aspectRatio: 16 / 9, |                         aspectRatio: 16 / 9, | ||||||
|                         child: Container( |                         child: Container( | ||||||
|                           color: Theme.of(context).colorScheme.surfaceContainerHigh, |                           color: Theme.of(context) | ||||||
|  |                               .colorScheme | ||||||
|  |                               .surfaceContainerHigh, | ||||||
|                           child: _banner != null |                           child: _banner != null | ||||||
|                               ? AutoResizeUniversalImage( |                               ? AutoResizeUniversalImage( | ||||||
|                                   sn.getAttachmentUrl(_banner!), |                                   sn.getAttachmentUrl(_banner!), | ||||||
|                                   fit: BoxFit.cover, |                                   fit: BoxFit.cover) | ||||||
|                                 ) |  | ||||||
|                               : const SizedBox.shrink(), |                               : const SizedBox.shrink(), | ||||||
|                         ), |                         ), | ||||||
|                       ), |                       ), | ||||||
| @@ -237,25 +256,24 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> | |||||||
|                 labelText: 'fieldUsername'.tr(), |                 labelText: 'fieldUsername'.tr(), | ||||||
|                 helperText: 'fieldUsernameCannotEditHint'.tr(), |                 helperText: 'fieldUsernameCannotEditHint'.tr(), | ||||||
|               ), |               ), | ||||||
|               onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |               onTapOutside: (_) => | ||||||
|  |                   FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|             ), |             ), | ||||||
|             const Gap(4), |             const Gap(4), | ||||||
|             TextField( |             TextField( | ||||||
|               controller: _nickController, |               controller: _nickController, | ||||||
|               decoration: InputDecoration( |               decoration: InputDecoration(labelText: 'fieldNickname'.tr()), | ||||||
|                 labelText: 'fieldNickname'.tr(), |               onTapOutside: (_) => | ||||||
|               ), |                   FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|               onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |  | ||||||
|             ), |             ), | ||||||
|             const Gap(4), |             const Gap(4), | ||||||
|             TextField( |             TextField( | ||||||
|               controller: _descriptionController, |               controller: _descriptionController, | ||||||
|               maxLines: null, |               maxLines: null, | ||||||
|               minLines: 3, |               minLines: 3, | ||||||
|               decoration: InputDecoration( |               decoration: InputDecoration(labelText: 'fieldDescription'.tr()), | ||||||
|                 labelText: 'fieldDescription'.tr(), |               onTapOutside: (_) => | ||||||
|               ), |                   FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|               onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |  | ||||||
|             ), |             ), | ||||||
|             const Gap(12), |             const Gap(12), | ||||||
|             Row( |             Row( | ||||||
| @@ -275,7 +293,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> | |||||||
|                   icon: const Icon(Symbols.save), |                   icon: const Icon(Symbols.save), | ||||||
|                 ), |                 ), | ||||||
|               ], |               ], | ||||||
|             ) |             ), | ||||||
|           ], |           ], | ||||||
|         ).padding(horizontal: 24, vertical: 12), |         ).padding(horizontal: 24, vertical: 12), | ||||||
|       ), |       ), | ||||||
|   | |||||||
| @@ -26,6 +26,7 @@ class _AccountPublisherNewScreenState extends State<AccountPublisherNewScreen> { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         leading: const PageBackButton(), |         leading: const PageBackButton(), | ||||||
|         title: Text('screenAccountPublisherNew').tr(), |         title: Text('screenAccountPublisherNew').tr(), | ||||||
| @@ -109,7 +110,7 @@ class _PublisherNewPersonalState extends State<_PublisherNewPersonal> { | |||||||
|  |  | ||||||
|     _nameController.text = ua.user!.name; |     _nameController.text = ua.user!.name; | ||||||
|     _nickController.text = ua.user!.nick; |     _nickController.text = ua.user!.nick; | ||||||
|     _descriptionController.text = ua.user!.description; |     _descriptionController.text = ua.user!.profile!.description; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   | |||||||
| @@ -33,7 +33,8 @@ class _PublisherScreenState extends State<PublisherScreen> { | |||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       final resp = await sn.client.get('/cgi/co/publishers/me'); |       final resp = await sn.client.get('/cgi/co/publishers/me'); | ||||||
|       final List<SnPublisher> out = List<SnPublisher>.from(resp.data?.map((e) => SnPublisher.fromJson(e)) ?? []); |       final List<SnPublisher> out = List<SnPublisher>.from( | ||||||
|  |           resp.data?.map((e) => SnPublisher.fromJson(e)) ?? []); | ||||||
|  |  | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|  |  | ||||||
| @@ -81,6 +82,7 @@ class _PublisherScreenState extends State<PublisherScreen> { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         leading: const PageBackButton(), |         leading: const PageBackButton(), | ||||||
|         title: Text('screenAccountPublishers').tr(), |         title: Text('screenAccountPublishers').tr(), | ||||||
| @@ -93,7 +95,9 @@ class _PublisherScreenState extends State<PublisherScreen> { | |||||||
|             contentPadding: const EdgeInsets.symmetric(horizontal: 24), |             contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|             leading: const Icon(Symbols.add_circle), |             leading: const Icon(Symbols.add_circle), | ||||||
|             onTap: () { |             onTap: () { | ||||||
|               GoRouter.of(context).pushNamed('accountPublisherNew').then((value) { |               GoRouter.of(context) | ||||||
|  |                   .pushNamed('accountPublisherNew') | ||||||
|  |                   .then((value) { | ||||||
|                 if (value == true) { |                 if (value == true) { | ||||||
|                   _publishers.clear(); |                   _publishers.clear(); | ||||||
|                   _fetchPublishers(); |                   _fetchPublishers(); | ||||||
| @@ -119,7 +123,8 @@ class _PublisherScreenState extends State<PublisherScreen> { | |||||||
|                     return ListTile( |                     return ListTile( | ||||||
|                       title: Text(publisher.nick), |                       title: Text(publisher.nick), | ||||||
|                       subtitle: Text('@${publisher.name}'), |                       subtitle: Text('@${publisher.name}'), | ||||||
|                       contentPadding: const EdgeInsets.symmetric(horizontal: 16), |                       contentPadding: | ||||||
|  |                           const EdgeInsets.symmetric(horizontal: 16), | ||||||
|                       leading: AccountImage(content: publisher.avatar), |                       leading: AccountImage(content: publisher.avatar), | ||||||
|                       trailing: PopupMenuButton( |                       trailing: PopupMenuButton( | ||||||
|                         itemBuilder: (BuildContext context) => [ |                         itemBuilder: (BuildContext context) => [ | ||||||
|   | |||||||
							
								
								
									
										187
									
								
								lib/screens/account/punishments.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										187
									
								
								lib/screens/account/punishments.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,187 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:gap/gap.dart'; | ||||||
|  | import 'package:go_router/go_router.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:surface/providers/sn_network.dart'; | ||||||
|  | import 'package:surface/types/account.dart'; | ||||||
|  | import 'package:surface/widgets/account/account_image.dart'; | ||||||
|  | import 'package:surface/widgets/dialog.dart'; | ||||||
|  | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  |  | ||||||
|  | const kPunishmentIcons = [ | ||||||
|  |   Symbols.warning, | ||||||
|  |   Symbols.emergency_home, | ||||||
|  |   Symbols.dangerous, | ||||||
|  | ]; | ||||||
|  |  | ||||||
|  | class PunishmentsScreen extends StatefulWidget { | ||||||
|  |   const PunishmentsScreen({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<PunishmentsScreen> createState() => _PunishmentsScreenState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _PunishmentsScreenState extends State<PunishmentsScreen> { | ||||||
|  |   bool _isBusy = false; | ||||||
|  |   List<SnPunishment>? _punishments; | ||||||
|  |  | ||||||
|  |   Future<void> _fetchPunishments() async { | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       final resp = await sn.client.get('/cgi/id/punishments'); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       _punishments = List.from( | ||||||
|  |         resp.data.map((ele) => SnPunishment.fromJson(ele)), | ||||||
|  |       ); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _fetchPunishments(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|  |       appBar: AppBar( | ||||||
|  |         title: Text('accountPunishments').tr(), | ||||||
|  |         leading: PageBackButton(), | ||||||
|  |       ), | ||||||
|  |       body: Column( | ||||||
|  |         children: [ | ||||||
|  |           LoadingIndicator(isActive: _isBusy), | ||||||
|  |           Card( | ||||||
|  |             margin: EdgeInsets.only(bottom: 8, left: 8, right: 8), | ||||||
|  |             child: Column( | ||||||
|  |               crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |               children: [ | ||||||
|  |                 Row( | ||||||
|  |                   children: [ | ||||||
|  |                     Icon(Symbols.visibility, size: 20), | ||||||
|  |                     const Gap(6), | ||||||
|  |                     Expanded( | ||||||
|  |                       child: Text('punishmentOverall').tr().fontSize(16).bold(), | ||||||
|  |                     ), | ||||||
|  |                   ], | ||||||
|  |                 ), | ||||||
|  |                 Builder( | ||||||
|  |                   builder: (context) { | ||||||
|  |                     if (_punishments == null) return Text('loading').tr(); | ||||||
|  |                     if (_punishments!.any((ele) => ele.type == 2)) { | ||||||
|  |                       return Text('punishmentStatusBanned').tr(); | ||||||
|  |                     } | ||||||
|  |                     if (_punishments!.any( | ||||||
|  |                       (ele) => ele.type == 1 && ele.permNodes.isEmpty, | ||||||
|  |                     )) { | ||||||
|  |                       return Text('punishmentStatusLimitedFully').tr(); | ||||||
|  |                     } else if (_punishments!.any((ele) => ele.type == 1)) { | ||||||
|  |                       return Text('punishmentStatusLimited').tr(); | ||||||
|  |                     } | ||||||
|  |                     if (_punishments!.any((ele) => ele.type == 0)) { | ||||||
|  |                       return Text('punishmentStatusWarned').tr(); | ||||||
|  |                     } | ||||||
|  |                     return Text('punishmentStatusNormal').tr(); | ||||||
|  |                   }, | ||||||
|  |                 ), | ||||||
|  |               ], | ||||||
|  |             ).padding(horizontal: 24, vertical: 16), | ||||||
|  |           ), | ||||||
|  |           Expanded( | ||||||
|  |             child: RefreshIndicator( | ||||||
|  |               onRefresh: _fetchPunishments, | ||||||
|  |               child: ListView.separated( | ||||||
|  |                 padding: EdgeInsets.zero, | ||||||
|  |                 itemCount: _punishments?.length ?? 0, | ||||||
|  |                 itemBuilder: (context, index) { | ||||||
|  |                   final ele = _punishments![index]; | ||||||
|  |                   return Card( | ||||||
|  |                     margin: EdgeInsets.symmetric(horizontal: 8), | ||||||
|  |                     child: Column( | ||||||
|  |                       crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                       children: [ | ||||||
|  |                         Row( | ||||||
|  |                           children: [ | ||||||
|  |                             Icon(kPunishmentIcons[ele.type], size: 20), | ||||||
|  |                             const Gap(6), | ||||||
|  |                             Expanded( | ||||||
|  |                               child: Text('punishmentType${ele.type}') | ||||||
|  |                                   .tr() | ||||||
|  |                                   .fontSize(16) | ||||||
|  |                                   .bold(), | ||||||
|  |                             ), | ||||||
|  |                           ], | ||||||
|  |                         ), | ||||||
|  |                         Text(ele.reason), | ||||||
|  |                         const Gap(4), | ||||||
|  |                         Text( | ||||||
|  |                           'punishmentCreatedAt'.tr(args: [ | ||||||
|  |                             DateFormat().format( | ||||||
|  |                               ele.createdAt.toLocal(), | ||||||
|  |                             ) | ||||||
|  |                           ]), | ||||||
|  |                         ).opacity(0.8), | ||||||
|  |                         Text( | ||||||
|  |                           ele.expiredAt == null | ||||||
|  |                               ? 'punishmentExpiredNever'.tr() | ||||||
|  |                               : 'punishmentExpiredAt'.tr(args: [ | ||||||
|  |                                   DateFormat().format( | ||||||
|  |                                     ele.expiredAt!.toLocal(), | ||||||
|  |                                   ) | ||||||
|  |                                 ]), | ||||||
|  |                         ).opacity(0.8), | ||||||
|  |                         const Gap(8), | ||||||
|  |                         if (ele.moderator != null) | ||||||
|  |                           Column( | ||||||
|  |                             crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                             children: [ | ||||||
|  |                               Text('punishmentModerator').tr().opacity(0.75), | ||||||
|  |                               InkWell( | ||||||
|  |                                 child: Row( | ||||||
|  |                                   children: [ | ||||||
|  |                                     AccountImage( | ||||||
|  |                                       content: ele.moderator!.avatar, | ||||||
|  |                                       radius: 8, | ||||||
|  |                                     ), | ||||||
|  |                                     const Gap(4), | ||||||
|  |                                     Text(ele.moderator?.nick ?? 'unknown'), | ||||||
|  |                                   ], | ||||||
|  |                                 ), | ||||||
|  |                                 onTap: () { | ||||||
|  |                                   GoRouter.of(context).pushNamed( | ||||||
|  |                                     'accountProfilePage', | ||||||
|  |                                     pathParameters: { | ||||||
|  |                                       'name': ele.moderator!.name, | ||||||
|  |                                     }, | ||||||
|  |                                   ); | ||||||
|  |                                 }, | ||||||
|  |                               ), | ||||||
|  |                             ], | ||||||
|  |                           ) | ||||||
|  |                         else | ||||||
|  |                           Text('punishmentMadeBySystem').tr().opacity(0.75), | ||||||
|  |                       ], | ||||||
|  |                     ).padding(horizontal: 24, vertical: 16), | ||||||
|  |                   ); | ||||||
|  |                 }, | ||||||
|  |                 separatorBuilder: (_, __) => const Gap(8), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -37,6 +37,7 @@ class AccountSettingsScreen extends StatelessWidget { | |||||||
|     final ua = context.watch<UserProvider>(); |     final ua = context.watch<UserProvider>(); | ||||||
| 
 | 
 | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         leading: PageBackButton(), |         leading: PageBackButton(), | ||||||
|         title: Text('screenAccountSettings').tr(), |         title: Text('screenAccountSettings').tr(), | ||||||
| @@ -87,6 +88,46 @@ class AccountSettingsScreen extends StatelessWidget { | |||||||
|                 ), |                 ), | ||||||
|               ), |               ), | ||||||
|             ), |             ), | ||||||
|  |             ListTile( | ||||||
|  |               title: Text('accountContactMethods').tr(), | ||||||
|  |               subtitle: Text('accountContactMethodsDescription').tr(), | ||||||
|  |               contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |               leading: const Icon(Symbols.contacts), | ||||||
|  |               trailing: const Icon(Symbols.chevron_right), | ||||||
|  |               onTap: () { | ||||||
|  |                 GoRouter.of(context).pushNamed('accountContactMethods'); | ||||||
|  |               }, | ||||||
|  |             ), | ||||||
|  |             ListTile( | ||||||
|  |               title: Text('accountSettingsNotify').tr(), | ||||||
|  |               subtitle: Text('accountSettingsNotifyDescription').tr(), | ||||||
|  |               contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |               leading: const Icon(Symbols.notifications), | ||||||
|  |               trailing: const Icon(Symbols.chevron_right), | ||||||
|  |               onTap: () { | ||||||
|  |                 GoRouter.of(context).pushNamed('accountSettingsNotify'); | ||||||
|  |               }, | ||||||
|  |             ), | ||||||
|  |             ListTile( | ||||||
|  |               title: Text('accountSettingsSecurity').tr(), | ||||||
|  |               subtitle: Text('accountSettingsSecurityDescription').tr(), | ||||||
|  |               contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |               leading: const Icon(Symbols.shield), | ||||||
|  |               trailing: const Icon(Symbols.chevron_right), | ||||||
|  |               onTap: () { | ||||||
|  |                 GoRouter.of(context).pushNamed('accountSettingsSecurity'); | ||||||
|  |               }, | ||||||
|  |             ), | ||||||
|  |             ListTile( | ||||||
|  |               title: Text('factorSettings').tr(), | ||||||
|  |               subtitle: Text('factorSettingsSubtitle').tr(), | ||||||
|  |               contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |               leading: const Icon(Symbols.lock), | ||||||
|  |               trailing: const Icon(Symbols.chevron_right), | ||||||
|  |               onTap: () { | ||||||
|  |                 GoRouter.of(context).pushNamed('factorSettings'); | ||||||
|  |               }, | ||||||
|  |             ), | ||||||
|             ListTile( |             ListTile( | ||||||
|               title: Text('accountProfileEdit').tr(), |               title: Text('accountProfileEdit').tr(), | ||||||
|               subtitle: Text('accountProfileEditSubtitle').tr(), |               subtitle: Text('accountProfileEditSubtitle').tr(), | ||||||
| @@ -10,7 +10,6 @@ import 'package:styled_widget/styled_widget.dart'; | |||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/providers/user_directory.dart'; | import 'package:surface/providers/user_directory.dart'; | ||||||
| import 'package:surface/types/attachment.dart'; | import 'package:surface/types/attachment.dart'; | ||||||
| import 'package:surface/widgets/app_bar_leading.dart'; |  | ||||||
| import 'package:surface/widgets/attachment/attachment_zoom.dart'; | import 'package:surface/widgets/attachment/attachment_zoom.dart'; | ||||||
| import 'package:surface/widgets/attachment/attachment_item.dart'; | import 'package:surface/widgets/attachment/attachment_item.dart'; | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
| @@ -106,7 +105,7 @@ class _AlbumScreenState extends State<AlbumScreen> { | |||||||
|         controller: _scrollController, |         controller: _scrollController, | ||||||
|         slivers: [ |         slivers: [ | ||||||
|           SliverAppBar( |           SliverAppBar( | ||||||
|             leading: AutoAppBarLeading(), |             leading: PageBackButton(), | ||||||
|             title: Text('screenAlbum').tr(), |             title: Text('screenAlbum').tr(), | ||||||
|           ), |           ), | ||||||
|           SliverToBoxAdapter( |           SliverToBoxAdapter( | ||||||
| @@ -119,7 +118,8 @@ class _AlbumScreenState extends State<AlbumScreen> { | |||||||
|                     child: CircularProgressIndicator( |                     child: CircularProgressIndicator( | ||||||
|                       value: _billing?.includedRatio ?? 0, |                       value: _billing?.includedRatio ?? 0, | ||||||
|                       strokeWidth: 8, |                       strokeWidth: 8, | ||||||
|                       backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, |                       backgroundColor: | ||||||
|  |                           Theme.of(context).colorScheme.surfaceContainerHigh, | ||||||
|                     ), |                     ), | ||||||
|                   ).padding(all: 12), |                   ).padding(all: 12), | ||||||
|                   const Gap(24), |                   const Gap(24), | ||||||
| @@ -129,7 +129,8 @@ class _AlbumScreenState extends State<AlbumScreen> { | |||||||
|                       children: [ |                       children: [ | ||||||
|                         Text('attachmentBillingUploaded').tr().bold(), |                         Text('attachmentBillingUploaded').tr().bold(), | ||||||
|                         Text( |                         Text( | ||||||
|                           (_billing?.currentBytes ?? 0).formatBytes(decimals: 4), |                           (_billing?.currentBytes ?? 0) | ||||||
|  |                               .formatBytes(decimals: 4), | ||||||
|                           style: GoogleFonts.robotoMono(), |                           style: GoogleFonts.robotoMono(), | ||||||
|                         ), |                         ), | ||||||
|                         Text('attachmentBillingDiscount').tr().bold(), |                         Text('attachmentBillingDiscount').tr().bold(), | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart'; | |||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
|  | import 'package:surface/screens/captcha/captcha.dart'; | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
| import 'package:url_launcher/url_launcher_string.dart'; | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
| @@ -33,10 +34,20 @@ class _RegisterScreenState extends State<RegisterScreen> { | |||||||
|     final username = _usernameController.value.text; |     final username = _usernameController.value.text; | ||||||
|     final nickname = _nicknameController.value.text; |     final nickname = _nicknameController.value.text; | ||||||
|     final password = _passwordController.value.text; |     final password = _passwordController.value.text; | ||||||
|     if (email.isEmpty || username.isEmpty || nickname.isEmpty || password.isEmpty) { |     if (email.isEmpty || | ||||||
|  |         username.isEmpty || | ||||||
|  |         nickname.isEmpty || | ||||||
|  |         password.isEmpty) { | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     final captchaTk = await Navigator.of(context, rootNavigator: true).push( | ||||||
|  |       MaterialPageRoute( | ||||||
|  |         builder: (context) => CaptchaScreen(), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |     if (captchaTk == null) return; | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       await sn.client.post('/cgi/id/users', data: { |       await sn.client.post('/cgi/id/users', data: { | ||||||
| @@ -45,6 +56,7 @@ class _RegisterScreenState extends State<RegisterScreen> { | |||||||
|         'email': email, |         'email': email, | ||||||
|         'password': password, |         'password': password, | ||||||
|         'language': EasyLocalization.of(context)!.currentLocale.toString(), |         'language': EasyLocalization.of(context)!.currentLocale.toString(), | ||||||
|  |         'captcha_token': captchaTk, | ||||||
|       }); |       }); | ||||||
|  |  | ||||||
|       if (!context.mounted) return; |       if (!context.mounted) return; | ||||||
| @@ -91,8 +103,11 @@ class _RegisterScreenState extends State<RegisterScreen> { | |||||||
|                   children: [ |                   children: [ | ||||||
|                     TextFormField( |                     TextFormField( | ||||||
|                       validator: (value) { |                       validator: (value) { | ||||||
|                         if (value == null || value.length < 4 || value.length > 32) { |                         if (value == null || | ||||||
|                           return 'fieldUsernameLengthLimit'.tr(args: [4.toString(), 32.toString()]); |                             value.length < 4 || | ||||||
|  |                             value.length > 32) { | ||||||
|  |                           return 'fieldUsernameLengthLimit' | ||||||
|  |                               .tr(args: [4.toString(), 32.toString()]); | ||||||
|                         } |                         } | ||||||
|                         if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value)) { |                         if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value)) { | ||||||
|                           return 'fieldUsernameAlphanumOnly'.tr(); |                           return 'fieldUsernameAlphanumOnly'.tr(); | ||||||
| @@ -108,13 +123,17 @@ class _RegisterScreenState extends State<RegisterScreen> { | |||||||
|                         border: const UnderlineInputBorder(), |                         border: const UnderlineInputBorder(), | ||||||
|                         labelText: 'fieldUsername'.tr(), |                         labelText: 'fieldUsername'.tr(), | ||||||
|                       ), |                       ), | ||||||
|                       onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |                       onTapOutside: (_) => | ||||||
|  |                           FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                     ), |                     ), | ||||||
|                     const Gap(12), |                     const Gap(12), | ||||||
|                     TextFormField( |                     TextFormField( | ||||||
|                       validator: (value) { |                       validator: (value) { | ||||||
|                         if (value == null || value.length < 4 || value.length > 32) { |                         if (value == null || | ||||||
|                           return 'fieldNicknameLengthLimit'.tr(args: [4.toString(), 32.toString()]); |                             value.length < 4 || | ||||||
|  |                             value.length > 32) { | ||||||
|  |                           return 'fieldNicknameLengthLimit' | ||||||
|  |                               .tr(args: [4.toString(), 32.toString()]); | ||||||
|                         } |                         } | ||||||
|                         return null; |                         return null; | ||||||
|                       }, |                       }, | ||||||
| @@ -127,7 +146,8 @@ class _RegisterScreenState extends State<RegisterScreen> { | |||||||
|                         border: const UnderlineInputBorder(), |                         border: const UnderlineInputBorder(), | ||||||
|                         labelText: 'fieldNickname'.tr(), |                         labelText: 'fieldNickname'.tr(), | ||||||
|                       ), |                       ), | ||||||
|                       onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |                       onTapOutside: (_) => | ||||||
|  |                           FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                     ), |                     ), | ||||||
|                     const Gap(12), |                     const Gap(12), | ||||||
|                     TextFormField( |                     TextFormField( | ||||||
| @@ -149,7 +169,8 @@ class _RegisterScreenState extends State<RegisterScreen> { | |||||||
|                         border: const UnderlineInputBorder(), |                         border: const UnderlineInputBorder(), | ||||||
|                         labelText: 'fieldEmail'.tr(), |                         labelText: 'fieldEmail'.tr(), | ||||||
|                       ), |                       ), | ||||||
|                       onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |                       onTapOutside: (_) => | ||||||
|  |                           FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                     ), |                     ), | ||||||
|                     const Gap(12), |                     const Gap(12), | ||||||
|                     TextFormField( |                     TextFormField( | ||||||
| @@ -169,7 +190,8 @@ class _RegisterScreenState extends State<RegisterScreen> { | |||||||
|                         border: const UnderlineInputBorder(), |                         border: const UnderlineInputBorder(), | ||||||
|                         labelText: 'fieldPassword'.tr(), |                         labelText: 'fieldPassword'.tr(), | ||||||
|                       ), |                       ), | ||||||
|                       onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |                       onTapOutside: (_) => | ||||||
|  |                           FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                     ), |                     ), | ||||||
|                   ], |                   ], | ||||||
|                 ).padding(horizontal: 7), |                 ).padding(horizontal: 7), | ||||||
| @@ -186,8 +208,12 @@ class _RegisterScreenState extends State<RegisterScreen> { | |||||||
|                         Text( |                         Text( | ||||||
|                           'termAcceptNextWithAgree'.tr(), |                           'termAcceptNextWithAgree'.tr(), | ||||||
|                           textAlign: TextAlign.end, |                           textAlign: TextAlign.end, | ||||||
|                           style: Theme.of(context).textTheme.bodySmall!.copyWith( |                           style: | ||||||
|                                 color: Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round()), |                               Theme.of(context).textTheme.bodySmall!.copyWith( | ||||||
|  |                                     color: Theme.of(context) | ||||||
|  |                                         .colorScheme | ||||||
|  |                                         .onSurface | ||||||
|  |                                         .withAlpha((255 * 0.75).round()), | ||||||
|                                   ), |                                   ), | ||||||
|                         ), |                         ), | ||||||
|                         Material( |                         Material( | ||||||
|   | |||||||
							
								
								
									
										3
									
								
								lib/screens/captcha/captcha.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								lib/screens/captcha/captcha.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | import 'package:flutter/foundation.dart' show kIsWeb; | ||||||
|  |  | ||||||
|  | export 'captcha_native.dart' if (kIsWeb) 'captcha_web.dart'; | ||||||
							
								
								
									
										37
									
								
								lib/screens/captcha/captcha_native.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								lib/screens/captcha/captcha_native.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_inappwebview/flutter_inappwebview.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/providers/config.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  |  | ||||||
|  | class CaptchaScreen extends StatefulWidget { | ||||||
|  |   const CaptchaScreen({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<CaptchaScreen> createState() => _CaptchaScreenState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _CaptchaScreenState extends State<CaptchaScreen> { | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     final cfg = context.read<ConfigProvider>(); | ||||||
|  |  | ||||||
|  |     return AppScaffold( | ||||||
|  |       appBar: AppBar(title: Text("reCaptcha").tr()), | ||||||
|  |       body: InAppWebView( | ||||||
|  |         initialUrlRequest: URLRequest( | ||||||
|  |           url: WebUri('${cfg.serverUrl}/captcha?redirect_uri=solink://captcha'), | ||||||
|  |         ), | ||||||
|  |         shouldOverrideUrlLoading: (controller, navigationAction) async { | ||||||
|  |           Uri? url = navigationAction.request.url; | ||||||
|  |           if (url != null && url.queryParameters.containsKey('captcha_tk')) { | ||||||
|  |             Navigator.pop(context, url.queryParameters['captcha_tk']!); | ||||||
|  |             return NavigationActionPolicy.CANCEL; | ||||||
|  |           } | ||||||
|  |           return NavigationActionPolicy.ALLOW; | ||||||
|  |         }, | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										54
									
								
								lib/screens/captcha/captcha_web.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								lib/screens/captcha/captcha_web.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | |||||||
|  | import 'dart:html' as html; | ||||||
|  | import 'dart:ui_web' as ui; | ||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/providers/config.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  |  | ||||||
|  | class CaptchaScreen extends StatefulWidget { | ||||||
|  |   const CaptchaScreen({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<CaptchaScreen> createState() => _CaptchaScreenState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _CaptchaScreenState extends State<CaptchaScreen> { | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _setupWebListener(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void _setupWebListener() { | ||||||
|  |     html.window.onMessage.listen((event) { | ||||||
|  |       if (event.data != null && event.data is String) { | ||||||
|  |         final message = event.data as String; | ||||||
|  |         if (message.startsWith("captcha_tk=")) { | ||||||
|  |           String token = message.replaceFirst("captcha_tk=", ""); | ||||||
|  |           Navigator.pop(context, token); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     final iframe = html.IFrameElement() | ||||||
|  |       ..src = '${context.read<ConfigProvider>().serverUrl}/captcha?redirect_uri=web' | ||||||
|  |       ..style.border = 'none' | ||||||
|  |       ..width = '100%' | ||||||
|  |       ..height = '100%'; | ||||||
|  |  | ||||||
|  |     html.document.body!.append(iframe); | ||||||
|  |     ui.platformViewRegistry.registerViewFactory( | ||||||
|  |       'captcha-iframe', | ||||||
|  |           (int viewId) => iframe, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AppScaffold( | ||||||
|  |       appBar: AppBar(title: Text("reCaptcha").tr()), | ||||||
|  |       body: HtmlElementView(viewType: 'captcha-iframe'), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -6,19 +6,16 @@ import 'package:go_router/go_router.dart'; | |||||||
| import 'package:google_fonts/google_fonts.dart'; | import 'package:google_fonts/google_fonts.dart'; | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:responsive_framework/responsive_framework.dart'; |  | ||||||
| import 'package:surface/providers/channel.dart'; | import 'package:surface/providers/channel.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/providers/user_directory.dart'; | import 'package:surface/providers/user_directory.dart'; | ||||||
| import 'package:surface/providers/userinfo.dart'; | import 'package:surface/providers/userinfo.dart'; | ||||||
| import 'package:surface/screens/chat/room.dart'; |  | ||||||
| import 'package:surface/types/chat.dart'; | import 'package:surface/types/chat.dart'; | ||||||
| import 'package:surface/widgets/account/account_image.dart'; | import 'package:surface/widgets/account/account_image.dart'; | ||||||
| import 'package:surface/widgets/account/account_select.dart'; | import 'package:surface/widgets/account/account_select.dart'; | ||||||
| import 'package:surface/widgets/app_bar_leading.dart'; | import 'package:surface/widgets/app_bar_leading.dart'; | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
| import 'package:surface/widgets/loading_indicator.dart'; | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
| import 'package:surface/widgets/navigation/app_background.dart'; |  | ||||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
| import 'package:surface/widgets/unauthorized_hint.dart'; | import 'package:surface/widgets/unauthorized_hint.dart'; | ||||||
| import 'package:uuid/uuid.dart'; | import 'package:uuid/uuid.dart'; | ||||||
| @@ -74,18 +71,20 @@ class _ChatScreenState extends State<ChatScreen> { | |||||||
|  |  | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       final ud = context.read<UserDirectoryProvider>(); |       final ud = context.read<UserDirectoryProvider>(); | ||||||
|  |       final idSet = <int>{}; | ||||||
|       for (final channel in channels) { |       for (final channel in channels) { | ||||||
|         if (channel.type == 1) { |         if (channel.type == 1) { | ||||||
|           await ud.listAccount( |           idSet.addAll( | ||||||
|             channel.members |             channel.members | ||||||
|                     ?.cast<SnChannelMember?>() |                     ?.cast<SnChannelMember?>() | ||||||
|                     .map((ele) => ele?.accountId) |                     .map((ele) => ele?.accountId) | ||||||
|                     .where((ele) => ele != null) |                     .where((ele) => ele != null) | ||||||
|                     .toSet() ?? |                     .cast<int>() ?? | ||||||
|                 {}, |                 [], | ||||||
|           ); |           ); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|  |       if (idSet.isNotEmpty) await ud.listAccount(idSet); | ||||||
|  |  | ||||||
|       if (mounted) setState(() => _channels = channels); |       if (mounted) setState(() => _channels = channels); | ||||||
|     }) |     }) | ||||||
| @@ -128,8 +127,6 @@ class _ChatScreenState extends State<ChatScreen> { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   SnChannel? _focusChannel; |  | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void initState() { |   void initState() { | ||||||
|     super.initState(); |     super.initState(); | ||||||
| @@ -138,13 +135,8 @@ class _ChatScreenState extends State<ChatScreen> { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   void _onTapChannel(SnChannel channel) { |   void _onTapChannel(SnChannel channel) { | ||||||
|     final doExpand = ResponsiveBreakpoints.of(context).largerOrEqualTo(DESKTOP); |     setState(() => _unreadCounts?[channel.id] = 0); | ||||||
|  |     GoRouter.of(context).pushReplacementNamed( | ||||||
|     if (doExpand) { |  | ||||||
|       setState(() => _focusChannel = channel); |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|     GoRouter.of(context).pushNamed( |  | ||||||
|       'chatRoom', |       'chatRoom', | ||||||
|       pathParameters: { |       pathParameters: { | ||||||
|         'scope': channel.realm?.alias ?? 'global', |         'scope': channel.realm?.alias ?? 'global', | ||||||
| @@ -152,7 +144,6 @@ class _ChatScreenState extends State<ChatScreen> { | |||||||
|       }, |       }, | ||||||
|     ).then((value) { |     ).then((value) { | ||||||
|       if (mounted) { |       if (mounted) { | ||||||
|         _unreadCounts?[channel.id] = 0; |  | ||||||
|         setState(() => _unreadCounts?[channel.id] = 0); |         setState(() => _unreadCounts?[channel.id] = 0); | ||||||
|         _refreshChannels(noRemote: true); |         _refreshChannels(noRemote: true); | ||||||
|       } |       } | ||||||
| @@ -161,7 +152,6 @@ class _ChatScreenState extends State<ChatScreen> { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     final ud = context.read<UserDirectoryProvider>(); |  | ||||||
|     final ua = context.read<UserProvider>(); |     final ua = context.read<UserProvider>(); | ||||||
|  |  | ||||||
|     if (!ua.isAuthorized) { |     if (!ua.isAuthorized) { | ||||||
| @@ -176,10 +166,8 @@ class _ChatScreenState extends State<ChatScreen> { | |||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     final doExpand = ResponsiveBreakpoints.of(context).largerOrEqualTo(DESKTOP); |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|     final chatList = AppScaffold( |  | ||||||
|       noBackground: doExpand, |  | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         leading: AutoAppBarLeading(), |         leading: AutoAppBarLeading(), | ||||||
|         title: Text('screenChat').tr(), |         title: Text('screenChat').tr(), | ||||||
| @@ -203,7 +191,6 @@ class _ChatScreenState extends State<ChatScreen> { | |||||||
|               Theme.of(context).floatingActionButtonTheme.foregroundColor, |               Theme.of(context).floatingActionButtonTheme.foregroundColor, | ||||||
|           backgroundColor: |           backgroundColor: | ||||||
|               Theme.of(context).floatingActionButtonTheme.backgroundColor, |               Theme.of(context).floatingActionButtonTheme.backgroundColor, | ||||||
|           shape: const CircleBorder(), |  | ||||||
|         ), |         ), | ||||||
|         closeButtonBuilder: DefaultFloatingActionButtonBuilder( |         closeButtonBuilder: DefaultFloatingActionButtonBuilder( | ||||||
|           child: const Icon(Symbols.close, size: 28), |           child: const Icon(Symbols.close, size: 28), | ||||||
| @@ -212,7 +199,6 @@ class _ChatScreenState extends State<ChatScreen> { | |||||||
|               Theme.of(context).floatingActionButtonTheme.foregroundColor, |               Theme.of(context).floatingActionButtonTheme.foregroundColor, | ||||||
|           backgroundColor: |           backgroundColor: | ||||||
|               Theme.of(context).floatingActionButtonTheme.backgroundColor, |               Theme.of(context).floatingActionButtonTheme.backgroundColor, | ||||||
|           shape: const CircleBorder(), |  | ||||||
|         ), |         ), | ||||||
|         children: [ |         children: [ | ||||||
|           Row( |           Row( | ||||||
| @@ -264,64 +250,61 @@ class _ChatScreenState extends State<ChatScreen> { | |||||||
|                     final channel = _channels![idx]; |                     final channel = _channels![idx]; | ||||||
|                     final lastMessage = _lastMessages?[channel.id]; |                     final lastMessage = _lastMessages?[channel.id]; | ||||||
|  |  | ||||||
|                     if (channel.type == 1) { |                     return _ChatChannelEntry( | ||||||
|                       final otherMember = |                       channel: channel, | ||||||
|                           channel.members?.cast<SnChannelMember?>().firstWhere( |                       lastMessage: lastMessage, | ||||||
|                                 (ele) => ele?.accountId != ua.user?.id, |                       unreadCount: _unreadCounts?[channel.id], | ||||||
|                                 orElse: () => null, |  | ||||||
|                               ); |  | ||||||
|  |  | ||||||
|                       return ListTile( |  | ||||||
|                         title: Row( |  | ||||||
|                           children: [ |  | ||||||
|                             Expanded( |  | ||||||
|                               child: Text(ud |  | ||||||
|                                       .getAccountFromCache( |  | ||||||
|                                           otherMember?.accountId) |  | ||||||
|                                       ?.nick ?? |  | ||||||
|                                   channel.name), |  | ||||||
|                             ), |  | ||||||
|                             const Gap(8), |  | ||||||
|                             if (_unreadCounts?[channel.id] != null && |  | ||||||
|                                 _unreadCounts![channel.id]! > 0) |  | ||||||
|                               Badge( |  | ||||||
|                                 label: Text('${_unreadCounts![channel.id]}'), |  | ||||||
|                               ), |  | ||||||
|                           ], |  | ||||||
|                         ), |  | ||||||
|                         subtitle: lastMessage != null |  | ||||||
|                             ? Text( |  | ||||||
|                                 '${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}', |  | ||||||
|                                 maxLines: 1, |  | ||||||
|                                 overflow: TextOverflow.ellipsis, |  | ||||||
|                               ) |  | ||||||
|                             : Text( |  | ||||||
|                                 channel.description, |  | ||||||
|                                 maxLines: 1, |  | ||||||
|                                 overflow: TextOverflow.ellipsis, |  | ||||||
|                               ), |  | ||||||
|                         contentPadding: |  | ||||||
|                             const EdgeInsets.symmetric(horizontal: 16), |  | ||||||
|                         leading: AccountImage( |  | ||||||
|                           content: ud |  | ||||||
|                               .getAccountFromCache(otherMember?.accountId) |  | ||||||
|                               ?.avatar, |  | ||||||
|                         ), |  | ||||||
|                       onTap: () { |                       onTap: () { | ||||||
|                         _onTapChannel(channel); |                         _onTapChannel(channel); | ||||||
|                       }, |                       }, | ||||||
|                     ); |                     ); | ||||||
|  |                   }, | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|   } |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _ChatChannelEntry extends StatelessWidget { | ||||||
|  |   final SnChannel channel; | ||||||
|  |   final int? unreadCount; | ||||||
|  |   final SnChatMessage? lastMessage; | ||||||
|  |   final Function? onTap; | ||||||
|  |   const _ChatChannelEntry({ | ||||||
|  |     required this.channel, | ||||||
|  |     this.unreadCount, | ||||||
|  |     this.lastMessage, | ||||||
|  |     this.onTap, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     final ud = context.read<UserDirectoryProvider>(); | ||||||
|  |     final ua = context.read<UserProvider>(); | ||||||
|  |  | ||||||
|  |     final otherMember = channel.type == 1 | ||||||
|  |         ? channel.members?.cast<SnChannelMember?>().firstWhere( | ||||||
|  |               (ele) => ele?.accountId != ua.user?.id, | ||||||
|  |               orElse: () => null, | ||||||
|  |             ) | ||||||
|  |         : null; | ||||||
|  |  | ||||||
|  |     final title = otherMember != null | ||||||
|  |         ? ud.getFromCache(otherMember.accountId)?.nick ?? channel.name | ||||||
|  |         : channel.name; | ||||||
|  |  | ||||||
|     return ListTile( |     return ListTile( | ||||||
|       title: Row( |       title: Row( | ||||||
|         children: [ |         children: [ | ||||||
|                           Expanded(child: Text(channel.name)), |           Expanded(child: Text(title)), | ||||||
|           const Gap(8), |           const Gap(8), | ||||||
|                           if (_unreadCounts?[channel.id] != null && |           if (unreadCount != null && unreadCount! > 0) | ||||||
|                               _unreadCounts![channel.id]! > 0) |  | ||||||
|             Badge( |             Badge( | ||||||
|                               label: Text('${_unreadCounts![channel.id]}'), |               label: Text(unreadCount.toString()), | ||||||
|             ), |             ), | ||||||
|         ], |         ], | ||||||
|       ), |       ), | ||||||
| @@ -329,35 +312,37 @@ class _ChatScreenState extends State<ChatScreen> { | |||||||
|           ? Row( |           ? Row( | ||||||
|               children: [ |               children: [ | ||||||
|                 Badge( |                 Badge( | ||||||
|                                   label: Text(ud |                   label: Text( | ||||||
|                                           .getAccountFromCache( |                       ud.getFromCache(lastMessage!.sender.accountId)?.nick ?? | ||||||
|                                               lastMessage.sender.accountId) |  | ||||||
|                                           ?.nick ?? |  | ||||||
|                           'unknown'.tr()), |                           'unknown'.tr()), | ||||||
|                                   backgroundColor: |                   backgroundColor: Theme.of(context).colorScheme.primary, | ||||||
|                                       Theme.of(context).colorScheme.primary, |                   textColor: Theme.of(context).colorScheme.onPrimary, | ||||||
|                 ), |                 ), | ||||||
|                 const Gap(6), |                 const Gap(6), | ||||||
|                 Expanded( |                 Expanded( | ||||||
|                   child: Text( |                   child: Text( | ||||||
|                                     lastMessage.body['text'] ?? |                     lastMessage!.body['algorithm'] == 'plain' | ||||||
|                                         'Unable preview', |                         ? lastMessage!.body['text'] ?? | ||||||
|  |                             'messageUnablePreview'.tr() | ||||||
|  |                         : 'messageUnablePreviewEncrypted'.tr(), | ||||||
|                     maxLines: 1, |                     maxLines: 1, | ||||||
|                     overflow: TextOverflow.ellipsis, |                     overflow: TextOverflow.ellipsis, | ||||||
|  |                     style: lastMessage!.body['algorithm'] != 'plain' || | ||||||
|  |                             lastMessage!.body['text'] == null | ||||||
|  |                         ? TextStyle(fontStyle: FontStyle.italic) | ||||||
|  |                         : null, | ||||||
|                   ), |                   ), | ||||||
|                 ), |                 ), | ||||||
|  |                 const Gap(4), | ||||||
|                 Text( |                 Text( | ||||||
|                   DateFormat( |                   DateFormat( | ||||||
|                                     lastMessage.createdAt.toLocal().day == |                     lastMessage!.createdAt.toLocal().day == DateTime.now().day | ||||||
|                                             DateTime.now().day |  | ||||||
|                         ? 'HH:mm' |                         ? 'HH:mm' | ||||||
|                                         : lastMessage.createdAt |                         : lastMessage!.createdAt.toLocal().year == | ||||||
|                                                     .toLocal() |  | ||||||
|                                                     .year == |  | ||||||
|                                 DateTime.now().year |                                 DateTime.now().year | ||||||
|                             ? 'MM/dd' |                             ? 'MM/dd' | ||||||
|                             : 'yy/MM/dd', |                             : 'yy/MM/dd', | ||||||
|                                   ).format(lastMessage.createdAt.toLocal()), |                   ).format(lastMessage!.createdAt.toLocal()), | ||||||
|                   style: GoogleFonts.robotoMono( |                   style: GoogleFonts.robotoMono( | ||||||
|                     fontSize: 12, |                     fontSize: 12, | ||||||
|                   ), |                   ), | ||||||
| @@ -369,50 +354,14 @@ class _ChatScreenState extends State<ChatScreen> { | |||||||
|               maxLines: 1, |               maxLines: 1, | ||||||
|               overflow: TextOverflow.ellipsis, |               overflow: TextOverflow.ellipsis, | ||||||
|             ), |             ), | ||||||
|                       contentPadding: |       contentPadding: const EdgeInsets.symmetric(horizontal: 16), | ||||||
|                           const EdgeInsets.symmetric(horizontal: 16), |  | ||||||
|       leading: AccountImage( |       leading: AccountImage( | ||||||
|                         content: channel.realm?.avatar, |         content: otherMember != null | ||||||
|  |             ? ud.getFromCache(otherMember.accountId)?.avatar | ||||||
|  |             : channel.realm?.avatar, | ||||||
|         fallbackWidget: const Icon(Symbols.chat, size: 20), |         fallbackWidget: const Icon(Symbols.chat, size: 20), | ||||||
|       ), |       ), | ||||||
|                       onTap: () { |       onTap: () => onTap?.call(), | ||||||
|                         if (doExpand) { |  | ||||||
|                           _unreadCounts?[channel.id] = 0; |  | ||||||
|                           setState(() => _focusChannel = channel); |  | ||||||
|                           return; |  | ||||||
|                         } |  | ||||||
|                         _onTapChannel(channel); |  | ||||||
|                       }, |  | ||||||
|                     ); |  | ||||||
|                   }, |  | ||||||
|                 ), |  | ||||||
|               ), |  | ||||||
|             ), |  | ||||||
|           ), |  | ||||||
|         ], |  | ||||||
|       ), |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     if (doExpand) { |  | ||||||
|       return AppBackground( |  | ||||||
|         isRoot: true, |  | ||||||
|         child: Row( |  | ||||||
|           children: [ |  | ||||||
|             SizedBox(width: 340, child: chatList), |  | ||||||
|             const VerticalDivider(width: 1), |  | ||||||
|             if (_focusChannel != null) |  | ||||||
|               Expanded( |  | ||||||
|                 child: ChatRoomScreen( |  | ||||||
|                   key: ValueKey(_focusChannel!.id), |  | ||||||
|                   scope: _focusChannel!.realm?.alias ?? 'global', |  | ||||||
|                   alias: _focusChannel!.alias, |  | ||||||
|                 ), |  | ||||||
|               ), |  | ||||||
|           ], |  | ||||||
|         ), |  | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|     return chatList; |  | ||||||
|   } |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -37,7 +37,8 @@ class _CallRoomScreenState extends State<CallRoomScreen> { | |||||||
|     return Stack( |     return Stack( | ||||||
|       children: [ |       children: [ | ||||||
|         Container( |         Container( | ||||||
|           color: Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.75), |           color: | ||||||
|  |               Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.75), | ||||||
|           child: call.focusTrack != null |           child: call.focusTrack != null | ||||||
|               ? InteractiveParticipantWidget( |               ? InteractiveParticipantWidget( | ||||||
|                   isFixedAvatar: false, |                   isFixedAvatar: false, | ||||||
| @@ -72,7 +73,8 @@ class _CallRoomScreenState extends State<CallRoomScreen> { | |||||||
|                       color: Theme.of(context).cardColor, |                       color: Theme.of(context).cardColor, | ||||||
|                       participant: track, |                       participant: track, | ||||||
|                       onTap: () { |                       onTap: () { | ||||||
|                         if (track.participant.sid != call.focusTrack?.participant.sid) { |                         if (track.participant.sid != | ||||||
|  |                             call.focusTrack?.participant.sid) { | ||||||
|                           call.setFocusTrack(track); |                           call.setFocusTrack(track); | ||||||
|                         } |                         } | ||||||
|                       }, |                       }, | ||||||
| @@ -114,10 +116,14 @@ class _CallRoomScreenState extends State<CallRoomScreen> { | |||||||
|             child: ClipRRect( |             child: ClipRRect( | ||||||
|               borderRadius: const BorderRadius.all(Radius.circular(8)), |               borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||||
|               child: InteractiveParticipantWidget( |               child: InteractiveParticipantWidget( | ||||||
|                 color: Theme.of(context).colorScheme.surfaceContainerHigh.withOpacity(0.75), |                 color: Theme.of(context) | ||||||
|  |                     .colorScheme | ||||||
|  |                     .surfaceContainerHigh | ||||||
|  |                     .withOpacity(0.75), | ||||||
|                 participant: track, |                 participant: track, | ||||||
|                 onTap: () { |                 onTap: () { | ||||||
|                   if (track.participant.sid != call.focusTrack?.participant.sid) { |                   if (track.participant.sid != | ||||||
|  |                       call.focusTrack?.participant.sid) { | ||||||
|                     call.setFocusTrack(track); |                     call.setFocusTrack(track); | ||||||
|                   } |                   } | ||||||
|                 }, |                 }, | ||||||
| @@ -149,6 +155,7 @@ class _CallRoomScreenState extends State<CallRoomScreen> { | |||||||
|         listenable: call, |         listenable: call, | ||||||
|         builder: (context, _) { |         builder: (context, _) { | ||||||
|           return AppScaffold( |           return AppScaffold( | ||||||
|  |             noBackground: true, | ||||||
|             appBar: AppBar( |             appBar: AppBar( | ||||||
|               title: RichText( |               title: RichText( | ||||||
|                 textAlign: TextAlign.center, |                 textAlign: TextAlign.center, | ||||||
| @@ -183,7 +190,8 @@ class _CallRoomScreenState extends State<CallRoomScreen> { | |||||||
|                         Builder(builder: (context) { |                         Builder(builder: (context) { | ||||||
|                           final call = context.read<ChatCallProvider>(); |                           final call = context.read<ChatCallProvider>(); | ||||||
|                           final connectionQuality = |                           final connectionQuality = | ||||||
|                               call.room.localParticipant?.connectionQuality ?? livekit.ConnectionQuality.unknown; |                               call.room.localParticipant?.connectionQuality ?? | ||||||
|  |                                   livekit.ConnectionQuality.unknown; | ||||||
|                           return Expanded( |                           return Expanded( | ||||||
|                             child: Column( |                             child: Column( | ||||||
|                               mainAxisSize: MainAxisSize.min, |                               mainAxisSize: MainAxisSize.min, | ||||||
| @@ -205,24 +213,35 @@ class _CallRoomScreenState extends State<CallRoomScreen> { | |||||||
|                                   children: [ |                                   children: [ | ||||||
|                                     Text( |                                     Text( | ||||||
|                                       { |                                       { | ||||||
|                                         livekit.ConnectionState.disconnected: 'callStatusDisconnected'.tr(), |                                         livekit.ConnectionState.disconnected: | ||||||
|                                         livekit.ConnectionState.connected: 'callStatusConnected'.tr(), |                                             'callStatusDisconnected'.tr(), | ||||||
|                                         livekit.ConnectionState.connecting: 'callStatusConnecting'.tr(), |                                         livekit.ConnectionState.connected: | ||||||
|                                         livekit.ConnectionState.reconnecting: 'callStatusReconnecting'.tr(), |                                             'callStatusConnected'.tr(), | ||||||
|  |                                         livekit.ConnectionState.connecting: | ||||||
|  |                                             'callStatusConnecting'.tr(), | ||||||
|  |                                         livekit.ConnectionState.reconnecting: | ||||||
|  |                                             'callStatusReconnecting'.tr(), | ||||||
|                                       }[call.room.connectionState]!, |                                       }[call.room.connectionState]!, | ||||||
|                                     ), |                                     ), | ||||||
|                                     const Gap(6), |                                     const Gap(6), | ||||||
|                                     if (connectionQuality != livekit.ConnectionQuality.unknown) |                                     if (connectionQuality != | ||||||
|  |                                         livekit.ConnectionQuality.unknown) | ||||||
|                                       Icon( |                                       Icon( | ||||||
|                                         { |                                         { | ||||||
|                                           livekit.ConnectionQuality.excellent: Icons.signal_cellular_alt, |                                           livekit.ConnectionQuality.excellent: | ||||||
|                                           livekit.ConnectionQuality.good: Icons.signal_cellular_alt_2_bar, |                                               Icons.signal_cellular_alt, | ||||||
|                                           livekit.ConnectionQuality.poor: Icons.signal_cellular_alt_1_bar, |                                           livekit.ConnectionQuality.good: | ||||||
|  |                                               Icons.signal_cellular_alt_2_bar, | ||||||
|  |                                           livekit.ConnectionQuality.poor: | ||||||
|  |                                               Icons.signal_cellular_alt_1_bar, | ||||||
|                                         }[connectionQuality], |                                         }[connectionQuality], | ||||||
|                                         color: { |                                         color: { | ||||||
|                                           livekit.ConnectionQuality.excellent: Colors.green, |                                           livekit.ConnectionQuality.excellent: | ||||||
|                                           livekit.ConnectionQuality.good: Colors.orange, |                                               Colors.green, | ||||||
|                                           livekit.ConnectionQuality.poor: Colors.red, |                                           livekit.ConnectionQuality.good: | ||||||
|  |                                               Colors.orange, | ||||||
|  |                                           livekit.ConnectionQuality.poor: | ||||||
|  |                                               Colors.red, | ||||||
|                                         }[connectionQuality], |                                         }[connectionQuality], | ||||||
|                                         size: 16, |                                         size: 16, | ||||||
|                                       ) |                                       ) | ||||||
| @@ -244,7 +263,9 @@ class _CallRoomScreenState extends State<CallRoomScreen> { | |||||||
|                         Row( |                         Row( | ||||||
|                           children: [ |                           children: [ | ||||||
|                             IconButton( |                             IconButton( | ||||||
|                               icon: _layoutMode == 0 ? const Icon(Icons.view_list) : const Icon(Icons.grid_view), |                               icon: _layoutMode == 0 | ||||||
|  |                                   ? const Icon(Icons.view_list) | ||||||
|  |                                   : const Icon(Icons.grid_view), | ||||||
|                               onPressed: () { |                               onPressed: () { | ||||||
|                                 _switchLayout(); |                                 _switchLayout(); | ||||||
|                               }, |                               }, | ||||||
|   | |||||||
| @@ -57,11 +57,10 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> { | |||||||
|     setState(() => _isBusy = true); |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final ct = context.read<ChatChannelProvider>(); | ||||||
|       final resp = |       final resp = await ct.getChannelProfile(_channel!); | ||||||
|           await sn.client.get('/cgi/im/channels/${_channel!.keyPath}/me'); |       _profile = resp; | ||||||
|       _profile = SnChannelMember.fromJson(resp.data); |       _notifyLevel = resp.notify; | ||||||
|       _notifyLevel = _profile!.notify; |  | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       final ud = context.read<UserDirectoryProvider>(); |       final ud = context.read<UserDirectoryProvider>(); | ||||||
|       await ud.getAccount(_profile!.accountId); |       await ud.getAccount(_profile!.accountId); | ||||||
| @@ -103,10 +102,12 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> { | |||||||
|     if (!mounted) return; |     if (!mounted) return; | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|  |       final ct = context.read<ChatChannelProvider>(); | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       await sn.client.delete( |       await sn.client.delete( | ||||||
|         '/cgi/im/channels/${_channel!.realm?.alias ?? 'global'}/${_channel!.alias}/me', |         '/cgi/im/channels/${_channel!.realm?.alias ?? 'global'}/${_channel!.alias}/me', | ||||||
|       ); |       ); | ||||||
|  |       await ct.removeLocalChannel(_channel!); | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       Navigator.pop(context, false); |       Navigator.pop(context, false); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
| @@ -130,12 +131,15 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> { | |||||||
|     setState(() => _isUpdatingNotifyLevel = true); |     setState(() => _isUpdatingNotifyLevel = true); | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|  |       final ct = context.read<ChatChannelProvider>(); | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       await sn.client.put( |       final resp = await sn.client.put( | ||||||
|         '/cgi/im/channels/${_channel!.keyPath}/members/me/notify', |         '/cgi/im/channels/${_channel!.keyPath}/members/me/notify', | ||||||
|         data: {'notify_level': value}, |         data: {'notify_level': value}, | ||||||
|       ); |       ); | ||||||
|  |       _profile = SnChannelMember.fromJson(resp.data); | ||||||
|       _notifyLevel = value; |       _notifyLevel = value; | ||||||
|  |       await ct.updateChannelProfile(_profile!); | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showSnackbar('channelNotifyLevelApplied'.tr()); |       context.showSnackbar('channelNotifyLevelApplied'.tr()); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
| @@ -216,6 +220,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> { | |||||||
|     final isOwned = ua.isAuthorized && _channel?.accountId == ua.user?.id; |     final isOwned = ua.isAuthorized && _channel?.accountId == ua.user?.id; | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         title: _channel != null ? Text(_channel!.name) : Text('loading').tr(), |         title: _channel != null ? Text(_channel!.name) : Text('loading').tr(), | ||||||
|       ), |       ), | ||||||
| @@ -289,15 +294,14 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> { | |||||||
|                   ), |                   ), | ||||||
|                   ListTile( |                   ListTile( | ||||||
|                     leading: AccountImage( |                     leading: AccountImage( | ||||||
|                       content: |                       content: ud.getFromCache(_profile!.accountId)?.avatar, | ||||||
|                           ud.getAccountFromCache(_profile!.accountId)?.avatar, |  | ||||||
|                       radius: 18, |                       radius: 18, | ||||||
|                     ), |                     ), | ||||||
|                     trailing: const Icon(Symbols.chevron_right), |                     trailing: const Icon(Symbols.chevron_right), | ||||||
|                     title: Text('channelEditProfile').tr(), |                     title: Text('channelEditProfile').tr(), | ||||||
|                     subtitle: Text( |                     subtitle: Text( | ||||||
|                       (_profile?.nick?.isEmpty ?? true) |                       (_profile?.nick?.isEmpty ?? true) | ||||||
|                           ? ud.getAccountFromCache(_profile!.accountId)!.nick |                           ? ud.getFromCache(_profile!.accountId)!.nick | ||||||
|                           : _profile!.nick!, |                           : _profile!.nick!, | ||||||
|                     ), |                     ), | ||||||
|                     contentPadding: const EdgeInsets.only(left: 20, right: 20), |                     contentPadding: const EdgeInsets.only(left: 20, right: 20), | ||||||
| @@ -408,11 +412,14 @@ class _ChannelProfileDetailDialogState | |||||||
|     setState(() => _isBusy = true); |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|  |       final ct = context.read<ChatChannelProvider>(); | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       await sn.client.put( |       final resp = await sn.client.put( | ||||||
|         '/cgi/im/channels/${widget.channel.keyPath}/members/me', |         '/cgi/im/channels/${widget.channel.keyPath}/members/me', | ||||||
|         data: {'nick': _nickController.text}, |         data: {'nick': _nickController.text}, | ||||||
|       ); |       ); | ||||||
|  |       final out = SnChannelMember.fromJson(resp.data); | ||||||
|  |       await ct.updateChannelProfile(out); | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       Navigator.pop(context, true); |       Navigator.pop(context, true); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
| @@ -575,11 +582,10 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> { | |||||||
|                 return ListTile( |                 return ListTile( | ||||||
|                   contentPadding: const EdgeInsets.only(right: 24, left: 16), |                   contentPadding: const EdgeInsets.only(right: 24, left: 16), | ||||||
|                   leading: AccountImage( |                   leading: AccountImage( | ||||||
|                     content: ud.getAccountFromCache(member.accountId)?.avatar, |                     content: ud.getFromCache(member.accountId)?.avatar, | ||||||
|                   ), |                   ), | ||||||
|                   title: Text( |                   title: Text( | ||||||
|                     ud.getAccountFromCache(member.accountId)?.name ?? |                     ud.getFromCache(member.accountId)?.name ?? 'unknown'.tr(), | ||||||
|                         'unknown'.tr(), |  | ||||||
|                   ), |                   ), | ||||||
|                   subtitle: Text(member.nick ?? 'unknown'.tr()), |                   subtitle: Text(member.nick ?? 'unknown'.tr()), | ||||||
|                   trailing: SizedBox( |                   trailing: SizedBox( | ||||||
|   | |||||||
| @@ -49,7 +49,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|         resp.data?.map((e) => SnRealm.fromJson(e)) ?? [], |         resp.data?.map((e) => SnRealm.fromJson(e)) ?? [], | ||||||
|       ); |       ); | ||||||
|       if (_editingChannel != null) { |       if (_editingChannel != null) { | ||||||
|         _belongToRealm = _realms?.firstWhereOrNull((e) => e.id == _editingChannel!.realmId); |         _belongToRealm = | ||||||
|  |             _realms?.firstWhereOrNull((e) => e.id == _editingChannel!.realmId); | ||||||
|       } |       } | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (mounted) context.showErrorDialog(err); |       if (mounted) context.showErrorDialog(err); | ||||||
| @@ -97,7 +98,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|       'is_community': _isCommunity, |       'is_community': _isCommunity, | ||||||
|       if (_editingChannel != null && _belongToRealm == null) |       if (_editingChannel != null && _belongToRealm == null) | ||||||
|         'new_belongs_realm': 'global' |         'new_belongs_realm': 'global' | ||||||
|       else if (_editingChannel != null && _belongToRealm?.id != _editingChannel?.realm?.id) |       else if (_editingChannel != null && | ||||||
|  |           _belongToRealm?.id != _editingChannel?.realm?.id) | ||||||
|         'new_belongs_realm': _belongToRealm!.alias, |         'new_belongs_realm': _belongToRealm!.alias, | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
| @@ -139,8 +141,11 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         title: widget.editingChannelAlias != null ? Text('screenChatManage').tr() : Text('screenChatNew').tr(), |         title: widget.editingChannelAlias != null | ||||||
|  |             ? Text('screenChatManage').tr() | ||||||
|  |             : Text('screenChatNew').tr(), | ||||||
|       ), |       ), | ||||||
|       body: SingleChildScrollView( |       body: SingleChildScrollView( | ||||||
|         child: Column( |         child: Column( | ||||||
| @@ -152,7 +157,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|                 leadingPadding: const EdgeInsets.only(left: 10, right: 20), |                 leadingPadding: const EdgeInsets.only(left: 10, right: 20), | ||||||
|                 dividerColor: Colors.transparent, |                 dividerColor: Colors.transparent, | ||||||
|                 content: Text( |                 content: Text( | ||||||
|                   'channelEditingNotice'.tr(args: ['#${_editingChannel!.alias}']), |                   'channelEditingNotice' | ||||||
|  |                       .tr(args: ['#${_editingChannel!.alias}']), | ||||||
|                 ), |                 ), | ||||||
|                 actions: [ |                 actions: [ | ||||||
|                   TextButton( |                   TextButton( | ||||||
| @@ -192,12 +198,15 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|                                   mainAxisSize: MainAxisSize.min, |                                   mainAxisSize: MainAxisSize.min, | ||||||
|                                   crossAxisAlignment: CrossAxisAlignment.start, |                                   crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|                                   children: [ |                                   children: [ | ||||||
|                                     Text(item.name).textStyle(Theme.of(context).textTheme.bodyMedium!), |                                     Text(item.name).textStyle(Theme.of(context) | ||||||
|  |                                         .textTheme | ||||||
|  |                                         .bodyMedium!), | ||||||
|                                     Text( |                                     Text( | ||||||
|                                       item.description, |                                       item.description, | ||||||
|                                       maxLines: 1, |                                       maxLines: 1, | ||||||
|                                       overflow: TextOverflow.ellipsis, |                                       overflow: TextOverflow.ellipsis, | ||||||
|                                     ).textStyle(Theme.of(context).textTheme.bodySmall!), |                                     ).textStyle( | ||||||
|  |                                         Theme.of(context).textTheme.bodySmall!), | ||||||
|                                   ], |                                   ], | ||||||
|                                 ), |                                 ), | ||||||
|                               ), |                               ), | ||||||
| @@ -213,7 +222,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|                         CircleAvatar( |                         CircleAvatar( | ||||||
|                           radius: 16, |                           radius: 16, | ||||||
|                           backgroundColor: Colors.transparent, |                           backgroundColor: Colors.transparent, | ||||||
|                           foregroundColor: Theme.of(context).colorScheme.onSurface, |                           foregroundColor: | ||||||
|  |                               Theme.of(context).colorScheme.onSurface, | ||||||
|                           child: const Icon(Symbols.clear), |                           child: const Icon(Symbols.clear), | ||||||
|                         ), |                         ), | ||||||
|                         const Gap(12), |                         const Gap(12), | ||||||
| @@ -222,7 +232,9 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|                             mainAxisSize: MainAxisSize.min, |                             mainAxisSize: MainAxisSize.min, | ||||||
|                             crossAxisAlignment: CrossAxisAlignment.start, |                             crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|                             children: [ |                             children: [ | ||||||
|                               Text('fieldChatBelongToRealmUnset').tr().textStyle( |                               Text('fieldChatBelongToRealmUnset') | ||||||
|  |                                   .tr() | ||||||
|  |                                   .textStyle( | ||||||
|                                     Theme.of(context).textTheme.bodyMedium!, |                                     Theme.of(context).textTheme.bodyMedium!, | ||||||
|                                   ), |                                   ), | ||||||
|                             ], |                             ], | ||||||
| @@ -257,7 +269,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|                     helperText: 'fieldChatAliasHint'.tr(), |                     helperText: 'fieldChatAliasHint'.tr(), | ||||||
|                     helperMaxLines: 2, |                     helperMaxLines: 2, | ||||||
|                   ), |                   ), | ||||||
|                   onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |                   onTapOutside: (_) => | ||||||
|  |                       FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                 ), |                 ), | ||||||
|                 const Gap(4), |                 const Gap(4), | ||||||
|                 TextField( |                 TextField( | ||||||
| @@ -266,7 +279,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|                     border: const UnderlineInputBorder(), |                     border: const UnderlineInputBorder(), | ||||||
|                     labelText: 'fieldChatName'.tr(), |                     labelText: 'fieldChatName'.tr(), | ||||||
|                   ), |                   ), | ||||||
|                   onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |                   onTapOutside: (_) => | ||||||
|  |                       FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                 ), |                 ), | ||||||
|                 const Gap(4), |                 const Gap(4), | ||||||
|                 TextField( |                 TextField( | ||||||
| @@ -277,7 +291,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|                     border: const UnderlineInputBorder(), |                     border: const UnderlineInputBorder(), | ||||||
|                     labelText: 'fieldChatDescription'.tr(), |                     labelText: 'fieldChatDescription'.tr(), | ||||||
|                   ), |                   ), | ||||||
|                   onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |                   onTapOutside: (_) => | ||||||
|  |                       FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                 ), |                 ), | ||||||
|                 const Gap(12), |                 const Gap(12), | ||||||
|                 CheckboxListTile( |                 CheckboxListTile( | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| import 'dart:async'; | import 'dart:async'; | ||||||
|  | import 'dart:convert'; | ||||||
| import 'dart:developer'; | import 'dart:developer'; | ||||||
|  |  | ||||||
| import 'package:dio/dio.dart'; | import 'package:dio/dio.dart'; | ||||||
| @@ -19,6 +20,7 @@ import 'package:surface/providers/user_directory.dart'; | |||||||
| import 'package:surface/providers/userinfo.dart'; | import 'package:surface/providers/userinfo.dart'; | ||||||
| import 'package:surface/providers/websocket.dart'; | import 'package:surface/providers/websocket.dart'; | ||||||
| import 'package:surface/types/chat.dart'; | import 'package:surface/types/chat.dart'; | ||||||
|  | import 'package:surface/types/websocket.dart'; | ||||||
| import 'package:surface/widgets/chat/call/call_prejoin.dart'; | import 'package:surface/widgets/chat/call/call_prejoin.dart'; | ||||||
| import 'package:surface/widgets/chat/chat_message.dart'; | import 'package:surface/widgets/chat/chat_message.dart'; | ||||||
| import 'package:surface/widgets/chat/chat_message_input.dart'; | import 'package:surface/widgets/chat/chat_message_input.dart'; | ||||||
| @@ -50,17 +52,41 @@ class ChatRoomScreen extends StatefulWidget { | |||||||
| class _ChatRoomScreenState extends State<ChatRoomScreen> { | class _ChatRoomScreenState extends State<ChatRoomScreen> { | ||||||
|   bool _isBusy = false; |   bool _isBusy = false; | ||||||
|   bool _isCalling = false; |   bool _isCalling = false; | ||||||
|  |   bool _isJoining = false; | ||||||
|  |  | ||||||
|   SnChannel? _channel; |   SnChannel? _channel; | ||||||
|  |   SnChannelMember? _currentMember; | ||||||
|   SnChannelMember? _otherMember; |   SnChannelMember? _otherMember; | ||||||
|   SnChatCall? _ongoingCall; |   SnChatCall? _ongoingCall; | ||||||
|  |  | ||||||
|   final GlobalKey<ChatMessageInputState> _inputGlobalKey = GlobalKey(); |   final GlobalKey<ChatMessageInputState> _inputGlobalKey = GlobalKey(); | ||||||
|   late final ChatMessageController _messageController; |   late final ChatMessageController _messageController; | ||||||
|  |  | ||||||
|  |   late final NotificationProvider _nty = context.read<NotificationProvider>(); | ||||||
|  |   late final WebSocketProvider _ws = context.read<WebSocketProvider>(); | ||||||
|  |  | ||||||
|  |   bool _isEncrypted = false; | ||||||
|  |  | ||||||
|   StreamSubscription? _wsSubscription; |   StreamSubscription? _wsSubscription; | ||||||
|  |  | ||||||
|   // TODO fetch user identity and ask them to join the channel or not |   Future<void> _joinChannel() async { | ||||||
|  |     try { | ||||||
|  |       setState(() => _isJoining = true); | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       final ua = context.read<UserProvider>(); | ||||||
|  |       await sn.client | ||||||
|  |           .post('/cgi/im/channels/${_channel!.keyPath}/members', data: { | ||||||
|  |         'related': ua.user?.name, | ||||||
|  |       }); | ||||||
|  |       _initializeChat(); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isJoining = true); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   Future<void> _fetchChannel() async { |   Future<void> _fetchChannel() async { | ||||||
|     setState(() => _isBusy = true); |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
| @@ -69,6 +95,12 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |||||||
|       _channel = await chan.getChannel('${widget.scope}:${widget.alias}'); |       _channel = await chan.getChannel('${widget.scope}:${widget.alias}'); | ||||||
|  |  | ||||||
|       if (!mounted || _channel == null) return; |       if (!mounted || _channel == null) return; | ||||||
|  |       final ct = context.read<ChatChannelProvider>(); | ||||||
|  |       try { | ||||||
|  |         _currentMember = await ct.getChannelProfile(_channel!); | ||||||
|  |       } catch (_) {} | ||||||
|  |  | ||||||
|  |       if (!mounted || _currentMember == null) return; | ||||||
|       final ud = context.read<UserDirectoryProvider>(); |       final ud = context.read<UserDirectoryProvider>(); | ||||||
|       final ua = context.read<UserProvider>(); |       final ua = context.read<UserProvider>(); | ||||||
|       if (_channel!.type == 1) { |       if (_channel!.type == 1) { | ||||||
| @@ -87,8 +119,18 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |||||||
|       } |       } | ||||||
|  |  | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       final nty = context.read<NotificationProvider>(); |       _nty.skippableNotifyChannel = _channel!.id; | ||||||
|       nty.skippableNotifyChannel = _channel!.id; |       final ws = context.read<WebSocketProvider>(); | ||||||
|  |       if (_channel != null) { | ||||||
|  |         ws.conn?.sink.add( | ||||||
|  |           jsonEncode(WebSocketPackage( | ||||||
|  |               method: 'events.subscribe', | ||||||
|  |               endpoint: 'im', | ||||||
|  |               payload: { | ||||||
|  |                 'channel_id': _channel!.id, | ||||||
|  |               })), | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showErrorDialog(err); |       context.showErrorDialog(err); | ||||||
| @@ -187,11 +229,9 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |||||||
|     return a.createdAt.difference(b.createdAt).inMinutes <= 3; |     return a.createdAt.difference(b.createdAt).inMinutes <= 3; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   Future<void> _initializeChat() async { | ||||||
|   void initState() { |  | ||||||
|     super.initState(); |  | ||||||
|     _messageController = ChatMessageController(context); |  | ||||||
|     _fetchChannel().then((_) async { |     _fetchChannel().then((_) async { | ||||||
|  |       if (_currentMember == null) return; | ||||||
|       await _messageController.initialize(_channel!); |       await _messageController.initialize(_channel!); | ||||||
|  |  | ||||||
|       if (widget.extra != null) { |       if (widget.extra != null) { | ||||||
| @@ -213,9 +253,15 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |||||||
|         _fetchOngoingCall(), |         _fetchOngoingCall(), | ||||||
|       ]); |       ]); | ||||||
|     }); |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|     final ws = context.read<WebSocketProvider>(); |   @override | ||||||
|     _wsSubscription = ws.pk.stream.listen((event) { |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _messageController = ChatMessageController(context); | ||||||
|  |     _initializeChat(); | ||||||
|  |  | ||||||
|  |     _wsSubscription = _ws.pk.stream.listen((event) { | ||||||
|       switch (event.method) { |       switch (event.method) { | ||||||
|         case 'calls.new': |         case 'calls.new': | ||||||
|           final payload = SnChatCall.fromJson(event.payload!); |           final payload = SnChatCall.fromJson(event.payload!); | ||||||
| @@ -237,8 +283,18 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |||||||
|   void dispose() { |   void dispose() { | ||||||
|     _wsSubscription?.cancel(); |     _wsSubscription?.cancel(); | ||||||
|     _messageController.dispose(); |     _messageController.dispose(); | ||||||
|     final nty = context.read<NotificationProvider>(); |     _nty.skippableNotifyChannel = null; | ||||||
|     nty.skippableNotifyChannel = null; |     if (_channel != null) { | ||||||
|  |       _ws.conn?.sink.add( | ||||||
|  |         jsonEncode(WebSocketPackage( | ||||||
|  |           method: 'events.unsubscribe', | ||||||
|  |           endpoint: 'im', | ||||||
|  |           payload: { | ||||||
|  |             'channel_id': _channel!.id, | ||||||
|  |           }, | ||||||
|  |         )), | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|     super.dispose(); |     super.dispose(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -248,14 +304,25 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |||||||
|     final ud = context.read<UserDirectoryProvider>(); |     final ud = context.read<UserDirectoryProvider>(); | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         title: Text( |         title: Text( | ||||||
|           _channel?.type == 1 |           _channel?.type == 1 | ||||||
|               ? ud.getAccountFromCache(_otherMember?.accountId)?.nick ?? |               ? ud.getFromCache(_otherMember?.accountId)?.nick ?? _channel!.name | ||||||
|                   _channel!.name |  | ||||||
|               : _channel?.name ?? 'loading'.tr(), |               : _channel?.name ?? 'loading'.tr(), | ||||||
|         ), |         ), | ||||||
|         actions: [ |         actions: [ | ||||||
|  |           if (_currentMember != null) | ||||||
|  |             IconButton( | ||||||
|  |               onPressed: () { | ||||||
|  |                 setState(() => _isEncrypted = !_isEncrypted); | ||||||
|  |                 _inputGlobalKey.currentState?.setEncrypted(_isEncrypted); | ||||||
|  |               }, | ||||||
|  |               icon: _isEncrypted | ||||||
|  |                   ? const Icon(Symbols.lock) | ||||||
|  |                   : const Icon(Symbols.no_encryption), | ||||||
|  |             ), | ||||||
|  |           if (_currentMember != null) | ||||||
|             IconButton( |             IconButton( | ||||||
|               icon: _ongoingCall == null |               icon: _ongoingCall == null | ||||||
|                   ? const Icon(Symbols.call) |                   ? const Icon(Symbols.call) | ||||||
| @@ -289,7 +356,9 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |||||||
|         builder: (context, _) { |         builder: (context, _) { | ||||||
|           return Column( |           return Column( | ||||||
|             children: [ |             children: [ | ||||||
|               LoadingIndicator(isActive: _isBusy), |               LoadingIndicator( | ||||||
|  |                 isActive: _isBusy || _messageController.isAggressiveLoading, | ||||||
|  |               ), | ||||||
|               SingleChildScrollView( |               SingleChildScrollView( | ||||||
|                 physics: const NeverScrollableScrollPhysics(), |                 physics: const NeverScrollableScrollPhysics(), | ||||||
|                 child: MaterialBanner( |                 child: MaterialBanner( | ||||||
| @@ -312,11 +381,45 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |||||||
|               ).height(_ongoingCall != null ? 54 : 0, animate: true).animate( |               ).height(_ongoingCall != null ? 54 : 0, animate: true).animate( | ||||||
|                   const Duration(milliseconds: 300), |                   const Duration(milliseconds: 300), | ||||||
|                   Curves.fastLinearToSlowEaseIn), |                   Curves.fastLinearToSlowEaseIn), | ||||||
|               if (_messageController.isPending) |               if (_currentMember == null && !_isBusy) | ||||||
|  |                 Expanded( | ||||||
|  |                   child: Center( | ||||||
|  |                     child: Container( | ||||||
|  |                       constraints: const BoxConstraints(maxWidth: 280), | ||||||
|  |                       child: Column( | ||||||
|  |                         mainAxisSize: MainAxisSize.min, | ||||||
|  |                         children: [ | ||||||
|  |                           const Icon(Symbols.person_remove, size: 40, fill: 1), | ||||||
|  |                           const Gap(8), | ||||||
|  |                           Text('chatUnjoined'.tr(), textAlign: TextAlign.center) | ||||||
|  |                               .fontSize(16) | ||||||
|  |                               .bold(), | ||||||
|  |                           Text('chatUnjoinedDescription'.tr(), | ||||||
|  |                                   textAlign: TextAlign.center) | ||||||
|  |                               .fontSize(13), | ||||||
|  |                           if (_channel!.isPublic) | ||||||
|  |                             Text('chatUnjoinedPublicDescription'.tr(), | ||||||
|  |                                     textAlign: TextAlign.center) | ||||||
|  |                                 .fontSize(13) | ||||||
|  |                                 .padding(top: 8), | ||||||
|  |                           if (_channel!.isPublic) | ||||||
|  |                             TextButton( | ||||||
|  |                               style: ButtonStyle( | ||||||
|  |                                 visualDensity: VisualDensity.compact, | ||||||
|  |                               ), | ||||||
|  |                               onPressed: _isJoining ? null : _joinChannel, | ||||||
|  |                               child: Text('chatJoin').tr(), | ||||||
|  |                             ), | ||||||
|  |                         ], | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|  |                 ) | ||||||
|  |               else if (_messageController.isPending) | ||||||
|                 Expanded( |                 Expanded( | ||||||
|                   child: const CircularProgressIndicator().center(), |                   child: const CircularProgressIndicator().center(), | ||||||
|                 ), |                 ) | ||||||
|               if (!_messageController.isPending) |               else | ||||||
|                 Expanded( |                 Expanded( | ||||||
|                   child: InfiniteList( |                   child: InfiniteList( | ||||||
|                     reverse: true, |                     reverse: true, | ||||||
| @@ -367,7 +470,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |||||||
|                     }, |                     }, | ||||||
|                   ), |                   ), | ||||||
|                 ), |                 ), | ||||||
|               if (!_messageController.isPending) |               if (!_messageController.isPending && _currentMember != null) | ||||||
|                 Material( |                 Material( | ||||||
|                   elevation: 2, |                   elevation: 2, | ||||||
|                   child: Column( |                   child: Column( | ||||||
|   | |||||||
| @@ -1,4 +1,3 @@ | |||||||
| import 'package:dropdown_button2/dropdown_button2.dart'; |  | ||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter_expandable_fab/flutter_expandable_fab.dart'; | import 'package:flutter_expandable_fab/flutter_expandable_fab.dart'; | ||||||
| @@ -6,19 +5,28 @@ import 'package:gap/gap.dart'; | |||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:responsive_framework/responsive_framework.dart'; | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:surface/providers/config.dart'; | ||||||
| import 'package:surface/providers/post.dart'; | import 'package:surface/providers/post.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/providers/sn_realm.dart'; | import 'package:surface/providers/sn_realm.dart'; | ||||||
|  | import 'package:surface/providers/userinfo.dart'; | ||||||
| import 'package:surface/types/post.dart'; | import 'package:surface/types/post.dart'; | ||||||
| import 'package:surface/types/realm.dart'; | import 'package:surface/types/realm.dart'; | ||||||
| import 'package:surface/widgets/account/account_image.dart'; | import 'package:surface/widgets/account/account_image.dart'; | ||||||
| import 'package:surface/widgets/app_bar_leading.dart'; | import 'package:surface/widgets/app_bar_leading.dart'; | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
|  | import 'package:surface/widgets/feed/feed_news.dart'; | ||||||
|  | import 'package:surface/widgets/feed/feed_unknown.dart'; | ||||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  | import 'package:surface/widgets/post/fediverse_post_item.dart'; | ||||||
| import 'package:surface/widgets/post/post_item.dart'; | import 'package:surface/widgets/post/post_item.dart'; | ||||||
| import 'package:very_good_infinite_list/very_good_infinite_list.dart'; | import 'package:very_good_infinite_list/very_good_infinite_list.dart'; | ||||||
|  |  | ||||||
|  | const kPostChannels = ['Global', 'Friends', 'Following']; | ||||||
|  | const kPostChannelIcons = [Symbols.globe, Symbols.group, Symbols.subscriptions]; | ||||||
|  |  | ||||||
| const Map<String, IconData> kCategoryIcons = { | const Map<String, IconData> kCategoryIcons = { | ||||||
|   'technology': Symbols.tools_wrench, |   'technology': Symbols.tools_wrench, | ||||||
|   'gaming': Symbols.gamepad, |   'gaming': Symbols.gamepad, | ||||||
| @@ -39,17 +47,17 @@ class ExploreScreen extends StatefulWidget { | |||||||
|   State<ExploreScreen> createState() => _ExploreScreenState(); |   State<ExploreScreen> createState() => _ExploreScreenState(); | ||||||
| } | } | ||||||
|  |  | ||||||
| // You know what? I'm not going to make this a global variable. |  | ||||||
| // Cuz the global key make the selected category not update to child widget when the category is changed. |  | ||||||
| SnPostCategory? _selectedCategory; |  | ||||||
|  |  | ||||||
| class _ExploreScreenState extends State<ExploreScreen> | class _ExploreScreenState extends State<ExploreScreen> | ||||||
|     with SingleTickerProviderStateMixin { |     with TickerProviderStateMixin { | ||||||
|   late final TabController _tabController = |   late TabController _tabController = TabController( | ||||||
|       TabController(length: 4, vsync: this); |     length: kPostChannels.length, | ||||||
|  |     vsync: this, | ||||||
|  |   ); | ||||||
|  |  | ||||||
|   final _fabKey = GlobalKey<ExpandableFabState>(); |   final _fabKey = GlobalKey<ExpandableFabState>(); | ||||||
|   final _listKeys = List.generate(4, (_) => GlobalKey<_PostListWidgetState>()); |   final _listKey = GlobalKey<_PostListWidgetState>(); | ||||||
|  |  | ||||||
|  |   bool _showCategories = false; | ||||||
|  |  | ||||||
|   final List<SnPostCategory> _categories = List.empty(growable: true); |   final List<SnPostCategory> _categories = List.empty(growable: true); | ||||||
|  |  | ||||||
| @@ -69,14 +77,70 @@ class _ExploreScreenState extends State<ExploreScreen> | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void _clearFilter() { |   final List<SnRealm> _realms = List.empty(growable: true); | ||||||
|     _selectedCategory = null; |  | ||||||
|  |   Future<void> _fetchRealms() async { | ||||||
|  |     try { | ||||||
|  |       final ua = context.read<UserProvider>(); | ||||||
|  |       if (!ua.isAuthorized) return; | ||||||
|  |       final rels = context.read<SnRealmProvider>(); | ||||||
|  |       final out = await rels.listAvailableRealms(); | ||||||
|  |       setState(() { | ||||||
|  |         _realms.addAll(out); | ||||||
|  |       }); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |       rethrow; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void _toggleShowCategories() { | ||||||
|  |     _showCategories = !_showCategories; | ||||||
|  |     if (_showCategories) { | ||||||
|  |       _tabController = TabController(length: _categories.length, vsync: this); | ||||||
|  |       _listKey.currentState?.setCategory(_categories[_tabController.index]); | ||||||
|  |       _listKey.currentState?.refreshPosts(); | ||||||
|  |     } else { | ||||||
|  |       _tabController = TabController(length: kPostChannels.length, vsync: this); | ||||||
|  |       _listKey.currentState?.setCategory(null); | ||||||
|  |       _listKey.currentState?.refreshPosts(); | ||||||
|  |     } | ||||||
|  |     _tabListen(); | ||||||
|  |     setState(() {}); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void _tabListen() { | ||||||
|  |     _tabController.addListener(() { | ||||||
|  |       if (_tabController.indexIsChanging) { | ||||||
|  |         if (_showCategories) { | ||||||
|  |           _listKey.currentState?.setCategory(_categories[_tabController.index]); | ||||||
|  |           _listKey.currentState?.refreshPosts(); | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  |         switch (_tabController.index) { | ||||||
|  |           case 0: | ||||||
|  |           case 3: | ||||||
|  |             _listKey.currentState?.setChannel(null); | ||||||
|  |             break; | ||||||
|  |           case 1: | ||||||
|  |             _listKey.currentState?.setChannel('friends'); | ||||||
|  |             break; | ||||||
|  |           case 2: | ||||||
|  |             _listKey.currentState?.setChannel('following'); | ||||||
|  |             break; | ||||||
|  |         } | ||||||
|  |         _listKey.currentState?.refreshPosts(); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void initState() { |   void initState() { | ||||||
|     _fetchCategories(); |  | ||||||
|     super.initState(); |     super.initState(); | ||||||
|  |     _tabListen(); | ||||||
|  |     _fetchCategories(); | ||||||
|  |     _fetchRealms(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
| @@ -86,12 +150,14 @@ class _ExploreScreenState extends State<ExploreScreen> | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> refreshPosts() async { |   Future<void> refreshPosts() async { | ||||||
|     await _listKeys[_tabController.index].currentState?.refreshPosts(); |     await _listKey.currentState?.refreshPosts(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|  |     final cfg = context.watch<ConfigProvider>(); | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|       floatingActionButtonLocation: ExpandableFab.location, |       floatingActionButtonLocation: ExpandableFab.location, | ||||||
|       floatingActionButton: ExpandableFab( |       floatingActionButton: ExpandableFab( | ||||||
|         key: _fabKey, |         key: _fabKey, | ||||||
| @@ -111,7 +177,6 @@ class _ExploreScreenState extends State<ExploreScreen> | |||||||
|               Theme.of(context).floatingActionButtonTheme.foregroundColor, |               Theme.of(context).floatingActionButtonTheme.foregroundColor, | ||||||
|           backgroundColor: |           backgroundColor: | ||||||
|               Theme.of(context).floatingActionButtonTheme.backgroundColor, |               Theme.of(context).floatingActionButtonTheme.backgroundColor, | ||||||
|           shape: const CircleBorder(), |  | ||||||
|         ), |         ), | ||||||
|         closeButtonBuilder: DefaultFloatingActionButtonBuilder( |         closeButtonBuilder: DefaultFloatingActionButtonBuilder( | ||||||
|           child: const Icon(Symbols.close, size: 28), |           child: const Icon(Symbols.close, size: 28), | ||||||
| @@ -120,90 +185,39 @@ class _ExploreScreenState extends State<ExploreScreen> | |||||||
|               Theme.of(context).floatingActionButtonTheme.foregroundColor, |               Theme.of(context).floatingActionButtonTheme.foregroundColor, | ||||||
|           backgroundColor: |           backgroundColor: | ||||||
|               Theme.of(context).floatingActionButtonTheme.backgroundColor, |               Theme.of(context).floatingActionButtonTheme.backgroundColor, | ||||||
|           shape: const CircleBorder(), |  | ||||||
|         ), |         ), | ||||||
|         children: [ |         children: [ | ||||||
|           Row( |           Row( | ||||||
|             children: [ |             children: [ | ||||||
|               Text('writePostTypeStory').tr(), |               Text('writePost').tr(), | ||||||
|               const Gap(20), |               const Gap(20), | ||||||
|               FloatingActionButton( |               FloatingActionButton( | ||||||
|                 heroTag: null, |                 heroTag: null, | ||||||
|                 tooltip: 'writePostTypeStory'.tr(), |                 tooltip: 'writePost'.tr(), | ||||||
|                 onPressed: () { |                 onPressed: () { | ||||||
|                   GoRouter.of(context).pushNamed('postEditor', pathParameters: { |                   GoRouter.of(context).pushNamed('postEditor').then((value) { | ||||||
|                     'mode': 'stories', |  | ||||||
|                   }).then((value) { |  | ||||||
|                     if (value == true) { |                     if (value == true) { | ||||||
|                       refreshPosts(); |                       refreshPosts(); | ||||||
|                     } |                     } | ||||||
|                   }); |                   }); | ||||||
|                   _fabKey.currentState!.toggle(); |                   _fabKey.currentState!.toggle(); | ||||||
|                 }, |                 }, | ||||||
|                 child: const Icon(Symbols.post_rounded), |                 child: const Icon(Symbols.edit), | ||||||
|               ), |               ), | ||||||
|             ], |             ], | ||||||
|           ), |           ), | ||||||
|           Row( |           Row( | ||||||
|             children: [ |             children: [ | ||||||
|               Text('writePostTypeArticle').tr(), |               Text('postDraftBox').tr(), | ||||||
|               const Gap(20), |               const Gap(20), | ||||||
|               FloatingActionButton( |               FloatingActionButton( | ||||||
|                 heroTag: null, |                 heroTag: null, | ||||||
|                 tooltip: 'writePostTypeArticle'.tr(), |                 tooltip: 'postDraftBox'.tr(), | ||||||
|                 onPressed: () { |                 onPressed: () { | ||||||
|                   GoRouter.of(context).pushNamed('postEditor', pathParameters: { |                   GoRouter.of(context).pushNamed('postDraftBox'); | ||||||
|                     'mode': 'articles', |  | ||||||
|                   }).then((value) { |  | ||||||
|                     if (value == true) { |  | ||||||
|                       refreshPosts(); |  | ||||||
|                     } |  | ||||||
|                   }); |  | ||||||
|                   _fabKey.currentState!.toggle(); |                   _fabKey.currentState!.toggle(); | ||||||
|                 }, |                 }, | ||||||
|                 child: const Icon(Symbols.news), |                 child: const Icon(Symbols.box_edit), | ||||||
|               ), |  | ||||||
|             ], |  | ||||||
|           ), |  | ||||||
|           Row( |  | ||||||
|             children: [ |  | ||||||
|               Text('writePostTypeQuestion').tr(), |  | ||||||
|               const Gap(20), |  | ||||||
|               FloatingActionButton( |  | ||||||
|                 heroTag: null, |  | ||||||
|                 tooltip: 'writePostTypeQuestion'.tr(), |  | ||||||
|                 onPressed: () { |  | ||||||
|                   GoRouter.of(context).pushNamed('postEditor', pathParameters: { |  | ||||||
|                     'mode': 'questions', |  | ||||||
|                   }).then((value) { |  | ||||||
|                     if (value == true) { |  | ||||||
|                       refreshPosts(); |  | ||||||
|                     } |  | ||||||
|                   }); |  | ||||||
|                   _fabKey.currentState!.toggle(); |  | ||||||
|                 }, |  | ||||||
|                 child: const Icon(Symbols.question_answer), |  | ||||||
|               ), |  | ||||||
|             ], |  | ||||||
|           ), |  | ||||||
|           Row( |  | ||||||
|             children: [ |  | ||||||
|               Text('writePostTypeVideo').tr(), |  | ||||||
|               const Gap(20), |  | ||||||
|               FloatingActionButton( |  | ||||||
|                 heroTag: null, |  | ||||||
|                 tooltip: 'writePostTypeVideo'.tr(), |  | ||||||
|                 onPressed: () { |  | ||||||
|                   GoRouter.of(context).pushNamed('postEditor', pathParameters: { |  | ||||||
|                     'mode': 'videos', |  | ||||||
|                   }).then((value) { |  | ||||||
|                     if (value == true) { |  | ||||||
|                       refreshPosts(); |  | ||||||
|                     } |  | ||||||
|                   }); |  | ||||||
|                   _fabKey.currentState!.toggle(); |  | ||||||
|                 }, |  | ||||||
|                 child: const Icon(Symbols.video_call), |  | ||||||
|               ), |               ), | ||||||
|             ], |             ], | ||||||
|           ), |           ), | ||||||
| @@ -215,26 +229,91 @@ class _ExploreScreenState extends State<ExploreScreen> | |||||||
|             SliverOverlapAbsorber( |             SliverOverlapAbsorber( | ||||||
|               handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), |               handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), | ||||||
|               sliver: SliverAppBar( |               sliver: SliverAppBar( | ||||||
|                 leading: AutoAppBarLeading(), |                 leading: | ||||||
|                 title: Text('screenExplore').tr(), |                     ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE) | ||||||
|  |                         ? AutoAppBarLeading() | ||||||
|  |                         : null, | ||||||
|  |                 titleSpacing: 0, | ||||||
|  |                 title: Row( | ||||||
|  |                   children: [ | ||||||
|  |                     if (ResponsiveBreakpoints.of(context).largerThan(MOBILE)) | ||||||
|  |                       const Gap(8), | ||||||
|  |                     IconButton( | ||||||
|  |                       icon: const Icon(Symbols.shuffle), | ||||||
|  |                       onPressed: () { | ||||||
|  |                         GoRouter.of(context).pushNamed('postShuffle'); | ||||||
|  |                       }, | ||||||
|  |                     ), | ||||||
|  |                     const Gap(48), | ||||||
|  |                     Expanded( | ||||||
|  |                       child: Center( | ||||||
|  |                         child: IconButton( | ||||||
|  |                           padding: EdgeInsets.zero, | ||||||
|  |                           constraints: const BoxConstraints(), | ||||||
|  |                           visualDensity: VisualDensity.compact, | ||||||
|  |                           icon: _listKey.currentState?.realm != null | ||||||
|  |                               ? AccountImage( | ||||||
|  |                                   content: _listKey.currentState!.realm!.avatar, | ||||||
|  |                                   radius: 14, | ||||||
|  |                                 ) | ||||||
|  |                               : Image.asset( | ||||||
|  |                                   'assets/icon/icon-dark.png', | ||||||
|  |                                   width: 32, | ||||||
|  |                                   height: 32, | ||||||
|  |                                   color: Theme.of(context) | ||||||
|  |                                       .appBarTheme | ||||||
|  |                                       .foregroundColor, | ||||||
|  |                                 ), | ||||||
|  |                           onPressed: () { | ||||||
|  |                             showModalBottomSheet( | ||||||
|  |                               context: context, | ||||||
|  |                               builder: (context) => _PostListRealmPopup( | ||||||
|  |                                 realms: _realms, | ||||||
|  |                                 onUpdate: (realm) { | ||||||
|  |                                   _listKey.currentState?.setRealm(realm); | ||||||
|  |                                   _listKey.currentState?.refreshPosts(); | ||||||
|  |                                   Future.delayed( | ||||||
|  |                                       const Duration(milliseconds: 100), () { | ||||||
|  |                                     if (mounted) { | ||||||
|  |                                       setState(() {}); | ||||||
|  |                                     } | ||||||
|  |                                   }); | ||||||
|  |                                 }, | ||||||
|  |                                 onMixedFeedChanged: (flag) { | ||||||
|  |                                   _listKey.currentState?.setRealm(null); | ||||||
|  |                                   _listKey.currentState?.setCategory(null); | ||||||
|  |                                   if (_showCategories && flag) { | ||||||
|  |                                     _toggleShowCategories(); | ||||||
|  |                                   } | ||||||
|  |                                   _listKey.currentState?.refreshPosts(); | ||||||
|  |                                 }, | ||||||
|  |                               ), | ||||||
|  |                             ); | ||||||
|  |                           }, | ||||||
|  |                         ), | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                   ], | ||||||
|  |                 ), | ||||||
|                 floating: true, |                 floating: true, | ||||||
|                 snap: true, |                 snap: true, | ||||||
|                 actions: [ |                 actions: [ | ||||||
|                   IconButton( |                   IconButton( | ||||||
|                     icon: const Icon(Symbols.category), |                     icon: const Icon(Symbols.category), | ||||||
|                     onPressed: () { |                     style: _showCategories | ||||||
|                       showModalBottomSheet( |                         ? ButtonStyle( | ||||||
|                         context: context, |                             foregroundColor: WidgetStateProperty.all( | ||||||
|                         builder: (context) => _PostCategoryPickerPopup( |                               Theme.of(context).colorScheme.primary, | ||||||
|                           categories: _categories, |  | ||||||
|                           selected: _selectedCategory, |  | ||||||
|                             ), |                             ), | ||||||
|                       ).then((value) { |                             backgroundColor: MaterialStateProperty.all( | ||||||
|                         if (value != null && context.mounted) { |                               Theme.of(context).colorScheme.secondaryContainer, | ||||||
|                           _selectedCategory = value == false ? null : value; |                             ), | ||||||
|                           refreshPosts(); |                           ) | ||||||
|                         } |                         : null, | ||||||
|                       }); |                     onPressed: cfg.mixedFeed | ||||||
|  |                         ? null | ||||||
|  |                         : () { | ||||||
|  |                             _toggleShowCategories(); | ||||||
|                           }, |                           }, | ||||||
|                   ), |                   ), | ||||||
|                   IconButton( |                   IconButton( | ||||||
| @@ -245,90 +324,72 @@ class _ExploreScreenState extends State<ExploreScreen> | |||||||
|                   ), |                   ), | ||||||
|                   const Gap(8), |                   const Gap(8), | ||||||
|                 ], |                 ], | ||||||
|                 bottom: TabBar( |                 bottom: cfg.mixedFeed | ||||||
|  |                     ? null | ||||||
|  |                     : TabBar( | ||||||
|  |                         isScrollable: _showCategories, | ||||||
|                         controller: _tabController, |                         controller: _tabController, | ||||||
|                   tabs: [ |                         tabs: _showCategories | ||||||
|  |                             ? [ | ||||||
|  |                                 for (final category in _categories) | ||||||
|                                   Tab( |                                   Tab( | ||||||
|                                     child: Row( |                                     child: Row( | ||||||
|                                       mainAxisSize: MainAxisSize.min, |                                       mainAxisSize: MainAxisSize.min, | ||||||
|                         crossAxisAlignment: CrossAxisAlignment.center, |                                       crossAxisAlignment: | ||||||
|  |                                           CrossAxisAlignment.center, | ||||||
|                                       children: [ |                                       children: [ | ||||||
|                           Icon(Symbols.globe, |                                         Icon( | ||||||
|                               size: 20, |                                           kCategoryIcons[category.alias] ?? | ||||||
|  |                                               Symbols.question_mark, | ||||||
|                                           color: Theme.of(context) |                                           color: Theme.of(context) | ||||||
|                                               .appBarTheme |                                               .appBarTheme | ||||||
|                                   .foregroundColor), |                                               .foregroundColor!, | ||||||
|  |                                         ), | ||||||
|                                         const Gap(8), |                                         const Gap(8), | ||||||
|                                         Flexible( |                                         Flexible( | ||||||
|                                           child: Text( |                                           child: Text( | ||||||
|                               'postChannelGlobal', |                                             'postCategory${category.alias.capitalize()}' | ||||||
|  |                                                     .trExists() | ||||||
|  |                                                 ? 'postCategory${category.alias.capitalize()}' | ||||||
|  |                                                     .tr() | ||||||
|  |                                                 : category.name, | ||||||
|                                             maxLines: 1, |                                             maxLines: 1, | ||||||
|                             ).tr().textColor( |                                           ).textColor( | ||||||
|                                 Theme.of(context).appBarTheme.foregroundColor), |                                             Theme.of(context) | ||||||
|  |                                                 .appBarTheme | ||||||
|  |                                                 .foregroundColor!, | ||||||
|  |                                           ), | ||||||
|                                         ), |                                         ), | ||||||
|                                       ], |                                       ], | ||||||
|                                     ), |                                     ), | ||||||
|                                   ), |                                   ), | ||||||
|  |                               ] | ||||||
|  |                             : [ | ||||||
|  |                                 for (final channel in kPostChannels) | ||||||
|                                   Tab( |                                   Tab( | ||||||
|                                     child: Row( |                                     child: Row( | ||||||
|                                       mainAxisSize: MainAxisSize.min, |                                       mainAxisSize: MainAxisSize.min, | ||||||
|                         crossAxisAlignment: CrossAxisAlignment.center, |                                       crossAxisAlignment: | ||||||
|  |                                           CrossAxisAlignment.center, | ||||||
|                                       children: [ |                                       children: [ | ||||||
|                           Icon(Symbols.group, |                                         Icon( | ||||||
|  |                                           kPostChannelIcons[ | ||||||
|  |                                               kPostChannels.indexOf(channel)], | ||||||
|                                           size: 20, |                                           size: 20, | ||||||
|                                           color: Theme.of(context) |                                           color: Theme.of(context) | ||||||
|                                               .appBarTheme |                                               .appBarTheme | ||||||
|                                   .foregroundColor), |                                               .foregroundColor, | ||||||
|  |                                         ), | ||||||
|                                         const Gap(8), |                                         const Gap(8), | ||||||
|                                         Flexible( |                                         Flexible( | ||||||
|                                           child: Text( |                                           child: Text( | ||||||
|                               'postChannelFriends', |                                             'postChannel$channel', | ||||||
|                                             maxLines: 1, |                                             maxLines: 1, | ||||||
|                               textAlign: TextAlign.center, |  | ||||||
|                                           ).tr().textColor( |                                           ).tr().textColor( | ||||||
|                                 Theme.of(context).appBarTheme.foregroundColor), |                                                 Theme.of(context) | ||||||
|                           ), |  | ||||||
|                         ], |  | ||||||
|                       ), |  | ||||||
|                     ), |  | ||||||
|                     Tab( |  | ||||||
|                       child: Row( |  | ||||||
|                         mainAxisSize: MainAxisSize.min, |  | ||||||
|                         crossAxisAlignment: CrossAxisAlignment.center, |  | ||||||
|                         children: [ |  | ||||||
|                           Icon(Symbols.subscriptions, |  | ||||||
|                               size: 20, |  | ||||||
|                               color: Theme.of(context) |  | ||||||
|                                                     .appBarTheme |                                                     .appBarTheme | ||||||
|                                   .foregroundColor), |                                                     .foregroundColor, | ||||||
|                           const Gap(8), |  | ||||||
|                           Flexible( |  | ||||||
|                             child: Text( |  | ||||||
|                               'postChannelFollowing', |  | ||||||
|                               maxLines: 1, |  | ||||||
|                             ).tr().textColor( |  | ||||||
|                                 Theme.of(context).appBarTheme.foregroundColor), |  | ||||||
|                                               ), |                                               ), | ||||||
|                         ], |  | ||||||
|                       ), |  | ||||||
|                     ), |  | ||||||
|                     Tab( |  | ||||||
|                       child: Row( |  | ||||||
|                         mainAxisSize: MainAxisSize.min, |  | ||||||
|                         crossAxisAlignment: CrossAxisAlignment.center, |  | ||||||
|                         children: [ |  | ||||||
|                           Icon(Symbols.workspaces, |  | ||||||
|                               size: 20, |  | ||||||
|                               color: Theme.of(context) |  | ||||||
|                                   .appBarTheme |  | ||||||
|                                   .foregroundColor), |  | ||||||
|                           const Gap(8), |  | ||||||
|                           Flexible( |  | ||||||
|                             child: Text( |  | ||||||
|                               'postChannelRealm', |  | ||||||
|                               maxLines: 1, |  | ||||||
|                             ).tr().textColor( |  | ||||||
|                                 Theme.of(context).appBarTheme.foregroundColor), |  | ||||||
|                                         ), |                                         ), | ||||||
|                                       ], |                                       ], | ||||||
|                                     ), |                                     ), | ||||||
| @@ -339,29 +400,8 @@ class _ExploreScreenState extends State<ExploreScreen> | |||||||
|             ), |             ), | ||||||
|           ]; |           ]; | ||||||
|         }, |         }, | ||||||
|         body: TabBarView( |         body: _PostListWidget( | ||||||
|           controller: _tabController, |           key: _listKey, | ||||||
|           children: [ |  | ||||||
|             _PostListWidget( |  | ||||||
|               key: _listKeys[0], |  | ||||||
|               onClearFilter: _clearFilter, |  | ||||||
|             ), |  | ||||||
|             _PostListWidget( |  | ||||||
|               key: _listKeys[1], |  | ||||||
|               channel: 'friends', |  | ||||||
|               onClearFilter: _clearFilter, |  | ||||||
|             ), |  | ||||||
|             _PostListWidget( |  | ||||||
|               key: _listKeys[2], |  | ||||||
|               channel: 'following', |  | ||||||
|               onClearFilter: _clearFilter, |  | ||||||
|             ), |  | ||||||
|             _PostListWidget( |  | ||||||
|               key: _listKeys[3], |  | ||||||
|               withRealm: true, |  | ||||||
|               onClearFilter: _clearFilter, |  | ||||||
|             ), |  | ||||||
|           ], |  | ||||||
|         ), |         ), | ||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
| @@ -369,15 +409,7 @@ class _ExploreScreenState extends State<ExploreScreen> | |||||||
| } | } | ||||||
|  |  | ||||||
| class _PostListWidget extends StatefulWidget { | class _PostListWidget extends StatefulWidget { | ||||||
|   final String? channel; |   const _PostListWidget({super.key}); | ||||||
|   final bool withRealm; |  | ||||||
|   final Function onClearFilter; |  | ||||||
|  |  | ||||||
|   const _PostListWidget( |  | ||||||
|       {super.key, |  | ||||||
|       this.channel, |  | ||||||
|       this.withRealm = false, |  | ||||||
|       required this.onClearFilter}); |  | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   State<_PostListWidget> createState() => _PostListWidgetState(); |   State<_PostListWidget> createState() => _PostListWidgetState(); | ||||||
| @@ -386,62 +418,98 @@ class _PostListWidget extends StatefulWidget { | |||||||
| class _PostListWidgetState extends State<_PostListWidget> { | class _PostListWidgetState extends State<_PostListWidget> { | ||||||
|   bool _isBusy = false; |   bool _isBusy = false; | ||||||
|  |  | ||||||
|   final List<SnPost> _posts = List.empty(growable: true); |   SnRealm? get realm => _selectedRealm; | ||||||
|   final List<SnRealm> _realms = List.empty(growable: true); |  | ||||||
|  |   final List<SnFeedEntry> _feed = List.empty(growable: true); | ||||||
|   SnRealm? _selectedRealm; |   SnRealm? _selectedRealm; | ||||||
|   int? _postCount; |   String? _selectedChannel; | ||||||
|  |   SnPostCategory? _selectedCategory; | ||||||
|   Future<void> _fetchRealms() async { |   bool _hasLoadedAll = false; | ||||||
|     try { |  | ||||||
|       final rels = context.read<SnRealmProvider>(); |  | ||||||
|       final out = await rels.listAvailableRealms(); |  | ||||||
|       setState(() { |  | ||||||
|         _realms.addAll(out); |  | ||||||
|         _selectedRealm = out.firstOrNull; |  | ||||||
|       }); |  | ||||||
|     } catch (err) { |  | ||||||
|       if (!mounted) return; |  | ||||||
|       context.showErrorDialog(err); |  | ||||||
|       rethrow; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|  |   // Called when using regular feed | ||||||
|   Future<void> _fetchPosts() async { |   Future<void> _fetchPosts() async { | ||||||
|     if (_postCount != null && _posts.length >= _postCount!) return; |     if (_hasLoadedAll) return; | ||||||
|  |  | ||||||
|     setState(() => _isBusy = true); |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
|     final pt = context.read<SnPostContentProvider>(); |     final pt = context.read<SnPostContentProvider>(); | ||||||
|     final result = await pt.listPosts( |     final result = await pt.listPosts( | ||||||
|       take: 10, |       take: 10, | ||||||
|       offset: _posts.length, |       offset: _feed.length, | ||||||
|       categories: _selectedCategory != null ? [_selectedCategory!.alias] : null, |       categories: _selectedCategory != null ? [_selectedCategory!.alias] : null, | ||||||
|       channel: widget.channel, |       channel: _selectedChannel, | ||||||
|       realm: _selectedRealm?.alias, |       realm: _selectedRealm?.alias, | ||||||
|     ); |     ); | ||||||
|     final out = result.$1; |     final out = result.$1; | ||||||
|  |  | ||||||
|     if (!mounted) return; |     if (!mounted) return; | ||||||
|  |  | ||||||
|     _postCount = result.$2; |     final postCount = result.$2; | ||||||
|     _posts.addAll(out); |     _feed.addAll( | ||||||
|  |       out.map((ele) => SnFeedEntry( | ||||||
|  |           type: 'interactive.post', | ||||||
|  |           data: ele.toJson(), | ||||||
|  |           createdAt: ele.createdAt)), | ||||||
|  |     ); | ||||||
|  |     _hasLoadedAll = _feed.length >= postCount; | ||||||
|  |  | ||||||
|     if (mounted) setState(() => _isBusy = false); |     if (mounted) setState(() => _isBusy = false); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   // Called when mixed feed is enabled | ||||||
|  |   Future<void> _fetchFeed() async { | ||||||
|  |     if (_hasLoadedAll) return; | ||||||
|  |  | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
|  |     final pt = context.read<SnPostContentProvider>(); | ||||||
|  |     final result = await pt.getFeed( | ||||||
|  |       cursor: _feed | ||||||
|  |           .where((ele) => !['reader.news'].contains(ele.type)) | ||||||
|  |           .lastOrNull | ||||||
|  |           ?.createdAt, | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (!mounted) return; | ||||||
|  |  | ||||||
|  |     _feed.addAll(result); | ||||||
|  |     _hasLoadedAll = result.isEmpty; | ||||||
|  |  | ||||||
|  |     if (mounted) setState(() => _isBusy = false); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void setChannel(String? channel) { | ||||||
|  |     _selectedChannel = channel; | ||||||
|  |     setState(() {}); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void setRealm(SnRealm? realm) { | ||||||
|  |     _selectedRealm = realm; | ||||||
|  |     setState(() {}); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void setCategory(SnPostCategory? category) { | ||||||
|  |     _selectedCategory = category; | ||||||
|  |     setState(() {}); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   Future<void> refreshPosts() { |   Future<void> refreshPosts() { | ||||||
|     _postCount = null; |     _hasLoadedAll = false; | ||||||
|     _posts.clear(); |     _feed.clear(); | ||||||
|  |     final cfg = context.read<ConfigProvider>(); | ||||||
|  |     if (cfg.mixedFeed) { | ||||||
|  |       return _fetchFeed(); | ||||||
|  |     } else { | ||||||
|       return _fetchPosts(); |       return _fetchPosts(); | ||||||
|     } |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void initState() { |   void initState() { | ||||||
|     super.initState(); |     super.initState(); | ||||||
|     if (widget.withRealm) { |     final cfg = context.read<ConfigProvider>(); | ||||||
|       _fetchRealms().then((_) { |     if (cfg.mixedFeed) { | ||||||
|         _fetchPosts(); |       _fetchFeed(); | ||||||
|       }); |  | ||||||
|     } else { |     } else { | ||||||
|       _fetchPosts(); |       _fetchPosts(); | ||||||
|     } |     } | ||||||
| @@ -449,176 +517,129 @@ class _PostListWidgetState extends State<_PostListWidget> { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return Column( |     final cfg = context.watch<ConfigProvider>(); | ||||||
|       children: [ |     return MediaQuery.removePadding( | ||||||
|         if (_selectedCategory != null) |  | ||||||
|           MaterialBanner( |  | ||||||
|             content: Text( |  | ||||||
|               'postFilterWithCategory'.tr(args: [ |  | ||||||
|                 'postCategory${_selectedCategory!.alias.capitalize()}'.trExists() |  | ||||||
|                     ? 'postCategory${_selectedCategory!.alias.capitalize()}' |  | ||||||
|                         .tr() |  | ||||||
|                     : _selectedCategory!.name, |  | ||||||
|               ]), |  | ||||||
|             ), |  | ||||||
|             leading: Icon(kCategoryIcons[_selectedCategory!.alias] ?? |  | ||||||
|                 Symbols.question_mark), |  | ||||||
|             actions: [ |  | ||||||
|               IconButton( |  | ||||||
|                 icon: const Icon(Symbols.clear), |  | ||||||
|                 onPressed: () { |  | ||||||
|                   widget.onClearFilter.call(); |  | ||||||
|                   refreshPosts(); |  | ||||||
|                 }, |  | ||||||
|               ), |  | ||||||
|             ], |  | ||||||
|             padding: const EdgeInsets.only(left: 20, right: 4), |  | ||||||
|           ), |  | ||||||
|         if (widget.withRealm) |  | ||||||
|           DropdownButtonHideUnderline( |  | ||||||
|             child: DropdownButton2<SnRealm>( |  | ||||||
|               isExpanded: true, |  | ||||||
|               items: _realms |  | ||||||
|                   .map( |  | ||||||
|                     (ele) => DropdownMenuItem<SnRealm>( |  | ||||||
|                       value: ele, |  | ||||||
|                       child: Row( |  | ||||||
|                         children: [ |  | ||||||
|                           AccountImage( |  | ||||||
|                             content: ele.avatar, |  | ||||||
|                             fallbackWidget: const Icon(Symbols.group, size: 16), |  | ||||||
|                             radius: 14, |  | ||||||
|                           ), |  | ||||||
|                           const Gap(8), |  | ||||||
|                           Text( |  | ||||||
|                             ele.name, |  | ||||||
|                             style: Theme.of(context).textTheme.bodyMedium, |  | ||||||
|                           ), |  | ||||||
|                         ], |  | ||||||
|                       ), |  | ||||||
|                     ), |  | ||||||
|                   ) |  | ||||||
|                   .toList(), |  | ||||||
|               value: _selectedRealm, |  | ||||||
|               onChanged: (SnRealm? value) { |  | ||||||
|                 setState(() => _selectedRealm = value); |  | ||||||
|                 refreshPosts(); |  | ||||||
|               }, |  | ||||||
|               buttonStyleData: const ButtonStyleData( |  | ||||||
|                 padding: EdgeInsets.only(left: 4, right: 12), |  | ||||||
|               ), |  | ||||||
|               menuItemStyleData: const MenuItemStyleData( |  | ||||||
|                 height: 48, |  | ||||||
|               ), |  | ||||||
|             ), |  | ||||||
|           ), |  | ||||||
|         if (widget.withRealm) const Divider(height: 1), |  | ||||||
|         Expanded( |  | ||||||
|           child: MediaQuery.removePadding( |  | ||||||
|       context: context, |       context: context, | ||||||
|       removeTop: true, |       removeTop: true, | ||||||
|       child: RefreshIndicator( |       child: RefreshIndicator( | ||||||
|         displacement: 40 + MediaQuery.of(context).padding.top, |         displacement: 40 + MediaQuery.of(context).padding.top, | ||||||
|         onRefresh: () => refreshPosts(), |         onRefresh: () => refreshPosts(), | ||||||
|         child: InfiniteList( |         child: InfiniteList( | ||||||
|                 itemCount: _posts.length, |           padding: EdgeInsets.only(top: 8), | ||||||
|  |           itemCount: _feed.length, | ||||||
|           isLoading: _isBusy, |           isLoading: _isBusy, | ||||||
|           centerLoading: true, |           centerLoading: true, | ||||||
|                 hasReachedMax: |           hasReachedMax: _hasLoadedAll, | ||||||
|                     _postCount != null && _posts.length >= _postCount!, |           onFetchData: cfg.mixedFeed ? _fetchFeed : _fetchPosts, | ||||||
|                 onFetchData: _fetchPosts, |  | ||||||
|           itemBuilder: (context, idx) { |           itemBuilder: (context, idx) { | ||||||
|  |             final ele = _feed[idx]; | ||||||
|  |             switch (ele.type) { | ||||||
|  |               case 'interactive.post': | ||||||
|                 return OpenablePostItem( |                 return OpenablePostItem( | ||||||
|                     data: _posts[idx], |                   useReplace: true, | ||||||
|  |                   data: SnPost.fromJson(ele.data), | ||||||
|                   maxWidth: 640, |                   maxWidth: 640, | ||||||
|                   onChanged: (data) { |                   onChanged: (data) { | ||||||
|                       setState(() => _posts[idx] = data); |                     setState(() { | ||||||
|  |                       _feed[idx] = _feed[idx].copyWith(data: data.toJson()); | ||||||
|  |                     }); | ||||||
|                   }, |                   }, | ||||||
|                   onDeleted: () { |                   onDeleted: () { | ||||||
|                     refreshPosts(); |                     refreshPosts(); | ||||||
|                   }, |                   }, | ||||||
|                 ); |                 ); | ||||||
|  |               case 'fediverse.post': | ||||||
|  |                 return FediversePostWidget( | ||||||
|  |                   data: SnFediversePost.fromJson(ele.data), | ||||||
|  |                   maxWidth: 640, | ||||||
|  |                 ); | ||||||
|  |               case 'reader.news': | ||||||
|  |                 return Center( | ||||||
|  |                   child: Container( | ||||||
|  |                     constraints: BoxConstraints(maxWidth: 640), | ||||||
|  |                     child: NewsFeedEntry(data: ele), | ||||||
|  |                   ), | ||||||
|  |                 ); | ||||||
|  |               default: | ||||||
|  |                 return Container( | ||||||
|  |                   constraints: BoxConstraints(maxWidth: 640), | ||||||
|  |                   child: FeedUnknownEntry(data: ele), | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|           }, |           }, | ||||||
|                 separatorBuilder: (_, __) => const Gap(8), |           separatorBuilder: (_, __) => const Divider().padding(vertical: 2), | ||||||
|         ), |         ), | ||||||
|       ), |       ), | ||||||
|           ).padding(top: 8), |  | ||||||
|         ), |  | ||||||
|       ], |  | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| class _PostCategoryPickerPopup extends StatelessWidget { | class _PostListRealmPopup extends StatelessWidget { | ||||||
|   final List<SnPostCategory> categories; |   final List<SnRealm>? realms; | ||||||
|   final SnPostCategory? selected; |   final Function(SnRealm?) onUpdate; | ||||||
|  |   final Function(bool) onMixedFeedChanged; | ||||||
|  |  | ||||||
|   const _PostCategoryPickerPopup({required this.categories, this.selected}); |   const _PostListRealmPopup({ | ||||||
|  |     required this.realms, | ||||||
|  |     required this.onUpdate, | ||||||
|  |     required this.onMixedFeedChanged, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|  |     final cfg = context.watch<ConfigProvider>(); | ||||||
|  |  | ||||||
|     return Column( |     return Column( | ||||||
|       crossAxisAlignment: CrossAxisAlignment.start, |       crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|       children: [ |       children: [ | ||||||
|         Row( |         Row( | ||||||
|           crossAxisAlignment: CrossAxisAlignment.center, |           crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|           children: [ |           children: [ | ||||||
|             const Icon(Symbols.category, size: 24), |             const Icon(Symbols.tune, size: 24), | ||||||
|             const Gap(16), |             const Gap(16), | ||||||
|             Text('postCategory') |             Text('filterFeed', style: Theme.of(context).textTheme.titleLarge) | ||||||
|                 .tr() |                 .tr(), | ||||||
|                 .textStyle(Theme.of(context).textTheme.titleLarge!), |  | ||||||
|           ], |           ], | ||||||
|         ).padding(horizontal: 20, top: 16, bottom: 12), |         ).padding(horizontal: 20, top: 16, bottom: 12), | ||||||
|  |         SwitchListTile( | ||||||
|  |           secondary: const Icon(Symbols.merge_type), | ||||||
|  |           contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |           title: Text('mixedFeed').tr(), | ||||||
|  |           subtitle: Text('mixedFeedDescription').tr(), | ||||||
|  |           value: cfg.mixedFeed, | ||||||
|  |           onChanged: (value) { | ||||||
|  |             cfg.mixedFeed = value; | ||||||
|  |             onMixedFeedChanged.call(value); | ||||||
|  |           }, | ||||||
|  |         ), | ||||||
|  |         if (!cfg.mixedFeed) | ||||||
|           ListTile( |           ListTile( | ||||||
|           leading: const Icon(Symbols.clear), |             leading: const Icon(Symbols.close), | ||||||
|           title: Text('postFilterReset').tr(), |             title: Text('postInGlobal').tr(), | ||||||
|           subtitle: Text('postFilterResetDescription').tr(), |             subtitle: Text('postViewInGlobalDescription').tr(), | ||||||
|           contentPadding: const EdgeInsets.symmetric(horizontal: 20), |             contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|             onTap: () { |             onTap: () { | ||||||
|             Navigator.pop(context, false); |               onUpdate.call(null); | ||||||
|  |               Navigator.pop(context); | ||||||
|             }, |             }, | ||||||
|           ), |           ), | ||||||
|         const Divider(height: 1), |         if (!cfg.mixedFeed) const Divider(height: 1), | ||||||
|  |         if (!cfg.mixedFeed) | ||||||
|           Expanded( |           Expanded( | ||||||
|           child: GridView.count( |             child: ListView.builder( | ||||||
|             crossAxisCount: 4, |               itemCount: realms?.length ?? 0, | ||||||
|             shrinkWrap: true, |               itemBuilder: (context, idx) { | ||||||
|             physics: const NeverScrollableScrollPhysics(), |                 final realm = realms![idx]; | ||||||
|             childAspectRatio: 1, |                 return ListTile( | ||||||
|             children: categories |                   title: Text(realm.name), | ||||||
|                 .map( |                   subtitle: Text('@${realm.alias}'), | ||||||
|                   (ele) => InkWell( |                   leading: AccountImage(content: realm.avatar, radius: 18), | ||||||
|                   onTap: () { |                   onTap: () { | ||||||
|                       _selectedCategory = ele; |                     onUpdate.call(realm); | ||||||
|                       Navigator.pop(context, ele); |                     Navigator.pop(context); | ||||||
|  |                   }, | ||||||
|  |                 ); | ||||||
|               }, |               }, | ||||||
|                     child: Column( |  | ||||||
|                       crossAxisAlignment: CrossAxisAlignment.center, |  | ||||||
|                       mainAxisAlignment: MainAxisAlignment.center, |  | ||||||
|                       mainAxisSize: MainAxisSize.min, |  | ||||||
|                       children: [ |  | ||||||
|                         Icon( |  | ||||||
|                           kCategoryIcons[ele.alias] ?? Symbols.question_mark, |  | ||||||
|                           color: selected == ele |  | ||||||
|                               ? Theme.of(context).colorScheme.primary |  | ||||||
|                               : null, |  | ||||||
|                         ), |  | ||||||
|                         const Gap(4), |  | ||||||
|                         Text( |  | ||||||
|                           'postCategory${ele.alias.capitalize()}'.trExists() |  | ||||||
|                               ? 'postCategory${ele.alias.capitalize()}'.tr() |  | ||||||
|                               : ele.name, |  | ||||||
|                         ) |  | ||||||
|                             .textStyle(Theme.of(context).textTheme.titleMedium!) |  | ||||||
|                             .textColor(selected == ele |  | ||||||
|                                 ? Theme.of(context).colorScheme.primary |  | ||||||
|                                 : null), |  | ||||||
|                       ], |  | ||||||
|                     ), |  | ||||||
|                   ), |  | ||||||
|                 ) |  | ||||||
|                 .toList(), |  | ||||||
|             ), |             ), | ||||||
|           ), |           ), | ||||||
|       ], |       ], | ||||||
|   | |||||||
| @@ -10,7 +10,6 @@ import 'package:surface/providers/userinfo.dart'; | |||||||
| import 'package:surface/types/account.dart'; | import 'package:surface/types/account.dart'; | ||||||
| import 'package:surface/widgets/account/account_image.dart'; | import 'package:surface/widgets/account/account_image.dart'; | ||||||
| import 'package:surface/widgets/account/account_select.dart'; | import 'package:surface/widgets/account/account_select.dart'; | ||||||
| import 'package:surface/widgets/app_bar_leading.dart'; |  | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
| import 'package:surface/widgets/loading_indicator.dart'; | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
| @@ -47,8 +46,7 @@ class _FriendScreenState extends State<FriendScreen> { | |||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final resp = await sn.client.get('/cgi/id/users/me/relations?status=1'); |       final resp = await sn.client.get('/cgi/id/users/me/relations?status=1'); | ||||||
|       _relations = List<SnRelationship>.from( |       _relations = List<SnRelationship>.from( | ||||||
|         resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [], |           resp.data?.map((e) => SnRelationship.fromJson(e)) ?? []); | ||||||
|       ); |  | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showErrorDialog(err); |       context.showErrorDialog(err); | ||||||
| @@ -67,8 +65,7 @@ class _FriendScreenState extends State<FriendScreen> { | |||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final resp = await sn.client.get('/cgi/id/users/me/relations?status=0,3'); |       final resp = await sn.client.get('/cgi/id/users/me/relations?status=0,3'); | ||||||
|       _requests = List<SnRelationship>.from( |       _requests = List<SnRelationship>.from( | ||||||
|         resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [], |           resp.data?.map((e) => SnRelationship.fromJson(e)) ?? []); | ||||||
|       ); |  | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showErrorDialog(err); |       context.showErrorDialog(err); | ||||||
| @@ -87,8 +84,7 @@ class _FriendScreenState extends State<FriendScreen> { | |||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final resp = await sn.client.get('/cgi/id/users/me/relations?status=2'); |       final resp = await sn.client.get('/cgi/id/users/me/relations?status=2'); | ||||||
|       _blocks = List<SnRelationship>.from( |       _blocks = List<SnRelationship>.from( | ||||||
|         resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [], |           resp.data?.map((e) => SnRelationship.fromJson(e)) ?? []); | ||||||
|       ); |  | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showErrorDialog(err); |       context.showErrorDialog(err); | ||||||
| @@ -105,10 +101,7 @@ class _FriendScreenState extends State<FriendScreen> { | |||||||
|     try { |     try { | ||||||
|       final rel = context.read<SnRelationshipProvider>(); |       final rel = context.read<SnRelationshipProvider>(); | ||||||
|       await rel.updateRelationship( |       await rel.updateRelationship( | ||||||
|         relation.relatedId, |           relation.relatedId, dstStatus, relation.permNodes); | ||||||
|         dstStatus, |  | ||||||
|         relation.permNodes, |  | ||||||
|       ); |  | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       _fetchRelations(); |       _fetchRelations(); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
| @@ -122,9 +115,8 @@ class _FriendScreenState extends State<FriendScreen> { | |||||||
|   Future<void> _deleteRelation(SnRelationship relation) async { |   Future<void> _deleteRelation(SnRelationship relation) async { | ||||||
|     final confirm = await context.showConfirmDialog( |     final confirm = await context.showConfirmDialog( | ||||||
|       'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]), |       'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]), | ||||||
|       'friendDeleteDescription'.tr(args: [ |       'friendDeleteDescription' | ||||||
|         relation.related?.nick ?? 'unknown'.tr(), |           .tr(args: [relation.related?.nick ?? 'unknown'.tr()]), | ||||||
|       ]), |  | ||||||
|     ); |     ); | ||||||
|     if (!confirm) return; |     if (!confirm) return; | ||||||
|     if (!mounted) return; |     if (!mounted) return; | ||||||
| @@ -147,8 +139,10 @@ class _FriendScreenState extends State<FriendScreen> { | |||||||
|   void _showRequests() { |   void _showRequests() { | ||||||
|     showModalBottomSheet( |     showModalBottomSheet( | ||||||
|             context: context, |             context: context, | ||||||
|       builder: (context) => _FriendshipListWidget(relations: _requests), |             builder: (context) => _FriendshipListWidget(relations: _requests)) | ||||||
|     ).then((value) { |         .then(( | ||||||
|  |       value, | ||||||
|  |     ) { | ||||||
|       if (value != null) { |       if (value != null) { | ||||||
|         _fetchRequests(); |         _fetchRequests(); | ||||||
|         _fetchRelations(); |         _fetchRelations(); | ||||||
| @@ -159,8 +153,9 @@ class _FriendScreenState extends State<FriendScreen> { | |||||||
|   void _showBlocks() { |   void _showBlocks() { | ||||||
|     showModalBottomSheet( |     showModalBottomSheet( | ||||||
|         context: context, |         context: context, | ||||||
|       builder: (context) => _FriendshipListWidget(relations: _blocks), |         builder: (context) => _FriendshipListWidget(relations: _blocks)).then(( | ||||||
|     ).then((value) { |       value, | ||||||
|  |     ) { | ||||||
|       if (value != null) { |       if (value != null) { | ||||||
|         _fetchBlocks(); |         _fetchBlocks(); | ||||||
|         _fetchRelations(); |         _fetchRelations(); | ||||||
| @@ -173,9 +168,8 @@ class _FriendScreenState extends State<FriendScreen> { | |||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       await sn.client.post('/cgi/id/users/me/relations', data: { |       await sn.client | ||||||
|         'related': user.name, |           .post('/cgi/id/users/me/relations', data: {'related': user.name}); | ||||||
|       }); |  | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showSnackbar('friendRequestSent'.tr()); |       context.showSnackbar('friendRequestSent'.tr()); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
| @@ -201,18 +195,16 @@ class _FriendScreenState extends State<FriendScreen> { | |||||||
|     if (!ua.isAuthorized) { |     if (!ua.isAuthorized) { | ||||||
|       return AppScaffold( |       return AppScaffold( | ||||||
|         appBar: AppBar( |         appBar: AppBar( | ||||||
|           leading: AutoAppBarLeading(), |           leading: PageBackButton(), | ||||||
|           title: Text('screenFriend').tr(), |           title: Text('screenFriend').tr(), | ||||||
|         ), |         ), | ||||||
|         body: Center( |         body: Center(child: UnauthorizedHint()), | ||||||
|           child: UnauthorizedHint(), |  | ||||||
|         ), |  | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         leading: AutoAppBarLeading(), |         leading: PageBackButton(), | ||||||
|         title: Text('screenFriend').tr(), |         title: Text('screenFriend').tr(), | ||||||
|       ), |       ), | ||||||
|       floatingActionButton: FloatingActionButton( |       floatingActionButton: FloatingActionButton( | ||||||
| @@ -220,9 +212,7 @@ class _FriendScreenState extends State<FriendScreen> { | |||||||
|         onPressed: () async { |         onPressed: () async { | ||||||
|           final user = await showModalBottomSheet<SnAccount?>( |           final user = await showModalBottomSheet<SnAccount?>( | ||||||
|             context: context, |             context: context, | ||||||
|             builder: (context) => AccountSelect( |             builder: (context) => AccountSelect(title: 'friendNew'.tr()), | ||||||
|               title: 'friendNew'.tr(), |  | ||||||
|             ), |  | ||||||
|           ); |           ); | ||||||
|           if (!mounted) return; |           if (!mounted) return; | ||||||
|           if (user == null) return; |           if (user == null) return; | ||||||
| @@ -235,9 +225,8 @@ class _FriendScreenState extends State<FriendScreen> { | |||||||
|           if (_requests.isNotEmpty) |           if (_requests.isNotEmpty) | ||||||
|             ListTile( |             ListTile( | ||||||
|               title: Text('friendRequests').tr(), |               title: Text('friendRequests').tr(), | ||||||
|               subtitle: Text( |               subtitle: | ||||||
|                 'friendRequestsDescription', |                   Text('friendRequestsDescription').plural(_requests.length), | ||||||
|               ).plural(_requests.length), |  | ||||||
|               contentPadding: const EdgeInsets.symmetric(horizontal: 24), |               contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|               leading: const Icon(Symbols.group_add), |               leading: const Icon(Symbols.group_add), | ||||||
|               trailing: const Icon(Symbols.chevron_right), |               trailing: const Icon(Symbols.chevron_right), | ||||||
| @@ -246,31 +235,30 @@ class _FriendScreenState extends State<FriendScreen> { | |||||||
|           if (_blocks.isNotEmpty) |           if (_blocks.isNotEmpty) | ||||||
|             ListTile( |             ListTile( | ||||||
|               title: Text('friendBlocklist').tr(), |               title: Text('friendBlocklist').tr(), | ||||||
|               subtitle: Text( |               subtitle: | ||||||
|                 'friendBlocklistDescription', |                   Text('friendBlocklistDescription').plural(_blocks.length), | ||||||
|               ).plural(_blocks.length), |  | ||||||
|               contentPadding: const EdgeInsets.symmetric(horizontal: 24), |               contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|               leading: const Icon(Symbols.block), |               leading: const Icon(Symbols.block), | ||||||
|               trailing: const Icon(Symbols.chevron_right), |               trailing: const Icon(Symbols.chevron_right), | ||||||
|               onTap: _showBlocks, |               onTap: _showBlocks, | ||||||
|             ), |             ), | ||||||
|           if (_requests.isNotEmpty || _blocks.isNotEmpty) const Divider(height: 1), |           if (_requests.isNotEmpty || _blocks.isNotEmpty) | ||||||
|  |             const Divider(height: 1), | ||||||
|           Expanded( |           Expanded( | ||||||
|             child: MediaQuery.removePadding( |             child: MediaQuery.removePadding( | ||||||
|               context: context, |               context: context, | ||||||
|               removeTop: true, |               removeTop: true, | ||||||
|               child: RefreshIndicator( |               child: RefreshIndicator( | ||||||
|                 onRefresh: () => Future.wait([ |                 onRefresh: () => | ||||||
|                   _fetchRelations(), |                     Future.wait([_fetchRelations(), _fetchRequests()]), | ||||||
|                   _fetchRequests(), |  | ||||||
|                 ]), |  | ||||||
|                 child: ListView.builder( |                 child: ListView.builder( | ||||||
|                   itemCount: _relations.length, |                   itemCount: _relations.length, | ||||||
|                   itemBuilder: (context, index) { |                   itemBuilder: (context, index) { | ||||||
|                     final relation = _relations[index]; |                     final relation = _relations[index]; | ||||||
|                     final other = relation.related; |                     final other = relation.related; | ||||||
|                     return ListTile( |                     return ListTile( | ||||||
|                       contentPadding: const EdgeInsets.only(right: 24, left: 16), |                       contentPadding: | ||||||
|  |                           const EdgeInsets.only(right: 24, left: 16), | ||||||
|                       leading: AccountImage(content: other?.avatar), |                       leading: AccountImage(content: other?.avatar), | ||||||
|                       title: Text(other?.nick ?? 'unknown'), |                       title: Text(other?.nick ?? 'unknown'), | ||||||
|                       subtitle: Text(other?.nick ?? 'unknown'), |                       subtitle: Text(other?.nick ?? 'unknown'), | ||||||
| @@ -286,12 +274,16 @@ class _FriendScreenState extends State<FriendScreen> { | |||||||
|                               mainAxisAlignment: MainAxisAlignment.end, |                               mainAxisAlignment: MainAxisAlignment.end, | ||||||
|                               children: [ |                               children: [ | ||||||
|                                 InkWell( |                                 InkWell( | ||||||
|                                   onTap: _isUpdating ? null : () => _changeRelation(relation, 2), |                                   onTap: _isUpdating | ||||||
|  |                                       ? null | ||||||
|  |                                       : () => _changeRelation(relation, 2), | ||||||
|                                   child: Text('friendBlock').tr(), |                                   child: Text('friendBlock').tr(), | ||||||
|                                 ), |                                 ), | ||||||
|                                 const Gap(8), |                                 const Gap(8), | ||||||
|                                 InkWell( |                                 InkWell( | ||||||
|                                   onTap: _isUpdating ? null : () => _deleteRelation(relation), |                                   onTap: _isUpdating | ||||||
|  |                                       ? null | ||||||
|  |                                       : () => _deleteRelation(relation), | ||||||
|                                   child: Text('friendDeleteAction').tr(), |                                   child: Text('friendDeleteAction').tr(), | ||||||
|                                 ), |                                 ), | ||||||
|                               ], |                               ], | ||||||
| @@ -361,10 +353,7 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> { | |||||||
|     try { |     try { | ||||||
|       final rel = context.read<SnRelationshipProvider>(); |       final rel = context.read<SnRelationshipProvider>(); | ||||||
|       await rel.updateRelationship( |       await rel.updateRelationship( | ||||||
|         relation.relatedId, |           relation.relatedId, dstStatus, relation.permNodes); | ||||||
|         dstStatus, |  | ||||||
|         relation.permNodes, |  | ||||||
|       ); |  | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       Navigator.pop(context, true); |       Navigator.pop(context, true); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
| @@ -378,9 +367,8 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> { | |||||||
|   Future<void> _deleteRelation(SnRelationship relation) async { |   Future<void> _deleteRelation(SnRelationship relation) async { | ||||||
|     final confirm = await context.showConfirmDialog( |     final confirm = await context.showConfirmDialog( | ||||||
|       'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]), |       'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]), | ||||||
|       'friendDeleteDescription'.tr(args: [ |       'friendDeleteDescription' | ||||||
|         relation.related?.nick ?? 'unknown'.tr(), |           .tr(args: [relation.related?.nick ?? 'unknown'.tr()]), | ||||||
|       ]), |  | ||||||
|     ); |     ); | ||||||
|     if (!confirm) return; |     if (!confirm) return; | ||||||
|     if (!mounted) return; |     if (!mounted) return; | ||||||
| @@ -420,7 +408,9 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> { | |||||||
|               mainAxisAlignment: MainAxisAlignment.center, |               mainAxisAlignment: MainAxisAlignment.center, | ||||||
|               crossAxisAlignment: CrossAxisAlignment.end, |               crossAxisAlignment: CrossAxisAlignment.end, | ||||||
|               children: [ |               children: [ | ||||||
|                 Text(kFriendStatus[relation.status] ?? 'unknown').tr().opacity(0.75), |                 Text(kFriendStatus[relation.status] ?? 'unknown') | ||||||
|  |                     .tr() | ||||||
|  |                     .opacity(0.75), | ||||||
|                 if (relation.status == 0) |                 if (relation.status == 0) | ||||||
|                   Row( |                   Row( | ||||||
|                     mainAxisAlignment: MainAxisAlignment.end, |                     mainAxisAlignment: MainAxisAlignment.end, | ||||||
| @@ -441,7 +431,8 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> { | |||||||
|                     mainAxisAlignment: MainAxisAlignment.end, |                     mainAxisAlignment: MainAxisAlignment.end, | ||||||
|                     children: [ |                     children: [ | ||||||
|                       InkWell( |                       InkWell( | ||||||
|                         onTap: _isBusy ? null : () => _changeRelation(relation, 1), |                         onTap: | ||||||
|  |                             _isBusy ? null : () => _changeRelation(relation, 1), | ||||||
|                         child: Text('friendUnblock').tr(), |                         child: Text('friendUnblock').tr(), | ||||||
|                       ), |                       ), | ||||||
|                       const Gap(8), |                       const Gap(8), | ||||||
|   | |||||||
| @@ -6,7 +6,6 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; | |||||||
| import 'package:gap/gap.dart'; | import 'package:gap/gap.dart'; | ||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
| import 'package:google_fonts/google_fonts.dart'; | import 'package:google_fonts/google_fonts.dart'; | ||||||
| import 'package:html/parser.dart'; |  | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:relative_time/relative_time.dart'; | import 'package:relative_time/relative_time.dart'; | ||||||
| @@ -19,14 +18,16 @@ import 'package:surface/providers/sn_network.dart'; | |||||||
| import 'package:surface/providers/special_day.dart'; | import 'package:surface/providers/special_day.dart'; | ||||||
| import 'package:surface/providers/userinfo.dart'; | import 'package:surface/providers/userinfo.dart'; | ||||||
| import 'package:surface/providers/widget.dart'; | import 'package:surface/providers/widget.dart'; | ||||||
|  | import 'package:surface/screens/captcha/captcha.dart'; | ||||||
| import 'package:surface/types/check_in.dart'; | import 'package:surface/types/check_in.dart'; | ||||||
| import 'package:surface/types/news.dart'; |  | ||||||
| import 'package:surface/types/post.dart'; | import 'package:surface/types/post.dart'; | ||||||
| import 'package:surface/widgets/app_bar_leading.dart'; | import 'package:surface/widgets/app_bar_leading.dart'; | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
| import 'package:surface/widgets/post/post_item.dart'; | import 'package:surface/widgets/post/post_item.dart'; | ||||||
| import 'package:surface/widgets/updater.dart'; | import 'package:surface/widgets/updater.dart'; | ||||||
|  | import 'package:flutter_animate/flutter_animate.dart'; | ||||||
|  | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
|  |  | ||||||
| class HomeScreenDashEntry { | class HomeScreenDashEntry { | ||||||
|   final String name; |   final String name; | ||||||
| @@ -66,7 +67,7 @@ class _HomeScreenState extends State<HomeScreen> { | |||||||
|     ), |     ), | ||||||
|     HomeScreenDashEntry( |     HomeScreenDashEntry( | ||||||
|       name: 'dashEntryTodayNews', |       name: 'dashEntryTodayNews', | ||||||
|       child: _HomeDashTodayNews(), |       child: _HomeDashServiceStatus(), | ||||||
|       cols: MediaQuery.of(context).size.width >= 640 ? 3 : 2, |       cols: MediaQuery.of(context).size.width >= 640 ? 3 : 2, | ||||||
|     ), |     ), | ||||||
|   ]; |   ]; | ||||||
| @@ -99,6 +100,7 @@ class _HomeScreenState extends State<HomeScreen> { | |||||||
|                         right: 8, |                         right: 8, | ||||||
|                       ), |                       ), | ||||||
|                     ), |                     ), | ||||||
|  |                     _HomeDashUnconfirmedWidget().padding(horizontal: 8), | ||||||
|                     _HomeDashSpecialDayWidget().padding(horizontal: 8), |                     _HomeDashSpecialDayWidget().padding(horizontal: 8), | ||||||
|                     StaggeredGrid.extent( |                     StaggeredGrid.extent( | ||||||
|                       maxCrossAxisExtent: 280, |                       maxCrossAxisExtent: 280, | ||||||
| @@ -123,6 +125,64 @@ class _HomeScreenState extends State<HomeScreen> { | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | class _HomeDashUnconfirmedWidget extends StatelessWidget { | ||||||
|  |   const _HomeDashUnconfirmedWidget(); | ||||||
|  |  | ||||||
|  |   Future<void> _resendConfirmationEmail(BuildContext context) async { | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       await sn.client.patch('/cgi/id/users/me/confirm'); | ||||||
|  |       if (!context.mounted) return; | ||||||
|  |       context.showSnackbar('accountUnconfirmedResendSuccessful'.tr()); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!context.mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     final ua = context.watch<UserProvider>(); | ||||||
|  |     if (ua.user == null || ua.user?.confirmedAt != null) { | ||||||
|  |       return SizedBox.shrink(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return Card( | ||||||
|  |       margin: EdgeInsets.zero, | ||||||
|  |       child: ListTile( | ||||||
|  |         leading: const Icon(Symbols.shield), | ||||||
|  |         title: Text('accountUnconfirmedTitle').tr(), | ||||||
|  |         subtitle: Column( | ||||||
|  |           crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |           children: [ | ||||||
|  |             Text('accountUnconfirmedSubtitle').tr(), | ||||||
|  |             const Gap(4), | ||||||
|  |             Row( | ||||||
|  |               children: [ | ||||||
|  |                 Text('accountUnconfirmedUnreceived').tr(), | ||||||
|  |                 const Gap(4), | ||||||
|  |                 InkWell( | ||||||
|  |                   child: Text( | ||||||
|  |                     'accountUnconfirmedResend', | ||||||
|  |                     style: TextStyle( | ||||||
|  |                       decoration: TextDecoration.underline, | ||||||
|  |                       color: Theme.of(context).colorScheme.onSurface, | ||||||
|  |                     ), | ||||||
|  |                   ).tr(), | ||||||
|  |                   onTap: () { | ||||||
|  |                     _resendConfirmationEmail(context); | ||||||
|  |                   }, | ||||||
|  |                 ), | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |         contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |       ), | ||||||
|  |     ).padding(bottom: 8); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
| class _HomeDashUpdateWidget extends StatelessWidget { | class _HomeDashUpdateWidget extends StatelessWidget { | ||||||
|   final EdgeInsets? padding; |   final EdgeInsets? padding; | ||||||
|  |  | ||||||
| @@ -131,7 +191,6 @@ class _HomeDashUpdateWidget extends StatelessWidget { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     final config = context.watch<ConfigProvider>(); |     final config = context.watch<ConfigProvider>(); | ||||||
|  |  | ||||||
|     return ListenableBuilder( |     return ListenableBuilder( | ||||||
|       listenable: config, |       listenable: config, | ||||||
|       builder: (context, _) { |       builder: (context, _) { | ||||||
| @@ -245,21 +304,31 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> { | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| class _HomeDashTodayNews extends StatefulWidget { | class _HomeDashServiceStatus extends StatefulWidget { | ||||||
|   const _HomeDashTodayNews(); |   const _HomeDashServiceStatus(); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   State<_HomeDashTodayNews> createState() => _HomeDashTodayNewsState(); |   State<_HomeDashServiceStatus> createState() => _HomeDashServiceStatusState(); | ||||||
| } | } | ||||||
|  |  | ||||||
| class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> { | class _HomeDashServiceStatusState extends State<_HomeDashServiceStatus> { | ||||||
|   SnNewsArticle? _article; |   Map<String, dynamic>? _statuses; | ||||||
|  |   ServiceStatus? _serviceStatus; | ||||||
|  |  | ||||||
|   Future<void> _fetchArticle() async { |   Future<void> _fetchStatuses() async { | ||||||
|     try { |     try { | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final resp = await sn.client.get('/cgi/re/news/today'); |       final resp = await sn.client.get('/directory/status'); | ||||||
|       _article = SnNewsArticle.fromJson(resp.data['data']); |       _statuses = resp.data; | ||||||
|  |       if (_statuses!.values.contains(false)) { | ||||||
|  |         if (_statuses!.values.contains(true)) { | ||||||
|  |           _serviceStatus = ServiceStatus.downgraded; | ||||||
|  |         } else { | ||||||
|  |           _serviceStatus = ServiceStatus.failed; | ||||||
|  |         } | ||||||
|  |       } else { | ||||||
|  |         _serviceStatus = ServiceStatus.operational; | ||||||
|  |       } | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showErrorDialog(err); |       context.showErrorDialog(err); | ||||||
| @@ -272,7 +341,7 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> { | |||||||
|   @override |   @override | ||||||
|   initState() { |   initState() { | ||||||
|     super.initState(); |     super.initState(); | ||||||
|     _fetchArticle(); |     _fetchStatuses(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
| @@ -284,73 +353,136 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> { | |||||||
|         children: [ |         children: [ | ||||||
|           Row( |           Row( | ||||||
|             children: [ |             children: [ | ||||||
|               const Icon(Symbols.newspaper), |               const Icon(Symbols.flare), | ||||||
|               const Gap(8), |               const Gap(8), | ||||||
|               Text( |  | ||||||
|                 'newsToday', |  | ||||||
|                 style: Theme.of(context).textTheme.titleLarge, |  | ||||||
|               ).tr() |  | ||||||
|             ], |  | ||||||
|           ).padding(horizontal: 18, top: 12, bottom: 8), |  | ||||||
|           if (_article != null) |  | ||||||
|               Expanded( |               Expanded( | ||||||
|               child: InkWell( |                 child: Text( | ||||||
|                 borderRadius: BorderRadius.all(Radius.circular(8)), |                   'serviceStatus', | ||||||
|                 child: Column( |                   style: Theme.of(context).textTheme.titleLarge, | ||||||
|                   crossAxisAlignment: CrossAxisAlignment.start, |                 ).tr(), | ||||||
|                   spacing: 4, |  | ||||||
|                   children: [ |  | ||||||
|                     Text( |  | ||||||
|                       _article!.title, |  | ||||||
|                       style: Theme.of(context) |  | ||||||
|                           .textTheme |  | ||||||
|                           .titleMedium! |  | ||||||
|                           .copyWith(fontSize: 18), |  | ||||||
|                       maxLines: |  | ||||||
|                           MediaQuery.of(context).size.width >= 640 ? 2 : 1, |  | ||||||
|                       overflow: TextOverflow.ellipsis, |  | ||||||
|               ), |               ), | ||||||
|                     Text( |               IconButton( | ||||||
|                       parse(_article!.description) |                 icon: const Icon(Symbols.launch, size: 20), | ||||||
|                           .children |                 visualDensity: VisualDensity(horizontal: -4, vertical: -4), | ||||||
|                           .map((e) => e.text.trim()) |                 constraints: const BoxConstraints(), | ||||||
|                           .join(), |                 padding: EdgeInsets.zero, | ||||||
|                       maxLines: 3, |                 onPressed: () { | ||||||
|                       overflow: TextOverflow.ellipsis, |                   launchUrlString('https://status.solsynth.dev'); | ||||||
|                       style: Theme.of(context).textTheme.bodyMedium, |  | ||||||
|                     ), |  | ||||||
|                     Builder(builder: (context) { |  | ||||||
|                       final date = _article!.publishedAt ?? _article!.createdAt; |  | ||||||
|                       return Row( |  | ||||||
|                         crossAxisAlignment: CrossAxisAlignment.center, |  | ||||||
|                         spacing: 2, |  | ||||||
|                         children: [ |  | ||||||
|                           Text(DateFormat().format(date)).textStyle( |  | ||||||
|                               Theme.of(context).textTheme.bodySmall!), |  | ||||||
|                           Text(' · ') |  | ||||||
|                               .textStyle(Theme.of(context).textTheme.bodySmall!) |  | ||||||
|                               .bold(), |  | ||||||
|                           Text(RelativeTime(context).format(date)).textStyle( |  | ||||||
|                               Theme.of(context).textTheme.bodySmall!), |  | ||||||
|                         ], |  | ||||||
|                       ).opacity(0.75); |  | ||||||
|                     }), |  | ||||||
|                   ], |  | ||||||
|                 ).padding(horizontal: 16), |  | ||||||
|                 onTap: () { |  | ||||||
|                   GoRouter.of(context).pushNamed( |  | ||||||
|                     'newsDetail', |  | ||||||
|                     pathParameters: {'hash': _article!.hash}, |  | ||||||
|                   ); |  | ||||||
|                 }, |                 }, | ||||||
|               ), |               ), | ||||||
|             ) |             ], | ||||||
|           else |           ).padding(horizontal: 18, top: 12, bottom: 8), | ||||||
|             Expanded( |           Container( | ||||||
|               child: Center( |             padding: EdgeInsets.symmetric(horizontal: 20, vertical: 6), | ||||||
|                 child: CircularProgressIndicator(), |             width: double.infinity, | ||||||
|  |             color: _serviceStatus == null | ||||||
|  |                 ? Theme.of(context).colorScheme.surfaceContainerHigh | ||||||
|  |                 : switch (_serviceStatus) { | ||||||
|  |                     ServiceStatus.operational => Colors.green[300], | ||||||
|  |                     ServiceStatus.failed => Colors.red[300], | ||||||
|  |                     _ => Colors.orange[300], | ||||||
|  |                   }, | ||||||
|  |             child: _serviceStatus == null | ||||||
|  |                 ? Row( | ||||||
|  |                     children: [ | ||||||
|  |                       const Icon( | ||||||
|  |                         Symbols.more_horiz, | ||||||
|  |                         size: 20, | ||||||
|                       ), |                       ), | ||||||
|  |                       const Gap(10), | ||||||
|  |                       Text('loading').tr(), | ||||||
|  |                     ], | ||||||
|                   ) |                   ) | ||||||
|  |                 : switch (_serviceStatus) { | ||||||
|  |                     ServiceStatus.operational => Row( | ||||||
|  |                         children: [ | ||||||
|  |                           Icon( | ||||||
|  |                             Symbols.check, | ||||||
|  |                             size: 20, | ||||||
|  |                             color: Colors.green[900], | ||||||
|  |                           ), | ||||||
|  |                           const Gap(10), | ||||||
|  |                           Text('serviceStatusOperational') | ||||||
|  |                               .tr() | ||||||
|  |                               .textColor(Colors.green[900]), | ||||||
|  |                         ], | ||||||
|  |                       ), | ||||||
|  |                     ServiceStatus.failed => Tooltip( | ||||||
|  |                         message: 'serviceStatusFailedDescription'.tr(), | ||||||
|  |                         child: Row( | ||||||
|  |                           children: [ | ||||||
|  |                             Icon( | ||||||
|  |                               Symbols.dangerous, | ||||||
|  |                               size: 20, | ||||||
|  |                               color: Colors.red[900], | ||||||
|  |                             ), | ||||||
|  |                             const Gap(10), | ||||||
|  |                             Text('serviceStatusFailed') | ||||||
|  |                                 .tr() | ||||||
|  |                                 .textColor(Colors.red[900]), | ||||||
|  |                           ], | ||||||
|  |                         ), | ||||||
|  |                       ), | ||||||
|  |                     _ => Row( | ||||||
|  |                         children: [ | ||||||
|  |                           Icon( | ||||||
|  |                             Symbols.error, | ||||||
|  |                             size: 20, | ||||||
|  |                             color: Colors.orange[900], | ||||||
|  |                           ), | ||||||
|  |                           const Gap(10), | ||||||
|  |                           Text('serviceStatusDowngraded') | ||||||
|  |                               .tr() | ||||||
|  |                               .textColor(Colors.orange[900]), | ||||||
|  |                         ], | ||||||
|  |                       ), | ||||||
|  |                   }, | ||||||
|  |           ), | ||||||
|  |           if (_statuses != null) | ||||||
|  |             Expanded( | ||||||
|  |               child: SingleChildScrollView( | ||||||
|  |                 padding: EdgeInsets.only(top: 6), | ||||||
|  |                 child: Wrap( | ||||||
|  |                   spacing: 8, | ||||||
|  |                   runSpacing: 8, | ||||||
|  |                   children: [ | ||||||
|  |                     for (final entry in _statuses!.entries) | ||||||
|  |                       Tooltip( | ||||||
|  |                         message: kServicesName[entry.key] != null | ||||||
|  |                             ? 'serviceName${kServicesName[entry.key]}'.tr() | ||||||
|  |                             : 'unknown'.tr(), | ||||||
|  |                         child: Chip( | ||||||
|  |                           visualDensity: | ||||||
|  |                               VisualDensity(horizontal: -4, vertical: -4), | ||||||
|  |                           avatar: entry.value | ||||||
|  |                               ? const Icon( | ||||||
|  |                                   Symbols.circle, | ||||||
|  |                                   color: Colors.green, | ||||||
|  |                                   fill: 1, | ||||||
|  |                                   size: 16, | ||||||
|  |                                 ) | ||||||
|  |                               : AnimateWidgetExtensions(const Icon( | ||||||
|  |                                   Symbols.error, | ||||||
|  |                                   color: Colors.red, | ||||||
|  |                                   fill: 1, | ||||||
|  |                                   size: 16, | ||||||
|  |                                 )) | ||||||
|  |                                   .animate(onPlay: (e) => e.repeat()) | ||||||
|  |                                   .fadeIn( | ||||||
|  |                                       duration: 500.ms, curve: Curves.easeOut) | ||||||
|  |                                   .then() | ||||||
|  |                                   .fadeOut( | ||||||
|  |                                     duration: 500.ms, | ||||||
|  |                                     delay: 1000.ms, | ||||||
|  |                                     curve: Curves.easeIn, | ||||||
|  |                                   ), | ||||||
|  |                           label: Text(kServicesName[entry.key] ?? entry.key), | ||||||
|  |                         ), | ||||||
|  |                       ), | ||||||
|  |                   ], | ||||||
|  |                 ).padding(horizontal: 12), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|         ], |         ], | ||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
| @@ -386,11 +518,20 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> _doCheckIn() async { |   Future<void> _doCheckIn() async { | ||||||
|  |     final captchaTk = await Navigator.of(context, rootNavigator: true).push( | ||||||
|  |       MaterialPageRoute( | ||||||
|  |         builder: (context) => CaptchaScreen(), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |     if (captchaTk == null) return; | ||||||
|  |  | ||||||
|     setState(() => _isBusy = true); |     setState(() => _isBusy = true); | ||||||
|     try { |     try { | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final home = context.read<HomeWidgetProvider>(); |       final home = context.read<HomeWidgetProvider>(); | ||||||
|       final resp = await sn.client.post('/cgi/id/check-in'); |       final resp = await sn.client.post('/cgi/id/check-in', data: { | ||||||
|  |         'captcha_token': captchaTk, | ||||||
|  |       }); | ||||||
|       _todayRecord = SnCheckInRecord.fromJson(resp.data); |       _todayRecord = SnCheckInRecord.fromJson(resp.data); | ||||||
|       await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson()); |       await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson()); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
| @@ -546,11 +687,26 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> { | |||||||
|                           '+${_todayRecord!.resultExperience} EXP', |                           '+${_todayRecord!.resultExperience} EXP', | ||||||
|                           style: Theme.of(context).textTheme.bodyLarge, |                           style: Theme.of(context).textTheme.bodyLarge, | ||||||
|                         ), |                         ), | ||||||
|                         if (_todayRecord!.resultCoin >= 0) |                         if (_todayRecord!.resultCoin > 0) | ||||||
|                           Text( |                           Text( | ||||||
|                             '+${_todayRecord!.resultCoin} ${'walletCurrencyShort'.tr()}', |                             '+${_todayRecord!.resultCoin} ${'walletCurrencyShort'.tr()}', | ||||||
|                             style: Theme.of(context).textTheme.bodyLarge, |                             style: Theme.of(context).textTheme.bodyLarge, | ||||||
|                           ) |                           ), | ||||||
|  |                         if (_todayRecord!.currentStreak > 0) | ||||||
|  |                           Row( | ||||||
|  |                             children: [ | ||||||
|  |                               const Icon( | ||||||
|  |                                 Symbols.local_fire_department, | ||||||
|  |                                 size: 14, | ||||||
|  |                               ).padding(bottom: 2), | ||||||
|  |                               const Gap(4), | ||||||
|  |                               Text( | ||||||
|  |                                 'checkInStreak' | ||||||
|  |                                     .plural(_todayRecord!.currentStreak), | ||||||
|  |                                 style: Theme.of(context).textTheme.bodySmall, | ||||||
|  |                               ), | ||||||
|  |                             ], | ||||||
|  |                           ).padding(top: 4), | ||||||
|                       ], |                       ], | ||||||
|                     ), |                     ), | ||||||
|             ), |             ), | ||||||
| @@ -659,7 +815,7 @@ class _HomeDashNotificationWidgetState | |||||||
|               child: IconButton( |               child: IconButton( | ||||||
|                 icon: const Icon(Symbols.arrow_right_alt), |                 icon: const Icon(Symbols.arrow_right_alt), | ||||||
|                 onPressed: () { |                 onPressed: () { | ||||||
|                   GoRouter.of(context).goNamed('notification'); |                   GoRouter.of(context).pushNamed('notification'); | ||||||
|                 }, |                 }, | ||||||
|               ), |               ), | ||||||
|             ), |             ), | ||||||
| @@ -743,8 +899,10 @@ class _HomeDashRecommendationPostWidgetState | |||||||
|                   ).tr(), |                   ).tr(), | ||||||
|                 ], |                 ], | ||||||
|               ), |               ), | ||||||
|               Text('${_currentPage + 1}/${_posts?.length ?? 0}', |               Text( | ||||||
|                   style: GoogleFonts.robotoMono()) |                 '${_currentPage + 1}/${_posts?.length ?? 0}', | ||||||
|  |                 style: GoogleFonts.robotoMono(), | ||||||
|  |               ) | ||||||
|             ], |             ], | ||||||
|           ).padding(horizontal: 18, top: 12, bottom: 8), |           ).padding(horizontal: 18, top: 12, bottom: 8), | ||||||
|           Expanded( |           Expanded( | ||||||
| @@ -762,6 +920,7 @@ class _HomeDashRecommendationPostWidgetState | |||||||
|                     child: PostItem( |                     child: PostItem( | ||||||
|                       data: _posts![index], |                       data: _posts![index], | ||||||
|                       showMenu: false, |                       showMenu: false, | ||||||
|  |                       showFullPost: true, | ||||||
|                     ).padding(bottom: 8), |                     ).padding(bottom: 8), | ||||||
|                     onTap: () { |                     onTap: () { | ||||||
|                       GoRouter.of(context) |                       GoRouter.of(context) | ||||||
|   | |||||||
							
								
								
									
										167
									
								
								lib/screens/logging.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								lib/screens/logging.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,167 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter/services.dart'; | ||||||
|  | import 'package:google_fonts/google_fonts.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
|  | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:surface/logger.dart'; | ||||||
|  | import 'package:surface/widgets/dialog.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  | import 'package:talker_dio_logger/dio_logs.dart'; | ||||||
|  | import 'package:talker_flutter/talker_flutter.dart'; | ||||||
|  |  | ||||||
|  | final Map<LogLevel, IconData> kLogLevelIcons = { | ||||||
|  |   LogLevel.error: Symbols.error, | ||||||
|  |   LogLevel.critical: Symbols.error, | ||||||
|  |   LogLevel.warning: Symbols.warning, | ||||||
|  |   LogLevel.info: Symbols.info, | ||||||
|  |   LogLevel.debug: Symbols.info_i, | ||||||
|  |   LogLevel.verbose: Symbols.info_i, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | final Map<LogLevel, bool> kLogLevelFilled = { | ||||||
|  |   LogLevel.error: false, | ||||||
|  |   LogLevel.critical: true, | ||||||
|  |   LogLevel.warning: true, | ||||||
|  |   LogLevel.info: true, | ||||||
|  |   LogLevel.debug: false, | ||||||
|  |   LogLevel.verbose: false, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | class DebugLoggingScreen extends StatelessWidget { | ||||||
|  |   const DebugLoggingScreen({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     final talkerTheme = TalkerScreenTheme.fromTheme(Theme.of(context)); | ||||||
|  |  | ||||||
|  |     return AppScaffold( | ||||||
|  |       appBar: AppBar( | ||||||
|  |         leading: const PageBackButton(), | ||||||
|  |         title: Text('debugLogging').tr(), | ||||||
|  |         actions: [ | ||||||
|  |           IconButton( | ||||||
|  |             onPressed: () { | ||||||
|  |               logging.cleanHistory(); | ||||||
|  |               Navigator.pop(context); | ||||||
|  |             }, | ||||||
|  |             icon: const Icon(Symbols.delete), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |       body: ListView.builder( | ||||||
|  |         reverse: true, | ||||||
|  |         padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom), | ||||||
|  |         itemCount: logging.history.length, | ||||||
|  |         itemBuilder: (context, index) { | ||||||
|  |           final log = logging.history[index]; | ||||||
|  |           final color = log.getFlutterColor(talkerTheme); | ||||||
|  |           return ListTile( | ||||||
|  |             minTileHeight: 0, | ||||||
|  |             tileColor: color.withOpacity(0.2), | ||||||
|  |             leading: Icon( | ||||||
|  |               kLogLevelIcons[log.logLevel ?? LogLevel.debug] ?? Symbols.help, | ||||||
|  |               color: color, | ||||||
|  |               fill: (kLogLevelFilled[log.logLevel ?? LogLevel.debug] ?? false) | ||||||
|  |                   ? 1 | ||||||
|  |                   : 0, | ||||||
|  |             ), | ||||||
|  |             title: Column( | ||||||
|  |               crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |               children: [ | ||||||
|  |                 if (log is DioRequestLog) | ||||||
|  |                   Column( | ||||||
|  |                     crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                     children: [ | ||||||
|  |                       Text( | ||||||
|  |                         '${log.requestOptions.method} ${log.displayMessage}', | ||||||
|  |                         style: GoogleFonts.robotoMono(fontSize: 13), | ||||||
|  |                       ), | ||||||
|  |                       if (log.requestOptions.data != null) | ||||||
|  |                         Theme( | ||||||
|  |                           data: Theme.of(context).copyWith( | ||||||
|  |                             dividerColor: Colors.transparent, | ||||||
|  |                           ), | ||||||
|  |                           child: ExpansionTile( | ||||||
|  |                             title: Text('Payload').fontSize(13), | ||||||
|  |                             minTileHeight: 0, | ||||||
|  |                             tilePadding: EdgeInsets.zero, | ||||||
|  |                             expandedCrossAxisAlignment: | ||||||
|  |                                 CrossAxisAlignment.start, | ||||||
|  |                             children: [ | ||||||
|  |                               Text( | ||||||
|  |                                 log.requestOptions.data.toString(), | ||||||
|  |                                 style: GoogleFonts.robotoMono(fontSize: 13), | ||||||
|  |                               ), | ||||||
|  |                             ], | ||||||
|  |                           ), | ||||||
|  |                         ), | ||||||
|  |                     ], | ||||||
|  |                   ) | ||||||
|  |                 else if (log is DioResponseLog) | ||||||
|  |                   Column( | ||||||
|  |                     crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                     children: [ | ||||||
|  |                       Text( | ||||||
|  |                         '${log.response.statusCode} ${log.displayMessage}', | ||||||
|  |                         style: GoogleFonts.robotoMono(fontSize: 13), | ||||||
|  |                       ), | ||||||
|  |                       if (log.response.data != null) | ||||||
|  |                         Theme( | ||||||
|  |                           data: Theme.of(context).copyWith( | ||||||
|  |                             dividerColor: Colors.transparent, | ||||||
|  |                           ), | ||||||
|  |                           child: ExpansionTile( | ||||||
|  |                             title: Text('Payload').fontSize(13), | ||||||
|  |                             minTileHeight: 0, | ||||||
|  |                             tilePadding: EdgeInsets.zero, | ||||||
|  |                             expandedCrossAxisAlignment: | ||||||
|  |                                 CrossAxisAlignment.start, | ||||||
|  |                             children: [ | ||||||
|  |                               Text( | ||||||
|  |                                 log.response.data.toString(), | ||||||
|  |                                 style: GoogleFonts.robotoMono(fontSize: 13), | ||||||
|  |                               ), | ||||||
|  |                             ], | ||||||
|  |                           ), | ||||||
|  |                         ), | ||||||
|  |                     ], | ||||||
|  |                   ) | ||||||
|  |                 else | ||||||
|  |                   Text( | ||||||
|  |                     log.displayMessage, | ||||||
|  |                     style: GoogleFonts.robotoMono(fontSize: 13), | ||||||
|  |                   ), | ||||||
|  |                 if (log.exception != null) | ||||||
|  |                   Text( | ||||||
|  |                     log.displayException, | ||||||
|  |                     style: GoogleFonts.robotoMono(fontSize: 13), | ||||||
|  |                   ).bold(), | ||||||
|  |                 if (log.error != null) | ||||||
|  |                   Text( | ||||||
|  |                     log.displayException, | ||||||
|  |                     style: GoogleFonts.robotoMono(fontSize: 13), | ||||||
|  |                   ).bold(), | ||||||
|  |                 if (log.stackTrace != null) | ||||||
|  |                   Text( | ||||||
|  |                     log.displayStackTrace, | ||||||
|  |                     style: GoogleFonts.robotoMono(fontSize: 12), | ||||||
|  |                   ).padding(top: 4), | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |             subtitle: Text( | ||||||
|  |               '${(log.title?.replaceAll('-', ' ') ?? 'default').capitalizeEachWord()} · ${log.displayTime()}', | ||||||
|  |             ).fontSize(11), | ||||||
|  |             onTap: () { | ||||||
|  |               Clipboard.setData( | ||||||
|  |                 ClipboardData( | ||||||
|  |                   text: log.generateTextMessage(), | ||||||
|  |                 ), | ||||||
|  |               ); | ||||||
|  |             }, | ||||||
|  |           ); | ||||||
|  |         }, | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,18 +1,17 @@ | |||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/gestures.dart'; |  | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:gap/gap.dart'; | import 'package:gap/gap.dart'; | ||||||
| import 'package:html/dom.dart' as dom; |  | ||||||
| import 'package:html/parser.dart'; | import 'package:html/parser.dart'; | ||||||
|  | import 'package:html2md/html2md.dart' as html2md; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:relative_time/relative_time.dart'; | import 'package:relative_time/relative_time.dart'; | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/types/news.dart'; | import 'package:surface/types/news.dart'; | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
|  | import 'package:surface/widgets/markdown_content.dart'; | ||||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
| import 'package:flutter_inappwebview/flutter_inappwebview.dart'; | import 'package:flutter_inappwebview/flutter_inappwebview.dart'; | ||||||
| import 'package:surface/widgets/universal_image.dart'; |  | ||||||
| import 'package:url_launcher/url_launcher_string.dart'; | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
|  |  | ||||||
| class NewsDetailScreen extends StatefulWidget { | class NewsDetailScreen extends StatefulWidget { | ||||||
| @@ -26,14 +25,12 @@ class NewsDetailScreen extends StatefulWidget { | |||||||
|  |  | ||||||
| class _NewsDetailScreenState extends State<NewsDetailScreen> { | class _NewsDetailScreenState extends State<NewsDetailScreen> { | ||||||
|   SnNewsArticle? _article; |   SnNewsArticle? _article; | ||||||
|   dom.Document? _articleFragment; |  | ||||||
|  |  | ||||||
|   Future<void> _fetchArticle() async { |   Future<void> _fetchArticle() async { | ||||||
|     try { |     try { | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final resp = await sn.client.get('/cgi/re/news/${widget.hash}'); |       final resp = await sn.client.get('/cgi/re/news/${widget.hash}'); | ||||||
|       _article = SnNewsArticle.fromJson(resp.data); |       _article = SnNewsArticle.fromJson(resp.data); | ||||||
|       _articleFragment = parse(_article!.content); |  | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showErrorDialog(err).then((_) { |       context.showErrorDialog(err).then((_) { | ||||||
| @@ -45,104 +42,6 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   List<Widget> _parseHtmlToWidgets(Iterable<dom.Element>? elements) { |  | ||||||
|     if (elements == null) return []; |  | ||||||
|  |  | ||||||
|     final List<Widget> widgets = []; |  | ||||||
|  |  | ||||||
|     for (final node in elements) { |  | ||||||
|       switch (node.localName) { |  | ||||||
|         case 'h1': |  | ||||||
|         case 'h2': |  | ||||||
|         case 'h3': |  | ||||||
|         case 'h4': |  | ||||||
|         case 'h5': |  | ||||||
|         case 'h6': |  | ||||||
|           widgets.add(Text(node.text.trim(), style: Theme.of(context).textTheme.titleMedium)); |  | ||||||
|           break; |  | ||||||
|         case 'p': |  | ||||||
|           if (node.text.trim().isEmpty) continue; |  | ||||||
|           widgets.add( |  | ||||||
|             Text.rich( |  | ||||||
|               TextSpan( |  | ||||||
|                 text: node.text.trim(), |  | ||||||
|                 children: [ |  | ||||||
|                   for (final child in node.children) |  | ||||||
|                     switch (child.localName) { |  | ||||||
|                       'a' => TextSpan( |  | ||||||
|                           text: child.text.trim(), |  | ||||||
|                           style: const TextStyle(decoration: TextDecoration.underline), |  | ||||||
|                           recognizer: TapGestureRecognizer() |  | ||||||
|                             ..onTap = () { |  | ||||||
|                               launchUrlString(child.attributes['href']!); |  | ||||||
|                             }, |  | ||||||
|                         ), |  | ||||||
|                       _ => TextSpan(text: child.text.trim()), |  | ||||||
|                     }, |  | ||||||
|                 ], |  | ||||||
|               ), |  | ||||||
|               style: Theme.of(context).textTheme.bodyLarge, |  | ||||||
|             ), |  | ||||||
|           ); |  | ||||||
|           break; |  | ||||||
|         case 'a': |  | ||||||
|           // drop single link |  | ||||||
|           break; |  | ||||||
|         case 'div': |  | ||||||
|           // ignore div text, normally it is not meaningful |  | ||||||
|           widgets.addAll(_parseHtmlToWidgets(node.children)); |  | ||||||
|           break; |  | ||||||
|         case 'hr': |  | ||||||
|           widgets.add(const Divider()); |  | ||||||
|           break; |  | ||||||
|         case 'img': |  | ||||||
|           var src = node.attributes['src']; |  | ||||||
|           if (src == null) break; |  | ||||||
|           final width = double.tryParse(node.attributes['width'] ?? 'null'); |  | ||||||
|           final height = double.tryParse(node.attributes['height'] ?? 'null'); |  | ||||||
|           final ratio = width != null && height != null ? width / height : 1.0; |  | ||||||
|           if (src.startsWith('//')) { |  | ||||||
|             src = 'https:$src'; |  | ||||||
|           } else if (!src.startsWith('http')) { |  | ||||||
|             final baseUri = Uri.parse(_article!.url); |  | ||||||
|             final baseUrl = '${baseUri.scheme}://${baseUri.host}'; |  | ||||||
|             src = '$baseUrl/$src'; |  | ||||||
|           } |  | ||||||
|           widgets.add( |  | ||||||
|             AspectRatio( |  | ||||||
|               aspectRatio: ratio, |  | ||||||
|               child: Container( |  | ||||||
|                 decoration: BoxDecoration( |  | ||||||
|                   borderRadius: BorderRadius.all(Radius.circular(8)), |  | ||||||
|                   border: Border.all( |  | ||||||
|                     color: Theme.of(context).dividerColor, |  | ||||||
|                     width: 1, |  | ||||||
|                   ), |  | ||||||
|                 ), |  | ||||||
|                 height: height ?? double.infinity, |  | ||||||
|                 child: ClipRRect( |  | ||||||
|                   borderRadius: BorderRadius.all(Radius.circular(8)), |  | ||||||
|                   child: Container( |  | ||||||
|                     color: Theme.of(context).colorScheme.surfaceContainer, |  | ||||||
|                     child: AutoResizeUniversalImage( |  | ||||||
|                       src, |  | ||||||
|                       fit: width != null && height != null ? BoxFit.cover : BoxFit.contain, |  | ||||||
|                     ), |  | ||||||
|                   ), |  | ||||||
|                 ), |  | ||||||
|               ), |  | ||||||
|             ), |  | ||||||
|           ); |  | ||||||
|           break; |  | ||||||
|         default: |  | ||||||
|           widgets.addAll(_parseHtmlToWidgets(node.children)); |  | ||||||
|           break; |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     return widgets; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void initState() { |   void initState() { | ||||||
|     super.initState(); |     super.initState(); | ||||||
| @@ -163,7 +62,9 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> { | |||||||
|           MaterialBanner( |           MaterialBanner( | ||||||
|             dividerColor: Colors.transparent, |             dividerColor: Colors.transparent, | ||||||
|             leading: const Icon(Icons.info), |             leading: const Icon(Icons.info), | ||||||
|             content: Text(_isReadingFromReader ? 'newsReadingFromReader'.tr() : 'newsReadingFromOriginal'.tr()), |             content: Text(_isReadingFromReader | ||||||
|  |                 ? 'newsReadingFromReader'.tr() | ||||||
|  |                 : 'newsReadingFromOriginal'.tr()), | ||||||
|             actions: [ |             actions: [ | ||||||
|               TextButton( |               TextButton( | ||||||
|                 child: Text('newsReadingProviderSwap').tr(), |                 child: Text('newsReadingProviderSwap').tr(), | ||||||
| @@ -173,7 +74,7 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> { | |||||||
|               ), |               ), | ||||||
|             ], |             ], | ||||||
|           ), |           ), | ||||||
|           if (_articleFragment != null && _isReadingFromReader) |           if (_article != null && _isReadingFromReader) | ||||||
|             Expanded( |             Expanded( | ||||||
|               child: Container( |               child: Container( | ||||||
|                 constraints: BoxConstraints(maxWidth: 640), |                 constraints: BoxConstraints(maxWidth: 640), | ||||||
| @@ -182,28 +83,43 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> { | |||||||
|                     crossAxisAlignment: CrossAxisAlignment.start, |                     crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|                     spacing: 8, |                     spacing: 8, | ||||||
|                     children: [ |                     children: [ | ||||||
|                       Text(_article!.title, style: Theme.of(context).textTheme.titleLarge), |                       Text(_article!.title, | ||||||
|  |                           style: Theme.of(context).textTheme.titleLarge), | ||||||
|                       Builder(builder: (context) { |                       Builder(builder: (context) { | ||||||
|                         final htmlDescription = parse(_article!.description); |                         final htmlDescription = parse(_article!.description); | ||||||
|                         return Text( |                         return Text( | ||||||
|                           htmlDescription.children.map((ele) => ele.text.trim()).join(), |                           htmlDescription.children | ||||||
|  |                               .map((ele) => ele.text.trim()) | ||||||
|  |                               .join(), | ||||||
|                           style: Theme.of(context).textTheme.bodyMedium, |                           style: Theme.of(context).textTheme.bodyMedium, | ||||||
|                         ); |                         ); | ||||||
|                       }), |                       }), | ||||||
|                       Builder(builder: (context) { |                       Builder(builder: (context) { | ||||||
|                         final date = _article!.publishedAt ?? _article!.createdAt; |                         final date = | ||||||
|  |                             _article!.publishedAt ?? _article!.createdAt; | ||||||
|                         return Row( |                         return Row( | ||||||
|                           spacing: 2, |                           spacing: 2, | ||||||
|                           children: [ |                           children: [ | ||||||
|                             Text(DateFormat().format(date)).textStyle(Theme.of(context).textTheme.bodySmall!), |                             Text(DateFormat().format(date)).textStyle( | ||||||
|                             Text(' · ').textStyle(Theme.of(context).textTheme.bodySmall!).bold(), |                                 Theme.of(context).textTheme.bodySmall!), | ||||||
|                             Text(RelativeTime(context).format(date)).textStyle(Theme.of(context).textTheme.bodySmall!), |                             Text(' · ') | ||||||
|  |                                 .textStyle( | ||||||
|  |                                     Theme.of(context).textTheme.bodySmall!) | ||||||
|  |                                 .bold(), | ||||||
|  |                             Text(RelativeTime(context).format(date)).textStyle( | ||||||
|  |                                 Theme.of(context).textTheme.bodySmall!), | ||||||
|                           ], |                           ], | ||||||
|                         ).opacity(0.75); |                         ).opacity(0.75); | ||||||
|                       }), |                       }), | ||||||
|                       Text('newsDisclaimer').tr().textStyle(Theme.of(context).textTheme.bodySmall!).opacity(0.75), |                       Text('newsDisclaimer') | ||||||
|  |                           .tr() | ||||||
|  |                           .textStyle(Theme.of(context).textTheme.bodySmall!) | ||||||
|  |                           .opacity(0.75), | ||||||
|                       const Divider(), |                       const Divider(), | ||||||
|                       ..._parseHtmlToWidgets(_articleFragment!.children), |                       MarkdownTextContent( | ||||||
|  |                         textScaler: TextScaler.linear(1.2), | ||||||
|  |                         content: html2md.convert(_article!.content), | ||||||
|  |                       ), | ||||||
|                       const Divider(), |                       const Divider(), | ||||||
|                       InkWell( |                       InkWell( | ||||||
|                         child: Row( |                         child: Row( | ||||||
| @@ -211,7 +127,8 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> { | |||||||
|                           children: [ |                           children: [ | ||||||
|                             Text( |                             Text( | ||||||
|                               'Reference from original website', |                               'Reference from original website', | ||||||
|                               style: TextStyle(decoration: TextDecoration.underline), |                               style: TextStyle( | ||||||
|  |                                   decoration: TextDecoration.underline), | ||||||
|                             ), |                             ), | ||||||
|                             const Gap(4), |                             const Gap(4), | ||||||
|                             Icon(Icons.launch, size: 16), |                             Icon(Icons.launch, size: 16), | ||||||
|   | |||||||
| @@ -29,6 +29,7 @@ const Map<String, IconData> kNotificationTopicIcons = { | |||||||
|   'passport.security.otp': Symbols.password, |   'passport.security.otp': Symbols.password, | ||||||
|   'interactive.subscription': Symbols.subscriptions, |   'interactive.subscription': Symbols.subscriptions, | ||||||
|   'interactive.feedback': Symbols.add_reaction, |   'interactive.feedback': Symbols.add_reaction, | ||||||
|  |   'interactive.reply': Symbols.reply, | ||||||
|   'messaging.callStart': Symbols.call_received, |   'messaging.callStart': Symbols.call_received, | ||||||
|   'wallet.transaction.new': Symbols.receipt, |   'wallet.transaction.new': Symbols.receipt, | ||||||
| }; | }; | ||||||
| @@ -57,11 +58,15 @@ class _NotificationScreenState extends State<NotificationScreen> { | |||||||
|     try { |     try { | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final nty = context.read<NotificationProvider>(); |       final nty = context.read<NotificationProvider>(); | ||||||
|       final resp = await sn.client.get('/cgi/id/notifications?take=10'); |       final resp = await sn.client.get( | ||||||
|       _totalCount = resp.data['count']; |         '/cgi/id/notifications', | ||||||
|       _notifications.addAll( |         queryParameters: {'take': 10, 'offset': _notifications.length}, | ||||||
|         resp.data['data']?.map((e) => SnNotification.fromJson(e)).cast<SnNotification>() ?? [], |  | ||||||
|       ); |       ); | ||||||
|  |       _totalCount = resp.data['count']; | ||||||
|  |       _notifications.addAll(resp.data['data'] | ||||||
|  |               ?.map((e) => SnNotification.fromJson(e)) | ||||||
|  |               .cast<SnNotification>() ?? | ||||||
|  |           []); | ||||||
|       nty.updateTray(); |       nty.updateTray(); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
| @@ -97,8 +102,7 @@ class _NotificationScreenState extends State<NotificationScreen> { | |||||||
|  |  | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showSnackbar( |       context.showSnackbar( | ||||||
|         'notificationMarkAllReadPrompt'.plural(resp.data['count']), |           'notificationMarkAllReadPrompt'.plural(resp.data['count'])); | ||||||
|       ); |  | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showErrorDialog(err); |       context.showErrorDialog(err); | ||||||
| @@ -123,8 +127,7 @@ class _NotificationScreenState extends State<NotificationScreen> { | |||||||
|  |  | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showSnackbar( |       context.showSnackbar( | ||||||
|         'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']), |           'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}'])); | ||||||
|       ); |  | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showErrorDialog(err); |       context.showErrorDialog(err); | ||||||
| @@ -146,12 +149,10 @@ class _NotificationScreenState extends State<NotificationScreen> { | |||||||
|     if (!ua.isAuthorized) { |     if (!ua.isAuthorized) { | ||||||
|       return AppScaffold( |       return AppScaffold( | ||||||
|         appBar: AppBar( |         appBar: AppBar( | ||||||
|           leading: AutoAppBarLeading(), |           leading: PageBackButton(), | ||||||
|           title: Text('screenNotification').tr(), |           title: Text('screenNotification').tr(), | ||||||
|         ), |         ), | ||||||
|         body: Center( |         body: Center(child: UnauthorizedHint()), | ||||||
|           child: UnauthorizedHint(), |  | ||||||
|         ), |  | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -162,8 +163,7 @@ class _NotificationScreenState extends State<NotificationScreen> { | |||||||
|         actions: [ |         actions: [ | ||||||
|           IconButton( |           IconButton( | ||||||
|               icon: const Icon(Symbols.checklist), |               icon: const Icon(Symbols.checklist), | ||||||
|             onPressed: _isSubmitting ? null : _markAllAsRead, |               onPressed: _isSubmitting ? null : _markAllAsRead), | ||||||
|           ), |  | ||||||
|           const Gap(8), |           const Gap(8), | ||||||
|         ], |         ], | ||||||
|       ), |       ), | ||||||
| @@ -179,14 +179,15 @@ class _NotificationScreenState extends State<NotificationScreen> { | |||||||
|               child: InfiniteList( |               child: InfiniteList( | ||||||
|                 padding: EdgeInsets.only( |                 padding: EdgeInsets.only( | ||||||
|                     top: 16, |                     top: 16, | ||||||
|                   bottom: math.max(MediaQuery.of(context).padding.bottom, 16), |                     bottom: | ||||||
|                 ), |                         math.max(MediaQuery.of(context).padding.bottom, 16)), | ||||||
|                 itemCount: _notifications.length, |                 itemCount: _notifications.length, | ||||||
|                 onFetchData: () { |                 onFetchData: () { | ||||||
|                   _fetchNotifications(); |                   _fetchNotifications(); | ||||||
|                 }, |                 }, | ||||||
|                 isLoading: _isBusy, |                 isLoading: _isBusy, | ||||||
|                 hasReachedMax: _totalCount != null && _notifications.length >= _totalCount!, |                 hasReachedMax: _totalCount != null && | ||||||
|  |                     _notifications.length >= _totalCount!, | ||||||
|                 itemBuilder: (context, idx) { |                 itemBuilder: (context, idx) { | ||||||
|                   final nty = _notifications[idx]; |                   final nty = _notifications[idx]; | ||||||
|                   return Row( |                   return Row( | ||||||
| @@ -200,50 +201,48 @@ class _NotificationScreenState extends State<NotificationScreen> { | |||||||
|                           children: [ |                           children: [ | ||||||
|                             if (nty.readAt == null) |                             if (nty.readAt == null) | ||||||
|                               StyledWidget(Badge( |                               StyledWidget(Badge( | ||||||
|                                 label: Text('notificationUnread').tr(), |                                       label: Text('notificationUnread').tr())) | ||||||
|                               )).padding(bottom: 4), |                                   .padding(bottom: 4), | ||||||
|                             Text( |                             Text(nty.title, | ||||||
|                               nty.title, |                                 style: Theme.of(context).textTheme.titleMedium), | ||||||
|                               style: Theme.of(context).textTheme.titleMedium, |  | ||||||
|                             ), |  | ||||||
|                             if (nty.subtitle != null) |                             if (nty.subtitle != null) | ||||||
|                               Text( |                               Text(nty.subtitle!, | ||||||
|                                 nty.subtitle!, |                                   style: | ||||||
|                                 style: Theme.of(context).textTheme.titleSmall, |                                       Theme.of(context).textTheme.titleSmall), | ||||||
|                               ), |  | ||||||
|                             if (nty.subtitle != null) const Gap(4), |                             if (nty.subtitle != null) const Gap(4), | ||||||
|                             SelectionArea( |                             SelectionArea( | ||||||
|                                 child: MarkdownTextContent( |                                 child: MarkdownTextContent( | ||||||
|                                 content: nty.body, |                                     content: nty.body, isAutoWarp: true)), | ||||||
|                                 isAutoWarp: true, |                             if ([ | ||||||
|                               ), |                                   'interactive.reply', | ||||||
|                             ), |                                   'interactive.feedback', | ||||||
|                             if (['interactive.reply', 'interactive.feedback', 'interactive.subscription'] |                                   'interactive.subscription', | ||||||
|                                     .contains(nty.topic) && |                                 ].contains(nty.topic) && | ||||||
|                                 nty.metadata['related_post'] != null) |                                 nty.metadata['related_post'] != null) | ||||||
|                               GestureDetector( |                               GestureDetector( | ||||||
|                                 child: Container( |                                 child: Container( | ||||||
|                                   decoration: BoxDecoration( |                                   decoration: BoxDecoration( | ||||||
|                                     borderRadius: const BorderRadius.all(Radius.circular(8)), |                                     borderRadius: const BorderRadius.all( | ||||||
|  |                                         Radius.circular(8)), | ||||||
|                                     border: Border.all( |                                     border: Border.all( | ||||||
|                                         color: Theme.of(context).dividerColor, |                                         color: Theme.of(context).dividerColor, | ||||||
|                                       width: 1, |                                         width: 1), | ||||||
|                                     ), |  | ||||||
|                                   ), |                                   ), | ||||||
|                                   child: PostItem( |                                   child: PostItem( | ||||||
|                                     data: SnPost.fromJson( |                                     data: SnPost.fromJson( | ||||||
|                                       nty.metadata['related_post']!, |                                         nty.metadata['related_post']!), | ||||||
|                                     ), |  | ||||||
|                                     showComments: false, |                                     showComments: false, | ||||||
|                                     showReactions: false, |                                     showReactions: false, | ||||||
|                                     showMenu: false, |                                     showMenu: false, | ||||||
|                                   ), |                                   ).padding(vertical: 4), | ||||||
|                                 ), |                                 ), | ||||||
|                                 onTap: () { |                                 onTap: () { | ||||||
|                                   GoRouter.of(context).pushNamed( |                                   GoRouter.of(context).pushNamed( | ||||||
|                                     'postDetail', |                                     'postDetail', | ||||||
|                                     pathParameters: { |                                     pathParameters: { | ||||||
|                                       'slug': nty.metadata['related_post']!['id'].toString(), |                                       'slug': nty | ||||||
|  |                                           .metadata['related_post']!['id'] | ||||||
|  |                                           .toString() | ||||||
|                                     }, |                                     }, | ||||||
|                                   ); |                                   ); | ||||||
|                                 }, |                                 }, | ||||||
| @@ -251,18 +250,15 @@ class _NotificationScreenState extends State<NotificationScreen> { | |||||||
|                             const Gap(8), |                             const Gap(8), | ||||||
|                             Row( |                             Row( | ||||||
|                               children: [ |                               children: [ | ||||||
|                                 Text( |                                 Text(DateFormat('yy/MM/dd') | ||||||
|                                   DateFormat('yy/MM/dd').format(nty.createdAt), |                                         .format(nty.createdAt)) | ||||||
|                                 ).fontSize(12), |                                     .fontSize(12), | ||||||
|                                 const Gap(4), |                                 const Gap(4), | ||||||
|                                 Text( |                                 Text('·', style: TextStyle(fontSize: 12)), | ||||||
|                                   '·', |  | ||||||
|                                   style: TextStyle(fontSize: 12), |  | ||||||
|                                 ), |  | ||||||
|                                 const Gap(4), |                                 const Gap(4), | ||||||
|                                 Text( |                                 Text(RelativeTime(context) | ||||||
|                                   RelativeTime(context).format(nty.createdAt), |                                         .format(nty.createdAt)) | ||||||
|                                 ).fontSize(12), |                                     .fontSize(12), | ||||||
|                               ], |                               ], | ||||||
|                             ).opacity(0.75), |                             ).opacity(0.75), | ||||||
|                           ], |                           ], | ||||||
| @@ -272,8 +268,10 @@ class _NotificationScreenState extends State<NotificationScreen> { | |||||||
|                       IconButton( |                       IconButton( | ||||||
|                         icon: const Icon(Symbols.check), |                         icon: const Icon(Symbols.check), | ||||||
|                         padding: EdgeInsets.all(0), |                         padding: EdgeInsets.all(0), | ||||||
|                         visualDensity: const VisualDensity(horizontal: -4, vertical: -4), |                         visualDensity: | ||||||
|                         onPressed: _isSubmitting ? null : () => _markOneAsRead(nty), |                             const VisualDensity(horizontal: -4, vertical: -4), | ||||||
|  |                         onPressed: | ||||||
|  |                             _isSubmitting ? null : () => _markOneAsRead(nty), | ||||||
|                       ), |                       ), | ||||||
|                     ], |                     ], | ||||||
|                   ).padding(horizontal: 16); |                   ).padding(horizontal: 16); | ||||||
|   | |||||||
| @@ -12,7 +12,6 @@ import 'package:surface/providers/userinfo.dart'; | |||||||
| import 'package:surface/types/post.dart'; | import 'package:surface/types/post.dart'; | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
| import 'package:surface/widgets/loading_indicator.dart'; | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
| import 'package:surface/widgets/navigation/app_background.dart'; |  | ||||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
| import 'package:surface/widgets/post/post_comment_list.dart'; | import 'package:surface/widgets/post/post_comment_list.dart'; | ||||||
| import 'package:surface/widgets/post/post_item.dart'; | import 'package:surface/widgets/post/post_item.dart'; | ||||||
| @@ -22,7 +21,8 @@ class PostDetailScreen extends StatefulWidget { | |||||||
|   final SnPost? preload; |   final SnPost? preload; | ||||||
|   final Function? onBack; |   final Function? onBack; | ||||||
|  |  | ||||||
|   const PostDetailScreen({super.key, required this.slug, this.preload, this.onBack}); |   const PostDetailScreen( | ||||||
|  |       {super.key, required this.slug, this.preload, this.onBack}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   State<PostDetailScreen> createState() => _PostDetailScreenState(); |   State<PostDetailScreen> createState() => _PostDetailScreenState(); | ||||||
| @@ -65,9 +65,8 @@ class _PostDetailScreenState extends State<PostDetailScreen> { | |||||||
|  |  | ||||||
|     final double maxWidth = _data?.type == 'video' ? double.infinity : 640; |     final double maxWidth = _data?.type == 'video' ? double.infinity : 640; | ||||||
|  |  | ||||||
|     return AppBackground( |     return AppScaffold( | ||||||
|       isRoot: widget.onBack != null, |       noBackground: true, | ||||||
|       child: AppScaffold( |  | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         leading: BackButton( |         leading: BackButton( | ||||||
|           onPressed: () { |           onPressed: () { | ||||||
| @@ -124,8 +123,11 @@ class _PostDetailScreenState extends State<PostDetailScreen> { | |||||||
|                 }, |                 }, | ||||||
|               ), |               ), | ||||||
|             ), |             ), | ||||||
|             if (_data != null && _data!.type != 'video') const SliverToBoxAdapter(child: Divider(height: 1)), |           if (_data != null) | ||||||
|             if (_data != null && _data!.type != 'video') |             SliverToBoxAdapter( | ||||||
|  |               child: Divider(height: 1).padding(top: 8), | ||||||
|  |             ), | ||||||
|  |           if (_data != null) | ||||||
|             SliverToBoxAdapter( |             SliverToBoxAdapter( | ||||||
|               child: Container( |               child: Container( | ||||||
|                 constraints: BoxConstraints(maxWidth: maxWidth), |                 constraints: BoxConstraints(maxWidth: maxWidth), | ||||||
| @@ -141,7 +143,7 @@ class _PostDetailScreenState extends State<PostDetailScreen> { | |||||||
|                 ).padding(horizontal: 20, vertical: 12).center(), |                 ).padding(horizontal: 20, vertical: 12).center(), | ||||||
|               ), |               ), | ||||||
|             ), |             ), | ||||||
|             if (_data != null && ua.isAuthorized && _data!.type != 'video') |           if (_data != null && ua.isAuthorized) | ||||||
|             SliverToBoxAdapter( |             SliverToBoxAdapter( | ||||||
|               child: PostCommentQuickAction( |               child: PostCommentQuickAction( | ||||||
|                 parentPost: _data!, |                 parentPost: _data!, | ||||||
| @@ -158,16 +160,17 @@ class _PostDetailScreenState extends State<PostDetailScreen> { | |||||||
|                 }, |                 }, | ||||||
|               ), |               ), | ||||||
|             ), |             ), | ||||||
|             if (_data != null && _data!.type != 'video') |           if (_data != null) SliverGap(8), | ||||||
|  |           if (_data != null) | ||||||
|             PostCommentSliverList( |             PostCommentSliverList( | ||||||
|               key: _childListKey, |               key: _childListKey, | ||||||
|               parentPost: _data!, |               parentPost: _data!, | ||||||
|               maxWidth: maxWidth, |               maxWidth: maxWidth, | ||||||
|             ), |             ), | ||||||
|             if (_data != null && _data!.type == 'video') SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)), |           if (_data != null) | ||||||
|  |             SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)), | ||||||
|         ], |         ], | ||||||
|       ), |       ), | ||||||
|       ), |  | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										89
									
								
								lib/screens/post/post_draft.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								lib/screens/post/post_draft.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,89 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:surface/providers/post.dart'; | ||||||
|  | import 'package:surface/types/post.dart'; | ||||||
|  | import 'package:surface/widgets/dialog.dart'; | ||||||
|  | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  | import 'package:surface/widgets/post/post_item.dart'; | ||||||
|  | import 'package:very_good_infinite_list/very_good_infinite_list.dart'; | ||||||
|  |  | ||||||
|  | class PostDraftBox extends StatefulWidget { | ||||||
|  |   const PostDraftBox({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<PostDraftBox> createState() => _PostDraftBoxState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _PostDraftBoxState extends State<PostDraftBox> { | ||||||
|  |   bool _isBusy = false; | ||||||
|  |   final List<SnPost> _posts = List.empty(growable: true); | ||||||
|  |   int? _totalCount; | ||||||
|  |  | ||||||
|  |   Future<void> _fetchPosts() async { | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |     try { | ||||||
|  |       final pt = context.read<SnPostContentProvider>(); | ||||||
|  |       final resp = await pt.listPosts( | ||||||
|  |         take: 10, | ||||||
|  |         offset: _posts.length, | ||||||
|  |         isDraft: true, | ||||||
|  |       ); | ||||||
|  |       final out = resp.$1; | ||||||
|  |       _totalCount = resp.$2; | ||||||
|  |       if (!mounted) return; | ||||||
|  |       _posts.addAll(out); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AppScaffold( | ||||||
|  |       appBar: AppBar( | ||||||
|  |         title: Text('postDraftBox').tr(), | ||||||
|  |       ), | ||||||
|  |       body: Column( | ||||||
|  |         children: [ | ||||||
|  |           LoadingIndicator(isActive: _isBusy), | ||||||
|  |           Expanded( | ||||||
|  |             child: RefreshIndicator( | ||||||
|  |               onRefresh: () { | ||||||
|  |                 _posts.clear(); | ||||||
|  |                 return _fetchPosts(); | ||||||
|  |               }, | ||||||
|  |               child: InfiniteList( | ||||||
|  |                 padding: EdgeInsets.only(top: 8), | ||||||
|  |                 hasReachedMax: | ||||||
|  |                     _totalCount != null && _posts.length >= _totalCount!, | ||||||
|  |                 itemCount: _posts.length, | ||||||
|  |                 onFetchData: () => _fetchPosts(), | ||||||
|  |                 itemBuilder: (context, idx) { | ||||||
|  |                   final ele = _posts[idx]; | ||||||
|  |                   return OpenablePostItem( | ||||||
|  |                     data: ele, | ||||||
|  |                     onChanged: (data) { | ||||||
|  |                       _posts[idx] = data; | ||||||
|  |                     }, | ||||||
|  |                     onDeleted: () { | ||||||
|  |                       _posts.clear(); | ||||||
|  |                       _fetchPosts(); | ||||||
|  |                     }, | ||||||
|  |                   ); | ||||||
|  |                 }, | ||||||
|  |                 separatorBuilder: (_, __) => | ||||||
|  |                     const Divider().padding(vertical: 2), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -18,6 +18,7 @@ import 'package:surface/controllers/post_write_controller.dart'; | |||||||
| import 'package:surface/providers/config.dart'; | import 'package:surface/providers/config.dart'; | ||||||
| import 'package:surface/providers/sn_attachment.dart'; | import 'package:surface/providers/sn_attachment.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
|  | import 'package:surface/providers/sn_realm.dart'; | ||||||
| import 'package:surface/types/attachment.dart'; | import 'package:surface/types/attachment.dart'; | ||||||
| import 'package:surface/types/post.dart'; | import 'package:surface/types/post.dart'; | ||||||
| import 'package:surface/types/realm.dart'; | import 'package:surface/types/realm.dart'; | ||||||
| @@ -36,24 +37,27 @@ import 'package:provider/provider.dart'; | |||||||
| import 'package:surface/widgets/post/post_poll_editor.dart'; | import 'package:surface/widgets/post/post_poll_editor.dart'; | ||||||
| import 'package:uuid/uuid.dart'; | import 'package:uuid/uuid.dart'; | ||||||
|  |  | ||||||
| import '../../providers/sn_realm.dart'; | const kPostTypes = ['Story', 'Article', 'Question', 'Video']; | ||||||
|  | const kPostTypeAliases = ['stories', 'articles', 'questions', 'videos']; | ||||||
|  |  | ||||||
| class PostEditorExtra { | class PostEditorExtra { | ||||||
|   final String? text; |   final String? text; | ||||||
|   final String? title; |   final String? title; | ||||||
|   final String? description; |   final String? description; | ||||||
|   final List<PostWriteMedia>? attachments; |   final List<PostWriteMedia>? attachments; | ||||||
|  |   final SnRealm? realm; | ||||||
|  |  | ||||||
|   const PostEditorExtra({ |   const PostEditorExtra({ | ||||||
|     this.text, |     this.text, | ||||||
|     this.title, |     this.title, | ||||||
|     this.description, |     this.description, | ||||||
|     this.attachments, |     this.attachments, | ||||||
|  |     this.realm, | ||||||
|   }); |   }); | ||||||
| } | } | ||||||
|  |  | ||||||
| class PostEditorScreen extends StatefulWidget { | class PostEditorScreen extends StatefulWidget { | ||||||
|   final String mode; |   final String? mode; | ||||||
|   final int? postEditId; |   final int? postEditId; | ||||||
|   final int? postReplyId; |   final int? postReplyId; | ||||||
|   final int? postRepostId; |   final int? postRepostId; | ||||||
| @@ -72,7 +76,10 @@ class PostEditorScreen extends StatefulWidget { | |||||||
|   State<PostEditorScreen> createState() => _PostEditorScreenState(); |   State<PostEditorScreen> createState() => _PostEditorScreenState(); | ||||||
| } | } | ||||||
|  |  | ||||||
| class _PostEditorScreenState extends State<PostEditorScreen> { | class _PostEditorScreenState extends State<PostEditorScreen> | ||||||
|  |     with SingleTickerProviderStateMixin { | ||||||
|  |   late final TabController _tabController = | ||||||
|  |       TabController(length: 4, vsync: this); | ||||||
|   late final PostWriteController _writeController = PostWriteController( |   late final PostWriteController _writeController = PostWriteController( | ||||||
|     doLoadFromTemporary: widget.postEditId == null, |     doLoadFromTemporary: widget.postEditId == null, | ||||||
|   ); |   ); | ||||||
| @@ -95,8 +102,9 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|         resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], |         resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], | ||||||
|       ); |       ); | ||||||
|       final beforeId = config.prefs.getInt('int_last_publisher_id'); |       final beforeId = config.prefs.getInt('int_last_publisher_id'); | ||||||
|       _writeController |       _writeController.setPublisher( | ||||||
|           .setPublisher(_publishers?.where((ele) => ele.id == beforeId).firstOrNull ?? _publishers?.firstOrNull); |           _publishers?.where((ele) => ele.id == beforeId).firstOrNull ?? | ||||||
|  |               _publishers?.firstOrNull); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showErrorDialog(err); |       context.showErrorDialog(err); | ||||||
| @@ -125,7 +133,20 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|  |  | ||||||
|   final HotKey _pasteHotKey = HotKey( |   final HotKey _pasteHotKey = HotKey( | ||||||
|     key: PhysicalKeyboardKey.keyV, |     key: PhysicalKeyboardKey.keyV, | ||||||
|     modifiers: [(!kIsWeb && Platform.isMacOS) ? HotKeyModifier.meta : HotKeyModifier.control], |     modifiers: [ | ||||||
|  |       (!kIsWeb && Platform.isMacOS) | ||||||
|  |           ? HotKeyModifier.meta | ||||||
|  |           : HotKeyModifier.control | ||||||
|  |     ], | ||||||
|  |     scope: HotKeyScope.inapp, | ||||||
|  |   ); | ||||||
|  |   final HotKey _saveDraftHotKey = HotKey( | ||||||
|  |     key: PhysicalKeyboardKey.keyS, | ||||||
|  |     modifiers: [ | ||||||
|  |       (!kIsWeb && Platform.isMacOS) | ||||||
|  |           ? HotKeyModifier.meta | ||||||
|  |           : HotKeyModifier.control | ||||||
|  |     ], | ||||||
|     scope: HotKeyScope.inapp, |     scope: HotKeyScope.inapp, | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
| @@ -143,6 +164,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|       ]); |       ]); | ||||||
|       setState(() {}); |       setState(() {}); | ||||||
|     }); |     }); | ||||||
|  |     hotKeyManager.register(_saveDraftHotKey, keyDownHandler: (_) async { | ||||||
|  |       if (mounted) { | ||||||
|  |         _writeController.sendPost(context, saveAsDraft: true); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void _showPublisherPopup() { |   void _showPublisherPopup() { | ||||||
| @@ -204,9 +230,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void dispose() { |   void dispose() { | ||||||
|  |     _tabController.dispose(); | ||||||
|     _writeController.dispose(); |     _writeController.dispose(); | ||||||
|     if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) { |     if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) { | ||||||
|       hotKeyManager.unregister(_pasteHotKey); |       hotKeyManager.unregister(_pasteHotKey); | ||||||
|  |       hotKeyManager.unregister(_saveDraftHotKey); | ||||||
|     } |     } | ||||||
|     super.dispose(); |     super.dispose(); | ||||||
|   } |   } | ||||||
| @@ -215,14 +243,16 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|   void initState() { |   void initState() { | ||||||
|     super.initState(); |     super.initState(); | ||||||
|     _registerHotKey(); |     _registerHotKey(); | ||||||
|     if (!PostWriteController.kTitleMap.keys.contains(widget.mode)) { |  | ||||||
|       context.showErrorDialog('Unknown post type'); |  | ||||||
|       Navigator.pop(context); |  | ||||||
|     } else { |  | ||||||
|       _writeController.setMode(widget.mode); |  | ||||||
|     } |  | ||||||
|     _fetchRealms(); |     _fetchRealms(); | ||||||
|     _fetchPublishers(); |     _fetchPublishers(); | ||||||
|  |     if (widget.mode != null) { | ||||||
|  |       _writeController.setMode(widget.mode!); | ||||||
|  |     } | ||||||
|  |     _tabController.addListener(() { | ||||||
|  |       if (_tabController.indexIsChanging) { | ||||||
|  |         _writeController.setMode(kPostTypeAliases[_tabController.index]); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|     _writeController.fetchRelatedPost( |     _writeController.fetchRelatedPost( | ||||||
|       context, |       context, | ||||||
|       editing: widget.postEditId, |       editing: widget.postEditId, | ||||||
| @@ -232,8 +262,10 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|     if (widget.extraProps != null) { |     if (widget.extraProps != null) { | ||||||
|       _writeController.contentController.text = widget.extraProps!.text ?? ''; |       _writeController.contentController.text = widget.extraProps!.text ?? ''; | ||||||
|       _writeController.titleController.text = widget.extraProps!.title ?? ''; |       _writeController.titleController.text = widget.extraProps!.title ?? ''; | ||||||
|       _writeController.descriptionController.text = widget.extraProps!.description ?? ''; |       _writeController.descriptionController.text = | ||||||
|  |           widget.extraProps!.description ?? ''; | ||||||
|       _writeController.addAttachments(widget.extraProps!.attachments ?? []); |       _writeController.addAttachments(widget.extraProps!.attachments ?? []); | ||||||
|  |       _writeController.setRealm(widget.extraProps!.realm); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -249,38 +281,58 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|                 Navigator.pop(context); |                 Navigator.pop(context); | ||||||
|               }, |               }, | ||||||
|             ), |             ), | ||||||
|             title: RichText( |             title: Text( | ||||||
|               textAlign: TextAlign.center, |               _writeController.title.isNotEmpty | ||||||
|               text: TextSpan(children: [ |                   ? _writeController.title | ||||||
|                 TextSpan( |                   : 'untitled'.tr(), | ||||||
|                   text: _writeController.title.isNotEmpty ? _writeController.title : 'untitled'.tr(), |  | ||||||
|                   style: Theme.of(context).textTheme.titleLarge!.copyWith( |  | ||||||
|                         color: Theme.of(context).appBarTheme.foregroundColor!, |  | ||||||
|                       ), |  | ||||||
|                 ), |  | ||||||
|                 const TextSpan(text: '\n'), |  | ||||||
|                 TextSpan( |  | ||||||
|                   text: PostWriteController.kTitleMap[widget.mode]!.tr(), |  | ||||||
|                   style: Theme.of(context).textTheme.bodySmall!.copyWith( |  | ||||||
|                         color: Theme.of(context).appBarTheme.foregroundColor!, |  | ||||||
|                       ), |  | ||||||
|                 ), |  | ||||||
|               ]), |  | ||||||
|               maxLines: 2, |  | ||||||
|             ), |             ), | ||||||
|             actions: [ |             actions: [ | ||||||
|  |               IconButton( | ||||||
|  |                 icon: _writeController.editingDraft | ||||||
|  |                     ? const Icon(Icons.save) | ||||||
|  |                     : const Icon(Symbols.save_as), | ||||||
|  |                 onPressed: () { | ||||||
|  |                   _writeController.sendPost(context, saveAsDraft: true).then( | ||||||
|  |                     (_) { | ||||||
|  |                       if (!context.mounted) return; | ||||||
|  |                       context.showSnackbar('postDraftSaved'.tr()); | ||||||
|  |                       HapticFeedback.mediumImpact(); | ||||||
|  |                     }, | ||||||
|  |                   ); | ||||||
|  |                 }, | ||||||
|  |               ), | ||||||
|               IconButton( |               IconButton( | ||||||
|                 icon: const Icon(Symbols.tune), |                 icon: const Icon(Symbols.tune), | ||||||
|                 onPressed: _writeController.isBusy ? null : _updateMeta, |                 onPressed: _writeController.isBusy ? null : _updateMeta, | ||||||
|               ), |               ), | ||||||
|               const Gap(8), |               const Gap(8), | ||||||
|             ], |             ], | ||||||
|  |             bottom: _writeController.isNotEmpty || widget.mode != null | ||||||
|  |                 ? null | ||||||
|  |                 : TabBar( | ||||||
|  |                     controller: _tabController, | ||||||
|  |                     tabs: [ | ||||||
|  |                       for (final type in kPostTypes) | ||||||
|  |                         Tab( | ||||||
|  |                           child: Text( | ||||||
|  |                             'postType$type'.tr(), | ||||||
|  |                             style: TextStyle( | ||||||
|  |                               color: Theme.of(context) | ||||||
|  |                                   .appBarTheme | ||||||
|  |                                   .foregroundColor!, | ||||||
|  |                             ), | ||||||
|  |                           ), | ||||||
|  |                         ), | ||||||
|  |                     ], | ||||||
|  |                   ), | ||||||
|           ), |           ), | ||||||
|           body: Column( |           body: Column( | ||||||
|             children: [ |             children: [ | ||||||
|               if (_writeController.editingPost != null) |               if (_writeController.editingPost != null && | ||||||
|  |                   !_writeController.editingDraft) | ||||||
|                 Container( |                 Container( | ||||||
|                   padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20, right: 20), |                   padding: const EdgeInsets.only( | ||||||
|  |                       top: 4, bottom: 4, left: 20, right: 20), | ||||||
|                   decoration: BoxDecoration( |                   decoration: BoxDecoration( | ||||||
|                     border: Border( |                     border: Border( | ||||||
|                       bottom: BorderSide( |                       bottom: BorderSide( | ||||||
| @@ -294,13 +346,16 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|                     children: [ |                     children: [ | ||||||
|                       const Icon(Icons.edit, size: 16), |                       const Icon(Icons.edit, size: 16), | ||||||
|                       const Gap(10), |                       const Gap(10), | ||||||
|                       Text('postEditingNotice').tr(args: ['@${_writeController.editingPost!.publisher.name}']), |                       Text('postEditingNotice').tr(args: [ | ||||||
|  |                         '@${_writeController.editingPost!.publisher.name}' | ||||||
|  |                       ]), | ||||||
|                     ], |                     ], | ||||||
|                   ), |                   ), | ||||||
|                 ), |                 ), | ||||||
|               if (_writeController.replyingPost != null) |               if (_writeController.replyingPost != null) | ||||||
|                 Container( |                 Container( | ||||||
|                   padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20, right: 20), |                   padding: const EdgeInsets.only( | ||||||
|  |                       top: 4, bottom: 4, left: 20, right: 20), | ||||||
|                   decoration: BoxDecoration( |                   decoration: BoxDecoration( | ||||||
|                     border: Border( |                     border: Border( | ||||||
|                       bottom: BorderSide( |                       bottom: BorderSide( | ||||||
| @@ -314,7 +369,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|                     children: [ |                     children: [ | ||||||
|                       const Icon(Symbols.reply, size: 16), |                       const Icon(Symbols.reply, size: 16), | ||||||
|                       const Gap(10), |                       const Gap(10), | ||||||
|                       Text('@${_writeController.replyingPost!.publisher.name}').bold(), |                       Text('@${_writeController.replyingPost!.publisher.name}') | ||||||
|  |                           .bold(), | ||||||
|                       const Gap(4), |                       const Gap(4), | ||||||
|                       Expanded( |                       Expanded( | ||||||
|                         child: Text( |                         child: Text( | ||||||
| @@ -328,7 +384,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|                 ), |                 ), | ||||||
|               if (_writeController.repostingPost != null) |               if (_writeController.repostingPost != null) | ||||||
|                 Container( |                 Container( | ||||||
|                   padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20, right: 20), |                   padding: const EdgeInsets.only( | ||||||
|  |                       top: 4, bottom: 4, left: 20, right: 20), | ||||||
|                   decoration: BoxDecoration( |                   decoration: BoxDecoration( | ||||||
|                     border: Border( |                     border: Border( | ||||||
|                       bottom: BorderSide( |                       bottom: BorderSide( | ||||||
| @@ -342,7 +399,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|                     children: [ |                     children: [ | ||||||
|                       const Icon(Symbols.forward, size: 16), |                       const Icon(Symbols.forward, size: 16), | ||||||
|                       const Gap(10), |                       const Gap(10), | ||||||
|                       Text('@${_writeController.repostingPost!.publisher.name}').bold(), |                       Text('@${_writeController.repostingPost!.publisher.name}') | ||||||
|  |                           .bold(), | ||||||
|                       const Gap(4), |                       const Gap(4), | ||||||
|                       Expanded( |                       Expanded( | ||||||
|                         child: Text( |                         child: Text( | ||||||
| @@ -359,7 +417,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|                   children: [ |                   children: [ | ||||||
|                     SingleChildScrollView( |                     SingleChildScrollView( | ||||||
|                       padding: EdgeInsets.only(bottom: 160), |                       padding: EdgeInsets.only(bottom: 160), | ||||||
|                       child: StyledWidget(switch (_writeController.mode) { |                       child: switch (_writeController.mode) { | ||||||
|                         'stories' => _PostStoryEditor( |                         'stories' => _PostStoryEditor( | ||||||
|                             controller: _writeController, |                             controller: _writeController, | ||||||
|                             onTapPublisher: _showPublisherPopup, |                             onTapPublisher: _showPublisherPopup, | ||||||
| @@ -381,10 +439,10 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|                             onTapRealm: _showRealmPopup, |                             onTapRealm: _showRealmPopup, | ||||||
|                           ), |                           ), | ||||||
|                         _ => const Placeholder(), |                         _ => const Placeholder(), | ||||||
|                       }) |                       }, | ||||||
|                           .padding(top: 8), |  | ||||||
|                     ), |                     ), | ||||||
|                     if (_writeController.attachments.isNotEmpty || _writeController.thumbnail != null) |                     if (_writeController.attachments.isNotEmpty || | ||||||
|  |                         _writeController.thumbnail != null) | ||||||
|                       Positioned( |                       Positioned( | ||||||
|                         bottom: 0, |                         bottom: 0, | ||||||
|                         left: 0, |                         left: 0, | ||||||
| @@ -393,16 +451,19 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|                           attachments: _writeController.attachments, |                           attachments: _writeController.attachments, | ||||||
|                           isBusy: _writeController.isBusy, |                           isBusy: _writeController.isBusy, | ||||||
|                           onUpload: (int idx) async { |                           onUpload: (int idx) async { | ||||||
|                             await _writeController.uploadSingleAttachment(context, idx); |                             await _writeController.uploadSingleAttachment( | ||||||
|  |                                 context, idx); | ||||||
|                           }, |                           }, | ||||||
|                           onInsertLink: (int idx) async { |                           onInsertLink: (int idx) async { | ||||||
|                             _writeController.contentController.text += |                             _writeController.contentController.text += | ||||||
|                                 '\n'; |                                 '\n'; | ||||||
|                           }, |                           }, | ||||||
|                           onUpdate: (int idx, PostWriteMedia updatedMedia) async { |                           onUpdate: | ||||||
|  |                               (int idx, PostWriteMedia updatedMedia) async { | ||||||
|                             _writeController.setIsBusy(true); |                             _writeController.setIsBusy(true); | ||||||
|                             try { |                             try { | ||||||
|                               _writeController.setAttachmentAt(idx, updatedMedia); |                               _writeController.setAttachmentAt( | ||||||
|  |                                   idx, updatedMedia); | ||||||
|                             } finally { |                             } finally { | ||||||
|                               _writeController.setIsBusy(false); |                               _writeController.setIsBusy(false); | ||||||
|                             } |                             } | ||||||
| @@ -415,7 +476,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|                               _writeController.setIsBusy(false); |                               _writeController.setIsBusy(false); | ||||||
|                             } |                             } | ||||||
|                           }, |                           }, | ||||||
|                           onUpdateBusy: (state) => _writeController.setIsBusy(state), |                           onUpdateBusy: (state) => | ||||||
|  |                               _writeController.setIsBusy(state), | ||||||
|                         ).padding(bottom: 8), |                         ).padding(bottom: 8), | ||||||
|                       ), |                       ), | ||||||
|                   ], |                   ], | ||||||
| @@ -426,11 +488,13 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|                 child: Column( |                 child: Column( | ||||||
|                   crossAxisAlignment: CrossAxisAlignment.start, |                   crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|                   children: [ |                   children: [ | ||||||
|                     if (_writeController.isBusy && _writeController.progress != null) |                     if (_writeController.isBusy && | ||||||
|  |                         _writeController.progress != null) | ||||||
|                       TweenAnimationBuilder<double>( |                       TweenAnimationBuilder<double>( | ||||||
|                         tween: Tween(begin: 0, end: _writeController.progress), |                         tween: Tween(begin: 0, end: _writeController.progress), | ||||||
|                         duration: Duration(milliseconds: 300), |                         duration: Duration(milliseconds: 300), | ||||||
|                         builder: (context, value, _) => LinearProgressIndicator(value: value, minHeight: 2), |                         builder: (context, value, _) => | ||||||
|  |                             LinearProgressIndicator(value: value, minHeight: 2), | ||||||
|                       ) |                       ) | ||||||
|                     else if (_writeController.isBusy) |                     else if (_writeController.isBusy) | ||||||
|                       const LinearProgressIndicator(value: null, minHeight: 2), |                       const LinearProgressIndicator(value: null, minHeight: 2), | ||||||
| @@ -439,12 +503,14 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|                     Container( |                     Container( | ||||||
|                       child: _writeController.temporaryRestored |                       child: _writeController.temporaryRestored | ||||||
|                           ? Container( |                           ? Container( | ||||||
|                               padding: const EdgeInsets.only(top: 4, bottom: 4, left: 28, right: 22), |                               padding: const EdgeInsets.only( | ||||||
|  |                                   top: 4, bottom: 4, left: 28, right: 22), | ||||||
|                               decoration: BoxDecoration( |                               decoration: BoxDecoration( | ||||||
|                                 border: Border( |                                 border: Border( | ||||||
|                                   bottom: BorderSide( |                                   bottom: BorderSide( | ||||||
|                                     color: Theme.of(context).dividerColor, |                                     color: Theme.of(context).dividerColor, | ||||||
|                                     width: 1 / MediaQuery.of(context).devicePixelRatio, |                                     width: 1 / | ||||||
|  |                                         MediaQuery.of(context).devicePixelRatio, | ||||||
|                                   ), |                                   ), | ||||||
|                                 ), |                                 ), | ||||||
|                               ), |                               ), | ||||||
| @@ -453,7 +519,9 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|                                 children: [ |                                 children: [ | ||||||
|                                   const Icon(Icons.restore, size: 20), |                                   const Icon(Icons.restore, size: 20), | ||||||
|                                   const Gap(8), |                                   const Gap(8), | ||||||
|                                   Expanded(child: Text('postLocalDraftRestored').tr()), |                                   Expanded( | ||||||
|  |                                       child: | ||||||
|  |                                           Text('postLocalDraftRestored').tr()), | ||||||
|                                   InkWell( |                                   InkWell( | ||||||
|                                     child: Text('dialogDismiss').tr(), |                                     child: Text('dialogDismiss').tr(), | ||||||
|                                     onTap: () { |                                     onTap: () { | ||||||
| @@ -464,8 +532,10 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|                               )) |                               )) | ||||||
|                           : const SizedBox.shrink(), |                           : const SizedBox.shrink(), | ||||||
|                     ) |                     ) | ||||||
|                         .height(_writeController.temporaryRestored ? 32 : 0, animate: true) |                         .height(_writeController.temporaryRestored ? 32 : 0, | ||||||
|                         .animate(const Duration(milliseconds: 300), Curves.fastLinearToSlowEaseIn), |                             animate: true) | ||||||
|  |                         .animate(const Duration(milliseconds: 300), | ||||||
|  |                             Curves.fastLinearToSlowEaseIn), | ||||||
|                     Row( |                     Row( | ||||||
|                       mainAxisAlignment: MainAxisAlignment.spaceBetween, |                       mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||||
|                       children: [ |                       children: [ | ||||||
| @@ -485,11 +555,18 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|                                   ), |                                   ), | ||||||
|                                   if (_writeController.mode == 'stories') |                                   if (_writeController.mode == 'stories') | ||||||
|                                     IconButton( |                                     IconButton( | ||||||
|                                       icon: Icon(Symbols.poll, color: Theme.of(context).colorScheme.primary), |                                       icon: Icon(Symbols.poll, | ||||||
|  |                                           color: Theme.of(context) | ||||||
|  |                                               .colorScheme | ||||||
|  |                                               .primary), | ||||||
|                                       style: ButtonStyle( |                                       style: ButtonStyle( | ||||||
|                                         backgroundColor: _writeController.poll == null |                                         backgroundColor: | ||||||
|  |                                             _writeController.poll == null | ||||||
|                                                 ? null |                                                 ? null | ||||||
|                                             : WidgetStatePropertyAll(Theme.of(context).colorScheme.surfaceContainer), |                                                 : WidgetStatePropertyAll( | ||||||
|  |                                                     Theme.of(context) | ||||||
|  |                                                         .colorScheme | ||||||
|  |                                                         .surfaceContainer), | ||||||
|                                       ), |                                       ), | ||||||
|                                       onPressed: () { |                                       onPressed: () { | ||||||
|                                         _showPollEditorDialog(); |                                         _showPollEditorDialog(); | ||||||
| @@ -497,14 +574,22 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|                                     ), |                                     ), | ||||||
|                                   if (_writeController.mode == 'articles') |                                   if (_writeController.mode == 'articles') | ||||||
|                                     IconButton( |                                     IconButton( | ||||||
|                                       icon: Icon(Symbols.full_coverage, color: Theme.of(context).colorScheme.primary), |                                       icon: Icon(Symbols.full_coverage, | ||||||
|  |                                           color: Theme.of(context) | ||||||
|  |                                               .colorScheme | ||||||
|  |                                               .primary), | ||||||
|                                       style: ButtonStyle( |                                       style: ButtonStyle( | ||||||
|                                         backgroundColor: _writeController.thumbnail == null |                                         backgroundColor: | ||||||
|  |                                             _writeController.thumbnail == null | ||||||
|                                                 ? null |                                                 ? null | ||||||
|                                             : WidgetStatePropertyAll(Theme.of(context).colorScheme.surfaceContainer), |                                                 : WidgetStatePropertyAll( | ||||||
|  |                                                     Theme.of(context) | ||||||
|  |                                                         .colorScheme | ||||||
|  |                                                         .surfaceContainer), | ||||||
|                                       ), |                                       ), | ||||||
|                                       onPressed: () { |                                       onPressed: () { | ||||||
|                                         if (_writeController.thumbnail != null) { |                                         if (_writeController.thumbnail != | ||||||
|  |                                             null) { | ||||||
|                                           _writeController.setThumbnail(null); |                                           _writeController.setThumbnail(null); | ||||||
|                                           return; |                                           return; | ||||||
|                                         } |                                         } | ||||||
| @@ -517,7 +602,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | |||||||
|                           ), |                           ), | ||||||
|                         ), |                         ), | ||||||
|                         TextButton.icon( |                         TextButton.icon( | ||||||
|                           onPressed: (_writeController.isBusy || _writeController.publisher == null) |                           onPressed: (_writeController.isBusy || | ||||||
|  |                                   _writeController.publisher == null) | ||||||
|                               ? null |                               ? null | ||||||
|                               : () { |                               : () { | ||||||
|                                   _writeController.sendPost(context).then((_) { |                                   _writeController.sendPost(context).then((_) { | ||||||
| @@ -556,7 +642,8 @@ class _PostPublisherPopup extends StatelessWidget { | |||||||
|   final List<SnPublisher>? publishers; |   final List<SnPublisher>? publishers; | ||||||
|   final Function onUpdate; |   final Function onUpdate; | ||||||
|  |  | ||||||
|   const _PostPublisherPopup({required this.controller, this.publishers, required this.onUpdate}); |   const _PostPublisherPopup( | ||||||
|  |       {required this.controller, this.publishers, required this.onUpdate}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
| @@ -568,7 +655,9 @@ class _PostPublisherPopup extends StatelessWidget { | |||||||
|           children: [ |           children: [ | ||||||
|             const Icon(Symbols.face, size: 24), |             const Icon(Symbols.face, size: 24), | ||||||
|             const Gap(16), |             const Gap(16), | ||||||
|             Text('accountPublishers', style: Theme.of(context).textTheme.titleLarge).tr(), |             Text('accountPublishers', | ||||||
|  |                     style: Theme.of(context).textTheme.titleLarge) | ||||||
|  |                 .tr(), | ||||||
|           ], |           ], | ||||||
|         ).padding(horizontal: 20, top: 16, bottom: 12), |         ).padding(horizontal: 20, top: 16, bottom: 12), | ||||||
|         ListTile( |         ListTile( | ||||||
| @@ -612,7 +701,8 @@ class _PostRealmPopup extends StatelessWidget { | |||||||
|   final List<SnRealm>? realms; |   final List<SnRealm>? realms; | ||||||
|   final Function onUpdate; |   final Function onUpdate; | ||||||
|  |  | ||||||
|   const _PostRealmPopup({required this.controller, this.realms, required this.onUpdate}); |   const _PostRealmPopup( | ||||||
|  |       {required this.controller, this.realms, required this.onUpdate}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
| @@ -624,7 +714,8 @@ class _PostRealmPopup extends StatelessWidget { | |||||||
|           children: [ |           children: [ | ||||||
|             const Icon(Symbols.face, size: 24), |             const Icon(Symbols.face, size: 24), | ||||||
|             const Gap(16), |             const Gap(16), | ||||||
|             Text('accountRealms', style: Theme.of(context).textTheme.titleLarge).tr(), |             Text('accountRealms', style: Theme.of(context).textTheme.titleLarge) | ||||||
|  |                 .tr(), | ||||||
|           ], |           ], | ||||||
|         ).padding(horizontal: 20, top: 16, bottom: 12), |         ).padding(horizontal: 20, top: 16, bottom: 12), | ||||||
|         ListTile( |         ListTile( | ||||||
| @@ -665,12 +756,13 @@ class _PostStoryEditor extends StatelessWidget { | |||||||
|   final Function? onTapPublisher; |   final Function? onTapPublisher; | ||||||
|   final Function? onTapRealm; |   final Function? onTapRealm; | ||||||
|  |  | ||||||
|   const _PostStoryEditor({required this.controller, this.onTapPublisher, this.onTapRealm}); |   const _PostStoryEditor( | ||||||
|  |       {required this.controller, this.onTapPublisher, this.onTapRealm}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return Container( |     return Container( | ||||||
|       padding: const EdgeInsets.symmetric(horizontal: 12), |       padding: const EdgeInsets.only(left: 12, right: 12, top: 8), | ||||||
|       constraints: const BoxConstraints(maxWidth: 640), |       constraints: const BoxConstraints(maxWidth: 640), | ||||||
|       child: Row( |       child: Row( | ||||||
|         crossAxisAlignment: CrossAxisAlignment.start, |         crossAxisAlignment: CrossAxisAlignment.start, | ||||||
| @@ -717,7 +809,8 @@ class _PostStoryEditor extends StatelessWidget { | |||||||
|                     border: InputBorder.none, |                     border: InputBorder.none, | ||||||
|                   ), |                   ), | ||||||
|                   style: Theme.of(context).textTheme.titleLarge, |                   style: Theme.of(context).textTheme.titleLarge, | ||||||
|                   onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |                   onTapOutside: (_) => | ||||||
|  |                       FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                 ).padding(horizontal: 16), |                 ).padding(horizontal: 16), | ||||||
|                 const Gap(8), |                 const Gap(8), | ||||||
|                 TextField( |                 TextField( | ||||||
| @@ -732,8 +825,10 @@ class _PostStoryEditor extends StatelessWidget { | |||||||
|                     ), |                     ), | ||||||
|                     border: InputBorder.none, |                     border: InputBorder.none, | ||||||
|                   ), |                   ), | ||||||
|                   onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |                   onTapOutside: (_) => | ||||||
|                   contentInsertionConfiguration: controller.contentInsertionConfiguration, |                       FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |                   contentInsertionConfiguration: | ||||||
|  |                       controller.contentInsertionConfiguration, | ||||||
|                 ), |                 ), | ||||||
|               ], |               ], | ||||||
|             ), |             ), | ||||||
| @@ -749,7 +844,8 @@ class _PostArticleEditor extends StatelessWidget { | |||||||
|   final Function? onTapPublisher; |   final Function? onTapPublisher; | ||||||
|   final Function? onTapRealm; |   final Function? onTapRealm; | ||||||
|  |  | ||||||
|   const _PostArticleEditor({required this.controller, this.onTapPublisher, this.onTapRealm}); |   const _PostArticleEditor( | ||||||
|  |       {required this.controller, this.onTapPublisher, this.onTapRealm}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
| @@ -857,8 +953,10 @@ class _PostArticleEditor extends StatelessWidget { | |||||||
|                       ), |                       ), | ||||||
|                       border: InputBorder.none, |                       border: InputBorder.none, | ||||||
|                     ), |                     ), | ||||||
|                     onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |                     onTapOutside: (_) => | ||||||
|                     contentInsertionConfiguration: controller.contentInsertionConfiguration, |                         FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |                     contentInsertionConfiguration: | ||||||
|  |                         controller.contentInsertionConfiguration, | ||||||
|                   ), |                   ), | ||||||
|                 ), |                 ), | ||||||
|                 const Gap(8), |                 const Gap(8), | ||||||
| @@ -893,7 +991,8 @@ class _PostArticleEditor extends StatelessWidget { | |||||||
|               border: InputBorder.none, |               border: InputBorder.none, | ||||||
|             ), |             ), | ||||||
|             onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |             onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|             contentInsertionConfiguration: controller.contentInsertionConfiguration, |             contentInsertionConfiguration: | ||||||
|  |                 controller.contentInsertionConfiguration, | ||||||
|           ), |           ), | ||||||
|         ), |         ), | ||||||
|       ], |       ], | ||||||
| @@ -906,12 +1005,13 @@ class _PostQuestionEditor extends StatelessWidget { | |||||||
|   final Function? onTapPublisher; |   final Function? onTapPublisher; | ||||||
|   final Function? onTapRealm; |   final Function? onTapRealm; | ||||||
|  |  | ||||||
|   const _PostQuestionEditor({required this.controller, this.onTapPublisher, this.onTapRealm}); |   const _PostQuestionEditor( | ||||||
|  |       {required this.controller, this.onTapPublisher, this.onTapRealm}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return Container( |     return Container( | ||||||
|       padding: const EdgeInsets.symmetric(horizontal: 12), |       padding: const EdgeInsets.only(left: 12, right: 12, top: 8), | ||||||
|       constraints: const BoxConstraints(maxWidth: 640), |       constraints: const BoxConstraints(maxWidth: 640), | ||||||
|       child: Row( |       child: Row( | ||||||
|         crossAxisAlignment: CrossAxisAlignment.start, |         crossAxisAlignment: CrossAxisAlignment.start, | ||||||
| @@ -958,7 +1058,8 @@ class _PostQuestionEditor extends StatelessWidget { | |||||||
|                     border: InputBorder.none, |                     border: InputBorder.none, | ||||||
|                   ), |                   ), | ||||||
|                   style: Theme.of(context).textTheme.titleLarge, |                   style: Theme.of(context).textTheme.titleLarge, | ||||||
|                   onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |                   onTapOutside: (_) => | ||||||
|  |                       FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                 ).padding(horizontal: 16), |                 ).padding(horizontal: 16), | ||||||
|                 const Gap(8), |                 const Gap(8), | ||||||
|                 TextField( |                 TextField( | ||||||
| @@ -969,7 +1070,8 @@ class _PostQuestionEditor extends StatelessWidget { | |||||||
|                     border: InputBorder.none, |                     border: InputBorder.none, | ||||||
|                     isCollapsed: true, |                     isCollapsed: true, | ||||||
|                   ), |                   ), | ||||||
|                   onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |                   onTapOutside: (_) => | ||||||
|  |                       FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                 ).padding(horizontal: 16), |                 ).padding(horizontal: 16), | ||||||
|                 const Gap(8), |                 const Gap(8), | ||||||
|                 TextField( |                 TextField( | ||||||
| @@ -984,14 +1086,16 @@ class _PostQuestionEditor extends StatelessWidget { | |||||||
|                     ), |                     ), | ||||||
|                     border: InputBorder.none, |                     border: InputBorder.none, | ||||||
|                   ), |                   ), | ||||||
|                   onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |                   onTapOutside: (_) => | ||||||
|                   contentInsertionConfiguration: controller.contentInsertionConfiguration, |                       FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |                   contentInsertionConfiguration: | ||||||
|  |                       controller.contentInsertionConfiguration, | ||||||
|                 ), |                 ), | ||||||
|               ], |               ], | ||||||
|             ), |             ), | ||||||
|           ), |           ), | ||||||
|         ], |         ], | ||||||
|       ).padding(top: 8), |       ), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
| @@ -1001,7 +1105,8 @@ class _PostVideoEditor extends StatelessWidget { | |||||||
|   final Function? onTapPublisher; |   final Function? onTapPublisher; | ||||||
|   final Function? onTapRealm; |   final Function? onTapRealm; | ||||||
|  |  | ||||||
|   const _PostVideoEditor({required this.controller, this.onTapPublisher, this.onTapRealm}); |   const _PostVideoEditor( | ||||||
|  |       {required this.controller, this.onTapPublisher, this.onTapRealm}); | ||||||
|  |  | ||||||
|   void _selectVideo(BuildContext context) async { |   void _selectVideo(BuildContext context) async { | ||||||
|     final video = await showDialog<SnAttachment?>( |     final video = await showDialog<SnAttachment?>( | ||||||
| @@ -1022,7 +1127,8 @@ class _PostVideoEditor extends StatelessWidget { | |||||||
|  |  | ||||||
|     final result = await showDialog<SnAttachment?>( |     final result = await showDialog<SnAttachment?>( | ||||||
|       context: context, |       context: context, | ||||||
|       builder: (context) => PendingAttachmentAltDialog(media: PostWriteMedia(controller.videoAttachment)), |       builder: (context) => PendingAttachmentAltDialog( | ||||||
|  |           media: PostWriteMedia(controller.videoAttachment)), | ||||||
|     ); |     ); | ||||||
|     if (result == null) return; |     if (result == null) return; | ||||||
|  |  | ||||||
| @@ -1034,7 +1140,8 @@ class _PostVideoEditor extends StatelessWidget { | |||||||
|  |  | ||||||
|     final result = await showDialog<SnAttachmentBoost?>( |     final result = await showDialog<SnAttachmentBoost?>( | ||||||
|       context: context, |       context: context, | ||||||
|       builder: (context) => PendingAttachmentBoostDialog(media: PostWriteMedia(controller.videoAttachment)), |       builder: (context) => PendingAttachmentBoostDialog( | ||||||
|  |           media: PostWriteMedia(controller.videoAttachment)), | ||||||
|     ); |     ); | ||||||
|     if (result == null) return; |     if (result == null) return; | ||||||
|  |  | ||||||
| @@ -1077,7 +1184,8 @@ class _PostVideoEditor extends StatelessWidget { | |||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       await sn.client.delete('/cgi/uc/attachments/${controller.videoAttachment!.id}'); |       await sn.client | ||||||
|  |           .delete('/cgi/uc/attachments/${controller.videoAttachment!.id}'); | ||||||
|       controller.setVideoAttachment(null); |       controller.setVideoAttachment(null); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!context.mounted) return; |       if (!context.mounted) return; | ||||||
| @@ -1087,7 +1195,11 @@ class _PostVideoEditor extends StatelessWidget { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return Column( |     return Container( | ||||||
|  |       padding: const EdgeInsets.only(left: 12, right: 12, top: 8), | ||||||
|  |       constraints: const BoxConstraints(maxWidth: 640), | ||||||
|  |       child: Row( | ||||||
|  |         crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|         children: [ |         children: [ | ||||||
|           Column( |           Column( | ||||||
|             children: [ |             children: [ | ||||||
| @@ -1120,7 +1232,10 @@ class _PostVideoEditor extends StatelessWidget { | |||||||
|               ), |               ), | ||||||
|             ], |             ], | ||||||
|           ), |           ), | ||||||
|         const Gap(16), |           Expanded( | ||||||
|  |             child: Column( | ||||||
|  |               children: [ | ||||||
|  |                 const Gap(6), | ||||||
|                 TextField( |                 TextField( | ||||||
|                   controller: controller.titleController, |                   controller: controller.titleController, | ||||||
|                   decoration: InputDecoration.collapsed( |                   decoration: InputDecoration.collapsed( | ||||||
| @@ -1128,7 +1243,8 @@ class _PostVideoEditor extends StatelessWidget { | |||||||
|                     border: InputBorder.none, |                     border: InputBorder.none, | ||||||
|                   ), |                   ), | ||||||
|                   style: Theme.of(context).textTheme.titleLarge, |                   style: Theme.of(context).textTheme.titleLarge, | ||||||
|           onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |                   onTapOutside: (_) => | ||||||
|  |                       FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                 ).padding(horizontal: 16), |                 ).padding(horizontal: 16), | ||||||
|                 const Gap(8), |                 const Gap(8), | ||||||
|                 TextField( |                 TextField( | ||||||
| @@ -1140,7 +1256,8 @@ class _PostVideoEditor extends StatelessWidget { | |||||||
|                   maxLines: null, |                   maxLines: null, | ||||||
|                   keyboardType: TextInputType.multiline, |                   keyboardType: TextInputType.multiline, | ||||||
|                   style: Theme.of(context).textTheme.bodyLarge, |                   style: Theme.of(context).textTheme.bodyLarge, | ||||||
|           onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |                   onTapOutside: (_) => | ||||||
|  |                       FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                 ).padding(horizontal: 16), |                 ).padding(horizontal: 16), | ||||||
|                 const Gap(12), |                 const Gap(12), | ||||||
|                 Container( |                 Container( | ||||||
| @@ -1177,7 +1294,8 @@ class _PostVideoEditor extends StatelessWidget { | |||||||
|                           label: 'attachmentCopyRandomId'.tr(), |                           label: 'attachmentCopyRandomId'.tr(), | ||||||
|                           icon: Symbols.content_copy, |                           icon: Symbols.content_copy, | ||||||
|                           onSelected: () { |                           onSelected: () { | ||||||
|                     Clipboard.setData(ClipboardData(text: controller.videoAttachment!.rid)); |                             Clipboard.setData(ClipboardData( | ||||||
|  |                                 text: controller.videoAttachment!.rid)); | ||||||
|                           }, |                           }, | ||||||
|                         ), |                         ), | ||||||
|                         MenuItem( |                         MenuItem( | ||||||
| @@ -1196,7 +1314,9 @@ class _PostVideoEditor extends StatelessWidget { | |||||||
|                     ), |                     ), | ||||||
|                     child: InkWell( |                     child: InkWell( | ||||||
|                       borderRadius: BorderRadius.circular(16), |                       borderRadius: BorderRadius.circular(16), | ||||||
|               onTap: controller.videoAttachment == null ? () => _selectVideo(context) : null, |                       onTap: controller.videoAttachment == null | ||||||
|  |                           ? () => _selectVideo(context) | ||||||
|  |                           : null, | ||||||
|                       child: AspectRatio( |                       child: AspectRatio( | ||||||
|                         aspectRatio: 16 / 9, |                         aspectRatio: 16 / 9, | ||||||
|                         child: controller.videoAttachment == null |                         child: controller.videoAttachment == null | ||||||
| @@ -1224,6 +1344,10 @@ class _PostVideoEditor extends StatelessWidget { | |||||||
|                   ), |                   ), | ||||||
|                 ), |                 ), | ||||||
|               ], |               ], | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -45,7 +45,8 @@ class _PostSearchScreenState extends State<PostSearchScreen> { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> _fetchPosts() async { |   Future<void> _fetchPosts() async { | ||||||
|     if (_searchTerm.isEmpty && _searchCategories.isEmpty && _searchTags.isEmpty) return; |     if (_searchTerm.isEmpty && _searchCategories.isEmpty && _searchTags.isEmpty) | ||||||
|  |       return; | ||||||
|     if (_postCount != null && _posts.length >= _postCount!) return; |     if (_postCount != null && _posts.length >= _postCount!) return; | ||||||
|  |  | ||||||
|     setState(() => _isBusy = true); |     setState(() => _isBusy = true); | ||||||
| @@ -152,7 +153,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> { | |||||||
|                 }, |                 }, | ||||||
|               ); |               ); | ||||||
|             }, |             }, | ||||||
|             separatorBuilder: (_, __) => const Gap(8), |             separatorBuilder: (_, __) => const Divider().padding(vertical: 2), | ||||||
|           ), |           ), | ||||||
|           Positioned( |           Positioned( | ||||||
|             top: 16, |             top: 16, | ||||||
| @@ -166,7 +167,8 @@ class _PostSearchScreenState extends State<PostSearchScreen> { | |||||||
|                   padding: const WidgetStatePropertyAll( |                   padding: const WidgetStatePropertyAll( | ||||||
|                     EdgeInsets.symmetric(horizontal: 24), |                     EdgeInsets.symmetric(horizontal: 24), | ||||||
|                   ), |                   ), | ||||||
|                   onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |                   onTapOutside: (_) => | ||||||
|  |                       FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                   onChanged: (value) { |                   onChanged: (value) { | ||||||
|                     _searchTerm = value; |                     _searchTerm = value; | ||||||
|                   }, |                   }, | ||||||
|   | |||||||
							
								
								
									
										127
									
								
								lib/screens/post/post_shuffle.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								lib/screens/post/post_shuffle.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,127 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_card_swiper/flutter_card_swiper.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:surface/providers/post.dart'; | ||||||
|  | import 'package:surface/types/post.dart'; | ||||||
|  | import 'package:surface/widgets/dialog.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  | import 'package:surface/widgets/post/post_item.dart'; | ||||||
|  |  | ||||||
|  | class PostShuffleScreen extends StatefulWidget { | ||||||
|  |   const PostShuffleScreen({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<PostShuffleScreen> createState() => _PostShuffleScreenState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _PostShuffleScreenState extends State<PostShuffleScreen> { | ||||||
|  |   late final CardSwiperController _cardController = CardSwiperController(); | ||||||
|  |  | ||||||
|  |   bool _isBusy = false; | ||||||
|  |   final List<SnPost> _posts = List.empty(growable: true); | ||||||
|  |  | ||||||
|  |   Future<void> _fetchPosts() async { | ||||||
|  |     _posts.clear(); | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |     try { | ||||||
|  |       final pt = context.read<SnPostContentProvider>(); | ||||||
|  |       final result = | ||||||
|  |           await pt.listPosts(take: 10, offset: _posts.length, isShuffle: true); | ||||||
|  |       _posts.addAll(result.$1); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _fetchPosts(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void dispose() { | ||||||
|  |     super.dispose(); | ||||||
|  |     _cardController.dispose(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AppScaffold( | ||||||
|  |       appBar: AppBar(title: Text('postShuffle').tr()), | ||||||
|  |       body: Stack( | ||||||
|  |         children: [ | ||||||
|  |           Column( | ||||||
|  |             children: [ | ||||||
|  |               if (_isBusy || _posts.isEmpty) | ||||||
|  |                 const Expanded( | ||||||
|  |                     child: Center(child: CircularProgressIndicator())) | ||||||
|  |               else | ||||||
|  |                 Expanded( | ||||||
|  |                   child: CardSwiper( | ||||||
|  |                     controller: _cardController, | ||||||
|  |                     isLoop: false, | ||||||
|  |                     padding: EdgeInsets.zero, | ||||||
|  |                     cardsCount: _posts.length, | ||||||
|  |                     cardBuilder: (context, idx, _, __) { | ||||||
|  |                       final ele = _posts[idx]; | ||||||
|  |                       return SingleChildScrollView( | ||||||
|  |                         child: Center( | ||||||
|  |                           child: Card( | ||||||
|  |                             color: Theme.of(context).colorScheme.surface, | ||||||
|  |                             child: OpenablePostItem( | ||||||
|  |                               key: ValueKey(ele), | ||||||
|  |                               data: ele, | ||||||
|  |                               maxWidth: 640, | ||||||
|  |                               onChanged: (ele) { | ||||||
|  |                                 _posts[idx] = ele; | ||||||
|  |                                 setState(() {}); | ||||||
|  |                               }, | ||||||
|  |                               onDeleted: () { | ||||||
|  |                                 _fetchPosts(); | ||||||
|  |                               }, | ||||||
|  |                             ).padding(all: 8), | ||||||
|  |                           ).padding( | ||||||
|  |                             all: 24, | ||||||
|  |                             bottom: | ||||||
|  |                                 MediaQuery.of(context).padding.bottom + 16 + 50, | ||||||
|  |                           ), | ||||||
|  |                         ), | ||||||
|  |                       ); | ||||||
|  |                     }, | ||||||
|  |                     onEnd: () { | ||||||
|  |                       _fetchPosts(); | ||||||
|  |                     }, | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |             ], | ||||||
|  |           ), | ||||||
|  |           if (!_isBusy && _posts.isNotEmpty) | ||||||
|  |             Positioned( | ||||||
|  |               bottom: MediaQuery.of(context).padding.bottom + 16, | ||||||
|  |               left: 16, | ||||||
|  |               right: 16, | ||||||
|  |               child: Row( | ||||||
|  |                 mainAxisAlignment: MainAxisAlignment.center, | ||||||
|  |                 children: [ | ||||||
|  |                   IconButton.filled( | ||||||
|  |                     icon: const Icon(Symbols.next_plan), | ||||||
|  |                     color: Theme.of(context).colorScheme.onPrimary, | ||||||
|  |                     onPressed: () { | ||||||
|  |                       _cardController.swipe(CardSwiperDirection.right); | ||||||
|  |                     }, | ||||||
|  |                   ), | ||||||
|  |                 ], | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -34,9 +34,11 @@ class PostPublisherScreen extends StatefulWidget { | |||||||
|   State<PostPublisherScreen> createState() => _PostPublisherScreenState(); |   State<PostPublisherScreen> createState() => _PostPublisherScreenState(); | ||||||
| } | } | ||||||
|  |  | ||||||
| class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTickerProviderStateMixin { | class _PostPublisherScreenState extends State<PostPublisherScreen> | ||||||
|  |     with SingleTickerProviderStateMixin { | ||||||
|   late final ScrollController _scrollController = ScrollController(); |   late final ScrollController _scrollController = ScrollController(); | ||||||
|   late final TabController _tabController = TabController(length: 3, vsync: this); |   late final TabController _tabController = | ||||||
|  |       TabController(length: 5, vsync: this); | ||||||
|  |  | ||||||
|   SnPublisher? _publisher; |   SnPublisher? _publisher; | ||||||
|   SnAccount? _account; |   SnAccount? _account; | ||||||
| @@ -66,7 +68,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | |||||||
|       _account = await ud.getAccount(_publisher?.accountId); |       _account = await ud.getAccount(_publisher?.accountId); | ||||||
|       _accountRelationship = await rel.getRelationship(_account!.id); |       _accountRelationship = await rel.getRelationship(_account!.id); | ||||||
|       if (_publisher?.realmId != null && _publisher!.realmId != 0) { |       if (_publisher?.realmId != null && _publisher!.realmId != 0) { | ||||||
|         final resp = await sn.client.get('/cgi/id/realms/${_publisher!.realmId}'); |         final resp = | ||||||
|  |             await sn.client.get('/cgi/id/realms/${_publisher!.realmId}'); | ||||||
|         _realm = SnRealm.fromJson(resp.data); |         _realm = SnRealm.fromJson(resp.data); | ||||||
|       } |       } | ||||||
|     } catch (_) { |     } catch (_) { | ||||||
| @@ -133,12 +136,14 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | |||||||
|   double _appBarBlur = 0.0; |   double _appBarBlur = 0.0; | ||||||
|  |  | ||||||
|   late final _appBarWidth = MediaQuery.of(context).size.width; |   late final _appBarWidth = MediaQuery.of(context).size.width; | ||||||
|   late final _appBarHeight = (_appBarWidth * kBannerAspectRatio).roundToDouble(); |   late final _appBarHeight = | ||||||
|  |       math.min((_appBarWidth * kBannerAspectRatio), 360).roundToDouble(); | ||||||
|  |  | ||||||
|   void _updateAppBarBlur() { |   void _updateAppBarBlur() { | ||||||
|     if (_scrollController.offset > _appBarHeight) return; |     if (_scrollController.offset > _appBarHeight) return; | ||||||
|     setState(() { |     setState(() { | ||||||
|       _appBarBlur = (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0); |       _appBarBlur = | ||||||
|  |           (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -160,6 +165,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | |||||||
|         type: switch (_tabController.index) { |         type: switch (_tabController.index) { | ||||||
|           1 => 'story', |           1 => 'story', | ||||||
|           2 => 'article', |           2 => 'article', | ||||||
|  |           3 => 'question', | ||||||
|  |           4 => 'video', | ||||||
|           _ => null, |           _ => null, | ||||||
|         }, |         }, | ||||||
|       ); |       ); | ||||||
| @@ -193,7 +200,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | |||||||
|         'related': _account!.name, |         'related': _account!.name, | ||||||
|       }); |       }); | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showSnackbar('userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}'])); |       context.showSnackbar( | ||||||
|  |           'userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}'])); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showErrorDialog(err); |       context.showErrorDialog(err); | ||||||
| @@ -209,9 +217,11 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | |||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       final rel = context.read<SnRelationshipProvider>(); |       final rel = context.read<SnRelationshipProvider>(); | ||||||
|       await rel.updateRelationship(_account!.id, 1, _accountRelationship?.permNodes ?? {}); |       await rel.updateRelationship( | ||||||
|  |           _account!.id, 1, _accountRelationship?.permNodes ?? {}); | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showSnackbar('userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}'])); |       context.showSnackbar( | ||||||
|  |           'userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}'])); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showErrorDialog(err); |       context.showErrorDialog(err); | ||||||
| @@ -276,6 +286,7 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | |||||||
|     final sn = context.read<SnNetworkProvider>(); |     final sn = context.read<SnNetworkProvider>(); | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|  |       noBackground: true, | ||||||
|       body: NestedScrollView( |       body: NestedScrollView( | ||||||
|         controller: _scrollController, |         controller: _scrollController, | ||||||
|         headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { |         headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { | ||||||
| @@ -299,7 +310,10 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | |||||||
|                               text: TextSpan(children: [ |                               text: TextSpan(children: [ | ||||||
|                                 TextSpan( |                                 TextSpan( | ||||||
|                                   text: _publisher!.nick, |                                   text: _publisher!.nick, | ||||||
|                                   style: Theme.of(context).textTheme.titleLarge!.copyWith( |                                   style: Theme.of(context) | ||||||
|  |                                       .textTheme | ||||||
|  |                                       .titleLarge! | ||||||
|  |                                       .copyWith( | ||||||
|                                         color: Colors.white, |                                         color: Colors.white, | ||||||
|                                         shadows: labelShadows, |                                         shadows: labelShadows, | ||||||
|                                       ), |                                       ), | ||||||
| @@ -307,7 +321,10 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | |||||||
|                                 const TextSpan(text: '\n'), |                                 const TextSpan(text: '\n'), | ||||||
|                                 TextSpan( |                                 TextSpan( | ||||||
|                                   text: '@${_publisher!.name}', |                                   text: '@${_publisher!.name}', | ||||||
|                                   style: Theme.of(context).textTheme.bodySmall!.copyWith( |                                   style: Theme.of(context) | ||||||
|  |                                       .textTheme | ||||||
|  |                                       .bodySmall! | ||||||
|  |                                       .copyWith( | ||||||
|                                         color: Colors.white, |                                         color: Colors.white, | ||||||
|                                         shadows: labelShadows, |                                         shadows: labelShadows, | ||||||
|                                       ), |                                       ), | ||||||
| @@ -330,13 +347,16 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | |||||||
|                                   ) |                                   ) | ||||||
|                                 else |                                 else | ||||||
|                                   Container( |                                   Container( | ||||||
|                                     color: Theme.of(context).colorScheme.surfaceContainer, |                                     color: Theme.of(context) | ||||||
|  |                                         .colorScheme | ||||||
|  |                                         .surfaceContainer, | ||||||
|                                   ), |                                   ), | ||||||
|                                 Positioned( |                                 Positioned( | ||||||
|                                   top: 0, |                                   top: 0, | ||||||
|                                   left: 0, |                                   left: 0, | ||||||
|                                   right: 0, |                                   right: 0, | ||||||
|                                   height: 56 + MediaQuery.of(context).padding.top, |                                   height: | ||||||
|  |                                       56 + MediaQuery.of(context).padding.top, | ||||||
|                                   child: ClipRect( |                                   child: ClipRect( | ||||||
|                                     child: BackdropFilter( |                                     child: BackdropFilter( | ||||||
|                                       filter: ImageFilter.blur( |                                       filter: ImageFilter.blur( | ||||||
| @@ -345,7 +365,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | |||||||
|                                       ), |                                       ), | ||||||
|                                       child: Container( |                                       child: Container( | ||||||
|                                         color: Colors.black.withOpacity( |                                         color: Colors.black.withOpacity( | ||||||
|                                           clampDouble(_appBarBlur * 0.1, 0, 0.5), |                                           clampDouble( | ||||||
|  |                                               _appBarBlur * 0.1, 0, 0.5), | ||||||
|                                         ), |                                         ), | ||||||
|                                       ), |                                       ), | ||||||
|                                     ), |                                     ), | ||||||
| @@ -372,11 +393,14 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | |||||||
|                                 const Gap(16), |                                 const Gap(16), | ||||||
|                                 Expanded( |                                 Expanded( | ||||||
|                                   child: Column( |                                   child: Column( | ||||||
|                                     crossAxisAlignment: CrossAxisAlignment.start, |                                     crossAxisAlignment: | ||||||
|  |                                         CrossAxisAlignment.start, | ||||||
|                                     children: [ |                                     children: [ | ||||||
|                                       Text( |                                       Text( | ||||||
|                                         _publisher!.nick, |                                         _publisher!.nick, | ||||||
|                                         style: Theme.of(context).textTheme.titleMedium, |                                         style: Theme.of(context) | ||||||
|  |                                             .textTheme | ||||||
|  |                                             .titleMedium, | ||||||
|                                       ).bold(), |                                       ).bold(), | ||||||
|                                       Text('@${_publisher!.name}').fontSize(13), |                                       Text('@${_publisher!.name}').fontSize(13), | ||||||
|                                     ], |                                     ], | ||||||
| @@ -387,7 +411,9 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | |||||||
|                                     style: ButtonStyle( |                                     style: ButtonStyle( | ||||||
|                                       elevation: WidgetStatePropertyAll(0), |                                       elevation: WidgetStatePropertyAll(0), | ||||||
|                                     ), |                                     ), | ||||||
|                                     onPressed: _isSubscribing ? null : _toggleSubscription, |                                     onPressed: _isSubscribing | ||||||
|  |                                         ? null | ||||||
|  |                                         : _toggleSubscription, | ||||||
|                                     label: Text('subscribe').tr(), |                                     label: Text('subscribe').tr(), | ||||||
|                                     icon: const Icon(Symbols.add), |                                     icon: const Icon(Symbols.add), | ||||||
|                                   ) |                                   ) | ||||||
| @@ -396,14 +422,17 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | |||||||
|                                     style: ButtonStyle( |                                     style: ButtonStyle( | ||||||
|                                       elevation: WidgetStatePropertyAll(0), |                                       elevation: WidgetStatePropertyAll(0), | ||||||
|                                     ), |                                     ), | ||||||
|                                     onPressed: _isSubscribing ? null : _toggleSubscription, |                                     onPressed: _isSubscribing | ||||||
|  |                                         ? null | ||||||
|  |                                         : _toggleSubscription, | ||||||
|                                     label: Text('unsubscribe').tr(), |                                     label: Text('unsubscribe').tr(), | ||||||
|                                     icon: const Icon(Symbols.remove), |                                     icon: const Icon(Symbols.remove), | ||||||
|                                   ), |                                   ), | ||||||
|                                 PopupMenuButton( |                                 PopupMenuButton( | ||||||
|                                   padding: EdgeInsets.zero, |                                   padding: EdgeInsets.zero, | ||||||
|                                   style: ButtonStyle( |                                   style: ButtonStyle( | ||||||
|                                     visualDensity: VisualDensity(horizontal: -4, vertical: -4), |                                     visualDensity: VisualDensity( | ||||||
|  |                                         horizontal: -4, vertical: -4), | ||||||
|                                   ), |                                   ), | ||||||
|                                   itemBuilder: (BuildContext context) => [ |                                   itemBuilder: (BuildContext context) => [ | ||||||
|                                     PopupMenuItem( |                                     PopupMenuItem( | ||||||
| @@ -443,7 +472,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | |||||||
|                               ], |                               ], | ||||||
|                             ), |                             ), | ||||||
|                             const Gap(12), |                             const Gap(12), | ||||||
|                             Text(_publisher!.description).padding(horizontal: 8), |                             Text(_publisher!.description) | ||||||
|  |                                 .padding(horizontal: 8), | ||||||
|                             const Gap(12), |                             const Gap(12), | ||||||
|                             Column( |                             Column( | ||||||
|                               children: [ |                               children: [ | ||||||
| @@ -451,8 +481,10 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | |||||||
|                                   children: [ |                                   children: [ | ||||||
|                                     const Icon(Symbols.calendar_add_on), |                                     const Icon(Symbols.calendar_add_on), | ||||||
|                                     const Gap(8), |                                     const Gap(8), | ||||||
|                                     Text('publisherJoinedAt') |                                     Text('publisherJoinedAt').tr(args: [ | ||||||
|                                         .tr(args: [DateFormat('y/M/d').format(_publisher!.createdAt)]), |                                       DateFormat('y/M/d') | ||||||
|  |                                           .format(_publisher!.createdAt) | ||||||
|  |                                     ]), | ||||||
|                                   ], |                                   ], | ||||||
|                                 ), |                                 ), | ||||||
|                                 Row( |                                 Row( | ||||||
| @@ -460,7 +492,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | |||||||
|                                     const Icon(Symbols.trending_up), |                                     const Icon(Symbols.trending_up), | ||||||
|                                     const Gap(8), |                                     const Gap(8), | ||||||
|                                     Text('publisherSocialPointTotal').plural( |                                     Text('publisherSocialPointTotal').plural( | ||||||
|                                       _publisher!.totalUpvote - _publisher!.totalDownvote, |                                       _publisher!.totalUpvote - | ||||||
|  |                                           _publisher!.totalDownvote, | ||||||
|                                     ), |                                     ), | ||||||
|                                   ], |                                   ], | ||||||
|                                 ), |                                 ), | ||||||
| @@ -470,18 +503,22 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | |||||||
|                                       const Icon(Symbols.group_work), |                                       const Icon(Symbols.group_work), | ||||||
|                                       const Gap(8), |                                       const Gap(8), | ||||||
|                                       InkWell( |                                       InkWell( | ||||||
|                                         child: Text('publisherAffiliatedBy').tr(args: [ |                                         child: Text('publisherAffiliatedBy') | ||||||
|  |                                             .tr(args: [ | ||||||
|                                           '@${_realm?.alias ?? 'unknown'}', |                                           '@${_realm?.alias ?? 'unknown'}', | ||||||
|                                         ]), |                                         ]), | ||||||
|                                         onTap: () { |                                         onTap: () { | ||||||
|                                           GoRouter.of(context).pushNamed( |                                           GoRouter.of(context).pushNamed( | ||||||
|                                             'realmDetail', |                                             'realmDetail', | ||||||
|                                             pathParameters: {'alias': _realm!.alias}, |                                             pathParameters: { | ||||||
|  |                                               'alias': _realm!.alias | ||||||
|  |                                             }, | ||||||
|                                           ); |                                           ); | ||||||
|                                         }, |                                         }, | ||||||
|                                       ), |                                       ), | ||||||
|                                       const Gap(8), |                                       const Gap(8), | ||||||
|                                       AccountImage(content: _realm?.avatar, radius: 8), |                                       AccountImage( | ||||||
|  |                                           content: _realm?.avatar, radius: 8), | ||||||
|                                     ], |                                     ], | ||||||
|                                   ), |                                   ), | ||||||
|                                 Row( |                                 Row( | ||||||
| @@ -502,7 +539,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | |||||||
|                                       }, |                                       }, | ||||||
|                                     ), |                                     ), | ||||||
|                                     const Gap(8), |                                     const Gap(8), | ||||||
|                                     AccountImage(content: _account?.avatar, radius: 8), |                                     AccountImage( | ||||||
|  |                                         content: _account?.avatar, radius: 8), | ||||||
|                                   ], |                                   ], | ||||||
|                                 ), |                                 ), | ||||||
|                               ], |                               ], | ||||||
| @@ -533,6 +571,18 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | |||||||
|                           color: Theme.of(context).colorScheme.onSurface, |                           color: Theme.of(context).colorScheme.onSurface, | ||||||
|                         ), |                         ), | ||||||
|                       ), |                       ), | ||||||
|  |                       Tab( | ||||||
|  |                         icon: Icon( | ||||||
|  |                           Symbols.help, | ||||||
|  |                           color: Theme.of(context).colorScheme.onSurface, | ||||||
|  |                         ), | ||||||
|  |                       ), | ||||||
|  |                       Tab( | ||||||
|  |                         icon: Icon( | ||||||
|  |                           Symbols.video_call, | ||||||
|  |                           color: Theme.of(context).colorScheme.onSurface, | ||||||
|  |                         ), | ||||||
|  |                       ), | ||||||
|                     ], |                     ], | ||||||
|                   ), |                   ), | ||||||
|                   SliverToBoxAdapter(child: const Divider(height: 1)), |                   SliverToBoxAdapter(child: const Divider(height: 1)), | ||||||
| @@ -606,7 +656,7 @@ class _PublisherPostList extends StatelessWidget { | |||||||
|           onDeleted: onDeleted, |           onDeleted: onDeleted, | ||||||
|         ); |         ); | ||||||
|       }, |       }, | ||||||
|       separatorBuilder: (_, __) => const Gap(8), |       separatorBuilder: (_, __) => const Divider().padding(vertical: 2), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										149
									
								
								lib/screens/realm/community.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								lib/screens/realm/community.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,149 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:go_router/go_router.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:surface/providers/post.dart'; | ||||||
|  | import 'package:surface/providers/sn_network.dart'; | ||||||
|  | import 'package:surface/screens/post/post_editor.dart'; | ||||||
|  | import 'package:surface/types/post.dart'; | ||||||
|  | import 'package:surface/types/realm.dart'; | ||||||
|  | import 'package:surface/widgets/dialog.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  | import 'package:surface/widgets/post/post_item.dart'; | ||||||
|  | import 'package:very_good_infinite_list/very_good_infinite_list.dart'; | ||||||
|  |  | ||||||
|  | class RealmCommunityScreen extends StatefulWidget { | ||||||
|  |   final String alias; | ||||||
|  |   const RealmCommunityScreen({super.key, required this.alias}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<RealmCommunityScreen> createState() => _RealmCommunityScreenState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _RealmCommunityScreenState extends State<RealmCommunityScreen> { | ||||||
|  |   SnRealm? _realm; | ||||||
|  |  | ||||||
|  |   Future<void> _fetchRealm() async { | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       final resp = await sn.client.get('/cgi/id/realms/${widget.alias}'); | ||||||
|  |       _realm = SnRealm.fromJson(resp.data); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |       rethrow; | ||||||
|  |     } finally { | ||||||
|  |       setState(() {}); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   bool _isBusy = false; | ||||||
|  |   int? _totalCount; | ||||||
|  |   final List<SnPost> _posts = List.empty(growable: true); | ||||||
|  |  | ||||||
|  |   Future<void> _fetchPosts() async { | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       final pt = context.read<SnPostContentProvider>(); | ||||||
|  |       final out = await pt.listPosts( | ||||||
|  |         take: 10, | ||||||
|  |         offset: _posts.length, | ||||||
|  |         realm: _realm?.id.toString(), | ||||||
|  |       ); | ||||||
|  |       _totalCount = out.$2; | ||||||
|  |       _posts.addAll(out.$1); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _fetchRealm(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AppScaffold( | ||||||
|  |       appBar: AppBar( | ||||||
|  |         title: Text(_realm?.name ?? 'loading'.tr()), | ||||||
|  |       ), | ||||||
|  |       floatingActionButton: _realm != null | ||||||
|  |           ? FloatingActionButton( | ||||||
|  |               child: const Icon(Symbols.edit), | ||||||
|  |               onPressed: () { | ||||||
|  |                 GoRouter.of(context).pushNamed( | ||||||
|  |                   'postEditor', | ||||||
|  |                   extra: PostEditorExtra(realm: _realm!), | ||||||
|  |                 ); | ||||||
|  |               }, | ||||||
|  |             ) | ||||||
|  |           : null, | ||||||
|  |       body: Column( | ||||||
|  |         crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |         children: [ | ||||||
|  |           if (_realm == null) | ||||||
|  |             Expanded( | ||||||
|  |               child: Center( | ||||||
|  |                 child: CircularProgressIndicator().center(), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           if (_realm != null) | ||||||
|  |             Column( | ||||||
|  |               crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |               children: [ | ||||||
|  |                 Text('realmCommunity'.tr(args: [_realm!.name])) | ||||||
|  |                     .fontSize(17) | ||||||
|  |                     .padding(horizontal: 20, bottom: 4), | ||||||
|  |                 Text('postTotalCount'.plural(_totalCount ?? 0)) | ||||||
|  |                     .fontSize(13) | ||||||
|  |                     .opacity(0.8) | ||||||
|  |                     .padding(horizontal: 20, bottom: 4), | ||||||
|  |               ], | ||||||
|  |             ).padding(horizontal: 20, vertical: 16), | ||||||
|  |           const Divider(height: 1), | ||||||
|  |           if (_realm != null) | ||||||
|  |             Expanded( | ||||||
|  |               child: MediaQuery.removePadding( | ||||||
|  |                 context: context, | ||||||
|  |                 removeTop: true, | ||||||
|  |                 child: RefreshIndicator( | ||||||
|  |                   onRefresh: _fetchPosts, | ||||||
|  |                   child: InfiniteList( | ||||||
|  |                     padding: const EdgeInsets.only(top: 8), | ||||||
|  |                     itemCount: _posts.length, | ||||||
|  |                     isLoading: _isBusy, | ||||||
|  |                     hasReachedMax: | ||||||
|  |                         _totalCount != null && _posts.length >= _totalCount!, | ||||||
|  |                     onFetchData: _fetchPosts, | ||||||
|  |                     itemBuilder: (context, idx) { | ||||||
|  |                       final post = _posts[idx]; | ||||||
|  |                       return OpenablePostItem( | ||||||
|  |                         data: post, | ||||||
|  |                         maxWidth: 640, | ||||||
|  |                         onChanged: (data) { | ||||||
|  |                           setState(() => _posts[idx] = data); | ||||||
|  |                         }, | ||||||
|  |                         onDeleted: () { | ||||||
|  |                           setState(() => _posts.removeAt(idx)); | ||||||
|  |                         }, | ||||||
|  |                       ); | ||||||
|  |                     }, | ||||||
|  |                     separatorBuilder: (_, __) => | ||||||
|  |                         const Divider().padding(vertical: 2), | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -51,7 +51,8 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> { | |||||||
|   Future<void> _fetchPublishers() async { |   Future<void> _fetchPublishers() async { | ||||||
|     try { |     try { | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final resp = await sn.client.get('/cgi/co/publishers?realm=${widget.alias}'); |       final resp = | ||||||
|  |           await sn.client.get('/cgi/co/publishers?realm=${widget.alias}'); | ||||||
|       _publishers = List<SnPublisher>.from( |       _publishers = List<SnPublisher>.from( | ||||||
|         resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], |         resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], | ||||||
|       ); |       ); | ||||||
| @@ -68,7 +69,8 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> { | |||||||
|   Future<void> _fetchChannels() async { |   Future<void> _fetchChannels() async { | ||||||
|     try { |     try { | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final resp = await sn.client.get('/cgi/im/channels/${widget.alias}/public'); |       final resp = | ||||||
|  |           await sn.client.get('/cgi/im/channels/${widget.alias}/public'); | ||||||
|       _channels = List<SnChannel>.from( |       _channels = List<SnChannel>.from( | ||||||
|         resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(), |         resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(), | ||||||
|       ); |       ); | ||||||
| @@ -98,15 +100,32 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> { | |||||||
|           headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { |           headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { | ||||||
|             return <Widget>[ |             return <Widget>[ | ||||||
|               SliverOverlapAbsorber( |               SliverOverlapAbsorber( | ||||||
|                 handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), |                 handle: | ||||||
|  |                     NestedScrollView.sliverOverlapAbsorberHandleFor(context), | ||||||
|                 sliver: SliverAppBar( |                 sliver: SliverAppBar( | ||||||
|                   title: Text(_realm?.name ?? 'loading'.tr()), |                   title: Text(_realm?.name ?? 'loading'.tr()), | ||||||
|                   bottom: TabBar( |                   bottom: TabBar( | ||||||
|                     tabs: [ |                     tabs: [ | ||||||
|                       Tab(icon: Icon(Symbols.home, color: Theme.of(context).appBarTheme.foregroundColor)), |                       Tab( | ||||||
|                       Tab(icon: Icon(Symbols.explore, color: Theme.of(context).appBarTheme.foregroundColor)), |                           icon: Icon(Symbols.home, | ||||||
|                       Tab(icon: Icon(Symbols.group, color: Theme.of(context).appBarTheme.foregroundColor)), |                               color: Theme.of(context) | ||||||
|                       Tab(icon: Icon(Symbols.settings, color: Theme.of(context).appBarTheme.foregroundColor)), |                                   .appBarTheme | ||||||
|  |                                   .foregroundColor)), | ||||||
|  |                       Tab( | ||||||
|  |                           icon: Icon(Symbols.explore, | ||||||
|  |                               color: Theme.of(context) | ||||||
|  |                                   .appBarTheme | ||||||
|  |                                   .foregroundColor)), | ||||||
|  |                       Tab( | ||||||
|  |                           icon: Icon(Symbols.group, | ||||||
|  |                               color: Theme.of(context) | ||||||
|  |                                   .appBarTheme | ||||||
|  |                                   .foregroundColor)), | ||||||
|  |                       Tab( | ||||||
|  |                           icon: Icon(Symbols.settings, | ||||||
|  |                               color: Theme.of(context) | ||||||
|  |                                   .appBarTheme | ||||||
|  |                                   .foregroundColor)), | ||||||
|                     ], |                     ], | ||||||
|                   ), |                   ), | ||||||
|                 ), |                 ), | ||||||
| @@ -115,7 +134,8 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> { | |||||||
|           }, |           }, | ||||||
|           body: TabBarView( |           body: TabBarView( | ||||||
|             children: [ |             children: [ | ||||||
|               _RealmDetailHomeWidget(realm: _realm, publishers: _publishers, channels: _channels), |               _RealmDetailHomeWidget( | ||||||
|  |                   realm: _realm, publishers: _publishers, channels: _channels), | ||||||
|               _RealmPostListWidget(realm: _realm), |               _RealmPostListWidget(realm: _realm), | ||||||
|               _RealmMemberListWidget(realm: _realm), |               _RealmMemberListWidget(realm: _realm), | ||||||
|               _RealmSettingsWidget( |               _RealmSettingsWidget( | ||||||
| @@ -137,7 +157,8 @@ class _RealmDetailHomeWidget extends StatelessWidget { | |||||||
|   final List<SnPublisher>? publishers; |   final List<SnPublisher>? publishers; | ||||||
|   final List<SnChannel>? channels; |   final List<SnChannel>? channels; | ||||||
|  |  | ||||||
|   const _RealmDetailHomeWidget({required this.realm, this.publishers, this.channels}); |   const _RealmDetailHomeWidget( | ||||||
|  |       {required this.realm, this.publishers, this.channels}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
| @@ -168,7 +189,8 @@ class _RealmDetailHomeWidget extends StatelessWidget { | |||||||
|                   child: Container( |                   child: Container( | ||||||
|                     width: double.infinity, |                     width: double.infinity, | ||||||
|                     color: Theme.of(context).colorScheme.surfaceContainerHigh, |                     color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||||
|                     child: Text('realmCommunityPublishersHint'.tr(), style: Theme.of(context).textTheme.bodyMedium) |                     child: Text('realmCommunityPublishersHint'.tr(), | ||||||
|  |                             style: Theme.of(context).textTheme.bodyMedium) | ||||||
|                         .padding(horizontal: 24, vertical: 8), |                         .padding(horizontal: 24, vertical: 8), | ||||||
|                   ), |                   ), | ||||||
|                 ), |                 ), | ||||||
| @@ -199,7 +221,8 @@ class _RealmDetailHomeWidget extends StatelessWidget { | |||||||
|                   child: Container( |                   child: Container( | ||||||
|                     width: double.infinity, |                     width: double.infinity, | ||||||
|                     color: Theme.of(context).colorScheme.surfaceContainerHigh, |                     color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||||
|                     child: Text('realmCommunityPublicChannelsHint'.tr(), style: Theme.of(context).textTheme.bodyMedium) |                     child: Text('realmCommunityPublicChannelsHint'.tr(), | ||||||
|  |                             style: Theme.of(context).textTheme.bodyMedium) | ||||||
|                         .padding(horizontal: 24, vertical: 8), |                         .padding(horizontal: 24, vertical: 8), | ||||||
|                   ), |                   ), | ||||||
|                 ), |                 ), | ||||||
| @@ -295,7 +318,7 @@ class _RealmPostListWidgetState extends State<_RealmPostListWidget> { | |||||||
|               }, |               }, | ||||||
|             ); |             ); | ||||||
|           }, |           }, | ||||||
|           separatorBuilder: (_, __) => const Gap(8), |           separatorBuilder: (_, __) => const Divider().padding(vertical: 2), | ||||||
|         ), |         ), | ||||||
|       ), |       ), | ||||||
|     ).padding(top: 8); |     ).padding(top: 8); | ||||||
| @@ -323,7 +346,9 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> { | |||||||
|     try { |     try { | ||||||
|       final ud = context.read<UserDirectoryProvider>(); |       final ud = context.read<UserDirectoryProvider>(); | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final resp = await sn.client.get('/cgi/id/realms/${widget.realm!.alias}/members', queryParameters: { |       final resp = await sn.client.get( | ||||||
|  |           '/cgi/id/realms/${widget.realm!.alias}/members', | ||||||
|  |           queryParameters: { | ||||||
|             'take': 10, |             'take': 10, | ||||||
|             'offset': _members.length, |             'offset': _members.length, | ||||||
|           }); |           }); | ||||||
| @@ -432,14 +457,14 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> { | |||||||
|             return ListTile( |             return ListTile( | ||||||
|               contentPadding: const EdgeInsets.only(right: 24, left: 16), |               contentPadding: const EdgeInsets.only(right: 24, left: 16), | ||||||
|               leading: AccountImage( |               leading: AccountImage( | ||||||
|                 content: ud.getAccountFromCache(member.accountId)?.avatar, |                 content: ud.getFromCache(member.accountId)?.avatar, | ||||||
|                 fallbackWidget: const Icon(Symbols.group, size: 24), |                 fallbackWidget: const Icon(Symbols.group, size: 24), | ||||||
|               ), |               ), | ||||||
|               title: Text( |               title: Text( | ||||||
|                 ud.getAccountFromCache(member.accountId)?.nick ?? 'unknown'.tr(), |                 ud.getFromCache(member.accountId)?.nick ?? 'unknown'.tr(), | ||||||
|               ), |               ), | ||||||
|               subtitle: Text( |               subtitle: Text( | ||||||
|                 ud.getAccountFromCache(member.accountId)?.name ?? 'unknown'.tr(), |                 ud.getFromCache(member.accountId)?.name ?? 'unknown'.tr(), | ||||||
|               ), |               ), | ||||||
|               trailing: IconButton( |               trailing: IconButton( | ||||||
|                 icon: const Icon(Symbols.person_remove), |                 icon: const Icon(Symbols.person_remove), | ||||||
|   | |||||||
| @@ -4,8 +4,10 @@ import 'package:gap/gap.dart'; | |||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:surface/providers/channel.dart'; | ||||||
| import 'package:surface/providers/config.dart'; | import 'package:surface/providers/config.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
|  | import 'package:surface/providers/sn_realm.dart'; | ||||||
| import 'package:surface/providers/userinfo.dart'; | import 'package:surface/providers/userinfo.dart'; | ||||||
| import 'package:surface/types/chat.dart'; | import 'package:surface/types/chat.dart'; | ||||||
| import 'package:surface/types/realm.dart'; | import 'package:surface/types/realm.dart'; | ||||||
| @@ -57,7 +59,9 @@ class _RealmDiscoveryScreenState extends State<RealmDiscoveryScreen> { | |||||||
|         title: Text('screenRealmDiscovery').tr(), |         title: Text('screenRealmDiscovery').tr(), | ||||||
|         actions: [ |         actions: [ | ||||||
|           IconButton( |           IconButton( | ||||||
|             icon: _isCompactView ? const Icon(Symbols.view_list) : const Icon(Symbols.view_module), |             icon: _isCompactView | ||||||
|  |                 ? const Icon(Symbols.view_list) | ||||||
|  |                 : const Icon(Symbols.view_module), | ||||||
|             onPressed: () { |             onPressed: () { | ||||||
|               setState(() => _isCompactView = !_isCompactView); |               setState(() => _isCompactView = !_isCompactView); | ||||||
|               context.read<ConfigProvider>().realmCompactView = _isCompactView; |               context.read<ConfigProvider>().realmCompactView = _isCompactView; | ||||||
| @@ -117,7 +121,8 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> { | |||||||
|     try { |     try { | ||||||
|       setState(() => _isBusy = true); |       setState(() => _isBusy = true); | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final resp = await sn.client.get('/cgi/im/channels/${widget.realm.alias}/public'); |       final resp = | ||||||
|  |           await sn.client.get('/cgi/im/channels/${widget.realm.alias}/public'); | ||||||
|       final out = List<SnChannel>.from( |       final out = List<SnChannel>.from( | ||||||
|         resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(), |         resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(), | ||||||
|       ); |       ); | ||||||
| @@ -135,10 +140,13 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> { | |||||||
|       setState(() => _isJoining = true); |       setState(() => _isJoining = true); | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final ua = context.read<UserProvider>(); |       final ua = context.read<UserProvider>(); | ||||||
|       await sn.client.post('/cgi/id/realms/${widget.realm.alias}/members', data: { |       final rel = context.read<SnRealmProvider>(); | ||||||
|  |       await sn.client | ||||||
|  |           .post('/cgi/id/realms/${widget.realm.alias}/members', data: { | ||||||
|         'related': ua.user?.name, |         'related': ua.user?.name, | ||||||
|       }); |       }); | ||||||
|       await _joinSelectedChannels(); |       await _joinSelectedChannels(); | ||||||
|  |       rel.addAvailableRealm(widget.realm); | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showSnackbar('realmJoined'.tr(args: [widget.realm.name])); |       context.showSnackbar('realmJoined'.tr(args: [widget.realm.name])); | ||||||
|       Navigator.pop(context); |       Navigator.pop(context); | ||||||
| @@ -156,13 +164,20 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> { | |||||||
|       try { |       try { | ||||||
|         final sn = context.read<SnNetworkProvider>(); |         final sn = context.read<SnNetworkProvider>(); | ||||||
|         final ua = context.read<UserProvider>(); |         final ua = context.read<UserProvider>(); | ||||||
|         await sn.client.post('/cgi/im/channels/${widget.realm.alias}/$channel/members', data: { |         await sn.client.post( | ||||||
|  |             '/cgi/im/channels/${widget.realm.alias}/$channel/members', | ||||||
|  |             data: { | ||||||
|               'related': ua.user?.name, |               'related': ua.user?.name, | ||||||
|             }); |             }); | ||||||
|       } catch (err) { |       } catch (err) { | ||||||
|         if (!mounted) return; |         if (!mounted) return; | ||||||
|         context.showErrorDialog(err); |         context.showErrorDialog(err); | ||||||
|       } |       } | ||||||
|  |       final ct = context.read<ChatChannelProvider>(); | ||||||
|  |       for (final channel | ||||||
|  |           in _channels!.where((ele) => _planJoinChannels.contains(ele.alias))) { | ||||||
|  |         ct.addAvailableChannel(channel); | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -182,7 +197,8 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> { | |||||||
|           children: [ |           children: [ | ||||||
|             const Icon(Symbols.group_add, size: 24), |             const Icon(Symbols.group_add, size: 24), | ||||||
|             const Gap(16), |             const Gap(16), | ||||||
|             Text('realmJoin', style: Theme.of(context).textTheme.titleLarge).tr(), |             Text('realmJoin', style: Theme.of(context).textTheme.titleLarge) | ||||||
|  |                 .tr(), | ||||||
|           ], |           ], | ||||||
|         ).padding(horizontal: 20, top: 16, bottom: 12), |         ).padding(horizontal: 20, top: 16, bottom: 12), | ||||||
|         Row( |         Row( | ||||||
| @@ -216,7 +232,8 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> { | |||||||
|         Container( |         Container( | ||||||
|           width: double.infinity, |           width: double.infinity, | ||||||
|           color: Theme.of(context).colorScheme.surfaceContainerHigh, |           color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||||
|           child: Text('realmCommunityPublicChannelsHint'.tr(), style: Theme.of(context).textTheme.bodyMedium) |           child: Text('realmCommunityPublicChannelsHint'.tr(), | ||||||
|  |                   style: Theme.of(context).textTheme.bodyMedium) | ||||||
|               .padding(horizontal: 24, vertical: 8), |               .padding(horizontal: 24, vertical: 8), | ||||||
|         ), |         ), | ||||||
|         Expanded( |         Expanded( | ||||||
|   | |||||||
| @@ -6,6 +6,7 @@ import 'package:easy_localization/easy_localization.dart'; | |||||||
| import 'package:flutter/foundation.dart'; | import 'package:flutter/foundation.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter/services.dart'; | import 'package:flutter/services.dart'; | ||||||
|  | import 'package:flutter_cache_manager/flutter_cache_manager.dart'; | ||||||
| import 'package:flutter_colorpicker/flutter_colorpicker.dart'; | import 'package:flutter_colorpicker/flutter_colorpicker.dart'; | ||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
| import 'package:google_fonts/google_fonts.dart'; | import 'package:google_fonts/google_fonts.dart'; | ||||||
| @@ -48,6 +49,7 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|   late final SharedPreferences _prefs; |   late final SharedPreferences _prefs; | ||||||
|   String _docBasepath = '/'; |   String _docBasepath = '/'; | ||||||
|  |  | ||||||
|  |   final TextEditingController _customFontController = TextEditingController(); | ||||||
|   final TextEditingController _serverUrlController = TextEditingController(); |   final TextEditingController _serverUrlController = TextEditingController(); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
| @@ -62,11 +64,15 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|     final config = context.read<ConfigProvider>(); |     final config = context.read<ConfigProvider>(); | ||||||
|     _prefs = config.prefs; |     _prefs = config.prefs; | ||||||
|     _serverUrlController.text = config.serverUrl; |     _serverUrlController.text = config.serverUrl; | ||||||
|  |     if (_prefs.getString(kAppCustomFonts) != null) { | ||||||
|  |       _customFontController.text = _prefs.getString(kAppCustomFonts) ?? ''; | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void dispose() { |   void dispose() { | ||||||
|     _serverUrlController.dispose(); |     _serverUrlController.dispose(); | ||||||
|  |     _customFontController.dispose(); | ||||||
|     super.dispose(); |     super.dispose(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -74,6 +80,9 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     final sn = context.read<SnNetworkProvider>(); |     final sn = context.read<SnNetworkProvider>(); | ||||||
|     final dt = context.read<DatabaseProvider>(); |     final dt = context.read<DatabaseProvider>(); | ||||||
|  |     final cfg = context.watch<ConfigProvider>(); | ||||||
|  |  | ||||||
|  |     final now = DateTime.now(); | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
| @@ -330,6 +339,85 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|                     setState(() {}); |                     setState(() {}); | ||||||
|                   }, |                   }, | ||||||
|                 ), |                 ), | ||||||
|  |                 CheckboxListTile( | ||||||
|  |                   secondary: const Icon(Symbols.hide), | ||||||
|  |                   title: Text('settingsHideBottomNav').tr(), | ||||||
|  |                   subtitle: Text('settingsHideBottomNavDescription').tr(), | ||||||
|  |                   contentPadding: const EdgeInsets.only(left: 24, right: 17), | ||||||
|  |                   value: _prefs.getBool(kAppHideBottomNav) ?? false, | ||||||
|  |                   onChanged: (value) { | ||||||
|  |                     _prefs.setBool(kAppHideBottomNav, value ?? false); | ||||||
|  |                     final cfg = context.read<ConfigProvider>(); | ||||||
|  |                     cfg.calcDrawerSize(context); | ||||||
|  |                     setState(() {}); | ||||||
|  |                   }, | ||||||
|  |                 ), | ||||||
|  |                 CheckboxListTile( | ||||||
|  |                   value: cfg.soundEffects, | ||||||
|  |                   onChanged: (value) { | ||||||
|  |                     cfg.soundEffects = value ?? false; | ||||||
|  |                     setState(() {}); | ||||||
|  |                   }, | ||||||
|  |                   contentPadding: const EdgeInsets.only(left: 24, right: 17), | ||||||
|  |                   title: Text('settingsSoundEffects').tr(), | ||||||
|  |                   subtitle: Text('settingsSoundEffectsDescription').tr(), | ||||||
|  |                   secondary: const Icon(Symbols.sound_sampler), | ||||||
|  |                 ), | ||||||
|  |                 if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) | ||||||
|  |                   ListTile( | ||||||
|  |                     leading: const Icon(Symbols.window), | ||||||
|  |                     title: Text('settingsResetMemorizedWindowSize').tr(), | ||||||
|  |                     subtitle: | ||||||
|  |                         Text('settingsResetMemorizedWindowSizeDescription') | ||||||
|  |                             .tr(), | ||||||
|  |                     trailing: const Icon(Symbols.chevron_right), | ||||||
|  |                     contentPadding: const EdgeInsets.only(left: 24, right: 24), | ||||||
|  |                     onTap: () { | ||||||
|  |                       final prefs = context.read<ConfigProvider>().prefs; | ||||||
|  |                       prefs.remove(kAppWindowSize); | ||||||
|  |                     }, | ||||||
|  |                   ), | ||||||
|  |                 ListTile( | ||||||
|  |                   leading: const Icon(Symbols.font_download), | ||||||
|  |                   title: Text('settingsCustomFonts').tr(), | ||||||
|  |                   subtitle: Text('settingsCustomFontsDescription').tr(), | ||||||
|  |                   contentPadding: const EdgeInsets.only(left: 24, right: 14), | ||||||
|  |                   trailing: IconButton( | ||||||
|  |                     padding: EdgeInsets.zero, | ||||||
|  |                     constraints: const BoxConstraints(), | ||||||
|  |                     icon: const Icon(Icons.clear), | ||||||
|  |                     onPressed: () { | ||||||
|  |                       _prefs.remove(kAppCustomFonts); | ||||||
|  |                       context.showSnackbar('settingsCustomFontApplied'.tr()); | ||||||
|  |                       final theme = context.read<ThemeProvider>(); | ||||||
|  |                       _customFontController.clear(); | ||||||
|  |                       theme.reloadTheme(); | ||||||
|  |                     }, | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |                 TextField( | ||||||
|  |                   controller: _customFontController, | ||||||
|  |                   decoration: InputDecoration( | ||||||
|  |                     border: const OutlineInputBorder(), | ||||||
|  |                     labelText: 'settingsCustomFontFamily'.tr(), | ||||||
|  |                     helperText: 'settingsCustomFontFamilyHint'.tr(), | ||||||
|  |                     prefixIcon: const Icon(Symbols.format_paint), | ||||||
|  |                     suffixIcon: IconButton( | ||||||
|  |                       icon: const Icon(Symbols.save), | ||||||
|  |                       onPressed: () { | ||||||
|  |                         _prefs.setString( | ||||||
|  |                           kAppCustomFonts, | ||||||
|  |                           _customFontController.text, | ||||||
|  |                         ); | ||||||
|  |                         context.showSnackbar('settingsCustomFontApplied'.tr()); | ||||||
|  |                         final theme = context.read<ThemeProvider>(); | ||||||
|  |                         theme.reloadTheme(); | ||||||
|  |                       }, | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|  |                   onTapOutside: (_) => | ||||||
|  |                       FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |                 ).padding(horizontal: 16, top: 8, bottom: 4), | ||||||
|               ], |               ], | ||||||
|             ), |             ), | ||||||
|             Column( |             Column( | ||||||
| @@ -340,6 +428,18 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|                     .fontSize(17) |                     .fontSize(17) | ||||||
|                     .tr() |                     .tr() | ||||||
|                     .padding(horizontal: 20, bottom: 4), |                     .padding(horizontal: 20, bottom: 4), | ||||||
|  |                 CheckboxListTile( | ||||||
|  |                   secondary: const Icon(Symbols.translate), | ||||||
|  |                   contentPadding: const EdgeInsets.only(left: 24, right: 17), | ||||||
|  |                   title: Text('settingsAutoTranslate').tr(), | ||||||
|  |                   subtitle: Text('settingsAutoTranslateDescription').tr(), | ||||||
|  |                   value: _prefs.getBool(kAppAutoTranslate) ?? false, | ||||||
|  |                   onChanged: (value) { | ||||||
|  |                     setState(() { | ||||||
|  |                       _prefs.setBool(kAppAutoTranslate, value ?? false); | ||||||
|  |                     }); | ||||||
|  |                   }, | ||||||
|  |                 ), | ||||||
|                 CheckboxListTile( |                 CheckboxListTile( | ||||||
|                   secondary: const Icon(Symbols.vibration), |                   secondary: const Icon(Symbols.vibration), | ||||||
|                   contentPadding: const EdgeInsets.only(left: 24, right: 17), |                   contentPadding: const EdgeInsets.only(left: 24, right: 17), | ||||||
| @@ -534,6 +634,37 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|                     .fontSize(17) |                     .fontSize(17) | ||||||
|                     .tr() |                     .tr() | ||||||
|                     .padding(horizontal: 20, bottom: 4), |                     .padding(horizontal: 20, bottom: 4), | ||||||
|  |                 ListTile( | ||||||
|  |                   leading: const Icon(Symbols.home_storage), | ||||||
|  |                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                   title: Text('cacheSize').tr(), | ||||||
|  |                   subtitle: FutureBuilder( | ||||||
|  |                     future: DefaultCacheManager().store.getCacheSize(), | ||||||
|  |                     builder: (context, snapshot) { | ||||||
|  |                       if (!snapshot.hasData || kIsWeb) { | ||||||
|  |                         return Text('unknown').tr(); | ||||||
|  |                       } | ||||||
|  |                       return Text( | ||||||
|  |                         snapshot.data!.formatBytes(), | ||||||
|  |                         style: GoogleFonts.robotoMono(), | ||||||
|  |                       ); | ||||||
|  |                     }, | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |                 ListTile( | ||||||
|  |                   leading: const Icon(Symbols.cleaning_services), | ||||||
|  |                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                   title: Text('cacheDelete').tr(), | ||||||
|  |                   subtitle: Text('cacheDeleteDescription').tr(), | ||||||
|  |                   trailing: const Icon(Symbols.chevron_right), | ||||||
|  |                   onTap: () async { | ||||||
|  |                     await DefaultCacheManager().emptyCache(); | ||||||
|  |                     if (!context.mounted) return; | ||||||
|  |                     HapticFeedback.heavyImpact(); | ||||||
|  |                     context.showSnackbar('cacheDeleted'.tr()); | ||||||
|  |                     setState(() {}); | ||||||
|  |                   }, | ||||||
|  |                 ), | ||||||
|                 ListTile( |                 ListTile( | ||||||
|                   leading: const Icon(Symbols.database), |                   leading: const Icon(Symbols.database), | ||||||
|                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), |                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
| @@ -618,6 +749,16 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|                     ); |                     ); | ||||||
|                   }, |                   }, | ||||||
|                 ), |                 ), | ||||||
|  |                 ListTile( | ||||||
|  |                   title: Text('runtimeLogsOpen').tr(), | ||||||
|  |                   subtitle: Text('runtimeLogsDescription').tr(), | ||||||
|  |                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                   leading: const Icon(Symbols.receipt_long), | ||||||
|  |                   trailing: const Icon(Symbols.chevron_right), | ||||||
|  |                   onTap: () async { | ||||||
|  |                     GoRouter.of(context).pushNamed('debugLogging'); | ||||||
|  |                   }, | ||||||
|  |                 ), | ||||||
|                 ListTile( |                 ListTile( | ||||||
|                   title: Text('settingsMiscAbout').tr(), |                   title: Text('settingsMiscAbout').tr(), | ||||||
|                   subtitle: Text('settingsMiscAboutDescription').tr(), |                   subtitle: Text('settingsMiscAboutDescription').tr(), | ||||||
| @@ -628,6 +769,18 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|                     GoRouter.of(context).pushNamed('about'); |                     GoRouter.of(context).pushNamed('about'); | ||||||
|                   }, |                   }, | ||||||
|                 ), |                 ), | ||||||
|  |                 if (now.day == 1 && now.month == 4) | ||||||
|  |                   CheckboxListTile( | ||||||
|  |                     title: Text('settingsAprilFoolFeatures').tr(), | ||||||
|  |                     subtitle: Text('settingsAprilFoolFeaturesDescription').tr(), | ||||||
|  |                     contentPadding: const EdgeInsets.only(left: 24, right: 17), | ||||||
|  |                     secondary: const Icon(Symbols.new_releases), | ||||||
|  |                     value: cfg.aprilFoolFeatures, | ||||||
|  |                     onChanged: (value) { | ||||||
|  |                       cfg.aprilFoolFeatures = value ?? false; | ||||||
|  |                       setState(() {}); | ||||||
|  |                     }, | ||||||
|  |                   ) | ||||||
|               ], |               ], | ||||||
|             ), |             ), | ||||||
|           ], |           ], | ||||||
|   | |||||||
| @@ -50,27 +50,37 @@ class _AppSharingListenerState extends State<AppSharingListener> { | |||||||
|               Card( |               Card( | ||||||
|                 child: Column( |                 child: Column( | ||||||
|                   children: [ |                   children: [ | ||||||
|  |                     const SizedBox(width: double.infinity), | ||||||
|                     ListTile( |                     ListTile( | ||||||
|                       contentPadding: const EdgeInsets.symmetric(horizontal: 24), |                       contentPadding: | ||||||
|                       shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), |                           const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                       shape: RoundedRectangleBorder( | ||||||
|  |                           borderRadius: BorderRadius.circular(8)), | ||||||
|                       leading: Icon(Icons.post_add), |                       leading: Icon(Icons.post_add), | ||||||
|                       trailing: const Icon(Icons.chevron_right), |                       trailing: const Icon(Icons.chevron_right), | ||||||
|                       title: Text('shareIntentPostStory').tr(), |                       title: Text('shareIntentPostStory').tr(), | ||||||
|                       onTap: () { |                       onTap: () { | ||||||
|                         GoRouter.of(context).pushNamed( |                         GoRouter.of(context).pushNamed( | ||||||
|                           'postEditor', |                           'postEditor', | ||||||
|                           pathParameters: { |                           queryParameters: { | ||||||
|                             'mode': 'stories', |                             'mode': 'stories', | ||||||
|                           }, |                           }, | ||||||
|                           extra: PostEditorExtra( |                           extra: PostEditorExtra( | ||||||
|                             text: value |                             text: value | ||||||
|                                 .where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type)) |                                 .where((e) => [ | ||||||
|  |                                       SharedMediaType.text, | ||||||
|  |                                       SharedMediaType.url | ||||||
|  |                                     ].contains(e.type)) | ||||||
|                                 .map((e) => e.path) |                                 .map((e) => e.path) | ||||||
|                                 .join('\n'), |                                 .join('\n'), | ||||||
|                             attachments: value |                             attachments: value | ||||||
|                                 .where((e) => [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image] |                                 .where((e) => [ | ||||||
|                                     .contains(e.type)) |                                       SharedMediaType.video, | ||||||
|                                 .map((e) => PostWriteMedia.fromFile(XFile(e.path))) |                                       SharedMediaType.file, | ||||||
|  |                                       SharedMediaType.image | ||||||
|  |                                     ].contains(e.type)) | ||||||
|  |                                 .map((e) => | ||||||
|  |                                     PostWriteMedia.fromFile(XFile(e.path))) | ||||||
|                                 .toList(), |                                 .toList(), | ||||||
|                           ), |                           ), | ||||||
|                         ); |                         ); | ||||||
| @@ -78,15 +88,18 @@ class _AppSharingListenerState extends State<AppSharingListener> { | |||||||
|                       }, |                       }, | ||||||
|                     ), |                     ), | ||||||
|                     ListTile( |                     ListTile( | ||||||
|                       contentPadding: const EdgeInsets.symmetric(horizontal: 24), |                       contentPadding: | ||||||
|                       shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), |                           const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                       shape: RoundedRectangleBorder( | ||||||
|  |                           borderRadius: BorderRadius.circular(8)), | ||||||
|                       leading: Icon(Icons.chat_outlined), |                       leading: Icon(Icons.chat_outlined), | ||||||
|                       trailing: const Icon(Icons.chevron_right), |                       trailing: const Icon(Icons.chevron_right), | ||||||
|                       title: Text('shareIntentSendChannel').tr(), |                       title: Text('shareIntentSendChannel').tr(), | ||||||
|                       onTap: () { |                       onTap: () { | ||||||
|                         showModalBottomSheet( |                         showModalBottomSheet( | ||||||
|                           context: context, |                           context: context, | ||||||
|                           builder: (context) => _ShareIntentChannelSelect(value: value), |                           builder: (context) => | ||||||
|  |                               _ShareIntentChannelSelect(value: value), | ||||||
|                         ).then((val) { |                         ).then((val) { | ||||||
|                           if (!context.mounted) return; |                           if (!context.mounted) return; | ||||||
|                           if (val == true) Navigator.pop(context); |                           if (val == true) Navigator.pop(context); | ||||||
| @@ -110,7 +123,8 @@ class _AppSharingListenerState extends State<AppSharingListener> { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   void _initialize() async { |   void _initialize() async { | ||||||
|     _shareIntentSubscription = ReceiveSharingIntent.instance.getMediaStream().listen((value) { |     _shareIntentSubscription = | ||||||
|  |         ReceiveSharingIntent.instance.getMediaStream().listen((value) { | ||||||
|       if (value.isEmpty) return; |       if (value.isEmpty) return; | ||||||
|       if (mounted) { |       if (mounted) { | ||||||
|         _gotoPost(value); |         _gotoPost(value); | ||||||
| @@ -157,7 +171,8 @@ class _ShareIntentChannelSelect extends StatefulWidget { | |||||||
|   const _ShareIntentChannelSelect({required this.value}); |   const _ShareIntentChannelSelect({required this.value}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   State<_ShareIntentChannelSelect> createState() => _ShareIntentChannelSelectState(); |   State<_ShareIntentChannelSelect> createState() => | ||||||
|  |       _ShareIntentChannelSelectState(); | ||||||
| } | } | ||||||
|  |  | ||||||
| class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> { | class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> { | ||||||
| @@ -178,8 +193,11 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> { | |||||||
|       final lastMessages = await chan.getLastMessages(channels); |       final lastMessages = await chan.getLastMessages(channels); | ||||||
|       _lastMessages = {for (final val in lastMessages) val.channelId: val}; |       _lastMessages = {for (final val in lastMessages) val.channelId: val}; | ||||||
|       channels.sort((a, b) { |       channels.sort((a, b) { | ||||||
|         if (_lastMessages!.containsKey(a.id) && _lastMessages!.containsKey(b.id)) { |         if (_lastMessages!.containsKey(a.id) && | ||||||
|           return _lastMessages![b.id]!.createdAt.compareTo(_lastMessages![a.id]!.createdAt); |             _lastMessages!.containsKey(b.id)) { | ||||||
|  |           return _lastMessages![b.id]! | ||||||
|  |               .createdAt | ||||||
|  |               .compareTo(_lastMessages![a.id]!.createdAt); | ||||||
|         } |         } | ||||||
|         if (_lastMessages!.containsKey(a.id)) return -1; |         if (_lastMessages!.containsKey(a.id)) return -1; | ||||||
|         if (_lastMessages!.containsKey(b.id)) return 1; |         if (_lastMessages!.containsKey(b.id)) return 1; | ||||||
| @@ -232,7 +250,9 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> { | |||||||
|           children: [ |           children: [ | ||||||
|             const Icon(Symbols.chat, size: 24), |             const Icon(Symbols.chat, size: 24), | ||||||
|             const Gap(16), |             const Gap(16), | ||||||
|             Text('shareIntentSendChannel', style: Theme.of(context).textTheme.titleLarge).tr(), |             Text('shareIntentSendChannel', | ||||||
|  |                     style: Theme.of(context).textTheme.titleLarge) | ||||||
|  |                 .tr(), | ||||||
|           ], |           ], | ||||||
|         ).padding(horizontal: 20, top: 16, bottom: 12), |         ).padding(horizontal: 20, top: 16, bottom: 12), | ||||||
|         LoadingIndicator(isActive: _isBusy), |         LoadingIndicator(isActive: _isBusy), | ||||||
| @@ -249,29 +269,34 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> { | |||||||
|                   final lastMessage = _lastMessages?[channel.id]; |                   final lastMessage = _lastMessages?[channel.id]; | ||||||
|  |  | ||||||
|                   if (channel.type == 1) { |                   if (channel.type == 1) { | ||||||
|                     final otherMember = channel.members?.cast<SnChannelMember?>().firstWhere( |                     final otherMember = | ||||||
|  |                         channel.members?.cast<SnChannelMember?>().firstWhere( | ||||||
|                               (ele) => ele?.accountId != ua.user?.id, |                               (ele) => ele?.accountId != ua.user?.id, | ||||||
|                               orElse: () => null, |                               orElse: () => null, | ||||||
|                             ); |                             ); | ||||||
|  |  | ||||||
|                     return ListTile( |                     return ListTile( | ||||||
|                       title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name), |                       title: Text( | ||||||
|  |                           ud.getFromCache(otherMember?.accountId)?.nick ?? | ||||||
|  |                               channel.name), | ||||||
|                       subtitle: lastMessage != null |                       subtitle: lastMessage != null | ||||||
|                           ? Text( |                           ? Text( | ||||||
|                               '${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}', |                               '${ud.getFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}', | ||||||
|                               maxLines: 1, |                               maxLines: 1, | ||||||
|                               overflow: TextOverflow.ellipsis, |                               overflow: TextOverflow.ellipsis, | ||||||
|                             ) |                             ) | ||||||
|                           : Text( |                           : Text( | ||||||
|                               'channelDirectMessageDescription'.tr(args: [ |                               'channelDirectMessageDescription'.tr(args: [ | ||||||
|                                 '@${ud.getAccountFromCache(otherMember?.accountId)?.name}', |                                 '@${ud.getFromCache(otherMember?.accountId)?.name}', | ||||||
|                               ]), |                               ]), | ||||||
|                               maxLines: 1, |                               maxLines: 1, | ||||||
|                               overflow: TextOverflow.ellipsis, |                               overflow: TextOverflow.ellipsis, | ||||||
|                             ), |                             ), | ||||||
|                       contentPadding: const EdgeInsets.symmetric(horizontal: 16), |                       contentPadding: | ||||||
|  |                           const EdgeInsets.symmetric(horizontal: 16), | ||||||
|                       leading: AccountImage( |                       leading: AccountImage( | ||||||
|                         content: ud.getAccountFromCache(otherMember?.accountId)?.avatar, |                         content: | ||||||
|  |                             ud.getFromCache(otherMember?.accountId)?.avatar, | ||||||
|                       ), |                       ), | ||||||
|                       onTap: () { |                       onTap: () { | ||||||
|                         GoRouter.of(context).pushNamed( |                         GoRouter.of(context).pushNamed( | ||||||
| @@ -291,7 +316,7 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> { | |||||||
|                     title: Text(channel.name), |                     title: Text(channel.name), | ||||||
|                     subtitle: lastMessage != null |                     subtitle: lastMessage != null | ||||||
|                         ? Text( |                         ? Text( | ||||||
|                             '${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}', |                             '${ud.getFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}', | ||||||
|                             maxLines: 1, |                             maxLines: 1, | ||||||
|                             overflow: TextOverflow.ellipsis, |                             overflow: TextOverflow.ellipsis, | ||||||
|                           ) |                           ) | ||||||
| @@ -316,13 +341,20 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> { | |||||||
|                         }, |                         }, | ||||||
|                         extra: ChatRoomScreenExtra( |                         extra: ChatRoomScreenExtra( | ||||||
|                           initialText: widget.value |                           initialText: widget.value | ||||||
|                               .where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type)) |                               .where((e) => [ | ||||||
|  |                                     SharedMediaType.text, | ||||||
|  |                                     SharedMediaType.url | ||||||
|  |                                   ].contains(e.type)) | ||||||
|                               .map((e) => e.path) |                               .map((e) => e.path) | ||||||
|                               .join('\n'), |                               .join('\n'), | ||||||
|                           initialAttachments: widget.value |                           initialAttachments: widget.value | ||||||
|                               .where((e) => |                               .where((e) => [ | ||||||
|                                   [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image].contains(e.type)) |                                     SharedMediaType.video, | ||||||
|                               .map((e) => PostWriteMedia.fromFile(XFile(e.path))) |                                     SharedMediaType.file, | ||||||
|  |                                     SharedMediaType.image | ||||||
|  |                                   ].contains(e.type)) | ||||||
|  |                               .map( | ||||||
|  |                                   (e) => PostWriteMedia.fromFile(XFile(e.path))) | ||||||
|                               .toList(), |                               .toList(), | ||||||
|                         ), |                         ), | ||||||
|                       ) |                       ) | ||||||
|   | |||||||
| @@ -9,7 +9,6 @@ import 'package:surface/providers/sn_network.dart'; | |||||||
| import 'package:surface/providers/sn_sticker.dart'; | import 'package:surface/providers/sn_sticker.dart'; | ||||||
| import 'package:surface/providers/userinfo.dart'; | import 'package:surface/providers/userinfo.dart'; | ||||||
| import 'package:surface/types/attachment.dart'; | import 'package:surface/types/attachment.dart'; | ||||||
| import 'package:surface/widgets/app_bar_leading.dart'; |  | ||||||
| import 'package:surface/widgets/attachment/attachment_item.dart'; | import 'package:surface/widgets/attachment/attachment_item.dart'; | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
| import 'package:surface/widgets/loading_indicator.dart'; | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
| @@ -134,7 +133,7 @@ class _StickerScreenState extends State<StickerScreen> | |||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         leading: AutoAppBarLeading(), |         leading: PageBackButton(), | ||||||
|         title: Text('screenStickers').tr(), |         title: Text('screenStickers').tr(), | ||||||
|         actions: [ |         actions: [ | ||||||
|           IconButton( |           IconButton( | ||||||
| @@ -179,7 +178,9 @@ class _StickerScreenState extends State<StickerScreen> | |||||||
|           child: InfiniteList( |           child: InfiniteList( | ||||||
|             itemCount: _packs.length, |             itemCount: _packs.length, | ||||||
|             onFetchData: _fetchPacks, |             onFetchData: _fetchPacks, | ||||||
|             hasReachedMax: _totalCount != null && _packs.length >= _totalCount!, |             hasReachedMax: | ||||||
|  |                 (_totalCount != null && _packs.length >= _totalCount!) || | ||||||
|  |                     _tabController.index == 2, | ||||||
|             isLoading: _isBusy, |             isLoading: _isBusy, | ||||||
|             itemBuilder: (context, idx) { |             itemBuilder: (context, idx) { | ||||||
|               final pack = _packs[idx]; |               final pack = _packs[idx]; | ||||||
| @@ -282,7 +283,10 @@ class _StickerPackAddPopupState extends State<_StickerPackAddPopup> { | |||||||
|       ); |       ); | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showSnackbar('stickersAdded'.tr()); |       context.showSnackbar('stickersAdded'.tr()); | ||||||
|       if (_pack?.stickers != null) stickers.putSticker(_pack!.stickers!); |       if (_pack?.stickers != null) { | ||||||
|  |         stickers.putSticker( | ||||||
|  |             _pack!.stickers!.map((ele) => ele.copyWith(pack: _pack!))); | ||||||
|  |       } | ||||||
|       Navigator.pop(context, true); |       Navigator.pop(context, true); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|   | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user