Compare commits
	
		
			74 Commits
		
	
	
		
			2.4.2+76
			...
			9311bfc3b5
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | 
							
								
								
									
										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
 | 
			
		||||
}
 | 
			
		||||
@@ -5,14 +5,14 @@ meta {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
put {
 | 
			
		||||
  url: {{endpoint}}/cgi/id/reports/abuse/3/status
 | 
			
		||||
  url: {{endpoint}}/cgi/id/reports/abuse/6/status
 | 
			
		||||
  body: json
 | 
			
		||||
  auth: inherit
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body:json {
 | 
			
		||||
  {
 | 
			
		||||
    "status": "processed",
 | 
			
		||||
    "message": "相关附件已经进行评级处理,未来会将该项权限下放到帖主以及社区成员。"
 | 
			
		||||
    "status": "rejected",
 | 
			
		||||
    "message": "Not a good reason"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -15,12 +15,10 @@ body:json {
 | 
			
		||||
    "client_id": "{{third_client_id}}",
 | 
			
		||||
    "client_secret":"{{third_client_tk}}",
 | 
			
		||||
    "type": "general",
 | 
			
		||||
    "subject": "新年快乐!",
 | 
			
		||||
    "subtitle": "一条来自 Solar Network 团队的信息",
 | 
			
		||||
    "content": "今天是农历正月初一,小羊祝您新年快乐 🎉",
 | 
			
		||||
    "metadata": {
 | 
			
		||||
      "image": "D2EDbcrsTugs3xk5"
 | 
			
		||||
    },
 | 
			
		||||
    "subject": "关于迁移服务器完成的提示",
 | 
			
		||||
    "subtitle": "一条来自 Solar Network 团队的运营信息",
 | 
			
		||||
    "content": "我们已经将所有用户数据迁移到新版服务器,刚刚发布新的 DNS,因为部分 DNS 缓存的影响。可能更改不会生效,可以使用 nslookup / ping 检查解析地址是否未 8. 开头,您可以主动刷新 DNS。谢谢!",
 | 
			
		||||
    "metadata": {},
 | 
			
		||||
    "priority": 10
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -7,5 +7,5 @@ meta {
 | 
			
		||||
get {
 | 
			
		||||
  url: {{endpoint}}/cgi/re/well-known/sources
 | 
			
		||||
  body: none
 | 
			
		||||
  auth: none
 | 
			
		||||
  auth: inherit
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -12,7 +12,7 @@ post {
 | 
			
		||||
 | 
			
		||||
body:json {
 | 
			
		||||
  {
 | 
			
		||||
    "sources": ["taiwan-ltn"],
 | 
			
		||||
    "sources": ["taiwan-pts"],
 | 
			
		||||
    "eager": true
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										
											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.",
 | 
			
		||||
  "accountSettings": "Account Settings",
 | 
			
		||||
  "accountSettingsSubtitle": "Manage your account and make it yours.",
 | 
			
		||||
  "accountProfileEdit": "Edit your profile",
 | 
			
		||||
  "accountProfileEdit": "Edit Profile",
 | 
			
		||||
  "accountProfileEditSubtitle": "Make your Solarpass account more looks like you.",
 | 
			
		||||
  "accountWallet": "Wallet",
 | 
			
		||||
  "accountWalletSubtitle": "View your balance and transactions.",
 | 
			
		||||
@@ -153,6 +153,11 @@
 | 
			
		||||
  "publisherRunBy": "Run by {}",
 | 
			
		||||
  "fieldPublisherBelongToRealm": "Belongs to",
 | 
			
		||||
  "fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm",
 | 
			
		||||
  "writePost": "Compose",
 | 
			
		||||
  "postTypeStory": "Story",
 | 
			
		||||
  "postTypeArticle": "Article",
 | 
			
		||||
  "postTypeQuestion": "Question",
 | 
			
		||||
  "postTypeVideo": "Video",
 | 
			
		||||
  "writePostTypeStory": "Post a story",
 | 
			
		||||
  "writePostTypeArticle": "Write an article",
 | 
			
		||||
  "writePostTypeQuestion": "Ask a question",
 | 
			
		||||
@@ -202,6 +207,7 @@
 | 
			
		||||
    "one": "{} comment",
 | 
			
		||||
    "other": "{} comments"
 | 
			
		||||
  },
 | 
			
		||||
  "postCommentExpand": "Show comments",
 | 
			
		||||
  "settingsAppearance": "Appearance",
 | 
			
		||||
  "settingsCustomFonts": "Custom Fonts",
 | 
			
		||||
  "settingsCustomFontsDescription": "Set custom fonts for the application.",
 | 
			
		||||
@@ -332,6 +338,7 @@
 | 
			
		||||
  "fieldAttachmentRandomId": "Random ID",
 | 
			
		||||
  "fieldAttachmentAlt": "Alternative text",
 | 
			
		||||
  "addAttachmentFromAlbum": "Add from album",
 | 
			
		||||
  "addAttachmentFromFiles": "Add from files",
 | 
			
		||||
  "addAttachmentFromClipboard": "Paste file",
 | 
			
		||||
  "addAttachmentFromCameraPhoto": "Take photo",
 | 
			
		||||
  "addAttachmentFromCameraVideo": "Take video",
 | 
			
		||||
@@ -763,5 +770,132 @@
 | 
			
		||||
  "decrypting": "Decrypting……",
 | 
			
		||||
  "decryptingKeyNotFound": "Key not found or exchange failed, the other party may not be online",
 | 
			
		||||
  "messageUnablePreview": "Unable preview",
 | 
			
		||||
  "messageUnablePreviewEncrypted": "Unable preview encrypted message"
 | 
			
		||||
  "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"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -137,6 +137,11 @@
 | 
			
		||||
  "publisherRunBy": "由 {} 管理",
 | 
			
		||||
  "fieldPublisherBelongToRealm": "所属领域",
 | 
			
		||||
  "fieldPublisherBelongToRealmUnset": "未设置发布者所属领域",
 | 
			
		||||
  "writePost": "撰写",
 | 
			
		||||
  "postTypeStory": "动态",
 | 
			
		||||
  "postTypeArticle": "文章",
 | 
			
		||||
  "postTypeQuestion": "问题",
 | 
			
		||||
  "postTypeVideo": "视频",
 | 
			
		||||
  "writePostTypeStory": "发动态",
 | 
			
		||||
  "writePostTypeArticle": "写文章",
 | 
			
		||||
  "writePostTypeQuestion": "提问题",
 | 
			
		||||
@@ -200,6 +205,7 @@
 | 
			
		||||
    "one": "{} 条评论",
 | 
			
		||||
    "other": "{} 条评论"
 | 
			
		||||
  },
 | 
			
		||||
  "postCommentExpand": "展开评论",
 | 
			
		||||
  "settingsAppearance": "外观",
 | 
			
		||||
  "settingsCustomFonts": "自定义字体",
 | 
			
		||||
  "settingsCustomFontsDescription": "设置应用程序使用的字体。",
 | 
			
		||||
@@ -330,6 +336,7 @@
 | 
			
		||||
  "fieldAttachmentRandomId": "访问 ID",
 | 
			
		||||
  "fieldAttachmentAlt": "概述文字",
 | 
			
		||||
  "addAttachmentFromAlbum": "从相册中添加附件",
 | 
			
		||||
  "addAttachmentFromFiles": "从文件中添加附件",
 | 
			
		||||
  "addAttachmentFromClipboard": "粘贴附件",
 | 
			
		||||
  "addAttachmentFromCameraPhoto": "拍摄照片",
 | 
			
		||||
  "addAttachmentFromCameraVideo": "拍摄视频",
 | 
			
		||||
@@ -761,5 +768,132 @@
 | 
			
		||||
  "decrypting": "解密中……",
 | 
			
		||||
  "decryptingKeyNotFound": "未找到密钥对或交换失败,对方可能不在线",
 | 
			
		||||
  "messageUnablePreview": "无法预览消息",
 | 
			
		||||
  "messageUnablePreviewEncrypted": "无法预览加密消息"
 | 
			
		||||
  "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": "由 {} 管理",
 | 
			
		||||
  "fieldPublisherBelongToRealm": "所屬領域",
 | 
			
		||||
  "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
 | 
			
		||||
  "writePost": "撰寫",
 | 
			
		||||
  "postTypeStory": "動態",
 | 
			
		||||
  "postTypeArticle": "文章",
 | 
			
		||||
  "postTypeQuestion": "問題",
 | 
			
		||||
  "postTypeVideo": "視頻",
 | 
			
		||||
  "writePostTypeStory": "發動態",
 | 
			
		||||
  "writePostTypeArticle": "寫文章",
 | 
			
		||||
  "writePostTypeQuestion": "提問題",
 | 
			
		||||
@@ -200,6 +205,7 @@
 | 
			
		||||
    "one": "{} 條評論",
 | 
			
		||||
    "other": "{} 條評論"
 | 
			
		||||
  },
 | 
			
		||||
  "postCommentExpand": "展開評論",
 | 
			
		||||
  "settingsAppearance": "外觀",
 | 
			
		||||
  "settingsCustomFonts": "自定義字體",
 | 
			
		||||
  "settingsCustomFontsDescription": "設置應用程序使用的字體。",
 | 
			
		||||
@@ -330,6 +336,7 @@
 | 
			
		||||
  "fieldAttachmentRandomId": "訪問 ID",
 | 
			
		||||
  "fieldAttachmentAlt": "概述文字",
 | 
			
		||||
  "addAttachmentFromAlbum": "從相冊中添加附件",
 | 
			
		||||
  "addAttachmentFromFiles": "從文件中添加附件",
 | 
			
		||||
  "addAttachmentFromClipboard": "粘貼附件",
 | 
			
		||||
  "addAttachmentFromCameraPhoto": "拍攝照片",
 | 
			
		||||
  "addAttachmentFromCameraVideo": "拍攝視頻",
 | 
			
		||||
@@ -761,5 +768,132 @@
 | 
			
		||||
  "decrypting": "解密中……",
 | 
			
		||||
  "decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線",
 | 
			
		||||
  "messageUnablePreview": "無法預覽消息",
 | 
			
		||||
  "messageUnablePreviewEncrypted": "無法預覽加密消息"
 | 
			
		||||
  "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": "由 {} 管理",
 | 
			
		||||
  "fieldPublisherBelongToRealm": "所屬領域",
 | 
			
		||||
  "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
 | 
			
		||||
  "writePost": "撰寫",
 | 
			
		||||
  "postTypeStory": "動態",
 | 
			
		||||
  "postTypeArticle": "文章",
 | 
			
		||||
  "postTypeQuestion": "問題",
 | 
			
		||||
  "postTypeVideo": "視頻",
 | 
			
		||||
  "writePostTypeStory": "發動態",
 | 
			
		||||
  "writePostTypeArticle": "寫文章",
 | 
			
		||||
  "writePostTypeQuestion": "提問題",
 | 
			
		||||
@@ -200,6 +205,7 @@
 | 
			
		||||
    "one": "{} 條評論",
 | 
			
		||||
    "other": "{} 條評論"
 | 
			
		||||
  },
 | 
			
		||||
  "postCommentExpand": "展開評論",
 | 
			
		||||
  "settingsAppearance": "外觀",
 | 
			
		||||
  "settingsCustomFonts": "自定義字體",
 | 
			
		||||
  "settingsCustomFontsDescription": "設置應用程序使用的字體。",
 | 
			
		||||
@@ -330,6 +336,7 @@
 | 
			
		||||
  "fieldAttachmentRandomId": "訪問 ID",
 | 
			
		||||
  "fieldAttachmentAlt": "概述文字",
 | 
			
		||||
  "addAttachmentFromAlbum": "從相冊中添加附件",
 | 
			
		||||
  "addAttachmentFromFiles": "從文件中添加附件",
 | 
			
		||||
  "addAttachmentFromClipboard": "粘貼附件",
 | 
			
		||||
  "addAttachmentFromCameraPhoto": "拍攝照片",
 | 
			
		||||
  "addAttachmentFromCameraVideo": "拍攝視頻",
 | 
			
		||||
@@ -761,5 +768,132 @@
 | 
			
		||||
  "decrypting": "解密中……",
 | 
			
		||||
  "decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線",
 | 
			
		||||
  "messageUnablePreview": "無法預覽消息",
 | 
			
		||||
  "messageUnablePreviewEncrypted": "無法預覽加密消息"
 | 
			
		||||
  "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": "或者註冊一個賬號"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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
											
										
									
								
							@@ -126,8 +126,6 @@ PODS:
 | 
			
		||||
  - gal (1.0.0):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - geolocator_apple (1.2.0):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - GoogleAppMeasurement (11.8.0):
 | 
			
		||||
    - GoogleAppMeasurement/AdIdSupport (= 11.8.0)
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
			
		||||
@@ -185,7 +183,7 @@ PODS:
 | 
			
		||||
  - in_app_review (2.0.0):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - Kingfisher (8.2.0)
 | 
			
		||||
  - livekit_client (2.4.0):
 | 
			
		||||
  - livekit_client (2.4.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - flutter_webrtc
 | 
			
		||||
    - WebRTC-SDK (= 125.6422.06)
 | 
			
		||||
@@ -234,6 +232,8 @@ PODS:
 | 
			
		||||
    - sqlite3/common
 | 
			
		||||
  - sqlite3/fts5 (3.49.1):
 | 
			
		||||
    - sqlite3/common
 | 
			
		||||
  - sqlite3/math (3.49.1):
 | 
			
		||||
    - sqlite3/common
 | 
			
		||||
  - sqlite3/perf-threadsafe (3.49.1):
 | 
			
		||||
    - sqlite3/common
 | 
			
		||||
  - sqlite3/rtree (3.49.1):
 | 
			
		||||
@@ -244,6 +244,7 @@ PODS:
 | 
			
		||||
    - sqlite3 (~> 3.49.1)
 | 
			
		||||
    - sqlite3/dbstatvtab
 | 
			
		||||
    - sqlite3/fts5
 | 
			
		||||
    - sqlite3/math
 | 
			
		||||
    - sqlite3/perf-threadsafe
 | 
			
		||||
    - sqlite3/rtree
 | 
			
		||||
  - SwiftyGif (5.4.5)
 | 
			
		||||
@@ -278,7 +279,6 @@ DEPENDENCIES:
 | 
			
		||||
  - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
 | 
			
		||||
  - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
 | 
			
		||||
  - gal (from `.symlinks/plugins/gal/darwin`)
 | 
			
		||||
  - geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`)
 | 
			
		||||
  - home_widget (from `.symlinks/plugins/home_widget/ios`)
 | 
			
		||||
  - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
 | 
			
		||||
  - in_app_review (from `.symlinks/plugins/in_app_review/ios`)
 | 
			
		||||
@@ -362,8 +362,6 @@ EXTERNAL SOURCES:
 | 
			
		||||
    :path: ".symlinks/plugins/flutter_webrtc/ios"
 | 
			
		||||
  gal:
 | 
			
		||||
    :path: ".symlinks/plugins/gal/darwin"
 | 
			
		||||
  geolocator_apple:
 | 
			
		||||
    :path: ".symlinks/plugins/geolocator_apple/ios"
 | 
			
		||||
  home_widget:
 | 
			
		||||
    :path: ".symlinks/plugins/home_widget/ios"
 | 
			
		||||
  image_picker_ios:
 | 
			
		||||
@@ -436,7 +434,6 @@ SPEC CHECKSUMS:
 | 
			
		||||
  flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab
 | 
			
		||||
  flutter_webrtc: 90260f83024b1b96d239a575ea4e3708e79344d1
 | 
			
		||||
  gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
 | 
			
		||||
  geolocator_apple: 9bcea1918ff7f0062d98345d238ae12718acfbc1
 | 
			
		||||
  GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896
 | 
			
		||||
  GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
 | 
			
		||||
  GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
 | 
			
		||||
@@ -444,7 +441,7 @@ SPEC CHECKSUMS:
 | 
			
		||||
  image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
 | 
			
		||||
  in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
 | 
			
		||||
  Kingfisher: 323e5c4ec7983aaace12af655a7b51a7f88a599d
 | 
			
		||||
  livekit_client: 9819ebc8be8ef00ed0fae7d806bf8938ec689573
 | 
			
		||||
  livekit_client: 170022ce5f7c8c70d7f862ac9c17e11508ad5fbc
 | 
			
		||||
  media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
 | 
			
		||||
  media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
 | 
			
		||||
  media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
 | 
			
		||||
@@ -463,7 +460,7 @@ SPEC CHECKSUMS:
 | 
			
		||||
  shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
 | 
			
		||||
  sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
 | 
			
		||||
  sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
 | 
			
		||||
  sqlite3_flutter_libs: cc304edcb8e1d8c595d1b08c7aeb46a47691d9db
 | 
			
		||||
  sqlite3_flutter_libs: 487032b9008b28de37c72a3aa66849ef3745f3e6
 | 
			
		||||
  SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
 | 
			
		||||
  url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
 | 
			
		||||
  video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe
 | 
			
		||||
 
 | 
			
		||||
@@ -79,6 +79,8 @@
 | 
			
		||||
		<string>UIInterfaceOrientationLandscapeLeft</string>
 | 
			
		||||
		<string>UIInterfaceOrientationLandscapeRight</string>
 | 
			
		||||
	</array>
 | 
			
		||||
	<key>LSSupportsOpeningDocumentsInPlace</key>
 | 
			
		||||
	<true/>
 | 
			
		||||
	<key>UISupportedInterfaceOrientations~ipad</key>
 | 
			
		||||
	<array>
 | 
			
		||||
		<string>UIInterfaceOrientationPortrait</string>
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
 | 
			
		||||
@@ -105,7 +106,8 @@ class PostWriteMedia {
 | 
			
		||||
  }) {
 | 
			
		||||
    if (attachment != null) {
 | 
			
		||||
      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) {
 | 
			
		||||
        return ResizeImage(
 | 
			
		||||
          provider,
 | 
			
		||||
@@ -116,7 +118,8 @@ class PostWriteMedia {
 | 
			
		||||
      }
 | 
			
		||||
      return provider;
 | 
			
		||||
    } 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) {
 | 
			
		||||
        return ResizeImage(
 | 
			
		||||
          provider,
 | 
			
		||||
@@ -159,11 +162,14 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
  final TextEditingController aliasController = TextEditingController();
 | 
			
		||||
  final TextEditingController rewardController = TextEditingController();
 | 
			
		||||
 | 
			
		||||
  ContentInsertionConfiguration get contentInsertionConfiguration => ContentInsertionConfiguration(
 | 
			
		||||
  ContentInsertionConfiguration get contentInsertionConfiguration =>
 | 
			
		||||
      ContentInsertionConfiguration(
 | 
			
		||||
        onContentInserted: (KeyboardInsertedContent content) {
 | 
			
		||||
          if (content.hasData) {
 | 
			
		||||
            addAttachments(
 | 
			
		||||
                [PostWriteMedia.fromBytes(content.data!, 'attachmentInsertedImage'.tr(), SnMediaType.image)]);
 | 
			
		||||
            addAttachments([
 | 
			
		||||
              PostWriteMedia.fromBytes(content.data!,
 | 
			
		||||
                  'attachmentInsertedImage'.tr(), SnMediaType.image)
 | 
			
		||||
            ]);
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
@@ -193,7 +199,8 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
 | 
			
		||||
  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;
 | 
			
		||||
  double? progress;
 | 
			
		||||
@@ -201,6 +208,7 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
  SnRealm? realm;
 | 
			
		||||
  SnPublisher? publisher;
 | 
			
		||||
  SnPost? editingPost, repostingPost, replyingPost;
 | 
			
		||||
  bool editingDraft = false;
 | 
			
		||||
 | 
			
		||||
  int visibility = 0;
 | 
			
		||||
  List<int> visibleUsers = List.empty();
 | 
			
		||||
@@ -237,14 +245,20 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
        publishedAt = post.publishedAt;
 | 
			
		||||
        publishedUntil = post.publishedUntil;
 | 
			
		||||
        visibleUsers = List.from(post.visibleUsersList ?? [], growable: true);
 | 
			
		||||
        invisibleUsers = List.from(post.invisibleUsersList ?? [], growable: true);
 | 
			
		||||
        invisibleUsers =
 | 
			
		||||
            List.from(post.invisibleUsersList ?? [], growable: true);
 | 
			
		||||
        visibility = post.visibility;
 | 
			
		||||
        tags = List.from(post.tags.map((ele) => ele.alias), growable: true);
 | 
			
		||||
        categories = List.from(post.categories.map((ele) => ele.alias), growable: true);
 | 
			
		||||
        attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
 | 
			
		||||
        categories =
 | 
			
		||||
            List.from(post.categories.map((ele) => ele.alias), growable: true);
 | 
			
		||||
        attachments.addAll(
 | 
			
		||||
            post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
 | 
			
		||||
        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);
 | 
			
		||||
        }
 | 
			
		||||
        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 {
 | 
			
		||||
    final attach = context.read<SnAttachmentProvider>();
 | 
			
		||||
 | 
			
		||||
@@ -281,7 +296,9 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
      media.name,
 | 
			
		||||
      'interactive',
 | 
			
		||||
      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(
 | 
			
		||||
@@ -297,9 +314,11 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
 | 
			
		||||
    if (media.type == SnMediaType.video && !isCompressed && context.mounted) {
 | 
			
		||||
      try {
 | 
			
		||||
        final compressedAttachment = await _tryCompressVideoCopy(context, media);
 | 
			
		||||
        final compressedAttachment =
 | 
			
		||||
            await _tryCompressVideoCopy(context, media);
 | 
			
		||||
        if (compressedAttachment != null) {
 | 
			
		||||
          item = await attach.updateOne(item, compressedId: compressedAttachment.id);
 | 
			
		||||
          item = await attach.updateOne(item,
 | 
			
		||||
              compressedId: compressedAttachment.id);
 | 
			
		||||
        }
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        if (context.mounted) context.showErrorDialog(err);
 | 
			
		||||
@@ -309,8 +328,10 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
    return item;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<SnAttachment?> _tryCompressVideoCopy(BuildContext context, PostWriteMedia media) async {
 | 
			
		||||
    if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) return null;
 | 
			
		||||
  Future<SnAttachment?> _tryCompressVideoCopy(
 | 
			
		||||
      BuildContext context, PostWriteMedia media) async {
 | 
			
		||||
    if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS))
 | 
			
		||||
      return null;
 | 
			
		||||
    if (media.type != SnMediaType.video) return null;
 | 
			
		||||
    if (media.file == null) return null;
 | 
			
		||||
    if (VideoCompress.isCompressing) return null;
 | 
			
		||||
@@ -334,7 +355,8 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
    if (!context.mounted) return null;
 | 
			
		||||
 | 
			
		||||
    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;
 | 
			
		||||
  }
 | 
			
		||||
@@ -370,18 +392,25 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
          'content': contentController.text,
 | 
			
		||||
          if (aliasController.text.isNotEmpty) 'alias': aliasController.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 (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.toJson(),
 | 
			
		||||
          'attachments':
 | 
			
		||||
              attachments.where((e) => e.attachment != null).map((e) => e.attachment!.toJson()).toList(growable: true),
 | 
			
		||||
          if (thumbnail != null && thumbnail!.attachment != null)
 | 
			
		||||
            'thumbnail': thumbnail!.attachment!.toJson(),
 | 
			
		||||
          'attachments': attachments
 | 
			
		||||
              .where((e) => e.attachment != null)
 | 
			
		||||
              .map((e) => e.attachment!.toJson())
 | 
			
		||||
              .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,
 | 
			
		||||
          'visible_users_list': visibleUsers,
 | 
			
		||||
          'invisible_users_list': invisibleUsers,
 | 
			
		||||
          if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(),
 | 
			
		||||
          if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
 | 
			
		||||
          if (publishedAt != null)
 | 
			
		||||
            'published_at': publishedAt!.toUtc().toIso8601String(),
 | 
			
		||||
          if (publishedUntil != null)
 | 
			
		||||
            'published_until': publishedAt!.toUtc().toIso8601String(),
 | 
			
		||||
          if (replyingPost != null) 'reply_to': replyingPost!.toJson(),
 | 
			
		||||
          if (repostingPost != null) 'repost_to': repostingPost!.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;
 | 
			
		||||
 | 
			
		||||
  void _temporaryLoad() {
 | 
			
		||||
@@ -403,18 +438,24 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
      titleController.text = data['title'] ?? '';
 | 
			
		||||
      descriptionController.text = data['description'] ?? '';
 | 
			
		||||
      rewardController.text = data['reward']?.toString() ?? '';
 | 
			
		||||
      if (data['thumbnail'] != null) thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail']));
 | 
			
		||||
      attachments
 | 
			
		||||
          .addAll(data['attachments'].map((ele) => PostWriteMedia(SnAttachment.fromJson(ele))).cast<PostWriteMedia>());
 | 
			
		||||
      if (data['thumbnail'] != null)
 | 
			
		||||
        thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail']));
 | 
			
		||||
      attachments.addAll(data['attachments']
 | 
			
		||||
          .map((ele) => PostWriteMedia(SnAttachment.fromJson(ele)))
 | 
			
		||||
          .cast<PostWriteMedia>());
 | 
			
		||||
      tags = List.from(data['tags'].map((ele) => ele['alias']));
 | 
			
		||||
      categories = List.from(data['categories'].map((ele) => ele['alias']));
 | 
			
		||||
      visibility = data['visibility'];
 | 
			
		||||
      visibleUsers = List.from(data['visible_users_list'] ?? []);
 | 
			
		||||
      invisibleUsers = List.from(data['invisible_users_list'] ?? []);
 | 
			
		||||
      if (data['published_at'] != null) publishedAt = DateTime.tryParse(data['published_at'])?.toLocal();
 | 
			
		||||
      if (data['published_until'] != 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;
 | 
			
		||||
      if (data['published_at'] != null)
 | 
			
		||||
        publishedAt = DateTime.tryParse(data['published_at'])?.toLocal();
 | 
			
		||||
      if (data['published_until'] != 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;
 | 
			
		||||
      realm = data['realm'] != null ? SnRealm.fromJson(data['realm']) : null;
 | 
			
		||||
      temporaryRestored = true;
 | 
			
		||||
@@ -436,7 +477,10 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> sendPost(BuildContext context) async {
 | 
			
		||||
  Future<void> sendPost(
 | 
			
		||||
    BuildContext context, {
 | 
			
		||||
    bool saveAsDraft = false,
 | 
			
		||||
  }) async {
 | 
			
		||||
    if (isBusy || publisher == null) return;
 | 
			
		||||
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
@@ -463,7 +507,9 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
          media.name,
 | 
			
		||||
          'interactive',
 | 
			
		||||
          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(
 | 
			
		||||
@@ -472,16 +518,20 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
          place.$2,
 | 
			
		||||
          onProgress: (value) {
 | 
			
		||||
            // Calculate overall progress for attachments
 | 
			
		||||
            progress = math.max(((i + value) / attachments.length) * kAttachmentProgressWeight, value);
 | 
			
		||||
            progress = math.max(
 | 
			
		||||
                ((i + value) / attachments.length) * kAttachmentProgressWeight,
 | 
			
		||||
                value);
 | 
			
		||||
            notifyListeners();
 | 
			
		||||
          },
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
          if (context.mounted) {
 | 
			
		||||
            final compressedAttachment = await _tryCompressVideoCopy(context, media);
 | 
			
		||||
            final compressedAttachment =
 | 
			
		||||
                await _tryCompressVideoCopy(context, media);
 | 
			
		||||
            if (compressedAttachment != null) {
 | 
			
		||||
              item = await attach.updateOne(item, compressedId: compressedAttachment.id);
 | 
			
		||||
              item = await attach.updateOne(item,
 | 
			
		||||
                  compressedId: compressedAttachment.id);
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        } catch (err) {
 | 
			
		||||
@@ -508,7 +558,7 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
    // Posting the content
 | 
			
		||||
    try {
 | 
			
		||||
      final baseProgressVal = progress!;
 | 
			
		||||
      await sn.client.request(
 | 
			
		||||
      final resp = await sn.client.request(
 | 
			
		||||
        [
 | 
			
		||||
          '/cgi/co/$mode',
 | 
			
		||||
          if (editingPost != null) '${editingPost!.id}',
 | 
			
		||||
@@ -518,36 +568,56 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
          'content': contentController.text,
 | 
			
		||||
          if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
 | 
			
		||||
          if (titleController.text.isNotEmpty) 'title': titleController.text,
 | 
			
		||||
          if (descriptionController.text.isNotEmpty) 'description': descriptionController.text,
 | 
			
		||||
          if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid,
 | 
			
		||||
          'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(),
 | 
			
		||||
          if (descriptionController.text.isNotEmpty)
 | 
			
		||||
            'description': descriptionController.text,
 | 
			
		||||
          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(),
 | 
			
		||||
          'categories': categories.map((ele) => {'alias': ele}).toList(),
 | 
			
		||||
          'visibility': visibility,
 | 
			
		||||
          'visible_users_list': visibleUsers,
 | 
			
		||||
          'invisible_users_list': invisibleUsers,
 | 
			
		||||
          if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(),
 | 
			
		||||
          if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
 | 
			
		||||
          if (publishedAt != null)
 | 
			
		||||
            'published_at': publishedAt!.toUtc().toIso8601String(),
 | 
			
		||||
          if (publishedUntil != null)
 | 
			
		||||
            'published_until': publishedAt!.toUtc().toIso8601String(),
 | 
			
		||||
          if (replyingPost != null) 'reply_to': replyingPost!.id,
 | 
			
		||||
          if (repostingPost != null) 'repost_to': repostingPost!.id,
 | 
			
		||||
          if (reward != null) 'reward': reward,
 | 
			
		||||
          if (videoAttachment != null) 'video': videoAttachment!.rid,
 | 
			
		||||
          if (poll != null) 'poll': poll!.id,
 | 
			
		||||
          if (realm != null) 'realm': realm!.id,
 | 
			
		||||
          'is_draft': saveAsDraft,
 | 
			
		||||
        },
 | 
			
		||||
        onSendProgress: (count, total) {
 | 
			
		||||
          progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
 | 
			
		||||
          progress =
 | 
			
		||||
              baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
 | 
			
		||||
          notifyListeners();
 | 
			
		||||
        },
 | 
			
		||||
        onReceiveProgress: (count, total) {
 | 
			
		||||
          progress = baseProgressVal + (kPostingProgressWeight / 2) + (count / total) * (kPostingProgressWeight / 2);
 | 
			
		||||
          progress = baseProgressVal +
 | 
			
		||||
              (kPostingProgressWeight / 2) +
 | 
			
		||||
              (count / total) * (kPostingProgressWeight / 2);
 | 
			
		||||
          notifyListeners();
 | 
			
		||||
        },
 | 
			
		||||
        options: Options(
 | 
			
		||||
          method: editingPost != null ? 'PUT' : 'POST',
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
      reset();
 | 
			
		||||
      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();
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!context.mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
@@ -683,7 +753,8 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
    repostingPost = null;
 | 
			
		||||
    mode = kTitleMap.keys.first;
 | 
			
		||||
    temporaryRestored = false;
 | 
			
		||||
    SharedPreferences.getInstance().then((prefs) => prefs.remove(kTemporaryStorageKey));
 | 
			
		||||
    SharedPreferences.getInstance()
 | 
			
		||||
        .then((prefs) => prefs.remove(kTemporaryStorageKey));
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -6,10 +6,12 @@ import 'package:surface/database/attachment.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/attachment.dart';
 | 
			
		||||
import 'package:surface/types/account.dart';
 | 
			
		||||
import 'package:surface/types/realm.dart';
 | 
			
		||||
 | 
			
		||||
part 'database.g.dart';
 | 
			
		||||
 | 
			
		||||
@@ -22,12 +24,13 @@ part 'database.g.dart';
 | 
			
		||||
  SnLocalAttachment,
 | 
			
		||||
  SnLocalSticker,
 | 
			
		||||
  SnLocalStickerPack,
 | 
			
		||||
  SnLocalRealm,
 | 
			
		||||
])
 | 
			
		||||
class AppDatabase extends _$AppDatabase {
 | 
			
		||||
  AppDatabase([QueryExecutor? e]) : super(e ?? _openConnection());
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get schemaVersion => 3;
 | 
			
		||||
  int get schemaVersion => 4;
 | 
			
		||||
 | 
			
		||||
  static QueryExecutor _openConnection() {
 | 
			
		||||
    return driftDatabase(
 | 
			
		||||
@@ -49,6 +52,10 @@ class AppDatabase extends _$AppDatabase {
 | 
			
		||||
        // 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);
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -23,8 +23,6 @@ class $SnLocalChatChannelTable extends SnLocalChatChannel
 | 
			
		||||
  late final GeneratedColumn<String> alias = GeneratedColumn<String>(
 | 
			
		||||
      'alias', aliasedName, false,
 | 
			
		||||
      type: DriftSqlType.string, requiredDuringInsert: true);
 | 
			
		||||
  static const VerificationMeta _contentMeta =
 | 
			
		||||
      const VerificationMeta('content');
 | 
			
		||||
  @override
 | 
			
		||||
  late final GeneratedColumnWithTypeConverter<SnChannel, String> content =
 | 
			
		||||
      GeneratedColumn<String>('content', aliasedName, false,
 | 
			
		||||
@@ -60,7 +58,6 @@ class $SnLocalChatChannelTable extends SnLocalChatChannel
 | 
			
		||||
    } else if (isInserting) {
 | 
			
		||||
      context.missing(_aliasMeta);
 | 
			
		||||
    }
 | 
			
		||||
    context.handle(_contentMeta, const VerificationResult.success());
 | 
			
		||||
    if (data.containsKey('created_at')) {
 | 
			
		||||
      context.handle(_createdAtMeta,
 | 
			
		||||
          createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
 | 
			
		||||
@@ -295,8 +292,6 @@ class $SnLocalChatMessageTable extends SnLocalChatMessage
 | 
			
		||||
  late final GeneratedColumn<int> senderId = GeneratedColumn<int>(
 | 
			
		||||
      'sender_id', aliasedName, true,
 | 
			
		||||
      type: DriftSqlType.int, requiredDuringInsert: false);
 | 
			
		||||
  static const VerificationMeta _contentMeta =
 | 
			
		||||
      const VerificationMeta('content');
 | 
			
		||||
  @override
 | 
			
		||||
  late final GeneratedColumnWithTypeConverter<SnChatMessage, String> content =
 | 
			
		||||
      GeneratedColumn<String>('content', aliasedName, false,
 | 
			
		||||
@@ -338,7 +333,6 @@ class $SnLocalChatMessageTable extends SnLocalChatMessage
 | 
			
		||||
      context.handle(_senderIdMeta,
 | 
			
		||||
          senderId.isAcceptableOrUnknown(data['sender_id']!, _senderIdMeta));
 | 
			
		||||
    }
 | 
			
		||||
    context.handle(_contentMeta, const VerificationResult.success());
 | 
			
		||||
    if (data.containsKey('created_at')) {
 | 
			
		||||
      context.handle(_createdAtMeta,
 | 
			
		||||
          createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
 | 
			
		||||
@@ -604,8 +598,6 @@ class $SnLocalChannelMemberTable extends SnLocalChannelMember
 | 
			
		||||
  late final GeneratedColumn<int> accountId = GeneratedColumn<int>(
 | 
			
		||||
      'account_id', aliasedName, false,
 | 
			
		||||
      type: DriftSqlType.int, requiredDuringInsert: true);
 | 
			
		||||
  static const VerificationMeta _contentMeta =
 | 
			
		||||
      const VerificationMeta('content');
 | 
			
		||||
  @override
 | 
			
		||||
  late final GeneratedColumnWithTypeConverter<SnChannelMember, String> content =
 | 
			
		||||
      GeneratedColumn<String>('content', aliasedName, false,
 | 
			
		||||
@@ -655,7 +647,6 @@ class $SnLocalChannelMemberTable extends SnLocalChannelMember
 | 
			
		||||
    } else if (isInserting) {
 | 
			
		||||
      context.missing(_accountIdMeta);
 | 
			
		||||
    }
 | 
			
		||||
    context.handle(_contentMeta, const VerificationResult.success());
 | 
			
		||||
    if (data.containsKey('created_at')) {
 | 
			
		||||
      context.handle(_createdAtMeta,
 | 
			
		||||
          createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
 | 
			
		||||
@@ -1265,8 +1256,6 @@ class $SnLocalAccountTable extends SnLocalAccount
 | 
			
		||||
  late final GeneratedColumn<String> name = GeneratedColumn<String>(
 | 
			
		||||
      'name', aliasedName, false,
 | 
			
		||||
      type: DriftSqlType.string, requiredDuringInsert: true);
 | 
			
		||||
  static const VerificationMeta _contentMeta =
 | 
			
		||||
      const VerificationMeta('content');
 | 
			
		||||
  @override
 | 
			
		||||
  late final GeneratedColumnWithTypeConverter<SnAccount, String> content =
 | 
			
		||||
      GeneratedColumn<String>('content', aliasedName, false,
 | 
			
		||||
@@ -1308,7 +1297,6 @@ class $SnLocalAccountTable extends SnLocalAccount
 | 
			
		||||
    } else if (isInserting) {
 | 
			
		||||
      context.missing(_nameMeta);
 | 
			
		||||
    }
 | 
			
		||||
    context.handle(_contentMeta, const VerificationResult.success());
 | 
			
		||||
    if (data.containsKey('created_at')) {
 | 
			
		||||
      context.handle(_createdAtMeta,
 | 
			
		||||
          createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
 | 
			
		||||
@@ -1582,8 +1570,6 @@ class $SnLocalAttachmentTable extends SnLocalAttachment
 | 
			
		||||
      type: DriftSqlType.string,
 | 
			
		||||
      requiredDuringInsert: true,
 | 
			
		||||
      defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'));
 | 
			
		||||
  static const VerificationMeta _contentMeta =
 | 
			
		||||
      const VerificationMeta('content');
 | 
			
		||||
  @override
 | 
			
		||||
  late final GeneratedColumnWithTypeConverter<SnAttachment, String> content =
 | 
			
		||||
      GeneratedColumn<String>('content', aliasedName, false,
 | 
			
		||||
@@ -1639,7 +1625,6 @@ class $SnLocalAttachmentTable extends SnLocalAttachment
 | 
			
		||||
    } else if (isInserting) {
 | 
			
		||||
      context.missing(_uuidMeta);
 | 
			
		||||
    }
 | 
			
		||||
    context.handle(_contentMeta, const VerificationResult.success());
 | 
			
		||||
    if (data.containsKey('account_id')) {
 | 
			
		||||
      context.handle(_accountIdMeta,
 | 
			
		||||
          accountId.isAcceptableOrUnknown(data['account_id']!, _accountIdMeta));
 | 
			
		||||
@@ -1968,8 +1953,6 @@ class $SnLocalStickerTable extends SnLocalSticker
 | 
			
		||||
  late final GeneratedColumn<String> fullAlias = GeneratedColumn<String>(
 | 
			
		||||
      'full_alias', aliasedName, false,
 | 
			
		||||
      type: DriftSqlType.string, requiredDuringInsert: true);
 | 
			
		||||
  static const VerificationMeta _contentMeta =
 | 
			
		||||
      const VerificationMeta('content');
 | 
			
		||||
  @override
 | 
			
		||||
  late final GeneratedColumnWithTypeConverter<SnSticker, String> content =
 | 
			
		||||
      GeneratedColumn<String>('content', aliasedName, false,
 | 
			
		||||
@@ -2011,7 +1994,6 @@ class $SnLocalStickerTable extends SnLocalSticker
 | 
			
		||||
    } else if (isInserting) {
 | 
			
		||||
      context.missing(_fullAliasMeta);
 | 
			
		||||
    }
 | 
			
		||||
    context.handle(_contentMeta, const VerificationResult.success());
 | 
			
		||||
    if (data.containsKey('created_at')) {
 | 
			
		||||
      context.handle(_createdAtMeta,
 | 
			
		||||
          createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
 | 
			
		||||
@@ -2261,8 +2243,6 @@ class $SnLocalStickerPackTable extends SnLocalStickerPack
 | 
			
		||||
      requiredDuringInsert: false,
 | 
			
		||||
      defaultConstraints:
 | 
			
		||||
          GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
 | 
			
		||||
  static const VerificationMeta _contentMeta =
 | 
			
		||||
      const VerificationMeta('content');
 | 
			
		||||
  @override
 | 
			
		||||
  late final GeneratedColumnWithTypeConverter<SnStickerPack, String> content =
 | 
			
		||||
      GeneratedColumn<String>('content', aliasedName, false,
 | 
			
		||||
@@ -2293,7 +2273,6 @@ class $SnLocalStickerPackTable extends SnLocalStickerPack
 | 
			
		||||
    if (data.containsKey('id')) {
 | 
			
		||||
      context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
 | 
			
		||||
    }
 | 
			
		||||
    context.handle(_contentMeta, const VerificationResult.success());
 | 
			
		||||
    if (data.containsKey('created_at')) {
 | 
			
		||||
      context.handle(_createdAtMeta,
 | 
			
		||||
          createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
 | 
			
		||||
@@ -2475,6 +2454,351 @@ class SnLocalStickerPackCompanion
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class $SnLocalRealmTable extends SnLocalRealm
 | 
			
		||||
    with TableInfo<$SnLocalRealmTable, SnLocalRealmData> {
 | 
			
		||||
  @override
 | 
			
		||||
  final GeneratedDatabase attachedDatabase;
 | 
			
		||||
  final String? _alias;
 | 
			
		||||
  $SnLocalRealmTable(this.attachedDatabase, [this._alias]);
 | 
			
		||||
  static const VerificationMeta _idMeta = const VerificationMeta('id');
 | 
			
		||||
  @override
 | 
			
		||||
  late final GeneratedColumn<int> id = GeneratedColumn<int>(
 | 
			
		||||
      'id', aliasedName, false,
 | 
			
		||||
      hasAutoIncrement: true,
 | 
			
		||||
      type: DriftSqlType.int,
 | 
			
		||||
      requiredDuringInsert: false,
 | 
			
		||||
      defaultConstraints:
 | 
			
		||||
          GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
 | 
			
		||||
  static const VerificationMeta _aliasMeta = const VerificationMeta('alias');
 | 
			
		||||
  @override
 | 
			
		||||
  late final GeneratedColumn<String> alias = GeneratedColumn<String>(
 | 
			
		||||
      'alias', aliasedName, false,
 | 
			
		||||
      type: DriftSqlType.string,
 | 
			
		||||
      requiredDuringInsert: true,
 | 
			
		||||
      defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'));
 | 
			
		||||
  @override
 | 
			
		||||
  late final GeneratedColumnWithTypeConverter<SnRealm, String> content =
 | 
			
		||||
      GeneratedColumn<String>('content', aliasedName, false,
 | 
			
		||||
              type: DriftSqlType.string, requiredDuringInsert: true)
 | 
			
		||||
          .withConverter<SnRealm>($SnLocalRealmTable.$convertercontent);
 | 
			
		||||
  static const VerificationMeta _accountIdMeta =
 | 
			
		||||
      const VerificationMeta('accountId');
 | 
			
		||||
  @override
 | 
			
		||||
  late final GeneratedColumn<int> accountId = GeneratedColumn<int>(
 | 
			
		||||
      'account_id', aliasedName, false,
 | 
			
		||||
      type: DriftSqlType.int, requiredDuringInsert: true);
 | 
			
		||||
  static const VerificationMeta _createdAtMeta =
 | 
			
		||||
      const VerificationMeta('createdAt');
 | 
			
		||||
  @override
 | 
			
		||||
  late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
 | 
			
		||||
      'created_at', aliasedName, false,
 | 
			
		||||
      type: DriftSqlType.dateTime,
 | 
			
		||||
      requiredDuringInsert: false,
 | 
			
		||||
      defaultValue: currentDateAndTime);
 | 
			
		||||
  static const VerificationMeta _cacheExpiredAtMeta =
 | 
			
		||||
      const VerificationMeta('cacheExpiredAt');
 | 
			
		||||
  @override
 | 
			
		||||
  late final GeneratedColumn<DateTime> cacheExpiredAt =
 | 
			
		||||
      GeneratedColumn<DateTime>('cache_expired_at', aliasedName, false,
 | 
			
		||||
          type: DriftSqlType.dateTime, requiredDuringInsert: true);
 | 
			
		||||
  @override
 | 
			
		||||
  List<GeneratedColumn> get $columns =>
 | 
			
		||||
      [id, alias, content, accountId, createdAt, cacheExpiredAt];
 | 
			
		||||
  @override
 | 
			
		||||
  String get aliasedName => _alias ?? actualTableName;
 | 
			
		||||
  @override
 | 
			
		||||
  String get actualTableName => $name;
 | 
			
		||||
  static const String $name = 'sn_local_realm';
 | 
			
		||||
  @override
 | 
			
		||||
  VerificationContext validateIntegrity(Insertable<SnLocalRealmData> instance,
 | 
			
		||||
      {bool isInserting = false}) {
 | 
			
		||||
    final context = VerificationContext();
 | 
			
		||||
    final data = instance.toColumns(true);
 | 
			
		||||
    if (data.containsKey('id')) {
 | 
			
		||||
      context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
 | 
			
		||||
    }
 | 
			
		||||
    if (data.containsKey('alias')) {
 | 
			
		||||
      context.handle(
 | 
			
		||||
          _aliasMeta, alias.isAcceptableOrUnknown(data['alias']!, _aliasMeta));
 | 
			
		||||
    } else if (isInserting) {
 | 
			
		||||
      context.missing(_aliasMeta);
 | 
			
		||||
    }
 | 
			
		||||
    if (data.containsKey('account_id')) {
 | 
			
		||||
      context.handle(_accountIdMeta,
 | 
			
		||||
          accountId.isAcceptableOrUnknown(data['account_id']!, _accountIdMeta));
 | 
			
		||||
    } else if (isInserting) {
 | 
			
		||||
      context.missing(_accountIdMeta);
 | 
			
		||||
    }
 | 
			
		||||
    if (data.containsKey('created_at')) {
 | 
			
		||||
      context.handle(_createdAtMeta,
 | 
			
		||||
          createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
 | 
			
		||||
    }
 | 
			
		||||
    if (data.containsKey('cache_expired_at')) {
 | 
			
		||||
      context.handle(
 | 
			
		||||
          _cacheExpiredAtMeta,
 | 
			
		||||
          cacheExpiredAt.isAcceptableOrUnknown(
 | 
			
		||||
              data['cache_expired_at']!, _cacheExpiredAtMeta));
 | 
			
		||||
    } else if (isInserting) {
 | 
			
		||||
      context.missing(_cacheExpiredAtMeta);
 | 
			
		||||
    }
 | 
			
		||||
    return context;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Set<GeneratedColumn> get $primaryKey => {id};
 | 
			
		||||
  @override
 | 
			
		||||
  SnLocalRealmData map(Map<String, dynamic> data, {String? tablePrefix}) {
 | 
			
		||||
    final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
 | 
			
		||||
    return SnLocalRealmData(
 | 
			
		||||
      id: attachedDatabase.typeMapping
 | 
			
		||||
          .read(DriftSqlType.int, data['${effectivePrefix}id'])!,
 | 
			
		||||
      alias: attachedDatabase.typeMapping
 | 
			
		||||
          .read(DriftSqlType.string, data['${effectivePrefix}alias'])!,
 | 
			
		||||
      content: $SnLocalRealmTable.$convertercontent.fromSql(attachedDatabase
 | 
			
		||||
          .typeMapping
 | 
			
		||||
          .read(DriftSqlType.string, data['${effectivePrefix}content'])!),
 | 
			
		||||
      accountId: attachedDatabase.typeMapping
 | 
			
		||||
          .read(DriftSqlType.int, data['${effectivePrefix}account_id'])!,
 | 
			
		||||
      createdAt: attachedDatabase.typeMapping
 | 
			
		||||
          .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!,
 | 
			
		||||
      cacheExpiredAt: attachedDatabase.typeMapping.read(
 | 
			
		||||
          DriftSqlType.dateTime, data['${effectivePrefix}cache_expired_at'])!,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  $SnLocalRealmTable createAlias(String alias) {
 | 
			
		||||
    return $SnLocalRealmTable(attachedDatabase, alias);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static JsonTypeConverter2<SnRealm, String, Map<String, Object?>>
 | 
			
		||||
      $convertercontent = const SnRealmConverter();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class SnLocalRealmData extends DataClass
 | 
			
		||||
    implements Insertable<SnLocalRealmData> {
 | 
			
		||||
  final int id;
 | 
			
		||||
  final String alias;
 | 
			
		||||
  final SnRealm content;
 | 
			
		||||
  final int accountId;
 | 
			
		||||
  final DateTime createdAt;
 | 
			
		||||
  final DateTime cacheExpiredAt;
 | 
			
		||||
  const SnLocalRealmData(
 | 
			
		||||
      {required this.id,
 | 
			
		||||
      required this.alias,
 | 
			
		||||
      required this.content,
 | 
			
		||||
      required this.accountId,
 | 
			
		||||
      required this.createdAt,
 | 
			
		||||
      required this.cacheExpiredAt});
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, Expression> toColumns(bool nullToAbsent) {
 | 
			
		||||
    final map = <String, Expression>{};
 | 
			
		||||
    map['id'] = Variable<int>(id);
 | 
			
		||||
    map['alias'] = Variable<String>(alias);
 | 
			
		||||
    {
 | 
			
		||||
      map['content'] =
 | 
			
		||||
          Variable<String>($SnLocalRealmTable.$convertercontent.toSql(content));
 | 
			
		||||
    }
 | 
			
		||||
    map['account_id'] = Variable<int>(accountId);
 | 
			
		||||
    map['created_at'] = Variable<DateTime>(createdAt);
 | 
			
		||||
    map['cache_expired_at'] = Variable<DateTime>(cacheExpiredAt);
 | 
			
		||||
    return map;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  SnLocalRealmCompanion toCompanion(bool nullToAbsent) {
 | 
			
		||||
    return SnLocalRealmCompanion(
 | 
			
		||||
      id: Value(id),
 | 
			
		||||
      alias: Value(alias),
 | 
			
		||||
      content: Value(content),
 | 
			
		||||
      accountId: Value(accountId),
 | 
			
		||||
      createdAt: Value(createdAt),
 | 
			
		||||
      cacheExpiredAt: Value(cacheExpiredAt),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  factory SnLocalRealmData.fromJson(Map<String, dynamic> json,
 | 
			
		||||
      {ValueSerializer? serializer}) {
 | 
			
		||||
    serializer ??= driftRuntimeOptions.defaultSerializer;
 | 
			
		||||
    return SnLocalRealmData(
 | 
			
		||||
      id: serializer.fromJson<int>(json['id']),
 | 
			
		||||
      alias: serializer.fromJson<String>(json['alias']),
 | 
			
		||||
      content: $SnLocalRealmTable.$convertercontent
 | 
			
		||||
          .fromJson(serializer.fromJson<Map<String, Object?>>(json['content'])),
 | 
			
		||||
      accountId: serializer.fromJson<int>(json['accountId']),
 | 
			
		||||
      createdAt: serializer.fromJson<DateTime>(json['createdAt']),
 | 
			
		||||
      cacheExpiredAt: serializer.fromJson<DateTime>(json['cacheExpiredAt']),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, dynamic> toJson({ValueSerializer? serializer}) {
 | 
			
		||||
    serializer ??= driftRuntimeOptions.defaultSerializer;
 | 
			
		||||
    return <String, dynamic>{
 | 
			
		||||
      'id': serializer.toJson<int>(id),
 | 
			
		||||
      'alias': serializer.toJson<String>(alias),
 | 
			
		||||
      'content': serializer.toJson<Map<String, Object?>>(
 | 
			
		||||
          $SnLocalRealmTable.$convertercontent.toJson(content)),
 | 
			
		||||
      'accountId': serializer.toJson<int>(accountId),
 | 
			
		||||
      'createdAt': serializer.toJson<DateTime>(createdAt),
 | 
			
		||||
      'cacheExpiredAt': serializer.toJson<DateTime>(cacheExpiredAt),
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  SnLocalRealmData copyWith(
 | 
			
		||||
          {int? id,
 | 
			
		||||
          String? alias,
 | 
			
		||||
          SnRealm? content,
 | 
			
		||||
          int? accountId,
 | 
			
		||||
          DateTime? createdAt,
 | 
			
		||||
          DateTime? cacheExpiredAt}) =>
 | 
			
		||||
      SnLocalRealmData(
 | 
			
		||||
        id: id ?? this.id,
 | 
			
		||||
        alias: alias ?? this.alias,
 | 
			
		||||
        content: content ?? this.content,
 | 
			
		||||
        accountId: accountId ?? this.accountId,
 | 
			
		||||
        createdAt: createdAt ?? this.createdAt,
 | 
			
		||||
        cacheExpiredAt: cacheExpiredAt ?? this.cacheExpiredAt,
 | 
			
		||||
      );
 | 
			
		||||
  SnLocalRealmData copyWithCompanion(SnLocalRealmCompanion data) {
 | 
			
		||||
    return SnLocalRealmData(
 | 
			
		||||
      id: data.id.present ? data.id.value : this.id,
 | 
			
		||||
      alias: data.alias.present ? data.alias.value : this.alias,
 | 
			
		||||
      content: data.content.present ? data.content.value : this.content,
 | 
			
		||||
      accountId: data.accountId.present ? data.accountId.value : this.accountId,
 | 
			
		||||
      createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
 | 
			
		||||
      cacheExpiredAt: data.cacheExpiredAt.present
 | 
			
		||||
          ? data.cacheExpiredAt.value
 | 
			
		||||
          : this.cacheExpiredAt,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return (StringBuffer('SnLocalRealmData(')
 | 
			
		||||
          ..write('id: $id, ')
 | 
			
		||||
          ..write('alias: $alias, ')
 | 
			
		||||
          ..write('content: $content, ')
 | 
			
		||||
          ..write('accountId: $accountId, ')
 | 
			
		||||
          ..write('createdAt: $createdAt, ')
 | 
			
		||||
          ..write('cacheExpiredAt: $cacheExpiredAt')
 | 
			
		||||
          ..write(')'))
 | 
			
		||||
        .toString();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode =>
 | 
			
		||||
      Object.hash(id, alias, content, accountId, createdAt, cacheExpiredAt);
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) =>
 | 
			
		||||
      identical(this, other) ||
 | 
			
		||||
      (other is SnLocalRealmData &&
 | 
			
		||||
          other.id == this.id &&
 | 
			
		||||
          other.alias == this.alias &&
 | 
			
		||||
          other.content == this.content &&
 | 
			
		||||
          other.accountId == this.accountId &&
 | 
			
		||||
          other.createdAt == this.createdAt &&
 | 
			
		||||
          other.cacheExpiredAt == this.cacheExpiredAt);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class SnLocalRealmCompanion extends UpdateCompanion<SnLocalRealmData> {
 | 
			
		||||
  final Value<int> id;
 | 
			
		||||
  final Value<String> alias;
 | 
			
		||||
  final Value<SnRealm> content;
 | 
			
		||||
  final Value<int> accountId;
 | 
			
		||||
  final Value<DateTime> createdAt;
 | 
			
		||||
  final Value<DateTime> cacheExpiredAt;
 | 
			
		||||
  const SnLocalRealmCompanion({
 | 
			
		||||
    this.id = const Value.absent(),
 | 
			
		||||
    this.alias = const Value.absent(),
 | 
			
		||||
    this.content = const Value.absent(),
 | 
			
		||||
    this.accountId = const Value.absent(),
 | 
			
		||||
    this.createdAt = const Value.absent(),
 | 
			
		||||
    this.cacheExpiredAt = const Value.absent(),
 | 
			
		||||
  });
 | 
			
		||||
  SnLocalRealmCompanion.insert({
 | 
			
		||||
    this.id = const Value.absent(),
 | 
			
		||||
    required String alias,
 | 
			
		||||
    required SnRealm content,
 | 
			
		||||
    required int accountId,
 | 
			
		||||
    this.createdAt = const Value.absent(),
 | 
			
		||||
    required DateTime cacheExpiredAt,
 | 
			
		||||
  })  : alias = Value(alias),
 | 
			
		||||
        content = Value(content),
 | 
			
		||||
        accountId = Value(accountId),
 | 
			
		||||
        cacheExpiredAt = Value(cacheExpiredAt);
 | 
			
		||||
  static Insertable<SnLocalRealmData> custom({
 | 
			
		||||
    Expression<int>? id,
 | 
			
		||||
    Expression<String>? alias,
 | 
			
		||||
    Expression<String>? content,
 | 
			
		||||
    Expression<int>? accountId,
 | 
			
		||||
    Expression<DateTime>? createdAt,
 | 
			
		||||
    Expression<DateTime>? cacheExpiredAt,
 | 
			
		||||
  }) {
 | 
			
		||||
    return RawValuesInsertable({
 | 
			
		||||
      if (id != null) 'id': id,
 | 
			
		||||
      if (alias != null) 'alias': alias,
 | 
			
		||||
      if (content != null) 'content': content,
 | 
			
		||||
      if (accountId != null) 'account_id': accountId,
 | 
			
		||||
      if (createdAt != null) 'created_at': createdAt,
 | 
			
		||||
      if (cacheExpiredAt != null) 'cache_expired_at': cacheExpiredAt,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  SnLocalRealmCompanion copyWith(
 | 
			
		||||
      {Value<int>? id,
 | 
			
		||||
      Value<String>? alias,
 | 
			
		||||
      Value<SnRealm>? content,
 | 
			
		||||
      Value<int>? accountId,
 | 
			
		||||
      Value<DateTime>? createdAt,
 | 
			
		||||
      Value<DateTime>? cacheExpiredAt}) {
 | 
			
		||||
    return SnLocalRealmCompanion(
 | 
			
		||||
      id: id ?? this.id,
 | 
			
		||||
      alias: alias ?? this.alias,
 | 
			
		||||
      content: content ?? this.content,
 | 
			
		||||
      accountId: accountId ?? this.accountId,
 | 
			
		||||
      createdAt: createdAt ?? this.createdAt,
 | 
			
		||||
      cacheExpiredAt: cacheExpiredAt ?? this.cacheExpiredAt,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, Expression> toColumns(bool nullToAbsent) {
 | 
			
		||||
    final map = <String, Expression>{};
 | 
			
		||||
    if (id.present) {
 | 
			
		||||
      map['id'] = Variable<int>(id.value);
 | 
			
		||||
    }
 | 
			
		||||
    if (alias.present) {
 | 
			
		||||
      map['alias'] = Variable<String>(alias.value);
 | 
			
		||||
    }
 | 
			
		||||
    if (content.present) {
 | 
			
		||||
      map['content'] = Variable<String>(
 | 
			
		||||
          $SnLocalRealmTable.$convertercontent.toSql(content.value));
 | 
			
		||||
    }
 | 
			
		||||
    if (accountId.present) {
 | 
			
		||||
      map['account_id'] = Variable<int>(accountId.value);
 | 
			
		||||
    }
 | 
			
		||||
    if (createdAt.present) {
 | 
			
		||||
      map['created_at'] = Variable<DateTime>(createdAt.value);
 | 
			
		||||
    }
 | 
			
		||||
    if (cacheExpiredAt.present) {
 | 
			
		||||
      map['cache_expired_at'] = Variable<DateTime>(cacheExpiredAt.value);
 | 
			
		||||
    }
 | 
			
		||||
    return map;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return (StringBuffer('SnLocalRealmCompanion(')
 | 
			
		||||
          ..write('id: $id, ')
 | 
			
		||||
          ..write('alias: $alias, ')
 | 
			
		||||
          ..write('content: $content, ')
 | 
			
		||||
          ..write('accountId: $accountId, ')
 | 
			
		||||
          ..write('createdAt: $createdAt, ')
 | 
			
		||||
          ..write('cacheExpiredAt: $cacheExpiredAt')
 | 
			
		||||
          ..write(')'))
 | 
			
		||||
        .toString();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
abstract class _$AppDatabase extends GeneratedDatabase {
 | 
			
		||||
  _$AppDatabase(QueryExecutor e) : super(e);
 | 
			
		||||
  $AppDatabaseManager get managers => $AppDatabaseManager(this);
 | 
			
		||||
@@ -2491,6 +2815,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
 | 
			
		||||
  late final $SnLocalStickerTable snLocalSticker = $SnLocalStickerTable(this);
 | 
			
		||||
  late final $SnLocalStickerPackTable snLocalStickerPack =
 | 
			
		||||
      $SnLocalStickerPackTable(this);
 | 
			
		||||
  late final $SnLocalRealmTable snLocalRealm = $SnLocalRealmTable(this);
 | 
			
		||||
  late final Index idxChannelAlias = Index('idx_channel_alias',
 | 
			
		||||
      'CREATE INDEX idx_channel_alias ON sn_local_chat_channel (alias)');
 | 
			
		||||
  late final Index idxChatChannel = Index('idx_chat_channel',
 | 
			
		||||
@@ -2501,6 +2826,10 @@ abstract class _$AppDatabase extends GeneratedDatabase {
 | 
			
		||||
      'CREATE INDEX idx_attachment_rid ON sn_local_attachment (rid)');
 | 
			
		||||
  late final Index idxAttachmentAccount = Index('idx_attachment_account',
 | 
			
		||||
      'CREATE INDEX idx_attachment_account ON sn_local_attachment (account_id)');
 | 
			
		||||
  late final Index idxRealmAlias = Index('idx_realm_alias',
 | 
			
		||||
      'CREATE INDEX idx_realm_alias ON sn_local_realm (alias)');
 | 
			
		||||
  late final Index idxRealmAccount = Index('idx_realm_account',
 | 
			
		||||
      'CREATE INDEX idx_realm_account ON sn_local_realm (account_id)');
 | 
			
		||||
  @override
 | 
			
		||||
  Iterable<TableInfo<Table, Object?>> get allTables =>
 | 
			
		||||
      allSchemaEntities.whereType<TableInfo<Table, Object?>>();
 | 
			
		||||
@@ -2514,11 +2843,14 @@ abstract class _$AppDatabase extends GeneratedDatabase {
 | 
			
		||||
        snLocalAttachment,
 | 
			
		||||
        snLocalSticker,
 | 
			
		||||
        snLocalStickerPack,
 | 
			
		||||
        snLocalRealm,
 | 
			
		||||
        idxChannelAlias,
 | 
			
		||||
        idxChatChannel,
 | 
			
		||||
        idxAccountName,
 | 
			
		||||
        idxAttachmentRid,
 | 
			
		||||
        idxAttachmentAccount
 | 
			
		||||
        idxAttachmentAccount,
 | 
			
		||||
        idxRealmAlias,
 | 
			
		||||
        idxRealmAccount
 | 
			
		||||
      ];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -3909,6 +4241,192 @@ typedef $$SnLocalStickerPackTableProcessedTableManager = ProcessedTableManager<
 | 
			
		||||
    ),
 | 
			
		||||
    SnLocalStickerPackData,
 | 
			
		||||
    PrefetchHooks Function()>;
 | 
			
		||||
typedef $$SnLocalRealmTableCreateCompanionBuilder = SnLocalRealmCompanion
 | 
			
		||||
    Function({
 | 
			
		||||
  Value<int> id,
 | 
			
		||||
  required String alias,
 | 
			
		||||
  required SnRealm content,
 | 
			
		||||
  required int accountId,
 | 
			
		||||
  Value<DateTime> createdAt,
 | 
			
		||||
  required DateTime cacheExpiredAt,
 | 
			
		||||
});
 | 
			
		||||
typedef $$SnLocalRealmTableUpdateCompanionBuilder = SnLocalRealmCompanion
 | 
			
		||||
    Function({
 | 
			
		||||
  Value<int> id,
 | 
			
		||||
  Value<String> alias,
 | 
			
		||||
  Value<SnRealm> content,
 | 
			
		||||
  Value<int> accountId,
 | 
			
		||||
  Value<DateTime> createdAt,
 | 
			
		||||
  Value<DateTime> cacheExpiredAt,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
class $$SnLocalRealmTableFilterComposer
 | 
			
		||||
    extends Composer<_$AppDatabase, $SnLocalRealmTable> {
 | 
			
		||||
  $$SnLocalRealmTableFilterComposer({
 | 
			
		||||
    required super.$db,
 | 
			
		||||
    required super.$table,
 | 
			
		||||
    super.joinBuilder,
 | 
			
		||||
    super.$addJoinBuilderToRootComposer,
 | 
			
		||||
    super.$removeJoinBuilderFromRootComposer,
 | 
			
		||||
  });
 | 
			
		||||
  ColumnFilters<int> get id => $composableBuilder(
 | 
			
		||||
      column: $table.id, builder: (column) => ColumnFilters(column));
 | 
			
		||||
 | 
			
		||||
  ColumnFilters<String> get alias => $composableBuilder(
 | 
			
		||||
      column: $table.alias, builder: (column) => ColumnFilters(column));
 | 
			
		||||
 | 
			
		||||
  ColumnWithTypeConverterFilters<SnRealm, SnRealm, String> get content =>
 | 
			
		||||
      $composableBuilder(
 | 
			
		||||
          column: $table.content,
 | 
			
		||||
          builder: (column) => ColumnWithTypeConverterFilters(column));
 | 
			
		||||
 | 
			
		||||
  ColumnFilters<int> get accountId => $composableBuilder(
 | 
			
		||||
      column: $table.accountId, builder: (column) => ColumnFilters(column));
 | 
			
		||||
 | 
			
		||||
  ColumnFilters<DateTime> get createdAt => $composableBuilder(
 | 
			
		||||
      column: $table.createdAt, builder: (column) => ColumnFilters(column));
 | 
			
		||||
 | 
			
		||||
  ColumnFilters<DateTime> get cacheExpiredAt => $composableBuilder(
 | 
			
		||||
      column: $table.cacheExpiredAt,
 | 
			
		||||
      builder: (column) => ColumnFilters(column));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class $$SnLocalRealmTableOrderingComposer
 | 
			
		||||
    extends Composer<_$AppDatabase, $SnLocalRealmTable> {
 | 
			
		||||
  $$SnLocalRealmTableOrderingComposer({
 | 
			
		||||
    required super.$db,
 | 
			
		||||
    required super.$table,
 | 
			
		||||
    super.joinBuilder,
 | 
			
		||||
    super.$addJoinBuilderToRootComposer,
 | 
			
		||||
    super.$removeJoinBuilderFromRootComposer,
 | 
			
		||||
  });
 | 
			
		||||
  ColumnOrderings<int> get id => $composableBuilder(
 | 
			
		||||
      column: $table.id, builder: (column) => ColumnOrderings(column));
 | 
			
		||||
 | 
			
		||||
  ColumnOrderings<String> get alias => $composableBuilder(
 | 
			
		||||
      column: $table.alias, builder: (column) => ColumnOrderings(column));
 | 
			
		||||
 | 
			
		||||
  ColumnOrderings<String> get content => $composableBuilder(
 | 
			
		||||
      column: $table.content, builder: (column) => ColumnOrderings(column));
 | 
			
		||||
 | 
			
		||||
  ColumnOrderings<int> get accountId => $composableBuilder(
 | 
			
		||||
      column: $table.accountId, builder: (column) => ColumnOrderings(column));
 | 
			
		||||
 | 
			
		||||
  ColumnOrderings<DateTime> get createdAt => $composableBuilder(
 | 
			
		||||
      column: $table.createdAt, builder: (column) => ColumnOrderings(column));
 | 
			
		||||
 | 
			
		||||
  ColumnOrderings<DateTime> get cacheExpiredAt => $composableBuilder(
 | 
			
		||||
      column: $table.cacheExpiredAt,
 | 
			
		||||
      builder: (column) => ColumnOrderings(column));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class $$SnLocalRealmTableAnnotationComposer
 | 
			
		||||
    extends Composer<_$AppDatabase, $SnLocalRealmTable> {
 | 
			
		||||
  $$SnLocalRealmTableAnnotationComposer({
 | 
			
		||||
    required super.$db,
 | 
			
		||||
    required super.$table,
 | 
			
		||||
    super.joinBuilder,
 | 
			
		||||
    super.$addJoinBuilderToRootComposer,
 | 
			
		||||
    super.$removeJoinBuilderFromRootComposer,
 | 
			
		||||
  });
 | 
			
		||||
  GeneratedColumn<int> get id =>
 | 
			
		||||
      $composableBuilder(column: $table.id, builder: (column) => column);
 | 
			
		||||
 | 
			
		||||
  GeneratedColumn<String> get alias =>
 | 
			
		||||
      $composableBuilder(column: $table.alias, builder: (column) => column);
 | 
			
		||||
 | 
			
		||||
  GeneratedColumnWithTypeConverter<SnRealm, String> get content =>
 | 
			
		||||
      $composableBuilder(column: $table.content, builder: (column) => column);
 | 
			
		||||
 | 
			
		||||
  GeneratedColumn<int> get accountId =>
 | 
			
		||||
      $composableBuilder(column: $table.accountId, builder: (column) => column);
 | 
			
		||||
 | 
			
		||||
  GeneratedColumn<DateTime> get createdAt =>
 | 
			
		||||
      $composableBuilder(column: $table.createdAt, builder: (column) => column);
 | 
			
		||||
 | 
			
		||||
  GeneratedColumn<DateTime> get cacheExpiredAt => $composableBuilder(
 | 
			
		||||
      column: $table.cacheExpiredAt, builder: (column) => column);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class $$SnLocalRealmTableTableManager extends RootTableManager<
 | 
			
		||||
    _$AppDatabase,
 | 
			
		||||
    $SnLocalRealmTable,
 | 
			
		||||
    SnLocalRealmData,
 | 
			
		||||
    $$SnLocalRealmTableFilterComposer,
 | 
			
		||||
    $$SnLocalRealmTableOrderingComposer,
 | 
			
		||||
    $$SnLocalRealmTableAnnotationComposer,
 | 
			
		||||
    $$SnLocalRealmTableCreateCompanionBuilder,
 | 
			
		||||
    $$SnLocalRealmTableUpdateCompanionBuilder,
 | 
			
		||||
    (
 | 
			
		||||
      SnLocalRealmData,
 | 
			
		||||
      BaseReferences<_$AppDatabase, $SnLocalRealmTable, SnLocalRealmData>
 | 
			
		||||
    ),
 | 
			
		||||
    SnLocalRealmData,
 | 
			
		||||
    PrefetchHooks Function()> {
 | 
			
		||||
  $$SnLocalRealmTableTableManager(_$AppDatabase db, $SnLocalRealmTable table)
 | 
			
		||||
      : super(TableManagerState(
 | 
			
		||||
          db: db,
 | 
			
		||||
          table: table,
 | 
			
		||||
          createFilteringComposer: () =>
 | 
			
		||||
              $$SnLocalRealmTableFilterComposer($db: db, $table: table),
 | 
			
		||||
          createOrderingComposer: () =>
 | 
			
		||||
              $$SnLocalRealmTableOrderingComposer($db: db, $table: table),
 | 
			
		||||
          createComputedFieldComposer: () =>
 | 
			
		||||
              $$SnLocalRealmTableAnnotationComposer($db: db, $table: table),
 | 
			
		||||
          updateCompanionCallback: ({
 | 
			
		||||
            Value<int> id = const Value.absent(),
 | 
			
		||||
            Value<String> alias = const Value.absent(),
 | 
			
		||||
            Value<SnRealm> content = const Value.absent(),
 | 
			
		||||
            Value<int> accountId = const Value.absent(),
 | 
			
		||||
            Value<DateTime> createdAt = const Value.absent(),
 | 
			
		||||
            Value<DateTime> cacheExpiredAt = const Value.absent(),
 | 
			
		||||
          }) =>
 | 
			
		||||
              SnLocalRealmCompanion(
 | 
			
		||||
            id: id,
 | 
			
		||||
            alias: alias,
 | 
			
		||||
            content: content,
 | 
			
		||||
            accountId: accountId,
 | 
			
		||||
            createdAt: createdAt,
 | 
			
		||||
            cacheExpiredAt: cacheExpiredAt,
 | 
			
		||||
          ),
 | 
			
		||||
          createCompanionCallback: ({
 | 
			
		||||
            Value<int> id = const Value.absent(),
 | 
			
		||||
            required String alias,
 | 
			
		||||
            required SnRealm content,
 | 
			
		||||
            required int accountId,
 | 
			
		||||
            Value<DateTime> createdAt = const Value.absent(),
 | 
			
		||||
            required DateTime cacheExpiredAt,
 | 
			
		||||
          }) =>
 | 
			
		||||
              SnLocalRealmCompanion.insert(
 | 
			
		||||
            id: id,
 | 
			
		||||
            alias: alias,
 | 
			
		||||
            content: content,
 | 
			
		||||
            accountId: accountId,
 | 
			
		||||
            createdAt: createdAt,
 | 
			
		||||
            cacheExpiredAt: cacheExpiredAt,
 | 
			
		||||
          ),
 | 
			
		||||
          withReferenceMapper: (p0) => p0
 | 
			
		||||
              .map((e) => (e.readTable(table), BaseReferences(db, table, e)))
 | 
			
		||||
              .toList(),
 | 
			
		||||
          prefetchHooksCallback: null,
 | 
			
		||||
        ));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
typedef $$SnLocalRealmTableProcessedTableManager = ProcessedTableManager<
 | 
			
		||||
    _$AppDatabase,
 | 
			
		||||
    $SnLocalRealmTable,
 | 
			
		||||
    SnLocalRealmData,
 | 
			
		||||
    $$SnLocalRealmTableFilterComposer,
 | 
			
		||||
    $$SnLocalRealmTableOrderingComposer,
 | 
			
		||||
    $$SnLocalRealmTableAnnotationComposer,
 | 
			
		||||
    $$SnLocalRealmTableCreateCompanionBuilder,
 | 
			
		||||
    $$SnLocalRealmTableUpdateCompanionBuilder,
 | 
			
		||||
    (
 | 
			
		||||
      SnLocalRealmData,
 | 
			
		||||
      BaseReferences<_$AppDatabase, $SnLocalRealmTable, SnLocalRealmData>
 | 
			
		||||
    ),
 | 
			
		||||
    SnLocalRealmData,
 | 
			
		||||
    PrefetchHooks Function()>;
 | 
			
		||||
 | 
			
		||||
class $AppDatabaseManager {
 | 
			
		||||
  final _$AppDatabase _db;
 | 
			
		||||
@@ -3929,4 +4447,6 @@ class $AppDatabaseManager {
 | 
			
		||||
      $$SnLocalStickerTableTableManager(_db, _db.snLocalSticker);
 | 
			
		||||
  $$SnLocalStickerPackTableTableManager get snLocalStickerPack =>
 | 
			
		||||
      $$SnLocalStickerPackTableTableManager(_db, _db.snLocalStickerPack);
 | 
			
		||||
  $$SnLocalRealmTableTableManager get snLocalRealm =>
 | 
			
		||||
      $$SnLocalRealmTableTableManager(_db, _db.snLocalRealm);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -412,9 +412,214 @@ class Shape8 extends i0.VersionedTable {
 | 
			
		||||
      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) {
 | 
			
		||||
@@ -428,6 +633,11 @@ i0.MigrationStepWithVersion migrationSteps({
 | 
			
		||||
        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');
 | 
			
		||||
    }
 | 
			
		||||
@@ -437,9 +647,11 @@ i0.MigrationStepWithVersion migrationSteps({
 | 
			
		||||
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,
 | 
			
		||||
    ));
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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()();
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										267
									
								
								lib/main.dart
									
									
									
									
									
								
							
							
						
						
									
										267
									
								
								lib/main.dart
									
									
									
									
									
								
							@@ -12,6 +12,7 @@ import 'package:firebase_core/firebase_core.dart';
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:hotkey_manager/hotkey_manager.dart';
 | 
			
		||||
import 'package:package_info_plus/package_info_plus.dart';
 | 
			
		||||
@@ -19,6 +20,7 @@ import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:relative_time/relative_time.dart';
 | 
			
		||||
import 'package:responsive_framework/responsive_framework.dart';
 | 
			
		||||
import 'package:shared_preferences/shared_preferences.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/firebase_options.dart';
 | 
			
		||||
import 'package:surface/logger.dart';
 | 
			
		||||
import 'package:surface/providers/channel.dart';
 | 
			
		||||
@@ -37,6 +39,7 @@ import 'package:surface/providers/sn_realm.dart';
 | 
			
		||||
import 'package:surface/providers/sn_sticker.dart';
 | 
			
		||||
import 'package:surface/providers/special_day.dart';
 | 
			
		||||
import 'package:surface/providers/theme.dart';
 | 
			
		||||
import 'package:surface/providers/translation.dart';
 | 
			
		||||
import 'package:surface/providers/user_directory.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/providers/websocket.dart';
 | 
			
		||||
@@ -44,6 +47,8 @@ import 'package:surface/providers/widget.dart';
 | 
			
		||||
import 'package:surface/router.dart';
 | 
			
		||||
import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
 | 
			
		||||
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:version/version.dart';
 | 
			
		||||
import 'package:workmanager/workmanager.dart';
 | 
			
		||||
@@ -84,19 +89,14 @@ void main() async {
 | 
			
		||||
  await EasyLocalization.ensureInitialized();
 | 
			
		||||
 | 
			
		||||
  if (!kIsWeb && !Platform.isLinux) {
 | 
			
		||||
    await Firebase.initializeApp(
 | 
			
		||||
      options: DefaultFirebaseOptions.currentPlatform,
 | 
			
		||||
    );
 | 
			
		||||
    await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  GoRouter.optionURLReflectsImperativeAPIs = true;
 | 
			
		||||
  usePathUrlStrategy();
 | 
			
		||||
 | 
			
		||||
  if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
 | 
			
		||||
    Workmanager().initialize(
 | 
			
		||||
      appBackgroundDispatcher,
 | 
			
		||||
      isInDebugMode: kDebugMode,
 | 
			
		||||
    );
 | 
			
		||||
    Workmanager().initialize(appBackgroundDispatcher, isInDebugMode: kDebugMode);
 | 
			
		||||
    if (Platform.isAndroid) {
 | 
			
		||||
      Workmanager().registerPeriodicTask(
 | 
			
		||||
        "widget-update-random-post",
 | 
			
		||||
@@ -109,8 +109,7 @@ void main() async {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!kIsWeb && Platform.isAndroid) {
 | 
			
		||||
    final ImagePickerPlatform imagePickerImplementation =
 | 
			
		||||
        ImagePickerPlatform.instance;
 | 
			
		||||
    final ImagePickerPlatform imagePickerImplementation = ImagePickerPlatform.instance;
 | 
			
		||||
    if (imagePickerImplementation is ImagePickerAndroid) {
 | 
			
		||||
      imagePickerImplementation.useAndroidPhotoPicker = true;
 | 
			
		||||
    }
 | 
			
		||||
@@ -127,12 +126,7 @@ class SolianApp extends StatelessWidget {
 | 
			
		||||
    return ResponsiveBreakpoints.builder(
 | 
			
		||||
      child: EasyLocalization(
 | 
			
		||||
        path: 'assets/translations',
 | 
			
		||||
        supportedLocales: [
 | 
			
		||||
          Locale('en', 'US'),
 | 
			
		||||
          Locale('zh', 'CN'),
 | 
			
		||||
          Locale('zh', 'TW'),
 | 
			
		||||
          Locale('zh', 'HK'),
 | 
			
		||||
        ],
 | 
			
		||||
        supportedLocales: [Locale('en', 'US'), Locale('zh', 'CN'), Locale('zh', 'TW'), Locale('zh', 'HK')],
 | 
			
		||||
        fallbackLocale: Locale('en', 'US'),
 | 
			
		||||
        useFallbackTranslations: true,
 | 
			
		||||
        assetLoader: JsonAssetLoader(),
 | 
			
		||||
@@ -155,7 +149,7 @@ class SolianApp extends StatelessWidget {
 | 
			
		||||
            Provider(create: (ctx) => SnNetworkProvider(ctx)),
 | 
			
		||||
            Provider(create: (ctx) => UserDirectoryProvider(ctx)),
 | 
			
		||||
            Provider(create: (ctx) => SnAttachmentProvider(ctx)),
 | 
			
		||||
            Provider(create: (ctx) => SnRealmProvider(ctx)),
 | 
			
		||||
            ChangeNotifierProvider(create: (ctx) => SnRealmProvider(ctx)),
 | 
			
		||||
            Provider(create: (ctx) => SnPostContentProvider(ctx)),
 | 
			
		||||
            Provider(create: (ctx) => SnRelationshipProvider(ctx)),
 | 
			
		||||
            Provider(create: (ctx) => SnLinkPreviewProvider(ctx)),
 | 
			
		||||
@@ -166,6 +160,7 @@ class SolianApp extends StatelessWidget {
 | 
			
		||||
            ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
 | 
			
		||||
            ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
 | 
			
		||||
            ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
 | 
			
		||||
            Provider(create: (ctx) => SnTranslator()),
 | 
			
		||||
 | 
			
		||||
            // Additional helper layer
 | 
			
		||||
            Provider(create: (ctx) => SpecialDayProvider(ctx)),
 | 
			
		||||
@@ -206,10 +201,7 @@ class _AppDelegate extends StatelessWidget {
 | 
			
		||||
      ],
 | 
			
		||||
      routerConfig: appRouter,
 | 
			
		||||
      builder: (context, child) {
 | 
			
		||||
        return _AppSplashScreen(
 | 
			
		||||
          key: const Key('global-splash-screen'),
 | 
			
		||||
          child: child!,
 | 
			
		||||
        );
 | 
			
		||||
        return _AppSplashScreen(key: const Key('global-splash-screen'), child: child!);
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
@@ -225,13 +217,15 @@ class _AppSplashScreen extends StatefulWidget {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
  String _phaseText = 'appInitStarting';
 | 
			
		||||
 | 
			
		||||
  void _tryRequestRating() async {
 | 
			
		||||
    final prefs = await SharedPreferences.getInstance();
 | 
			
		||||
    if (prefs.containsKey('first_boot_time')) {
 | 
			
		||||
      final rawTime = prefs.getString('first_boot_time');
 | 
			
		||||
      final time = DateTime.tryParse(rawTime ?? '');
 | 
			
		||||
      if (time != null &&
 | 
			
		||||
          time.isBefore(DateTime.now().subtract(const Duration(days: 3)))) {
 | 
			
		||||
      if (time != null && time.isBefore(DateTime.now().subtract(const Duration(days: 3)))) {
 | 
			
		||||
        final inAppReview = InAppReview.instance;
 | 
			
		||||
        if (prefs.getBool('rating_requested') == true) return;
 | 
			
		||||
        if (await inAppReview.isAvailable()) {
 | 
			
		||||
@@ -252,28 +246,17 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
			
		||||
      final info = await PackageInfo.fromPlatform();
 | 
			
		||||
      final localVersionString = '${info.version}+${info.buildNumber}';
 | 
			
		||||
      final resp = await Dio(
 | 
			
		||||
        BaseOptions(
 | 
			
		||||
          sendTimeout: const Duration(seconds: 60),
 | 
			
		||||
          receiveTimeout: const Duration(seconds: 60),
 | 
			
		||||
        ),
 | 
			
		||||
      ).get(
 | 
			
		||||
        'https://api.github.com/repos/Solsynth/HyperNet.Surface/releases/latest',
 | 
			
		||||
      );
 | 
			
		||||
        BaseOptions(sendTimeout: const Duration(seconds: 60), receiveTimeout: const Duration(seconds: 60)),
 | 
			
		||||
      ).get('https://api.github.com/repos/Solsynth/HyperNet.Surface/releases/latest');
 | 
			
		||||
      final remoteVersionString = resp.data?['tag_name'] ?? '0.0.0+0';
 | 
			
		||||
      final remoteVersion = Version.parse(remoteVersionString.split('+').first);
 | 
			
		||||
      final localVersion = Version.parse(localVersionString.split('+').first);
 | 
			
		||||
      final remoteBuildNumber =
 | 
			
		||||
          int.tryParse(remoteVersionString.split('+').last) ?? 0;
 | 
			
		||||
      final localBuildNumber =
 | 
			
		||||
          int.tryParse(localVersionString.split('+').last) ?? 0;
 | 
			
		||||
      logging.info(
 | 
			
		||||
          "[Update] Local: $localVersionString, Remote: $remoteVersionString");
 | 
			
		||||
      if ((remoteVersion > localVersion ||
 | 
			
		||||
              remoteBuildNumber > localBuildNumber) &&
 | 
			
		||||
          mounted) {
 | 
			
		||||
      final remoteBuildNumber = int.tryParse(remoteVersionString.split('+').last) ?? 0;
 | 
			
		||||
      final localBuildNumber = int.tryParse(localVersionString.split('+').last) ?? 0;
 | 
			
		||||
      logging.info("[Update] Local: $localVersionString, Remote: $remoteVersionString");
 | 
			
		||||
      if ((remoteVersion > localVersion || remoteBuildNumber > localBuildNumber) && mounted) {
 | 
			
		||||
        final config = context.read<ConfigProvider>();
 | 
			
		||||
        config.setUpdate(
 | 
			
		||||
            remoteVersionString, resp.data?['body'] ?? 'No changelog');
 | 
			
		||||
        config.setUpdate(remoteVersionString, resp.data?['body'] ?? 'No changelog');
 | 
			
		||||
        logging.info("[Update] Update available: $remoteVersionString");
 | 
			
		||||
      }
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
@@ -282,6 +265,11 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _setPhaseText(String text) {
 | 
			
		||||
    _phaseText = 'appInit${text.capitalize()}'.tr();
 | 
			
		||||
    if (mounted) setState(() {});
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _initialize() async {
 | 
			
		||||
    try {
 | 
			
		||||
      final cfg = context.read<ConfigProvider>();
 | 
			
		||||
@@ -294,31 +282,51 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
			
		||||
      // 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 also save initialize the Config, so it not need to be initialized again
 | 
			
		||||
      _setPhaseText('network');
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.initializeUserAgent();
 | 
			
		||||
      await sn.setConfigWithNative();
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      _setPhaseText('userdata');
 | 
			
		||||
      final ua = context.read<UserProvider>();
 | 
			
		||||
      await ua.initialize();
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      _setPhaseText('websocket');
 | 
			
		||||
      final ws = context.read<WebSocketProvider>();
 | 
			
		||||
      await ws.tryConnect();
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      final notify = context.read<NotificationProvider>();
 | 
			
		||||
      notify.listen();
 | 
			
		||||
      await notify.registerPushNotifications();
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      final kp = context.read<KeyPairProvider>();
 | 
			
		||||
      await kp.reloadActive();
 | 
			
		||||
      kp.listen();
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      final sticker = context.read<SnStickerProvider>();
 | 
			
		||||
      await sticker.listSticker();
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      final ud = context.read<UserDirectoryProvider>();
 | 
			
		||||
      final userCacheSize = await ud.loadAccountCache();
 | 
			
		||||
      logging.info('[Users] Loaded local user cache, size: $userCacheSize');
 | 
			
		||||
      logging.info('[Bootstrap] Everything initialized!');
 | 
			
		||||
      try {
 | 
			
		||||
        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>();
 | 
			
		||||
        notify.listen();
 | 
			
		||||
        try {
 | 
			
		||||
          await notify.registerPushNotifications();
 | 
			
		||||
        } catch (_) {}
 | 
			
		||||
        if (!mounted) return;
 | 
			
		||||
        _setPhaseText('stickers');
 | 
			
		||||
        final sticker = context.read<SnStickerProvider>();
 | 
			
		||||
        await sticker.listSticker();
 | 
			
		||||
        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');
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      await context.showErrorDialog(err);
 | 
			
		||||
@@ -331,51 +339,24 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
			
		||||
 | 
			
		||||
  Future<void> _hotkeyInitialization() async {
 | 
			
		||||
    if (kIsWeb) return;
 | 
			
		||||
 | 
			
		||||
    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');
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    // The quit key has been removed, and the logic of the quit key is moved to system menu bar activator.
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  final Menu _appTrayMenu = Menu(
 | 
			
		||||
    items: [
 | 
			
		||||
      MenuItem(
 | 
			
		||||
        key: 'version_label',
 | 
			
		||||
        label: 'Solian',
 | 
			
		||||
        disabled: true,
 | 
			
		||||
      ),
 | 
			
		||||
      MenuItem(key: 'version_label', label: 'Solian', disabled: true),
 | 
			
		||||
      MenuItem.separator(),
 | 
			
		||||
      MenuItem.checkbox(
 | 
			
		||||
        checked: false,
 | 
			
		||||
        key: 'mute_notification',
 | 
			
		||||
        label: 'trayMenuMuteNotification'.tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      MenuItem.checkbox(checked: false, key: 'mute_notification', label: 'trayMenuMuteNotification'.tr()),
 | 
			
		||||
      MenuItem.separator(),
 | 
			
		||||
      MenuItem(
 | 
			
		||||
        key: 'window_show',
 | 
			
		||||
        label: 'trayMenuShow'.tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      MenuItem(
 | 
			
		||||
        key: 'exit',
 | 
			
		||||
        label: 'trayMenuExit'.tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      MenuItem(key: 'window_show', label: 'trayMenuShow'.tr()),
 | 
			
		||||
      MenuItem(key: 'exit', label: 'trayMenuExit'.tr()),
 | 
			
		||||
    ],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  Future<void> _trayInitialization() async {
 | 
			
		||||
    if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
 | 
			
		||||
 | 
			
		||||
    final icon = Platform.isWindows
 | 
			
		||||
        ? 'assets/icon/tray-icon.ico'
 | 
			
		||||
        : 'assets/icon/tray-icon.png';
 | 
			
		||||
    final icon = Platform.isWindows ? 'assets/icon/tray-icon.ico' : 'assets/icon/tray-icon.png';
 | 
			
		||||
    final appVersion = await PackageInfo.fromPlatform();
 | 
			
		||||
 | 
			
		||||
    trayManager.addListener(this);
 | 
			
		||||
@@ -393,10 +374,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
			
		||||
  Future<void> _notifyInitialization() async {
 | 
			
		||||
    if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
 | 
			
		||||
 | 
			
		||||
    await localNotifier.setup(
 | 
			
		||||
      appName: 'Solian',
 | 
			
		||||
      shortcutPolicy: ShortcutPolicy.requireCreate,
 | 
			
		||||
    );
 | 
			
		||||
    await localNotifier.setup(appName: 'Solian', shortcutPolicy: ShortcutPolicy.requireCreate);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  AppLifecycleListener? _appLifecycleListener;
 | 
			
		||||
@@ -405,10 +383,9 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
 | 
			
		||||
    _isBusy = true;
 | 
			
		||||
    if (!kIsWeb && !(Platform.isIOS || Platform.isAndroid)) {
 | 
			
		||||
      _appLifecycleListener = AppLifecycleListener(
 | 
			
		||||
        onExitRequested: _onExitRequested,
 | 
			
		||||
      );
 | 
			
		||||
      _appLifecycleListener = AppLifecycleListener(onExitRequested: _onExitRequested);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _trayInitialization();
 | 
			
		||||
@@ -418,6 +395,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
			
		||||
      _postInitialization();
 | 
			
		||||
      _tryRequestRating();
 | 
			
		||||
      _checkForUpdate();
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -426,6 +404,15 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
			
		||||
    return AppExitResponse.cancel;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _quitApp() {
 | 
			
		||||
    _appLifecycleListener?.dispose();
 | 
			
		||||
    if (Platform.isWindows) {
 | 
			
		||||
      appWindow.close();
 | 
			
		||||
    } else {
 | 
			
		||||
      SystemChannels.platform.invokeMethod('SystemNavigator.pop');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void onTrayIconMouseDown() {
 | 
			
		||||
    if (Platform.isWindows) {
 | 
			
		||||
@@ -460,12 +447,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
			
		||||
        Timer(const Duration(milliseconds: 100), () => appWindow.show());
 | 
			
		||||
        break;
 | 
			
		||||
      case 'exit':
 | 
			
		||||
        _appLifecycleListener?.dispose();
 | 
			
		||||
        if (Platform.isWindows) {
 | 
			
		||||
          appWindow.close();
 | 
			
		||||
        } else {
 | 
			
		||||
          SystemChannels.platform.invokeMethod('SystemNavigator.pop');
 | 
			
		||||
        }
 | 
			
		||||
        _quitApp();
 | 
			
		||||
        break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@@ -482,28 +464,73 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final cfg = context.read<ConfigProvider>();
 | 
			
		||||
    return NotificationListener<SizeChangedLayoutNotification>(
 | 
			
		||||
      onNotification: (notification) {
 | 
			
		||||
        WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
			
		||||
          cfg.calcDrawerSize(context);
 | 
			
		||||
        });
 | 
			
		||||
        return false;
 | 
			
		||||
      },
 | 
			
		||||
      child: OrientationBuilder(
 | 
			
		||||
        builder: (context, orientation) {
 | 
			
		||||
          final cfg = context.read<ConfigProvider>();
 | 
			
		||||
    return AppSystemMenuBar(
 | 
			
		||||
      onQuit: _quitApp,
 | 
			
		||||
      child: NotificationListener<SizeChangedLayoutNotification>(
 | 
			
		||||
        onNotification: (notification) {
 | 
			
		||||
          WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
			
		||||
            cfg.calcDrawerSize(context);
 | 
			
		||||
          });
 | 
			
		||||
          Future.delayed(const Duration(milliseconds: 300), () {
 | 
			
		||||
            if (context.mounted) {
 | 
			
		||||
              cfg.calcDrawerSize(context);
 | 
			
		||||
            }
 | 
			
		||||
          });
 | 
			
		||||
          return SizeChangedLayoutNotifier(
 | 
			
		||||
            child: widget.child,
 | 
			
		||||
          );
 | 
			
		||||
          return false;
 | 
			
		||||
        },
 | 
			
		||||
        child: OrientationBuilder(
 | 
			
		||||
          builder: (context, orientation) {
 | 
			
		||||
            final cfg = context.read<ConfigProvider>();
 | 
			
		||||
            WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
			
		||||
              cfg.calcDrawerSize(context);
 | 
			
		||||
            });
 | 
			
		||||
            Future.delayed(const Duration(milliseconds: 300), () {
 | 
			
		||||
              if (context.mounted) {
 | 
			
		||||
                cfg.calcDrawerSize(context);
 | 
			
		||||
              }
 | 
			
		||||
            });
 | 
			
		||||
            return SizeChangedLayoutNotifier(
 | 
			
		||||
              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,
 | 
			
		||||
            );
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -28,6 +28,24 @@ class ChatChannelProvider extends ChangeNotifier {
 | 
			
		||||
    _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 {
 | 
			
		||||
    await Future.wait(
 | 
			
		||||
      channels.map(
 | 
			
		||||
 
 | 
			
		||||
@@ -19,6 +19,9 @@ const kAppExpandPostLink = 'app_expand_post_link';
 | 
			
		||||
const kAppExpandChatLink = 'app_expand_chat_link';
 | 
			
		||||
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 Map<String, FilterQuality> kImageQualityLevel = {
 | 
			
		||||
  'settingsImageQualityLowest': FilterQuality.none,
 | 
			
		||||
@@ -81,8 +84,36 @@ class ConfigProvider extends ChangeNotifier {
 | 
			
		||||
    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;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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) {
 | 
			
		||||
    prefs.setBool(kAppRealmCompactView, value);
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  set serverUrl(String url) {
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:shared_preferences/shared_preferences.dart';
 | 
			
		||||
import 'package:surface/types/realm.dart';
 | 
			
		||||
 | 
			
		||||
class AppNavDestination {
 | 
			
		||||
  final String label;
 | 
			
		||||
@@ -24,13 +25,10 @@ class NavigationProvider extends ChangeNotifier {
 | 
			
		||||
 | 
			
		||||
  int? get currentIndex => _currentIndex;
 | 
			
		||||
 | 
			
		||||
  static const List<String> kShowBottomNavScreen = [
 | 
			
		||||
    'home',
 | 
			
		||||
    'explore',
 | 
			
		||||
    'account',
 | 
			
		||||
    'album',
 | 
			
		||||
    'chat',
 | 
			
		||||
  ];
 | 
			
		||||
  List<String> get showBottomNavScreen => destinations
 | 
			
		||||
      .where((ele) => ele.isPinned)
 | 
			
		||||
      .map((ele) => ele.screen)
 | 
			
		||||
      .toList();
 | 
			
		||||
 | 
			
		||||
  static const List<AppNavDestination> kAllDestination = [
 | 
			
		||||
    AppNavDestination(
 | 
			
		||||
@@ -63,32 +61,12 @@ class NavigationProvider extends ChangeNotifier {
 | 
			
		||||
      screen: 'news',
 | 
			
		||||
      label: 'screenNews',
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavDestination(
 | 
			
		||||
      icon: Icon(Symbols.emoji_emotions, weight: 400, opticalSize: 20),
 | 
			
		||||
      screen: 'stickers',
 | 
			
		||||
      label: 'screenStickers',
 | 
			
		||||
    ),
 | 
			
		||||
    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 = [
 | 
			
		||||
    'home',
 | 
			
		||||
    'explore',
 | 
			
		||||
    'chat',
 | 
			
		||||
    'account',
 | 
			
		||||
    'realm',
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  List<AppNavDestination> destinations = [];
 | 
			
		||||
@@ -143,4 +121,11 @@ class NavigationProvider extends ChangeNotifier {
 | 
			
		||||
    _currentIndex = idx;
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  SnRealm? focusedRealm;
 | 
			
		||||
 | 
			
		||||
  void setFocusedRealm(SnRealm? realm) {
 | 
			
		||||
    focusedRealm = realm;
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -48,13 +48,11 @@ class NotificationProvider extends ChangeNotifier {
 | 
			
		||||
    var deviceUuid = await FlutterUdid.consistentUdid;
 | 
			
		||||
 | 
			
		||||
    if (deviceUuid.isEmpty) {
 | 
			
		||||
      logging.warning(
 | 
			
		||||
          '[Push Notification] 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;
 | 
			
		||||
    } else {
 | 
			
		||||
      logging.info('[Push Notification] Device UUID is $deviceUuid');
 | 
			
		||||
      logging
 | 
			
		||||
          .info('[Push Notification] Registering device push notifications...');
 | 
			
		||||
      logging.info('[Push Notification] Registering device push notifications...');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (Platform.isIOS || Platform.isMacOS) {
 | 
			
		||||
@@ -66,14 +64,14 @@ class NotificationProvider extends ChangeNotifier {
 | 
			
		||||
    }
 | 
			
		||||
    logging.info('[Push Notification] Device Push Token is $token');
 | 
			
		||||
 | 
			
		||||
    await _sn.client.post(
 | 
			
		||||
      '/cgi/id/notifications/subscription',
 | 
			
		||||
      data: {
 | 
			
		||||
        'provider': provider,
 | 
			
		||||
        'device_token': token,
 | 
			
		||||
        'device_id': deviceUuid,
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
    try {
 | 
			
		||||
      await _sn.client.post(
 | 
			
		||||
        '/cgi/id/notifications/subscription',
 | 
			
		||||
        data: {'provider': provider, 'device_token': token, 'device_id': deviceUuid},
 | 
			
		||||
      );
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      logging.error('[Push Notification] Unable to register push notifications: $err');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  int showingCount = 0;
 | 
			
		||||
@@ -91,8 +89,7 @@ class NotificationProvider extends ChangeNotifier {
 | 
			
		||||
        final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true;
 | 
			
		||||
        if (doHaptic) HapticFeedback.mediumImpact();
 | 
			
		||||
 | 
			
		||||
        if (notification.topic == 'messaging.message' &&
 | 
			
		||||
            skippableNotifyChannel != null) {
 | 
			
		||||
        if (notification.topic == 'messaging.message' && skippableNotifyChannel != null) {
 | 
			
		||||
          if (notification.metadata['channel_id'] != null &&
 | 
			
		||||
              notification.metadata['channel_id'] == skippableNotifyChannel) {
 | 
			
		||||
            return;
 | 
			
		||||
 
 | 
			
		||||
@@ -60,16 +60,24 @@ class SnPostContentProvider {
 | 
			
		||||
 | 
			
		||||
      out[i] = out[i].copyWith(
 | 
			
		||||
        preload: SnPostPreload(
 | 
			
		||||
          thumbnail: attachments.where((ele) => ele?.rid == out[i].body['thumbnail']).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,
 | 
			
		||||
          thumbnail: attachments
 | 
			
		||||
              .where((ele) => ele?.rid == out[i].body['thumbnail'])
 | 
			
		||||
              .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,
 | 
			
		||||
          realm: realm,
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    uids.addAll(attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
 | 
			
		||||
    uids.addAll(
 | 
			
		||||
        attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
 | 
			
		||||
    await _ud.listAccount(uids);
 | 
			
		||||
 | 
			
		||||
    return out;
 | 
			
		||||
@@ -107,15 +115,23 @@ class SnPostContentProvider {
 | 
			
		||||
 | 
			
		||||
    out = out.copyWith(
 | 
			
		||||
      preload: SnPostPreload(
 | 
			
		||||
        thumbnail: attachments.where((ele) => ele?.rid == out.body['thumbnail']).firstOrNull,
 | 
			
		||||
        attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(),
 | 
			
		||||
        video: attachments.where((ele) => ele?.rid == out.body['video']).firstOrNull,
 | 
			
		||||
        thumbnail: attachments
 | 
			
		||||
            .where((ele) => ele?.rid == out.body['thumbnail'])
 | 
			
		||||
            .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,
 | 
			
		||||
        realm: realm,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    uids.addAll(attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
 | 
			
		||||
    uids.addAll(
 | 
			
		||||
        attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
 | 
			
		||||
    await _ud.listAccount(uids);
 | 
			
		||||
 | 
			
		||||
    return out;
 | 
			
		||||
@@ -129,6 +145,36 @@ class SnPostContentProvider {
 | 
			
		||||
    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({
 | 
			
		||||
    int take = 10,
 | 
			
		||||
    int offset = 0,
 | 
			
		||||
@@ -138,17 +184,25 @@ class SnPostContentProvider {
 | 
			
		||||
    Iterable<String>? tags,
 | 
			
		||||
    String? realm,
 | 
			
		||||
    String? channel,
 | 
			
		||||
    bool isDraft = false,
 | 
			
		||||
    bool isShuffle = false,
 | 
			
		||||
  }) async {
 | 
			
		||||
    final resp = await _sn.client.get('/cgi/co/posts', queryParameters: {
 | 
			
		||||
      'take': take,
 | 
			
		||||
      'offset': offset,
 | 
			
		||||
      if (type != null) 'type': type,
 | 
			
		||||
      if (author != null) 'author': author,
 | 
			
		||||
      if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
 | 
			
		||||
      if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','),
 | 
			
		||||
      if (realm != null) 'realm': realm,
 | 
			
		||||
      if (channel != null) 'channel': channel,
 | 
			
		||||
    });
 | 
			
		||||
    final resp = await _sn.client.get(
 | 
			
		||||
      isShuffle
 | 
			
		||||
          ? '/cgi/co/recommendations/shuffle'
 | 
			
		||||
          : '/cgi/co/posts${isDraft ? '/drafts' : ''}',
 | 
			
		||||
      queryParameters: {
 | 
			
		||||
        'take': take,
 | 
			
		||||
        'offset': offset,
 | 
			
		||||
        if (type != null) 'type': type,
 | 
			
		||||
        if (author != null) 'author': author,
 | 
			
		||||
        if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
 | 
			
		||||
        if (categories?.isNotEmpty ?? false)
 | 
			
		||||
          'categories': categories!.join(','),
 | 
			
		||||
        if (realm != null) 'realm': realm,
 | 
			
		||||
        if (channel != null) 'channel': channel,
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
    final List<SnPost> out = await _preloadRelatedDataInBatch(
 | 
			
		||||
      List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
 | 
			
		||||
    );
 | 
			
		||||
@@ -161,7 +215,8 @@ class SnPostContentProvider {
 | 
			
		||||
    int take = 10,
 | 
			
		||||
    int offset = 0,
 | 
			
		||||
  }) 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,
 | 
			
		||||
      'offset': offset,
 | 
			
		||||
    });
 | 
			
		||||
@@ -200,4 +255,9 @@ class SnPostContentProvider {
 | 
			
		||||
    );
 | 
			
		||||
    return out;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<SnPost> completePostData(SnPost post) async {
 | 
			
		||||
    final out = await _preloadRelatedDataSingle(post);
 | 
			
		||||
    return out;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -321,13 +321,13 @@ class SnAttachmentProvider {
 | 
			
		||||
          uuid: ele.uuid,
 | 
			
		||||
          content: ele,
 | 
			
		||||
          accountId: ele.accountId,
 | 
			
		||||
          cacheExpiredAt: DateTime.now().add(const Duration(days: 7)),
 | 
			
		||||
          cacheExpiredAt: DateTime.now().add(const Duration(hours: 1)),
 | 
			
		||||
        ),
 | 
			
		||||
        onConflict: DoUpdate(
 | 
			
		||||
          (_) => SnLocalAttachmentCompanion.custom(
 | 
			
		||||
            content: Constant(jsonEncode(ele.toJson())),
 | 
			
		||||
            cacheExpiredAt:
 | 
			
		||||
                Constant(DateTime.now().add(const Duration(days: 7))),
 | 
			
		||||
                Constant(DateTime.now().add(const Duration(hours: 1))),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
 
 | 
			
		||||
@@ -17,6 +17,20 @@ 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 = [
 | 
			
		||||
  ('Solar Network', 'https://api.sn.solsynth.dev'),
 | 
			
		||||
  ('Local', 'http://localhost:8001'),
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,30 @@
 | 
			
		||||
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/providers/database.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/types/realm.dart';
 | 
			
		||||
 | 
			
		||||
class SnRealmProvider {
 | 
			
		||||
class SnRealmProvider extends ChangeNotifier {
 | 
			
		||||
  late final SnNetworkProvider _sn;
 | 
			
		||||
  late final DatabaseProvider _dt;
 | 
			
		||||
 | 
			
		||||
  SnRealmProvider(BuildContext context) {
 | 
			
		||||
    _sn = context.read<SnNetworkProvider>();
 | 
			
		||||
    _dt = context.read<DatabaseProvider>();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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 {
 | 
			
		||||
    final resp = await _sn.client.get('/cgi/id/realms/me/available');
 | 
			
		||||
@@ -21,17 +35,56 @@ class SnRealmProvider {
 | 
			
		||||
      _cache[realm.alias] = realm;
 | 
			
		||||
      _cache[realm.id.toString()] = realm;
 | 
			
		||||
    }
 | 
			
		||||
    _saveToLocal(out);
 | 
			
		||||
    return out;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void addAvailableRealm(SnRealm realm) {
 | 
			
		||||
    _availableRealms.add(realm);
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<SnRealm> getRealm(dynamic aliasOrId) async {
 | 
			
		||||
    if (_cache.containsKey(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 out = SnRealm.fromJson(resp.data);
 | 
			
		||||
    _cache[out.alias] = out;
 | 
			
		||||
    _cache[out.id.toString()] = out;
 | 
			
		||||
    _saveToLocal([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))),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -19,6 +19,7 @@ class UserDirectoryProvider {
 | 
			
		||||
 | 
			
		||||
  final Map<String, int> _idCache = {};
 | 
			
		||||
  final Map<int, SnAccount> _cache = {};
 | 
			
		||||
  DateTime? _cacheExpiredAt;
 | 
			
		||||
 | 
			
		||||
  Future<int> loadAccountCache({int max = 100}) async {
 | 
			
		||||
    final out = await (_dt.db.snLocalAccount.select()..limit(max)).get();
 | 
			
		||||
@@ -26,11 +27,18 @@ class UserDirectoryProvider {
 | 
			
		||||
      _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 {
 | 
			
		||||
    // 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 plannedQuery = <int>{};
 | 
			
		||||
    for (var idx = 0; idx < out.length; idx++) {
 | 
			
		||||
@@ -62,6 +70,7 @@ class UserDirectoryProvider {
 | 
			
		||||
      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(',')});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,5 @@
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:shared_preferences/shared_preferences.dart';
 | 
			
		||||
@@ -35,7 +37,34 @@ class UserProvider extends ChangeNotifier {
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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 {
 | 
			
		||||
    if (!isAuthorized) return null;
 | 
			
		||||
    final resp = await _sn.client.get('/cgi/id/users/me');
 | 
			
		||||
    final out = SnAccount.fromJson(resp.data);
 | 
			
		||||
 | 
			
		||||
@@ -47,7 +76,13 @@ class UserProvider extends ChangeNotifier {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void logoutUser() async {
 | 
			
		||||
    _sn.clearTokenPair();
 | 
			
		||||
    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();
 | 
			
		||||
    });
 | 
			
		||||
    isAuthorized = false;
 | 
			
		||||
    user = null;
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
 
 | 
			
		||||
@@ -4,14 +4,19 @@ import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:surface/screens/abuse_report.dart';
 | 
			
		||||
import 'package:surface/screens/account.dart';
 | 
			
		||||
import 'package:surface/screens/account/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/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_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/publishers.dart';
 | 
			
		||||
import 'package:surface/screens/account/auth_tickets.dart';
 | 
			
		||||
import 'package:surface/screens/album.dart';
 | 
			
		||||
import 'package:surface/screens/auth/login.dart';
 | 
			
		||||
import 'package:surface/screens/auth/register.dart';
 | 
			
		||||
@@ -28,10 +33,13 @@ import 'package:surface/screens/news/news_detail.dart';
 | 
			
		||||
import 'package:surface/screens/news/news_list.dart';
 | 
			
		||||
import 'package:surface/screens/notification.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_shuffle.dart';
 | 
			
		||||
import 'package:surface/screens/post/publisher_page.dart';
 | 
			
		||||
import 'package:surface/screens/post/post_search.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/realm_detail.dart';
 | 
			
		||||
import 'package:surface/screens/realm/realm_discovery.dart';
 | 
			
		||||
@@ -66,10 +74,15 @@ final _appRoutes = [
 | 
			
		||||
    builder: (context, state) => const ExploreScreen(),
 | 
			
		||||
    routes: [
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/write/:mode',
 | 
			
		||||
        path: '/draft',
 | 
			
		||||
        name: 'postDraftBox',
 | 
			
		||||
        builder: (context, state) => const PostDraftBox(),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/write',
 | 
			
		||||
        name: 'postEditor',
 | 
			
		||||
        builder: (context, state) => PostEditorScreen(
 | 
			
		||||
          mode: state.pathParameters['mode']!,
 | 
			
		||||
          mode: state.uri.queryParameters['mode'],
 | 
			
		||||
          postEditId: int.tryParse(
 | 
			
		||||
            state.uri.queryParameters['editing'] ?? '',
 | 
			
		||||
          ),
 | 
			
		||||
@@ -82,6 +95,11 @@ final _appRoutes = [
 | 
			
		||||
          extraProps: state.extra as PostEditorExtra?,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/shuffle',
 | 
			
		||||
        name: 'postShuffle',
 | 
			
		||||
        builder: (context, state) => const PostShuffleScreen(),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/search',
 | 
			
		||||
        name: 'postSearch',
 | 
			
		||||
@@ -112,6 +130,21 @@ final _appRoutes = [
 | 
			
		||||
    name: 'account',
 | 
			
		||||
    builder: (context, state) => const AccountScreen(),
 | 
			
		||||
    routes: [
 | 
			
		||||
      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',
 | 
			
		||||
@@ -131,6 +164,18 @@ final _appRoutes = [
 | 
			
		||||
        path: '/settings',
 | 
			
		||||
        name: 'accountSettings',
 | 
			
		||||
        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(
 | 
			
		||||
        path: '/settings/factors',
 | 
			
		||||
@@ -160,7 +205,7 @@ final _appRoutes = [
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/:name',
 | 
			
		||||
        path: '/profile/:name',
 | 
			
		||||
        name: 'accountProfilePage',
 | 
			
		||||
        pageBuilder: (context, state) => NoTransitionPage(
 | 
			
		||||
          child: UserScreen(name: state.pathParameters['name']!),
 | 
			
		||||
@@ -215,6 +260,13 @@ final _appRoutes = [
 | 
			
		||||
      child: const RealmScreen(),
 | 
			
		||||
    ),
 | 
			
		||||
    routes: [
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/:alias/community',
 | 
			
		||||
        name: 'realmCommunity',
 | 
			
		||||
        builder: (context, state) => RealmCommunityScreen(
 | 
			
		||||
          alias: state.pathParameters['alias']!,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/manage',
 | 
			
		||||
        name: 'realmManage',
 | 
			
		||||
 
 | 
			
		||||
@@ -11,7 +11,9 @@ import 'package:surface/providers/database.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.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_status.dart';
 | 
			
		||||
import 'package:surface/widgets/app_bar_leading.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
@@ -28,19 +30,7 @@ class AccountScreen extends StatelessWidget {
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: AutoAppBarLeading(),
 | 
			
		||||
        title: Text(
 | 
			
		||||
          "screenAccount",
 | 
			
		||||
          style: TextStyle(
 | 
			
		||||
            color: Colors.white,
 | 
			
		||||
            shadows: [
 | 
			
		||||
              Shadow(
 | 
			
		||||
                offset: Offset(1, 1),
 | 
			
		||||
                blurRadius: 5.0,
 | 
			
		||||
                color: Color.fromARGB(255, 0, 0, 0),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ).tr(),
 | 
			
		||||
        title: Text("screenAccount").tr(),
 | 
			
		||||
        flexibleSpace: ua.user != null && ua.user!.banner.isNotEmpty
 | 
			
		||||
            ? Stack(
 | 
			
		||||
                fit: StackFit.expand,
 | 
			
		||||
@@ -112,7 +102,14 @@ class _AuthorizedAccountScreen extends StatelessWidget {
 | 
			
		||||
              child: Column(
 | 
			
		||||
                crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                children: [
 | 
			
		||||
                  AccountImage(content: ua.user!.avatar, radius: 28),
 | 
			
		||||
                  Row(
 | 
			
		||||
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | 
			
		||||
                    crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      AccountImage(content: ua.user!.avatar, radius: 28),
 | 
			
		||||
                      _AccountStatusWidget(account: ua.user!),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                  const Gap(8),
 | 
			
		||||
                  Row(
 | 
			
		||||
                    crossAxisAlignment: CrossAxisAlignment.baseline,
 | 
			
		||||
@@ -149,23 +146,33 @@ class _AuthorizedAccountScreen extends StatelessWidget {
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          title: Text('abuseReport').tr(),
 | 
			
		||||
          subtitle: Text('abuseReportActionDescription').tr(),
 | 
			
		||||
          title: Text('friends').tr(),
 | 
			
		||||
          subtitle: Text('friendsDescription').tr(),
 | 
			
		||||
          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
          leading: const Icon(Symbols.flag),
 | 
			
		||||
          leading: const Icon(Symbols.person),
 | 
			
		||||
          trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            GoRouter.of(context).pushNamed('abuseReport');
 | 
			
		||||
            GoRouter.of(context).pushNamed('friend');
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          title: Text('factorSettings').tr(),
 | 
			
		||||
          subtitle: Text('factorSettingsSubtitle').tr(),
 | 
			
		||||
          title: Text('album').tr(),
 | 
			
		||||
          subtitle: Text('albumDescription').tr(),
 | 
			
		||||
          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
          leading: const Icon(Symbols.lock),
 | 
			
		||||
          leading: const Icon(Symbols.photo_library),
 | 
			
		||||
          trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            GoRouter.of(context).pushNamed('factorSettings');
 | 
			
		||||
            GoRouter.of(context).pushNamed('album');
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          title: Text('stickers').tr(),
 | 
			
		||||
          subtitle: Text('stickersDescription').tr(),
 | 
			
		||||
          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
          leading: const Icon(Symbols.emoji_emotions),
 | 
			
		||||
          trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            GoRouter.of(context).pushNamed('stickers');
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        ListTile(
 | 
			
		||||
@@ -198,6 +205,26 @@ class _AuthorizedAccountScreen extends StatelessWidget {
 | 
			
		||||
            GoRouter.of(context).pushNamed('accountKeyPairs');
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          title: Text('accountActionEvent').tr(),
 | 
			
		||||
          subtitle: Text('accountActionEventDescription').tr(),
 | 
			
		||||
          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
          leading: const Icon(Symbols.history),
 | 
			
		||||
          trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            GoRouter.of(context).pushNamed('accountActionEvents');
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          title: Text('accountAuthTickets').tr(),
 | 
			
		||||
          subtitle: Text('accountAuthTicketsDescription').tr(),
 | 
			
		||||
          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
          leading: const Icon(Symbols.confirmation_number),
 | 
			
		||||
          trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            GoRouter.of(context).pushNamed('accountAuthTickets');
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          title: Text('accountSettings').tr(),
 | 
			
		||||
          subtitle: Text('accountSettingsSubtitle').tr(),
 | 
			
		||||
@@ -208,6 +235,16 @@ class _AuthorizedAccountScreen extends StatelessWidget {
 | 
			
		||||
            GoRouter.of(context).pushNamed('accountSettings');
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        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(
 | 
			
		||||
          title: Text('accountLogout').tr(),
 | 
			
		||||
          subtitle: Text('accountLogoutSubtitle').tr(),
 | 
			
		||||
@@ -269,9 +306,7 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
 | 
			
		||||
            GoRouter.of(context).pushNamed('authLogin').then((value) {
 | 
			
		||||
              if (value == true && context.mounted) {
 | 
			
		||||
                final ua = context.read<UserProvider>();
 | 
			
		||||
                context.showSnackbar('loginSuccess'.tr(args: [
 | 
			
		||||
                  '@${ua.user?.name} (${ua.user?.nick})',
 | 
			
		||||
                ]));
 | 
			
		||||
                ua.refreshUser();
 | 
			
		||||
              }
 | 
			
		||||
            });
 | 
			
		||||
          },
 | 
			
		||||
@@ -290,3 +325,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();
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -87,6 +87,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(
 | 
			
		||||
              title: Text('accountProfileEdit').tr(),
 | 
			
		||||
              subtitle: Text('accountProfileEditSubtitle').tr(),
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										160
									
								
								lib/screens/account/action_events.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										160
									
								
								lib/screens/account/action_events.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,160 @@
 | 
			
		||||
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(
 | 
			
		||||
      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(),
 | 
			
		||||
                    ),
 | 
			
		||||
                  );
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										186
									
								
								lib/screens/account/auth_tickets.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										186
									
								
								lib/screens/account/auth_tickets.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,186 @@
 | 
			
		||||
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(
 | 
			
		||||
      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);
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										322
									
								
								lib/screens/account/contact_methods.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										322
									
								
								lib/screens/account/contact_methods.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,322 @@
 | 
			
		||||
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(
 | 
			
		||||
      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(),
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										122
									
								
								lib/screens/account/prefs/notify.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								lib/screens/account/prefs/notify.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,122 @@
 | 
			
		||||
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(
 | 
			
		||||
      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;
 | 
			
		||||
                    });
 | 
			
		||||
                  },
 | 
			
		||||
                );
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										147
									
								
								lib/screens/account/prefs/security.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								lib/screens/account/prefs/security.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,147 @@
 | 
			
		||||
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(
 | 
			
		||||
      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);
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -18,6 +18,7 @@ import 'package:surface/types/account.dart';
 | 
			
		||||
import 'package:surface/types/check_in.dart';
 | 
			
		||||
import 'package:surface/types/post.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/universal_image.dart';
 | 
			
		||||
import 'package:surface/theme.dart';
 | 
			
		||||
@@ -450,19 +451,25 @@ class _UserScreenState extends State<UserScreen>
 | 
			
		||||
                    child: Row(
 | 
			
		||||
                      children: [
 | 
			
		||||
                        Icon(
 | 
			
		||||
                          Symbols.circle,
 | 
			
		||||
                          fill: 1,
 | 
			
		||||
                          (_status?.isDisturbable ?? true)
 | 
			
		||||
                              ? Symbols.circle
 | 
			
		||||
                              : Symbols.do_not_disturb_on,
 | 
			
		||||
                          fill: (_status?.isOnline ?? false) ? 1 : 0,
 | 
			
		||||
                          size: 16,
 | 
			
		||||
                          color: (_status?.isOnline ?? false)
 | 
			
		||||
                              ? Colors.green
 | 
			
		||||
                              ? (_status?.isDisturbable ?? true)
 | 
			
		||||
                                  ? Colors.green
 | 
			
		||||
                                  : Colors.red
 | 
			
		||||
                              : Colors.grey,
 | 
			
		||||
                        ).padding(all: 4),
 | 
			
		||||
                        const Gap(8),
 | 
			
		||||
                        Text(
 | 
			
		||||
                          _status != null
 | 
			
		||||
                              ? _status!.isOnline
 | 
			
		||||
                                  ? 'accountStatusOnline'.tr()
 | 
			
		||||
                                  : 'accountStatusOffline'.tr()
 | 
			
		||||
                              ? (_status!.status?.label.isNotEmpty ?? false)
 | 
			
		||||
                                  ? _status!.status!.label
 | 
			
		||||
                                  : _status!.isOnline
 | 
			
		||||
                                      ? 'accountStatusOnline'.tr()
 | 
			
		||||
                                      : 'accountStatusOffline'.tr()
 | 
			
		||||
                              : 'loading'.tr(),
 | 
			
		||||
                        ),
 | 
			
		||||
                        if (_status != null &&
 | 
			
		||||
@@ -484,34 +491,7 @@ class _UserScreenState extends State<UserScreen>
 | 
			
		||||
                  Wrap(
 | 
			
		||||
                    children: _account!.badges
 | 
			
		||||
                        .map(
 | 
			
		||||
                          (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: ele.metadata['color'] != null
 | 
			
		||||
                                  ? HexColor.fromHex(ele.metadata['color']!)
 | 
			
		||||
                                  : kBadgesMeta[ele.type]?.$3,
 | 
			
		||||
                              fill: 1,
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                          (ele) => AccountBadge(badge: ele),
 | 
			
		||||
                        )
 | 
			
		||||
                        .toList(),
 | 
			
		||||
                  ).padding(horizontal: 8),
 | 
			
		||||
 
 | 
			
		||||
@@ -10,7 +10,6 @@ import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/user_directory.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_item.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
@@ -106,7 +105,7 @@ class _AlbumScreenState extends State<AlbumScreen> {
 | 
			
		||||
        controller: _scrollController,
 | 
			
		||||
        slivers: [
 | 
			
		||||
          SliverAppBar(
 | 
			
		||||
            leading: AutoAppBarLeading(),
 | 
			
		||||
            leading: PageBackButton(),
 | 
			
		||||
            title: Text('screenAlbum').tr(),
 | 
			
		||||
          ),
 | 
			
		||||
          SliverToBoxAdapter(
 | 
			
		||||
@@ -119,7 +118,8 @@ class _AlbumScreenState extends State<AlbumScreen> {
 | 
			
		||||
                    child: CircularProgressIndicator(
 | 
			
		||||
                      value: _billing?.includedRatio ?? 0,
 | 
			
		||||
                      strokeWidth: 8,
 | 
			
		||||
                      backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
			
		||||
                      backgroundColor:
 | 
			
		||||
                          Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
			
		||||
                    ),
 | 
			
		||||
                  ).padding(all: 12),
 | 
			
		||||
                  const Gap(24),
 | 
			
		||||
@@ -129,7 +129,8 @@ class _AlbumScreenState extends State<AlbumScreen> {
 | 
			
		||||
                      children: [
 | 
			
		||||
                        Text('attachmentBillingUploaded').tr().bold(),
 | 
			
		||||
                        Text(
 | 
			
		||||
                          (_billing?.currentBytes ?? 0).formatBytes(decimals: 4),
 | 
			
		||||
                          (_billing?.currentBytes ?? 0)
 | 
			
		||||
                              .formatBytes(decimals: 4),
 | 
			
		||||
                          style: GoogleFonts.robotoMono(),
 | 
			
		||||
                        ),
 | 
			
		||||
                        Text('attachmentBillingDiscount').tr().bold(),
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@ 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/screens/captcha.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:url_launcher/url_launcher_string.dart';
 | 
			
		||||
@@ -33,10 +34,20 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
    final username = _usernameController.value.text;
 | 
			
		||||
    final nickname = _nicknameController.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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final captchaTk = await Navigator.of(context, rootNavigator: true).push(
 | 
			
		||||
      MaterialPageRoute(
 | 
			
		||||
        builder: (context) => CaptchaScreen(),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    if (captchaTk == null) return;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.post('/cgi/id/users', data: {
 | 
			
		||||
@@ -45,6 +56,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
        'email': email,
 | 
			
		||||
        'password': password,
 | 
			
		||||
        'language': EasyLocalization.of(context)!.currentLocale.toString(),
 | 
			
		||||
        'captcha_token': captchaTk,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (!context.mounted) return;
 | 
			
		||||
@@ -91,8 +103,11 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
                  children: [
 | 
			
		||||
                    TextFormField(
 | 
			
		||||
                      validator: (value) {
 | 
			
		||||
                        if (value == null || value.length < 4 || value.length > 32) {
 | 
			
		||||
                          return 'fieldUsernameLengthLimit'.tr(args: [4.toString(), 32.toString()]);
 | 
			
		||||
                        if (value == null ||
 | 
			
		||||
                            value.length < 4 ||
 | 
			
		||||
                            value.length > 32) {
 | 
			
		||||
                          return 'fieldUsernameLengthLimit'
 | 
			
		||||
                              .tr(args: [4.toString(), 32.toString()]);
 | 
			
		||||
                        }
 | 
			
		||||
                        if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value)) {
 | 
			
		||||
                          return 'fieldUsernameAlphanumOnly'.tr();
 | 
			
		||||
@@ -108,13 +123,17 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
                        border: const UnderlineInputBorder(),
 | 
			
		||||
                        labelText: 'fieldUsername'.tr(),
 | 
			
		||||
                      ),
 | 
			
		||||
                      onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                      onTapOutside: (_) =>
 | 
			
		||||
                          FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                    ),
 | 
			
		||||
                    const Gap(12),
 | 
			
		||||
                    TextFormField(
 | 
			
		||||
                      validator: (value) {
 | 
			
		||||
                        if (value == null || value.length < 4 || value.length > 32) {
 | 
			
		||||
                          return 'fieldNicknameLengthLimit'.tr(args: [4.toString(), 32.toString()]);
 | 
			
		||||
                        if (value == null ||
 | 
			
		||||
                            value.length < 4 ||
 | 
			
		||||
                            value.length > 32) {
 | 
			
		||||
                          return 'fieldNicknameLengthLimit'
 | 
			
		||||
                              .tr(args: [4.toString(), 32.toString()]);
 | 
			
		||||
                        }
 | 
			
		||||
                        return null;
 | 
			
		||||
                      },
 | 
			
		||||
@@ -127,7 +146,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
                        border: const UnderlineInputBorder(),
 | 
			
		||||
                        labelText: 'fieldNickname'.tr(),
 | 
			
		||||
                      ),
 | 
			
		||||
                      onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                      onTapOutside: (_) =>
 | 
			
		||||
                          FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                    ),
 | 
			
		||||
                    const Gap(12),
 | 
			
		||||
                    TextFormField(
 | 
			
		||||
@@ -149,7 +169,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
                        border: const UnderlineInputBorder(),
 | 
			
		||||
                        labelText: 'fieldEmail'.tr(),
 | 
			
		||||
                      ),
 | 
			
		||||
                      onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                      onTapOutside: (_) =>
 | 
			
		||||
                          FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                    ),
 | 
			
		||||
                    const Gap(12),
 | 
			
		||||
                    TextFormField(
 | 
			
		||||
@@ -169,7 +190,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
                        border: const UnderlineInputBorder(),
 | 
			
		||||
                        labelText: 'fieldPassword'.tr(),
 | 
			
		||||
                      ),
 | 
			
		||||
                      onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                      onTapOutside: (_) =>
 | 
			
		||||
                          FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ],
 | 
			
		||||
                ).padding(horizontal: 7),
 | 
			
		||||
@@ -186,9 +208,13 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
                        Text(
 | 
			
		||||
                          'termAcceptNextWithAgree'.tr(),
 | 
			
		||||
                          textAlign: TextAlign.end,
 | 
			
		||||
                          style: Theme.of(context).textTheme.bodySmall!.copyWith(
 | 
			
		||||
                                color: Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round()),
 | 
			
		||||
                              ),
 | 
			
		||||
                          style:
 | 
			
		||||
                              Theme.of(context).textTheme.bodySmall!.copyWith(
 | 
			
		||||
                                    color: Theme.of(context)
 | 
			
		||||
                                        .colorScheme
 | 
			
		||||
                                        .onSurface
 | 
			
		||||
                                        .withAlpha((255 * 0.75).round()),
 | 
			
		||||
                                  ),
 | 
			
		||||
                        ),
 | 
			
		||||
                        Material(
 | 
			
		||||
                          color: Colors.transparent,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										82
									
								
								lib/screens/captcha.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								lib/screens/captcha.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,82 @@
 | 
			
		||||
import 'dart:html' as html;
 | 
			
		||||
import 'dart:ui_web' as ui;
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/foundation.dart' show kIsWeb;
 | 
			
		||||
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
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    if (kIsWeb) {
 | 
			
		||||
      _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);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Create an iframe for the captcha page
 | 
			
		||||
    final iframe = html.IFrameElement()
 | 
			
		||||
      ..src = '${context.read<ConfigProvider>().serverUrl}/captcha?redirect_uri=solink://captcha'
 | 
			
		||||
      ..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) {
 | 
			
		||||
    final cfg = context.read<ConfigProvider>();
 | 
			
		||||
 | 
			
		||||
    if (kIsWeb) {
 | 
			
		||||
      return AppScaffold(
 | 
			
		||||
        appBar: AppBar(title: Text("reCaptcha").tr()),
 | 
			
		||||
        body: HtmlElementView(viewType: 'captcha-iframe'),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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;
 | 
			
		||||
        },
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -204,7 +204,6 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
			
		||||
              Theme.of(context).floatingActionButtonTheme.foregroundColor,
 | 
			
		||||
          backgroundColor:
 | 
			
		||||
              Theme.of(context).floatingActionButtonTheme.backgroundColor,
 | 
			
		||||
          shape: const CircleBorder(),
 | 
			
		||||
        ),
 | 
			
		||||
        closeButtonBuilder: DefaultFloatingActionButtonBuilder(
 | 
			
		||||
          child: const Icon(Symbols.close, size: 28),
 | 
			
		||||
@@ -213,7 +212,6 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
			
		||||
              Theme.of(context).floatingActionButtonTheme.foregroundColor,
 | 
			
		||||
          backgroundColor:
 | 
			
		||||
              Theme.of(context).floatingActionButtonTheme.backgroundColor,
 | 
			
		||||
          shape: const CircleBorder(),
 | 
			
		||||
        ),
 | 
			
		||||
        children: [
 | 
			
		||||
          Row(
 | 
			
		||||
 
 | 
			
		||||
@@ -52,8 +52,10 @@ class ChatRoomScreen extends StatefulWidget {
 | 
			
		||||
class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
  bool _isCalling = false;
 | 
			
		||||
  bool _isJoining = false;
 | 
			
		||||
 | 
			
		||||
  SnChannel? _channel;
 | 
			
		||||
  SnChannelMember? _currentMember;
 | 
			
		||||
  SnChannelMember? _otherMember;
 | 
			
		||||
  SnChatCall? _ongoingCall;
 | 
			
		||||
 | 
			
		||||
@@ -67,7 +69,24 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
 | 
			
		||||
  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 {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
@@ -76,6 +95,12 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
      _channel = await chan.getChannel('${widget.scope}:${widget.alias}');
 | 
			
		||||
 | 
			
		||||
      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 ua = context.read<UserProvider>();
 | 
			
		||||
      if (_channel!.type == 1) {
 | 
			
		||||
@@ -204,11 +229,9 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
    return a.createdAt.difference(b.createdAt).inMinutes <= 3;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _messageController = ChatMessageController(context);
 | 
			
		||||
  Future<void> _initializeChat() async {
 | 
			
		||||
    _fetchChannel().then((_) async {
 | 
			
		||||
      if (_currentMember == null) return;
 | 
			
		||||
      await _messageController.initialize(_channel!);
 | 
			
		||||
 | 
			
		||||
      if (widget.extra != null) {
 | 
			
		||||
@@ -230,6 +253,13 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
        _fetchOngoingCall(),
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _messageController = ChatMessageController(context);
 | 
			
		||||
    _initializeChat();
 | 
			
		||||
 | 
			
		||||
    _wsSubscription = _ws.pk.stream.listen((event) {
 | 
			
		||||
      switch (event.method) {
 | 
			
		||||
@@ -281,25 +311,27 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
              : _channel?.name ?? 'loading'.tr(),
 | 
			
		||||
        ),
 | 
			
		||||
        actions: [
 | 
			
		||||
          IconButton(
 | 
			
		||||
            onPressed: () {
 | 
			
		||||
              setState(() => _isEncrypted = !_isEncrypted);
 | 
			
		||||
              _inputGlobalKey.currentState?.setEncrypted(_isEncrypted);
 | 
			
		||||
            },
 | 
			
		||||
            icon: _isEncrypted
 | 
			
		||||
                ? const Icon(Symbols.lock)
 | 
			
		||||
                : const Icon(Symbols.no_encryption),
 | 
			
		||||
          ),
 | 
			
		||||
          IconButton(
 | 
			
		||||
            icon: _ongoingCall == null
 | 
			
		||||
                ? const Icon(Symbols.call)
 | 
			
		||||
                : const Icon(Symbols.call_end),
 | 
			
		||||
            onPressed: _isCalling
 | 
			
		||||
                ? null
 | 
			
		||||
                : _ongoingCall == null
 | 
			
		||||
                    ? _makeCall
 | 
			
		||||
                    : _endCall,
 | 
			
		||||
          ),
 | 
			
		||||
          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(
 | 
			
		||||
              icon: _ongoingCall == null
 | 
			
		||||
                  ? const Icon(Symbols.call)
 | 
			
		||||
                  : const Icon(Symbols.call_end),
 | 
			
		||||
              onPressed: _isCalling
 | 
			
		||||
                  ? null
 | 
			
		||||
                  : _ongoingCall == null
 | 
			
		||||
                      ? _makeCall
 | 
			
		||||
                      : _endCall,
 | 
			
		||||
            ),
 | 
			
		||||
          IconButton(
 | 
			
		||||
            icon: const Icon(Symbols.more_vert),
 | 
			
		||||
            onPressed: () {
 | 
			
		||||
@@ -348,7 +380,41 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
              ).height(_ongoingCall != null ? 54 : 0, animate: true).animate(
 | 
			
		||||
                  const Duration(milliseconds: 300),
 | 
			
		||||
                  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(
 | 
			
		||||
                  child: const CircularProgressIndicator().center(),
 | 
			
		||||
                )
 | 
			
		||||
@@ -403,7 +469,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              if (!_messageController.isPending)
 | 
			
		||||
              if (!_messageController.isPending && _currentMember != null)
 | 
			
		||||
                Material(
 | 
			
		||||
                  elevation: 2,
 | 
			
		||||
                  child: Column(
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
import 'package:dropdown_button2/dropdown_button2.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.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:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:responsive_framework/responsive_framework.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/config.dart';
 | 
			
		||||
import 'package:surface/providers/post.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/sn_realm.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/types/realm.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/app_bar_leading.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/post/fediverse_post_item.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_item.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 = {
 | 
			
		||||
  'technology': Symbols.tools_wrench,
 | 
			
		||||
  'gaming': Symbols.gamepad,
 | 
			
		||||
@@ -39,17 +47,17 @@ class ExploreScreen extends StatefulWidget {
 | 
			
		||||
  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>
 | 
			
		||||
    with SingleTickerProviderStateMixin {
 | 
			
		||||
  late final TabController _tabController =
 | 
			
		||||
      TabController(length: 4, vsync: this);
 | 
			
		||||
    with TickerProviderStateMixin {
 | 
			
		||||
  late TabController _tabController = TabController(
 | 
			
		||||
    length: kPostChannels.length,
 | 
			
		||||
    vsync: this,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  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);
 | 
			
		||||
 | 
			
		||||
@@ -69,14 +77,70 @@ class _ExploreScreenState extends State<ExploreScreen>
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _clearFilter() {
 | 
			
		||||
    _selectedCategory = null;
 | 
			
		||||
  final List<SnRealm> _realms = List.empty(growable: true);
 | 
			
		||||
 | 
			
		||||
  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
 | 
			
		||||
  void initState() {
 | 
			
		||||
    _fetchCategories();
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _tabListen();
 | 
			
		||||
    _fetchCategories();
 | 
			
		||||
    _fetchRealms();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -86,11 +150,12 @@ class _ExploreScreenState extends State<ExploreScreen>
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> refreshPosts() async {
 | 
			
		||||
    await _listKeys[_tabController.index].currentState?.refreshPosts();
 | 
			
		||||
    await _listKey.currentState?.refreshPosts();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final cfg = context.watch<ConfigProvider>();
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      floatingActionButtonLocation: ExpandableFab.location,
 | 
			
		||||
      floatingActionButton: ExpandableFab(
 | 
			
		||||
@@ -111,7 +176,6 @@ class _ExploreScreenState extends State<ExploreScreen>
 | 
			
		||||
              Theme.of(context).floatingActionButtonTheme.foregroundColor,
 | 
			
		||||
          backgroundColor:
 | 
			
		||||
              Theme.of(context).floatingActionButtonTheme.backgroundColor,
 | 
			
		||||
          shape: const CircleBorder(),
 | 
			
		||||
        ),
 | 
			
		||||
        closeButtonBuilder: DefaultFloatingActionButtonBuilder(
 | 
			
		||||
          child: const Icon(Symbols.close, size: 28),
 | 
			
		||||
@@ -120,90 +184,39 @@ class _ExploreScreenState extends State<ExploreScreen>
 | 
			
		||||
              Theme.of(context).floatingActionButtonTheme.foregroundColor,
 | 
			
		||||
          backgroundColor:
 | 
			
		||||
              Theme.of(context).floatingActionButtonTheme.backgroundColor,
 | 
			
		||||
          shape: const CircleBorder(),
 | 
			
		||||
        ),
 | 
			
		||||
        children: [
 | 
			
		||||
          Row(
 | 
			
		||||
            children: [
 | 
			
		||||
              Text('writePostTypeStory').tr(),
 | 
			
		||||
              Text('writePost').tr(),
 | 
			
		||||
              const Gap(20),
 | 
			
		||||
              FloatingActionButton(
 | 
			
		||||
                heroTag: null,
 | 
			
		||||
                tooltip: 'writePostTypeStory'.tr(),
 | 
			
		||||
                tooltip: 'writePost'.tr(),
 | 
			
		||||
                onPressed: () {
 | 
			
		||||
                  GoRouter.of(context).pushNamed('postEditor', pathParameters: {
 | 
			
		||||
                    'mode': 'stories',
 | 
			
		||||
                  }).then((value) {
 | 
			
		||||
                  GoRouter.of(context).pushNamed('postEditor').then((value) {
 | 
			
		||||
                    if (value == true) {
 | 
			
		||||
                      refreshPosts();
 | 
			
		||||
                    }
 | 
			
		||||
                  });
 | 
			
		||||
                  _fabKey.currentState!.toggle();
 | 
			
		||||
                },
 | 
			
		||||
                child: const Icon(Symbols.post_rounded),
 | 
			
		||||
                child: const Icon(Symbols.edit),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
          Row(
 | 
			
		||||
            children: [
 | 
			
		||||
              Text('writePostTypeArticle').tr(),
 | 
			
		||||
              Text('postDraftBox').tr(),
 | 
			
		||||
              const Gap(20),
 | 
			
		||||
              FloatingActionButton(
 | 
			
		||||
                heroTag: null,
 | 
			
		||||
                tooltip: 'writePostTypeArticle'.tr(),
 | 
			
		||||
                tooltip: 'postDraftBox'.tr(),
 | 
			
		||||
                onPressed: () {
 | 
			
		||||
                  GoRouter.of(context).pushNamed('postEditor', pathParameters: {
 | 
			
		||||
                    'mode': 'articles',
 | 
			
		||||
                  }).then((value) {
 | 
			
		||||
                    if (value == true) {
 | 
			
		||||
                      refreshPosts();
 | 
			
		||||
                    }
 | 
			
		||||
                  });
 | 
			
		||||
                  GoRouter.of(context).pushNamed('postDraftBox');
 | 
			
		||||
                  _fabKey.currentState!.toggle();
 | 
			
		||||
                },
 | 
			
		||||
                child: const Icon(Symbols.news),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
          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),
 | 
			
		||||
                child: const Icon(Symbols.box_edit),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
@@ -215,27 +228,91 @@ class _ExploreScreenState extends State<ExploreScreen>
 | 
			
		||||
            SliverOverlapAbsorber(
 | 
			
		||||
              handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
 | 
			
		||||
              sliver: SliverAppBar(
 | 
			
		||||
                leading: AutoAppBarLeading(),
 | 
			
		||||
                title: Text('screenExplore').tr(),
 | 
			
		||||
                leading:
 | 
			
		||||
                    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');
 | 
			
		||||
                      },
 | 
			
		||||
                    ),
 | 
			
		||||
                    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,
 | 
			
		||||
                snap: true,
 | 
			
		||||
                actions: [
 | 
			
		||||
                  IconButton(
 | 
			
		||||
                    icon: const Icon(Symbols.category),
 | 
			
		||||
                    onPressed: () {
 | 
			
		||||
                      showModalBottomSheet(
 | 
			
		||||
                        context: context,
 | 
			
		||||
                        builder: (context) => _PostCategoryPickerPopup(
 | 
			
		||||
                          categories: _categories,
 | 
			
		||||
                          selected: _selectedCategory,
 | 
			
		||||
                        ),
 | 
			
		||||
                      ).then((value) {
 | 
			
		||||
                        if (value != null && context.mounted) {
 | 
			
		||||
                          _selectedCategory = value == false ? null : value;
 | 
			
		||||
                          refreshPosts();
 | 
			
		||||
                        }
 | 
			
		||||
                      });
 | 
			
		||||
                    },
 | 
			
		||||
                    style: _showCategories
 | 
			
		||||
                        ? ButtonStyle(
 | 
			
		||||
                            foregroundColor: WidgetStateProperty.all(
 | 
			
		||||
                              Theme.of(context).colorScheme.primary,
 | 
			
		||||
                            ),
 | 
			
		||||
                            backgroundColor: MaterialStateProperty.all(
 | 
			
		||||
                              Theme.of(context).colorScheme.secondaryContainer,
 | 
			
		||||
                            ),
 | 
			
		||||
                          )
 | 
			
		||||
                        : null,
 | 
			
		||||
                    onPressed: cfg.mixedFeed
 | 
			
		||||
                        ? null
 | 
			
		||||
                        : () {
 | 
			
		||||
                            _toggleShowCategories();
 | 
			
		||||
                          },
 | 
			
		||||
                  ),
 | 
			
		||||
                  IconButton(
 | 
			
		||||
                    icon: const Icon(Symbols.search),
 | 
			
		||||
@@ -245,123 +322,84 @@ class _ExploreScreenState extends State<ExploreScreen>
 | 
			
		||||
                  ),
 | 
			
		||||
                  const Gap(8),
 | 
			
		||||
                ],
 | 
			
		||||
                bottom: TabBar(
 | 
			
		||||
                  controller: _tabController,
 | 
			
		||||
                  tabs: [
 | 
			
		||||
                    Tab(
 | 
			
		||||
                      child: Row(
 | 
			
		||||
                        mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                        crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                        children: [
 | 
			
		||||
                          Icon(Symbols.globe,
 | 
			
		||||
                              size: 20,
 | 
			
		||||
                              color: Theme.of(context)
 | 
			
		||||
                                  .appBarTheme
 | 
			
		||||
                                  .foregroundColor),
 | 
			
		||||
                          const Gap(8),
 | 
			
		||||
                          Flexible(
 | 
			
		||||
                            child: Text(
 | 
			
		||||
                              'postChannelGlobal',
 | 
			
		||||
                              maxLines: 1,
 | 
			
		||||
                            ).tr().textColor(
 | 
			
		||||
                                Theme.of(context).appBarTheme.foregroundColor),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ],
 | 
			
		||||
                bottom: cfg.mixedFeed
 | 
			
		||||
                    ? null
 | 
			
		||||
                    : TabBar(
 | 
			
		||||
                        isScrollable: _showCategories,
 | 
			
		||||
                        controller: _tabController,
 | 
			
		||||
                        tabs: _showCategories
 | 
			
		||||
                            ? [
 | 
			
		||||
                                for (final category in _categories)
 | 
			
		||||
                                  Tab(
 | 
			
		||||
                                    child: Row(
 | 
			
		||||
                                      mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                                      crossAxisAlignment:
 | 
			
		||||
                                          CrossAxisAlignment.center,
 | 
			
		||||
                                      children: [
 | 
			
		||||
                                        Icon(
 | 
			
		||||
                                          kCategoryIcons[category.alias] ??
 | 
			
		||||
                                              Symbols.question_mark,
 | 
			
		||||
                                          color: Theme.of(context)
 | 
			
		||||
                                              .appBarTheme
 | 
			
		||||
                                              .foregroundColor!,
 | 
			
		||||
                                        ),
 | 
			
		||||
                                        const Gap(8),
 | 
			
		||||
                                        Flexible(
 | 
			
		||||
                                          child: Text(
 | 
			
		||||
                                            'postCategory${category.alias.capitalize()}'
 | 
			
		||||
                                                    .trExists()
 | 
			
		||||
                                                ? 'postCategory${category.alias.capitalize()}'
 | 
			
		||||
                                                    .tr()
 | 
			
		||||
                                                : category.name,
 | 
			
		||||
                                            maxLines: 1,
 | 
			
		||||
                                          ).textColor(
 | 
			
		||||
                                            Theme.of(context)
 | 
			
		||||
                                                .appBarTheme
 | 
			
		||||
                                                .foregroundColor!,
 | 
			
		||||
                                          ),
 | 
			
		||||
                                        ),
 | 
			
		||||
                                      ],
 | 
			
		||||
                                    ),
 | 
			
		||||
                                  ),
 | 
			
		||||
                              ]
 | 
			
		||||
                            : [
 | 
			
		||||
                                for (final channel in kPostChannels)
 | 
			
		||||
                                  Tab(
 | 
			
		||||
                                    child: Row(
 | 
			
		||||
                                      mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                                      crossAxisAlignment:
 | 
			
		||||
                                          CrossAxisAlignment.center,
 | 
			
		||||
                                      children: [
 | 
			
		||||
                                        Icon(
 | 
			
		||||
                                          kPostChannelIcons[
 | 
			
		||||
                                              kPostChannels.indexOf(channel)],
 | 
			
		||||
                                          size: 20,
 | 
			
		||||
                                          color: Theme.of(context)
 | 
			
		||||
                                              .appBarTheme
 | 
			
		||||
                                              .foregroundColor,
 | 
			
		||||
                                        ),
 | 
			
		||||
                                        const Gap(8),
 | 
			
		||||
                                        Flexible(
 | 
			
		||||
                                          child: Text(
 | 
			
		||||
                                            'postChannel$channel',
 | 
			
		||||
                                            maxLines: 1,
 | 
			
		||||
                                          ).tr().textColor(
 | 
			
		||||
                                                Theme.of(context)
 | 
			
		||||
                                                    .appBarTheme
 | 
			
		||||
                                                    .foregroundColor,
 | 
			
		||||
                                              ),
 | 
			
		||||
                                        ),
 | 
			
		||||
                                      ],
 | 
			
		||||
                                    ),
 | 
			
		||||
                                  ),
 | 
			
		||||
                              ],
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                    Tab(
 | 
			
		||||
                      child: Row(
 | 
			
		||||
                        mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                        crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                        children: [
 | 
			
		||||
                          Icon(Symbols.group,
 | 
			
		||||
                              size: 20,
 | 
			
		||||
                              color: Theme.of(context)
 | 
			
		||||
                                  .appBarTheme
 | 
			
		||||
                                  .foregroundColor),
 | 
			
		||||
                          const Gap(8),
 | 
			
		||||
                          Flexible(
 | 
			
		||||
                            child: Text(
 | 
			
		||||
                              'postChannelFriends',
 | 
			
		||||
                              maxLines: 1,
 | 
			
		||||
                              textAlign: TextAlign.center,
 | 
			
		||||
                            ).tr().textColor(
 | 
			
		||||
                                Theme.of(context).appBarTheme.foregroundColor),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ],
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                    Tab(
 | 
			
		||||
                      child: Row(
 | 
			
		||||
                        mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                        crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                        children: [
 | 
			
		||||
                          Icon(Symbols.subscriptions,
 | 
			
		||||
                              size: 20,
 | 
			
		||||
                              color: Theme.of(context)
 | 
			
		||||
                                  .appBarTheme
 | 
			
		||||
                                  .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),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ],
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ];
 | 
			
		||||
        },
 | 
			
		||||
        body: TabBarView(
 | 
			
		||||
          controller: _tabController,
 | 
			
		||||
          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,
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        body: _PostListWidget(
 | 
			
		||||
          key: _listKey,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
@@ -369,15 +407,7 @@ class _ExploreScreenState extends State<ExploreScreen>
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostListWidget extends StatefulWidget {
 | 
			
		||||
  final String? channel;
 | 
			
		||||
  final bool withRealm;
 | 
			
		||||
  final Function onClearFilter;
 | 
			
		||||
 | 
			
		||||
  const _PostListWidget(
 | 
			
		||||
      {super.key,
 | 
			
		||||
      this.channel,
 | 
			
		||||
      this.withRealm = false,
 | 
			
		||||
      required this.onClearFilter});
 | 
			
		||||
  const _PostListWidget({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_PostListWidget> createState() => _PostListWidgetState();
 | 
			
		||||
@@ -386,62 +416,98 @@ class _PostListWidget extends StatefulWidget {
 | 
			
		||||
class _PostListWidgetState extends State<_PostListWidget> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
 | 
			
		||||
  final List<SnPost> _posts = List.empty(growable: true);
 | 
			
		||||
  final List<SnRealm> _realms = List.empty(growable: true);
 | 
			
		||||
  SnRealm? get realm => _selectedRealm;
 | 
			
		||||
 | 
			
		||||
  final List<SnFeedEntry> _feed = List.empty(growable: true);
 | 
			
		||||
  SnRealm? _selectedRealm;
 | 
			
		||||
  int? _postCount;
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchRealms() async {
 | 
			
		||||
    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;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  String? _selectedChannel;
 | 
			
		||||
  SnPostCategory? _selectedCategory;
 | 
			
		||||
  bool _hasLoadedAll = false;
 | 
			
		||||
 | 
			
		||||
  // Called when using regular feed
 | 
			
		||||
  Future<void> _fetchPosts() async {
 | 
			
		||||
    if (_postCount != null && _posts.length >= _postCount!) return;
 | 
			
		||||
    if (_hasLoadedAll) return;
 | 
			
		||||
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    final pt = context.read<SnPostContentProvider>();
 | 
			
		||||
    final result = await pt.listPosts(
 | 
			
		||||
      take: 10,
 | 
			
		||||
      offset: _posts.length,
 | 
			
		||||
      offset: _feed.length,
 | 
			
		||||
      categories: _selectedCategory != null ? [_selectedCategory!.alias] : null,
 | 
			
		||||
      channel: widget.channel,
 | 
			
		||||
      channel: _selectedChannel,
 | 
			
		||||
      realm: _selectedRealm?.alias,
 | 
			
		||||
    );
 | 
			
		||||
    final out = result.$1;
 | 
			
		||||
 | 
			
		||||
    if (!mounted) return;
 | 
			
		||||
 | 
			
		||||
    _postCount = result.$2;
 | 
			
		||||
    _posts.addAll(out);
 | 
			
		||||
    final postCount = result.$2;
 | 
			
		||||
    _feed.addAll(
 | 
			
		||||
      out.map((ele) => SnFeedEntry(
 | 
			
		||||
          type: 'interactive.post',
 | 
			
		||||
          data: ele.toJson(),
 | 
			
		||||
          createdAt: ele.createdAt)),
 | 
			
		||||
    );
 | 
			
		||||
    _hasLoadedAll = _feed.length >= postCount;
 | 
			
		||||
 | 
			
		||||
    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() {
 | 
			
		||||
    _postCount = null;
 | 
			
		||||
    _posts.clear();
 | 
			
		||||
    return _fetchPosts();
 | 
			
		||||
    _hasLoadedAll = false;
 | 
			
		||||
    _feed.clear();
 | 
			
		||||
    final cfg = context.read<ConfigProvider>();
 | 
			
		||||
    if (cfg.mixedFeed) {
 | 
			
		||||
      return _fetchFeed();
 | 
			
		||||
    } else {
 | 
			
		||||
      return _fetchPosts();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    if (widget.withRealm) {
 | 
			
		||||
      _fetchRealms().then((_) {
 | 
			
		||||
        _fetchPosts();
 | 
			
		||||
      });
 | 
			
		||||
    final cfg = context.read<ConfigProvider>();
 | 
			
		||||
    if (cfg.mixedFeed) {
 | 
			
		||||
      _fetchFeed();
 | 
			
		||||
    } else {
 | 
			
		||||
      _fetchPosts();
 | 
			
		||||
    }
 | 
			
		||||
@@ -449,178 +515,130 @@ class _PostListWidgetState extends State<_PostListWidget> {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Column(
 | 
			
		||||
      children: [
 | 
			
		||||
        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,
 | 
			
		||||
            removeTop: true,
 | 
			
		||||
            child: RefreshIndicator(
 | 
			
		||||
              displacement: 40 + MediaQuery.of(context).padding.top,
 | 
			
		||||
              onRefresh: () => refreshPosts(),
 | 
			
		||||
              child: InfiniteList(
 | 
			
		||||
                itemCount: _posts.length,
 | 
			
		||||
                isLoading: _isBusy,
 | 
			
		||||
                centerLoading: true,
 | 
			
		||||
                hasReachedMax:
 | 
			
		||||
                    _postCount != null && _posts.length >= _postCount!,
 | 
			
		||||
                onFetchData: _fetchPosts,
 | 
			
		||||
                itemBuilder: (context, idx) {
 | 
			
		||||
                  return OpenablePostItem(
 | 
			
		||||
                    data: _posts[idx],
 | 
			
		||||
                    maxWidth: 640,
 | 
			
		||||
                    onChanged: (data) {
 | 
			
		||||
                      setState(() => _posts[idx] = data);
 | 
			
		||||
                    },
 | 
			
		||||
                    onDeleted: () {
 | 
			
		||||
                      refreshPosts();
 | 
			
		||||
                    },
 | 
			
		||||
                  );
 | 
			
		||||
                },
 | 
			
		||||
                separatorBuilder: (_, __) => const Gap(8),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ).padding(top: 8),
 | 
			
		||||
    final cfg = context.watch<ConfigProvider>();
 | 
			
		||||
    return MediaQuery.removePadding(
 | 
			
		||||
      context: context,
 | 
			
		||||
      removeTop: true,
 | 
			
		||||
      child: RefreshIndicator(
 | 
			
		||||
        displacement: 40 + MediaQuery.of(context).padding.top,
 | 
			
		||||
        onRefresh: () => refreshPosts(),
 | 
			
		||||
        child: InfiniteList(
 | 
			
		||||
          padding: EdgeInsets.only(top: 8),
 | 
			
		||||
          itemCount: _feed.length,
 | 
			
		||||
          isLoading: _isBusy,
 | 
			
		||||
          centerLoading: true,
 | 
			
		||||
          hasReachedMax: _hasLoadedAll,
 | 
			
		||||
          onFetchData: cfg.mixedFeed ? _fetchFeed : _fetchPosts,
 | 
			
		||||
          itemBuilder: (context, idx) {
 | 
			
		||||
            final ele = _feed[idx];
 | 
			
		||||
            switch (ele.type) {
 | 
			
		||||
              case 'interactive.post':
 | 
			
		||||
                return OpenablePostItem(
 | 
			
		||||
                  data: SnPost.fromJson(ele.data),
 | 
			
		||||
                  maxWidth: 640,
 | 
			
		||||
                  onChanged: (data) {
 | 
			
		||||
                    setState(() {
 | 
			
		||||
                      _feed[idx] = _feed[idx].copyWith(data: data.toJson());
 | 
			
		||||
                    });
 | 
			
		||||
                  },
 | 
			
		||||
                  onDeleted: () {
 | 
			
		||||
                    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 Divider().padding(vertical: 2),
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostCategoryPickerPopup extends StatelessWidget {
 | 
			
		||||
  final List<SnPostCategory> categories;
 | 
			
		||||
  final SnPostCategory? selected;
 | 
			
		||||
class _PostListRealmPopup extends StatelessWidget {
 | 
			
		||||
  final List<SnRealm>? realms;
 | 
			
		||||
  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
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final cfg = context.watch<ConfigProvider>();
 | 
			
		||||
 | 
			
		||||
    return Column(
 | 
			
		||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
      children: [
 | 
			
		||||
        Row(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
          children: [
 | 
			
		||||
            const Icon(Symbols.category, size: 24),
 | 
			
		||||
            const Icon(Symbols.tune, size: 24),
 | 
			
		||||
            const Gap(16),
 | 
			
		||||
            Text('postCategory')
 | 
			
		||||
                .tr()
 | 
			
		||||
                .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
			
		||||
            Text('filterFeed', style: Theme.of(context).textTheme.titleLarge)
 | 
			
		||||
                .tr(),
 | 
			
		||||
          ],
 | 
			
		||||
        ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          leading: const Icon(Symbols.clear),
 | 
			
		||||
          title: Text('postFilterReset').tr(),
 | 
			
		||||
          subtitle: Text('postFilterResetDescription').tr(),
 | 
			
		||||
          contentPadding: const EdgeInsets.symmetric(horizontal: 20),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            Navigator.pop(context, false);
 | 
			
		||||
        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);
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        const Divider(height: 1),
 | 
			
		||||
        Expanded(
 | 
			
		||||
          child: GridView.count(
 | 
			
		||||
            crossAxisCount: 4,
 | 
			
		||||
            shrinkWrap: true,
 | 
			
		||||
            physics: const NeverScrollableScrollPhysics(),
 | 
			
		||||
            childAspectRatio: 1,
 | 
			
		||||
            children: categories
 | 
			
		||||
                .map(
 | 
			
		||||
                  (ele) => InkWell(
 | 
			
		||||
                    onTap: () {
 | 
			
		||||
                      _selectedCategory = ele;
 | 
			
		||||
                      Navigator.pop(context, ele);
 | 
			
		||||
                    },
 | 
			
		||||
                    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(),
 | 
			
		||||
        if (!cfg.mixedFeed)
 | 
			
		||||
          ListTile(
 | 
			
		||||
            leading: const Icon(Symbols.close),
 | 
			
		||||
            title: Text('postInGlobal').tr(),
 | 
			
		||||
            subtitle: Text('postViewInGlobalDescription').tr(),
 | 
			
		||||
            contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
            onTap: () {
 | 
			
		||||
              onUpdate.call(null);
 | 
			
		||||
              Navigator.pop(context);
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
        if (!cfg.mixedFeed) const Divider(height: 1),
 | 
			
		||||
        if (!cfg.mixedFeed)
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: ListView.builder(
 | 
			
		||||
              itemCount: realms?.length ?? 0,
 | 
			
		||||
              itemBuilder: (context, idx) {
 | 
			
		||||
                final realm = realms![idx];
 | 
			
		||||
                return ListTile(
 | 
			
		||||
                  title: Text(realm.name),
 | 
			
		||||
                  subtitle: Text('@${realm.alias}'),
 | 
			
		||||
                  leading: AccountImage(content: realm.avatar, radius: 18),
 | 
			
		||||
                  onTap: () {
 | 
			
		||||
                    onUpdate.call(realm);
 | 
			
		||||
                    Navigator.pop(context);
 | 
			
		||||
                  },
 | 
			
		||||
                );
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -201,7 +201,7 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
    if (!ua.isAuthorized) {
 | 
			
		||||
      return AppScaffold(
 | 
			
		||||
        appBar: AppBar(
 | 
			
		||||
          leading: AutoAppBarLeading(),
 | 
			
		||||
          leading: PageBackButton(),
 | 
			
		||||
          title: Text('screenFriend').tr(),
 | 
			
		||||
        ),
 | 
			
		||||
        body: Center(
 | 
			
		||||
@@ -254,7 +254,8 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
              trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
              onTap: _showBlocks,
 | 
			
		||||
            ),
 | 
			
		||||
          if (_requests.isNotEmpty || _blocks.isNotEmpty) const Divider(height: 1),
 | 
			
		||||
          if (_requests.isNotEmpty || _blocks.isNotEmpty)
 | 
			
		||||
            const Divider(height: 1),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: MediaQuery.removePadding(
 | 
			
		||||
              context: context,
 | 
			
		||||
@@ -270,7 +271,8 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
                    final relation = _relations[index];
 | 
			
		||||
                    final other = relation.related;
 | 
			
		||||
                    return ListTile(
 | 
			
		||||
                      contentPadding: const EdgeInsets.only(right: 24, left: 16),
 | 
			
		||||
                      contentPadding:
 | 
			
		||||
                          const EdgeInsets.only(right: 24, left: 16),
 | 
			
		||||
                      leading: AccountImage(content: other?.avatar),
 | 
			
		||||
                      title: Text(other?.nick ?? 'unknown'),
 | 
			
		||||
                      subtitle: Text(other?.nick ?? 'unknown'),
 | 
			
		||||
@@ -286,12 +288,16 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
                              mainAxisAlignment: MainAxisAlignment.end,
 | 
			
		||||
                              children: [
 | 
			
		||||
                                InkWell(
 | 
			
		||||
                                  onTap: _isUpdating ? null : () => _changeRelation(relation, 2),
 | 
			
		||||
                                  onTap: _isUpdating
 | 
			
		||||
                                      ? null
 | 
			
		||||
                                      : () => _changeRelation(relation, 2),
 | 
			
		||||
                                  child: Text('friendBlock').tr(),
 | 
			
		||||
                                ),
 | 
			
		||||
                                const Gap(8),
 | 
			
		||||
                                InkWell(
 | 
			
		||||
                                  onTap: _isUpdating ? null : () => _deleteRelation(relation),
 | 
			
		||||
                                  onTap: _isUpdating
 | 
			
		||||
                                      ? null
 | 
			
		||||
                                      : () => _deleteRelation(relation),
 | 
			
		||||
                                  child: Text('friendDeleteAction').tr(),
 | 
			
		||||
                                ),
 | 
			
		||||
                              ],
 | 
			
		||||
@@ -420,7 +426,9 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
 | 
			
		||||
              mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.end,
 | 
			
		||||
              children: [
 | 
			
		||||
                Text(kFriendStatus[relation.status] ?? 'unknown').tr().opacity(0.75),
 | 
			
		||||
                Text(kFriendStatus[relation.status] ?? 'unknown')
 | 
			
		||||
                    .tr()
 | 
			
		||||
                    .opacity(0.75),
 | 
			
		||||
                if (relation.status == 0)
 | 
			
		||||
                  Row(
 | 
			
		||||
                    mainAxisAlignment: MainAxisAlignment.end,
 | 
			
		||||
@@ -441,7 +449,8 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
 | 
			
		||||
                    mainAxisAlignment: MainAxisAlignment.end,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      InkWell(
 | 
			
		||||
                        onTap: _isBusy ? null : () => _changeRelation(relation, 1),
 | 
			
		||||
                        onTap:
 | 
			
		||||
                            _isBusy ? null : () => _changeRelation(relation, 1),
 | 
			
		||||
                        child: Text('friendUnblock').tr(),
 | 
			
		||||
                      ),
 | 
			
		||||
                      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:go_router/go_router.dart';
 | 
			
		||||
import 'package:google_fonts/google_fonts.dart';
 | 
			
		||||
import 'package:html/parser.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:provider/provider.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/userinfo.dart';
 | 
			
		||||
import 'package:surface/providers/widget.dart';
 | 
			
		||||
import 'package:surface/screens/captcha.dart';
 | 
			
		||||
import 'package:surface/types/check_in.dart';
 | 
			
		||||
import 'package:surface/types/news.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/widgets/app_bar_leading.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:surface/widgets/updater.dart';
 | 
			
		||||
import 'package:flutter_animate/flutter_animate.dart';
 | 
			
		||||
import 'package:url_launcher/url_launcher_string.dart';
 | 
			
		||||
 | 
			
		||||
class HomeScreenDashEntry {
 | 
			
		||||
  final String name;
 | 
			
		||||
@@ -66,7 +67,7 @@ class _HomeScreenState extends State<HomeScreen> {
 | 
			
		||||
    ),
 | 
			
		||||
    HomeScreenDashEntry(
 | 
			
		||||
      name: 'dashEntryTodayNews',
 | 
			
		||||
      child: _HomeDashTodayNews(),
 | 
			
		||||
      child: _HomeDashServiceStatus(),
 | 
			
		||||
      cols: MediaQuery.of(context).size.width >= 640 ? 3 : 2,
 | 
			
		||||
    ),
 | 
			
		||||
  ];
 | 
			
		||||
@@ -99,6 +100,7 @@ class _HomeScreenState extends State<HomeScreen> {
 | 
			
		||||
                        right: 8,
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                    _HomeDashUnconfirmedWidget().padding(horizontal: 8),
 | 
			
		||||
                    _HomeDashSpecialDayWidget().padding(horizontal: 8),
 | 
			
		||||
                    StaggeredGrid.extent(
 | 
			
		||||
                      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 {
 | 
			
		||||
  final EdgeInsets? padding;
 | 
			
		||||
 | 
			
		||||
@@ -131,7 +191,6 @@ class _HomeDashUpdateWidget extends StatelessWidget {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final config = context.watch<ConfigProvider>();
 | 
			
		||||
 | 
			
		||||
    return ListenableBuilder(
 | 
			
		||||
      listenable: config,
 | 
			
		||||
      builder: (context, _) {
 | 
			
		||||
@@ -245,21 +304,31 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _HomeDashTodayNews extends StatefulWidget {
 | 
			
		||||
  const _HomeDashTodayNews();
 | 
			
		||||
class _HomeDashServiceStatus extends StatefulWidget {
 | 
			
		||||
  const _HomeDashServiceStatus();
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_HomeDashTodayNews> createState() => _HomeDashTodayNewsState();
 | 
			
		||||
  State<_HomeDashServiceStatus> createState() => _HomeDashServiceStatusState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
 | 
			
		||||
  SnNewsArticle? _article;
 | 
			
		||||
class _HomeDashServiceStatusState extends State<_HomeDashServiceStatus> {
 | 
			
		||||
  Map<String, dynamic>? _statuses;
 | 
			
		||||
  ServiceStatus? _serviceStatus;
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchArticle() async {
 | 
			
		||||
  Future<void> _fetchStatuses() async {
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/re/news/today');
 | 
			
		||||
      _article = SnNewsArticle.fromJson(resp.data['data']);
 | 
			
		||||
      final resp = await sn.client.get('/directory/status');
 | 
			
		||||
      _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) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
@@ -272,7 +341,7 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
 | 
			
		||||
  @override
 | 
			
		||||
  initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _fetchArticle();
 | 
			
		||||
    _fetchStatuses();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -284,73 +353,127 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
 | 
			
		||||
        children: [
 | 
			
		||||
          Row(
 | 
			
		||||
            children: [
 | 
			
		||||
              const Icon(Symbols.newspaper),
 | 
			
		||||
              const Icon(Symbols.flare),
 | 
			
		||||
              const Gap(8),
 | 
			
		||||
              Text(
 | 
			
		||||
                'newsToday',
 | 
			
		||||
                style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
              ).tr()
 | 
			
		||||
            ],
 | 
			
		||||
          ).padding(horizontal: 18, top: 12, bottom: 8),
 | 
			
		||||
          if (_article != null)
 | 
			
		||||
            Expanded(
 | 
			
		||||
              child: InkWell(
 | 
			
		||||
                borderRadius: BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                child: Column(
 | 
			
		||||
                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                  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(
 | 
			
		||||
                      parse(_article!.description)
 | 
			
		||||
                          .children
 | 
			
		||||
                          .map((e) => e.text.trim())
 | 
			
		||||
                          .join(),
 | 
			
		||||
                      maxLines: 3,
 | 
			
		||||
                      overflow: TextOverflow.ellipsis,
 | 
			
		||||
                      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},
 | 
			
		||||
                  );
 | 
			
		||||
              Expanded(
 | 
			
		||||
                child: Text(
 | 
			
		||||
                  'serviceStatus',
 | 
			
		||||
                  style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
                ).tr(),
 | 
			
		||||
              ),
 | 
			
		||||
              IconButton(
 | 
			
		||||
                icon: const Icon(Symbols.launch, size: 20),
 | 
			
		||||
                visualDensity: VisualDensity(horizontal: -4, vertical: -4),
 | 
			
		||||
                constraints: const BoxConstraints(),
 | 
			
		||||
                padding: EdgeInsets.zero,
 | 
			
		||||
                onPressed: () {
 | 
			
		||||
                  launchUrlString('https://status.solsynth.dev');
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
            )
 | 
			
		||||
          else
 | 
			
		||||
            ],
 | 
			
		||||
          ).padding(horizontal: 18, top: 12, bottom: 8),
 | 
			
		||||
          Container(
 | 
			
		||||
            padding: EdgeInsets.symmetric(horizontal: 20, vertical: 6),
 | 
			
		||||
            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: [
 | 
			
		||||
                          const Icon(
 | 
			
		||||
                            Symbols.check,
 | 
			
		||||
                            size: 20,
 | 
			
		||||
                          ),
 | 
			
		||||
                          const Gap(10),
 | 
			
		||||
                          Text('serviceStatusOperational').tr(),
 | 
			
		||||
                        ],
 | 
			
		||||
                      ),
 | 
			
		||||
                    ServiceStatus.failed => Tooltip(
 | 
			
		||||
                        message: 'serviceStatusFailedDescription'.tr(),
 | 
			
		||||
                        child: Row(
 | 
			
		||||
                          children: [
 | 
			
		||||
                            const Icon(
 | 
			
		||||
                              Symbols.dangerous,
 | 
			
		||||
                              size: 20,
 | 
			
		||||
                            ),
 | 
			
		||||
                            const Gap(10),
 | 
			
		||||
                            Text('serviceStatusFailed').tr(),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                    _ => Row(
 | 
			
		||||
                        children: [
 | 
			
		||||
                          const Icon(
 | 
			
		||||
                            Symbols.error,
 | 
			
		||||
                            size: 20,
 | 
			
		||||
                          ),
 | 
			
		||||
                          const Gap(10),
 | 
			
		||||
                          Text('serviceStatusDowngraded').tr(),
 | 
			
		||||
                        ],
 | 
			
		||||
                      ),
 | 
			
		||||
                  },
 | 
			
		||||
          ),
 | 
			
		||||
          if (_statuses != null)
 | 
			
		||||
            Expanded(
 | 
			
		||||
              child: Center(
 | 
			
		||||
                child: CircularProgressIndicator(),
 | 
			
		||||
              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 +509,20 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _doCheckIn() async {
 | 
			
		||||
    final captchaTk = await Navigator.of(context, rootNavigator: true).push(
 | 
			
		||||
      MaterialPageRoute(
 | 
			
		||||
        builder: (context) => CaptchaScreen(),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    if (captchaTk == null) return;
 | 
			
		||||
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      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);
 | 
			
		||||
      await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson());
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
@@ -546,11 +678,26 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
 | 
			
		||||
                          '+${_todayRecord!.resultExperience} EXP',
 | 
			
		||||
                          style: Theme.of(context).textTheme.bodyLarge,
 | 
			
		||||
                        ),
 | 
			
		||||
                        if (_todayRecord!.resultCoin >= 0)
 | 
			
		||||
                        if (_todayRecord!.resultCoin > 0)
 | 
			
		||||
                          Text(
 | 
			
		||||
                            '+${_todayRecord!.resultCoin} ${'walletCurrencyShort'.tr()}',
 | 
			
		||||
                            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 +806,7 @@ class _HomeDashNotificationWidgetState
 | 
			
		||||
              child: IconButton(
 | 
			
		||||
                icon: const Icon(Symbols.arrow_right_alt),
 | 
			
		||||
                onPressed: () {
 | 
			
		||||
                  GoRouter.of(context).goNamed('notification');
 | 
			
		||||
                  GoRouter.of(context).pushNamed('notification');
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
@@ -743,8 +890,10 @@ class _HomeDashRecommendationPostWidgetState
 | 
			
		||||
                  ).tr(),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
              Text('${_currentPage + 1}/${_posts?.length ?? 0}',
 | 
			
		||||
                  style: GoogleFonts.robotoMono())
 | 
			
		||||
              Text(
 | 
			
		||||
                '${_currentPage + 1}/${_posts?.length ?? 0}',
 | 
			
		||||
                style: GoogleFonts.robotoMono(),
 | 
			
		||||
              )
 | 
			
		||||
            ],
 | 
			
		||||
          ).padding(horizontal: 18, top: 12, bottom: 8),
 | 
			
		||||
          Expanded(
 | 
			
		||||
@@ -762,6 +911,7 @@ class _HomeDashRecommendationPostWidgetState
 | 
			
		||||
                    child: PostItem(
 | 
			
		||||
                      data: _posts![index],
 | 
			
		||||
                      showMenu: false,
 | 
			
		||||
                      showFullPost: true,
 | 
			
		||||
                    ).padding(bottom: 8),
 | 
			
		||||
                    onTap: () {
 | 
			
		||||
                      GoRouter.of(context)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,18 +1,17 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/gestures.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:html/dom.dart' as dom;
 | 
			
		||||
import 'package:html/parser.dart';
 | 
			
		||||
import 'package:html2md/html2md.dart' as html2md;
 | 
			
		||||
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/news.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/markdown_content.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
import 'package:url_launcher/url_launcher_string.dart';
 | 
			
		||||
 | 
			
		||||
class NewsDetailScreen extends StatefulWidget {
 | 
			
		||||
@@ -26,14 +25,12 @@ class NewsDetailScreen extends StatefulWidget {
 | 
			
		||||
 | 
			
		||||
class _NewsDetailScreenState extends State<NewsDetailScreen> {
 | 
			
		||||
  SnNewsArticle? _article;
 | 
			
		||||
  dom.Document? _articleFragment;
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchArticle() async {
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/re/news/${widget.hash}');
 | 
			
		||||
      _article = SnNewsArticle.fromJson(resp.data);
 | 
			
		||||
      _articleFragment = parse(_article!.content);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      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
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
@@ -163,7 +62,9 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
 | 
			
		||||
          MaterialBanner(
 | 
			
		||||
            dividerColor: Colors.transparent,
 | 
			
		||||
            leading: const Icon(Icons.info),
 | 
			
		||||
            content: Text(_isReadingFromReader ? 'newsReadingFromReader'.tr() : 'newsReadingFromOriginal'.tr()),
 | 
			
		||||
            content: Text(_isReadingFromReader
 | 
			
		||||
                ? 'newsReadingFromReader'.tr()
 | 
			
		||||
                : 'newsReadingFromOriginal'.tr()),
 | 
			
		||||
            actions: [
 | 
			
		||||
              TextButton(
 | 
			
		||||
                child: Text('newsReadingProviderSwap').tr(),
 | 
			
		||||
@@ -173,7 +74,7 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
          if (_articleFragment != null && _isReadingFromReader)
 | 
			
		||||
          if (_article != null && _isReadingFromReader)
 | 
			
		||||
            Expanded(
 | 
			
		||||
              child: Container(
 | 
			
		||||
                constraints: BoxConstraints(maxWidth: 640),
 | 
			
		||||
@@ -182,28 +83,43 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
 | 
			
		||||
                    crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                    spacing: 8,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      Text(_article!.title, style: Theme.of(context).textTheme.titleLarge),
 | 
			
		||||
                      Text(_article!.title,
 | 
			
		||||
                          style: Theme.of(context).textTheme.titleLarge),
 | 
			
		||||
                      Builder(builder: (context) {
 | 
			
		||||
                        final htmlDescription = parse(_article!.description);
 | 
			
		||||
                        return Text(
 | 
			
		||||
                          htmlDescription.children.map((ele) => ele.text.trim()).join(),
 | 
			
		||||
                          htmlDescription.children
 | 
			
		||||
                              .map((ele) => ele.text.trim())
 | 
			
		||||
                              .join(),
 | 
			
		||||
                          style: Theme.of(context).textTheme.bodyMedium,
 | 
			
		||||
                        );
 | 
			
		||||
                      }),
 | 
			
		||||
                      Builder(builder: (context) {
 | 
			
		||||
                        final date = _article!.publishedAt ?? _article!.createdAt;
 | 
			
		||||
                        final date =
 | 
			
		||||
                            _article!.publishedAt ?? _article!.createdAt;
 | 
			
		||||
                        return Row(
 | 
			
		||||
                          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!),
 | 
			
		||||
                            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);
 | 
			
		||||
                      }),
 | 
			
		||||
                      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(),
 | 
			
		||||
                      ..._parseHtmlToWidgets(_articleFragment!.children),
 | 
			
		||||
                      MarkdownTextContent(
 | 
			
		||||
                        textScaler: TextScaler.linear(1.2),
 | 
			
		||||
                        content: html2md.convert(_article!.content),
 | 
			
		||||
                      ),
 | 
			
		||||
                      const Divider(),
 | 
			
		||||
                      InkWell(
 | 
			
		||||
                        child: Row(
 | 
			
		||||
@@ -211,7 +127,8 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
 | 
			
		||||
                          children: [
 | 
			
		||||
                            Text(
 | 
			
		||||
                              'Reference from original website',
 | 
			
		||||
                              style: TextStyle(decoration: TextDecoration.underline),
 | 
			
		||||
                              style: TextStyle(
 | 
			
		||||
                                  decoration: TextDecoration.underline),
 | 
			
		||||
                            ),
 | 
			
		||||
                            const Gap(4),
 | 
			
		||||
                            Icon(Icons.launch, size: 16),
 | 
			
		||||
 
 | 
			
		||||
@@ -63,7 +63,10 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
        queryParameters: {'take': 10, 'offset': _notifications.length},
 | 
			
		||||
      );
 | 
			
		||||
      _totalCount = resp.data['count'];
 | 
			
		||||
      _notifications.addAll(resp.data['data']?.map((e) => SnNotification.fromJson(e)).cast<SnNotification>() ?? []);
 | 
			
		||||
      _notifications.addAll(resp.data['data']
 | 
			
		||||
              ?.map((e) => SnNotification.fromJson(e))
 | 
			
		||||
              .cast<SnNotification>() ??
 | 
			
		||||
          []);
 | 
			
		||||
      nty.updateTray();
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
@@ -98,7 +101,8 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
      nty.clear();
 | 
			
		||||
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar('notificationMarkAllReadPrompt'.plural(resp.data['count']));
 | 
			
		||||
      context.showSnackbar(
 | 
			
		||||
          'notificationMarkAllReadPrompt'.plural(resp.data['count']));
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
@@ -122,7 +126,8 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
      _fetchNotifications();
 | 
			
		||||
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar('notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']));
 | 
			
		||||
      context.showSnackbar(
 | 
			
		||||
          'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']));
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
@@ -143,7 +148,10 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
 | 
			
		||||
    if (!ua.isAuthorized) {
 | 
			
		||||
      return AppScaffold(
 | 
			
		||||
        appBar: AppBar(leading: AutoAppBarLeading(), title: Text('screenNotification').tr()),
 | 
			
		||||
        appBar: AppBar(
 | 
			
		||||
          leading: PageBackButton(),
 | 
			
		||||
          title: Text('screenNotification').tr(),
 | 
			
		||||
        ),
 | 
			
		||||
        body: Center(child: UnauthorizedHint()),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
@@ -153,7 +161,9 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
        leading: AutoAppBarLeading(),
 | 
			
		||||
        title: Text('screenNotification').tr(),
 | 
			
		||||
        actions: [
 | 
			
		||||
          IconButton(icon: const Icon(Symbols.checklist), onPressed: _isSubmitting ? null : _markAllAsRead),
 | 
			
		||||
          IconButton(
 | 
			
		||||
              icon: const Icon(Symbols.checklist),
 | 
			
		||||
              onPressed: _isSubmitting ? null : _markAllAsRead),
 | 
			
		||||
          const Gap(8),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
@@ -167,13 +177,17 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
                return _fetchNotifications();
 | 
			
		||||
              },
 | 
			
		||||
              child: InfiniteList(
 | 
			
		||||
                padding: EdgeInsets.only(top: 16, bottom: math.max(MediaQuery.of(context).padding.bottom, 16)),
 | 
			
		||||
                padding: EdgeInsets.only(
 | 
			
		||||
                    top: 16,
 | 
			
		||||
                    bottom:
 | 
			
		||||
                        math.max(MediaQuery.of(context).padding.bottom, 16)),
 | 
			
		||||
                itemCount: _notifications.length,
 | 
			
		||||
                onFetchData: () {
 | 
			
		||||
                  _fetchNotifications();
 | 
			
		||||
                },
 | 
			
		||||
                isLoading: _isBusy,
 | 
			
		||||
                hasReachedMax: _totalCount != null && _notifications.length >= _totalCount!,
 | 
			
		||||
                hasReachedMax: _totalCount != null &&
 | 
			
		||||
                    _notifications.length >= _totalCount!,
 | 
			
		||||
                itemBuilder: (context, idx) {
 | 
			
		||||
                  final nty = _notifications[idx];
 | 
			
		||||
                  return Row(
 | 
			
		||||
@@ -186,12 +200,19 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
                          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            if (nty.readAt == null)
 | 
			
		||||
                              StyledWidget(Badge(label: Text('notificationUnread').tr())).padding(bottom: 4),
 | 
			
		||||
                            Text(nty.title, style: Theme.of(context).textTheme.titleMedium),
 | 
			
		||||
                              StyledWidget(Badge(
 | 
			
		||||
                                      label: Text('notificationUnread').tr()))
 | 
			
		||||
                                  .padding(bottom: 4),
 | 
			
		||||
                            Text(nty.title,
 | 
			
		||||
                                style: Theme.of(context).textTheme.titleMedium),
 | 
			
		||||
                            if (nty.subtitle != null)
 | 
			
		||||
                              Text(nty.subtitle!, style: Theme.of(context).textTheme.titleSmall),
 | 
			
		||||
                              Text(nty.subtitle!,
 | 
			
		||||
                                  style:
 | 
			
		||||
                                      Theme.of(context).textTheme.titleSmall),
 | 
			
		||||
                            if (nty.subtitle != null) const Gap(4),
 | 
			
		||||
                            SelectionArea(child: MarkdownTextContent(content: nty.body, isAutoWarp: true)),
 | 
			
		||||
                            SelectionArea(
 | 
			
		||||
                                child: MarkdownTextContent(
 | 
			
		||||
                                    content: nty.body, isAutoWarp: true)),
 | 
			
		||||
                            if ([
 | 
			
		||||
                                  'interactive.reply',
 | 
			
		||||
                                  'interactive.feedback',
 | 
			
		||||
@@ -201,31 +222,43 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
                              GestureDetector(
 | 
			
		||||
                                child: Container(
 | 
			
		||||
                                  decoration: BoxDecoration(
 | 
			
		||||
                                    borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                                    border: Border.all(color: Theme.of(context).dividerColor, width: 1),
 | 
			
		||||
                                    borderRadius: const BorderRadius.all(
 | 
			
		||||
                                        Radius.circular(8)),
 | 
			
		||||
                                    border: Border.all(
 | 
			
		||||
                                        color: Theme.of(context).dividerColor,
 | 
			
		||||
                                        width: 1),
 | 
			
		||||
                                  ),
 | 
			
		||||
                                  child: PostItem(
 | 
			
		||||
                                    data: SnPost.fromJson(nty.metadata['related_post']!),
 | 
			
		||||
                                    data: SnPost.fromJson(
 | 
			
		||||
                                        nty.metadata['related_post']!),
 | 
			
		||||
                                    showComments: false,
 | 
			
		||||
                                    showReactions: false,
 | 
			
		||||
                                    showMenu: false,
 | 
			
		||||
                                  ),
 | 
			
		||||
                                  ).padding(vertical: 4),
 | 
			
		||||
                                ),
 | 
			
		||||
                                onTap: () {
 | 
			
		||||
                                  GoRouter.of(context).pushNamed(
 | 
			
		||||
                                    'postDetail',
 | 
			
		||||
                                    pathParameters: {'slug': nty.metadata['related_post']!['id'].toString()},
 | 
			
		||||
                                    pathParameters: {
 | 
			
		||||
                                      'slug': nty
 | 
			
		||||
                                          .metadata['related_post']!['id']
 | 
			
		||||
                                          .toString()
 | 
			
		||||
                                    },
 | 
			
		||||
                                  );
 | 
			
		||||
                                },
 | 
			
		||||
                              ).padding(top: 8),
 | 
			
		||||
                            const Gap(8),
 | 
			
		||||
                            Row(
 | 
			
		||||
                              children: [
 | 
			
		||||
                                Text(DateFormat('yy/MM/dd').format(nty.createdAt)).fontSize(12),
 | 
			
		||||
                                Text(DateFormat('yy/MM/dd')
 | 
			
		||||
                                        .format(nty.createdAt))
 | 
			
		||||
                                    .fontSize(12),
 | 
			
		||||
                                const Gap(4),
 | 
			
		||||
                                Text('·', style: TextStyle(fontSize: 12)),
 | 
			
		||||
                                const Gap(4),
 | 
			
		||||
                                Text(RelativeTime(context).format(nty.createdAt)).fontSize(12),
 | 
			
		||||
                                Text(RelativeTime(context)
 | 
			
		||||
                                        .format(nty.createdAt))
 | 
			
		||||
                                    .fontSize(12),
 | 
			
		||||
                              ],
 | 
			
		||||
                            ).opacity(0.75),
 | 
			
		||||
                          ],
 | 
			
		||||
@@ -235,8 +268,10 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
                      IconButton(
 | 
			
		||||
                        icon: const Icon(Symbols.check),
 | 
			
		||||
                        padding: EdgeInsets.all(0),
 | 
			
		||||
                        visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
 | 
			
		||||
                        onPressed: _isSubmitting ? null : () => _markOneAsRead(nty),
 | 
			
		||||
                        visualDensity:
 | 
			
		||||
                            const VisualDensity(horizontal: -4, vertical: -4),
 | 
			
		||||
                        onPressed:
 | 
			
		||||
                            _isSubmitting ? null : () => _markOneAsRead(nty),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ).padding(horizontal: 16);
 | 
			
		||||
 
 | 
			
		||||
@@ -22,7 +22,8 @@ class PostDetailScreen extends StatefulWidget {
 | 
			
		||||
  final SnPost? preload;
 | 
			
		||||
  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
 | 
			
		||||
  State<PostDetailScreen> createState() => _PostDetailScreenState();
 | 
			
		||||
@@ -88,14 +89,16 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
 | 
			
		||||
                    TextSpan(
 | 
			
		||||
                      text: _data?.body['title'] ?? 'postNoun'.tr(),
 | 
			
		||||
                      style: Theme.of(context).textTheme.titleLarge!.copyWith(
 | 
			
		||||
                            color: Theme.of(context).appBarTheme.foregroundColor!,
 | 
			
		||||
                            color:
 | 
			
		||||
                                Theme.of(context).appBarTheme.foregroundColor!,
 | 
			
		||||
                          ),
 | 
			
		||||
                    ),
 | 
			
		||||
                    const TextSpan(text: '\n'),
 | 
			
		||||
                    TextSpan(
 | 
			
		||||
                      text: 'postDetail'.tr(),
 | 
			
		||||
                      style: Theme.of(context).textTheme.bodySmall!.copyWith(
 | 
			
		||||
                            color: Theme.of(context).appBarTheme.foregroundColor!,
 | 
			
		||||
                            color:
 | 
			
		||||
                                Theme.of(context).appBarTheme.foregroundColor!,
 | 
			
		||||
                          ),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ]),
 | 
			
		||||
@@ -124,8 +127,11 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            if (_data != null && _data!.type != 'video') const SliverToBoxAdapter(child: Divider(height: 1)),
 | 
			
		||||
            if (_data != null && _data!.type != 'video')
 | 
			
		||||
            if (_data != null)
 | 
			
		||||
              SliverToBoxAdapter(
 | 
			
		||||
                child: Divider(height: 1).padding(top: 8),
 | 
			
		||||
              ),
 | 
			
		||||
            if (_data != null)
 | 
			
		||||
              SliverToBoxAdapter(
 | 
			
		||||
                child: Container(
 | 
			
		||||
                  constraints: BoxConstraints(maxWidth: maxWidth),
 | 
			
		||||
@@ -141,7 +147,7 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
 | 
			
		||||
                  ).padding(horizontal: 20, vertical: 12).center(),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            if (_data != null && ua.isAuthorized && _data!.type != 'video')
 | 
			
		||||
            if (_data != null && ua.isAuthorized)
 | 
			
		||||
              SliverToBoxAdapter(
 | 
			
		||||
                child: PostCommentQuickAction(
 | 
			
		||||
                  parentPost: _data!,
 | 
			
		||||
@@ -158,13 +164,15 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            if (_data != null && _data!.type != 'video')
 | 
			
		||||
            if (_data != null) SliverGap(8),
 | 
			
		||||
            if (_data != null)
 | 
			
		||||
              PostCommentSliverList(
 | 
			
		||||
                key: _childListKey,
 | 
			
		||||
                parentPost: _data!,
 | 
			
		||||
                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/sn_attachment.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/post.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:uuid/uuid.dart';
 | 
			
		||||
 | 
			
		||||
import '../../providers/sn_realm.dart';
 | 
			
		||||
const kPostTypes = ['Story', 'Article', 'Question', 'Video'];
 | 
			
		||||
const kPostTypeAliases = ['stories', 'articles', 'questions', 'videos'];
 | 
			
		||||
 | 
			
		||||
class PostEditorExtra {
 | 
			
		||||
  final String? text;
 | 
			
		||||
  final String? title;
 | 
			
		||||
  final String? description;
 | 
			
		||||
  final List<PostWriteMedia>? attachments;
 | 
			
		||||
  final SnRealm? realm;
 | 
			
		||||
 | 
			
		||||
  const PostEditorExtra({
 | 
			
		||||
    this.text,
 | 
			
		||||
    this.title,
 | 
			
		||||
    this.description,
 | 
			
		||||
    this.attachments,
 | 
			
		||||
    this.realm,
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class PostEditorScreen extends StatefulWidget {
 | 
			
		||||
  final String mode;
 | 
			
		||||
  final String? mode;
 | 
			
		||||
  final int? postEditId;
 | 
			
		||||
  final int? postReplyId;
 | 
			
		||||
  final int? postRepostId;
 | 
			
		||||
@@ -72,7 +76,10 @@ class PostEditorScreen extends StatefulWidget {
 | 
			
		||||
  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(
 | 
			
		||||
    doLoadFromTemporary: widget.postEditId == null,
 | 
			
		||||
  );
 | 
			
		||||
@@ -133,6 +140,15 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
    ],
 | 
			
		||||
    scope: HotKeyScope.inapp,
 | 
			
		||||
  );
 | 
			
		||||
  final HotKey _saveDraftHotKey = HotKey(
 | 
			
		||||
    key: PhysicalKeyboardKey.keyS,
 | 
			
		||||
    modifiers: [
 | 
			
		||||
      (!kIsWeb && Platform.isMacOS)
 | 
			
		||||
          ? HotKeyModifier.meta
 | 
			
		||||
          : HotKeyModifier.control
 | 
			
		||||
    ],
 | 
			
		||||
    scope: HotKeyScope.inapp,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  void _registerHotKey() {
 | 
			
		||||
    if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
 | 
			
		||||
@@ -148,6 +164,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
      ]);
 | 
			
		||||
      setState(() {});
 | 
			
		||||
    });
 | 
			
		||||
    hotKeyManager.register(_saveDraftHotKey, keyDownHandler: (_) async {
 | 
			
		||||
      if (mounted) {
 | 
			
		||||
        _writeController.sendPost(context, saveAsDraft: true);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _showPublisherPopup() {
 | 
			
		||||
@@ -209,9 +230,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    _tabController.dispose();
 | 
			
		||||
    _writeController.dispose();
 | 
			
		||||
    if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) {
 | 
			
		||||
      hotKeyManager.unregister(_pasteHotKey);
 | 
			
		||||
      hotKeyManager.unregister(_saveDraftHotKey);
 | 
			
		||||
    }
 | 
			
		||||
    super.dispose();
 | 
			
		||||
  }
 | 
			
		||||
@@ -220,14 +243,16 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _registerHotKey();
 | 
			
		||||
    if (!PostWriteController.kTitleMap.keys.contains(widget.mode)) {
 | 
			
		||||
      context.showErrorDialog('Unknown post type');
 | 
			
		||||
      Navigator.pop(context);
 | 
			
		||||
    } else {
 | 
			
		||||
      _writeController.setMode(widget.mode);
 | 
			
		||||
    }
 | 
			
		||||
    _fetchRealms();
 | 
			
		||||
    _fetchPublishers();
 | 
			
		||||
    if (widget.mode != null) {
 | 
			
		||||
      _writeController.setMode(widget.mode!);
 | 
			
		||||
    }
 | 
			
		||||
    _tabController.addListener(() {
 | 
			
		||||
      if (_tabController.indexIsChanging) {
 | 
			
		||||
        _writeController.setMode(kPostTypeAliases[_tabController.index]);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    _writeController.fetchRelatedPost(
 | 
			
		||||
      context,
 | 
			
		||||
      editing: widget.postEditId,
 | 
			
		||||
@@ -240,6 +265,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
      _writeController.descriptionController.text =
 | 
			
		||||
          widget.extraProps!.description ?? '';
 | 
			
		||||
      _writeController.addAttachments(widget.extraProps!.attachments ?? []);
 | 
			
		||||
      _writeController.setRealm(widget.extraProps!.realm);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -255,38 +281,55 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                Navigator.pop(context);
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
            title: RichText(
 | 
			
		||||
              textAlign: TextAlign.center,
 | 
			
		||||
              text: TextSpan(children: [
 | 
			
		||||
                TextSpan(
 | 
			
		||||
                  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,
 | 
			
		||||
            title: Text(
 | 
			
		||||
              _writeController.title.isNotEmpty
 | 
			
		||||
                  ? _writeController.title
 | 
			
		||||
                  : 'untitled'.tr(),
 | 
			
		||||
            ),
 | 
			
		||||
            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(
 | 
			
		||||
                icon: const Icon(Symbols.tune),
 | 
			
		||||
                onPressed: _writeController.isBusy ? null : _updateMeta,
 | 
			
		||||
              ),
 | 
			
		||||
              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(
 | 
			
		||||
            children: [
 | 
			
		||||
              if (_writeController.editingPost != null)
 | 
			
		||||
              if (_writeController.editingPost != null &&
 | 
			
		||||
                  !_writeController.editingDraft)
 | 
			
		||||
                Container(
 | 
			
		||||
                  padding: const EdgeInsets.only(
 | 
			
		||||
                      top: 4, bottom: 4, left: 20, right: 20),
 | 
			
		||||
@@ -374,7 +417,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                  children: [
 | 
			
		||||
                    SingleChildScrollView(
 | 
			
		||||
                      padding: EdgeInsets.only(bottom: 160),
 | 
			
		||||
                      child: StyledWidget(switch (_writeController.mode) {
 | 
			
		||||
                      child: switch (_writeController.mode) {
 | 
			
		||||
                        'stories' => _PostStoryEditor(
 | 
			
		||||
                            controller: _writeController,
 | 
			
		||||
                            onTapPublisher: _showPublisherPopup,
 | 
			
		||||
@@ -396,8 +439,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                            onTapRealm: _showRealmPopup,
 | 
			
		||||
                          ),
 | 
			
		||||
                        _ => const Placeholder(),
 | 
			
		||||
                      })
 | 
			
		||||
                          .padding(top: 8),
 | 
			
		||||
                      },
 | 
			
		||||
                    ),
 | 
			
		||||
                    if (_writeController.attachments.isNotEmpty ||
 | 
			
		||||
                        _writeController.thumbnail != null)
 | 
			
		||||
@@ -720,7 +762,7 @@ class _PostStoryEditor extends StatelessWidget {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Container(
 | 
			
		||||
      padding: const EdgeInsets.symmetric(horizontal: 12),
 | 
			
		||||
      padding: const EdgeInsets.only(left: 12, right: 12, top: 8),
 | 
			
		||||
      constraints: const BoxConstraints(maxWidth: 640),
 | 
			
		||||
      child: Row(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
@@ -969,7 +1011,7 @@ class _PostQuestionEditor extends StatelessWidget {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Container(
 | 
			
		||||
      padding: const EdgeInsets.symmetric(horizontal: 12),
 | 
			
		||||
      padding: const EdgeInsets.only(left: 12, right: 12, top: 8),
 | 
			
		||||
      constraints: const BoxConstraints(maxWidth: 640),
 | 
			
		||||
      child: Row(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
@@ -1053,7 +1095,7 @@ class _PostQuestionEditor extends StatelessWidget {
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ).padding(top: 8),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1154,7 +1196,7 @@ class _PostVideoEditor extends StatelessWidget {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Container(
 | 
			
		||||
      padding: const EdgeInsets.symmetric(horizontal: 12),
 | 
			
		||||
      padding: const EdgeInsets.only(left: 12, right: 12, top: 8),
 | 
			
		||||
      constraints: const BoxConstraints(maxWidth: 640),
 | 
			
		||||
      child: Row(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
 
 | 
			
		||||
@@ -45,7 +45,8 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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;
 | 
			
		||||
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
@@ -152,7 +153,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
 | 
			
		||||
                },
 | 
			
		||||
              );
 | 
			
		||||
            },
 | 
			
		||||
            separatorBuilder: (_, __) => const Gap(8),
 | 
			
		||||
            separatorBuilder: (_, __) => const Divider().padding(vertical: 2),
 | 
			
		||||
          ),
 | 
			
		||||
          Positioned(
 | 
			
		||||
            top: 16,
 | 
			
		||||
@@ -166,7 +167,8 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
 | 
			
		||||
                  padding: const WidgetStatePropertyAll(
 | 
			
		||||
                    EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                  onTapOutside: (_) =>
 | 
			
		||||
                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                  onChanged: (value) {
 | 
			
		||||
                    _searchTerm = value;
 | 
			
		||||
                  },
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										132
									
								
								lib/screens/post/post_shuffle.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								lib/screens/post/post_shuffle.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,132 @@
 | 
			
		||||
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: OpenablePostItem(
 | 
			
		||||
                            key: ValueKey(ele),
 | 
			
		||||
                            data: ele,
 | 
			
		||||
                            maxWidth: 640,
 | 
			
		||||
                            onChanged: (ele) {
 | 
			
		||||
                              _posts[idx] = ele;
 | 
			
		||||
                              setState(() {});
 | 
			
		||||
                            },
 | 
			
		||||
                            onDeleted: () {
 | 
			
		||||
                              _fetchPosts();
 | 
			
		||||
                            },
 | 
			
		||||
                          ).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();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTickerProviderStateMixin {
 | 
			
		||||
class _PostPublisherScreenState extends State<PostPublisherScreen>
 | 
			
		||||
    with SingleTickerProviderStateMixin {
 | 
			
		||||
  late final ScrollController _scrollController = ScrollController();
 | 
			
		||||
  late final TabController _tabController = TabController(length: 3, vsync: this);
 | 
			
		||||
  late final TabController _tabController =
 | 
			
		||||
      TabController(length: 3, vsync: this);
 | 
			
		||||
 | 
			
		||||
  SnPublisher? _publisher;
 | 
			
		||||
  SnAccount? _account;
 | 
			
		||||
@@ -66,7 +68,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
 | 
			
		||||
      _account = await ud.getAccount(_publisher?.accountId);
 | 
			
		||||
      _accountRelationship = await rel.getRelationship(_account!.id);
 | 
			
		||||
      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);
 | 
			
		||||
      }
 | 
			
		||||
    } catch (_) {
 | 
			
		||||
@@ -133,12 +136,14 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
 | 
			
		||||
  double _appBarBlur = 0.0;
 | 
			
		||||
 | 
			
		||||
  late final _appBarWidth = MediaQuery.of(context).size.width;
 | 
			
		||||
  late final _appBarHeight = (_appBarWidth * kBannerAspectRatio).roundToDouble();
 | 
			
		||||
  late final _appBarHeight =
 | 
			
		||||
      (_appBarWidth * kBannerAspectRatio).roundToDouble();
 | 
			
		||||
 | 
			
		||||
  void _updateAppBarBlur() {
 | 
			
		||||
    if (_scrollController.offset > _appBarHeight) return;
 | 
			
		||||
    setState(() {
 | 
			
		||||
      _appBarBlur = (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
 | 
			
		||||
      _appBarBlur =
 | 
			
		||||
          (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -193,7 +198,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
 | 
			
		||||
        'related': _account!.name,
 | 
			
		||||
      });
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar('userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}']));
 | 
			
		||||
      context.showSnackbar(
 | 
			
		||||
          'userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}']));
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
@@ -209,9 +215,11 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final rel = context.read<SnRelationshipProvider>();
 | 
			
		||||
      await rel.updateRelationship(_account!.id, 1, _accountRelationship?.permNodes ?? {});
 | 
			
		||||
      await rel.updateRelationship(
 | 
			
		||||
          _account!.id, 1, _accountRelationship?.permNodes ?? {});
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar('userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}']));
 | 
			
		||||
      context.showSnackbar(
 | 
			
		||||
          'userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}']));
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
@@ -299,7 +307,10 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
 | 
			
		||||
                              text: TextSpan(children: [
 | 
			
		||||
                                TextSpan(
 | 
			
		||||
                                  text: _publisher!.nick,
 | 
			
		||||
                                  style: Theme.of(context).textTheme.titleLarge!.copyWith(
 | 
			
		||||
                                  style: Theme.of(context)
 | 
			
		||||
                                      .textTheme
 | 
			
		||||
                                      .titleLarge!
 | 
			
		||||
                                      .copyWith(
 | 
			
		||||
                                        color: Colors.white,
 | 
			
		||||
                                        shadows: labelShadows,
 | 
			
		||||
                                      ),
 | 
			
		||||
@@ -307,7 +318,10 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
 | 
			
		||||
                                const TextSpan(text: '\n'),
 | 
			
		||||
                                TextSpan(
 | 
			
		||||
                                  text: '@${_publisher!.name}',
 | 
			
		||||
                                  style: Theme.of(context).textTheme.bodySmall!.copyWith(
 | 
			
		||||
                                  style: Theme.of(context)
 | 
			
		||||
                                      .textTheme
 | 
			
		||||
                                      .bodySmall!
 | 
			
		||||
                                      .copyWith(
 | 
			
		||||
                                        color: Colors.white,
 | 
			
		||||
                                        shadows: labelShadows,
 | 
			
		||||
                                      ),
 | 
			
		||||
@@ -330,13 +344,16 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
 | 
			
		||||
                                  )
 | 
			
		||||
                                else
 | 
			
		||||
                                  Container(
 | 
			
		||||
                                    color: Theme.of(context).colorScheme.surfaceContainer,
 | 
			
		||||
                                    color: Theme.of(context)
 | 
			
		||||
                                        .colorScheme
 | 
			
		||||
                                        .surfaceContainer,
 | 
			
		||||
                                  ),
 | 
			
		||||
                                Positioned(
 | 
			
		||||
                                  top: 0,
 | 
			
		||||
                                  left: 0,
 | 
			
		||||
                                  right: 0,
 | 
			
		||||
                                  height: 56 + MediaQuery.of(context).padding.top,
 | 
			
		||||
                                  height:
 | 
			
		||||
                                      56 + MediaQuery.of(context).padding.top,
 | 
			
		||||
                                  child: ClipRect(
 | 
			
		||||
                                    child: BackdropFilter(
 | 
			
		||||
                                      filter: ImageFilter.blur(
 | 
			
		||||
@@ -345,7 +362,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
 | 
			
		||||
                                      ),
 | 
			
		||||
                                      child: Container(
 | 
			
		||||
                                        color: Colors.black.withOpacity(
 | 
			
		||||
                                          clampDouble(_appBarBlur * 0.1, 0, 0.5),
 | 
			
		||||
                                          clampDouble(
 | 
			
		||||
                                              _appBarBlur * 0.1, 0, 0.5),
 | 
			
		||||
                                        ),
 | 
			
		||||
                                      ),
 | 
			
		||||
                                    ),
 | 
			
		||||
@@ -372,11 +390,14 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
 | 
			
		||||
                                const Gap(16),
 | 
			
		||||
                                Expanded(
 | 
			
		||||
                                  child: Column(
 | 
			
		||||
                                    crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                                    crossAxisAlignment:
 | 
			
		||||
                                        CrossAxisAlignment.start,
 | 
			
		||||
                                    children: [
 | 
			
		||||
                                      Text(
 | 
			
		||||
                                        _publisher!.nick,
 | 
			
		||||
                                        style: Theme.of(context).textTheme.titleMedium,
 | 
			
		||||
                                        style: Theme.of(context)
 | 
			
		||||
                                            .textTheme
 | 
			
		||||
                                            .titleMedium,
 | 
			
		||||
                                      ).bold(),
 | 
			
		||||
                                      Text('@${_publisher!.name}').fontSize(13),
 | 
			
		||||
                                    ],
 | 
			
		||||
@@ -387,7 +408,9 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
 | 
			
		||||
                                    style: ButtonStyle(
 | 
			
		||||
                                      elevation: WidgetStatePropertyAll(0),
 | 
			
		||||
                                    ),
 | 
			
		||||
                                    onPressed: _isSubscribing ? null : _toggleSubscription,
 | 
			
		||||
                                    onPressed: _isSubscribing
 | 
			
		||||
                                        ? null
 | 
			
		||||
                                        : _toggleSubscription,
 | 
			
		||||
                                    label: Text('subscribe').tr(),
 | 
			
		||||
                                    icon: const Icon(Symbols.add),
 | 
			
		||||
                                  )
 | 
			
		||||
@@ -396,14 +419,17 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
 | 
			
		||||
                                    style: ButtonStyle(
 | 
			
		||||
                                      elevation: WidgetStatePropertyAll(0),
 | 
			
		||||
                                    ),
 | 
			
		||||
                                    onPressed: _isSubscribing ? null : _toggleSubscription,
 | 
			
		||||
                                    onPressed: _isSubscribing
 | 
			
		||||
                                        ? null
 | 
			
		||||
                                        : _toggleSubscription,
 | 
			
		||||
                                    label: Text('unsubscribe').tr(),
 | 
			
		||||
                                    icon: const Icon(Symbols.remove),
 | 
			
		||||
                                  ),
 | 
			
		||||
                                PopupMenuButton(
 | 
			
		||||
                                  padding: EdgeInsets.zero,
 | 
			
		||||
                                  style: ButtonStyle(
 | 
			
		||||
                                    visualDensity: VisualDensity(horizontal: -4, vertical: -4),
 | 
			
		||||
                                    visualDensity: VisualDensity(
 | 
			
		||||
                                        horizontal: -4, vertical: -4),
 | 
			
		||||
                                  ),
 | 
			
		||||
                                  itemBuilder: (BuildContext context) => [
 | 
			
		||||
                                    PopupMenuItem(
 | 
			
		||||
@@ -443,7 +469,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
 | 
			
		||||
                              ],
 | 
			
		||||
                            ),
 | 
			
		||||
                            const Gap(12),
 | 
			
		||||
                            Text(_publisher!.description).padding(horizontal: 8),
 | 
			
		||||
                            Text(_publisher!.description)
 | 
			
		||||
                                .padding(horizontal: 8),
 | 
			
		||||
                            const Gap(12),
 | 
			
		||||
                            Column(
 | 
			
		||||
                              children: [
 | 
			
		||||
@@ -451,8 +478,10 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
 | 
			
		||||
                                  children: [
 | 
			
		||||
                                    const Icon(Symbols.calendar_add_on),
 | 
			
		||||
                                    const Gap(8),
 | 
			
		||||
                                    Text('publisherJoinedAt')
 | 
			
		||||
                                        .tr(args: [DateFormat('y/M/d').format(_publisher!.createdAt)]),
 | 
			
		||||
                                    Text('publisherJoinedAt').tr(args: [
 | 
			
		||||
                                      DateFormat('y/M/d')
 | 
			
		||||
                                          .format(_publisher!.createdAt)
 | 
			
		||||
                                    ]),
 | 
			
		||||
                                  ],
 | 
			
		||||
                                ),
 | 
			
		||||
                                Row(
 | 
			
		||||
@@ -460,7 +489,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
 | 
			
		||||
                                    const Icon(Symbols.trending_up),
 | 
			
		||||
                                    const Gap(8),
 | 
			
		||||
                                    Text('publisherSocialPointTotal').plural(
 | 
			
		||||
                                      _publisher!.totalUpvote - _publisher!.totalDownvote,
 | 
			
		||||
                                      _publisher!.totalUpvote -
 | 
			
		||||
                                          _publisher!.totalDownvote,
 | 
			
		||||
                                    ),
 | 
			
		||||
                                  ],
 | 
			
		||||
                                ),
 | 
			
		||||
@@ -470,18 +500,22 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
 | 
			
		||||
                                      const Icon(Symbols.group_work),
 | 
			
		||||
                                      const Gap(8),
 | 
			
		||||
                                      InkWell(
 | 
			
		||||
                                        child: Text('publisherAffiliatedBy').tr(args: [
 | 
			
		||||
                                        child: Text('publisherAffiliatedBy')
 | 
			
		||||
                                            .tr(args: [
 | 
			
		||||
                                          '@${_realm?.alias ?? 'unknown'}',
 | 
			
		||||
                                        ]),
 | 
			
		||||
                                        onTap: () {
 | 
			
		||||
                                          GoRouter.of(context).pushNamed(
 | 
			
		||||
                                            'realmDetail',
 | 
			
		||||
                                            pathParameters: {'alias': _realm!.alias},
 | 
			
		||||
                                            pathParameters: {
 | 
			
		||||
                                              'alias': _realm!.alias
 | 
			
		||||
                                            },
 | 
			
		||||
                                          );
 | 
			
		||||
                                        },
 | 
			
		||||
                                      ),
 | 
			
		||||
                                      const Gap(8),
 | 
			
		||||
                                      AccountImage(content: _realm?.avatar, radius: 8),
 | 
			
		||||
                                      AccountImage(
 | 
			
		||||
                                          content: _realm?.avatar, radius: 8),
 | 
			
		||||
                                    ],
 | 
			
		||||
                                  ),
 | 
			
		||||
                                Row(
 | 
			
		||||
@@ -502,7 +536,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
 | 
			
		||||
                                      },
 | 
			
		||||
                                    ),
 | 
			
		||||
                                    const Gap(8),
 | 
			
		||||
                                    AccountImage(content: _account?.avatar, radius: 8),
 | 
			
		||||
                                    AccountImage(
 | 
			
		||||
                                        content: _account?.avatar, radius: 8),
 | 
			
		||||
                                  ],
 | 
			
		||||
                                ),
 | 
			
		||||
                              ],
 | 
			
		||||
@@ -606,7 +641,7 @@ class _PublisherPostList extends StatelessWidget {
 | 
			
		||||
          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),
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -318,7 +318,7 @@ class _RealmPostListWidgetState extends State<_RealmPostListWidget> {
 | 
			
		||||
              },
 | 
			
		||||
            );
 | 
			
		||||
          },
 | 
			
		||||
          separatorBuilder: (_, __) => const Gap(8),
 | 
			
		||||
          separatorBuilder: (_, __) => const Divider().padding(vertical: 2),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    ).padding(top: 8);
 | 
			
		||||
 
 | 
			
		||||
@@ -4,8 +4,10 @@ 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/channel.dart';
 | 
			
		||||
import 'package:surface/providers/config.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/sn_realm.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/types/chat.dart';
 | 
			
		||||
import 'package:surface/types/realm.dart';
 | 
			
		||||
@@ -57,7 +59,9 @@ class _RealmDiscoveryScreenState extends State<RealmDiscoveryScreen> {
 | 
			
		||||
        title: Text('screenRealmDiscovery').tr(),
 | 
			
		||||
        actions: [
 | 
			
		||||
          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: () {
 | 
			
		||||
              setState(() => _isCompactView = !_isCompactView);
 | 
			
		||||
              context.read<ConfigProvider>().realmCompactView = _isCompactView;
 | 
			
		||||
@@ -117,7 +121,8 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
 | 
			
		||||
    try {
 | 
			
		||||
      setState(() => _isBusy = true);
 | 
			
		||||
      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(
 | 
			
		||||
        resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(),
 | 
			
		||||
      );
 | 
			
		||||
@@ -135,10 +140,13 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
 | 
			
		||||
      setState(() => _isJoining = true);
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      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,
 | 
			
		||||
      });
 | 
			
		||||
      await _joinSelectedChannels();
 | 
			
		||||
      rel.addAvailableRealm(widget.realm);
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar('realmJoined'.tr(args: [widget.realm.name]));
 | 
			
		||||
      Navigator.pop(context);
 | 
			
		||||
@@ -156,13 +164,20 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
 | 
			
		||||
      try {
 | 
			
		||||
        final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
        final ua = context.read<UserProvider>();
 | 
			
		||||
        await sn.client.post('/cgi/im/channels/${widget.realm.alias}/$channel/members', data: {
 | 
			
		||||
          'related': ua.user?.name,
 | 
			
		||||
        });
 | 
			
		||||
        await sn.client.post(
 | 
			
		||||
            '/cgi/im/channels/${widget.realm.alias}/$channel/members',
 | 
			
		||||
            data: {
 | 
			
		||||
              'related': ua.user?.name,
 | 
			
		||||
            });
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        if (!mounted) return;
 | 
			
		||||
        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: [
 | 
			
		||||
            const Icon(Symbols.group_add, size: 24),
 | 
			
		||||
            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),
 | 
			
		||||
        Row(
 | 
			
		||||
@@ -216,7 +232,8 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
 | 
			
		||||
        Container(
 | 
			
		||||
          width: double.infinity,
 | 
			
		||||
          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),
 | 
			
		||||
        ),
 | 
			
		||||
        Expanded(
 | 
			
		||||
 
 | 
			
		||||
@@ -336,6 +336,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
			
		||||
                    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(() {});
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                ListTile(
 | 
			
		||||
                  leading: const Icon(Symbols.font_download),
 | 
			
		||||
                  title: Text('settingsCustomFonts').tr(),
 | 
			
		||||
@@ -387,6 +400,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
			
		||||
                    .fontSize(17)
 | 
			
		||||
                    .tr()
 | 
			
		||||
                    .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(
 | 
			
		||||
                  secondary: const Icon(Symbols.vibration),
 | 
			
		||||
                  contentPadding: const EdgeInsets.only(left: 24, right: 17),
 | 
			
		||||
 
 | 
			
		||||
@@ -61,7 +61,7 @@ class _AppSharingListenerState extends State<AppSharingListener> {
 | 
			
		||||
                      onTap: () {
 | 
			
		||||
                        GoRouter.of(context).pushNamed(
 | 
			
		||||
                          'postEditor',
 | 
			
		||||
                          pathParameters: {
 | 
			
		||||
                          queryParameters: {
 | 
			
		||||
                            'mode': 'stories',
 | 
			
		||||
                          },
 | 
			
		||||
                          extra: PostEditorExtra(
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,6 @@ import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/sn_sticker.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.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/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
@@ -134,7 +133,7 @@ class _StickerScreenState extends State<StickerScreen>
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: AutoAppBarLeading(),
 | 
			
		||||
        leading: PageBackButton(),
 | 
			
		||||
        title: Text('screenStickers').tr(),
 | 
			
		||||
        actions: [
 | 
			
		||||
          IconButton(
 | 
			
		||||
 
 | 
			
		||||
@@ -50,16 +50,17 @@ Future<ThemeData> createAppTheme(
 | 
			
		||||
      useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? true);
 | 
			
		||||
 | 
			
		||||
  final inUseFonts = (customFonts ?? prefs.getString(kAppCustomFonts))
 | 
			
		||||
      ?.split(',')
 | 
			
		||||
      .map((ele) => ele.trim())
 | 
			
		||||
      .toList();
 | 
			
		||||
          ?.split(',')
 | 
			
		||||
          .map((ele) => ele.trim())
 | 
			
		||||
          .toList() ??
 | 
			
		||||
      ['Nunito'];
 | 
			
		||||
 | 
			
		||||
  return ThemeData(
 | 
			
		||||
    useMaterial3: useM3,
 | 
			
		||||
    colorScheme: colorScheme,
 | 
			
		||||
    brightness: brightness,
 | 
			
		||||
    fontFamily: inUseFonts?.firstOrNull,
 | 
			
		||||
    fontFamilyFallback: inUseFonts?.sublist(1),
 | 
			
		||||
    fontFamily: inUseFonts.firstOrNull,
 | 
			
		||||
    fontFamilyFallback: inUseFonts.sublist(1),
 | 
			
		||||
    iconTheme: IconThemeData(
 | 
			
		||||
      fill: 0,
 | 
			
		||||
      weight: 400,
 | 
			
		||||
@@ -87,6 +88,8 @@ Future<ThemeData> createAppTheme(
 | 
			
		||||
        TargetPlatform.windows: ZoomPageTransitionsBuilder(),
 | 
			
		||||
      },
 | 
			
		||||
    ),
 | 
			
		||||
    progressIndicatorTheme: ProgressIndicatorThemeData(year2023: false),
 | 
			
		||||
    sliderTheme: SliderThemeData(year2023: false),
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -119,13 +119,33 @@ abstract class SnAccountStatusInfo with _$SnAccountStatusInfo {
 | 
			
		||||
    required bool isDisturbable,
 | 
			
		||||
    required bool isOnline,
 | 
			
		||||
    required DateTime? lastSeenAt,
 | 
			
		||||
    required dynamic status,
 | 
			
		||||
    required SnAccountStatus? status,
 | 
			
		||||
  }) = _SnAccountStatusInfo;
 | 
			
		||||
 | 
			
		||||
  factory SnAccountStatusInfo.fromJson(Map<String, Object?> json) =>
 | 
			
		||||
      _$SnAccountStatusInfoFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
abstract class SnAccountStatus with _$SnAccountStatus {
 | 
			
		||||
  const factory SnAccountStatus({
 | 
			
		||||
    required int id,
 | 
			
		||||
    required DateTime createdAt,
 | 
			
		||||
    required DateTime updatedAt,
 | 
			
		||||
    required DateTime? deletedAt,
 | 
			
		||||
    required String type,
 | 
			
		||||
    required String label,
 | 
			
		||||
    required int attitude,
 | 
			
		||||
    required bool isNoDisturb,
 | 
			
		||||
    required bool isInvisible,
 | 
			
		||||
    required DateTime? clearAt,
 | 
			
		||||
    required int accountId,
 | 
			
		||||
  }) = _SnAccountStatus;
 | 
			
		||||
 | 
			
		||||
  factory SnAccountStatus.fromJson(Map<String, Object?> json) =>
 | 
			
		||||
      _$SnAccountStatusFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
abstract class SnAbuseReport with _$SnAbuseReport {
 | 
			
		||||
  const factory SnAbuseReport({
 | 
			
		||||
@@ -142,3 +162,25 @@ abstract class SnAbuseReport with _$SnAbuseReport {
 | 
			
		||||
  factory SnAbuseReport.fromJson(Map<String, Object?> json) =>
 | 
			
		||||
      _$SnAbuseReportFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
abstract class SnActionEvent with _$SnActionEvent {
 | 
			
		||||
  const factory SnActionEvent({
 | 
			
		||||
    required int id,
 | 
			
		||||
    required DateTime createdAt,
 | 
			
		||||
    required DateTime updatedAt,
 | 
			
		||||
    required DateTime? deletedAt,
 | 
			
		||||
    required String type,
 | 
			
		||||
    required Map<String, dynamic>? metadata,
 | 
			
		||||
    required String? location,
 | 
			
		||||
    required double? coordinateX,
 | 
			
		||||
    required double? coordinateY,
 | 
			
		||||
    required String ipAddress,
 | 
			
		||||
    required String userAgent,
 | 
			
		||||
    required SnAccount account,
 | 
			
		||||
    required int accountId,
 | 
			
		||||
  }) = _SnActionEvent;
 | 
			
		||||
 | 
			
		||||
  factory SnActionEvent.fromJson(Map<String, Object?> json) =>
 | 
			
		||||
      _$SnActionEventFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -2139,7 +2139,7 @@ mixin _$SnAccountStatusInfo {
 | 
			
		||||
  bool get isDisturbable;
 | 
			
		||||
  bool get isOnline;
 | 
			
		||||
  DateTime? get lastSeenAt;
 | 
			
		||||
  dynamic get status;
 | 
			
		||||
  SnAccountStatus? get status;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnAccountStatusInfo
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
@@ -2163,13 +2163,13 @@ mixin _$SnAccountStatusInfo {
 | 
			
		||||
                other.isOnline == isOnline) &&
 | 
			
		||||
            (identical(other.lastSeenAt, lastSeenAt) ||
 | 
			
		||||
                other.lastSeenAt == lastSeenAt) &&
 | 
			
		||||
            const DeepCollectionEquality().equals(other.status, status));
 | 
			
		||||
            (identical(other.status, status) || other.status == status));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => Object.hash(runtimeType, isDisturbable, isOnline,
 | 
			
		||||
      lastSeenAt, const DeepCollectionEquality().hash(status));
 | 
			
		||||
  int get hashCode =>
 | 
			
		||||
      Object.hash(runtimeType, isDisturbable, isOnline, lastSeenAt, status);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
@@ -2187,7 +2187,9 @@ abstract mixin class $SnAccountStatusInfoCopyWith<$Res> {
 | 
			
		||||
      {bool isDisturbable,
 | 
			
		||||
      bool isOnline,
 | 
			
		||||
      DateTime? lastSeenAt,
 | 
			
		||||
      dynamic status});
 | 
			
		||||
      SnAccountStatus? status});
 | 
			
		||||
 | 
			
		||||
  $SnAccountStatusCopyWith<$Res>? get status;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@@ -2224,9 +2226,23 @@ class _$SnAccountStatusInfoCopyWithImpl<$Res>
 | 
			
		||||
      status: freezed == status
 | 
			
		||||
          ? _self.status
 | 
			
		||||
          : status // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as dynamic,
 | 
			
		||||
              as SnAccountStatus?,
 | 
			
		||||
    ));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnAccountStatusInfo
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  $SnAccountStatusCopyWith<$Res>? get status {
 | 
			
		||||
    if (_self.status == null) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return $SnAccountStatusCopyWith<$Res>(_self.status!, (value) {
 | 
			
		||||
      return _then(_self.copyWith(status: value));
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@@ -2247,7 +2263,7 @@ class _SnAccountStatusInfo implements SnAccountStatusInfo {
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime? lastSeenAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final dynamic status;
 | 
			
		||||
  final SnAccountStatus? status;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnAccountStatusInfo
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
@@ -2276,13 +2292,13 @@ class _SnAccountStatusInfo implements SnAccountStatusInfo {
 | 
			
		||||
                other.isOnline == isOnline) &&
 | 
			
		||||
            (identical(other.lastSeenAt, lastSeenAt) ||
 | 
			
		||||
                other.lastSeenAt == lastSeenAt) &&
 | 
			
		||||
            const DeepCollectionEquality().equals(other.status, status));
 | 
			
		||||
            (identical(other.status, status) || other.status == status));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => Object.hash(runtimeType, isDisturbable, isOnline,
 | 
			
		||||
      lastSeenAt, const DeepCollectionEquality().hash(status));
 | 
			
		||||
  int get hashCode =>
 | 
			
		||||
      Object.hash(runtimeType, isDisturbable, isOnline, lastSeenAt, status);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
@@ -2302,7 +2318,10 @@ abstract mixin class _$SnAccountStatusInfoCopyWith<$Res>
 | 
			
		||||
      {bool isDisturbable,
 | 
			
		||||
      bool isOnline,
 | 
			
		||||
      DateTime? lastSeenAt,
 | 
			
		||||
      dynamic status});
 | 
			
		||||
      SnAccountStatus? status});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  $SnAccountStatusCopyWith<$Res>? get status;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@@ -2339,7 +2358,386 @@ class __$SnAccountStatusInfoCopyWithImpl<$Res>
 | 
			
		||||
      status: freezed == status
 | 
			
		||||
          ? _self.status
 | 
			
		||||
          : status // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as dynamic,
 | 
			
		||||
              as SnAccountStatus?,
 | 
			
		||||
    ));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnAccountStatusInfo
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  $SnAccountStatusCopyWith<$Res>? get status {
 | 
			
		||||
    if (_self.status == null) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return $SnAccountStatusCopyWith<$Res>(_self.status!, (value) {
 | 
			
		||||
      return _then(_self.copyWith(status: value));
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
mixin _$SnAccountStatus {
 | 
			
		||||
  int get id;
 | 
			
		||||
  DateTime get createdAt;
 | 
			
		||||
  DateTime get updatedAt;
 | 
			
		||||
  DateTime? get deletedAt;
 | 
			
		||||
  String get type;
 | 
			
		||||
  String get label;
 | 
			
		||||
  int get attitude;
 | 
			
		||||
  bool get isNoDisturb;
 | 
			
		||||
  bool get isInvisible;
 | 
			
		||||
  DateTime? get clearAt;
 | 
			
		||||
  int get accountId;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnAccountStatus
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  $SnAccountStatusCopyWith<SnAccountStatus> get copyWith =>
 | 
			
		||||
      _$SnAccountStatusCopyWithImpl<SnAccountStatus>(
 | 
			
		||||
          this as SnAccountStatus, _$identity);
 | 
			
		||||
 | 
			
		||||
  /// Serializes this SnAccountStatus to a JSON map.
 | 
			
		||||
  Map<String, dynamic> toJson();
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    return identical(this, other) ||
 | 
			
		||||
        (other.runtimeType == runtimeType &&
 | 
			
		||||
            other is SnAccountStatus &&
 | 
			
		||||
            (identical(other.id, id) || other.id == id) &&
 | 
			
		||||
            (identical(other.createdAt, createdAt) ||
 | 
			
		||||
                other.createdAt == createdAt) &&
 | 
			
		||||
            (identical(other.updatedAt, updatedAt) ||
 | 
			
		||||
                other.updatedAt == updatedAt) &&
 | 
			
		||||
            (identical(other.deletedAt, deletedAt) ||
 | 
			
		||||
                other.deletedAt == deletedAt) &&
 | 
			
		||||
            (identical(other.type, type) || other.type == type) &&
 | 
			
		||||
            (identical(other.label, label) || other.label == label) &&
 | 
			
		||||
            (identical(other.attitude, attitude) ||
 | 
			
		||||
                other.attitude == attitude) &&
 | 
			
		||||
            (identical(other.isNoDisturb, isNoDisturb) ||
 | 
			
		||||
                other.isNoDisturb == isNoDisturb) &&
 | 
			
		||||
            (identical(other.isInvisible, isInvisible) ||
 | 
			
		||||
                other.isInvisible == isInvisible) &&
 | 
			
		||||
            (identical(other.clearAt, clearAt) || other.clearAt == clearAt) &&
 | 
			
		||||
            (identical(other.accountId, accountId) ||
 | 
			
		||||
                other.accountId == accountId));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => Object.hash(
 | 
			
		||||
      runtimeType,
 | 
			
		||||
      id,
 | 
			
		||||
      createdAt,
 | 
			
		||||
      updatedAt,
 | 
			
		||||
      deletedAt,
 | 
			
		||||
      type,
 | 
			
		||||
      label,
 | 
			
		||||
      attitude,
 | 
			
		||||
      isNoDisturb,
 | 
			
		||||
      isInvisible,
 | 
			
		||||
      clearAt,
 | 
			
		||||
      accountId);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnAccountStatus(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, label: $label, attitude: $attitude, isNoDisturb: $isNoDisturb, isInvisible: $isInvisible, clearAt: $clearAt, accountId: $accountId)';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract mixin class $SnAccountStatusCopyWith<$Res> {
 | 
			
		||||
  factory $SnAccountStatusCopyWith(
 | 
			
		||||
          SnAccountStatus value, $Res Function(SnAccountStatus) _then) =
 | 
			
		||||
      _$SnAccountStatusCopyWithImpl;
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {int id,
 | 
			
		||||
      DateTime createdAt,
 | 
			
		||||
      DateTime updatedAt,
 | 
			
		||||
      DateTime? deletedAt,
 | 
			
		||||
      String type,
 | 
			
		||||
      String label,
 | 
			
		||||
      int attitude,
 | 
			
		||||
      bool isNoDisturb,
 | 
			
		||||
      bool isInvisible,
 | 
			
		||||
      DateTime? clearAt,
 | 
			
		||||
      int accountId});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class _$SnAccountStatusCopyWithImpl<$Res>
 | 
			
		||||
    implements $SnAccountStatusCopyWith<$Res> {
 | 
			
		||||
  _$SnAccountStatusCopyWithImpl(this._self, this._then);
 | 
			
		||||
 | 
			
		||||
  final SnAccountStatus _self;
 | 
			
		||||
  final $Res Function(SnAccountStatus) _then;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnAccountStatus
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  @override
 | 
			
		||||
  $Res call({
 | 
			
		||||
    Object? id = null,
 | 
			
		||||
    Object? createdAt = null,
 | 
			
		||||
    Object? updatedAt = null,
 | 
			
		||||
    Object? deletedAt = freezed,
 | 
			
		||||
    Object? type = null,
 | 
			
		||||
    Object? label = null,
 | 
			
		||||
    Object? attitude = null,
 | 
			
		||||
    Object? isNoDisturb = null,
 | 
			
		||||
    Object? isInvisible = null,
 | 
			
		||||
    Object? clearAt = freezed,
 | 
			
		||||
    Object? accountId = null,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_self.copyWith(
 | 
			
		||||
      id: null == id
 | 
			
		||||
          ? _self.id
 | 
			
		||||
          : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      createdAt: null == createdAt
 | 
			
		||||
          ? _self.createdAt
 | 
			
		||||
          : createdAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      updatedAt: null == updatedAt
 | 
			
		||||
          ? _self.updatedAt
 | 
			
		||||
          : updatedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      deletedAt: freezed == deletedAt
 | 
			
		||||
          ? _self.deletedAt
 | 
			
		||||
          : deletedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime?,
 | 
			
		||||
      type: null == type
 | 
			
		||||
          ? _self.type
 | 
			
		||||
          : type // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      label: null == label
 | 
			
		||||
          ? _self.label
 | 
			
		||||
          : label // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      attitude: null == attitude
 | 
			
		||||
          ? _self.attitude
 | 
			
		||||
          : attitude // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      isNoDisturb: null == isNoDisturb
 | 
			
		||||
          ? _self.isNoDisturb
 | 
			
		||||
          : isNoDisturb // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as bool,
 | 
			
		||||
      isInvisible: null == isInvisible
 | 
			
		||||
          ? _self.isInvisible
 | 
			
		||||
          : isInvisible // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as bool,
 | 
			
		||||
      clearAt: freezed == clearAt
 | 
			
		||||
          ? _self.clearAt
 | 
			
		||||
          : clearAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime?,
 | 
			
		||||
      accountId: null == accountId
 | 
			
		||||
          ? _self.accountId
 | 
			
		||||
          : accountId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
    ));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@JsonSerializable()
 | 
			
		||||
class _SnAccountStatus implements SnAccountStatus {
 | 
			
		||||
  const _SnAccountStatus(
 | 
			
		||||
      {required this.id,
 | 
			
		||||
      required this.createdAt,
 | 
			
		||||
      required this.updatedAt,
 | 
			
		||||
      required this.deletedAt,
 | 
			
		||||
      required this.type,
 | 
			
		||||
      required this.label,
 | 
			
		||||
      required this.attitude,
 | 
			
		||||
      required this.isNoDisturb,
 | 
			
		||||
      required this.isInvisible,
 | 
			
		||||
      required this.clearAt,
 | 
			
		||||
      required this.accountId});
 | 
			
		||||
  factory _SnAccountStatus.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$SnAccountStatusFromJson(json);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  final int id;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime createdAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime updatedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime? deletedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final String type;
 | 
			
		||||
  @override
 | 
			
		||||
  final String label;
 | 
			
		||||
  @override
 | 
			
		||||
  final int attitude;
 | 
			
		||||
  @override
 | 
			
		||||
  final bool isNoDisturb;
 | 
			
		||||
  @override
 | 
			
		||||
  final bool isInvisible;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime? clearAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final int accountId;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnAccountStatus
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  _$SnAccountStatusCopyWith<_SnAccountStatus> get copyWith =>
 | 
			
		||||
      __$SnAccountStatusCopyWithImpl<_SnAccountStatus>(this, _$identity);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    return _$SnAccountStatusToJson(
 | 
			
		||||
      this,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    return identical(this, other) ||
 | 
			
		||||
        (other.runtimeType == runtimeType &&
 | 
			
		||||
            other is _SnAccountStatus &&
 | 
			
		||||
            (identical(other.id, id) || other.id == id) &&
 | 
			
		||||
            (identical(other.createdAt, createdAt) ||
 | 
			
		||||
                other.createdAt == createdAt) &&
 | 
			
		||||
            (identical(other.updatedAt, updatedAt) ||
 | 
			
		||||
                other.updatedAt == updatedAt) &&
 | 
			
		||||
            (identical(other.deletedAt, deletedAt) ||
 | 
			
		||||
                other.deletedAt == deletedAt) &&
 | 
			
		||||
            (identical(other.type, type) || other.type == type) &&
 | 
			
		||||
            (identical(other.label, label) || other.label == label) &&
 | 
			
		||||
            (identical(other.attitude, attitude) ||
 | 
			
		||||
                other.attitude == attitude) &&
 | 
			
		||||
            (identical(other.isNoDisturb, isNoDisturb) ||
 | 
			
		||||
                other.isNoDisturb == isNoDisturb) &&
 | 
			
		||||
            (identical(other.isInvisible, isInvisible) ||
 | 
			
		||||
                other.isInvisible == isInvisible) &&
 | 
			
		||||
            (identical(other.clearAt, clearAt) || other.clearAt == clearAt) &&
 | 
			
		||||
            (identical(other.accountId, accountId) ||
 | 
			
		||||
                other.accountId == accountId));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => Object.hash(
 | 
			
		||||
      runtimeType,
 | 
			
		||||
      id,
 | 
			
		||||
      createdAt,
 | 
			
		||||
      updatedAt,
 | 
			
		||||
      deletedAt,
 | 
			
		||||
      type,
 | 
			
		||||
      label,
 | 
			
		||||
      attitude,
 | 
			
		||||
      isNoDisturb,
 | 
			
		||||
      isInvisible,
 | 
			
		||||
      clearAt,
 | 
			
		||||
      accountId);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnAccountStatus(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, label: $label, attitude: $attitude, isNoDisturb: $isNoDisturb, isInvisible: $isInvisible, clearAt: $clearAt, accountId: $accountId)';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract mixin class _$SnAccountStatusCopyWith<$Res>
 | 
			
		||||
    implements $SnAccountStatusCopyWith<$Res> {
 | 
			
		||||
  factory _$SnAccountStatusCopyWith(
 | 
			
		||||
          _SnAccountStatus value, $Res Function(_SnAccountStatus) _then) =
 | 
			
		||||
      __$SnAccountStatusCopyWithImpl;
 | 
			
		||||
  @override
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {int id,
 | 
			
		||||
      DateTime createdAt,
 | 
			
		||||
      DateTime updatedAt,
 | 
			
		||||
      DateTime? deletedAt,
 | 
			
		||||
      String type,
 | 
			
		||||
      String label,
 | 
			
		||||
      int attitude,
 | 
			
		||||
      bool isNoDisturb,
 | 
			
		||||
      bool isInvisible,
 | 
			
		||||
      DateTime? clearAt,
 | 
			
		||||
      int accountId});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class __$SnAccountStatusCopyWithImpl<$Res>
 | 
			
		||||
    implements _$SnAccountStatusCopyWith<$Res> {
 | 
			
		||||
  __$SnAccountStatusCopyWithImpl(this._self, this._then);
 | 
			
		||||
 | 
			
		||||
  final _SnAccountStatus _self;
 | 
			
		||||
  final $Res Function(_SnAccountStatus) _then;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnAccountStatus
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  $Res call({
 | 
			
		||||
    Object? id = null,
 | 
			
		||||
    Object? createdAt = null,
 | 
			
		||||
    Object? updatedAt = null,
 | 
			
		||||
    Object? deletedAt = freezed,
 | 
			
		||||
    Object? type = null,
 | 
			
		||||
    Object? label = null,
 | 
			
		||||
    Object? attitude = null,
 | 
			
		||||
    Object? isNoDisturb = null,
 | 
			
		||||
    Object? isInvisible = null,
 | 
			
		||||
    Object? clearAt = freezed,
 | 
			
		||||
    Object? accountId = null,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_SnAccountStatus(
 | 
			
		||||
      id: null == id
 | 
			
		||||
          ? _self.id
 | 
			
		||||
          : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      createdAt: null == createdAt
 | 
			
		||||
          ? _self.createdAt
 | 
			
		||||
          : createdAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      updatedAt: null == updatedAt
 | 
			
		||||
          ? _self.updatedAt
 | 
			
		||||
          : updatedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      deletedAt: freezed == deletedAt
 | 
			
		||||
          ? _self.deletedAt
 | 
			
		||||
          : deletedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime?,
 | 
			
		||||
      type: null == type
 | 
			
		||||
          ? _self.type
 | 
			
		||||
          : type // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      label: null == label
 | 
			
		||||
          ? _self.label
 | 
			
		||||
          : label // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      attitude: null == attitude
 | 
			
		||||
          ? _self.attitude
 | 
			
		||||
          : attitude // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      isNoDisturb: null == isNoDisturb
 | 
			
		||||
          ? _self.isNoDisturb
 | 
			
		||||
          : isNoDisturb // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as bool,
 | 
			
		||||
      isInvisible: null == isInvisible
 | 
			
		||||
          ? _self.isInvisible
 | 
			
		||||
          : isInvisible // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as bool,
 | 
			
		||||
      clearAt: freezed == clearAt
 | 
			
		||||
          ? _self.clearAt
 | 
			
		||||
          : clearAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime?,
 | 
			
		||||
      accountId: null == accountId
 | 
			
		||||
          ? _self.accountId
 | 
			
		||||
          : accountId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
    ));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -2629,4 +3027,447 @@ class __$SnAbuseReportCopyWithImpl<$Res>
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
mixin _$SnActionEvent {
 | 
			
		||||
  int get id;
 | 
			
		||||
  DateTime get createdAt;
 | 
			
		||||
  DateTime get updatedAt;
 | 
			
		||||
  DateTime? get deletedAt;
 | 
			
		||||
  String get type;
 | 
			
		||||
  Map<String, dynamic>? get metadata;
 | 
			
		||||
  String? get location;
 | 
			
		||||
  double? get coordinateX;
 | 
			
		||||
  double? get coordinateY;
 | 
			
		||||
  String get ipAddress;
 | 
			
		||||
  String get userAgent;
 | 
			
		||||
  SnAccount get account;
 | 
			
		||||
  int get accountId;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnActionEvent
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  $SnActionEventCopyWith<SnActionEvent> get copyWith =>
 | 
			
		||||
      _$SnActionEventCopyWithImpl<SnActionEvent>(
 | 
			
		||||
          this as SnActionEvent, _$identity);
 | 
			
		||||
 | 
			
		||||
  /// Serializes this SnActionEvent to a JSON map.
 | 
			
		||||
  Map<String, dynamic> toJson();
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    return identical(this, other) ||
 | 
			
		||||
        (other.runtimeType == runtimeType &&
 | 
			
		||||
            other is SnActionEvent &&
 | 
			
		||||
            (identical(other.id, id) || other.id == id) &&
 | 
			
		||||
            (identical(other.createdAt, createdAt) ||
 | 
			
		||||
                other.createdAt == createdAt) &&
 | 
			
		||||
            (identical(other.updatedAt, updatedAt) ||
 | 
			
		||||
                other.updatedAt == updatedAt) &&
 | 
			
		||||
            (identical(other.deletedAt, deletedAt) ||
 | 
			
		||||
                other.deletedAt == deletedAt) &&
 | 
			
		||||
            (identical(other.type, type) || other.type == type) &&
 | 
			
		||||
            const DeepCollectionEquality().equals(other.metadata, metadata) &&
 | 
			
		||||
            (identical(other.location, location) ||
 | 
			
		||||
                other.location == location) &&
 | 
			
		||||
            (identical(other.coordinateX, coordinateX) ||
 | 
			
		||||
                other.coordinateX == coordinateX) &&
 | 
			
		||||
            (identical(other.coordinateY, coordinateY) ||
 | 
			
		||||
                other.coordinateY == coordinateY) &&
 | 
			
		||||
            (identical(other.ipAddress, ipAddress) ||
 | 
			
		||||
                other.ipAddress == ipAddress) &&
 | 
			
		||||
            (identical(other.userAgent, userAgent) ||
 | 
			
		||||
                other.userAgent == userAgent) &&
 | 
			
		||||
            (identical(other.account, account) || other.account == account) &&
 | 
			
		||||
            (identical(other.accountId, accountId) ||
 | 
			
		||||
                other.accountId == accountId));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => Object.hash(
 | 
			
		||||
      runtimeType,
 | 
			
		||||
      id,
 | 
			
		||||
      createdAt,
 | 
			
		||||
      updatedAt,
 | 
			
		||||
      deletedAt,
 | 
			
		||||
      type,
 | 
			
		||||
      const DeepCollectionEquality().hash(metadata),
 | 
			
		||||
      location,
 | 
			
		||||
      coordinateX,
 | 
			
		||||
      coordinateY,
 | 
			
		||||
      ipAddress,
 | 
			
		||||
      userAgent,
 | 
			
		||||
      account,
 | 
			
		||||
      accountId);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnActionEvent(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, metadata: $metadata, location: $location, coordinateX: $coordinateX, coordinateY: $coordinateY, ipAddress: $ipAddress, userAgent: $userAgent, account: $account, accountId: $accountId)';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract mixin class $SnActionEventCopyWith<$Res> {
 | 
			
		||||
  factory $SnActionEventCopyWith(
 | 
			
		||||
          SnActionEvent value, $Res Function(SnActionEvent) _then) =
 | 
			
		||||
      _$SnActionEventCopyWithImpl;
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {int id,
 | 
			
		||||
      DateTime createdAt,
 | 
			
		||||
      DateTime updatedAt,
 | 
			
		||||
      DateTime? deletedAt,
 | 
			
		||||
      String type,
 | 
			
		||||
      Map<String, dynamic>? metadata,
 | 
			
		||||
      String? location,
 | 
			
		||||
      double? coordinateX,
 | 
			
		||||
      double? coordinateY,
 | 
			
		||||
      String ipAddress,
 | 
			
		||||
      String userAgent,
 | 
			
		||||
      SnAccount account,
 | 
			
		||||
      int accountId});
 | 
			
		||||
 | 
			
		||||
  $SnAccountCopyWith<$Res> get account;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class _$SnActionEventCopyWithImpl<$Res>
 | 
			
		||||
    implements $SnActionEventCopyWith<$Res> {
 | 
			
		||||
  _$SnActionEventCopyWithImpl(this._self, this._then);
 | 
			
		||||
 | 
			
		||||
  final SnActionEvent _self;
 | 
			
		||||
  final $Res Function(SnActionEvent) _then;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnActionEvent
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  @override
 | 
			
		||||
  $Res call({
 | 
			
		||||
    Object? id = null,
 | 
			
		||||
    Object? createdAt = null,
 | 
			
		||||
    Object? updatedAt = null,
 | 
			
		||||
    Object? deletedAt = freezed,
 | 
			
		||||
    Object? type = null,
 | 
			
		||||
    Object? metadata = freezed,
 | 
			
		||||
    Object? location = freezed,
 | 
			
		||||
    Object? coordinateX = freezed,
 | 
			
		||||
    Object? coordinateY = freezed,
 | 
			
		||||
    Object? ipAddress = null,
 | 
			
		||||
    Object? userAgent = null,
 | 
			
		||||
    Object? account = null,
 | 
			
		||||
    Object? accountId = null,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_self.copyWith(
 | 
			
		||||
      id: null == id
 | 
			
		||||
          ? _self.id
 | 
			
		||||
          : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      createdAt: null == createdAt
 | 
			
		||||
          ? _self.createdAt
 | 
			
		||||
          : createdAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      updatedAt: null == updatedAt
 | 
			
		||||
          ? _self.updatedAt
 | 
			
		||||
          : updatedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      deletedAt: freezed == deletedAt
 | 
			
		||||
          ? _self.deletedAt
 | 
			
		||||
          : deletedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime?,
 | 
			
		||||
      type: null == type
 | 
			
		||||
          ? _self.type
 | 
			
		||||
          : type // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      metadata: freezed == metadata
 | 
			
		||||
          ? _self.metadata
 | 
			
		||||
          : metadata // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as Map<String, dynamic>?,
 | 
			
		||||
      location: freezed == location
 | 
			
		||||
          ? _self.location
 | 
			
		||||
          : location // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String?,
 | 
			
		||||
      coordinateX: freezed == coordinateX
 | 
			
		||||
          ? _self.coordinateX
 | 
			
		||||
          : coordinateX // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as double?,
 | 
			
		||||
      coordinateY: freezed == coordinateY
 | 
			
		||||
          ? _self.coordinateY
 | 
			
		||||
          : coordinateY // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as double?,
 | 
			
		||||
      ipAddress: null == ipAddress
 | 
			
		||||
          ? _self.ipAddress
 | 
			
		||||
          : ipAddress // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      userAgent: null == userAgent
 | 
			
		||||
          ? _self.userAgent
 | 
			
		||||
          : userAgent // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      account: null == account
 | 
			
		||||
          ? _self.account
 | 
			
		||||
          : account // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as SnAccount,
 | 
			
		||||
      accountId: null == accountId
 | 
			
		||||
          ? _self.accountId
 | 
			
		||||
          : accountId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
    ));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnActionEvent
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  $SnAccountCopyWith<$Res> get account {
 | 
			
		||||
    return $SnAccountCopyWith<$Res>(_self.account, (value) {
 | 
			
		||||
      return _then(_self.copyWith(account: value));
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@JsonSerializable()
 | 
			
		||||
class _SnActionEvent implements SnActionEvent {
 | 
			
		||||
  const _SnActionEvent(
 | 
			
		||||
      {required this.id,
 | 
			
		||||
      required this.createdAt,
 | 
			
		||||
      required this.updatedAt,
 | 
			
		||||
      required this.deletedAt,
 | 
			
		||||
      required this.type,
 | 
			
		||||
      required final Map<String, dynamic>? metadata,
 | 
			
		||||
      required this.location,
 | 
			
		||||
      required this.coordinateX,
 | 
			
		||||
      required this.coordinateY,
 | 
			
		||||
      required this.ipAddress,
 | 
			
		||||
      required this.userAgent,
 | 
			
		||||
      required this.account,
 | 
			
		||||
      required this.accountId})
 | 
			
		||||
      : _metadata = metadata;
 | 
			
		||||
  factory _SnActionEvent.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$SnActionEventFromJson(json);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  final int id;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime createdAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime updatedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime? deletedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final String type;
 | 
			
		||||
  final Map<String, dynamic>? _metadata;
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, dynamic>? get metadata {
 | 
			
		||||
    final value = _metadata;
 | 
			
		||||
    if (value == null) return null;
 | 
			
		||||
    if (_metadata is EqualUnmodifiableMapView) return _metadata;
 | 
			
		||||
    // ignore: implicit_dynamic_type
 | 
			
		||||
    return EqualUnmodifiableMapView(value);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  final String? location;
 | 
			
		||||
  @override
 | 
			
		||||
  final double? coordinateX;
 | 
			
		||||
  @override
 | 
			
		||||
  final double? coordinateY;
 | 
			
		||||
  @override
 | 
			
		||||
  final String ipAddress;
 | 
			
		||||
  @override
 | 
			
		||||
  final String userAgent;
 | 
			
		||||
  @override
 | 
			
		||||
  final SnAccount account;
 | 
			
		||||
  @override
 | 
			
		||||
  final int accountId;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnActionEvent
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  _$SnActionEventCopyWith<_SnActionEvent> get copyWith =>
 | 
			
		||||
      __$SnActionEventCopyWithImpl<_SnActionEvent>(this, _$identity);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    return _$SnActionEventToJson(
 | 
			
		||||
      this,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    return identical(this, other) ||
 | 
			
		||||
        (other.runtimeType == runtimeType &&
 | 
			
		||||
            other is _SnActionEvent &&
 | 
			
		||||
            (identical(other.id, id) || other.id == id) &&
 | 
			
		||||
            (identical(other.createdAt, createdAt) ||
 | 
			
		||||
                other.createdAt == createdAt) &&
 | 
			
		||||
            (identical(other.updatedAt, updatedAt) ||
 | 
			
		||||
                other.updatedAt == updatedAt) &&
 | 
			
		||||
            (identical(other.deletedAt, deletedAt) ||
 | 
			
		||||
                other.deletedAt == deletedAt) &&
 | 
			
		||||
            (identical(other.type, type) || other.type == type) &&
 | 
			
		||||
            const DeepCollectionEquality().equals(other._metadata, _metadata) &&
 | 
			
		||||
            (identical(other.location, location) ||
 | 
			
		||||
                other.location == location) &&
 | 
			
		||||
            (identical(other.coordinateX, coordinateX) ||
 | 
			
		||||
                other.coordinateX == coordinateX) &&
 | 
			
		||||
            (identical(other.coordinateY, coordinateY) ||
 | 
			
		||||
                other.coordinateY == coordinateY) &&
 | 
			
		||||
            (identical(other.ipAddress, ipAddress) ||
 | 
			
		||||
                other.ipAddress == ipAddress) &&
 | 
			
		||||
            (identical(other.userAgent, userAgent) ||
 | 
			
		||||
                other.userAgent == userAgent) &&
 | 
			
		||||
            (identical(other.account, account) || other.account == account) &&
 | 
			
		||||
            (identical(other.accountId, accountId) ||
 | 
			
		||||
                other.accountId == accountId));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => Object.hash(
 | 
			
		||||
      runtimeType,
 | 
			
		||||
      id,
 | 
			
		||||
      createdAt,
 | 
			
		||||
      updatedAt,
 | 
			
		||||
      deletedAt,
 | 
			
		||||
      type,
 | 
			
		||||
      const DeepCollectionEquality().hash(_metadata),
 | 
			
		||||
      location,
 | 
			
		||||
      coordinateX,
 | 
			
		||||
      coordinateY,
 | 
			
		||||
      ipAddress,
 | 
			
		||||
      userAgent,
 | 
			
		||||
      account,
 | 
			
		||||
      accountId);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnActionEvent(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, metadata: $metadata, location: $location, coordinateX: $coordinateX, coordinateY: $coordinateY, ipAddress: $ipAddress, userAgent: $userAgent, account: $account, accountId: $accountId)';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract mixin class _$SnActionEventCopyWith<$Res>
 | 
			
		||||
    implements $SnActionEventCopyWith<$Res> {
 | 
			
		||||
  factory _$SnActionEventCopyWith(
 | 
			
		||||
          _SnActionEvent value, $Res Function(_SnActionEvent) _then) =
 | 
			
		||||
      __$SnActionEventCopyWithImpl;
 | 
			
		||||
  @override
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {int id,
 | 
			
		||||
      DateTime createdAt,
 | 
			
		||||
      DateTime updatedAt,
 | 
			
		||||
      DateTime? deletedAt,
 | 
			
		||||
      String type,
 | 
			
		||||
      Map<String, dynamic>? metadata,
 | 
			
		||||
      String? location,
 | 
			
		||||
      double? coordinateX,
 | 
			
		||||
      double? coordinateY,
 | 
			
		||||
      String ipAddress,
 | 
			
		||||
      String userAgent,
 | 
			
		||||
      SnAccount account,
 | 
			
		||||
      int accountId});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  $SnAccountCopyWith<$Res> get account;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class __$SnActionEventCopyWithImpl<$Res>
 | 
			
		||||
    implements _$SnActionEventCopyWith<$Res> {
 | 
			
		||||
  __$SnActionEventCopyWithImpl(this._self, this._then);
 | 
			
		||||
 | 
			
		||||
  final _SnActionEvent _self;
 | 
			
		||||
  final $Res Function(_SnActionEvent) _then;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnActionEvent
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  $Res call({
 | 
			
		||||
    Object? id = null,
 | 
			
		||||
    Object? createdAt = null,
 | 
			
		||||
    Object? updatedAt = null,
 | 
			
		||||
    Object? deletedAt = freezed,
 | 
			
		||||
    Object? type = null,
 | 
			
		||||
    Object? metadata = freezed,
 | 
			
		||||
    Object? location = freezed,
 | 
			
		||||
    Object? coordinateX = freezed,
 | 
			
		||||
    Object? coordinateY = freezed,
 | 
			
		||||
    Object? ipAddress = null,
 | 
			
		||||
    Object? userAgent = null,
 | 
			
		||||
    Object? account = null,
 | 
			
		||||
    Object? accountId = null,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_SnActionEvent(
 | 
			
		||||
      id: null == id
 | 
			
		||||
          ? _self.id
 | 
			
		||||
          : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      createdAt: null == createdAt
 | 
			
		||||
          ? _self.createdAt
 | 
			
		||||
          : createdAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      updatedAt: null == updatedAt
 | 
			
		||||
          ? _self.updatedAt
 | 
			
		||||
          : updatedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      deletedAt: freezed == deletedAt
 | 
			
		||||
          ? _self.deletedAt
 | 
			
		||||
          : deletedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime?,
 | 
			
		||||
      type: null == type
 | 
			
		||||
          ? _self.type
 | 
			
		||||
          : type // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      metadata: freezed == metadata
 | 
			
		||||
          ? _self._metadata
 | 
			
		||||
          : metadata // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as Map<String, dynamic>?,
 | 
			
		||||
      location: freezed == location
 | 
			
		||||
          ? _self.location
 | 
			
		||||
          : location // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String?,
 | 
			
		||||
      coordinateX: freezed == coordinateX
 | 
			
		||||
          ? _self.coordinateX
 | 
			
		||||
          : coordinateX // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as double?,
 | 
			
		||||
      coordinateY: freezed == coordinateY
 | 
			
		||||
          ? _self.coordinateY
 | 
			
		||||
          : coordinateY // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as double?,
 | 
			
		||||
      ipAddress: null == ipAddress
 | 
			
		||||
          ? _self.ipAddress
 | 
			
		||||
          : ipAddress // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      userAgent: null == userAgent
 | 
			
		||||
          ? _self.userAgent
 | 
			
		||||
          : userAgent // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      account: null == account
 | 
			
		||||
          ? _self.account
 | 
			
		||||
          : account // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as SnAccount,
 | 
			
		||||
      accountId: null == accountId
 | 
			
		||||
          ? _self.accountId
 | 
			
		||||
          : accountId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
    ));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnActionEvent
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  $SnAccountCopyWith<$Res> get account {
 | 
			
		||||
    return $SnAccountCopyWith<$Res>(_self.account, (value) {
 | 
			
		||||
      return _then(_self.copyWith(account: value));
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// dart format on
 | 
			
		||||
 
 | 
			
		||||
@@ -210,7 +210,9 @@ _SnAccountStatusInfo _$SnAccountStatusInfoFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      lastSeenAt: json['last_seen_at'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : DateTime.parse(json['last_seen_at'] as String),
 | 
			
		||||
      status: json['status'],
 | 
			
		||||
      status: json['status'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : SnAccountStatus.fromJson(json['status'] as Map<String, dynamic>),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$SnAccountStatusInfoToJson(
 | 
			
		||||
@@ -219,7 +221,41 @@ Map<String, dynamic> _$SnAccountStatusInfoToJson(
 | 
			
		||||
      'is_disturbable': instance.isDisturbable,
 | 
			
		||||
      'is_online': instance.isOnline,
 | 
			
		||||
      'last_seen_at': instance.lastSeenAt?.toIso8601String(),
 | 
			
		||||
      'status': instance.status,
 | 
			
		||||
      'status': instance.status?.toJson(),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
_SnAccountStatus _$SnAccountStatusFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
    _SnAccountStatus(
 | 
			
		||||
      id: (json['id'] as num).toInt(),
 | 
			
		||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
			
		||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
			
		||||
      deletedAt: json['deleted_at'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : DateTime.parse(json['deleted_at'] as String),
 | 
			
		||||
      type: json['type'] as String,
 | 
			
		||||
      label: json['label'] as String,
 | 
			
		||||
      attitude: (json['attitude'] as num).toInt(),
 | 
			
		||||
      isNoDisturb: json['is_no_disturb'] as bool,
 | 
			
		||||
      isInvisible: json['is_invisible'] as bool,
 | 
			
		||||
      clearAt: json['clear_at'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : DateTime.parse(json['clear_at'] as String),
 | 
			
		||||
      accountId: (json['account_id'] as num).toInt(),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$SnAccountStatusToJson(_SnAccountStatus instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'id': instance.id,
 | 
			
		||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
			
		||||
      'updated_at': instance.updatedAt.toIso8601String(),
 | 
			
		||||
      'deleted_at': instance.deletedAt?.toIso8601String(),
 | 
			
		||||
      'type': instance.type,
 | 
			
		||||
      'label': instance.label,
 | 
			
		||||
      'attitude': instance.attitude,
 | 
			
		||||
      'is_no_disturb': instance.isNoDisturb,
 | 
			
		||||
      'is_invisible': instance.isInvisible,
 | 
			
		||||
      'clear_at': instance.clearAt?.toIso8601String(),
 | 
			
		||||
      'account_id': instance.accountId,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
_SnAbuseReport _$SnAbuseReportFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
@@ -247,3 +283,39 @@ Map<String, dynamic> _$SnAbuseReportToJson(_SnAbuseReport instance) =>
 | 
			
		||||
      'status': instance.status,
 | 
			
		||||
      'account_id': instance.accountId,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
_SnActionEvent _$SnActionEventFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
    _SnActionEvent(
 | 
			
		||||
      id: (json['id'] as num).toInt(),
 | 
			
		||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
			
		||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
			
		||||
      deletedAt: json['deleted_at'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : DateTime.parse(json['deleted_at'] as String),
 | 
			
		||||
      type: json['type'] as String,
 | 
			
		||||
      metadata: json['metadata'] as Map<String, dynamic>?,
 | 
			
		||||
      location: json['location'] as String?,
 | 
			
		||||
      coordinateX: (json['coordinate_x'] as num?)?.toDouble(),
 | 
			
		||||
      coordinateY: (json['coordinate_y'] as num?)?.toDouble(),
 | 
			
		||||
      ipAddress: json['ip_address'] as String,
 | 
			
		||||
      userAgent: json['user_agent'] as String,
 | 
			
		||||
      account: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
 | 
			
		||||
      accountId: (json['account_id'] as num).toInt(),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$SnActionEventToJson(_SnActionEvent instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'id': instance.id,
 | 
			
		||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
			
		||||
      'updated_at': instance.updatedAt.toIso8601String(),
 | 
			
		||||
      'deleted_at': instance.deletedAt?.toIso8601String(),
 | 
			
		||||
      'type': instance.type,
 | 
			
		||||
      'metadata': instance.metadata,
 | 
			
		||||
      'location': instance.location,
 | 
			
		||||
      'coordinate_x': instance.coordinateX,
 | 
			
		||||
      'coordinate_y': instance.coordinateY,
 | 
			
		||||
      'ip_address': instance.ipAddress,
 | 
			
		||||
      'user_agent': instance.userAgent,
 | 
			
		||||
      'account': instance.account.toJson(),
 | 
			
		||||
      'account_id': instance.accountId,
 | 
			
		||||
    };
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,9 @@ abstract class SnAuthTicket with _$SnAuthTicket {
 | 
			
		||||
    required String? accessToken,
 | 
			
		||||
    required String? refreshToken,
 | 
			
		||||
    required String ipAddress,
 | 
			
		||||
    required String location,
 | 
			
		||||
    required String? location,
 | 
			
		||||
    required double? coordinateX,
 | 
			
		||||
    required double? coordinateY,
 | 
			
		||||
    required String userAgent,
 | 
			
		||||
    required DateTime? expiredAt,
 | 
			
		||||
    required DateTime? lastGrantAt,
 | 
			
		||||
 
 | 
			
		||||
@@ -217,7 +217,9 @@ mixin _$SnAuthTicket {
 | 
			
		||||
  String? get accessToken;
 | 
			
		||||
  String? get refreshToken;
 | 
			
		||||
  String get ipAddress;
 | 
			
		||||
  String get location;
 | 
			
		||||
  String? get location;
 | 
			
		||||
  double? get coordinateX;
 | 
			
		||||
  double? get coordinateY;
 | 
			
		||||
  String get userAgent;
 | 
			
		||||
  DateTime? get expiredAt;
 | 
			
		||||
  DateTime? get lastGrantAt;
 | 
			
		||||
@@ -261,6 +263,10 @@ mixin _$SnAuthTicket {
 | 
			
		||||
                other.ipAddress == ipAddress) &&
 | 
			
		||||
            (identical(other.location, location) ||
 | 
			
		||||
                other.location == location) &&
 | 
			
		||||
            (identical(other.coordinateX, coordinateX) ||
 | 
			
		||||
                other.coordinateX == coordinateX) &&
 | 
			
		||||
            (identical(other.coordinateY, coordinateY) ||
 | 
			
		||||
                other.coordinateY == coordinateY) &&
 | 
			
		||||
            (identical(other.userAgent, userAgent) ||
 | 
			
		||||
                other.userAgent == userAgent) &&
 | 
			
		||||
            (identical(other.expiredAt, expiredAt) ||
 | 
			
		||||
@@ -278,29 +284,32 @@ mixin _$SnAuthTicket {
 | 
			
		||||
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => Object.hash(
 | 
			
		||||
      runtimeType,
 | 
			
		||||
      id,
 | 
			
		||||
      createdAt,
 | 
			
		||||
      updatedAt,
 | 
			
		||||
      deletedAt,
 | 
			
		||||
      stepRemain,
 | 
			
		||||
      grantToken,
 | 
			
		||||
      accessToken,
 | 
			
		||||
      refreshToken,
 | 
			
		||||
      ipAddress,
 | 
			
		||||
      location,
 | 
			
		||||
      userAgent,
 | 
			
		||||
      expiredAt,
 | 
			
		||||
      lastGrantAt,
 | 
			
		||||
      availableAt,
 | 
			
		||||
      nonce,
 | 
			
		||||
      accountId,
 | 
			
		||||
      const DeepCollectionEquality().hash(factorTrail));
 | 
			
		||||
  int get hashCode => Object.hashAll([
 | 
			
		||||
        runtimeType,
 | 
			
		||||
        id,
 | 
			
		||||
        createdAt,
 | 
			
		||||
        updatedAt,
 | 
			
		||||
        deletedAt,
 | 
			
		||||
        stepRemain,
 | 
			
		||||
        grantToken,
 | 
			
		||||
        accessToken,
 | 
			
		||||
        refreshToken,
 | 
			
		||||
        ipAddress,
 | 
			
		||||
        location,
 | 
			
		||||
        coordinateX,
 | 
			
		||||
        coordinateY,
 | 
			
		||||
        userAgent,
 | 
			
		||||
        expiredAt,
 | 
			
		||||
        lastGrantAt,
 | 
			
		||||
        availableAt,
 | 
			
		||||
        nonce,
 | 
			
		||||
        accountId,
 | 
			
		||||
        const DeepCollectionEquality().hash(factorTrail)
 | 
			
		||||
      ]);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnAuthTicket(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, stepRemain: $stepRemain, grantToken: $grantToken, accessToken: $accessToken, refreshToken: $refreshToken, ipAddress: $ipAddress, location: $location, userAgent: $userAgent, expiredAt: $expiredAt, lastGrantAt: $lastGrantAt, availableAt: $availableAt, nonce: $nonce, accountId: $accountId, factorTrail: $factorTrail)';
 | 
			
		||||
    return 'SnAuthTicket(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, stepRemain: $stepRemain, grantToken: $grantToken, accessToken: $accessToken, refreshToken: $refreshToken, ipAddress: $ipAddress, location: $location, coordinateX: $coordinateX, coordinateY: $coordinateY, userAgent: $userAgent, expiredAt: $expiredAt, lastGrantAt: $lastGrantAt, availableAt: $availableAt, nonce: $nonce, accountId: $accountId, factorTrail: $factorTrail)';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -320,7 +329,9 @@ abstract mixin class $SnAuthTicketCopyWith<$Res> {
 | 
			
		||||
      String? accessToken,
 | 
			
		||||
      String? refreshToken,
 | 
			
		||||
      String ipAddress,
 | 
			
		||||
      String location,
 | 
			
		||||
      String? location,
 | 
			
		||||
      double? coordinateX,
 | 
			
		||||
      double? coordinateY,
 | 
			
		||||
      String userAgent,
 | 
			
		||||
      DateTime? expiredAt,
 | 
			
		||||
      DateTime? lastGrantAt,
 | 
			
		||||
@@ -351,7 +362,9 @@ class _$SnAuthTicketCopyWithImpl<$Res> implements $SnAuthTicketCopyWith<$Res> {
 | 
			
		||||
    Object? accessToken = freezed,
 | 
			
		||||
    Object? refreshToken = freezed,
 | 
			
		||||
    Object? ipAddress = null,
 | 
			
		||||
    Object? location = null,
 | 
			
		||||
    Object? location = freezed,
 | 
			
		||||
    Object? coordinateX = freezed,
 | 
			
		||||
    Object? coordinateY = freezed,
 | 
			
		||||
    Object? userAgent = null,
 | 
			
		||||
    Object? expiredAt = freezed,
 | 
			
		||||
    Object? lastGrantAt = freezed,
 | 
			
		||||
@@ -397,10 +410,18 @@ class _$SnAuthTicketCopyWithImpl<$Res> implements $SnAuthTicketCopyWith<$Res> {
 | 
			
		||||
          ? _self.ipAddress
 | 
			
		||||
          : ipAddress // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      location: null == location
 | 
			
		||||
      location: freezed == location
 | 
			
		||||
          ? _self.location
 | 
			
		||||
          : location // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
              as String?,
 | 
			
		||||
      coordinateX: freezed == coordinateX
 | 
			
		||||
          ? _self.coordinateX
 | 
			
		||||
          : coordinateX // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as double?,
 | 
			
		||||
      coordinateY: freezed == coordinateY
 | 
			
		||||
          ? _self.coordinateY
 | 
			
		||||
          : coordinateY // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as double?,
 | 
			
		||||
      userAgent: null == userAgent
 | 
			
		||||
          ? _self.userAgent
 | 
			
		||||
          : userAgent // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
@@ -447,6 +468,8 @@ class _SnAuthTicket implements SnAuthTicket {
 | 
			
		||||
      required this.refreshToken,
 | 
			
		||||
      required this.ipAddress,
 | 
			
		||||
      required this.location,
 | 
			
		||||
      required this.coordinateX,
 | 
			
		||||
      required this.coordinateY,
 | 
			
		||||
      required this.userAgent,
 | 
			
		||||
      required this.expiredAt,
 | 
			
		||||
      required this.lastGrantAt,
 | 
			
		||||
@@ -477,7 +500,11 @@ class _SnAuthTicket implements SnAuthTicket {
 | 
			
		||||
  @override
 | 
			
		||||
  final String ipAddress;
 | 
			
		||||
  @override
 | 
			
		||||
  final String location;
 | 
			
		||||
  final String? location;
 | 
			
		||||
  @override
 | 
			
		||||
  final double? coordinateX;
 | 
			
		||||
  @override
 | 
			
		||||
  final double? coordinateY;
 | 
			
		||||
  @override
 | 
			
		||||
  final String userAgent;
 | 
			
		||||
  @override
 | 
			
		||||
@@ -538,6 +565,10 @@ class _SnAuthTicket implements SnAuthTicket {
 | 
			
		||||
                other.ipAddress == ipAddress) &&
 | 
			
		||||
            (identical(other.location, location) ||
 | 
			
		||||
                other.location == location) &&
 | 
			
		||||
            (identical(other.coordinateX, coordinateX) ||
 | 
			
		||||
                other.coordinateX == coordinateX) &&
 | 
			
		||||
            (identical(other.coordinateY, coordinateY) ||
 | 
			
		||||
                other.coordinateY == coordinateY) &&
 | 
			
		||||
            (identical(other.userAgent, userAgent) ||
 | 
			
		||||
                other.userAgent == userAgent) &&
 | 
			
		||||
            (identical(other.expiredAt, expiredAt) ||
 | 
			
		||||
@@ -555,29 +586,32 @@ class _SnAuthTicket implements SnAuthTicket {
 | 
			
		||||
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => Object.hash(
 | 
			
		||||
      runtimeType,
 | 
			
		||||
      id,
 | 
			
		||||
      createdAt,
 | 
			
		||||
      updatedAt,
 | 
			
		||||
      deletedAt,
 | 
			
		||||
      stepRemain,
 | 
			
		||||
      grantToken,
 | 
			
		||||
      accessToken,
 | 
			
		||||
      refreshToken,
 | 
			
		||||
      ipAddress,
 | 
			
		||||
      location,
 | 
			
		||||
      userAgent,
 | 
			
		||||
      expiredAt,
 | 
			
		||||
      lastGrantAt,
 | 
			
		||||
      availableAt,
 | 
			
		||||
      nonce,
 | 
			
		||||
      accountId,
 | 
			
		||||
      const DeepCollectionEquality().hash(_factorTrail));
 | 
			
		||||
  int get hashCode => Object.hashAll([
 | 
			
		||||
        runtimeType,
 | 
			
		||||
        id,
 | 
			
		||||
        createdAt,
 | 
			
		||||
        updatedAt,
 | 
			
		||||
        deletedAt,
 | 
			
		||||
        stepRemain,
 | 
			
		||||
        grantToken,
 | 
			
		||||
        accessToken,
 | 
			
		||||
        refreshToken,
 | 
			
		||||
        ipAddress,
 | 
			
		||||
        location,
 | 
			
		||||
        coordinateX,
 | 
			
		||||
        coordinateY,
 | 
			
		||||
        userAgent,
 | 
			
		||||
        expiredAt,
 | 
			
		||||
        lastGrantAt,
 | 
			
		||||
        availableAt,
 | 
			
		||||
        nonce,
 | 
			
		||||
        accountId,
 | 
			
		||||
        const DeepCollectionEquality().hash(_factorTrail)
 | 
			
		||||
      ]);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnAuthTicket(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, stepRemain: $stepRemain, grantToken: $grantToken, accessToken: $accessToken, refreshToken: $refreshToken, ipAddress: $ipAddress, location: $location, userAgent: $userAgent, expiredAt: $expiredAt, lastGrantAt: $lastGrantAt, availableAt: $availableAt, nonce: $nonce, accountId: $accountId, factorTrail: $factorTrail)';
 | 
			
		||||
    return 'SnAuthTicket(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, stepRemain: $stepRemain, grantToken: $grantToken, accessToken: $accessToken, refreshToken: $refreshToken, ipAddress: $ipAddress, location: $location, coordinateX: $coordinateX, coordinateY: $coordinateY, userAgent: $userAgent, expiredAt: $expiredAt, lastGrantAt: $lastGrantAt, availableAt: $availableAt, nonce: $nonce, accountId: $accountId, factorTrail: $factorTrail)';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -599,7 +633,9 @@ abstract mixin class _$SnAuthTicketCopyWith<$Res>
 | 
			
		||||
      String? accessToken,
 | 
			
		||||
      String? refreshToken,
 | 
			
		||||
      String ipAddress,
 | 
			
		||||
      String location,
 | 
			
		||||
      String? location,
 | 
			
		||||
      double? coordinateX,
 | 
			
		||||
      double? coordinateY,
 | 
			
		||||
      String userAgent,
 | 
			
		||||
      DateTime? expiredAt,
 | 
			
		||||
      DateTime? lastGrantAt,
 | 
			
		||||
@@ -631,7 +667,9 @@ class __$SnAuthTicketCopyWithImpl<$Res>
 | 
			
		||||
    Object? accessToken = freezed,
 | 
			
		||||
    Object? refreshToken = freezed,
 | 
			
		||||
    Object? ipAddress = null,
 | 
			
		||||
    Object? location = null,
 | 
			
		||||
    Object? location = freezed,
 | 
			
		||||
    Object? coordinateX = freezed,
 | 
			
		||||
    Object? coordinateY = freezed,
 | 
			
		||||
    Object? userAgent = null,
 | 
			
		||||
    Object? expiredAt = freezed,
 | 
			
		||||
    Object? lastGrantAt = freezed,
 | 
			
		||||
@@ -677,10 +715,18 @@ class __$SnAuthTicketCopyWithImpl<$Res>
 | 
			
		||||
          ? _self.ipAddress
 | 
			
		||||
          : ipAddress // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      location: null == location
 | 
			
		||||
      location: freezed == location
 | 
			
		||||
          ? _self.location
 | 
			
		||||
          : location // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
              as String?,
 | 
			
		||||
      coordinateX: freezed == coordinateX
 | 
			
		||||
          ? _self.coordinateX
 | 
			
		||||
          : coordinateX // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as double?,
 | 
			
		||||
      coordinateY: freezed == coordinateY
 | 
			
		||||
          ? _self.coordinateY
 | 
			
		||||
          : coordinateY // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as double?,
 | 
			
		||||
      userAgent: null == userAgent
 | 
			
		||||
          ? _self.userAgent
 | 
			
		||||
          : userAgent // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
 
 | 
			
		||||
@@ -33,7 +33,9 @@ _SnAuthTicket _$SnAuthTicketFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      accessToken: json['access_token'] as String?,
 | 
			
		||||
      refreshToken: json['refresh_token'] as String?,
 | 
			
		||||
      ipAddress: json['ip_address'] as String,
 | 
			
		||||
      location: json['location'] as String,
 | 
			
		||||
      location: json['location'] as String?,
 | 
			
		||||
      coordinateX: (json['coordinate_x'] as num?)?.toDouble(),
 | 
			
		||||
      coordinateY: (json['coordinate_y'] as num?)?.toDouble(),
 | 
			
		||||
      userAgent: json['user_agent'] as String,
 | 
			
		||||
      expiredAt: json['expired_at'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
@@ -64,6 +66,8 @@ Map<String, dynamic> _$SnAuthTicketToJson(_SnAuthTicket instance) =>
 | 
			
		||||
      'refresh_token': instance.refreshToken,
 | 
			
		||||
      'ip_address': instance.ipAddress,
 | 
			
		||||
      'location': instance.location,
 | 
			
		||||
      'coordinate_x': instance.coordinateX,
 | 
			
		||||
      'coordinate_y': instance.coordinateY,
 | 
			
		||||
      'user_agent': instance.userAgent,
 | 
			
		||||
      'expired_at': instance.expiredAt?.toIso8601String(),
 | 
			
		||||
      'last_grant_at': instance.lastGrantAt?.toIso8601String(),
 | 
			
		||||
 
 | 
			
		||||
@@ -25,11 +25,13 @@ abstract class SnCheckInRecord with _$SnCheckInRecord {
 | 
			
		||||
    required int resultTier,
 | 
			
		||||
    required int resultExperience,
 | 
			
		||||
    required double resultCoin,
 | 
			
		||||
    @Default(0) int currentStreak,
 | 
			
		||||
    required List<int> resultModifiers,
 | 
			
		||||
    required int accountId,
 | 
			
		||||
  }) = _SnCheckInRecord;
 | 
			
		||||
 | 
			
		||||
  factory SnCheckInRecord.fromJson(Map<String, dynamic> json) => _$SnCheckInRecordFromJson(json);
 | 
			
		||||
  factory SnCheckInRecord.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$SnCheckInRecordFromJson(json);
 | 
			
		||||
 | 
			
		||||
  String get symbol => kCheckInResultTierSymbols[resultTier];
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -22,6 +22,7 @@ mixin _$SnCheckInRecord {
 | 
			
		||||
  int get resultTier;
 | 
			
		||||
  int get resultExperience;
 | 
			
		||||
  double get resultCoin;
 | 
			
		||||
  int get currentStreak;
 | 
			
		||||
  List<int> get resultModifiers;
 | 
			
		||||
  int get accountId;
 | 
			
		||||
 | 
			
		||||
@@ -54,6 +55,8 @@ mixin _$SnCheckInRecord {
 | 
			
		||||
                other.resultExperience == resultExperience) &&
 | 
			
		||||
            (identical(other.resultCoin, resultCoin) ||
 | 
			
		||||
                other.resultCoin == resultCoin) &&
 | 
			
		||||
            (identical(other.currentStreak, currentStreak) ||
 | 
			
		||||
                other.currentStreak == currentStreak) &&
 | 
			
		||||
            const DeepCollectionEquality()
 | 
			
		||||
                .equals(other.resultModifiers, resultModifiers) &&
 | 
			
		||||
            (identical(other.accountId, accountId) ||
 | 
			
		||||
@@ -71,12 +74,13 @@ mixin _$SnCheckInRecord {
 | 
			
		||||
      resultTier,
 | 
			
		||||
      resultExperience,
 | 
			
		||||
      resultCoin,
 | 
			
		||||
      currentStreak,
 | 
			
		||||
      const DeepCollectionEquality().hash(resultModifiers),
 | 
			
		||||
      accountId);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultCoin: $resultCoin, resultModifiers: $resultModifiers, accountId: $accountId)';
 | 
			
		||||
    return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultCoin: $resultCoin, currentStreak: $currentStreak, resultModifiers: $resultModifiers, accountId: $accountId)';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -94,6 +98,7 @@ abstract mixin class $SnCheckInRecordCopyWith<$Res> {
 | 
			
		||||
      int resultTier,
 | 
			
		||||
      int resultExperience,
 | 
			
		||||
      double resultCoin,
 | 
			
		||||
      int currentStreak,
 | 
			
		||||
      List<int> resultModifiers,
 | 
			
		||||
      int accountId});
 | 
			
		||||
}
 | 
			
		||||
@@ -118,6 +123,7 @@ class _$SnCheckInRecordCopyWithImpl<$Res>
 | 
			
		||||
    Object? resultTier = null,
 | 
			
		||||
    Object? resultExperience = null,
 | 
			
		||||
    Object? resultCoin = null,
 | 
			
		||||
    Object? currentStreak = null,
 | 
			
		||||
    Object? resultModifiers = null,
 | 
			
		||||
    Object? accountId = null,
 | 
			
		||||
  }) {
 | 
			
		||||
@@ -150,6 +156,10 @@ class _$SnCheckInRecordCopyWithImpl<$Res>
 | 
			
		||||
          ? _self.resultCoin
 | 
			
		||||
          : resultCoin // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as double,
 | 
			
		||||
      currentStreak: null == currentStreak
 | 
			
		||||
          ? _self.currentStreak
 | 
			
		||||
          : currentStreak // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      resultModifiers: null == resultModifiers
 | 
			
		||||
          ? _self.resultModifiers
 | 
			
		||||
          : resultModifiers // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
@@ -173,6 +183,7 @@ class _SnCheckInRecord extends SnCheckInRecord {
 | 
			
		||||
      required this.resultTier,
 | 
			
		||||
      required this.resultExperience,
 | 
			
		||||
      required this.resultCoin,
 | 
			
		||||
      this.currentStreak = 0,
 | 
			
		||||
      required final List<int> resultModifiers,
 | 
			
		||||
      required this.accountId})
 | 
			
		||||
      : _resultModifiers = resultModifiers,
 | 
			
		||||
@@ -194,6 +205,9 @@ class _SnCheckInRecord extends SnCheckInRecord {
 | 
			
		||||
  final int resultExperience;
 | 
			
		||||
  @override
 | 
			
		||||
  final double resultCoin;
 | 
			
		||||
  @override
 | 
			
		||||
  @JsonKey()
 | 
			
		||||
  final int currentStreak;
 | 
			
		||||
  final List<int> _resultModifiers;
 | 
			
		||||
  @override
 | 
			
		||||
  List<int> get resultModifiers {
 | 
			
		||||
@@ -238,6 +252,8 @@ class _SnCheckInRecord extends SnCheckInRecord {
 | 
			
		||||
                other.resultExperience == resultExperience) &&
 | 
			
		||||
            (identical(other.resultCoin, resultCoin) ||
 | 
			
		||||
                other.resultCoin == resultCoin) &&
 | 
			
		||||
            (identical(other.currentStreak, currentStreak) ||
 | 
			
		||||
                other.currentStreak == currentStreak) &&
 | 
			
		||||
            const DeepCollectionEquality()
 | 
			
		||||
                .equals(other._resultModifiers, _resultModifiers) &&
 | 
			
		||||
            (identical(other.accountId, accountId) ||
 | 
			
		||||
@@ -255,12 +271,13 @@ class _SnCheckInRecord extends SnCheckInRecord {
 | 
			
		||||
      resultTier,
 | 
			
		||||
      resultExperience,
 | 
			
		||||
      resultCoin,
 | 
			
		||||
      currentStreak,
 | 
			
		||||
      const DeepCollectionEquality().hash(_resultModifiers),
 | 
			
		||||
      accountId);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultCoin: $resultCoin, resultModifiers: $resultModifiers, accountId: $accountId)';
 | 
			
		||||
    return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultCoin: $resultCoin, currentStreak: $currentStreak, resultModifiers: $resultModifiers, accountId: $accountId)';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -280,6 +297,7 @@ abstract mixin class _$SnCheckInRecordCopyWith<$Res>
 | 
			
		||||
      int resultTier,
 | 
			
		||||
      int resultExperience,
 | 
			
		||||
      double resultCoin,
 | 
			
		||||
      int currentStreak,
 | 
			
		||||
      List<int> resultModifiers,
 | 
			
		||||
      int accountId});
 | 
			
		||||
}
 | 
			
		||||
@@ -304,6 +322,7 @@ class __$SnCheckInRecordCopyWithImpl<$Res>
 | 
			
		||||
    Object? resultTier = null,
 | 
			
		||||
    Object? resultExperience = null,
 | 
			
		||||
    Object? resultCoin = null,
 | 
			
		||||
    Object? currentStreak = null,
 | 
			
		||||
    Object? resultModifiers = null,
 | 
			
		||||
    Object? accountId = null,
 | 
			
		||||
  }) {
 | 
			
		||||
@@ -336,6 +355,10 @@ class __$SnCheckInRecordCopyWithImpl<$Res>
 | 
			
		||||
          ? _self.resultCoin
 | 
			
		||||
          : resultCoin // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as double,
 | 
			
		||||
      currentStreak: null == currentStreak
 | 
			
		||||
          ? _self.currentStreak
 | 
			
		||||
          : currentStreak // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      resultModifiers: null == resultModifiers
 | 
			
		||||
          ? _self._resultModifiers
 | 
			
		||||
          : resultModifiers // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
 
 | 
			
		||||
@@ -17,6 +17,7 @@ _SnCheckInRecord _$SnCheckInRecordFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      resultTier: (json['result_tier'] as num).toInt(),
 | 
			
		||||
      resultExperience: (json['result_experience'] as num).toInt(),
 | 
			
		||||
      resultCoin: (json['result_coin'] as num).toDouble(),
 | 
			
		||||
      currentStreak: (json['current_streak'] as num?)?.toInt() ?? 0,
 | 
			
		||||
      resultModifiers: (json['result_modifiers'] as List<dynamic>)
 | 
			
		||||
          .map((e) => (e as num).toInt())
 | 
			
		||||
          .toList(),
 | 
			
		||||
@@ -32,6 +33,7 @@ Map<String, dynamic> _$SnCheckInRecordToJson(_SnCheckInRecord instance) =>
 | 
			
		||||
      'result_tier': instance.resultTier,
 | 
			
		||||
      'result_experience': instance.resultExperience,
 | 
			
		||||
      'result_coin': instance.resultCoin,
 | 
			
		||||
      'current_streak': instance.currentStreak,
 | 
			
		||||
      'result_modifiers': instance.resultModifiers,
 | 
			
		||||
      'account_id': instance.accountId,
 | 
			
		||||
    };
 | 
			
		||||
 
 | 
			
		||||
@@ -166,3 +166,53 @@ abstract class SnSubscription with _$SnSubscription {
 | 
			
		||||
  factory SnSubscription.fromJson(Map<String, Object?> json) =>
 | 
			
		||||
      _$SnSubscriptionFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
abstract class SnFeedEntry with _$SnFeedEntry {
 | 
			
		||||
  const factory SnFeedEntry({
 | 
			
		||||
    required String type,
 | 
			
		||||
    required dynamic data,
 | 
			
		||||
    required DateTime createdAt,
 | 
			
		||||
  }) = _SnFeedEntry;
 | 
			
		||||
 | 
			
		||||
  factory SnFeedEntry.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$SnFeedEntryFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
abstract class SnFediversePost with _$SnFediversePost {
 | 
			
		||||
  const factory SnFediversePost({
 | 
			
		||||
    required int id,
 | 
			
		||||
    required DateTime createdAt,
 | 
			
		||||
    required DateTime updatedAt,
 | 
			
		||||
    required DateTime? deletedAt,
 | 
			
		||||
    required String identifier,
 | 
			
		||||
    required String origin,
 | 
			
		||||
    required String content,
 | 
			
		||||
    required String language,
 | 
			
		||||
    required List<String> images,
 | 
			
		||||
    required SnFediverseUser user,
 | 
			
		||||
    required int userId,
 | 
			
		||||
  }) = _SnFediversePost;
 | 
			
		||||
 | 
			
		||||
  factory SnFediversePost.fromJson(Map<String, Object?> json) =>
 | 
			
		||||
      _$SnFediversePostFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
abstract class SnFediverseUser with _$SnFediverseUser {
 | 
			
		||||
  const factory SnFediverseUser({
 | 
			
		||||
    required int id,
 | 
			
		||||
    required DateTime createdAt,
 | 
			
		||||
    required DateTime updatedAt,
 | 
			
		||||
    required DateTime? deletedAt,
 | 
			
		||||
    required String identifier,
 | 
			
		||||
    required String origin,
 | 
			
		||||
    required String avatar,
 | 
			
		||||
    required String name,
 | 
			
		||||
    required String nick,
 | 
			
		||||
  }) = _SnFediverseUser;
 | 
			
		||||
 | 
			
		||||
  factory SnFediverseUser.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$SnFediverseUserFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -3120,4 +3120,874 @@ class __$SnSubscriptionCopyWithImpl<$Res>
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
mixin _$SnFeedEntry {
 | 
			
		||||
  String get type;
 | 
			
		||||
  dynamic get data;
 | 
			
		||||
  DateTime get createdAt;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnFeedEntry
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  $SnFeedEntryCopyWith<SnFeedEntry> get copyWith =>
 | 
			
		||||
      _$SnFeedEntryCopyWithImpl<SnFeedEntry>(this as SnFeedEntry, _$identity);
 | 
			
		||||
 | 
			
		||||
  /// Serializes this SnFeedEntry to a JSON map.
 | 
			
		||||
  Map<String, dynamic> toJson();
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    return identical(this, other) ||
 | 
			
		||||
        (other.runtimeType == runtimeType &&
 | 
			
		||||
            other is SnFeedEntry &&
 | 
			
		||||
            (identical(other.type, type) || other.type == type) &&
 | 
			
		||||
            const DeepCollectionEquality().equals(other.data, data) &&
 | 
			
		||||
            (identical(other.createdAt, createdAt) ||
 | 
			
		||||
                other.createdAt == createdAt));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => Object.hash(
 | 
			
		||||
      runtimeType, type, const DeepCollectionEquality().hash(data), createdAt);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnFeedEntry(type: $type, data: $data, createdAt: $createdAt)';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract mixin class $SnFeedEntryCopyWith<$Res> {
 | 
			
		||||
  factory $SnFeedEntryCopyWith(
 | 
			
		||||
          SnFeedEntry value, $Res Function(SnFeedEntry) _then) =
 | 
			
		||||
      _$SnFeedEntryCopyWithImpl;
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call({String type, dynamic data, DateTime createdAt});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class _$SnFeedEntryCopyWithImpl<$Res> implements $SnFeedEntryCopyWith<$Res> {
 | 
			
		||||
  _$SnFeedEntryCopyWithImpl(this._self, this._then);
 | 
			
		||||
 | 
			
		||||
  final SnFeedEntry _self;
 | 
			
		||||
  final $Res Function(SnFeedEntry) _then;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnFeedEntry
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  @override
 | 
			
		||||
  $Res call({
 | 
			
		||||
    Object? type = null,
 | 
			
		||||
    Object? data = freezed,
 | 
			
		||||
    Object? createdAt = null,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_self.copyWith(
 | 
			
		||||
      type: null == type
 | 
			
		||||
          ? _self.type
 | 
			
		||||
          : type // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      data: freezed == data
 | 
			
		||||
          ? _self.data
 | 
			
		||||
          : data // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as dynamic,
 | 
			
		||||
      createdAt: null == createdAt
 | 
			
		||||
          ? _self.createdAt
 | 
			
		||||
          : createdAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
    ));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@JsonSerializable()
 | 
			
		||||
class _SnFeedEntry implements SnFeedEntry {
 | 
			
		||||
  const _SnFeedEntry(
 | 
			
		||||
      {required this.type, required this.data, required this.createdAt});
 | 
			
		||||
  factory _SnFeedEntry.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$SnFeedEntryFromJson(json);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  final String type;
 | 
			
		||||
  @override
 | 
			
		||||
  final dynamic data;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime createdAt;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnFeedEntry
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  _$SnFeedEntryCopyWith<_SnFeedEntry> get copyWith =>
 | 
			
		||||
      __$SnFeedEntryCopyWithImpl<_SnFeedEntry>(this, _$identity);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    return _$SnFeedEntryToJson(
 | 
			
		||||
      this,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    return identical(this, other) ||
 | 
			
		||||
        (other.runtimeType == runtimeType &&
 | 
			
		||||
            other is _SnFeedEntry &&
 | 
			
		||||
            (identical(other.type, type) || other.type == type) &&
 | 
			
		||||
            const DeepCollectionEquality().equals(other.data, data) &&
 | 
			
		||||
            (identical(other.createdAt, createdAt) ||
 | 
			
		||||
                other.createdAt == createdAt));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => Object.hash(
 | 
			
		||||
      runtimeType, type, const DeepCollectionEquality().hash(data), createdAt);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnFeedEntry(type: $type, data: $data, createdAt: $createdAt)';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract mixin class _$SnFeedEntryCopyWith<$Res>
 | 
			
		||||
    implements $SnFeedEntryCopyWith<$Res> {
 | 
			
		||||
  factory _$SnFeedEntryCopyWith(
 | 
			
		||||
          _SnFeedEntry value, $Res Function(_SnFeedEntry) _then) =
 | 
			
		||||
      __$SnFeedEntryCopyWithImpl;
 | 
			
		||||
  @override
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call({String type, dynamic data, DateTime createdAt});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class __$SnFeedEntryCopyWithImpl<$Res> implements _$SnFeedEntryCopyWith<$Res> {
 | 
			
		||||
  __$SnFeedEntryCopyWithImpl(this._self, this._then);
 | 
			
		||||
 | 
			
		||||
  final _SnFeedEntry _self;
 | 
			
		||||
  final $Res Function(_SnFeedEntry) _then;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnFeedEntry
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  $Res call({
 | 
			
		||||
    Object? type = null,
 | 
			
		||||
    Object? data = freezed,
 | 
			
		||||
    Object? createdAt = null,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_SnFeedEntry(
 | 
			
		||||
      type: null == type
 | 
			
		||||
          ? _self.type
 | 
			
		||||
          : type // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      data: freezed == data
 | 
			
		||||
          ? _self.data
 | 
			
		||||
          : data // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as dynamic,
 | 
			
		||||
      createdAt: null == createdAt
 | 
			
		||||
          ? _self.createdAt
 | 
			
		||||
          : createdAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
    ));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
mixin _$SnFediversePost {
 | 
			
		||||
  int get id;
 | 
			
		||||
  DateTime get createdAt;
 | 
			
		||||
  DateTime get updatedAt;
 | 
			
		||||
  DateTime? get deletedAt;
 | 
			
		||||
  String get identifier;
 | 
			
		||||
  String get origin;
 | 
			
		||||
  String get content;
 | 
			
		||||
  String get language;
 | 
			
		||||
  List<String> get images;
 | 
			
		||||
  SnFediverseUser get user;
 | 
			
		||||
  int get userId;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnFediversePost
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  $SnFediversePostCopyWith<SnFediversePost> get copyWith =>
 | 
			
		||||
      _$SnFediversePostCopyWithImpl<SnFediversePost>(
 | 
			
		||||
          this as SnFediversePost, _$identity);
 | 
			
		||||
 | 
			
		||||
  /// Serializes this SnFediversePost to a JSON map.
 | 
			
		||||
  Map<String, dynamic> toJson();
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    return identical(this, other) ||
 | 
			
		||||
        (other.runtimeType == runtimeType &&
 | 
			
		||||
            other is SnFediversePost &&
 | 
			
		||||
            (identical(other.id, id) || other.id == id) &&
 | 
			
		||||
            (identical(other.createdAt, createdAt) ||
 | 
			
		||||
                other.createdAt == createdAt) &&
 | 
			
		||||
            (identical(other.updatedAt, updatedAt) ||
 | 
			
		||||
                other.updatedAt == updatedAt) &&
 | 
			
		||||
            (identical(other.deletedAt, deletedAt) ||
 | 
			
		||||
                other.deletedAt == deletedAt) &&
 | 
			
		||||
            (identical(other.identifier, identifier) ||
 | 
			
		||||
                other.identifier == identifier) &&
 | 
			
		||||
            (identical(other.origin, origin) || other.origin == origin) &&
 | 
			
		||||
            (identical(other.content, content) || other.content == content) &&
 | 
			
		||||
            (identical(other.language, language) ||
 | 
			
		||||
                other.language == language) &&
 | 
			
		||||
            const DeepCollectionEquality().equals(other.images, images) &&
 | 
			
		||||
            (identical(other.user, user) || other.user == user) &&
 | 
			
		||||
            (identical(other.userId, userId) || other.userId == userId));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => Object.hash(
 | 
			
		||||
      runtimeType,
 | 
			
		||||
      id,
 | 
			
		||||
      createdAt,
 | 
			
		||||
      updatedAt,
 | 
			
		||||
      deletedAt,
 | 
			
		||||
      identifier,
 | 
			
		||||
      origin,
 | 
			
		||||
      content,
 | 
			
		||||
      language,
 | 
			
		||||
      const DeepCollectionEquality().hash(images),
 | 
			
		||||
      user,
 | 
			
		||||
      userId);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnFediversePost(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, identifier: $identifier, origin: $origin, content: $content, language: $language, images: $images, user: $user, userId: $userId)';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract mixin class $SnFediversePostCopyWith<$Res> {
 | 
			
		||||
  factory $SnFediversePostCopyWith(
 | 
			
		||||
          SnFediversePost value, $Res Function(SnFediversePost) _then) =
 | 
			
		||||
      _$SnFediversePostCopyWithImpl;
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {int id,
 | 
			
		||||
      DateTime createdAt,
 | 
			
		||||
      DateTime updatedAt,
 | 
			
		||||
      DateTime? deletedAt,
 | 
			
		||||
      String identifier,
 | 
			
		||||
      String origin,
 | 
			
		||||
      String content,
 | 
			
		||||
      String language,
 | 
			
		||||
      List<String> images,
 | 
			
		||||
      SnFediverseUser user,
 | 
			
		||||
      int userId});
 | 
			
		||||
 | 
			
		||||
  $SnFediverseUserCopyWith<$Res> get user;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class _$SnFediversePostCopyWithImpl<$Res>
 | 
			
		||||
    implements $SnFediversePostCopyWith<$Res> {
 | 
			
		||||
  _$SnFediversePostCopyWithImpl(this._self, this._then);
 | 
			
		||||
 | 
			
		||||
  final SnFediversePost _self;
 | 
			
		||||
  final $Res Function(SnFediversePost) _then;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnFediversePost
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  @override
 | 
			
		||||
  $Res call({
 | 
			
		||||
    Object? id = null,
 | 
			
		||||
    Object? createdAt = null,
 | 
			
		||||
    Object? updatedAt = null,
 | 
			
		||||
    Object? deletedAt = freezed,
 | 
			
		||||
    Object? identifier = null,
 | 
			
		||||
    Object? origin = null,
 | 
			
		||||
    Object? content = null,
 | 
			
		||||
    Object? language = null,
 | 
			
		||||
    Object? images = null,
 | 
			
		||||
    Object? user = null,
 | 
			
		||||
    Object? userId = null,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_self.copyWith(
 | 
			
		||||
      id: null == id
 | 
			
		||||
          ? _self.id
 | 
			
		||||
          : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      createdAt: null == createdAt
 | 
			
		||||
          ? _self.createdAt
 | 
			
		||||
          : createdAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      updatedAt: null == updatedAt
 | 
			
		||||
          ? _self.updatedAt
 | 
			
		||||
          : updatedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      deletedAt: freezed == deletedAt
 | 
			
		||||
          ? _self.deletedAt
 | 
			
		||||
          : deletedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime?,
 | 
			
		||||
      identifier: null == identifier
 | 
			
		||||
          ? _self.identifier
 | 
			
		||||
          : identifier // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      origin: null == origin
 | 
			
		||||
          ? _self.origin
 | 
			
		||||
          : origin // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      content: null == content
 | 
			
		||||
          ? _self.content
 | 
			
		||||
          : content // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      language: null == language
 | 
			
		||||
          ? _self.language
 | 
			
		||||
          : language // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      images: null == images
 | 
			
		||||
          ? _self.images
 | 
			
		||||
          : images // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as List<String>,
 | 
			
		||||
      user: null == user
 | 
			
		||||
          ? _self.user
 | 
			
		||||
          : user // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as SnFediverseUser,
 | 
			
		||||
      userId: null == userId
 | 
			
		||||
          ? _self.userId
 | 
			
		||||
          : userId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
    ));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnFediversePost
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  $SnFediverseUserCopyWith<$Res> get user {
 | 
			
		||||
    return $SnFediverseUserCopyWith<$Res>(_self.user, (value) {
 | 
			
		||||
      return _then(_self.copyWith(user: value));
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@JsonSerializable()
 | 
			
		||||
class _SnFediversePost implements SnFediversePost {
 | 
			
		||||
  const _SnFediversePost(
 | 
			
		||||
      {required this.id,
 | 
			
		||||
      required this.createdAt,
 | 
			
		||||
      required this.updatedAt,
 | 
			
		||||
      required this.deletedAt,
 | 
			
		||||
      required this.identifier,
 | 
			
		||||
      required this.origin,
 | 
			
		||||
      required this.content,
 | 
			
		||||
      required this.language,
 | 
			
		||||
      required final List<String> images,
 | 
			
		||||
      required this.user,
 | 
			
		||||
      required this.userId})
 | 
			
		||||
      : _images = images;
 | 
			
		||||
  factory _SnFediversePost.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$SnFediversePostFromJson(json);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  final int id;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime createdAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime updatedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime? deletedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final String identifier;
 | 
			
		||||
  @override
 | 
			
		||||
  final String origin;
 | 
			
		||||
  @override
 | 
			
		||||
  final String content;
 | 
			
		||||
  @override
 | 
			
		||||
  final String language;
 | 
			
		||||
  final List<String> _images;
 | 
			
		||||
  @override
 | 
			
		||||
  List<String> get images {
 | 
			
		||||
    if (_images is EqualUnmodifiableListView) return _images;
 | 
			
		||||
    // ignore: implicit_dynamic_type
 | 
			
		||||
    return EqualUnmodifiableListView(_images);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  final SnFediverseUser user;
 | 
			
		||||
  @override
 | 
			
		||||
  final int userId;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnFediversePost
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  _$SnFediversePostCopyWith<_SnFediversePost> get copyWith =>
 | 
			
		||||
      __$SnFediversePostCopyWithImpl<_SnFediversePost>(this, _$identity);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    return _$SnFediversePostToJson(
 | 
			
		||||
      this,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    return identical(this, other) ||
 | 
			
		||||
        (other.runtimeType == runtimeType &&
 | 
			
		||||
            other is _SnFediversePost &&
 | 
			
		||||
            (identical(other.id, id) || other.id == id) &&
 | 
			
		||||
            (identical(other.createdAt, createdAt) ||
 | 
			
		||||
                other.createdAt == createdAt) &&
 | 
			
		||||
            (identical(other.updatedAt, updatedAt) ||
 | 
			
		||||
                other.updatedAt == updatedAt) &&
 | 
			
		||||
            (identical(other.deletedAt, deletedAt) ||
 | 
			
		||||
                other.deletedAt == deletedAt) &&
 | 
			
		||||
            (identical(other.identifier, identifier) ||
 | 
			
		||||
                other.identifier == identifier) &&
 | 
			
		||||
            (identical(other.origin, origin) || other.origin == origin) &&
 | 
			
		||||
            (identical(other.content, content) || other.content == content) &&
 | 
			
		||||
            (identical(other.language, language) ||
 | 
			
		||||
                other.language == language) &&
 | 
			
		||||
            const DeepCollectionEquality().equals(other._images, _images) &&
 | 
			
		||||
            (identical(other.user, user) || other.user == user) &&
 | 
			
		||||
            (identical(other.userId, userId) || other.userId == userId));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => Object.hash(
 | 
			
		||||
      runtimeType,
 | 
			
		||||
      id,
 | 
			
		||||
      createdAt,
 | 
			
		||||
      updatedAt,
 | 
			
		||||
      deletedAt,
 | 
			
		||||
      identifier,
 | 
			
		||||
      origin,
 | 
			
		||||
      content,
 | 
			
		||||
      language,
 | 
			
		||||
      const DeepCollectionEquality().hash(_images),
 | 
			
		||||
      user,
 | 
			
		||||
      userId);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnFediversePost(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, identifier: $identifier, origin: $origin, content: $content, language: $language, images: $images, user: $user, userId: $userId)';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract mixin class _$SnFediversePostCopyWith<$Res>
 | 
			
		||||
    implements $SnFediversePostCopyWith<$Res> {
 | 
			
		||||
  factory _$SnFediversePostCopyWith(
 | 
			
		||||
          _SnFediversePost value, $Res Function(_SnFediversePost) _then) =
 | 
			
		||||
      __$SnFediversePostCopyWithImpl;
 | 
			
		||||
  @override
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {int id,
 | 
			
		||||
      DateTime createdAt,
 | 
			
		||||
      DateTime updatedAt,
 | 
			
		||||
      DateTime? deletedAt,
 | 
			
		||||
      String identifier,
 | 
			
		||||
      String origin,
 | 
			
		||||
      String content,
 | 
			
		||||
      String language,
 | 
			
		||||
      List<String> images,
 | 
			
		||||
      SnFediverseUser user,
 | 
			
		||||
      int userId});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  $SnFediverseUserCopyWith<$Res> get user;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class __$SnFediversePostCopyWithImpl<$Res>
 | 
			
		||||
    implements _$SnFediversePostCopyWith<$Res> {
 | 
			
		||||
  __$SnFediversePostCopyWithImpl(this._self, this._then);
 | 
			
		||||
 | 
			
		||||
  final _SnFediversePost _self;
 | 
			
		||||
  final $Res Function(_SnFediversePost) _then;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnFediversePost
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  $Res call({
 | 
			
		||||
    Object? id = null,
 | 
			
		||||
    Object? createdAt = null,
 | 
			
		||||
    Object? updatedAt = null,
 | 
			
		||||
    Object? deletedAt = freezed,
 | 
			
		||||
    Object? identifier = null,
 | 
			
		||||
    Object? origin = null,
 | 
			
		||||
    Object? content = null,
 | 
			
		||||
    Object? language = null,
 | 
			
		||||
    Object? images = null,
 | 
			
		||||
    Object? user = null,
 | 
			
		||||
    Object? userId = null,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_SnFediversePost(
 | 
			
		||||
      id: null == id
 | 
			
		||||
          ? _self.id
 | 
			
		||||
          : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      createdAt: null == createdAt
 | 
			
		||||
          ? _self.createdAt
 | 
			
		||||
          : createdAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      updatedAt: null == updatedAt
 | 
			
		||||
          ? _self.updatedAt
 | 
			
		||||
          : updatedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      deletedAt: freezed == deletedAt
 | 
			
		||||
          ? _self.deletedAt
 | 
			
		||||
          : deletedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime?,
 | 
			
		||||
      identifier: null == identifier
 | 
			
		||||
          ? _self.identifier
 | 
			
		||||
          : identifier // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      origin: null == origin
 | 
			
		||||
          ? _self.origin
 | 
			
		||||
          : origin // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      content: null == content
 | 
			
		||||
          ? _self.content
 | 
			
		||||
          : content // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      language: null == language
 | 
			
		||||
          ? _self.language
 | 
			
		||||
          : language // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      images: null == images
 | 
			
		||||
          ? _self._images
 | 
			
		||||
          : images // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as List<String>,
 | 
			
		||||
      user: null == user
 | 
			
		||||
          ? _self.user
 | 
			
		||||
          : user // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as SnFediverseUser,
 | 
			
		||||
      userId: null == userId
 | 
			
		||||
          ? _self.userId
 | 
			
		||||
          : userId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
    ));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnFediversePost
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  $SnFediverseUserCopyWith<$Res> get user {
 | 
			
		||||
    return $SnFediverseUserCopyWith<$Res>(_self.user, (value) {
 | 
			
		||||
      return _then(_self.copyWith(user: value));
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
mixin _$SnFediverseUser {
 | 
			
		||||
  int get id;
 | 
			
		||||
  DateTime get createdAt;
 | 
			
		||||
  DateTime get updatedAt;
 | 
			
		||||
  DateTime? get deletedAt;
 | 
			
		||||
  String get identifier;
 | 
			
		||||
  String get origin;
 | 
			
		||||
  String get avatar;
 | 
			
		||||
  String get name;
 | 
			
		||||
  String get nick;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnFediverseUser
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  $SnFediverseUserCopyWith<SnFediverseUser> get copyWith =>
 | 
			
		||||
      _$SnFediverseUserCopyWithImpl<SnFediverseUser>(
 | 
			
		||||
          this as SnFediverseUser, _$identity);
 | 
			
		||||
 | 
			
		||||
  /// Serializes this SnFediverseUser to a JSON map.
 | 
			
		||||
  Map<String, dynamic> toJson();
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    return identical(this, other) ||
 | 
			
		||||
        (other.runtimeType == runtimeType &&
 | 
			
		||||
            other is SnFediverseUser &&
 | 
			
		||||
            (identical(other.id, id) || other.id == id) &&
 | 
			
		||||
            (identical(other.createdAt, createdAt) ||
 | 
			
		||||
                other.createdAt == createdAt) &&
 | 
			
		||||
            (identical(other.updatedAt, updatedAt) ||
 | 
			
		||||
                other.updatedAt == updatedAt) &&
 | 
			
		||||
            (identical(other.deletedAt, deletedAt) ||
 | 
			
		||||
                other.deletedAt == deletedAt) &&
 | 
			
		||||
            (identical(other.identifier, identifier) ||
 | 
			
		||||
                other.identifier == identifier) &&
 | 
			
		||||
            (identical(other.origin, origin) || other.origin == origin) &&
 | 
			
		||||
            (identical(other.avatar, avatar) || other.avatar == avatar) &&
 | 
			
		||||
            (identical(other.name, name) || other.name == name) &&
 | 
			
		||||
            (identical(other.nick, nick) || other.nick == nick));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt,
 | 
			
		||||
      deletedAt, identifier, origin, avatar, name, nick);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnFediverseUser(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, identifier: $identifier, origin: $origin, avatar: $avatar, name: $name, nick: $nick)';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract mixin class $SnFediverseUserCopyWith<$Res> {
 | 
			
		||||
  factory $SnFediverseUserCopyWith(
 | 
			
		||||
          SnFediverseUser value, $Res Function(SnFediverseUser) _then) =
 | 
			
		||||
      _$SnFediverseUserCopyWithImpl;
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {int id,
 | 
			
		||||
      DateTime createdAt,
 | 
			
		||||
      DateTime updatedAt,
 | 
			
		||||
      DateTime? deletedAt,
 | 
			
		||||
      String identifier,
 | 
			
		||||
      String origin,
 | 
			
		||||
      String avatar,
 | 
			
		||||
      String name,
 | 
			
		||||
      String nick});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class _$SnFediverseUserCopyWithImpl<$Res>
 | 
			
		||||
    implements $SnFediverseUserCopyWith<$Res> {
 | 
			
		||||
  _$SnFediverseUserCopyWithImpl(this._self, this._then);
 | 
			
		||||
 | 
			
		||||
  final SnFediverseUser _self;
 | 
			
		||||
  final $Res Function(SnFediverseUser) _then;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnFediverseUser
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  @override
 | 
			
		||||
  $Res call({
 | 
			
		||||
    Object? id = null,
 | 
			
		||||
    Object? createdAt = null,
 | 
			
		||||
    Object? updatedAt = null,
 | 
			
		||||
    Object? deletedAt = freezed,
 | 
			
		||||
    Object? identifier = null,
 | 
			
		||||
    Object? origin = null,
 | 
			
		||||
    Object? avatar = null,
 | 
			
		||||
    Object? name = null,
 | 
			
		||||
    Object? nick = null,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_self.copyWith(
 | 
			
		||||
      id: null == id
 | 
			
		||||
          ? _self.id
 | 
			
		||||
          : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      createdAt: null == createdAt
 | 
			
		||||
          ? _self.createdAt
 | 
			
		||||
          : createdAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      updatedAt: null == updatedAt
 | 
			
		||||
          ? _self.updatedAt
 | 
			
		||||
          : updatedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      deletedAt: freezed == deletedAt
 | 
			
		||||
          ? _self.deletedAt
 | 
			
		||||
          : deletedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime?,
 | 
			
		||||
      identifier: null == identifier
 | 
			
		||||
          ? _self.identifier
 | 
			
		||||
          : identifier // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      origin: null == origin
 | 
			
		||||
          ? _self.origin
 | 
			
		||||
          : origin // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      avatar: null == avatar
 | 
			
		||||
          ? _self.avatar
 | 
			
		||||
          : avatar // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      name: null == name
 | 
			
		||||
          ? _self.name
 | 
			
		||||
          : name // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      nick: null == nick
 | 
			
		||||
          ? _self.nick
 | 
			
		||||
          : nick // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
    ));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@JsonSerializable()
 | 
			
		||||
class _SnFediverseUser implements SnFediverseUser {
 | 
			
		||||
  const _SnFediverseUser(
 | 
			
		||||
      {required this.id,
 | 
			
		||||
      required this.createdAt,
 | 
			
		||||
      required this.updatedAt,
 | 
			
		||||
      required this.deletedAt,
 | 
			
		||||
      required this.identifier,
 | 
			
		||||
      required this.origin,
 | 
			
		||||
      required this.avatar,
 | 
			
		||||
      required this.name,
 | 
			
		||||
      required this.nick});
 | 
			
		||||
  factory _SnFediverseUser.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$SnFediverseUserFromJson(json);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  final int id;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime createdAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime updatedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime? deletedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final String identifier;
 | 
			
		||||
  @override
 | 
			
		||||
  final String origin;
 | 
			
		||||
  @override
 | 
			
		||||
  final String avatar;
 | 
			
		||||
  @override
 | 
			
		||||
  final String name;
 | 
			
		||||
  @override
 | 
			
		||||
  final String nick;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnFediverseUser
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  _$SnFediverseUserCopyWith<_SnFediverseUser> get copyWith =>
 | 
			
		||||
      __$SnFediverseUserCopyWithImpl<_SnFediverseUser>(this, _$identity);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    return _$SnFediverseUserToJson(
 | 
			
		||||
      this,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    return identical(this, other) ||
 | 
			
		||||
        (other.runtimeType == runtimeType &&
 | 
			
		||||
            other is _SnFediverseUser &&
 | 
			
		||||
            (identical(other.id, id) || other.id == id) &&
 | 
			
		||||
            (identical(other.createdAt, createdAt) ||
 | 
			
		||||
                other.createdAt == createdAt) &&
 | 
			
		||||
            (identical(other.updatedAt, updatedAt) ||
 | 
			
		||||
                other.updatedAt == updatedAt) &&
 | 
			
		||||
            (identical(other.deletedAt, deletedAt) ||
 | 
			
		||||
                other.deletedAt == deletedAt) &&
 | 
			
		||||
            (identical(other.identifier, identifier) ||
 | 
			
		||||
                other.identifier == identifier) &&
 | 
			
		||||
            (identical(other.origin, origin) || other.origin == origin) &&
 | 
			
		||||
            (identical(other.avatar, avatar) || other.avatar == avatar) &&
 | 
			
		||||
            (identical(other.name, name) || other.name == name) &&
 | 
			
		||||
            (identical(other.nick, nick) || other.nick == nick));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt,
 | 
			
		||||
      deletedAt, identifier, origin, avatar, name, nick);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnFediverseUser(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, identifier: $identifier, origin: $origin, avatar: $avatar, name: $name, nick: $nick)';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract mixin class _$SnFediverseUserCopyWith<$Res>
 | 
			
		||||
    implements $SnFediverseUserCopyWith<$Res> {
 | 
			
		||||
  factory _$SnFediverseUserCopyWith(
 | 
			
		||||
          _SnFediverseUser value, $Res Function(_SnFediverseUser) _then) =
 | 
			
		||||
      __$SnFediverseUserCopyWithImpl;
 | 
			
		||||
  @override
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {int id,
 | 
			
		||||
      DateTime createdAt,
 | 
			
		||||
      DateTime updatedAt,
 | 
			
		||||
      DateTime? deletedAt,
 | 
			
		||||
      String identifier,
 | 
			
		||||
      String origin,
 | 
			
		||||
      String avatar,
 | 
			
		||||
      String name,
 | 
			
		||||
      String nick});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class __$SnFediverseUserCopyWithImpl<$Res>
 | 
			
		||||
    implements _$SnFediverseUserCopyWith<$Res> {
 | 
			
		||||
  __$SnFediverseUserCopyWithImpl(this._self, this._then);
 | 
			
		||||
 | 
			
		||||
  final _SnFediverseUser _self;
 | 
			
		||||
  final $Res Function(_SnFediverseUser) _then;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnFediverseUser
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  $Res call({
 | 
			
		||||
    Object? id = null,
 | 
			
		||||
    Object? createdAt = null,
 | 
			
		||||
    Object? updatedAt = null,
 | 
			
		||||
    Object? deletedAt = freezed,
 | 
			
		||||
    Object? identifier = null,
 | 
			
		||||
    Object? origin = null,
 | 
			
		||||
    Object? avatar = null,
 | 
			
		||||
    Object? name = null,
 | 
			
		||||
    Object? nick = null,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_SnFediverseUser(
 | 
			
		||||
      id: null == id
 | 
			
		||||
          ? _self.id
 | 
			
		||||
          : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      createdAt: null == createdAt
 | 
			
		||||
          ? _self.createdAt
 | 
			
		||||
          : createdAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      updatedAt: null == updatedAt
 | 
			
		||||
          ? _self.updatedAt
 | 
			
		||||
          : updatedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      deletedAt: freezed == deletedAt
 | 
			
		||||
          ? _self.deletedAt
 | 
			
		||||
          : deletedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime?,
 | 
			
		||||
      identifier: null == identifier
 | 
			
		||||
          ? _self.identifier
 | 
			
		||||
          : identifier // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      origin: null == origin
 | 
			
		||||
          ? _self.origin
 | 
			
		||||
          : origin // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      avatar: null == avatar
 | 
			
		||||
          ? _self.avatar
 | 
			
		||||
          : avatar // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      name: null == name
 | 
			
		||||
          ? _self.name
 | 
			
		||||
          : name // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      nick: null == nick
 | 
			
		||||
          ? _self.nick
 | 
			
		||||
          : nick // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
    ));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// dart format on
 | 
			
		||||
 
 | 
			
		||||
@@ -282,3 +282,77 @@ Map<String, dynamic> _$SnSubscriptionToJson(_SnSubscription instance) =>
 | 
			
		||||
      'follower_id': instance.followerId,
 | 
			
		||||
      'account_id': instance.accountId,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
_SnFeedEntry _$SnFeedEntryFromJson(Map<String, dynamic> json) => _SnFeedEntry(
 | 
			
		||||
      type: json['type'] as String,
 | 
			
		||||
      data: json['data'],
 | 
			
		||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$SnFeedEntryToJson(_SnFeedEntry instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'type': instance.type,
 | 
			
		||||
      'data': instance.data,
 | 
			
		||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
_SnFediversePost _$SnFediversePostFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
    _SnFediversePost(
 | 
			
		||||
      id: (json['id'] as num).toInt(),
 | 
			
		||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
			
		||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
			
		||||
      deletedAt: json['deleted_at'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : DateTime.parse(json['deleted_at'] as String),
 | 
			
		||||
      identifier: json['identifier'] as String,
 | 
			
		||||
      origin: json['origin'] as String,
 | 
			
		||||
      content: json['content'] as String,
 | 
			
		||||
      language: json['language'] as String,
 | 
			
		||||
      images:
 | 
			
		||||
          (json['images'] as List<dynamic>).map((e) => e as String).toList(),
 | 
			
		||||
      user: SnFediverseUser.fromJson(json['user'] as Map<String, dynamic>),
 | 
			
		||||
      userId: (json['user_id'] as num).toInt(),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$SnFediversePostToJson(_SnFediversePost instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'id': instance.id,
 | 
			
		||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
			
		||||
      'updated_at': instance.updatedAt.toIso8601String(),
 | 
			
		||||
      'deleted_at': instance.deletedAt?.toIso8601String(),
 | 
			
		||||
      'identifier': instance.identifier,
 | 
			
		||||
      'origin': instance.origin,
 | 
			
		||||
      'content': instance.content,
 | 
			
		||||
      'language': instance.language,
 | 
			
		||||
      'images': instance.images,
 | 
			
		||||
      'user': instance.user.toJson(),
 | 
			
		||||
      'user_id': instance.userId,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
_SnFediverseUser _$SnFediverseUserFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
    _SnFediverseUser(
 | 
			
		||||
      id: (json['id'] as num).toInt(),
 | 
			
		||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
			
		||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
			
		||||
      deletedAt: json['deleted_at'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : DateTime.parse(json['deleted_at'] as String),
 | 
			
		||||
      identifier: json['identifier'] as String,
 | 
			
		||||
      origin: json['origin'] as String,
 | 
			
		||||
      avatar: json['avatar'] as String,
 | 
			
		||||
      name: json['name'] as String,
 | 
			
		||||
      nick: json['nick'] as String,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$SnFediverseUserToJson(_SnFediverseUser instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'id': instance.id,
 | 
			
		||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
			
		||||
      'updated_at': instance.updatedAt.toIso8601String(),
 | 
			
		||||
      'deleted_at': instance.deletedAt?.toIso8601String(),
 | 
			
		||||
      'identifier': instance.identifier,
 | 
			
		||||
      'origin': instance.origin,
 | 
			
		||||
      'avatar': instance.avatar,
 | 
			
		||||
      'name': instance.name,
 | 
			
		||||
      'nick': instance.nick,
 | 
			
		||||
    };
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,8 @@ class AccountImage extends StatelessWidget {
 | 
			
		||||
  final double? borderRadius;
 | 
			
		||||
  final Widget? fallbackWidget;
 | 
			
		||||
  final Widget? badge;
 | 
			
		||||
  final Offset? badgeOffset;
 | 
			
		||||
  final FilterQuality? filterQuality;
 | 
			
		||||
 | 
			
		||||
  const AccountImage({
 | 
			
		||||
    super.key,
 | 
			
		||||
@@ -23,6 +25,8 @@ class AccountImage extends StatelessWidget {
 | 
			
		||||
    this.borderRadius,
 | 
			
		||||
    this.fallbackWidget,
 | 
			
		||||
    this.badge,
 | 
			
		||||
    this.badgeOffset,
 | 
			
		||||
    this.filterQuality,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -40,7 +44,8 @@ class AccountImage extends StatelessWidget {
 | 
			
		||||
            borderRadius: BorderRadius.circular(borderRadius ?? radius ?? 20),
 | 
			
		||||
            child: (content?.isEmpty ?? true)
 | 
			
		||||
                ? Container(
 | 
			
		||||
                    color: backgroundColor ?? Theme.of(context).colorScheme.primaryContainer,
 | 
			
		||||
                    color: backgroundColor ??
 | 
			
		||||
                        Theme.of(context).colorScheme.primaryContainer,
 | 
			
		||||
                    child: (fallbackWidget ??
 | 
			
		||||
                            Icon(
 | 
			
		||||
                              Symbols.account_circle,
 | 
			
		||||
@@ -51,6 +56,7 @@ class AccountImage extends StatelessWidget {
 | 
			
		||||
                  )
 | 
			
		||||
                : AutoResizeUniversalImage(
 | 
			
		||||
                    sn.getAttachmentUrl(url),
 | 
			
		||||
                    filterQuality: filterQuality,
 | 
			
		||||
                    key: Key('attachment-${content.hashCode}'),
 | 
			
		||||
                    fit: BoxFit.cover,
 | 
			
		||||
                  ),
 | 
			
		||||
@@ -58,8 +64,8 @@ class AccountImage extends StatelessWidget {
 | 
			
		||||
        ),
 | 
			
		||||
        if (badge != null)
 | 
			
		||||
          Positioned(
 | 
			
		||||
            right: -4,
 | 
			
		||||
            bottom: -2,
 | 
			
		||||
            right: badgeOffset?.dx ?? -4,
 | 
			
		||||
            bottom: badgeOffset?.dy ?? -2,
 | 
			
		||||
            child: badge!,
 | 
			
		||||
          ),
 | 
			
		||||
      ],
 | 
			
		||||
 
 | 
			
		||||
@@ -8,9 +8,9 @@ import 'package:relative_time/relative_time.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/experience.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/screens/account/profile_page.dart';
 | 
			
		||||
import 'package:surface/types/account.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/account/badge.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
 | 
			
		||||
class AccountPopoverCard extends StatelessWidget {
 | 
			
		||||
@@ -72,37 +72,21 @@ class AccountPopoverCard extends StatelessWidget {
 | 
			
		||||
            const Gap(8)
 | 
			
		||||
          ],
 | 
			
		||||
        ).padding(horizontal: 16),
 | 
			
		||||
        if (data.badges.isNotEmpty) const Gap(12),
 | 
			
		||||
        if (data.badges.isNotEmpty)
 | 
			
		||||
          Wrap(
 | 
			
		||||
            spacing: 4,
 | 
			
		||||
            children: data.badges
 | 
			
		||||
                .map(
 | 
			
		||||
                  (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,
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                  (ele) => AccountBadge(badge: ele),
 | 
			
		||||
                )
 | 
			
		||||
                .toList(),
 | 
			
		||||
          ).padding(horizontal: 24),
 | 
			
		||||
        const Gap(8),
 | 
			
		||||
          ).padding(horizontal: 24, bottom: 12, top: 12),
 | 
			
		||||
        if (data.profile?.description.isNotEmpty ?? false)
 | 
			
		||||
          Text(
 | 
			
		||||
            data.profile?.description ?? '',
 | 
			
		||||
            maxLines: 2,
 | 
			
		||||
            overflow: TextOverflow.ellipsis,
 | 
			
		||||
          ).padding(horizontal: 26, bottom: 8),
 | 
			
		||||
        Row(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
          children: [
 | 
			
		||||
@@ -110,7 +94,9 @@ class AccountPopoverCard extends StatelessWidget {
 | 
			
		||||
            const Gap(8),
 | 
			
		||||
            Text('Lv${getLevelFromExp(data.profile?.experience ?? 0)}'),
 | 
			
		||||
            const Gap(8),
 | 
			
		||||
            Text(calcLevelUpProgressLevel(data.profile?.experience ?? 0)).fontSize(11).opacity(0.5),
 | 
			
		||||
            Text(calcLevelUpProgressLevel(data.profile?.experience ?? 0))
 | 
			
		||||
                .fontSize(11)
 | 
			
		||||
                .opacity(0.5),
 | 
			
		||||
            const Gap(8),
 | 
			
		||||
            Container(
 | 
			
		||||
              width: double.infinity,
 | 
			
		||||
@@ -126,25 +112,36 @@ class AccountPopoverCard extends StatelessWidget {
 | 
			
		||||
        FutureBuilder(
 | 
			
		||||
          future: sn.client.get('/cgi/id/users/${data.name}/status'),
 | 
			
		||||
          builder: (context, snapshot) {
 | 
			
		||||
            final SnAccountStatusInfo? status =
 | 
			
		||||
                snapshot.hasData ? SnAccountStatusInfo.fromJson(snapshot.data!.data) : null;
 | 
			
		||||
            final SnAccountStatusInfo? status = snapshot.hasData
 | 
			
		||||
                ? SnAccountStatusInfo.fromJson(snapshot.data!.data)
 | 
			
		||||
                : null;
 | 
			
		||||
            return Row(
 | 
			
		||||
              children: [
 | 
			
		||||
                Icon(
 | 
			
		||||
                  Symbols.circle,
 | 
			
		||||
                  fill: 1,
 | 
			
		||||
                  (status?.isDisturbable ?? true)
 | 
			
		||||
                      ? Symbols.circle
 | 
			
		||||
                      : Symbols.do_not_disturb_on,
 | 
			
		||||
                  fill: (status?.isOnline ?? false) ? 1 : 0,
 | 
			
		||||
                  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),
 | 
			
		||||
                const Gap(8),
 | 
			
		||||
                Text(
 | 
			
		||||
                  status != null
 | 
			
		||||
                      ? status.isOnline
 | 
			
		||||
                          ? 'accountStatusOnline'.tr()
 | 
			
		||||
                          : 'accountStatusOffline'.tr()
 | 
			
		||||
                      ? (status.status?.label.isNotEmpty ?? false)
 | 
			
		||||
                          ? status.status!.label
 | 
			
		||||
                          : status.isOnline
 | 
			
		||||
                              ? 'accountStatusOnline'.tr()
 | 
			
		||||
                              : 'accountStatusOffline'.tr()
 | 
			
		||||
                      : 'loading'.tr(),
 | 
			
		||||
                ),
 | 
			
		||||
                if (status != null && !status.isOnline && status.lastSeenAt != null)
 | 
			
		||||
                if (status != null &&
 | 
			
		||||
                    !status.isOnline &&
 | 
			
		||||
                    status.lastSeenAt != null)
 | 
			
		||||
                  Text(
 | 
			
		||||
                    'accountStatusLastSeen'.tr(args: [
 | 
			
		||||
                      status.lastSeenAt != null
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										391
									
								
								lib/widgets/account/account_status.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										391
									
								
								lib/widgets/account/account_status.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,391 @@
 | 
			
		||||
import 'package:dio/dio.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';
 | 
			
		||||
 | 
			
		||||
final Map<String, (Widget, String, String?)> kPresetStatus = {
 | 
			
		||||
  'online': (
 | 
			
		||||
    const Icon(Symbols.circle, color: Colors.green, fill: 1),
 | 
			
		||||
    'accountStatusOnline'.tr(),
 | 
			
		||||
    null,
 | 
			
		||||
  ),
 | 
			
		||||
  'silent': (
 | 
			
		||||
    const Icon(Symbols.do_not_disturb_on, color: Colors.red),
 | 
			
		||||
    'accountStatusSilent'.tr(),
 | 
			
		||||
    'accountStatusSilentDesc'.tr(),
 | 
			
		||||
  ),
 | 
			
		||||
  'invisible': (
 | 
			
		||||
    const Icon(Symbols.circle, color: Colors.grey),
 | 
			
		||||
    'accountStatusInvisible'.tr(),
 | 
			
		||||
    'accountStatusInvisibleDesc'.tr(),
 | 
			
		||||
  ),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
class AccountStatusActionPopup extends StatefulWidget {
 | 
			
		||||
  final SnAccountStatusInfo? currentStatus;
 | 
			
		||||
  const AccountStatusActionPopup({super.key, this.currentStatus});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<AccountStatusActionPopup> createState() =>
 | 
			
		||||
      _AccountStatusActionPopupState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AccountStatusActionPopupState extends State<AccountStatusActionPopup> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
 | 
			
		||||
  Future<void> setStatus(
 | 
			
		||||
    String type,
 | 
			
		||||
    String? label,
 | 
			
		||||
    int attitude, {
 | 
			
		||||
    bool isUpdate = false,
 | 
			
		||||
    bool isSilent = false,
 | 
			
		||||
    bool isInvisible = false,
 | 
			
		||||
    DateTime? clearAt,
 | 
			
		||||
  }) async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
 | 
			
		||||
    final payload = {
 | 
			
		||||
      'type': type,
 | 
			
		||||
      'label': label,
 | 
			
		||||
      'attitude': attitude,
 | 
			
		||||
      'is_no_disturb': isSilent,
 | 
			
		||||
      'is_invisible': isInvisible,
 | 
			
		||||
      'clear_at': clearAt?.toUtc().toIso8601String()
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      await sn.client.request(
 | 
			
		||||
        '/cgi/id/users/me/status',
 | 
			
		||||
        data: payload,
 | 
			
		||||
        options: Options(method: isUpdate ? 'PUT' : 'POST'),
 | 
			
		||||
      );
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      Navigator.pop(context, true);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _clearStatus() async {
 | 
			
		||||
    if (_isBusy) return;
 | 
			
		||||
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.delete('/cgi/id/users/me/status');
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      Navigator.of(context).pop(true);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Column(
 | 
			
		||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
      children: [
 | 
			
		||||
        Row(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
          children: [
 | 
			
		||||
            const Icon(Symbols.mood, size: 24),
 | 
			
		||||
            const Gap(16),
 | 
			
		||||
            Text('accountChangeStatus',
 | 
			
		||||
                    style: Theme.of(context).textTheme.titleLarge)
 | 
			
		||||
                .tr(),
 | 
			
		||||
          ],
 | 
			
		||||
        ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
			
		||||
        LoadingIndicator(isActive: _isBusy),
 | 
			
		||||
        SizedBox(
 | 
			
		||||
          height: 48,
 | 
			
		||||
          child: ListView(
 | 
			
		||||
            padding: EdgeInsets.symmetric(horizontal: 18),
 | 
			
		||||
            scrollDirection: Axis.horizontal,
 | 
			
		||||
            children: kPresetStatus.entries
 | 
			
		||||
                .map(
 | 
			
		||||
                  (x) => StyledWidget(ActionChip(
 | 
			
		||||
                    avatar: x.value.$1,
 | 
			
		||||
                    label: Text(x.value.$2),
 | 
			
		||||
                    tooltip: x.value.$3,
 | 
			
		||||
                    onPressed: _isBusy
 | 
			
		||||
                        ? null
 | 
			
		||||
                        : () {
 | 
			
		||||
                            setStatus(
 | 
			
		||||
                              x.key,
 | 
			
		||||
                              x.value.$2,
 | 
			
		||||
                              0,
 | 
			
		||||
                              isInvisible: x.key == 'invisible',
 | 
			
		||||
                              isSilent: x.key == 'silent',
 | 
			
		||||
                            );
 | 
			
		||||
                          },
 | 
			
		||||
                  )).padding(right: 6),
 | 
			
		||||
                )
 | 
			
		||||
                .toList(),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        const Gap(16),
 | 
			
		||||
        const Divider(thickness: 0.3, height: 0.3),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
          leading: widget.currentStatus != null
 | 
			
		||||
              ? const Icon(Icons.edit)
 | 
			
		||||
              : const Icon(Icons.add),
 | 
			
		||||
          title: Text('accountCustomStatus').tr(),
 | 
			
		||||
          subtitle: Text('accountCustomStatusDescription').tr(),
 | 
			
		||||
          onTap: _isBusy
 | 
			
		||||
              ? null
 | 
			
		||||
              : () async {
 | 
			
		||||
                  final val = await showDialog(
 | 
			
		||||
                    context: context,
 | 
			
		||||
                    builder: (context) => _AccountStatusEditorDialog(
 | 
			
		||||
                      currentStatus: widget.currentStatus,
 | 
			
		||||
                    ),
 | 
			
		||||
                  );
 | 
			
		||||
                  if (val == true && context.mounted) {
 | 
			
		||||
                    Navigator.of(context).pop(true);
 | 
			
		||||
                  }
 | 
			
		||||
                },
 | 
			
		||||
        ),
 | 
			
		||||
        if (widget.currentStatus != null)
 | 
			
		||||
          ListTile(
 | 
			
		||||
            contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
            leading: const Icon(Icons.clear),
 | 
			
		||||
            title: Text('accountClearStatus').tr(),
 | 
			
		||||
            subtitle: Text('accountClearStatusDescription').tr(),
 | 
			
		||||
            onTap: _isBusy
 | 
			
		||||
                ? null
 | 
			
		||||
                : () {
 | 
			
		||||
                    _clearStatus();
 | 
			
		||||
                  },
 | 
			
		||||
          ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AccountStatusEditorDialog extends StatefulWidget {
 | 
			
		||||
  final SnAccountStatusInfo? currentStatus;
 | 
			
		||||
  const _AccountStatusEditorDialog({this.currentStatus});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_AccountStatusEditorDialog> createState() =>
 | 
			
		||||
      _AccountStatusEditorDialogState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AccountStatusEditorDialogState
 | 
			
		||||
    extends State<_AccountStatusEditorDialog> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
 | 
			
		||||
  final TextEditingController _labelController = TextEditingController();
 | 
			
		||||
  final TextEditingController _clearAtController = TextEditingController();
 | 
			
		||||
 | 
			
		||||
  int _attitude = 0;
 | 
			
		||||
  bool _isSilent = false;
 | 
			
		||||
  bool _isInvisible = false;
 | 
			
		||||
  DateTime? _clearAt;
 | 
			
		||||
 | 
			
		||||
  Future<void> _selectClearAt() async {
 | 
			
		||||
    final DateTime? pickedDate = await showDatePicker(
 | 
			
		||||
      context: context,
 | 
			
		||||
      initialDate: _clearAt?.toLocal() ?? DateTime.now(),
 | 
			
		||||
      firstDate: DateTime.now(),
 | 
			
		||||
      lastDate: DateTime.now().add(const Duration(days: 365)),
 | 
			
		||||
    );
 | 
			
		||||
    if (pickedDate == null) return;
 | 
			
		||||
    if (!mounted) return;
 | 
			
		||||
    final TimeOfDay? pickedTime = await showTimePicker(
 | 
			
		||||
      context: context,
 | 
			
		||||
      initialTime: TimeOfDay.now(),
 | 
			
		||||
    );
 | 
			
		||||
    if (pickedTime == null) return;
 | 
			
		||||
    if (!mounted) return;
 | 
			
		||||
    final picked = pickedDate.copyWith(
 | 
			
		||||
      hour: pickedTime.hour,
 | 
			
		||||
      minute: pickedTime.minute,
 | 
			
		||||
    );
 | 
			
		||||
    setState(() {
 | 
			
		||||
      _clearAt = picked;
 | 
			
		||||
      _clearAtController.text = DateFormat('y/M/d HH:mm').format(_clearAt!);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _applyStatus() async {
 | 
			
		||||
    if (_isBusy) return;
 | 
			
		||||
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.request(
 | 
			
		||||
        '/cgi/id/users/me/status',
 | 
			
		||||
        data: {
 | 
			
		||||
          'type': 'custom',
 | 
			
		||||
          'label': _labelController.text,
 | 
			
		||||
          'attitude': _attitude,
 | 
			
		||||
          'is_no_disturb': _isSilent,
 | 
			
		||||
          'is_invisible': _isInvisible,
 | 
			
		||||
          'clear_at': _clearAt?.toUtc().toIso8601String(),
 | 
			
		||||
        },
 | 
			
		||||
        options: Options(
 | 
			
		||||
          method: widget.currentStatus?.status != null ? 'PUT' : 'POST',
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      Navigator.of(context).pop(true);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _syncWidget() {
 | 
			
		||||
    if (widget.currentStatus?.status != null) {
 | 
			
		||||
      _clearAt = widget.currentStatus!.status!.clearAt;
 | 
			
		||||
      if (_clearAt != null) {
 | 
			
		||||
        _clearAtController.text = DateFormat('y/M/d HH:mm').format(_clearAt!);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      _labelController.text = widget.currentStatus!.status!.label;
 | 
			
		||||
      _attitude = widget.currentStatus!.status!.attitude;
 | 
			
		||||
      _isInvisible = widget.currentStatus!.status!.isInvisible;
 | 
			
		||||
      _isSilent = widget.currentStatus!.status!.isNoDisturb;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    _syncWidget();
 | 
			
		||||
    super.initState();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AlertDialog(
 | 
			
		||||
      title: Text('accountCustomStatus').tr(),
 | 
			
		||||
      content: Column(
 | 
			
		||||
        mainAxisSize: MainAxisSize.min,
 | 
			
		||||
        children: [
 | 
			
		||||
          LoadingIndicator(isActive: _isBusy),
 | 
			
		||||
          TextField(
 | 
			
		||||
            controller: _labelController,
 | 
			
		||||
            decoration: InputDecoration(
 | 
			
		||||
              isDense: true,
 | 
			
		||||
              prefixIcon: const Icon(Icons.label),
 | 
			
		||||
              border: const OutlineInputBorder(),
 | 
			
		||||
              labelText: 'fieldAccountStatusLabel'.tr(),
 | 
			
		||||
            ),
 | 
			
		||||
            onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
          ),
 | 
			
		||||
          const Gap(8),
 | 
			
		||||
          TextField(
 | 
			
		||||
            controller: _clearAtController,
 | 
			
		||||
            readOnly: true,
 | 
			
		||||
            decoration: InputDecoration(
 | 
			
		||||
              isDense: true,
 | 
			
		||||
              prefixIcon: const Icon(Icons.event_busy),
 | 
			
		||||
              border: const OutlineInputBorder(),
 | 
			
		||||
              labelText: 'fieldAccountStatusClearAt'.tr(),
 | 
			
		||||
            ),
 | 
			
		||||
            onTap: () => _selectClearAt(),
 | 
			
		||||
          ),
 | 
			
		||||
          const Gap(8),
 | 
			
		||||
          SingleChildScrollView(
 | 
			
		||||
            scrollDirection: Axis.horizontal,
 | 
			
		||||
            child: Wrap(
 | 
			
		||||
              spacing: 6,
 | 
			
		||||
              runSpacing: 0,
 | 
			
		||||
              children: [
 | 
			
		||||
                ChoiceChip(
 | 
			
		||||
                  avatar: Icon(
 | 
			
		||||
                    Symbols.radio_button_unchecked,
 | 
			
		||||
                    color: Theme.of(context).colorScheme.onSurfaceVariant,
 | 
			
		||||
                  ),
 | 
			
		||||
                  selected: _attitude == 2,
 | 
			
		||||
                  label: Text('accountStatusNegative'.tr()),
 | 
			
		||||
                  onSelected: (val) {
 | 
			
		||||
                    if (val) setState(() => _attitude = 2);
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                ChoiceChip(
 | 
			
		||||
                  avatar: Icon(
 | 
			
		||||
                    Symbols.contrast,
 | 
			
		||||
                    color: Theme.of(context).colorScheme.onSurfaceVariant,
 | 
			
		||||
                  ),
 | 
			
		||||
                  selected: _attitude == 0,
 | 
			
		||||
                  label: Text('accountStatusNeutral'.tr()),
 | 
			
		||||
                  onSelected: (val) {
 | 
			
		||||
                    if (val) setState(() => _attitude = 0);
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                ChoiceChip(
 | 
			
		||||
                  avatar: Icon(
 | 
			
		||||
                    Symbols.circle,
 | 
			
		||||
                    color: Theme.of(context).colorScheme.onSurfaceVariant,
 | 
			
		||||
                  ),
 | 
			
		||||
                  selected: _attitude == 1,
 | 
			
		||||
                  label: Text('accountStatusPositive'.tr()),
 | 
			
		||||
                  onSelected: (val) {
 | 
			
		||||
                    if (val) setState(() => _attitude = 1);
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          const Gap(4),
 | 
			
		||||
          SingleChildScrollView(
 | 
			
		||||
            scrollDirection: Axis.horizontal,
 | 
			
		||||
            child: Wrap(
 | 
			
		||||
              spacing: 6,
 | 
			
		||||
              runSpacing: 0,
 | 
			
		||||
              children: [
 | 
			
		||||
                ChoiceChip(
 | 
			
		||||
                  selected: _isSilent,
 | 
			
		||||
                  label: Text('accountStatusSilent').tr(),
 | 
			
		||||
                  onSelected: (val) {
 | 
			
		||||
                    setState(() => _isSilent = val);
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                ChoiceChip(
 | 
			
		||||
                  selected: _isInvisible,
 | 
			
		||||
                  label: Text('accountStatusInvisible').tr(),
 | 
			
		||||
                  onSelected: (val) {
 | 
			
		||||
                    setState(() => _isInvisible = val);
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
      actions: <Widget>[
 | 
			
		||||
        TextButton(
 | 
			
		||||
          style: TextButton.styleFrom(
 | 
			
		||||
            foregroundColor:
 | 
			
		||||
                Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
 | 
			
		||||
          ),
 | 
			
		||||
          onPressed: _isBusy ? null : () => Navigator.pop(context),
 | 
			
		||||
          child: Text('dialogCancel').tr(),
 | 
			
		||||
        ),
 | 
			
		||||
        TextButton(
 | 
			
		||||
          onPressed: _isBusy ? null : () => _applyStatus(),
 | 
			
		||||
          child: Text('dialogConfirm').tr(),
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										50
									
								
								lib/widgets/account/badge.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								lib/widgets/account/badge.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,50 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:surface/screens/account/profile_page.dart' show kBadgesMeta;
 | 
			
		||||
import 'package:surface/types/account.dart';
 | 
			
		||||
 | 
			
		||||
class AccountBadge extends StatelessWidget {
 | 
			
		||||
  final SnAccountBadge badge;
 | 
			
		||||
  final double radius;
 | 
			
		||||
  final EdgeInsets? padding;
 | 
			
		||||
  const AccountBadge({
 | 
			
		||||
    super.key,
 | 
			
		||||
    required this.badge,
 | 
			
		||||
    this.radius = 20,
 | 
			
		||||
    this.padding,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Tooltip(
 | 
			
		||||
      richMessage: TextSpan(
 | 
			
		||||
        children: [
 | 
			
		||||
          TextSpan(text: kBadgesMeta[badge.type]?.$1.tr() ?? 'unknown'.tr()),
 | 
			
		||||
          if (badge.metadata['title'] != null)
 | 
			
		||||
            TextSpan(
 | 
			
		||||
              text: '\n${badge.metadata['title']}',
 | 
			
		||||
              style: const TextStyle(fontWeight: FontWeight.bold),
 | 
			
		||||
            ),
 | 
			
		||||
          TextSpan(text: '\n'),
 | 
			
		||||
          TextSpan(
 | 
			
		||||
            text: DateFormat.yMEd().format(badge.createdAt),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
      child: Container(
 | 
			
		||||
        padding: padding ?? EdgeInsets.all(3),
 | 
			
		||||
        decoration: BoxDecoration(
 | 
			
		||||
          borderRadius: BorderRadius.circular(radius),
 | 
			
		||||
          color: kBadgesMeta[badge.type]?.$3,
 | 
			
		||||
        ),
 | 
			
		||||
        child: Icon(
 | 
			
		||||
          kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark,
 | 
			
		||||
          color: Colors.white,
 | 
			
		||||
          fill: 1,
 | 
			
		||||
          size: radius - 4,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -22,12 +22,14 @@ class AttachmentItem extends StatelessWidget {
 | 
			
		||||
  final SnAttachment? data;
 | 
			
		||||
  final String? heroTag;
 | 
			
		||||
  final BoxFit fit;
 | 
			
		||||
  final FilterQuality? filterQuality;
 | 
			
		||||
 | 
			
		||||
  const AttachmentItem({
 | 
			
		||||
    super.key,
 | 
			
		||||
    this.fit = BoxFit.cover,
 | 
			
		||||
    required this.data,
 | 
			
		||||
    required this.heroTag,
 | 
			
		||||
    this.filterQuality,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  Widget _buildContent(BuildContext context) {
 | 
			
		||||
@@ -43,10 +45,25 @@ class AttachmentItem extends StatelessWidget {
 | 
			
		||||
      case 'image':
 | 
			
		||||
        return Hero(
 | 
			
		||||
          tag: 'attachment-${data!.rid}-$tag',
 | 
			
		||||
          child: AutoResizeUniversalImage(
 | 
			
		||||
            sn.getAttachmentUrl(data!.rid),
 | 
			
		||||
            key: Key('attachment-${data!.rid}-$tag'),
 | 
			
		||||
            fit: fit,
 | 
			
		||||
          child: Stack(
 | 
			
		||||
            fit: StackFit.expand,
 | 
			
		||||
            children: [
 | 
			
		||||
              ImageFiltered(
 | 
			
		||||
                imageFilter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
 | 
			
		||||
                child: AutoResizeUniversalImage(
 | 
			
		||||
                  sn.getAttachmentUrl(data!.rid),
 | 
			
		||||
                  key: Key('attachment-${data!.rid}-$tag-blur-background'),
 | 
			
		||||
                  fit: BoxFit.cover,
 | 
			
		||||
                  filterQuality: filterQuality,
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              AutoResizeUniversalImage(
 | 
			
		||||
                sn.getAttachmentUrl(data!.rid),
 | 
			
		||||
                key: Key('attachment-${data!.rid}-$tag'),
 | 
			
		||||
                fit: fit,
 | 
			
		||||
                filterQuality: filterQuality,
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
      case 'video':
 | 
			
		||||
@@ -83,13 +100,16 @@ class _AttachmentItemSensitiveBlur extends StatefulWidget {
 | 
			
		||||
  final Widget child;
 | 
			
		||||
  final bool isCompact;
 | 
			
		||||
 | 
			
		||||
  const _AttachmentItemSensitiveBlur({required this.child, this.isCompact = false});
 | 
			
		||||
  const _AttachmentItemSensitiveBlur(
 | 
			
		||||
      {required this.child, this.isCompact = false});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_AttachmentItemSensitiveBlur> createState() => _AttachmentItemSensitiveBlurState();
 | 
			
		||||
  State<_AttachmentItemSensitiveBlur> createState() =>
 | 
			
		||||
      _AttachmentItemSensitiveBlurState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AttachmentItemSensitiveBlurState extends State<_AttachmentItemSensitiveBlur> {
 | 
			
		||||
class _AttachmentItemSensitiveBlurState
 | 
			
		||||
    extends State<_AttachmentItemSensitiveBlur> {
 | 
			
		||||
  bool _doesShow = false;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -124,10 +144,15 @@ class _AttachmentItemSensitiveBlurState extends State<_AttachmentItemSensitiveBl
 | 
			
		||||
                      Text(
 | 
			
		||||
                        'sensitiveContentDescription',
 | 
			
		||||
                        textAlign: TextAlign.center,
 | 
			
		||||
                      ).tr().fontSize(14).textColor(Colors.white.withOpacity(0.8)),
 | 
			
		||||
                      )
 | 
			
		||||
                          .tr()
 | 
			
		||||
                          .fontSize(14)
 | 
			
		||||
                          .textColor(Colors.white.withOpacity(0.8)),
 | 
			
		||||
                    if (!widget.isCompact) const Gap(16),
 | 
			
		||||
                    InkWell(
 | 
			
		||||
                      child: Text('sensitiveContentReveal').tr().textColor(Colors.white),
 | 
			
		||||
                      child: Text('sensitiveContentReveal')
 | 
			
		||||
                          .tr()
 | 
			
		||||
                          .textColor(Colors.white),
 | 
			
		||||
                      onTap: () {
 | 
			
		||||
                        setState(() => _doesShow = !_doesShow);
 | 
			
		||||
                      },
 | 
			
		||||
@@ -137,7 +162,9 @@ class _AttachmentItemSensitiveBlurState extends State<_AttachmentItemSensitiveBl
 | 
			
		||||
              ).center(),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ).opacity(_doesShow ? 0 : 1, animate: true).animate(const Duration(milliseconds: 300), Curves.easeInOut),
 | 
			
		||||
        )
 | 
			
		||||
            .opacity(_doesShow ? 0 : 1, animate: true)
 | 
			
		||||
            .animate(const Duration(milliseconds: 300), Curves.easeInOut),
 | 
			
		||||
        if (_doesShow)
 | 
			
		||||
          Positioned(
 | 
			
		||||
            top: 0,
 | 
			
		||||
@@ -174,10 +201,12 @@ class _AttachmentItemContentVideo extends StatefulWidget {
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_AttachmentItemContentVideo> createState() => _AttachmentItemContentVideoState();
 | 
			
		||||
  State<_AttachmentItemContentVideo> createState() =>
 | 
			
		||||
      _AttachmentItemContentVideoState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo> {
 | 
			
		||||
class _AttachmentItemContentVideoState
 | 
			
		||||
    extends State<_AttachmentItemContentVideo> {
 | 
			
		||||
  bool _showContent = false;
 | 
			
		||||
  bool _showOriginal = false;
 | 
			
		||||
 | 
			
		||||
@@ -188,7 +217,9 @@ class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo
 | 
			
		||||
    setState(() => _showContent = true);
 | 
			
		||||
    MediaKit.ensureInitialized();
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
    final url = _showOriginal ? sn.getAttachmentUrl(widget.data.rid) : sn.getAttachmentUrl(widget.data.compressed!.rid);
 | 
			
		||||
    final url = _showOriginal
 | 
			
		||||
        ? sn.getAttachmentUrl(widget.data.rid)
 | 
			
		||||
        : sn.getAttachmentUrl(widget.data.compressed!.rid);
 | 
			
		||||
    _videoPlayer = Player();
 | 
			
		||||
    _videoController = VideoController(_videoPlayer!);
 | 
			
		||||
    _videoPlayer!.open(Media(url), play: !widget.isAutoload);
 | 
			
		||||
@@ -201,7 +232,9 @@ class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
    _videoPlayer?.open(
 | 
			
		||||
      Media(
 | 
			
		||||
        _showOriginal ? sn.getAttachmentUrl(widget.data.rid) : sn.getAttachmentUrl(widget.data.compressed!.rid),
 | 
			
		||||
        _showOriginal
 | 
			
		||||
            ? sn.getAttachmentUrl(widget.data.rid)
 | 
			
		||||
            : sn.getAttachmentUrl(widget.data.compressed!.rid),
 | 
			
		||||
      ),
 | 
			
		||||
      play: true,
 | 
			
		||||
    );
 | 
			
		||||
@@ -232,6 +265,7 @@ class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo
 | 
			
		||||
      return GestureDetector(
 | 
			
		||||
        behavior: HitTestBehavior.opaque,
 | 
			
		||||
        child: Stack(
 | 
			
		||||
          fit: StackFit.expand,
 | 
			
		||||
          children: [
 | 
			
		||||
            if (widget.data.thumbnail != null)
 | 
			
		||||
              AutoResizeUniversalImage(
 | 
			
		||||
@@ -283,7 +317,9 @@ class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo
 | 
			
		||||
                          ),
 | 
			
		||||
                          Text(
 | 
			
		||||
                            Duration(
 | 
			
		||||
                              milliseconds: (widget.data.data['duration'] ?? 0).toInt() * 1000,
 | 
			
		||||
                              milliseconds:
 | 
			
		||||
                                  (widget.data.data['duration'] ?? 0).toInt() *
 | 
			
		||||
                                      1000,
 | 
			
		||||
                            ).toString(),
 | 
			
		||||
                            style: GoogleFonts.robotoMono(
 | 
			
		||||
                              fontSize: 12,
 | 
			
		||||
@@ -346,7 +382,9 @@ class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo
 | 
			
		||||
            MaterialDesktopCustomButton(
 | 
			
		||||
              iconSize: 24,
 | 
			
		||||
              onPressed: _toggleOriginal,
 | 
			
		||||
              icon: _showOriginal ? const Icon(Symbols.high_quality, size: 24) : const Icon(Symbols.sd, size: 24),
 | 
			
		||||
              icon: _showOriginal
 | 
			
		||||
                  ? const Icon(Symbols.high_quality, size: 24)
 | 
			
		||||
                  : const Icon(Symbols.sd, size: 24),
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
@@ -354,8 +392,9 @@ class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo
 | 
			
		||||
        child: Video(
 | 
			
		||||
          controller: _videoController!,
 | 
			
		||||
          aspectRatio: ratio,
 | 
			
		||||
          controls:
 | 
			
		||||
              !kIsWeb && (Platform.isAndroid || Platform.isIOS) ? MaterialVideoControls : MaterialDesktopVideoControls,
 | 
			
		||||
          controls: !kIsWeb && (Platform.isAndroid || Platform.isIOS)
 | 
			
		||||
              ? MaterialVideoControls
 | 
			
		||||
              : MaterialDesktopVideoControls,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
@@ -378,10 +417,12 @@ class _AttachmentItemContentAudio extends StatefulWidget {
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_AttachmentItemContentAudio> createState() => _AttachmentItemContentAudioState();
 | 
			
		||||
  State<_AttachmentItemContentAudio> createState() =>
 | 
			
		||||
      _AttachmentItemContentAudioState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AttachmentItemContentAudioState extends State<_AttachmentItemContentAudio> {
 | 
			
		||||
class _AttachmentItemContentAudioState
 | 
			
		||||
    extends State<_AttachmentItemContentAudio> {
 | 
			
		||||
  bool _showContent = false;
 | 
			
		||||
 | 
			
		||||
  double? _draggingValue;
 | 
			
		||||
@@ -429,6 +470,7 @@ class _AttachmentItemContentAudioState extends State<_AttachmentItemContentAudio
 | 
			
		||||
      return GestureDetector(
 | 
			
		||||
        behavior: HitTestBehavior.opaque,
 | 
			
		||||
        child: Stack(
 | 
			
		||||
          fit: StackFit.expand,
 | 
			
		||||
          children: [
 | 
			
		||||
            if (widget.data.thumbnail != null)
 | 
			
		||||
              AspectRatio(
 | 
			
		||||
@@ -552,8 +594,12 @@ class _AttachmentItemContentAudioState extends State<_AttachmentItemContentAudio
 | 
			
		||||
                            overlayShape: SliderComponentShape.noOverlay,
 | 
			
		||||
                          ),
 | 
			
		||||
                          child: Slider(
 | 
			
		||||
                            secondaryTrackValue: _bufferedPosition.inMilliseconds.abs().toDouble(),
 | 
			
		||||
                            value: _draggingValue?.abs() ?? _position.inMilliseconds.toDouble().abs(),
 | 
			
		||||
                            secondaryTrackValue: _bufferedPosition
 | 
			
		||||
                                .inMilliseconds
 | 
			
		||||
                                .abs()
 | 
			
		||||
                                .toDouble(),
 | 
			
		||||
                            value: _draggingValue?.abs() ??
 | 
			
		||||
                                _position.inMilliseconds.toDouble().abs(),
 | 
			
		||||
                            min: 0,
 | 
			
		||||
                            max: math
 | 
			
		||||
                                .max(
 | 
			
		||||
@@ -593,7 +639,9 @@ class _AttachmentItemContentAudioState extends State<_AttachmentItemContentAudio
 | 
			
		||||
                  ),
 | 
			
		||||
                  const Gap(16),
 | 
			
		||||
                  IconButton.filled(
 | 
			
		||||
                    icon: _isPlaying ? const Icon(Symbols.pause) : const Icon(Symbols.play_arrow),
 | 
			
		||||
                    icon: _isPlaying
 | 
			
		||||
                        ? const Icon(Symbols.pause)
 | 
			
		||||
                        : const Icon(Symbols.play_arrow),
 | 
			
		||||
                    onPressed: () {
 | 
			
		||||
                      _audioPlayer!.playOrPause();
 | 
			
		||||
                    },
 | 
			
		||||
 
 | 
			
		||||
@@ -21,6 +21,7 @@ class AttachmentList extends StatefulWidget {
 | 
			
		||||
  final double? minWidth;
 | 
			
		||||
  final double? maxWidth;
 | 
			
		||||
  final EdgeInsets? padding;
 | 
			
		||||
  final FilterQuality? filterQuality;
 | 
			
		||||
 | 
			
		||||
  const AttachmentList({
 | 
			
		||||
    super.key,
 | 
			
		||||
@@ -33,23 +34,27 @@ class AttachmentList extends StatefulWidget {
 | 
			
		||||
    this.minWidth,
 | 
			
		||||
    this.maxWidth,
 | 
			
		||||
    this.padding,
 | 
			
		||||
    this.filterQuality,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  static const BorderRadius kDefaultRadius = BorderRadius.all(Radius.circular(8));
 | 
			
		||||
  static const BorderRadius kDefaultRadius =
 | 
			
		||||
      BorderRadius.all(Radius.circular(8));
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<AttachmentList> createState() => _AttachmentListState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AttachmentListState extends State<AttachmentList> {
 | 
			
		||||
  late final List<String> heroTags = List.generate(widget.data.length, (_) => const Uuid().v4());
 | 
			
		||||
  late final List<String> heroTags =
 | 
			
		||||
      List.generate(widget.data.length, (_) => const Uuid().v4());
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return LayoutBuilder(
 | 
			
		||||
      builder: (context, layoutConstraints) {
 | 
			
		||||
        final borderSide =
 | 
			
		||||
            widget.bordered ? BorderSide(width: 1, color: Theme.of(context).dividerColor) : BorderSide.none;
 | 
			
		||||
        final borderSide = widget.bordered
 | 
			
		||||
            ? BorderSide(width: 1, color: Theme.of(context).dividerColor)
 | 
			
		||||
            : BorderSide.none;
 | 
			
		||||
        final backgroundColor = Theme.of(context).colorScheme.surfaceContainer;
 | 
			
		||||
        final constraints = BoxConstraints(
 | 
			
		||||
          minWidth: widget.minWidth ?? 80,
 | 
			
		||||
@@ -58,13 +63,13 @@ class _AttachmentListState extends State<AttachmentList> {
 | 
			
		||||
 | 
			
		||||
        if (widget.data.isEmpty) return const SizedBox.shrink();
 | 
			
		||||
        if (widget.data.length == 1) {
 | 
			
		||||
          final singleAspectRatio =
 | 
			
		||||
              widget.data[0]?.data['ratio']?.toDouble() ??
 | 
			
		||||
          final singleAspectRatio = widget.data[0]?.data['ratio']?.toDouble() ??
 | 
			
		||||
              switch (widget.data[0]?.mimetype.split('/').firstOrNull) {
 | 
			
		||||
                'audio' => 16 / 9,
 | 
			
		||||
                'video' => 16 / 9,
 | 
			
		||||
                _ => 1,
 | 
			
		||||
              }.toDouble();
 | 
			
		||||
              }
 | 
			
		||||
                  .toDouble();
 | 
			
		||||
 | 
			
		||||
          return Container(
 | 
			
		||||
            padding: widget.padding ?? EdgeInsets.zero,
 | 
			
		||||
@@ -80,12 +85,19 @@ class _AttachmentListState extends State<AttachmentList> {
 | 
			
		||||
                  ),
 | 
			
		||||
                  child: ClipRRect(
 | 
			
		||||
                    borderRadius: AttachmentList.kDefaultRadius,
 | 
			
		||||
                    child: AttachmentItem(data: widget.data[0], heroTag: heroTags[0], fit: widget.fit),
 | 
			
		||||
                    child: AttachmentItem(
 | 
			
		||||
                      data: widget.data[0],
 | 
			
		||||
                      heroTag: heroTags[0],
 | 
			
		||||
                      fit: widget.fit,
 | 
			
		||||
                      filterQuality: widget.filterQuality,
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              onTap: () {
 | 
			
		||||
                if (widget.data.firstOrNull?.mediaType != SnMediaType.image) return;
 | 
			
		||||
                if (widget.data.firstOrNull?.mediaType != SnMediaType.image) {
 | 
			
		||||
                  return;
 | 
			
		||||
                }
 | 
			
		||||
                context.pushTransparentRoute(
 | 
			
		||||
                  AttachmentZoomView(
 | 
			
		||||
                    data: widget.data.where((ele) => ele != null).cast(),
 | 
			
		||||
@@ -100,8 +112,10 @@ class _AttachmentListState extends State<AttachmentList> {
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        final fullOfImage =
 | 
			
		||||
            widget.data.where((ele) => ele?.mediaType == SnMediaType.image).length == widget.data.length;
 | 
			
		||||
        final fullOfImage = widget.data
 | 
			
		||||
                .where((ele) => ele?.mediaType == SnMediaType.image)
 | 
			
		||||
                .length ==
 | 
			
		||||
            widget.data.length;
 | 
			
		||||
 | 
			
		||||
        if (widget.gridded && fullOfImage) {
 | 
			
		||||
          return Container(
 | 
			
		||||
@@ -117,29 +131,38 @@ class _AttachmentListState extends State<AttachmentList> {
 | 
			
		||||
                crossAxisCount: math.min(widget.data.length, 2),
 | 
			
		||||
                crossAxisSpacing: 4,
 | 
			
		||||
                mainAxisSpacing: 4,
 | 
			
		||||
                children:
 | 
			
		||||
                    widget.data
 | 
			
		||||
                        .mapIndexed(
 | 
			
		||||
                          (idx, ele) => GestureDetector(
 | 
			
		||||
                            child: Container(
 | 
			
		||||
                              constraints: constraints,
 | 
			
		||||
                              child: AttachmentItem(data: ele, heroTag: heroTags[idx], fit: BoxFit.cover),
 | 
			
		||||
                            ),
 | 
			
		||||
                            onTap: () {
 | 
			
		||||
                              if (widget.data[idx]!.mediaType != SnMediaType.image) return;
 | 
			
		||||
                              context.pushTransparentRoute(
 | 
			
		||||
                                AttachmentZoomView(
 | 
			
		||||
                                  data: widget.data.where((ele) => ele != null).cast(),
 | 
			
		||||
                                  initialIndex: idx,
 | 
			
		||||
                                  heroTags: heroTags,
 | 
			
		||||
                                ),
 | 
			
		||||
                                backgroundColor: Colors.black.withOpacity(0.7),
 | 
			
		||||
                                rootNavigator: true,
 | 
			
		||||
                              );
 | 
			
		||||
                            },
 | 
			
		||||
                children: widget.data
 | 
			
		||||
                    .mapIndexed(
 | 
			
		||||
                      (idx, ele) => GestureDetector(
 | 
			
		||||
                        child: Container(
 | 
			
		||||
                          constraints: constraints,
 | 
			
		||||
                          child: AttachmentItem(
 | 
			
		||||
                            data: ele,
 | 
			
		||||
                            heroTag: heroTags[idx],
 | 
			
		||||
                            fit: BoxFit.cover,
 | 
			
		||||
                            filterQuality: widget.filterQuality,
 | 
			
		||||
                          ),
 | 
			
		||||
                        )
 | 
			
		||||
                        .toList(),
 | 
			
		||||
                        ),
 | 
			
		||||
                        onTap: () {
 | 
			
		||||
                          if (widget.data[idx]!.mediaType !=
 | 
			
		||||
                              SnMediaType.image) {
 | 
			
		||||
                            return;
 | 
			
		||||
                          }
 | 
			
		||||
                          context.pushTransparentRoute(
 | 
			
		||||
                            AttachmentZoomView(
 | 
			
		||||
                              data: widget.data
 | 
			
		||||
                                  .where((ele) => ele != null)
 | 
			
		||||
                                  .cast(),
 | 
			
		||||
                              initialIndex: idx,
 | 
			
		||||
                              heroTags: heroTags,
 | 
			
		||||
                            ),
 | 
			
		||||
                            backgroundColor: Colors.black.withOpacity(0.7),
 | 
			
		||||
                            rootNavigator: true,
 | 
			
		||||
                          );
 | 
			
		||||
                        },
 | 
			
		||||
                      ),
 | 
			
		||||
                    )
 | 
			
		||||
                    .toList(),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
@@ -156,22 +179,26 @@ class _AttachmentListState extends State<AttachmentList> {
 | 
			
		||||
            child: ClipRRect(
 | 
			
		||||
              borderRadius: AttachmentList.kDefaultRadius,
 | 
			
		||||
              child: Column(
 | 
			
		||||
                children:
 | 
			
		||||
                    widget.data
 | 
			
		||||
                        .mapIndexed(
 | 
			
		||||
                          (idx, ele) => GestureDetector(
 | 
			
		||||
                            child: AspectRatio(
 | 
			
		||||
                              aspectRatio: ele?.data['ratio']?.toDouble() ?? 1,
 | 
			
		||||
                              child: Container(
 | 
			
		||||
                                constraints: constraints,
 | 
			
		||||
                                child: AttachmentItem(data: ele, heroTag: heroTags[idx], fit: BoxFit.cover),
 | 
			
		||||
                              ),
 | 
			
		||||
                children: widget.data
 | 
			
		||||
                    .mapIndexed(
 | 
			
		||||
                      (idx, ele) => GestureDetector(
 | 
			
		||||
                        child: AspectRatio(
 | 
			
		||||
                          aspectRatio: ele?.data['ratio']?.toDouble() ?? 1,
 | 
			
		||||
                          child: Container(
 | 
			
		||||
                            constraints: constraints,
 | 
			
		||||
                            child: AttachmentItem(
 | 
			
		||||
                              data: ele,
 | 
			
		||||
                              heroTag: heroTags[idx],
 | 
			
		||||
                              fit: BoxFit.cover,
 | 
			
		||||
                              filterQuality: widget.filterQuality,
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                        )
 | 
			
		||||
                        .expand((ele) => [ele, const Divider(height: 1)])
 | 
			
		||||
                        .toList()
 | 
			
		||||
                      ..removeLast(),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                    )
 | 
			
		||||
                    .expand((ele) => [ele, const Divider(height: 1)])
 | 
			
		||||
                    .toList()
 | 
			
		||||
                  ..removeLast(),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
@@ -179,26 +206,33 @@ class _AttachmentListState extends State<AttachmentList> {
 | 
			
		||||
 | 
			
		||||
        return Container(
 | 
			
		||||
          constraints: BoxConstraints(maxHeight: constraints.maxHeight),
 | 
			
		||||
          width: double.infinity,
 | 
			
		||||
          child: AspectRatio(
 | 
			
		||||
            aspectRatio: widget.data[0]?.data['ratio']?.toDouble() ?? 1,
 | 
			
		||||
            child: ScrollConfiguration(
 | 
			
		||||
              behavior: _AttachmentListScrollBehavior(),
 | 
			
		||||
              behavior: AttachmentListScrollBehavior(),
 | 
			
		||||
              child: ListView.separated(
 | 
			
		||||
                padding: widget.padding,
 | 
			
		||||
                shrinkWrap: true,
 | 
			
		||||
                itemCount: widget.data.length,
 | 
			
		||||
                itemBuilder: (context, idx) {
 | 
			
		||||
                  return Container(
 | 
			
		||||
                    constraints: constraints.copyWith(maxWidth: widget.maxWidth),
 | 
			
		||||
                    constraints:
 | 
			
		||||
                        constraints.copyWith(maxWidth: widget.maxWidth),
 | 
			
		||||
                    child: AspectRatio(
 | 
			
		||||
                      aspectRatio: (widget.data[idx]?.data['ratio'] ?? 1).toDouble(),
 | 
			
		||||
                      aspectRatio:
 | 
			
		||||
                          (widget.data[idx]?.data['ratio'] ?? 1).toDouble(),
 | 
			
		||||
                      child: GestureDetector(
 | 
			
		||||
                        onTap: () {
 | 
			
		||||
                          if (widget.data[idx]?.mediaType != SnMediaType.image) return;
 | 
			
		||||
                          if (widget.data[idx]?.mediaType != SnMediaType.image)
 | 
			
		||||
                            return;
 | 
			
		||||
                          context.pushTransparentRoute(
 | 
			
		||||
                            AttachmentZoomView(
 | 
			
		||||
                              data:
 | 
			
		||||
                                  widget.data.where((ele) => ele != null && ele.mediaType == SnMediaType.image).cast(),
 | 
			
		||||
                              data: widget.data
 | 
			
		||||
                                  .where((ele) =>
 | 
			
		||||
                                      ele != null &&
 | 
			
		||||
                                      ele.mediaType == SnMediaType.image)
 | 
			
		||||
                                  .cast(),
 | 
			
		||||
                              initialIndex: idx,
 | 
			
		||||
                              heroTags: heroTags,
 | 
			
		||||
                            ),
 | 
			
		||||
@@ -212,18 +246,25 @@ class _AttachmentListState extends State<AttachmentList> {
 | 
			
		||||
                            Container(
 | 
			
		||||
                              decoration: BoxDecoration(
 | 
			
		||||
                                color: backgroundColor,
 | 
			
		||||
                                border: Border(top: borderSide, bottom: borderSide),
 | 
			
		||||
                                border:
 | 
			
		||||
                                    Border(top: borderSide, bottom: borderSide),
 | 
			
		||||
                                borderRadius: AttachmentList.kDefaultRadius,
 | 
			
		||||
                              ),
 | 
			
		||||
                              child: ClipRRect(
 | 
			
		||||
                                borderRadius: AttachmentList.kDefaultRadius,
 | 
			
		||||
                                child: AttachmentItem(data: widget.data[idx], heroTag: heroTags[idx]),
 | 
			
		||||
                                child: AttachmentItem(
 | 
			
		||||
                                  data: widget.data[idx],
 | 
			
		||||
                                  heroTag: heroTags[idx],
 | 
			
		||||
                                  filterQuality: widget.filterQuality,
 | 
			
		||||
                                ),
 | 
			
		||||
                              ),
 | 
			
		||||
                            ),
 | 
			
		||||
                            Positioned(
 | 
			
		||||
                              right: 8,
 | 
			
		||||
                              bottom: 8,
 | 
			
		||||
                              child: Chip(label: Text('${idx + 1}/${widget.data.length}')),
 | 
			
		||||
                              child: Chip(
 | 
			
		||||
                                  label:
 | 
			
		||||
                                      Text('${idx + 1}/${widget.data.length}')),
 | 
			
		||||
                            ),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
@@ -243,7 +284,8 @@ class _AttachmentListState extends State<AttachmentList> {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AttachmentListScrollBehavior extends MaterialScrollBehavior {
 | 
			
		||||
class AttachmentListScrollBehavior extends MaterialScrollBehavior {
 | 
			
		||||
  @override
 | 
			
		||||
  Set<PointerDeviceKind> get dragDevices => {PointerDeviceKind.touch, PointerDeviceKind.mouse};
 | 
			
		||||
  Set<PointerDeviceKind> get dragDevices =>
 | 
			
		||||
      {PointerDeviceKind.touch, PointerDeviceKind.mouse};
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,4 @@
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
import 'dart:math' show max;
 | 
			
		||||
 | 
			
		||||
import 'package:dio/dio.dart';
 | 
			
		||||
import 'package:dismissible_page/dismissible_page.dart';
 | 
			
		||||
@@ -48,11 +47,14 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
 | 
			
		||||
  bool _showOverlay = true;
 | 
			
		||||
  bool _dismissable = true;
 | 
			
		||||
 | 
			
		||||
  int _page = 0;
 | 
			
		||||
 | 
			
		||||
  void _updatePage() {
 | 
			
		||||
    setState(() {
 | 
			
		||||
      if (_isCompletedDownload) {
 | 
			
		||||
        setState(() => _isCompletedDownload = false);
 | 
			
		||||
      }
 | 
			
		||||
      _page = _pageController.page?.round() ?? 0;
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -155,7 +157,7 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
 | 
			
		||||
        Navigator.of(context).pop();
 | 
			
		||||
      },
 | 
			
		||||
      direction: _dismissable
 | 
			
		||||
          ? DismissiblePageDismissDirection.multi
 | 
			
		||||
          ? DismissiblePageDismissDirection.down
 | 
			
		||||
          : DismissiblePageDismissDirection.none,
 | 
			
		||||
      backgroundColor: Colors.transparent,
 | 
			
		||||
      isFullScreen: true,
 | 
			
		||||
@@ -222,31 +224,11 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
 | 
			
		||||
                      BoxDecoration(color: Colors.transparent),
 | 
			
		||||
                );
 | 
			
		||||
              }),
 | 
			
		||||
              Positioned(
 | 
			
		||||
                top: max(MediaQuery.of(context).padding.top, 8),
 | 
			
		||||
                left: 14,
 | 
			
		||||
                child: IgnorePointer(
 | 
			
		||||
                  ignoring: !_showOverlay,
 | 
			
		||||
                  child: IconButton(
 | 
			
		||||
                    constraints: const BoxConstraints(),
 | 
			
		||||
                    icon: const Icon(Icons.close),
 | 
			
		||||
                    style: ButtonStyle(
 | 
			
		||||
                      backgroundColor: MaterialStateProperty.all(
 | 
			
		||||
                        Theme.of(context).colorScheme.surface.withOpacity(0.5),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                    onPressed: () {
 | 
			
		||||
                      Navigator.of(context).pop();
 | 
			
		||||
                    },
 | 
			
		||||
                  ).opacity(_showOverlay ? 1 : 0, animate: true).animate(
 | 
			
		||||
                      const Duration(milliseconds: 300), Curves.easeInOut),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              Align(
 | 
			
		||||
                alignment: Alignment.bottomCenter,
 | 
			
		||||
                child: IgnorePointer(
 | 
			
		||||
                  child: Container(
 | 
			
		||||
                    height: 300,
 | 
			
		||||
                    height: 200,
 | 
			
		||||
                    decoration: BoxDecoration(
 | 
			
		||||
                      gradient: LinearGradient(
 | 
			
		||||
                        begin: Alignment.bottomCenter,
 | 
			
		||||
@@ -269,153 +251,130 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
 | 
			
		||||
                child: Material(
 | 
			
		||||
                  color: Colors.transparent,
 | 
			
		||||
                  child: Builder(builder: (context) {
 | 
			
		||||
                    final ud = context.read<UserDirectoryProvider>();
 | 
			
		||||
                    final item = widget.data.elementAt(
 | 
			
		||||
                      widget.data.length > 1
 | 
			
		||||
                          ? _pageController.page?.round() ?? 0
 | 
			
		||||
                          : 0,
 | 
			
		||||
                    );
 | 
			
		||||
                    final account = ud.getFromCache(item.accountId);
 | 
			
		||||
 | 
			
		||||
                    return Column(
 | 
			
		||||
                      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                    return Row(
 | 
			
		||||
                      children: [
 | 
			
		||||
                        if (item.accountId > 0)
 | 
			
		||||
                          Row(
 | 
			
		||||
                            children: [
 | 
			
		||||
                              IgnorePointer(
 | 
			
		||||
                                child: AccountImage(
 | 
			
		||||
                                  content: account?.avatar,
 | 
			
		||||
                                  radius: 19,
 | 
			
		||||
                                ),
 | 
			
		||||
                              ),
 | 
			
		||||
                              const Gap(8),
 | 
			
		||||
                              Expanded(
 | 
			
		||||
                                child: IgnorePointer(
 | 
			
		||||
                                  child: Column(
 | 
			
		||||
                                    crossAxisAlignment:
 | 
			
		||||
                                        CrossAxisAlignment.start,
 | 
			
		||||
                                    children: [
 | 
			
		||||
                                      Text(
 | 
			
		||||
                                        'attachmentUploadBy'.tr(),
 | 
			
		||||
                                        style: Theme.of(context)
 | 
			
		||||
                                            .textTheme
 | 
			
		||||
                                            .bodySmall,
 | 
			
		||||
                                      ),
 | 
			
		||||
                                      Text(
 | 
			
		||||
                                        account?.nick ?? 'unknown'.tr(),
 | 
			
		||||
                                        style: Theme.of(context)
 | 
			
		||||
                                            .textTheme
 | 
			
		||||
                                            .bodyMedium,
 | 
			
		||||
                                      ),
 | 
			
		||||
                                    ],
 | 
			
		||||
                                  ),
 | 
			
		||||
                                ),
 | 
			
		||||
                              ),
 | 
			
		||||
                              if (widget.data.length > 1)
 | 
			
		||||
                                IgnorePointer(
 | 
			
		||||
                                  child: Text(
 | 
			
		||||
                                    '${(_pageController.page?.round() ?? 0) + 1}/${widget.data.length}',
 | 
			
		||||
                                    style: GoogleFonts.robotoMono(fontSize: 13),
 | 
			
		||||
                                  ).padding(right: 8),
 | 
			
		||||
                                ),
 | 
			
		||||
                              InkWell(
 | 
			
		||||
                                borderRadius:
 | 
			
		||||
                                    const BorderRadius.all(Radius.circular(16)),
 | 
			
		||||
                                onTap: _isDownloading
 | 
			
		||||
                                    ? null
 | 
			
		||||
                                    : () => _saveToAlbum(widget.data.length > 1
 | 
			
		||||
                                        ? _pageController.page?.round() ?? 0
 | 
			
		||||
                                        : 0),
 | 
			
		||||
                                child: Container(
 | 
			
		||||
                                  padding: const EdgeInsets.all(6),
 | 
			
		||||
                                  child: !_isDownloading
 | 
			
		||||
                                      ? !_isCompletedDownload
 | 
			
		||||
                                          ? const Icon(Symbols.save_alt)
 | 
			
		||||
                                          : const Icon(Symbols.download_done)
 | 
			
		||||
                                      : SizedBox(
 | 
			
		||||
                                          width: 24,
 | 
			
		||||
                                          height: 24,
 | 
			
		||||
                                          child: CircularProgressIndicator(
 | 
			
		||||
                                            value: _progressOfDownload,
 | 
			
		||||
                                            strokeWidth: 3,
 | 
			
		||||
                                          ),
 | 
			
		||||
                                        ),
 | 
			
		||||
                                ),
 | 
			
		||||
                              ),
 | 
			
		||||
                            ],
 | 
			
		||||
                          ),
 | 
			
		||||
                        const Gap(4),
 | 
			
		||||
                        IgnorePointer(
 | 
			
		||||
                          child: Text(
 | 
			
		||||
                            item.alt,
 | 
			
		||||
                            maxLines: 2,
 | 
			
		||||
                            overflow: TextOverflow.ellipsis,
 | 
			
		||||
                            style: const TextStyle(
 | 
			
		||||
                              fontSize: 15,
 | 
			
		||||
                              fontWeight: FontWeight.w500,
 | 
			
		||||
                        IconButton(
 | 
			
		||||
                          iconSize: 18,
 | 
			
		||||
                          constraints: const BoxConstraints(),
 | 
			
		||||
                          icon: const Icon(Icons.close),
 | 
			
		||||
                          style: ButtonStyle(
 | 
			
		||||
                            backgroundColor: MaterialStateProperty.all(
 | 
			
		||||
                              Theme.of(context)
 | 
			
		||||
                                  .colorScheme
 | 
			
		||||
                                  .surface
 | 
			
		||||
                                  .withOpacity(0.5),
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                          onPressed: () {
 | 
			
		||||
                            Navigator.of(context).pop();
 | 
			
		||||
                          },
 | 
			
		||||
                        ),
 | 
			
		||||
                        const Gap(2),
 | 
			
		||||
                        IgnorePointer(
 | 
			
		||||
                          child: Wrap(
 | 
			
		||||
                            spacing: 6,
 | 
			
		||||
                            children: [
 | 
			
		||||
                              if (item.metadata['exif'] == null)
 | 
			
		||||
                                Text(
 | 
			
		||||
                                  '#${item.rid}',
 | 
			
		||||
                                  style: metaTextStyle,
 | 
			
		||||
                                ),
 | 
			
		||||
                              if (item.metadata['exif']?['Model'] != null)
 | 
			
		||||
                                Text(
 | 
			
		||||
                                  'attachmentShotOn'.tr(args: [
 | 
			
		||||
                                    item.metadata['exif']?['Model'],
 | 
			
		||||
                                  ]),
 | 
			
		||||
                                  style: metaTextStyle,
 | 
			
		||||
                                ).padding(right: 2),
 | 
			
		||||
                              if (item.metadata['exif']?['Megapixels'] !=
 | 
			
		||||
                                      null &&
 | 
			
		||||
                                  item.metadata['exif']?['Model'] != null)
 | 
			
		||||
                                Text(
 | 
			
		||||
                                  '${item.metadata['exif']?['Megapixels']}MP',
 | 
			
		||||
                                  style: metaTextStyle,
 | 
			
		||||
                                )
 | 
			
		||||
                              else
 | 
			
		||||
                                Text(
 | 
			
		||||
                                  item.size.formatBytes(),
 | 
			
		||||
                                  style: metaTextStyle,
 | 
			
		||||
                                ),
 | 
			
		||||
                              if (item.metadata['width'] != null &&
 | 
			
		||||
                                  item.metadata['height'] != null)
 | 
			
		||||
                                Text(
 | 
			
		||||
                                  '${item.metadata['width']}x${item.metadata['height']}',
 | 
			
		||||
                                  style: metaTextStyle,
 | 
			
		||||
                                ),
 | 
			
		||||
                            ],
 | 
			
		||||
                        IconButton(
 | 
			
		||||
                            iconSize: 20,
 | 
			
		||||
                            constraints: const BoxConstraints(),
 | 
			
		||||
                            padding: EdgeInsets.zero,
 | 
			
		||||
                            visualDensity: VisualDensity.compact,
 | 
			
		||||
                            icon: const Icon(Symbols.hide).padding(all: 6),
 | 
			
		||||
                            onPressed: () {
 | 
			
		||||
                              setState(() => _showOverlay = false);
 | 
			
		||||
                            }),
 | 
			
		||||
                        Expanded(
 | 
			
		||||
                          child: IgnorePointer(
 | 
			
		||||
                            child: Builder(builder: (context) {
 | 
			
		||||
                              final item = widget.data.elementAt(_page);
 | 
			
		||||
                              final doShowCameraInfo =
 | 
			
		||||
                                  item.metadata['exif']?['Model'] != null;
 | 
			
		||||
                              final exif = item.metadata['exif'];
 | 
			
		||||
                              return Column(
 | 
			
		||||
                                children: [
 | 
			
		||||
                                  if (widget.data.length > 1)
 | 
			
		||||
                                    Text(
 | 
			
		||||
                                      '${_page + 1}/${widget.data.length}',
 | 
			
		||||
                                      style:
 | 
			
		||||
                                          GoogleFonts.robotoMono(fontSize: 13),
 | 
			
		||||
                                    ).padding(right: 8),
 | 
			
		||||
                                  if (doShowCameraInfo)
 | 
			
		||||
                                    Text(
 | 
			
		||||
                                      'attachmentShotOn'
 | 
			
		||||
                                          .tr(args: [exif?['Model']]),
 | 
			
		||||
                                      style: metaTextStyle,
 | 
			
		||||
                                      textAlign: TextAlign.center,
 | 
			
		||||
                                    ),
 | 
			
		||||
                                  if (doShowCameraInfo)
 | 
			
		||||
                                    Row(
 | 
			
		||||
                                      spacing: 4,
 | 
			
		||||
                                      mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                                      children: [
 | 
			
		||||
                                        if (exif?['Megapixels'] != null)
 | 
			
		||||
                                          Text(
 | 
			
		||||
                                            '${exif?['Megapixels']}MP',
 | 
			
		||||
                                            style: metaTextStyle,
 | 
			
		||||
                                          ),
 | 
			
		||||
                                        if (exif?['ISO'] != null)
 | 
			
		||||
                                          Text(
 | 
			
		||||
                                            'ISO${exif['ISO']}',
 | 
			
		||||
                                            style: metaTextStyle,
 | 
			
		||||
                                          ),
 | 
			
		||||
                                        if (exif?['FNumber'] != null)
 | 
			
		||||
                                          Text(
 | 
			
		||||
                                            'f/${exif['FNumber']}',
 | 
			
		||||
                                            style: metaTextStyle,
 | 
			
		||||
                                          ),
 | 
			
		||||
                                      ],
 | 
			
		||||
                                    )
 | 
			
		||||
                                ],
 | 
			
		||||
                              );
 | 
			
		||||
                            }),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ),
 | 
			
		||||
                        const Gap(4),
 | 
			
		||||
                        InkWell(
 | 
			
		||||
                          onTap: () {
 | 
			
		||||
                        IconButton(
 | 
			
		||||
                          constraints: const BoxConstraints(),
 | 
			
		||||
                          padding: EdgeInsets.zero,
 | 
			
		||||
                          visualDensity: VisualDensity.compact,
 | 
			
		||||
                          icon: Container(
 | 
			
		||||
                            padding: const EdgeInsets.all(6),
 | 
			
		||||
                            child: !_isDownloading
 | 
			
		||||
                                ? !_isCompletedDownload
 | 
			
		||||
                                    ? const Icon(Symbols.save_alt)
 | 
			
		||||
                                    : const Icon(Symbols.download_done)
 | 
			
		||||
                                : SizedBox(
 | 
			
		||||
                                    width: 20,
 | 
			
		||||
                                    height: 20,
 | 
			
		||||
                                    child: CircularProgressIndicator(
 | 
			
		||||
                                      backgroundColor: Theme.of(context)
 | 
			
		||||
                                          .colorScheme
 | 
			
		||||
                                          .surfaceContainerHighest,
 | 
			
		||||
                                      value: _progressOfDownload,
 | 
			
		||||
                                      strokeWidth: 3,
 | 
			
		||||
                                    ),
 | 
			
		||||
                                  ),
 | 
			
		||||
                          ),
 | 
			
		||||
                          onPressed:
 | 
			
		||||
                              _isDownloading ? null : () => _saveToAlbum(_page),
 | 
			
		||||
                        ),
 | 
			
		||||
                        IconButton(
 | 
			
		||||
                          iconSize: 18,
 | 
			
		||||
                          constraints: const BoxConstraints(),
 | 
			
		||||
                          icon: const Icon(Icons.info_outline),
 | 
			
		||||
                          style: ButtonStyle(
 | 
			
		||||
                            backgroundColor: MaterialStateProperty.all(
 | 
			
		||||
                              Theme.of(context)
 | 
			
		||||
                                  .colorScheme
 | 
			
		||||
                                  .surface
 | 
			
		||||
                                  .withOpacity(0.5),
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                          onPressed: () {
 | 
			
		||||
                            _showDetail = true;
 | 
			
		||||
                            showModalBottomSheet(
 | 
			
		||||
                              context: context,
 | 
			
		||||
                              builder: (context) => _AttachmentZoomDetailPopup(
 | 
			
		||||
                                data: widget.data.elementAt(
 | 
			
		||||
                                    widget.data.length > 1
 | 
			
		||||
                                        ? _pageController.page?.round() ?? 0
 | 
			
		||||
                                        : 0),
 | 
			
		||||
                                data: widget.data.elementAt(_page),
 | 
			
		||||
                              ),
 | 
			
		||||
                            ).then((_) {
 | 
			
		||||
                              _showDetail = false;
 | 
			
		||||
                            });
 | 
			
		||||
                          },
 | 
			
		||||
                          child: Text(
 | 
			
		||||
                            'viewDetailedAttachment'.tr(),
 | 
			
		||||
                            style: metaTextStyle.copyWith(
 | 
			
		||||
                                decoration: TextDecoration.underline),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ],
 | 
			
		||||
                    );
 | 
			
		||||
@@ -427,18 +386,20 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        onTap: () {
 | 
			
		||||
          if (_showOverlay) {
 | 
			
		||||
            Navigator.pop(context);
 | 
			
		||||
            return;
 | 
			
		||||
          }
 | 
			
		||||
          setState(() => _showOverlay = !_showOverlay);
 | 
			
		||||
        },
 | 
			
		||||
        onVerticalDragUpdate: (details) {
 | 
			
		||||
          if (_showDetail) return;
 | 
			
		||||
          if (_showDetail || !_dismissable) return;
 | 
			
		||||
          if (details.delta.dy <= -20) {
 | 
			
		||||
            _showDetail = true;
 | 
			
		||||
            showModalBottomSheet(
 | 
			
		||||
              context: context,
 | 
			
		||||
              builder: (context) => _AttachmentZoomDetailPopup(
 | 
			
		||||
                data: widget.data.elementAt(widget.data.length > 1
 | 
			
		||||
                    ? _pageController.page?.round() ?? 0
 | 
			
		||||
                    : 0),
 | 
			
		||||
                data: widget.data.elementAt(_page),
 | 
			
		||||
              ),
 | 
			
		||||
            ).then((_) {
 | 
			
		||||
              _showDetail = false;
 | 
			
		||||
 
 | 
			
		||||
@@ -10,14 +10,16 @@ import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/config.dart';
 | 
			
		||||
import 'package:surface/providers/keypair.dart';
 | 
			
		||||
import 'package:surface/providers/translation.dart';
 | 
			
		||||
import 'package:surface/providers/user_directory.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/screens/account/profile_page.dart';
 | 
			
		||||
import 'package:surface/types/chat.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_popover.dart';
 | 
			
		||||
import 'package:surface/widgets/account/badge.dart';
 | 
			
		||||
import 'package:surface/widgets/attachment/attachment_list.dart';
 | 
			
		||||
import 'package:surface/widgets/context_menu.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/link_preview.dart';
 | 
			
		||||
import 'package:surface/widgets/markdown_content.dart';
 | 
			
		||||
import 'package:flutter_animate/flutter_animate.dart';
 | 
			
		||||
@@ -63,7 +65,7 @@ class ChatMessage extends StatelessWidget {
 | 
			
		||||
      key: Key('chat-message-${data.id}'),
 | 
			
		||||
      iconOnLeftSwipe: Symbols.reply,
 | 
			
		||||
      iconOnRightSwipe: Symbols.edit,
 | 
			
		||||
      swipeSensitivity: 20,
 | 
			
		||||
      swipeSensitivity: 10,
 | 
			
		||||
      onLeftSwipe: onReply != null ? (_) => onReply!(data) : null,
 | 
			
		||||
      onRightSwipe: (onEdit != null && isOwner) ? (_) => onEdit!(data) : null,
 | 
			
		||||
      child: ContextMenuArea(
 | 
			
		||||
@@ -109,18 +111,10 @@ class ChatMessage extends StatelessWidget {
 | 
			
		||||
                      child: AccountImage(
 | 
			
		||||
                        content: user?.avatar,
 | 
			
		||||
                        badge: (user?.badges.isNotEmpty ?? false)
 | 
			
		||||
                            ? Icon(
 | 
			
		||||
                                kBadgesMeta[user!.badges.first.type]?.$2 ??
 | 
			
		||||
                                    Symbols.question_mark,
 | 
			
		||||
                                color: kBadgesMeta[user.badges.first.type]?.$3,
 | 
			
		||||
                                fill: 1,
 | 
			
		||||
                                size: 18,
 | 
			
		||||
                                shadows: [
 | 
			
		||||
                                  Shadow(
 | 
			
		||||
                                      offset: Offset(1, 1),
 | 
			
		||||
                                      blurRadius: 5.0,
 | 
			
		||||
                                      color: Color.fromARGB(200, 0, 0, 0)),
 | 
			
		||||
                                ],
 | 
			
		||||
                            ? AccountBadge(
 | 
			
		||||
                                badge: user!.badges.first,
 | 
			
		||||
                                radius: 16,
 | 
			
		||||
                                padding: EdgeInsets.all(2),
 | 
			
		||||
                              )
 | 
			
		||||
                            : null,
 | 
			
		||||
                      ),
 | 
			
		||||
@@ -214,7 +208,8 @@ class ChatMessage extends StatelessWidget {
 | 
			
		||||
                data.type == 'messages.new' &&
 | 
			
		||||
                (data.body['text']?.isNotEmpty ?? false) &&
 | 
			
		||||
                (cfg.prefs.getBool(kAppExpandChatLink) ?? true))
 | 
			
		||||
              LinkPreviewWidget(text: data.body['text']!).padding(left: 48),
 | 
			
		||||
              LinkPreviewWidget(text: data.body['text']!)
 | 
			
		||||
                  .padding(left: isCompact ? 0 : 48),
 | 
			
		||||
            if (data.preload?.attachments?.isNotEmpty ?? false)
 | 
			
		||||
              AttachmentList(
 | 
			
		||||
                data: data.preload!.attachments!,
 | 
			
		||||
@@ -235,7 +230,7 @@ class ChatMessage extends StatelessWidget {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ChatMessageText extends StatelessWidget {
 | 
			
		||||
class _ChatMessageText extends StatefulWidget {
 | 
			
		||||
  final SnChatMessage data;
 | 
			
		||||
  final Function(SnChatMessage)? onReply;
 | 
			
		||||
  final Function(SnChatMessage)? onEdit;
 | 
			
		||||
@@ -244,13 +239,56 @@ class _ChatMessageText extends StatelessWidget {
 | 
			
		||||
  const _ChatMessageText(
 | 
			
		||||
      {required this.data, this.onReply, this.onEdit, this.onDelete});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_ChatMessageText> createState() => _ChatMessageTextState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ChatMessageTextState extends State<_ChatMessageText> {
 | 
			
		||||
  late String _displayText = widget.data.body['text'] ?? '';
 | 
			
		||||
  bool _isTranslated = false;
 | 
			
		||||
  bool _isTranslating = false;
 | 
			
		||||
 | 
			
		||||
  Future<void> _translateText() async {
 | 
			
		||||
    if (widget.data.body['text'] == null || widget.data.body['text']!.isEmpty) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    final ta = context.read<SnTranslator>();
 | 
			
		||||
    setState(() => _isTranslating = true);
 | 
			
		||||
    try {
 | 
			
		||||
      final to = EasyLocalization.of(context)!.locale.languageCode;
 | 
			
		||||
      _displayText = await ta.translate(
 | 
			
		||||
        widget.data.body['text'],
 | 
			
		||||
        to: to,
 | 
			
		||||
      );
 | 
			
		||||
      _isTranslated = true;
 | 
			
		||||
      if (mounted) setState(() {});
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (mounted) context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isTranslating = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    final cfg = context.read<ConfigProvider>();
 | 
			
		||||
    if (cfg.autoTranslate) {
 | 
			
		||||
      Future.delayed(const Duration(milliseconds: 100), () {
 | 
			
		||||
        _translateText();
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final ua = context.read<UserProvider>();
 | 
			
		||||
 | 
			
		||||
    final isOwner = ua.isAuthorized && data.sender.accountId == ua.user?.id;
 | 
			
		||||
    final isOwner =
 | 
			
		||||
        ua.isAuthorized && widget.data.sender.accountId == ua.user?.id;
 | 
			
		||||
 | 
			
		||||
    if (data.body['text'] != null && data.body['text'].isNotEmpty) {
 | 
			
		||||
    if (widget.data.body['text'] != null &&
 | 
			
		||||
        widget.data.body['text'].isNotEmpty) {
 | 
			
		||||
      return Column(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
        children: [
 | 
			
		||||
@@ -259,38 +297,50 @@ class _ChatMessageText extends StatelessWidget {
 | 
			
		||||
              final List<ContextMenuButtonItem> items =
 | 
			
		||||
                  editableTextState.contextMenuButtonItems;
 | 
			
		||||
 | 
			
		||||
              if (onReply != null) {
 | 
			
		||||
              if (widget.onReply != null) {
 | 
			
		||||
                items.insert(
 | 
			
		||||
                  0,
 | 
			
		||||
                  ContextMenuButtonItem(
 | 
			
		||||
                    label: 'reply'.tr(),
 | 
			
		||||
                    onPressed: () {
 | 
			
		||||
                      ContextMenuController.removeAny();
 | 
			
		||||
                      onReply?.call(data);
 | 
			
		||||
                      widget.onReply?.call(widget.data);
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
                );
 | 
			
		||||
              }
 | 
			
		||||
              if (isOwner && onEdit != null) {
 | 
			
		||||
              if (isOwner && widget.onEdit != null) {
 | 
			
		||||
                items.insert(
 | 
			
		||||
                  1,
 | 
			
		||||
                  ContextMenuButtonItem(
 | 
			
		||||
                    label: 'edit'.tr(),
 | 
			
		||||
                    onPressed: () {
 | 
			
		||||
                      ContextMenuController.removeAny();
 | 
			
		||||
                      onEdit?.call(data);
 | 
			
		||||
                      widget.onEdit?.call(widget.data);
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
                );
 | 
			
		||||
              }
 | 
			
		||||
              if (isOwner && onDelete != null) {
 | 
			
		||||
              if (isOwner && widget.onDelete != null) {
 | 
			
		||||
                items.insert(
 | 
			
		||||
                  2,
 | 
			
		||||
                  ContextMenuButtonItem(
 | 
			
		||||
                    label: 'delete'.tr(),
 | 
			
		||||
                    onPressed: () {
 | 
			
		||||
                      ContextMenuController.removeAny();
 | 
			
		||||
                      onDelete?.call(data);
 | 
			
		||||
                      widget.onDelete?.call(widget.data);
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
                );
 | 
			
		||||
              }
 | 
			
		||||
              if (widget.data.body['algorithm'] == 'plain') {
 | 
			
		||||
                items.insert(
 | 
			
		||||
                  3,
 | 
			
		||||
                  ContextMenuButtonItem(
 | 
			
		||||
                    label: 'translate'.tr(),
 | 
			
		||||
                    onPressed: () {
 | 
			
		||||
                      ContextMenuController.removeAny();
 | 
			
		||||
                      _translateText();
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
                );
 | 
			
		||||
@@ -301,26 +351,47 @@ class _ChatMessageText extends StatelessWidget {
 | 
			
		||||
                buttonItems: items,
 | 
			
		||||
              );
 | 
			
		||||
            },
 | 
			
		||||
            child: switch (data.body['algorithm']) {
 | 
			
		||||
              'rsa' => _ChatDecryptMessage(message: data),
 | 
			
		||||
            child: switch (widget.data.body['algorithm']) {
 | 
			
		||||
              'rsa' => _ChatDecryptMessage(message: widget.data),
 | 
			
		||||
              _ => MarkdownTextContent(
 | 
			
		||||
                  content: data.body['text'],
 | 
			
		||||
                  content: _displayText,
 | 
			
		||||
                  isAutoWarp: true,
 | 
			
		||||
                  isEnlargeSticker:
 | 
			
		||||
                      RegExp(r"^:([-\w]+):$").hasMatch(data.body['text'] ?? ''),
 | 
			
		||||
                  isEnlargeSticker: RegExp(r"^:([-\w]+):$")
 | 
			
		||||
                      .hasMatch(widget.data.body['text'] ?? ''),
 | 
			
		||||
                ),
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
          if (data.updatedAt != data.createdAt)
 | 
			
		||||
          if (widget.data.updatedAt != widget.data.createdAt)
 | 
			
		||||
            Text('messageEditedHint'.tr()).fontSize(13).opacity(0.75),
 | 
			
		||||
          if (_isTranslating)
 | 
			
		||||
            AnimateWidgetExtensions(Text('translating').tr())
 | 
			
		||||
                .animate(onPlay: (e) => e.repeat())
 | 
			
		||||
                .fadeIn(duration: 500.ms, curve: Curves.easeOut)
 | 
			
		||||
                .then()
 | 
			
		||||
                .fadeOut(
 | 
			
		||||
                  duration: 500.ms,
 | 
			
		||||
                  delay: 1000.ms,
 | 
			
		||||
                  curve: Curves.easeIn,
 | 
			
		||||
                ),
 | 
			
		||||
          if (_isTranslated)
 | 
			
		||||
            InkWell(
 | 
			
		||||
              child: Text('translated').tr().opacity(0.75),
 | 
			
		||||
              onTap: () {
 | 
			
		||||
                setState(() {
 | 
			
		||||
                  _displayText = widget.data.body['text'] ?? '';
 | 
			
		||||
                  _isTranslated = false;
 | 
			
		||||
                });
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
        ],
 | 
			
		||||
      );
 | 
			
		||||
    } else if (data.body['attachments']?.isNotEmpty) {
 | 
			
		||||
    } else if (widget.data.body['attachments']?.isNotEmpty) {
 | 
			
		||||
      return Row(
 | 
			
		||||
        children: [
 | 
			
		||||
          const Icon(Symbols.file_present, size: 20),
 | 
			
		||||
          const Gap(4),
 | 
			
		||||
          Text('messageFileHint'.plural(data.body['attachments']!.length)),
 | 
			
		||||
          Text('messageFileHint'
 | 
			
		||||
              .plural(widget.data.body['attachments']!.length)),
 | 
			
		||||
        ],
 | 
			
		||||
      ).opacity(0.8);
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:google_fonts/google_fonts.dart';
 | 
			
		||||
import 'package:hotkey_manager/hotkey_manager.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
@@ -445,6 +446,61 @@ class _StickerPicker extends StatelessWidget {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final sticker = context.read<SnStickerProvider>();
 | 
			
		||||
    if (sticker.stickersByPack.isEmpty) {
 | 
			
		||||
      return GestureDetector(
 | 
			
		||||
        onTap: () {
 | 
			
		||||
          onDismiss?.call();
 | 
			
		||||
        },
 | 
			
		||||
        child: Container(
 | 
			
		||||
          constraints: BoxConstraints(
 | 
			
		||||
            maxWidth: min(360, MediaQuery.of(context).size.width - 40),
 | 
			
		||||
          ),
 | 
			
		||||
          child: Material(
 | 
			
		||||
            elevation: 8,
 | 
			
		||||
            borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
            child: ClipRRect(
 | 
			
		||||
              borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
              child: Column(
 | 
			
		||||
                mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                children: [
 | 
			
		||||
                  const Icon(Symbols.ar_stickers, size: 48),
 | 
			
		||||
                  const Gap(8),
 | 
			
		||||
                  Text('stickerPickerEmpty').tr().bold(),
 | 
			
		||||
                  Text(
 | 
			
		||||
                    'stickerPickerEmptyHint',
 | 
			
		||||
                    textAlign: TextAlign.center,
 | 
			
		||||
                  ).tr().opacity(0.75),
 | 
			
		||||
                  TextButton(
 | 
			
		||||
                    child: Text('goto'.tr(args: ['screenStickers'.tr()])),
 | 
			
		||||
                    onPressed: () {
 | 
			
		||||
                      GoRouter.of(context).goNamed('stickers');
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
                  InkWell(
 | 
			
		||||
                    child: Text(
 | 
			
		||||
                      'stickersReload',
 | 
			
		||||
                      style: TextStyle(
 | 
			
		||||
                        fontSize: 12,
 | 
			
		||||
                        color: Theme.of(context).colorScheme.primary,
 | 
			
		||||
                      ),
 | 
			
		||||
                    ).tr(),
 | 
			
		||||
                    onTap: () async {
 | 
			
		||||
                      await sticker.listSticker();
 | 
			
		||||
                      if (!context.mounted) return;
 | 
			
		||||
                      HapticFeedback.heavyImpact();
 | 
			
		||||
                      context.showSnackbar('stickersReloaded'.tr());
 | 
			
		||||
                      onDismiss?.call();
 | 
			
		||||
                    },
 | 
			
		||||
                  )
 | 
			
		||||
                ],
 | 
			
		||||
              ).padding(all: 64),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return GestureDetector(
 | 
			
		||||
      onTap: () {
 | 
			
		||||
        onDismiss?.call();
 | 
			
		||||
 
 | 
			
		||||
@@ -36,10 +36,12 @@ class ChatTypingIndicator extends StatelessWidget {
 | 
			
		||||
                      'messageTyping'
 | 
			
		||||
                          .plural(controller.typingMembers.length, args: [
 | 
			
		||||
                        controller.typingMembers
 | 
			
		||||
                            .map((ele) => (ele.nick?.isNotEmpty ?? false)
 | 
			
		||||
                                ? ele.nick!
 | 
			
		||||
                                : ud.getFromCache(ele.accountId)?.name ??
 | 
			
		||||
                                    'unknown')
 | 
			
		||||
                            .map(
 | 
			
		||||
                              (ele) => (ele.nick?.isNotEmpty ?? false)
 | 
			
		||||
                                  ? ele.nick!
 | 
			
		||||
                                  : ud.getFromCache(ele.accountId)?.nick ??
 | 
			
		||||
                                      'unknown',
 | 
			
		||||
                            )
 | 
			
		||||
                            .join(', '),
 | 
			
		||||
                      ]),
 | 
			
		||||
                    ),
 | 
			
		||||
 
 | 
			
		||||
@@ -16,7 +16,12 @@ class ConnectionIndicator extends StatelessWidget {
 | 
			
		||||
    final ws = context.watch<WebSocketProvider>();
 | 
			
		||||
    final cfg = context.watch<ConfigProvider>();
 | 
			
		||||
 | 
			
		||||
    final marginLeft = cfg.drawerIsCollapsed ? 0.0 : cfg.drawerIsExpanded ? 304.0 : 80.0;
 | 
			
		||||
    final marginLeft =
 | 
			
		||||
        cfg.drawerIsCollapsed
 | 
			
		||||
            ? 0.0
 | 
			
		||||
            : cfg.drawerIsExpanded
 | 
			
		||||
            ? 304.0
 | 
			
		||||
            : 80.0;
 | 
			
		||||
 | 
			
		||||
    return ListenableBuilder(
 | 
			
		||||
      listenable: ws,
 | 
			
		||||
@@ -32,37 +37,39 @@ class ConnectionIndicator extends StatelessWidget {
 | 
			
		||||
                elevation: 2,
 | 
			
		||||
                shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16))),
 | 
			
		||||
                color: Theme.of(context).colorScheme.secondaryContainer,
 | 
			
		||||
                child: ua.isAuthorized
 | 
			
		||||
                    ? Row(
 | 
			
		||||
                        mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                        mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
                        crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                        children: [
 | 
			
		||||
                          if (ws.isBusy)
 | 
			
		||||
                            Text('serverConnecting').tr().textColor(Theme.of(context).colorScheme.onSecondaryContainer)
 | 
			
		||||
                          else if (!ws.isConnected)
 | 
			
		||||
                            Text('serverDisconnected')
 | 
			
		||||
                                .tr()
 | 
			
		||||
                                .textColor(Theme.of(context).colorScheme.onSecondaryContainer)
 | 
			
		||||
                          else
 | 
			
		||||
                            Text('serverConnected').tr().textColor(Theme.of(context).colorScheme.onSecondaryContainer),
 | 
			
		||||
                          const Gap(8),
 | 
			
		||||
                          if (ws.isBusy)
 | 
			
		||||
                            const CircularProgressIndicator(strokeWidth: 2.5)
 | 
			
		||||
                                .width(12)
 | 
			
		||||
                                .height(12)
 | 
			
		||||
                                .padding(horizontal: 4, right: 4)
 | 
			
		||||
                          else if (!ws.isConnected)
 | 
			
		||||
                            const Icon(Symbols.power_off, size: 18)
 | 
			
		||||
                          else
 | 
			
		||||
                            const Icon(Symbols.power, size: 18),
 | 
			
		||||
                        ],
 | 
			
		||||
                      ).padding(horizontal: 8, vertical: 4)
 | 
			
		||||
                    : const SizedBox.shrink(),
 | 
			
		||||
              ).opacity(show ? 1 : 0, animate: true).animate(
 | 
			
		||||
                    const Duration(milliseconds: 300),
 | 
			
		||||
                    Curves.easeInOut,
 | 
			
		||||
                  ),
 | 
			
		||||
                child:
 | 
			
		||||
                    ua.isAuthorized
 | 
			
		||||
                        ? Row(
 | 
			
		||||
                          mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                          mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
                          crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            if (ws.isBusy)
 | 
			
		||||
                              Text(
 | 
			
		||||
                                'serverConnecting',
 | 
			
		||||
                              ).tr().textColor(Theme.of(context).colorScheme.onSecondaryContainer)
 | 
			
		||||
                            else if (!ws.isConnected)
 | 
			
		||||
                              Text(
 | 
			
		||||
                                'serverDisconnected',
 | 
			
		||||
                              ).tr().textColor(Theme.of(context).colorScheme.onSecondaryContainer)
 | 
			
		||||
                            else
 | 
			
		||||
                              Text(
 | 
			
		||||
                                'serverConnected',
 | 
			
		||||
                              ).tr().textColor(Theme.of(context).colorScheme.onSecondaryContainer),
 | 
			
		||||
                            const Gap(8),
 | 
			
		||||
                            if (ws.isBusy)
 | 
			
		||||
                              const CircularProgressIndicator(
 | 
			
		||||
                                strokeWidth: 2.5,
 | 
			
		||||
                                padding: EdgeInsets.zero,
 | 
			
		||||
                              ).width(12).height(12).padding(horizontal: 4, right: 4)
 | 
			
		||||
                            else if (!ws.isConnected)
 | 
			
		||||
                              const Icon(Symbols.power_off, size: 18)
 | 
			
		||||
                            else
 | 
			
		||||
                              const Icon(Symbols.power, size: 18),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ).padding(horizontal: 8, vertical: 4)
 | 
			
		||||
                        : const SizedBox.shrink(),
 | 
			
		||||
              ).opacity(show ? 1 : 0, animate: true).animate(const Duration(milliseconds: 300), Curves.easeInOut),
 | 
			
		||||
              onTap: () {
 | 
			
		||||
                if (!ws.isConnected && !ws.isBusy) {
 | 
			
		||||
                  ws.connect();
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										105
									
								
								lib/widgets/feed/feed_news.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								lib/widgets/feed/feed_news.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,105 @@
 | 
			
		||||
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:relative_time/relative_time.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/types/news.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
 | 
			
		||||
class NewsFeedEntry extends StatelessWidget {
 | 
			
		||||
  final SnFeedEntry data;
 | 
			
		||||
  const NewsFeedEntry({super.key, required this.data});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final List<SnNewsArticle> news = data.data
 | 
			
		||||
        .map((ele) => SnNewsArticle.fromJson(ele))
 | 
			
		||||
        .cast<SnNewsArticle>()
 | 
			
		||||
        .toList();
 | 
			
		||||
 | 
			
		||||
    return Column(
 | 
			
		||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
      children: [
 | 
			
		||||
        Row(
 | 
			
		||||
          children: [
 | 
			
		||||
            const Icon(Symbols.newspaper),
 | 
			
		||||
            const Gap(8),
 | 
			
		||||
            Text(
 | 
			
		||||
              'newsToday',
 | 
			
		||||
              style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
            ).tr()
 | 
			
		||||
          ],
 | 
			
		||||
        ).padding(horizontal: 18, top: 12, bottom: 8),
 | 
			
		||||
        Container(
 | 
			
		||||
          margin: const EdgeInsets.only(bottom: 12),
 | 
			
		||||
          height: 150,
 | 
			
		||||
          child: ListView.separated(
 | 
			
		||||
            scrollDirection: Axis.horizontal,
 | 
			
		||||
            itemCount: news.length,
 | 
			
		||||
            padding: const EdgeInsets.symmetric(horizontal: 12),
 | 
			
		||||
            itemBuilder: (context, idx) {
 | 
			
		||||
              return Container(
 | 
			
		||||
                width: 360,
 | 
			
		||||
                decoration: BoxDecoration(
 | 
			
		||||
                  border: Border.all(
 | 
			
		||||
                    color: Theme.of(context).dividerColor,
 | 
			
		||||
                    width: 1,
 | 
			
		||||
                  ),
 | 
			
		||||
                  borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                ),
 | 
			
		||||
                child: Material(
 | 
			
		||||
                  elevation: 0,
 | 
			
		||||
                  color: Theme.of(context).colorScheme.surface,
 | 
			
		||||
                  borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                  child: InkWell(
 | 
			
		||||
                    borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                    child: Column(
 | 
			
		||||
                      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                      children: [
 | 
			
		||||
                        Text(
 | 
			
		||||
                          news[idx].title,
 | 
			
		||||
                          maxLines: 2,
 | 
			
		||||
                          style: Theme.of(context).textTheme.titleMedium,
 | 
			
		||||
                        ).padding(horizontal: 16, top: 12, bottom: 4),
 | 
			
		||||
                        Text(
 | 
			
		||||
                          news[idx].description,
 | 
			
		||||
                          maxLines: 2,
 | 
			
		||||
                          style: Theme.of(context).textTheme.bodyMedium,
 | 
			
		||||
                        ).padding(horizontal: 16, vertical: 4),
 | 
			
		||||
                        const Gap(4),
 | 
			
		||||
                        Row(
 | 
			
		||||
                          children: [
 | 
			
		||||
                            Text(
 | 
			
		||||
                              DateFormat('y/M/d HH:mm')
 | 
			
		||||
                                  .format(news[idx].createdAt.toLocal()),
 | 
			
		||||
                              style: Theme.of(context).textTheme.bodySmall,
 | 
			
		||||
                            ),
 | 
			
		||||
                            const Gap(4),
 | 
			
		||||
                            Text(
 | 
			
		||||
                              RelativeTime(context)
 | 
			
		||||
                                  .format(news[idx].createdAt.toLocal()),
 | 
			
		||||
                              style: Theme.of(context).textTheme.bodySmall,
 | 
			
		||||
                            ),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ).opacity(0.8).padding(horizontal: 16),
 | 
			
		||||
                      ],
 | 
			
		||||
                    ),
 | 
			
		||||
                    onTap: () {
 | 
			
		||||
                      GoRouter.of(context).pushNamed(
 | 
			
		||||
                        'newsDetail',
 | 
			
		||||
                        pathParameters: {'hash': news[idx].hash},
 | 
			
		||||
                      );
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              );
 | 
			
		||||
            },
 | 
			
		||||
            separatorBuilder: (_, __) => const Gap(12),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										25
									
								
								lib/widgets/feed/feed_unknown.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								lib/widgets/feed/feed_unknown.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:gap/gap.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/types/post.dart';
 | 
			
		||||
 | 
			
		||||
class FeedUnknownEntry extends StatelessWidget {
 | 
			
		||||
  final SnFeedEntry data;
 | 
			
		||||
  const FeedUnknownEntry({super.key, required this.data});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Column(
 | 
			
		||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
      children: [
 | 
			
		||||
        const Icon(Symbols.help, size: 36),
 | 
			
		||||
        const Gap(4),
 | 
			
		||||
        Text('feedUnknownItem').tr(),
 | 
			
		||||
        Text(data.type, style: GoogleFonts.robotoMono()),
 | 
			
		||||
      ],
 | 
			
		||||
    ).padding(horizontal: 12, vertical: 8);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										108
									
								
								lib/widgets/html.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								lib/widgets/html.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,108 @@
 | 
			
		||||
import 'package:flutter/gestures.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:html/dom.dart' as dom;
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
import 'package:url_launcher/url_launcher_string.dart';
 | 
			
		||||
 | 
			
		||||
List<Widget> parseHtmlToWidgets(
 | 
			
		||||
    BuildContext context, 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(context, 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 = 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(context, node.children));
 | 
			
		||||
        break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return widgets;
 | 
			
		||||
}
 | 
			
		||||
@@ -76,7 +76,10 @@ class _LoadingIndicatorState extends State<LoadingIndicator>
 | 
			
		||||
                  const SizedBox(
 | 
			
		||||
                    height: 16,
 | 
			
		||||
                    width: 16,
 | 
			
		||||
                    child: CircularProgressIndicator(strokeWidth: 2.5),
 | 
			
		||||
                    child: CircularProgressIndicator(
 | 
			
		||||
                      strokeWidth: 2.5,
 | 
			
		||||
                      padding: EdgeInsets.zero,
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                  const Gap(16),
 | 
			
		||||
                  Text('loading').tr(),
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,5 @@
 | 
			
		||||
import 'dart:math' as math;
 | 
			
		||||
 | 
			
		||||
import 'package:dismissible_page/dismissible_page.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_highlight/flutter_highlight.dart';
 | 
			
		||||
@@ -174,7 +176,7 @@ class MarkdownTextContent extends StatelessWidget {
 | 
			
		||||
                    child: ClipRRect(
 | 
			
		||||
                      borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                      child: AspectRatio(
 | 
			
		||||
                        aspectRatio: attachment.metadata['ratio'] ??
 | 
			
		||||
                        aspectRatio: attachment.metadata['ratio']?.toDouble() ??
 | 
			
		||||
                            switch (attachment.mimetype
 | 
			
		||||
                                    .split('/')
 | 
			
		||||
                                    .firstOrNull) {
 | 
			
		||||
@@ -207,10 +209,14 @@ class MarkdownTextContent extends StatelessWidget {
 | 
			
		||||
          }
 | 
			
		||||
          return const SizedBox.shrink();
 | 
			
		||||
        }
 | 
			
		||||
        width ??= math.min(MediaQuery.of(context).size.width, 640);
 | 
			
		||||
        height ??= width;
 | 
			
		||||
        return UniversalImage(
 | 
			
		||||
          url,
 | 
			
		||||
          width: width,
 | 
			
		||||
          height: height,
 | 
			
		||||
          cacheHeight: height,
 | 
			
		||||
          cacheWidth: width,
 | 
			
		||||
          fit: fit,
 | 
			
		||||
        );
 | 
			
		||||
      },
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										114
									
								
								lib/widgets/menu_bar.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								lib/widgets/menu_bar.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,114 @@
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
 | 
			
		||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:surface/providers/navigation.dart';
 | 
			
		||||
import 'package:surface/router.dart';
 | 
			
		||||
 | 
			
		||||
// https://api.flutter.dev/flutter/widgets/PlatformMenuBar-class.html
 | 
			
		||||
// All the code following is only works on macOS
 | 
			
		||||
class AppSystemMenuBar extends StatelessWidget {
 | 
			
		||||
  final Function? onQuit;
 | 
			
		||||
  final Widget child;
 | 
			
		||||
  const AppSystemMenuBar({super.key, this.onQuit, required this.child});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    if (kIsWeb || !Platform.isMacOS) return child;
 | 
			
		||||
 | 
			
		||||
    final nav = context.watch<NavigationProvider>();
 | 
			
		||||
 | 
			
		||||
    return PlatformMenuBar(
 | 
			
		||||
      menus: <PlatformMenuItem>[
 | 
			
		||||
        PlatformMenu(
 | 
			
		||||
          label: 'Solian',
 | 
			
		||||
          menus: <PlatformMenuItem>[
 | 
			
		||||
            PlatformMenuItemGroup(
 | 
			
		||||
              members: <PlatformMenuItem>[
 | 
			
		||||
                PlatformMenuItem(
 | 
			
		||||
                  label: 'screenAbout'.tr(),
 | 
			
		||||
                  onSelected: () {
 | 
			
		||||
                    appRouter.goNamed('about');
 | 
			
		||||
                    nav.autoDetectIndex(appRouter);
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
            PlatformMenuItemGroup(
 | 
			
		||||
              members: [
 | 
			
		||||
                PlatformMenuItem(
 | 
			
		||||
                  label: 'screenHome'.tr(),
 | 
			
		||||
                  shortcut: const SingleActivator(
 | 
			
		||||
                    LogicalKeyboardKey.digit1,
 | 
			
		||||
                    meta: true,
 | 
			
		||||
                  ),
 | 
			
		||||
                  onSelected: () {
 | 
			
		||||
                    appRouter.goNamed('home');
 | 
			
		||||
                    nav.autoDetectIndex(appRouter);
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                PlatformMenuItem(
 | 
			
		||||
                  label: 'screenExplore'.tr(),
 | 
			
		||||
                  shortcut: const SingleActivator(
 | 
			
		||||
                    LogicalKeyboardKey.digit2,
 | 
			
		||||
                    meta: true,
 | 
			
		||||
                  ),
 | 
			
		||||
                  onSelected: () {
 | 
			
		||||
                    appRouter.goNamed('explore');
 | 
			
		||||
                    nav.autoDetectIndex(appRouter);
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                PlatformMenuItem(
 | 
			
		||||
                  label: 'screenChat'.tr(),
 | 
			
		||||
                  shortcut: const SingleActivator(
 | 
			
		||||
                    LogicalKeyboardKey.digit3,
 | 
			
		||||
                    meta: true,
 | 
			
		||||
                  ),
 | 
			
		||||
                  onSelected: () {
 | 
			
		||||
                    appRouter.goNamed('chat');
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                PlatformMenuItem(
 | 
			
		||||
                  label: 'screenAccount'.tr(),
 | 
			
		||||
                  shortcut: const SingleActivator(
 | 
			
		||||
                    LogicalKeyboardKey.digit4,
 | 
			
		||||
                    meta: true,
 | 
			
		||||
                  ),
 | 
			
		||||
                  onSelected: () {
 | 
			
		||||
                    appRouter.goNamed('account');
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
            PlatformMenuItem(
 | 
			
		||||
              shortcut: const SingleActivator(
 | 
			
		||||
                LogicalKeyboardKey.keyH,
 | 
			
		||||
                meta: true,
 | 
			
		||||
              ),
 | 
			
		||||
              label: 'trayMenuHide'.tr(),
 | 
			
		||||
              onSelected: () {
 | 
			
		||||
                appWindow.hide();
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
            if (onQuit != null)
 | 
			
		||||
              PlatformMenuItem(
 | 
			
		||||
                shortcut: const SingleActivator(
 | 
			
		||||
                  LogicalKeyboardKey.keyQ,
 | 
			
		||||
                  meta: true,
 | 
			
		||||
                ),
 | 
			
		||||
                label: 'trayMenuExit'.tr(),
 | 
			
		||||
                onSelected: () {
 | 
			
		||||
                  onQuit?.call();
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
      child: child,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -37,17 +37,15 @@ class _AppBottomNavigationBarState extends State<AppBottomNavigationBar> {
 | 
			
		||||
          ...nav.destinations.where((ele) => ele.isPinned),
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
        return BottomNavigationBar(
 | 
			
		||||
          currentIndex: nav.getIndexInRange(0, nav.pinnedDestinationCount),
 | 
			
		||||
          type: BottomNavigationBarType.fixed,
 | 
			
		||||
          showUnselectedLabels: false,
 | 
			
		||||
          items: destinations.map((ele) {
 | 
			
		||||
            return BottomNavigationBarItem(
 | 
			
		||||
        return NavigationBar(
 | 
			
		||||
          selectedIndex: nav.getIndexInRange(0, nav.pinnedDestinationCount),
 | 
			
		||||
          destinations: destinations.map((ele) {
 | 
			
		||||
            return NavigationDestination(
 | 
			
		||||
              icon: ele.icon,
 | 
			
		||||
              label: ele.label.tr(),
 | 
			
		||||
            );
 | 
			
		||||
          }).toList(),
 | 
			
		||||
          onTap: (idx) {
 | 
			
		||||
          onDestinationSelected: (idx) {
 | 
			
		||||
            nav.setIndex(idx);
 | 
			
		||||
            GoRouter.of(context).goNamed(destinations[idx].screen);
 | 
			
		||||
          },
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,25 @@
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
 | 
			
		||||
import 'package:animations/animations.dart';
 | 
			
		||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/foundation.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:responsive_framework/responsive_framework.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/channel.dart';
 | 
			
		||||
import 'package:surface/providers/config.dart';
 | 
			
		||||
import 'package:surface/providers/navigation.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/sn_realm.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
import 'package:surface/widgets/version_label.dart';
 | 
			
		||||
 | 
			
		||||
class AppNavigationDrawer extends StatefulWidget {
 | 
			
		||||
@@ -25,74 +36,308 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
			
		||||
      context.read<NavigationProvider>().autoDetectIndex(GoRouter.maybeOf(context));
 | 
			
		||||
      context
 | 
			
		||||
          .read<NavigationProvider>()
 | 
			
		||||
          .autoDetectIndex(GoRouter.maybeOf(context));
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final ua = context.read<UserProvider>();
 | 
			
		||||
    final nav = context.watch<NavigationProvider>();
 | 
			
		||||
    final cfg = context.watch<ConfigProvider>();
 | 
			
		||||
 | 
			
		||||
    final routeName = GoRouter.of(context)
 | 
			
		||||
        .routerDelegate
 | 
			
		||||
        .currentConfiguration
 | 
			
		||||
        .last
 | 
			
		||||
        .route
 | 
			
		||||
        .name;
 | 
			
		||||
    final showNavButtons = cfg.hideBottomNav ||
 | 
			
		||||
        !(nav.showBottomNavScreen.contains(routeName)
 | 
			
		||||
            ? ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)
 | 
			
		||||
            : false);
 | 
			
		||||
 | 
			
		||||
    final backgroundColor = cfg.drawerIsExpanded ? Colors.transparent : null;
 | 
			
		||||
 | 
			
		||||
    return ListenableBuilder(
 | 
			
		||||
      listenable: nav,
 | 
			
		||||
      builder: (context, _) {
 | 
			
		||||
        final destinations = [
 | 
			
		||||
          ...nav.destinations.where((ele) => ele.isPinned),
 | 
			
		||||
          ...nav.destinations.where((ele) => !ele.isPinned),
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
        return NavigationDrawer(
 | 
			
		||||
        return Drawer(
 | 
			
		||||
          elevation: widget.elevation,
 | 
			
		||||
          backgroundColor: backgroundColor,
 | 
			
		||||
          selectedIndex: nav.currentIndex,
 | 
			
		||||
          children: [
 | 
			
		||||
            if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS) && !cfg.drawerIsExpanded)
 | 
			
		||||
              Container(
 | 
			
		||||
                decoration: BoxDecoration(
 | 
			
		||||
                  border: Border(
 | 
			
		||||
                    bottom: BorderSide(
 | 
			
		||||
                      color: Theme.of(context).dividerColor,
 | 
			
		||||
                      width: 1 / MediaQuery.of(context).devicePixelRatio,
 | 
			
		||||
          shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(0))),
 | 
			
		||||
          child: Column(
 | 
			
		||||
            mainAxisSize: MainAxisSize.max,
 | 
			
		||||
            crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
            children: [
 | 
			
		||||
              if (!kIsWeb &&
 | 
			
		||||
                  (Platform.isWindows ||
 | 
			
		||||
                      Platform.isLinux ||
 | 
			
		||||
                      Platform.isMacOS) &&
 | 
			
		||||
                  !cfg.drawerIsExpanded)
 | 
			
		||||
                Container(
 | 
			
		||||
                  decoration: BoxDecoration(
 | 
			
		||||
                    border: Border(
 | 
			
		||||
                      bottom: BorderSide(
 | 
			
		||||
                        color: Theme.of(context).dividerColor,
 | 
			
		||||
                        width: 1 / MediaQuery.of(context).devicePixelRatio,
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                  child: WindowTitleBarBox(),
 | 
			
		||||
                ),
 | 
			
		||||
                child: WindowTitleBarBox(),
 | 
			
		||||
              Gap(MediaQuery.of(context).padding.top),
 | 
			
		||||
              Expanded(
 | 
			
		||||
                child: _DrawerContentList(),
 | 
			
		||||
              ),
 | 
			
		||||
            Column(
 | 
			
		||||
              mainAxisSize: MainAxisSize.min,
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
              children: [
 | 
			
		||||
                Text('Solar Network').bold(),
 | 
			
		||||
                AppVersionLabel(),
 | 
			
		||||
              ],
 | 
			
		||||
            ).padding(
 | 
			
		||||
              horizontal: 32,
 | 
			
		||||
              vertical: 12,
 | 
			
		||||
            ),
 | 
			
		||||
            ...destinations.where((ele) => ele.isPinned).map((ele) {
 | 
			
		||||
              return NavigationDrawerDestination(
 | 
			
		||||
                icon: ele.icon,
 | 
			
		||||
                label: Text(ele.label).tr(),
 | 
			
		||||
              );
 | 
			
		||||
            }),
 | 
			
		||||
            const Divider(),
 | 
			
		||||
            ...destinations.where((ele) => !ele.isPinned).map((ele) {
 | 
			
		||||
              return NavigationDrawerDestination(
 | 
			
		||||
                icon: ele.icon,
 | 
			
		||||
                label: Text(ele.label).tr(),
 | 
			
		||||
              );
 | 
			
		||||
            }),
 | 
			
		||||
          ],
 | 
			
		||||
          onDestinationSelected: (idx) {
 | 
			
		||||
            nav.setIndex(idx);
 | 
			
		||||
            GoRouter.of(context).goNamed(destinations[idx].screen);
 | 
			
		||||
            Scaffold.of(context).closeDrawer();
 | 
			
		||||
          },
 | 
			
		||||
              if (showNavButtons)
 | 
			
		||||
                Row(
 | 
			
		||||
                  spacing: 8,
 | 
			
		||||
                  children:
 | 
			
		||||
                      nav.destinations.where((ele) => ele.isPinned).mapIndexed(
 | 
			
		||||
                    (idx, ele) {
 | 
			
		||||
                      return Expanded(
 | 
			
		||||
                        child: Tooltip(
 | 
			
		||||
                          message: ele.label.tr(),
 | 
			
		||||
                          child: IconButton(
 | 
			
		||||
                            icon: ele.icon,
 | 
			
		||||
                            color: nav.currentIndex == idx
 | 
			
		||||
                                ? Theme.of(context)
 | 
			
		||||
                                    .colorScheme
 | 
			
		||||
                                    .onPrimaryContainer
 | 
			
		||||
                                : Theme.of(context).colorScheme.onSurface,
 | 
			
		||||
                            style: ButtonStyle(
 | 
			
		||||
                              backgroundColor: WidgetStatePropertyAll(
 | 
			
		||||
                                nav.currentIndex == idx
 | 
			
		||||
                                    ? Theme.of(context)
 | 
			
		||||
                                        .colorScheme
 | 
			
		||||
                                        .primaryContainer
 | 
			
		||||
                                    : Colors.transparent,
 | 
			
		||||
                              ),
 | 
			
		||||
                            ),
 | 
			
		||||
                            onPressed: () {
 | 
			
		||||
                              GoRouter.of(context).goNamed(ele.screen);
 | 
			
		||||
                              Scaffold.of(context).closeDrawer();
 | 
			
		||||
                              nav.setIndex(idx);
 | 
			
		||||
                            },
 | 
			
		||||
                          ),
 | 
			
		||||
                        ),
 | 
			
		||||
                      );
 | 
			
		||||
                    },
 | 
			
		||||
                  ).toList(),
 | 
			
		||||
                ).padding(horizontal: 16, bottom: 8),
 | 
			
		||||
              Align(
 | 
			
		||||
                alignment: Alignment.bottomCenter,
 | 
			
		||||
                child: ListTile(
 | 
			
		||||
                  contentPadding: EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
                  leading: AccountImage(
 | 
			
		||||
                    content: ua.user?.avatar,
 | 
			
		||||
                    fallbackWidget:
 | 
			
		||||
                        ua.isAuthorized ? null : const Icon(Symbols.login),
 | 
			
		||||
                  ),
 | 
			
		||||
                  title: ua.isAuthorized
 | 
			
		||||
                      ? Text(ua.user?.nick ?? 'unknown'.tr()).fontSize(15)
 | 
			
		||||
                      : Text('screenAuthLogin').tr(),
 | 
			
		||||
                  subtitle: ua.isAuthorized
 | 
			
		||||
                      ? Text('@${ua.user?.name ?? 'unknown'.tr()}').fontSize(13)
 | 
			
		||||
                      : Text('navBottomUnauthorizedCaption').fontSize(13).tr(),
 | 
			
		||||
                  trailing: Row(
 | 
			
		||||
                    mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      if (ua.isAuthorized)
 | 
			
		||||
                        IconButton(
 | 
			
		||||
                          icon: const Icon(Symbols.notifications, fill: 1),
 | 
			
		||||
                          padding: EdgeInsets.zero,
 | 
			
		||||
                          visualDensity: VisualDensity.compact,
 | 
			
		||||
                          onPressed: () {
 | 
			
		||||
                            GoRouter.of(context).pushNamed('notification');
 | 
			
		||||
                            Scaffold.of(context).closeDrawer();
 | 
			
		||||
                          },
 | 
			
		||||
                        ),
 | 
			
		||||
                      IconButton(
 | 
			
		||||
                        icon: const Icon(Symbols.settings, fill: 1),
 | 
			
		||||
                        padding: EdgeInsets.zero,
 | 
			
		||||
                        visualDensity: VisualDensity.compact,
 | 
			
		||||
                        onPressed: () {
 | 
			
		||||
                          GoRouter.of(context).pushNamed('settings');
 | 
			
		||||
                          Scaffold.of(context).closeDrawer();
 | 
			
		||||
                        },
 | 
			
		||||
                      ),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTap: () {
 | 
			
		||||
                    GoRouter.of(context).pushNamed('account');
 | 
			
		||||
                    Scaffold.of(context).closeDrawer();
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              Gap(MediaQuery.of(context).padding.bottom + 8),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _DrawerContentList extends StatelessWidget {
 | 
			
		||||
  const _DrawerContentList();
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final ct = context.read<ChatChannelProvider>();
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
    final nav = context.watch<NavigationProvider>();
 | 
			
		||||
    final rel = context.watch<SnRealmProvider>();
 | 
			
		||||
 | 
			
		||||
    return PageTransitionSwitcher(
 | 
			
		||||
      duration: const Duration(milliseconds: 300),
 | 
			
		||||
      transitionBuilder: (Widget child, Animation<double> primaryAnimation,
 | 
			
		||||
          Animation<double> secondaryAnimation) {
 | 
			
		||||
        return SharedAxisTransition(
 | 
			
		||||
          animation: primaryAnimation,
 | 
			
		||||
          secondaryAnimation: secondaryAnimation,
 | 
			
		||||
          fillColor: Colors.transparent,
 | 
			
		||||
          transitionType: SharedAxisTransitionType.horizontal,
 | 
			
		||||
          child: child,
 | 
			
		||||
        );
 | 
			
		||||
      },
 | 
			
		||||
      child: nav.focusedRealm == null
 | 
			
		||||
          ? ListView(
 | 
			
		||||
              key: const Key('realm-list-view'),
 | 
			
		||||
              padding: EdgeInsets.zero,
 | 
			
		||||
              children: [
 | 
			
		||||
                Column(
 | 
			
		||||
                  mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Text('Solar Network').bold(),
 | 
			
		||||
                    AppVersionLabel(),
 | 
			
		||||
                  ],
 | 
			
		||||
                ).padding(
 | 
			
		||||
                  horizontal: 32,
 | 
			
		||||
                  vertical: 12,
 | 
			
		||||
                ),
 | 
			
		||||
                ...rel.availableRealms.map((ele) {
 | 
			
		||||
                  return ListTile(
 | 
			
		||||
                    minTileHeight: 48,
 | 
			
		||||
                    contentPadding: EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
                    leading: AccountImage(
 | 
			
		||||
                      content: ele.avatar,
 | 
			
		||||
                      radius: 16,
 | 
			
		||||
                    ),
 | 
			
		||||
                    title: Text(ele.name),
 | 
			
		||||
                    onTap: () {
 | 
			
		||||
                      nav.setFocusedRealm(ele);
 | 
			
		||||
                    },
 | 
			
		||||
                  );
 | 
			
		||||
                }),
 | 
			
		||||
                ListTile(
 | 
			
		||||
                  minTileHeight: 48,
 | 
			
		||||
                  contentPadding: EdgeInsets.only(left: 28, right: 16),
 | 
			
		||||
                  leading: const Icon(Symbols.globe).padding(right: 4),
 | 
			
		||||
                  title: Text('screenRealmDiscovery').tr(),
 | 
			
		||||
                  onTap: () {
 | 
			
		||||
                    GoRouter.of(context).pushNamed('realmDiscovery');
 | 
			
		||||
                    Scaffold.of(context).closeDrawer();
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            )
 | 
			
		||||
          : ListView(
 | 
			
		||||
              key: ValueKey(nav.focusedRealm),
 | 
			
		||||
              padding: EdgeInsets.zero,
 | 
			
		||||
              children: [
 | 
			
		||||
                if (nav.focusedRealm!.banner != null)
 | 
			
		||||
                  AspectRatio(
 | 
			
		||||
                    aspectRatio: 16 / 9,
 | 
			
		||||
                    child: AutoResizeUniversalImage(
 | 
			
		||||
                      sn.getAttachmentUrl(
 | 
			
		||||
                        nav.focusedRealm!.banner!,
 | 
			
		||||
                      ),
 | 
			
		||||
                      fit: BoxFit.cover,
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                ListTile(
 | 
			
		||||
                  minTileHeight: 48,
 | 
			
		||||
                  tileColor: Theme.of(context).colorScheme.surfaceContainer,
 | 
			
		||||
                  contentPadding: EdgeInsets.only(
 | 
			
		||||
                    left: 24,
 | 
			
		||||
                    right: 16,
 | 
			
		||||
                  ),
 | 
			
		||||
                  leading: AccountImage(
 | 
			
		||||
                    content: nav.focusedRealm!.avatar,
 | 
			
		||||
                    radius: 16,
 | 
			
		||||
                  ),
 | 
			
		||||
                  trailing: IconButton(
 | 
			
		||||
                    icon: const Icon(Symbols.close),
 | 
			
		||||
                    padding: EdgeInsets.zero,
 | 
			
		||||
                    constraints: const BoxConstraints(),
 | 
			
		||||
                    visualDensity: VisualDensity.compact,
 | 
			
		||||
                    onPressed: () {
 | 
			
		||||
                      nav.setFocusedRealm(null);
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
                  title: Text(nav.focusedRealm!.name),
 | 
			
		||||
                  onTap: () {
 | 
			
		||||
                    GoRouter.of(context).goNamed(
 | 
			
		||||
                      'realmDetail',
 | 
			
		||||
                      pathParameters: {
 | 
			
		||||
                        'alias': nav.focusedRealm!.alias,
 | 
			
		||||
                      },
 | 
			
		||||
                    );
 | 
			
		||||
                    Scaffold.of(context).closeDrawer();
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                ListTile(
 | 
			
		||||
                  minTileHeight: 48,
 | 
			
		||||
                  contentPadding: EdgeInsets.only(
 | 
			
		||||
                    left: 28,
 | 
			
		||||
                    right: 8,
 | 
			
		||||
                  ),
 | 
			
		||||
                  leading: const Icon(Symbols.globe),
 | 
			
		||||
                  title: Text('community').tr(),
 | 
			
		||||
                  onTap: () {
 | 
			
		||||
                    GoRouter.of(context).goNamed(
 | 
			
		||||
                      'realmCommunity',
 | 
			
		||||
                      pathParameters: {
 | 
			
		||||
                        'alias': nav.focusedRealm!.alias,
 | 
			
		||||
                      },
 | 
			
		||||
                    );
 | 
			
		||||
                    Scaffold.of(context).closeDrawer();
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                if (ct.availableChannels
 | 
			
		||||
                    .where((ele) => ele.realmId == nav.focusedRealm?.id)
 | 
			
		||||
                    .isNotEmpty)
 | 
			
		||||
                  const Divider(height: 1),
 | 
			
		||||
                ...(ct.availableChannels
 | 
			
		||||
                    .where((ele) => ele.realmId == nav.focusedRealm?.id)
 | 
			
		||||
                    .map((ele) {
 | 
			
		||||
                  return ListTile(
 | 
			
		||||
                    minTileHeight: 48,
 | 
			
		||||
                    contentPadding: EdgeInsets.only(
 | 
			
		||||
                      left: 28,
 | 
			
		||||
                      right: 8,
 | 
			
		||||
                    ),
 | 
			
		||||
                    leading: const Icon(Symbols.tag),
 | 
			
		||||
                    title: Text(ele.name),
 | 
			
		||||
                    onTap: () {
 | 
			
		||||
                      GoRouter.of(context).goNamed(
 | 
			
		||||
                        'chatRoom',
 | 
			
		||||
                        pathParameters: {
 | 
			
		||||
                          'scope': ele.realm?.alias ?? 'global',
 | 
			
		||||
                          'alias': ele.alias,
 | 
			
		||||
                        },
 | 
			
		||||
                      );
 | 
			
		||||
                      Scaffold.of(context).closeDrawer();
 | 
			
		||||
                    },
 | 
			
		||||
                  );
 | 
			
		||||
                }))
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -107,6 +107,7 @@ class AppRootScaffold extends StatelessWidget {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final cfg = context.watch<ConfigProvider>();
 | 
			
		||||
    final nav = context.watch<NavigationProvider>();
 | 
			
		||||
    final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
 | 
			
		||||
 | 
			
		||||
    final isCollapseDrawer = cfg.drawerIsCollapsed;
 | 
			
		||||
@@ -118,8 +119,9 @@ class AppRootScaffold extends StatelessWidget {
 | 
			
		||||
        .last
 | 
			
		||||
        .route
 | 
			
		||||
        .name;
 | 
			
		||||
    final isShowBottomNavigation =
 | 
			
		||||
        NavigationProvider.kShowBottomNavScreen.contains(routeName)
 | 
			
		||||
    final isShowBottomNavigation = cfg.hideBottomNav
 | 
			
		||||
        ? false
 | 
			
		||||
        : nav.showBottomNavScreen.contains(routeName)
 | 
			
		||||
            ? ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)
 | 
			
		||||
            : false;
 | 
			
		||||
    final isPopable = !NavigationProvider.kAllDestination
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										168
									
								
								lib/widgets/post/fediverse_post_item.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										168
									
								
								lib/widgets/post/fediverse_post_item.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,168 @@
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:html2md/html2md.dart' as html2md;
 | 
			
		||||
import 'package:relative_time/relative_time.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/attachment/attachment_list.dart';
 | 
			
		||||
import 'package:surface/widgets/markdown_content.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
 | 
			
		||||
class FediversePostWidget extends StatelessWidget {
 | 
			
		||||
  final SnFediversePost data;
 | 
			
		||||
  final double maxWidth;
 | 
			
		||||
  const FediversePostWidget({
 | 
			
		||||
    super.key,
 | 
			
		||||
    required this.data,
 | 
			
		||||
    required this.maxWidth,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Center(
 | 
			
		||||
      child: Container(
 | 
			
		||||
        constraints: BoxConstraints(maxWidth: maxWidth),
 | 
			
		||||
        child: Column(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
          children: [
 | 
			
		||||
            Row(
 | 
			
		||||
              children: [
 | 
			
		||||
                AccountImage(
 | 
			
		||||
                  content: data.user.avatar,
 | 
			
		||||
                  radius: 20,
 | 
			
		||||
                ),
 | 
			
		||||
                const Gap(12),
 | 
			
		||||
                Column(
 | 
			
		||||
                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Text(
 | 
			
		||||
                      data.user.nick.isNotEmpty
 | 
			
		||||
                          ? data.user.nick
 | 
			
		||||
                          : '@${data.user.name}',
 | 
			
		||||
                      maxLines: 1,
 | 
			
		||||
                    ).bold(),
 | 
			
		||||
                    Row(
 | 
			
		||||
                      children: [
 | 
			
		||||
                        Text(
 | 
			
		||||
                          data.user.identifier.contains('@')
 | 
			
		||||
                              ? data.user.identifier
 | 
			
		||||
                              : '${data.user.identifier}@${data.user.origin}',
 | 
			
		||||
                          maxLines: 1,
 | 
			
		||||
                        ).fontSize(13),
 | 
			
		||||
                        const Gap(4),
 | 
			
		||||
                        Text(
 | 
			
		||||
                          RelativeTime(context)
 | 
			
		||||
                              .format(data.createdAt.toLocal()),
 | 
			
		||||
                        ).fontSize(13),
 | 
			
		||||
                      ],
 | 
			
		||||
                    ),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ).padding(horizontal: 12, vertical: 8),
 | 
			
		||||
            MarkdownTextContent(
 | 
			
		||||
              isAutoWarp: true,
 | 
			
		||||
              content: html2md.convert(data.content),
 | 
			
		||||
            ).padding(horizontal: 16, bottom: 6),
 | 
			
		||||
            if (data.images.isNotEmpty)
 | 
			
		||||
              _FediversePostImageList(
 | 
			
		||||
                data: data,
 | 
			
		||||
                maxWidth: maxWidth,
 | 
			
		||||
              ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _FediversePostImageList extends StatelessWidget {
 | 
			
		||||
  const _FediversePostImageList({
 | 
			
		||||
    required this.data,
 | 
			
		||||
    required this.maxWidth,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  final SnFediversePost data;
 | 
			
		||||
  final double maxWidth;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final borderSide =
 | 
			
		||||
        BorderSide(width: 1, color: Theme.of(context).dividerColor);
 | 
			
		||||
    final backgroundColor = Theme.of(context).colorScheme.surfaceContainer;
 | 
			
		||||
 | 
			
		||||
    if (data.images.length == 1) {
 | 
			
		||||
      return AspectRatio(
 | 
			
		||||
        aspectRatio: 1,
 | 
			
		||||
        child: Container(
 | 
			
		||||
          constraints: BoxConstraints(maxWidth: maxWidth),
 | 
			
		||||
          decoration: BoxDecoration(
 | 
			
		||||
            color: backgroundColor,
 | 
			
		||||
            border: Border(
 | 
			
		||||
              top: borderSide,
 | 
			
		||||
              bottom: borderSide,
 | 
			
		||||
            ),
 | 
			
		||||
            borderRadius: AttachmentList.kDefaultRadius,
 | 
			
		||||
          ),
 | 
			
		||||
          child: ClipRRect(
 | 
			
		||||
            borderRadius: AttachmentList.kDefaultRadius,
 | 
			
		||||
            child: AutoResizeUniversalImage(
 | 
			
		||||
              data.images.first,
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ).padding(horizontal: 8);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return AspectRatio(
 | 
			
		||||
      aspectRatio: 1,
 | 
			
		||||
      child: ScrollConfiguration(
 | 
			
		||||
        behavior: AttachmentListScrollBehavior(),
 | 
			
		||||
        child: ListView.separated(
 | 
			
		||||
          shrinkWrap: true,
 | 
			
		||||
          itemCount: data.images.length,
 | 
			
		||||
          itemBuilder: (context, idx) {
 | 
			
		||||
            return Container(
 | 
			
		||||
              constraints: BoxConstraints(maxWidth: maxWidth),
 | 
			
		||||
              child: AspectRatio(
 | 
			
		||||
                aspectRatio: 1,
 | 
			
		||||
                child: Stack(
 | 
			
		||||
                  fit: StackFit.expand,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Container(
 | 
			
		||||
                      decoration: BoxDecoration(
 | 
			
		||||
                        color: backgroundColor,
 | 
			
		||||
                        border: Border(
 | 
			
		||||
                          top: borderSide,
 | 
			
		||||
                          bottom: borderSide,
 | 
			
		||||
                        ),
 | 
			
		||||
                        borderRadius: AttachmentList.kDefaultRadius,
 | 
			
		||||
                      ),
 | 
			
		||||
                      child: ClipRRect(
 | 
			
		||||
                        borderRadius: AttachmentList.kDefaultRadius,
 | 
			
		||||
                        child: AutoResizeUniversalImage(
 | 
			
		||||
                          data.images[idx],
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                    Positioned(
 | 
			
		||||
                      right: 8,
 | 
			
		||||
                      bottom: 8,
 | 
			
		||||
                      child: Chip(
 | 
			
		||||
                        label: Text('${idx + 1}/${data.images.length}'),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            );
 | 
			
		||||
          },
 | 
			
		||||
          separatorBuilder: (context, index) => const Gap(8),
 | 
			
		||||
          physics: const BouncingScrollPhysics(),
 | 
			
		||||
          scrollDirection: Axis.horizontal,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:responsive_framework/responsive_framework.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/post.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
@@ -14,14 +15,13 @@ import 'package:surface/widgets/post/post_item.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_mini_editor.dart';
 | 
			
		||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
			
		||||
 | 
			
		||||
import '../../providers/sn_network.dart';
 | 
			
		||||
 | 
			
		||||
class PostCommentQuickAction extends StatelessWidget {
 | 
			
		||||
  final double? maxWidth;
 | 
			
		||||
  final SnPost parentPost;
 | 
			
		||||
  final Function? onPosted;
 | 
			
		||||
 | 
			
		||||
  const PostCommentQuickAction({super.key, this.maxWidth, required this.parentPost, this.onPosted});
 | 
			
		||||
  const PostCommentQuickAction(
 | 
			
		||||
      {super.key, this.maxWidth, required this.parentPost, this.onPosted});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
@@ -30,7 +30,9 @@ class PostCommentQuickAction extends StatelessWidget {
 | 
			
		||||
    return Container(
 | 
			
		||||
      height: 240,
 | 
			
		||||
      constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
 | 
			
		||||
      margin: ResponsiveBreakpoints.of(context).largerThan(MOBILE) ? const EdgeInsets.symmetric(vertical: 8) : EdgeInsets.zero,
 | 
			
		||||
      margin: ResponsiveBreakpoints.of(context).largerThan(MOBILE)
 | 
			
		||||
          ? const EdgeInsets.symmetric(vertical: 8)
 | 
			
		||||
          : EdgeInsets.zero,
 | 
			
		||||
      decoration: BoxDecoration(
 | 
			
		||||
        borderRadius: ResponsiveBreakpoints.of(context).largerThan(MOBILE)
 | 
			
		||||
            ? const BorderRadius.all(Radius.circular(8))
 | 
			
		||||
@@ -99,8 +101,9 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
 | 
			
		||||
  Future<void> _selectAnswer(SnPost answer) async {
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.put('/cgi/co/questions/${widget.parentPost.id}/answer', data: {
 | 
			
		||||
        'publisher': answer.publisherId,
 | 
			
		||||
      await sn.client
 | 
			
		||||
          .put('/cgi/co/questions/${widget.parentPost.id}/answer', data: {
 | 
			
		||||
        'publisher': widget.parentPost.publisherId,
 | 
			
		||||
        'answer_id': answer.id,
 | 
			
		||||
      });
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
@@ -135,7 +138,10 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
 | 
			
		||||
          child: PostItem(
 | 
			
		||||
            data: _posts[idx],
 | 
			
		||||
            maxWidth: widget.maxWidth,
 | 
			
		||||
            onSelectAnswer: widget.parentPost.type == 'question' ? () => _selectAnswer(_posts[idx]) : null,
 | 
			
		||||
            showExpandableComments: true,
 | 
			
		||||
            onSelectAnswer: widget.parentPost.type == 'question'
 | 
			
		||||
                ? () => _selectAnswer(_posts[idx])
 | 
			
		||||
                : null,
 | 
			
		||||
            onChanged: (data) {
 | 
			
		||||
              setState(() => _posts[idx] = data);
 | 
			
		||||
            },
 | 
			
		||||
@@ -145,6 +151,7 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            Navigator.pop(context);
 | 
			
		||||
            GoRouter.of(context).pushNamed(
 | 
			
		||||
              'postDetail',
 | 
			
		||||
              pathParameters: {'slug': _posts[idx].id.toString()},
 | 
			
		||||
@@ -153,7 +160,8 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
 | 
			
		||||
          },
 | 
			
		||||
        );
 | 
			
		||||
      },
 | 
			
		||||
      separatorBuilder: (context, index) => const Divider(height: 1),
 | 
			
		||||
      separatorBuilder: (context, index) =>
 | 
			
		||||
          const Divider().padding(vertical: 2),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -161,11 +169,13 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
 | 
			
		||||
class PostCommentListPopup extends StatefulWidget {
 | 
			
		||||
  final SnPost post;
 | 
			
		||||
  final int commentCount;
 | 
			
		||||
  final int depth;
 | 
			
		||||
 | 
			
		||||
  const PostCommentListPopup({
 | 
			
		||||
    super.key,
 | 
			
		||||
    required this.post,
 | 
			
		||||
    this.commentCount = 0,
 | 
			
		||||
    this.depth = 1,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -180,48 +190,57 @@ class _PostCommentListPopupState extends State<PostCommentListPopup> {
 | 
			
		||||
    final ua = context.watch<UserProvider>();
 | 
			
		||||
    final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
 | 
			
		||||
 | 
			
		||||
    return Column(
 | 
			
		||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
      children: [
 | 
			
		||||
        Row(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
          children: [
 | 
			
		||||
            const Icon(Symbols.comment, size: 24),
 | 
			
		||||
            const Gap(16),
 | 
			
		||||
            Text('postCommentsDetailed').plural(widget.commentCount).textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
			
		||||
          ],
 | 
			
		||||
        ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
			
		||||
        Expanded(
 | 
			
		||||
          child: CustomScrollView(
 | 
			
		||||
            slivers: [
 | 
			
		||||
              if (ua.isAuthorized)
 | 
			
		||||
                SliverToBoxAdapter(
 | 
			
		||||
                  child: Container(
 | 
			
		||||
                    height: 240,
 | 
			
		||||
                    decoration: BoxDecoration(
 | 
			
		||||
                      border: Border.symmetric(
 | 
			
		||||
                        horizontal: BorderSide(
 | 
			
		||||
                          color: Theme.of(context).dividerColor,
 | 
			
		||||
                          width: 1 / devicePixelRatio,
 | 
			
		||||
    return SizedBox(
 | 
			
		||||
      height: MediaQuery.of(context).size.height * 0.85,
 | 
			
		||||
      child: Column(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
        children: [
 | 
			
		||||
          Row(
 | 
			
		||||
            crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
            children: [
 | 
			
		||||
              const Icon(Symbols.comment, size: 24),
 | 
			
		||||
              const Gap(16),
 | 
			
		||||
              Text('postCommentsDetailed')
 | 
			
		||||
                  .plural(widget.commentCount)
 | 
			
		||||
                  .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
			
		||||
            ],
 | 
			
		||||
          ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: CustomScrollView(
 | 
			
		||||
              slivers: [
 | 
			
		||||
                if (ua.isAuthorized)
 | 
			
		||||
                  SliverToBoxAdapter(
 | 
			
		||||
                    child: Container(
 | 
			
		||||
                      margin: const EdgeInsets.only(bottom: 8),
 | 
			
		||||
                      height: 240,
 | 
			
		||||
                      decoration: BoxDecoration(
 | 
			
		||||
                        border: Border.symmetric(
 | 
			
		||||
                          horizontal: BorderSide(
 | 
			
		||||
                            color: Theme.of(context).dividerColor,
 | 
			
		||||
                            width: 1 / devicePixelRatio,
 | 
			
		||||
                          ),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                    child: PostMiniEditor(
 | 
			
		||||
                      postReplyId: widget.post.id,
 | 
			
		||||
                      onPost: () {
 | 
			
		||||
                        _childListKey.currentState!.refresh();
 | 
			
		||||
                      },
 | 
			
		||||
                      child: PostMiniEditor(
 | 
			
		||||
                        postReplyId: widget.post.id,
 | 
			
		||||
                        onPost: () {
 | 
			
		||||
                          _childListKey.currentState!.refresh();
 | 
			
		||||
                        },
 | 
			
		||||
                        onExpand: () {
 | 
			
		||||
                          Navigator.pop(context);
 | 
			
		||||
                        },
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                PostCommentSliverList(
 | 
			
		||||
                  parentPost: widget.post,
 | 
			
		||||
                  key: _childListKey,
 | 
			
		||||
                ),
 | 
			
		||||
              PostCommentSliverList(
 | 
			
		||||
                parentPost: widget.post,
 | 
			
		||||
                key: _childListKey,
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user