Compare commits
	
		
			35 Commits
		
	
	
		
			2.2.2+60
			...
			f0a3bbe023
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| f0a3bbe023 | |||
| df81c84438 | |||
| 8b12395fca | |||
| cb2b71d194 | |||
| 7ed508e2bb | |||
| dad869967e | |||
| 2d5b3b554e | |||
| 74882116e3 | |||
| a97c3bce3a | |||
| 1aa70827dc | |||
| fe028860e9 | |||
| a2d2ce4d38 | |||
| 167c11b9eb | |||
| 8cb3933fcc | |||
| 3818328afe | |||
| 11627e2455 | |||
| 3f82c06ff8 | |||
| 2350f59131 | |||
| 9fe7c9530a | |||
| 52f1826e91 | |||
| 28a4c86dbf | |||
| 85e48ce03b | |||
| efef61a8ea | |||
| 10ead95af9 | |||
| 838ee4d55d | |||
| 13e42429a9 | |||
| c6ce3fe2b7 | |||
| ae9a7eb0fd | |||
| 5d6fb2442f | |||
| 5a85985534 | |||
| c80499db03 | |||
| b8dcdb2315 | |||
| b7b921f1f4 | |||
| 319d5c7d7f | |||
| 4b5b001739 | 
@@ -26,7 +26,7 @@
 | 
			
		||||
        <activity
 | 
			
		||||
            android:name=".MainActivity"
 | 
			
		||||
            android:exported="true"
 | 
			
		||||
            android:launchMode="singleTask"
 | 
			
		||||
            android:launchMode="singleInstance"
 | 
			
		||||
            android:taskAffinity=""
 | 
			
		||||
            android:theme="@style/LaunchTheme"
 | 
			
		||||
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
 | 
			
		||||
 
 | 
			
		||||
@@ -12,9 +12,9 @@ post {
 | 
			
		||||
 | 
			
		||||
body:json {
 | 
			
		||||
  {
 | 
			
		||||
    "alias": "AteChip",
 | 
			
		||||
    "name": "Cat ate chips",
 | 
			
		||||
    "attachment_id": "d0b692cc64054463",
 | 
			
		||||
    "pack_id": 2
 | 
			
		||||
    "alias": "BaLoading",
 | 
			
		||||
    "name": "BaLoading",
 | 
			
		||||
    "attachment_id": "2JCI2uh21mKkfk9P",
 | 
			
		||||
    "pack_id": 3
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										11
									
								
								api/Paperclip/Stickers/Get Sticker Packs.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								api/Paperclip/Stickers/Get Sticker Packs.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
meta {
 | 
			
		||||
  name: Get Sticker Packs
 | 
			
		||||
  type: http
 | 
			
		||||
  seq: 3
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
get {
 | 
			
		||||
  url: {{endpoint}}/cgi/uc/stickers/packs
 | 
			
		||||
  body: none
 | 
			
		||||
  auth: none
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										15
									
								
								api/Paperclip/Stickers/Get Stickers.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								api/Paperclip/Stickers/Get Stickers.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
meta {
 | 
			
		||||
  name: Get Stickers
 | 
			
		||||
  type: http
 | 
			
		||||
  seq: 4
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
get {
 | 
			
		||||
  url: {{endpoint}}/cgi/uc/stickers?take=10
 | 
			
		||||
  body: none
 | 
			
		||||
  auth: none
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
params:query {
 | 
			
		||||
  take: 10
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										26
									
								
								api/Passport/Developer Notify One User.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								api/Passport/Developer Notify One User.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
meta {
 | 
			
		||||
  name: Developer Notify One User
 | 
			
		||||
  type: http
 | 
			
		||||
  seq: 2
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
post {
 | 
			
		||||
  url: {{endpoint}}/cgi/id/dev/notify/1
 | 
			
		||||
  body: json
 | 
			
		||||
  auth: inherit
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body:json {
 | 
			
		||||
  {
 | 
			
		||||
    "client_id": "{{third_client_id}}",
 | 
			
		||||
    "client_secret":"{{third_client_tk}}",
 | 
			
		||||
    "type": "general",
 | 
			
		||||
    "subject": "测试",
 | 
			
		||||
    "subtitle": "Alphabot です",
 | 
			
		||||
    "content": "全新通知动画",
 | 
			
		||||
    "metadata": {
 | 
			
		||||
      "image": "D2EDbcrsTugs3xk5"
 | 
			
		||||
    },
 | 
			
		||||
    "priority": 10
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								api/Wallet/Create Order.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								api/Wallet/Create Order.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
meta {
 | 
			
		||||
  name: Create Order
 | 
			
		||||
  type: http
 | 
			
		||||
  seq: 1
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
post {
 | 
			
		||||
  url: {{endpoint}}/cgi/wa/orders
 | 
			
		||||
  body: json
 | 
			
		||||
  auth: none
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body:json {
 | 
			
		||||
  {
 | 
			
		||||
    "client_id": "highland-mc",
 | 
			
		||||
    "client_secret": "(3^DLAvo3v",
 | 
			
		||||
    "remark": "我是秦始皇,现在被困香港,现在 SN 转我 500 源点,帮助我回到咸阳,到时候封你为太监一职。",
 | 
			
		||||
    "amount": 500
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										21
									
								
								api/Wallet/Create Transaction.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								api/Wallet/Create Transaction.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
meta {
 | 
			
		||||
  name: Create Transaction
 | 
			
		||||
  type: http
 | 
			
		||||
  seq: 3
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
post {
 | 
			
		||||
  url: {{endpoint}}/cgi/wa/transactions
 | 
			
		||||
  body: json
 | 
			
		||||
  auth: none
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body:json {
 | 
			
		||||
  {
 | 
			
		||||
    "client_id": "alphabot",
 | 
			
		||||
    "client_secret": "_uR0sVnHTh",
 | 
			
		||||
    "remark": "新年红包",
 | 
			
		||||
    "amount": 9705,
 | 
			
		||||
    "payee_id": 2
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								api/Wallet/Get Order.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								api/Wallet/Get Order.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
meta {
 | 
			
		||||
  name: Get Order
 | 
			
		||||
  type: http
 | 
			
		||||
  seq: 2
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
get {
 | 
			
		||||
  url: {{endpoint}}/cgi/wa/orders/4
 | 
			
		||||
  body: none
 | 
			
		||||
  auth: none
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body:json {
 | 
			
		||||
  {
 | 
			
		||||
    "client_id": "highland-mc",
 | 
			
		||||
    "client_secret": "(3^DLAvo3v",
 | 
			
		||||
    "remark": "我是秦始皇,现在被困香港,现在 SN 转我 500 源点,帮助我回到咸阳,到时候封你为太监一职。",
 | 
			
		||||
    "amount": 500
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								api/Wallet/Get Transaction.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								api/Wallet/Get Transaction.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
meta {
 | 
			
		||||
  name: Get Transaction
 | 
			
		||||
  type: http
 | 
			
		||||
  seq: 4
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
get {
 | 
			
		||||
  url: {{endpoint}}/cgi/wa/transactions/67
 | 
			
		||||
  body: none
 | 
			
		||||
  auth: inherit
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body:json {
 | 
			
		||||
  {
 | 
			
		||||
    "client_id": "highland-mc",
 | 
			
		||||
    "client_secret": "(3^DLAvo3v",
 | 
			
		||||
    "remark": "我是秦始皇,现在被困香港,现在 SN 转我 500 源点,帮助我回到咸阳,到时候封你为太监一职。",
 | 
			
		||||
    "amount": 500
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								assets/icon/tray-icon.ico
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/icon/tray-icon.ico
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 16 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								assets/icon/tray-icon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/icon/tray-icon.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 228 KiB  | 
@@ -154,9 +154,12 @@
 | 
			
		||||
  "fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm",
 | 
			
		||||
  "writePostTypeStory": "Post a story",
 | 
			
		||||
  "writePostTypeArticle": "Write an article",
 | 
			
		||||
  "writePostTypeQuestion": "Ask a question",
 | 
			
		||||
  "writePostTypeVideo": "Post a video",
 | 
			
		||||
  "fieldPostPublisher": "Post publisher",
 | 
			
		||||
  "fieldPostContent": "What happened?!",
 | 
			
		||||
  "fieldPostTitle": "Title",
 | 
			
		||||
  "fieldPostQuestionReward": "Answer Rewards (Source Points)",
 | 
			
		||||
  "fieldPostDescription": "Description",
 | 
			
		||||
  "fieldPostTags": "Tags",
 | 
			
		||||
  "fieldPostCategories": "Categories",
 | 
			
		||||
@@ -166,9 +169,9 @@
 | 
			
		||||
  "postPosted": "Post has been posted.",
 | 
			
		||||
  "postPublishedAt": "Published At",
 | 
			
		||||
  "postPublishedUntil": "Published Until",
 | 
			
		||||
  "postEditingNotice": "You're about to editing a post that posted {}.",
 | 
			
		||||
  "postReplyingNotice": "You're about to reply to a post that posted {}.",
 | 
			
		||||
  "postRepostingNotice": "You're about to repost a post that posted {}.",
 | 
			
		||||
  "postEditingNotice": "You're about to editing a post that posted by {}.",
 | 
			
		||||
  "postReplyingNotice": "You're about to reply to a post that posted by {}.",
 | 
			
		||||
  "postRepostingNotice": "You're about to repost a post that posted by {}.",
 | 
			
		||||
  "postReact": "React",
 | 
			
		||||
  "postReactions": "Reactions of Post",
 | 
			
		||||
  "postReactionUpvote": {
 | 
			
		||||
@@ -241,6 +244,8 @@
 | 
			
		||||
  "settingsMisc": "Misc",
 | 
			
		||||
  "settingsMiscAbout": "About",
 | 
			
		||||
  "settingsMiscAboutDescription": "View the version information of Solian.",
 | 
			
		||||
  "settingsAccountLanguage": "Account Language",
 | 
			
		||||
  "settingsAccountLanguageDescription": "Set the language for email, notification, and other account-related content.",
 | 
			
		||||
  "sensitiveContent": "Sensitive Content",
 | 
			
		||||
  "sensitiveContentCollapsed": "Sensitive content has been collapsed.",
 | 
			
		||||
  "sensitiveContentDescription": "This content has been marked as sensitive, and may not be suitable for all viewers.",
 | 
			
		||||
@@ -562,6 +567,7 @@
 | 
			
		||||
  "shareIntent": "Share",
 | 
			
		||||
  "shareIntentDescription": "What do you want to do with the content you are sharing?",
 | 
			
		||||
  "shareIntentPostStory": "Post a Story",
 | 
			
		||||
  "shareIntentSendChannel": "Share to Channel",
 | 
			
		||||
  "updateAvailable": "Update Available",
 | 
			
		||||
  "updateOngoing": "Updating, please wait...",
 | 
			
		||||
  "custom": "Custom",
 | 
			
		||||
@@ -574,6 +580,7 @@
 | 
			
		||||
  "colorSchemeWhite": "White",
 | 
			
		||||
  "colorSchemeBlack": "Black",
 | 
			
		||||
  "colorSchemeApplied": "Color scheme has been applied, may need restart the app to take effect.",
 | 
			
		||||
  "postFeaturedComment": "Featured Comment",
 | 
			
		||||
  "postCategoryTechnology": "Technology",
 | 
			
		||||
  "postCategoryGaming": "Gaming",
 | 
			
		||||
  "postCategoryLife": "Life",
 | 
			
		||||
@@ -604,5 +611,13 @@
 | 
			
		||||
    "one": "{} Source Point",
 | 
			
		||||
    "other": "{} Source Points"
 | 
			
		||||
  },
 | 
			
		||||
  "aiThinkingProcess": "AI Thinking Process"
 | 
			
		||||
  "aiThinkingProcess": "AI Thinking Process",
 | 
			
		||||
  "accountSettingsApplied": "Account settings have been applied.",
 | 
			
		||||
  "trayMenuExit": "Exit",
 | 
			
		||||
  "postQuestionUnanswered": "Unanswered Question",
 | 
			
		||||
  "postQuestionUnansweredWithReward": "Unanswered Question, reward source points {}",
 | 
			
		||||
  "postQuestionAnswered": "Answered Question",
 | 
			
		||||
  "postQuestionAnswerSelect": "Select as Answer",
 | 
			
		||||
  "postQuestionAnswerSelected": "Answer has been selected, reward has been applied.",
 | 
			
		||||
  "postVideoUpload": "Upload Video"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -138,9 +138,12 @@
 | 
			
		||||
  "fieldPublisherBelongToRealmUnset": "未设置发布者所属领域",
 | 
			
		||||
  "writePostTypeStory": "发动态",
 | 
			
		||||
  "writePostTypeArticle": "写文章",
 | 
			
		||||
  "writePostTypeQuestion": "提问题",
 | 
			
		||||
  "writePostTypeVideo": "发视频",
 | 
			
		||||
  "fieldPostPublisher": "帖子发布者",
 | 
			
		||||
  "fieldPostContent": "发生什么事了?!",
 | 
			
		||||
  "fieldPostTitle": "标题",
 | 
			
		||||
  "fieldPostQuestionReward": "回答奖励源点",
 | 
			
		||||
  "fieldPostDescription": "描述",
 | 
			
		||||
  "fieldPostTags": "标签",
 | 
			
		||||
  "fieldPostCategories": "分类",
 | 
			
		||||
@@ -239,6 +242,8 @@
 | 
			
		||||
  "settingsMisc": "杂项",
 | 
			
		||||
  "settingsMiscAbout": "关于",
 | 
			
		||||
  "settingsMiscAboutDescription": "查看 Solian 的版本信息。",
 | 
			
		||||
  "settingsAccountLanguage": "帐号偏好语言",
 | 
			
		||||
  "settingsAccountLanguageDescription": "设置邮件、通知和其他帐号相关内容的语言。",
 | 
			
		||||
  "sensitiveContent": "敏感内容",
 | 
			
		||||
  "sensitiveContentCollapsed": "敏感内容已折叠。",
 | 
			
		||||
  "sensitiveContentDescription": "此内容已被标记,可能不适合所有人查看。",
 | 
			
		||||
@@ -560,6 +565,7 @@
 | 
			
		||||
  "shareIntent": "分享",
 | 
			
		||||
  "shareIntentDescription": "您想对您分享的内容做些什么?",
 | 
			
		||||
  "shareIntentPostStory": "发布动态",
 | 
			
		||||
  "shareIntentSendChannel": "分享到聊天频道",
 | 
			
		||||
  "updateAvailable": "检测到更新可用",
 | 
			
		||||
  "updateOngoing": "正在更新,请稍后……",
 | 
			
		||||
  "custom": "自定义",
 | 
			
		||||
@@ -572,6 +578,7 @@
 | 
			
		||||
  "colorSchemeWhite": "白色",
 | 
			
		||||
  "colorSchemeBlack": "黑色",
 | 
			
		||||
  "colorSchemeApplied": "主题色已应用,可能需要重启来生效。",
 | 
			
		||||
  "postFeaturedComment": "精选评论",
 | 
			
		||||
  "postCategoryTechnology": "技术",
 | 
			
		||||
  "postCategoryGaming": "游戏",
 | 
			
		||||
  "postCategoryLife": "生活",
 | 
			
		||||
@@ -602,5 +609,14 @@
 | 
			
		||||
    "one": "{} 源点",
 | 
			
		||||
    "other": "{} 源点"
 | 
			
		||||
  },
 | 
			
		||||
  "aiThinkingProcess": "AI 思考过程"
 | 
			
		||||
  "aiThinkingProcess": "AI 思考过程",
 | 
			
		||||
  "accountSettingsApplied": "帐号设置已应用。",
 | 
			
		||||
  "trayMenuExit": "退出",
 | 
			
		||||
  "postQuestionUnanswered": "未解答的问题",
 | 
			
		||||
  "postQuestionUnansweredWithReward": "未解答的问题,悬赏源点 {}",
 | 
			
		||||
  "postQuestionAnswered": "已解答的问题",
 | 
			
		||||
  "postQuestionAnswerTitle": "精选解答",
 | 
			
		||||
  "postQuestionAnswerSelect": "选择解答",
 | 
			
		||||
  "postQuestionAnswerSelected": "解答已选择,奖励已发放。",
 | 
			
		||||
  "postVideoUpload": "上传视频"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -138,9 +138,11 @@
 | 
			
		||||
  "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
 | 
			
		||||
  "writePostTypeStory": "發動態",
 | 
			
		||||
  "writePostTypeArticle": "寫文章",
 | 
			
		||||
  "writePostTypeQuestion": "提問題",
 | 
			
		||||
  "fieldPostPublisher": "帖子發佈者",
 | 
			
		||||
  "fieldPostContent": "發生什麼事了?!",
 | 
			
		||||
  "fieldPostTitle": "標題",
 | 
			
		||||
  "fieldPostQuestionReward": "回答獎勵源點",
 | 
			
		||||
  "fieldPostDescription": "描述",
 | 
			
		||||
  "fieldPostTags": "標籤",
 | 
			
		||||
  "fieldPostCategories": "分類",
 | 
			
		||||
@@ -239,6 +241,8 @@
 | 
			
		||||
  "settingsMisc": "雜項",
 | 
			
		||||
  "settingsMiscAbout": "關於",
 | 
			
		||||
  "settingsMiscAboutDescription": "查看 Solian 的版本信息。",
 | 
			
		||||
  "settingsAccountLanguage": "帳號偏好語言",
 | 
			
		||||
  "settingsAccountLanguageDescription": "設置郵件、通知和其他帳號相關內容的語言。",
 | 
			
		||||
  "sensitiveContent": "敏感內容",
 | 
			
		||||
  "sensitiveContentCollapsed": "敏感內容已摺疊。",
 | 
			
		||||
  "sensitiveContentDescription": "此內容已被標記,可能不適合所有人查看。",
 | 
			
		||||
@@ -560,6 +564,7 @@
 | 
			
		||||
  "shareIntent": "分享",
 | 
			
		||||
  "shareIntentDescription": "您想對您分享的內容做些什麼?",
 | 
			
		||||
  "shareIntentPostStory": "發佈動態",
 | 
			
		||||
  "shareIntentSendChannel": "分享到聊天頻道",
 | 
			
		||||
  "updateAvailable": "檢測到更新可用",
 | 
			
		||||
  "updateOngoing": "正在更新,請稍後……",
 | 
			
		||||
  "custom": "自定義",
 | 
			
		||||
@@ -572,6 +577,7 @@
 | 
			
		||||
  "colorSchemeWhite": "白色",
 | 
			
		||||
  "colorSchemeBlack": "黑色",
 | 
			
		||||
  "colorSchemeApplied": "主題色已應用,可能需要重啓來生效。",
 | 
			
		||||
  "postFeaturedComment": "精選評論",
 | 
			
		||||
  "postCategoryTechnology": "技術",
 | 
			
		||||
  "postCategoryGaming": "遊戲",
 | 
			
		||||
  "postCategoryLife": "生活",
 | 
			
		||||
@@ -602,5 +608,13 @@
 | 
			
		||||
    "one": "{} 源點",
 | 
			
		||||
    "other": "{} 源點"
 | 
			
		||||
  },
 | 
			
		||||
  "aiThinkingProcess": "AI 思考過程"
 | 
			
		||||
  "aiThinkingProcess": "AI 思考過程",
 | 
			
		||||
  "accountSettingsApplied": "帳號設置已應用。",
 | 
			
		||||
  "trayMenuExit": "退出",
 | 
			
		||||
  "postQuestionUnanswered": "未解答的問題",
 | 
			
		||||
  "postQuestionUnansweredWithReward": "未解答的問題,懸賞源點 {}",
 | 
			
		||||
  "postQuestionAnswered": "已解答的問題",
 | 
			
		||||
  "postQuestionAnswerTitle": "精選解答",
 | 
			
		||||
  "postQuestionAnswerSelect": "選擇解答",
 | 
			
		||||
  "postQuestionAnswerSelected": "解答已選擇,獎勵已發放。"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -138,9 +138,11 @@
 | 
			
		||||
  "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
 | 
			
		||||
  "writePostTypeStory": "發動態",
 | 
			
		||||
  "writePostTypeArticle": "寫文章",
 | 
			
		||||
  "writePostTypeQuestion": "提問題",
 | 
			
		||||
  "fieldPostPublisher": "帖子發佈者",
 | 
			
		||||
  "fieldPostContent": "發生什麼事了?!",
 | 
			
		||||
  "fieldPostTitle": "標題",
 | 
			
		||||
  "fieldPostQuestionReward": "回答獎勵源點",
 | 
			
		||||
  "fieldPostDescription": "描述",
 | 
			
		||||
  "fieldPostTags": "標籤",
 | 
			
		||||
  "fieldPostCategories": "分類",
 | 
			
		||||
@@ -239,6 +241,8 @@
 | 
			
		||||
  "settingsMisc": "雜項",
 | 
			
		||||
  "settingsMiscAbout": "關於",
 | 
			
		||||
  "settingsMiscAboutDescription": "查看 Solian 的版本信息。",
 | 
			
		||||
  "settingsAccountLanguage": "帳號偏好語言",
 | 
			
		||||
  "settingsAccountLanguageDescription": "設置郵件、通知和其他帳號相關內容的語言。",
 | 
			
		||||
  "sensitiveContent": "敏感內容",
 | 
			
		||||
  "sensitiveContentCollapsed": "敏感內容已摺疊。",
 | 
			
		||||
  "sensitiveContentDescription": "此內容已被標記,可能不適合所有人查看。",
 | 
			
		||||
@@ -560,6 +564,7 @@
 | 
			
		||||
  "shareIntent": "分享",
 | 
			
		||||
  "shareIntentDescription": "您想對您分享的內容做些什麼?",
 | 
			
		||||
  "shareIntentPostStory": "發佈動態",
 | 
			
		||||
  "shareIntentSendChannel": "分享到聊天頻道",
 | 
			
		||||
  "updateAvailable": "檢測到更新可用",
 | 
			
		||||
  "updateOngoing": "正在更新,請稍後……",
 | 
			
		||||
  "custom": "自定義",
 | 
			
		||||
@@ -572,6 +577,7 @@
 | 
			
		||||
  "colorSchemeWhite": "白色",
 | 
			
		||||
  "colorSchemeBlack": "黑色",
 | 
			
		||||
  "colorSchemeApplied": "主題色已應用,可能需要重啟來生效。",
 | 
			
		||||
  "postFeaturedComment": "精選評論",
 | 
			
		||||
  "postCategoryTechnology": "技術",
 | 
			
		||||
  "postCategoryGaming": "遊戲",
 | 
			
		||||
  "postCategoryLife": "生活",
 | 
			
		||||
@@ -602,5 +608,13 @@
 | 
			
		||||
    "one": "{} 源點",
 | 
			
		||||
    "other": "{} 源點"
 | 
			
		||||
  },
 | 
			
		||||
  "aiThinkingProcess": "AI 思考過程"
 | 
			
		||||
  "aiThinkingProcess": "AI 思考過程",
 | 
			
		||||
  "accountSettingsApplied": "帳號設置已應用。",
 | 
			
		||||
  "trayMenuExit": "退出",
 | 
			
		||||
  "postQuestionUnanswered": "未解答的問題",
 | 
			
		||||
  "postQuestionUnansweredWithReward": "未解答的問題,懸賞源點 {}",
 | 
			
		||||
  "postQuestionAnswered": "已解答的問題",
 | 
			
		||||
  "postQuestionAnswerTitle": "精選解答",
 | 
			
		||||
  "postQuestionAnswerSelect": "選擇解答",
 | 
			
		||||
  "postQuestionAnswerSelected": "解答已選擇,獎勵已發放。"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,6 @@ PODS:
 | 
			
		||||
  - Alamofire (5.10.2)
 | 
			
		||||
  - connectivity_plus (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - croppy (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - device_info_plus (0.0.1):
 | 
			
		||||
@@ -43,58 +42,58 @@ PODS:
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - file_saver (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - Firebase/Analytics (11.6.0):
 | 
			
		||||
  - Firebase/Analytics (11.7.0):
 | 
			
		||||
    - Firebase/Core
 | 
			
		||||
  - Firebase/Core (11.6.0):
 | 
			
		||||
  - Firebase/Core (11.7.0):
 | 
			
		||||
    - Firebase/CoreOnly
 | 
			
		||||
    - FirebaseAnalytics (~> 11.6.0)
 | 
			
		||||
  - Firebase/CoreOnly (11.6.0):
 | 
			
		||||
    - FirebaseCore (~> 11.6.0)
 | 
			
		||||
  - Firebase/Messaging (11.6.0):
 | 
			
		||||
    - FirebaseAnalytics (~> 11.7.0)
 | 
			
		||||
  - Firebase/CoreOnly (11.7.0):
 | 
			
		||||
    - FirebaseCore (~> 11.7.0)
 | 
			
		||||
  - Firebase/Messaging (11.7.0):
 | 
			
		||||
    - Firebase/CoreOnly
 | 
			
		||||
    - FirebaseMessaging (~> 11.6.0)
 | 
			
		||||
  - firebase_analytics (11.4.1):
 | 
			
		||||
    - Firebase/Analytics (= 11.6.0)
 | 
			
		||||
    - FirebaseMessaging (~> 11.7.0)
 | 
			
		||||
  - firebase_analytics (11.4.2):
 | 
			
		||||
    - Firebase/Analytics (= 11.7.0)
 | 
			
		||||
    - firebase_core
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - firebase_core (3.10.1):
 | 
			
		||||
    - Firebase/CoreOnly (= 11.6.0)
 | 
			
		||||
  - firebase_core (3.11.0):
 | 
			
		||||
    - Firebase/CoreOnly (= 11.7.0)
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - firebase_messaging (15.2.1):
 | 
			
		||||
    - Firebase/Messaging (= 11.6.0)
 | 
			
		||||
  - firebase_messaging (15.2.2):
 | 
			
		||||
    - Firebase/Messaging (= 11.7.0)
 | 
			
		||||
    - firebase_core
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - FirebaseAnalytics (11.6.0):
 | 
			
		||||
    - FirebaseAnalytics/AdIdSupport (= 11.6.0)
 | 
			
		||||
    - FirebaseCore (~> 11.6.0)
 | 
			
		||||
  - FirebaseAnalytics (11.7.0):
 | 
			
		||||
    - FirebaseAnalytics/AdIdSupport (= 11.7.0)
 | 
			
		||||
    - FirebaseCore (~> 11.7.0)
 | 
			
		||||
    - FirebaseInstallations (~> 11.0)
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/Network (~> 8.0)
 | 
			
		||||
    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
			
		||||
    - nanopb (~> 3.30910.0)
 | 
			
		||||
  - FirebaseAnalytics/AdIdSupport (11.6.0):
 | 
			
		||||
    - FirebaseCore (~> 11.6.0)
 | 
			
		||||
  - FirebaseAnalytics/AdIdSupport (11.7.0):
 | 
			
		||||
    - FirebaseCore (~> 11.7.0)
 | 
			
		||||
    - FirebaseInstallations (~> 11.0)
 | 
			
		||||
    - GoogleAppMeasurement (= 11.6.0)
 | 
			
		||||
    - GoogleAppMeasurement (= 11.7.0)
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/Network (~> 8.0)
 | 
			
		||||
    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
			
		||||
    - nanopb (~> 3.30910.0)
 | 
			
		||||
  - FirebaseCore (11.6.0):
 | 
			
		||||
    - FirebaseCoreInternal (~> 11.6.0)
 | 
			
		||||
  - FirebaseCore (11.7.0):
 | 
			
		||||
    - FirebaseCoreInternal (~> 11.7.0)
 | 
			
		||||
    - GoogleUtilities/Environment (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/Logger (~> 8.0)
 | 
			
		||||
  - FirebaseCoreInternal (11.6.0):
 | 
			
		||||
  - FirebaseCoreInternal (11.7.0):
 | 
			
		||||
    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
			
		||||
  - FirebaseInstallations (11.6.0):
 | 
			
		||||
    - FirebaseCore (~> 11.6.0)
 | 
			
		||||
  - FirebaseInstallations (11.7.0):
 | 
			
		||||
    - FirebaseCore (~> 11.7.0)
 | 
			
		||||
    - GoogleUtilities/Environment (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/UserDefaults (~> 8.0)
 | 
			
		||||
    - PromisesObjC (~> 2.4)
 | 
			
		||||
  - FirebaseMessaging (11.6.0):
 | 
			
		||||
    - FirebaseCore (~> 11.6.0)
 | 
			
		||||
  - FirebaseMessaging (11.7.0):
 | 
			
		||||
    - FirebaseCore (~> 11.7.0)
 | 
			
		||||
    - FirebaseInstallations (~> 11.0)
 | 
			
		||||
    - GoogleDataTransport (~> 10.0)
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
			
		||||
@@ -123,21 +122,21 @@ PODS:
 | 
			
		||||
  - gal (1.0.0):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - GoogleAppMeasurement (11.6.0):
 | 
			
		||||
    - GoogleAppMeasurement/AdIdSupport (= 11.6.0)
 | 
			
		||||
  - GoogleAppMeasurement (11.7.0):
 | 
			
		||||
    - GoogleAppMeasurement/AdIdSupport (= 11.7.0)
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/Network (~> 8.0)
 | 
			
		||||
    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
			
		||||
    - nanopb (~> 3.30910.0)
 | 
			
		||||
  - GoogleAppMeasurement/AdIdSupport (11.6.0):
 | 
			
		||||
    - GoogleAppMeasurement/WithoutAdIdSupport (= 11.6.0)
 | 
			
		||||
  - GoogleAppMeasurement/AdIdSupport (11.7.0):
 | 
			
		||||
    - GoogleAppMeasurement/WithoutAdIdSupport (= 11.7.0)
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/Network (~> 8.0)
 | 
			
		||||
    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
			
		||||
    - nanopb (~> 3.30910.0)
 | 
			
		||||
  - GoogleAppMeasurement/WithoutAdIdSupport (11.6.0):
 | 
			
		||||
  - GoogleAppMeasurement/WithoutAdIdSupport (11.7.0):
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/Network (~> 8.0)
 | 
			
		||||
@@ -179,8 +178,8 @@ PODS:
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - in_app_review (2.0.0):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - Kingfisher (8.1.3)
 | 
			
		||||
  - livekit_client (2.3.5):
 | 
			
		||||
  - Kingfisher (8.2.0)
 | 
			
		||||
  - livekit_client (2.3.6):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - flutter_webrtc
 | 
			
		||||
    - WebRTC-SDK (= 125.6422.06)
 | 
			
		||||
@@ -237,7 +236,7 @@ PODS:
 | 
			
		||||
 | 
			
		||||
DEPENDENCIES:
 | 
			
		||||
  - Alamofire
 | 
			
		||||
  - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
 | 
			
		||||
  - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
 | 
			
		||||
  - croppy (from `.symlinks/plugins/croppy/ios`)
 | 
			
		||||
  - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
 | 
			
		||||
  - file_picker (from `.symlinks/plugins/file_picker/ios`)
 | 
			
		||||
@@ -300,7 +299,7 @@ SPEC REPOS:
 | 
			
		||||
 | 
			
		||||
EXTERNAL SOURCES:
 | 
			
		||||
  connectivity_plus:
 | 
			
		||||
    :path: ".symlinks/plugins/connectivity_plus/darwin"
 | 
			
		||||
    :path: ".symlinks/plugins/connectivity_plus/ios"
 | 
			
		||||
  croppy:
 | 
			
		||||
    :path: ".symlinks/plugins/croppy/ios"
 | 
			
		||||
  device_info_plus:
 | 
			
		||||
@@ -374,22 +373,22 @@ EXTERNAL SOURCES:
 | 
			
		||||
 | 
			
		||||
SPEC CHECKSUMS:
 | 
			
		||||
  Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
 | 
			
		||||
  connectivity_plus: 18382e7311ba19efcaee94442b23b32507b20695
 | 
			
		||||
  connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
 | 
			
		||||
  croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321
 | 
			
		||||
  device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
 | 
			
		||||
  DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
 | 
			
		||||
  DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
 | 
			
		||||
  file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
 | 
			
		||||
  file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
 | 
			
		||||
  Firebase: 374a441a91ead896215703a674d58cdb3e9d772b
 | 
			
		||||
  firebase_analytics: 13ea4ad8a42c5060bad7e6694304dabb8b02fe7e
 | 
			
		||||
  firebase_core: e2aa06dbd854d961f8ce46c2e20933bee1bf2d2b
 | 
			
		||||
  firebase_messaging: 96cf6d67121b3f39746b2a4f29a26c0eee4af70e
 | 
			
		||||
  FirebaseAnalytics: 7114c698cac995602e3b1b96663473e50d54d6e7
 | 
			
		||||
  FirebaseCore: 48b0dd707581cf9c1a1220da68223fb0a562afaa
 | 
			
		||||
  FirebaseCoreInternal: d98ab91e2d80a56d7b246856a8885443b302c0c2
 | 
			
		||||
  FirebaseInstallations: efc0946fc756e4d22d8113f7c761948120322e8c
 | 
			
		||||
  FirebaseMessaging: e1aca1fcc23e8b9eddb0e33f375ff90944623021
 | 
			
		||||
  Firebase: a64bf6a8546e6eab54f1c715cd6151f39d2329f4
 | 
			
		||||
  firebase_analytics: 7236e6115c1b4e62c2270faa29c052a317e31107
 | 
			
		||||
  firebase_core: aa979ae726f00b3ef4ccf59dfb96170af84efbd4
 | 
			
		||||
  firebase_messaging: 3af84b6a90aeac4d7a67fbf4c43a91e7083bea1f
 | 
			
		||||
  FirebaseAnalytics: bc9e565af9044ba8d6c6e4157e4edca8e5fdf7ec
 | 
			
		||||
  FirebaseCore: 3227e35f4197a924206fbcdc0349325baf4f5de4
 | 
			
		||||
  FirebaseCoreInternal: d6c17dafc8dc33614733a8b52df78fcb4394c881
 | 
			
		||||
  FirebaseInstallations: 9347e719c3d52d8d7b9074b2c32407dd027305e9
 | 
			
		||||
  FirebaseMessaging: 00ece041b71ddb52a2862ffdee73fb6e9824bd0c
 | 
			
		||||
  Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
 | 
			
		||||
  flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc
 | 
			
		||||
  flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4
 | 
			
		||||
@@ -397,14 +396,14 @@ SPEC CHECKSUMS:
 | 
			
		||||
  flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab
 | 
			
		||||
  flutter_webrtc: 90260f83024b1b96d239a575ea4e3708e79344d1
 | 
			
		||||
  gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
 | 
			
		||||
  GoogleAppMeasurement: 6a9e6317b6a6d810ad03d4a66564ca6c4c5818a3
 | 
			
		||||
  GoogleAppMeasurement: 0471a5b5bff51f3a91b1e76df22c952d04c63967
 | 
			
		||||
  GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
 | 
			
		||||
  GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
 | 
			
		||||
  home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
 | 
			
		||||
  image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
 | 
			
		||||
  in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
 | 
			
		||||
  Kingfisher: f2af9028b16baf9dc6c07c570072bc41cbf009ef
 | 
			
		||||
  livekit_client: dcc5fd47ba69c98fc6baeb12e862c9d43807d976
 | 
			
		||||
  Kingfisher: 323e5c4ec7983aaace12af655a7b51a7f88a599d
 | 
			
		||||
  livekit_client: 148b2cf67a09aaf475ba8e5bf1667fe10dc35f81
 | 
			
		||||
  media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
 | 
			
		||||
  media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
 | 
			
		||||
  media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
 | 
			
		||||
 
 | 
			
		||||
@@ -71,7 +71,7 @@ class ChatMessageController extends ChangeNotifier {
 | 
			
		||||
      resp.data as Map<String, dynamic>,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    _wsSubscription = _ws.stream.stream.listen((event) {
 | 
			
		||||
    _wsSubscription = _ws.pk.stream.listen((event) {
 | 
			
		||||
      switch (event.method) {
 | 
			
		||||
        case 'events.new':
 | 
			
		||||
          if (event.payload?['channel_id'] != channel?.id) break;
 | 
			
		||||
 
 | 
			
		||||
@@ -144,6 +144,8 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
  static const Map<String, String> kTitleMap = {
 | 
			
		||||
    'stories': 'writePostTypeStory',
 | 
			
		||||
    'articles': 'writePostTypeArticle',
 | 
			
		||||
    'questions': 'writePostTypeQuestion',
 | 
			
		||||
    'videos': 'writePostTypeVideo',
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  static const kAttachmentProgressWeight = 0.9;
 | 
			
		||||
@@ -153,6 +155,7 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
  final TextEditingController titleController = TextEditingController();
 | 
			
		||||
  final TextEditingController descriptionController = TextEditingController();
 | 
			
		||||
  final TextEditingController aliasController = TextEditingController();
 | 
			
		||||
  final TextEditingController rewardController = TextEditingController();
 | 
			
		||||
 | 
			
		||||
  bool _temporarySaveActive = false;
 | 
			
		||||
 | 
			
		||||
@@ -168,6 +171,7 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
    });
 | 
			
		||||
    contentController.addListener(() {
 | 
			
		||||
      _temporaryPlanSave();
 | 
			
		||||
      notifyListeners();
 | 
			
		||||
    });
 | 
			
		||||
    if (doLoadFromTemporary) _temporaryLoad();
 | 
			
		||||
  }
 | 
			
		||||
@@ -194,6 +198,7 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
  PostWriteMedia? thumbnail;
 | 
			
		||||
  List<PostWriteMedia> attachments = List.empty(growable: true);
 | 
			
		||||
  DateTime? publishedAt, publishedUntil;
 | 
			
		||||
  SnAttachment? videoAttachment;
 | 
			
		||||
 | 
			
		||||
  Future<void> fetchRelatedPost(
 | 
			
		||||
    BuildContext context, {
 | 
			
		||||
@@ -214,6 +219,8 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
        descriptionController.text = post.body['description'] ?? '';
 | 
			
		||||
        contentController.text = post.body['content'] ?? '';
 | 
			
		||||
        aliasController.text = post.alias ?? '';
 | 
			
		||||
        rewardController.text = post.body['reward']?.toString() ?? '';
 | 
			
		||||
        videoAttachment = post.preload?.video;
 | 
			
		||||
        publishedAt = post.publishedAt;
 | 
			
		||||
        publishedUntil = post.publishedUntil;
 | 
			
		||||
        visibleUsers = List.from(post.visibleUsersList ?? [], growable: true);
 | 
			
		||||
@@ -347,6 +354,7 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
          if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
 | 
			
		||||
          if (titleController.text.isNotEmpty) 'title': titleController.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),
 | 
			
		||||
@@ -375,6 +383,7 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
      aliasController.text = data['alias'] ?? '';
 | 
			
		||||
      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>());
 | 
			
		||||
@@ -473,6 +482,8 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
    progress = kAttachmentProgressWeight;
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
 | 
			
		||||
    final reward = double.tryParse(rewardController.text);
 | 
			
		||||
 | 
			
		||||
    // Posting the content
 | 
			
		||||
    try {
 | 
			
		||||
      final baseProgressVal = progress!;
 | 
			
		||||
@@ -498,6 +509,8 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
          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,
 | 
			
		||||
        },
 | 
			
		||||
        onSendProgress: (count, total) {
 | 
			
		||||
          progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
 | 
			
		||||
@@ -624,6 +637,11 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setVideoAttachment(SnAttachment? value) {
 | 
			
		||||
    videoAttachment = value;
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void reset() {
 | 
			
		||||
    publishedAt = null;
 | 
			
		||||
    publishedUntil = null;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										106
									
								
								lib/main.dart
									
									
									
									
									
								
							
							
						
						
									
										106
									
								
								lib/main.dart
									
									
									
									
									
								
							@@ -1,6 +1,7 @@
 | 
			
		||||
import 'dart:async';
 | 
			
		||||
import 'dart:developer';
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
import 'dart:ui';
 | 
			
		||||
 | 
			
		||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
 | 
			
		||||
import 'package:croppy/croppy.dart';
 | 
			
		||||
@@ -10,8 +11,10 @@ import 'package:easy_localization_loader/easy_localization_loader.dart';
 | 
			
		||||
import 'package:firebase_core/firebase_core.dart';
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:hive_flutter/hive_flutter.dart';
 | 
			
		||||
import 'package:hotkey_manager/hotkey_manager.dart';
 | 
			
		||||
import 'package:package_info_plus/package_info_plus.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:relative_time/relative_time.dart';
 | 
			
		||||
@@ -40,6 +43,7 @@ import 'package:surface/types/chat.dart';
 | 
			
		||||
import 'package:surface/types/realm.dart';
 | 
			
		||||
import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:tray_manager/tray_manager.dart';
 | 
			
		||||
import 'package:version/version.dart';
 | 
			
		||||
import 'package:workmanager/workmanager.dart';
 | 
			
		||||
import 'package:in_app_review/in_app_review.dart';
 | 
			
		||||
@@ -206,7 +210,7 @@ class _AppSplashScreen extends StatefulWidget {
 | 
			
		||||
  State<_AppSplashScreen> createState() => _AppSplashScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AppSplashScreenState extends State<_AppSplashScreen> {
 | 
			
		||||
class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
			
		||||
  void _tryRequestRating() async {
 | 
			
		||||
    final prefs = await SharedPreferences.getInstance();
 | 
			
		||||
    if (prefs.containsKey('first_boot_time')) {
 | 
			
		||||
@@ -281,6 +285,9 @@ class _AppSplashScreenState extends State<_AppSplashScreen> {
 | 
			
		||||
      final notify = context.read<NotificationProvider>();
 | 
			
		||||
      notify.listen();
 | 
			
		||||
      await notify.registerPushNotifications();
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      final sticker = context.read<SnStickerProvider>();
 | 
			
		||||
      await sticker.listStickerEagerly();
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      await context.showErrorDialog(err);
 | 
			
		||||
@@ -291,9 +298,62 @@ class _AppSplashScreenState extends State<_AppSplashScreen> {
 | 
			
		||||
    await widgetUpdateRandomPost();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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');
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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 appVersion = await PackageInfo.fromPlatform();
 | 
			
		||||
 | 
			
		||||
    trayManager.addListener(this);
 | 
			
		||||
    await trayManager.setIcon(icon);
 | 
			
		||||
 | 
			
		||||
    Menu menu = Menu(
 | 
			
		||||
      items: [
 | 
			
		||||
        MenuItem(
 | 
			
		||||
          key: 'version_label',
 | 
			
		||||
          label: 'Solian ${appVersion.version}+${appVersion.buildNumber}',
 | 
			
		||||
          disabled: true,
 | 
			
		||||
        ),
 | 
			
		||||
        MenuItem.separator(),
 | 
			
		||||
        MenuItem(
 | 
			
		||||
          key: 'exit',
 | 
			
		||||
          label: 'trayMenuExit'.tr(),
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
    await trayManager.setContextMenu(menu);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  AppLifecycleListener? _appLifecycleListener;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
 | 
			
		||||
    if (!kIsWeb && !(Platform.isIOS || Platform.isAndroid)) {
 | 
			
		||||
      _appLifecycleListener = AppLifecycleListener(
 | 
			
		||||
        onExitRequested: _onExitRequested,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _trayInitialization();
 | 
			
		||||
    _hotkeyInitialization();
 | 
			
		||||
    _initialize().then((_) {
 | 
			
		||||
      _postInitialization();
 | 
			
		||||
      _tryRequestRating();
 | 
			
		||||
@@ -301,6 +361,50 @@ class _AppSplashScreenState extends State<_AppSplashScreen> {
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<AppExitResponse> _onExitRequested() async {
 | 
			
		||||
    appWindow.hide();
 | 
			
		||||
    return AppExitResponse.cancel;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void onTrayIconMouseDown() {
 | 
			
		||||
    if (Platform.isWindows) {
 | 
			
		||||
      context.read<NotificationProvider>().clearTray();
 | 
			
		||||
      appWindow.show();
 | 
			
		||||
    } else {
 | 
			
		||||
      trayManager.popUpContextMenu();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void onTrayIconRightMouseDown() {
 | 
			
		||||
    if (Platform.isWindows) {
 | 
			
		||||
      trayManager.popUpContextMenu();
 | 
			
		||||
    } else {
 | 
			
		||||
      context.read<NotificationProvider>().clearTray();
 | 
			
		||||
      appWindow.show();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void onTrayMenuItemClick(MenuItem menuItem) {
 | 
			
		||||
    switch (menuItem.key) {
 | 
			
		||||
      case 'exit':
 | 
			
		||||
        _appLifecycleListener?.dispose();
 | 
			
		||||
        SystemChannels.platform.invokeMethod('SystemNavigator.pop');
 | 
			
		||||
        break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) {
 | 
			
		||||
      trayManager.removeListener(this);
 | 
			
		||||
      hotKeyManager.unregisterAll();
 | 
			
		||||
    }
 | 
			
		||||
    super.dispose();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final cfg = context.read<ConfigProvider>();
 | 
			
		||||
 
 | 
			
		||||
@@ -12,6 +12,7 @@ import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/providers/websocket.dart';
 | 
			
		||||
import 'package:surface/types/notification.dart';
 | 
			
		||||
import 'package:tray_manager/tray_manager.dart';
 | 
			
		||||
 | 
			
		||||
class NotificationProvider extends ChangeNotifier {
 | 
			
		||||
  late final SnNetworkProvider _sn;
 | 
			
		||||
@@ -71,22 +72,48 @@ class NotificationProvider extends ChangeNotifier {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  int showingCount = 0;
 | 
			
		||||
  int showingTrayCount = 0;
 | 
			
		||||
  List<SnNotification> notifications = List.empty(growable: true);
 | 
			
		||||
 | 
			
		||||
  void listen() {
 | 
			
		||||
    _ws.stream.stream.listen((event) {
 | 
			
		||||
    _ws.pk.stream.listen((event) {
 | 
			
		||||
      if (event.method == 'notifications.new') {
 | 
			
		||||
        final notification = SnNotification.fromJson(event.payload!);
 | 
			
		||||
        if (showingCount < 0) showingCount = 0;
 | 
			
		||||
        showingCount++;
 | 
			
		||||
        showingTrayCount++;
 | 
			
		||||
        notifications.add(notification);
 | 
			
		||||
        Future.delayed(const Duration(seconds: 3), () {
 | 
			
		||||
          if (showingCount >= 0) showingCount--;
 | 
			
		||||
          notifyListeners();
 | 
			
		||||
        });
 | 
			
		||||
        notifyListeners();
 | 
			
		||||
        updateTray();
 | 
			
		||||
        final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true;
 | 
			
		||||
        if (doHaptic) HapticFeedback.lightImpact();
 | 
			
		||||
        if (doHaptic) HapticFeedback.mediumImpact();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void clearTray() {
 | 
			
		||||
    showingTrayCount = 0;
 | 
			
		||||
    updateTray();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void updateTray() {
 | 
			
		||||
    if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
 | 
			
		||||
    if (showingTrayCount == 0) {
 | 
			
		||||
      trayManager.setTitle('');
 | 
			
		||||
    } else {
 | 
			
		||||
      trayManager.setTitle(' $showingTrayCount');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void clear() {
 | 
			
		||||
    showingCount = 0;
 | 
			
		||||
    notifications.clear();
 | 
			
		||||
    updateTray();
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -23,6 +23,9 @@ class SnPostContentProvider {
 | 
			
		||||
      if (out[i].body['thumbnail'] != null) {
 | 
			
		||||
        rids.add(out[i].body['thumbnail']);
 | 
			
		||||
      }
 | 
			
		||||
      if (out[i].body['video'] != null) {
 | 
			
		||||
        rids.add(out[i].body['video']);
 | 
			
		||||
      }
 | 
			
		||||
      if (out[i].repostTo != null) {
 | 
			
		||||
        out[i] = out[i].copyWith(
 | 
			
		||||
          repostTo: await _preloadRelatedDataSingle(out[i].repostTo!),
 | 
			
		||||
@@ -36,6 +39,7 @@ class SnPostContentProvider {
 | 
			
		||||
        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,
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
@@ -53,6 +57,9 @@ class SnPostContentProvider {
 | 
			
		||||
    if (out.body['thumbnail'] != null) {
 | 
			
		||||
      rids.add(out.body['thumbnail']);
 | 
			
		||||
    }
 | 
			
		||||
    if (out.body['video'] != null) {
 | 
			
		||||
      rids.add(out.body['video']);
 | 
			
		||||
    }
 | 
			
		||||
    if (out.repostTo != null) {
 | 
			
		||||
      out = out.copyWith(
 | 
			
		||||
        repostTo: await _preloadRelatedDataSingle(out.repostTo!),
 | 
			
		||||
@@ -64,6 +71,7 @@ class SnPostContentProvider {
 | 
			
		||||
      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,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,6 +9,10 @@ class SnStickerProvider {
 | 
			
		||||
  late final SnNetworkProvider _sn;
 | 
			
		||||
  final Map<String, SnSticker?> _cache = {};
 | 
			
		||||
 | 
			
		||||
  final Map<int, List<SnSticker>> stickersByPack = {};
 | 
			
		||||
 | 
			
		||||
  List<SnSticker> get stickers => _cache.values.where((ele) => ele != null).cast<SnSticker>().toList();
 | 
			
		||||
 | 
			
		||||
  SnStickerProvider(BuildContext context) {
 | 
			
		||||
    _sn = context.read<SnNetworkProvider>();
 | 
			
		||||
  }
 | 
			
		||||
@@ -17,6 +21,12 @@ class SnStickerProvider {
 | 
			
		||||
    return _cache.containsKey(alias) && _cache[alias] == null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _cacheSticker(SnSticker sticker) {
 | 
			
		||||
    _cache['${sticker.pack.prefix}:${sticker.alias}'] = sticker;
 | 
			
		||||
    if (stickersByPack[sticker.pack.id] == null) stickersByPack[sticker.pack.id] = List.empty(growable: true);
 | 
			
		||||
    if (!stickersByPack[sticker.pack.id]!.contains(sticker)) stickersByPack[sticker.pack.id]!.add(sticker);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<SnSticker?> lookupSticker(String alias) async {
 | 
			
		||||
    if (_cache.containsKey(alias)) {
 | 
			
		||||
      return _cache[alias];
 | 
			
		||||
@@ -25,7 +35,7 @@ class SnStickerProvider {
 | 
			
		||||
    try {
 | 
			
		||||
      final resp = await _sn.client.get('/cgi/uc/stickers/lookup/$alias');
 | 
			
		||||
      final sticker = SnSticker.fromJson(resp.data);
 | 
			
		||||
      _cache[alias] = sticker;
 | 
			
		||||
      _cacheSticker(sticker);
 | 
			
		||||
 | 
			
		||||
      return sticker;
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
@@ -35,4 +45,30 @@ class SnStickerProvider {
 | 
			
		||||
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> listStickerEagerly() async {
 | 
			
		||||
    var count = await listSticker();
 | 
			
		||||
    for (var page = 1; count > 0; count -= 10) {
 | 
			
		||||
      await listSticker(page: page);
 | 
			
		||||
      page++;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<int> listSticker({int page = 0}) async {
 | 
			
		||||
    try {
 | 
			
		||||
      final resp = await _sn.client.get('/cgi/uc/stickers', queryParameters: {
 | 
			
		||||
        'take': 10,
 | 
			
		||||
        'offset': page * 10,
 | 
			
		||||
      });
 | 
			
		||||
      final data = resp.data;
 | 
			
		||||
      final stickers = List.from(data['data']).map((ele) => SnSticker.fromJson(ele));
 | 
			
		||||
      for (final sticker in stickers) {
 | 
			
		||||
        _cacheSticker(sticker);
 | 
			
		||||
      }
 | 
			
		||||
      return data['count'] as int;
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      log('[Sticker] Failed to list stickers: $err');
 | 
			
		||||
      rethrow;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -53,4 +53,11 @@ class UserProvider extends ChangeNotifier {
 | 
			
		||||
    user = null;
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setLanguage(String? value) {
 | 
			
		||||
    if (value == null) return;
 | 
			
		||||
    if (user == null) return;
 | 
			
		||||
    user = user!.copyWith(language: value);
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,8 @@ class WebSocketProvider extends ChangeNotifier {
 | 
			
		||||
  late final SnNetworkProvider _sn;
 | 
			
		||||
  late final UserProvider _ua;
 | 
			
		||||
 | 
			
		||||
  StreamController<WebSocketPackage> stream = StreamController.broadcast();
 | 
			
		||||
  StreamController<WebSocketPackage> pk = StreamController.broadcast();
 | 
			
		||||
  Stream<dynamic>? _wsStream;
 | 
			
		||||
 | 
			
		||||
  WebSocketProvider(BuildContext context) {
 | 
			
		||||
    _sn = context.read<SnNetworkProvider>();
 | 
			
		||||
@@ -33,7 +34,16 @@ class WebSocketProvider extends ChangeNotifier {
 | 
			
		||||
    await connect();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Completer<void>? _connectCompleter;
 | 
			
		||||
 | 
			
		||||
  Future<void> connect({noRetry = false}) async {
 | 
			
		||||
    if (_connectCompleter != null) {
 | 
			
		||||
      await _connectCompleter!.future;
 | 
			
		||||
      _connectCompleter = null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _connectCompleter = Completer<void>();
 | 
			
		||||
 | 
			
		||||
    if (!_ua.isAuthorized) return;
 | 
			
		||||
    if (isConnected || conn != null) {
 | 
			
		||||
      disconnect();
 | 
			
		||||
@@ -50,6 +60,7 @@ class WebSocketProvider extends ChangeNotifier {
 | 
			
		||||
    try {
 | 
			
		||||
      conn = WebSocketChannel.connect(uri);
 | 
			
		||||
      await conn!.ready;
 | 
			
		||||
      _wsStream = conn!.stream.asBroadcastStream();
 | 
			
		||||
      listen();
 | 
			
		||||
      log('[WebSocket] Connected to server!');
 | 
			
		||||
      isConnected = true;
 | 
			
		||||
@@ -70,6 +81,7 @@ class WebSocketProvider extends ChangeNotifier {
 | 
			
		||||
    } finally {
 | 
			
		||||
      isBusy = false;
 | 
			
		||||
      notifyListeners();
 | 
			
		||||
      _connectCompleter!.complete();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -83,11 +95,12 @@ class WebSocketProvider extends ChangeNotifier {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void listen() {
 | 
			
		||||
    conn?.stream.listen(
 | 
			
		||||
    if (_wsStream == null) return;
 | 
			
		||||
    _wsStream!.listen(
 | 
			
		||||
      (event) {
 | 
			
		||||
        final packet = WebSocketPackage.fromJson(jsonDecode(event));
 | 
			
		||||
        log('Websocket incoming message: ${packet.method} ${packet.message}');
 | 
			
		||||
        stream.sink.add(packet);
 | 
			
		||||
        pk.sink.add(packet);
 | 
			
		||||
      },
 | 
			
		||||
      onDone: () {
 | 
			
		||||
        isConnected = false;
 | 
			
		||||
 
 | 
			
		||||
@@ -47,6 +47,7 @@ class HomeWidgetProvider {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
Future<void> widgetUpdateRandomPost() async {
 | 
			
		||||
  if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) return;
 | 
			
		||||
  final snc = await SnNetworkProvider.createOffContextClient();
 | 
			
		||||
  final resp = await snc.get('/cgi/co/recommendations/shuffle?take=1');
 | 
			
		||||
  final post = SnPost.fromJson(resp.data['data'][0]);
 | 
			
		||||
 
 | 
			
		||||
@@ -73,7 +73,7 @@ final _appRoutes = [
 | 
			
		||||
          postRepostId: int.tryParse(
 | 
			
		||||
            state.uri.queryParameters['reposting'] ?? '',
 | 
			
		||||
          ),
 | 
			
		||||
          extraProps: state.extra as PostEditorExtraProps?,
 | 
			
		||||
          extraProps: state.extra as PostEditorExtra?,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
@@ -156,6 +156,7 @@ final _appRoutes = [
 | 
			
		||||
        builder: (context, state) => ChatRoomScreen(
 | 
			
		||||
          scope: state.pathParameters['scope']!,
 | 
			
		||||
          alias: state.pathParameters['alias']!,
 | 
			
		||||
          extra: state.extra as ChatRoomScreenExtra?,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
@@ -191,11 +192,6 @@ final _appRoutes = [
 | 
			
		||||
      child: const RealmScreen(),
 | 
			
		||||
    ),
 | 
			
		||||
    routes: [
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/:alias',
 | 
			
		||||
        name: 'realmDetail',
 | 
			
		||||
        builder: (context, state) => RealmDetailScreen(alias: state.pathParameters['alias']!),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/manage',
 | 
			
		||||
        name: 'realmManage',
 | 
			
		||||
@@ -203,6 +199,11 @@ final _appRoutes = [
 | 
			
		||||
          editingRealmAlias: state.uri.queryParameters['editing'],
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/:alias',
 | 
			
		||||
        name: 'realmDetail',
 | 
			
		||||
        builder: (context, state) => RealmDetailScreen(alias: state.pathParameters['alias']!),
 | 
			
		||||
      ),
 | 
			
		||||
    ],
 | 
			
		||||
  ),
 | 
			
		||||
  GoRoute(path: '/news', name: 'news', builder: (context, state) => const NewsScreen(), routes: [
 | 
			
		||||
 
 | 
			
		||||
@@ -28,7 +28,19 @@ class AccountScreen extends StatelessWidget {
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: AutoAppBarLeading(),
 | 
			
		||||
        title: Text("screenAccount").tr(),
 | 
			
		||||
        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(),
 | 
			
		||||
        flexibleSpace: ua.user != null && ua.user!.banner.isNotEmpty
 | 
			
		||||
            ? Stack(
 | 
			
		||||
                fit: StackFit.expand,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,17 +1,41 @@
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:dropdown_button2/dropdown_button2.dart';
 | 
			
		||||
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/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:intl/locale.dart';
 | 
			
		||||
 | 
			
		||||
class AccountSettingsScreen extends StatelessWidget {
 | 
			
		||||
  const AccountSettingsScreen({super.key});
 | 
			
		||||
 | 
			
		||||
  Future<void> _setAccountLanguage(BuildContext context, Locale? value) async {
 | 
			
		||||
    if (value == null) return;
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final ua = context.read<UserProvider>();
 | 
			
		||||
      await sn.client.put('/cgi/id/users/me/language', data: {
 | 
			
		||||
        'language': value.toString(),
 | 
			
		||||
      });
 | 
			
		||||
      if (!context.mounted) return;
 | 
			
		||||
      context.showSnackbar('accountSettingsApplied'.tr());
 | 
			
		||||
      await ua.refreshUser();
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!context.mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final ua = context.watch<UserProvider>();
 | 
			
		||||
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: PageBackButton(),
 | 
			
		||||
@@ -21,6 +45,42 @@ class AccountSettingsScreen extends StatelessWidget {
 | 
			
		||||
        child: Column(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
          children: [
 | 
			
		||||
            ListTile(
 | 
			
		||||
              title: Text('settingsAccountLanguage').tr(),
 | 
			
		||||
              subtitle: Text('settingsAccountLanguageDescription').tr(),
 | 
			
		||||
              contentPadding: const EdgeInsets.only(left: 24, right: 17),
 | 
			
		||||
              leading: const Icon(Symbols.translate),
 | 
			
		||||
              trailing: DropdownButtonHideUnderline(
 | 
			
		||||
                child: DropdownButton2<Locale?>(
 | 
			
		||||
                  isExpanded: true,
 | 
			
		||||
                  items: [
 | 
			
		||||
                    ...EasyLocalization.of(context)!.supportedLocales.mapIndexed((idx, ele) {
 | 
			
		||||
                      return DropdownMenuItem<Locale?>(
 | 
			
		||||
                        value: Locale.parse(ele.toString()),
 | 
			
		||||
                        child: Text('${ele.languageCode}-${ele.countryCode}').fontSize(14),
 | 
			
		||||
                      );
 | 
			
		||||
                    }),
 | 
			
		||||
                  ],
 | 
			
		||||
                  value: ua.user?.language != null ? Locale.parse(ua.user!.language) : Locale.parse('en-US'),
 | 
			
		||||
                  onChanged: (Locale? value) {
 | 
			
		||||
                    if (value == null) return;
 | 
			
		||||
                    _setAccountLanguage(context, value);
 | 
			
		||||
                    ua.setLanguage(value.toString());
 | 
			
		||||
                  },
 | 
			
		||||
                  buttonStyleData: const ButtonStyleData(
 | 
			
		||||
                    padding: EdgeInsets.symmetric(
 | 
			
		||||
                      horizontal: 16,
 | 
			
		||||
                      vertical: 5,
 | 
			
		||||
                    ),
 | 
			
		||||
                    height: 40,
 | 
			
		||||
                    width: 160,
 | 
			
		||||
                  ),
 | 
			
		||||
                  menuItemStyleData: const MenuItemStyleData(
 | 
			
		||||
                    height: 40,
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
            ListTile(
 | 
			
		||||
              title: Text('accountProfileEdit').tr(),
 | 
			
		||||
              subtitle: Text('accountProfileEditSubtitle').tr(),
 | 
			
		||||
 
 | 
			
		||||
@@ -44,6 +44,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
        'nick': nickname,
 | 
			
		||||
        'email': email,
 | 
			
		||||
        'password': password,
 | 
			
		||||
        'language': EasyLocalization.of(context)!.currentLocale.toString(),
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (!context.mounted) return;
 | 
			
		||||
 
 | 
			
		||||
@@ -155,12 +155,16 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
 | 
			
		||||
                text: TextSpan(children: [
 | 
			
		||||
                  TextSpan(
 | 
			
		||||
                    text: 'call'.tr(),
 | 
			
		||||
                    style: Theme.of(context).textTheme.titleLarge!.copyWith(color: Colors.white),
 | 
			
		||||
                    style: Theme.of(context).textTheme.titleLarge!.copyWith(
 | 
			
		||||
                          color: Theme.of(context).appBarTheme.foregroundColor,
 | 
			
		||||
                        ),
 | 
			
		||||
                  ),
 | 
			
		||||
                  const TextSpan(text: '\n'),
 | 
			
		||||
                  TextSpan(
 | 
			
		||||
                    text: call.lastDuration.toString(),
 | 
			
		||||
                    style: Theme.of(context).textTheme.bodySmall!.copyWith(color: Colors.white),
 | 
			
		||||
                    style: Theme.of(context).textTheme.bodySmall!.copyWith(
 | 
			
		||||
                          color: Theme.of(context).appBarTheme.foregroundColor,
 | 
			
		||||
                        ),
 | 
			
		||||
                  ),
 | 
			
		||||
                ]),
 | 
			
		||||
              ),
 | 
			
		||||
 
 | 
			
		||||
@@ -10,8 +10,10 @@ import 'package:surface/providers/channel.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/user_directory.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/types/account.dart';
 | 
			
		||||
import 'package:surface/types/chat.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_select.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
@@ -20,6 +22,7 @@ import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
			
		||||
class ChannelDetailScreen extends StatefulWidget {
 | 
			
		||||
  final String scope;
 | 
			
		||||
  final String alias;
 | 
			
		||||
 | 
			
		||||
  const ChannelDetailScreen({
 | 
			
		||||
    super.key,
 | 
			
		||||
    required this.scope,
 | 
			
		||||
@@ -55,8 +58,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client
 | 
			
		||||
          .get('/cgi/im/channels/${_channel!.keyPath}/members/me');
 | 
			
		||||
      final resp = await sn.client.get('/cgi/im/channels/${_channel!.keyPath}/members/me');
 | 
			
		||||
      _profile = SnChannelMember.fromJson(resp.data);
 | 
			
		||||
      _notifyLevel = _profile!.notify;
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
@@ -143,6 +145,25 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _addMember(SnAccount related) async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.post(
 | 
			
		||||
        '/cgi/im/channels/${_channel!.keyPath}/members',
 | 
			
		||||
        data: {'related': related.name},
 | 
			
		||||
      );
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar('channelMemberAdded'.tr());
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _showChannelProfileDetail() {
 | 
			
		||||
    showDialog(
 | 
			
		||||
      context: context,
 | 
			
		||||
@@ -166,13 +187,16 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _showMemberAdd() {
 | 
			
		||||
    showModalBottomSheet(
 | 
			
		||||
  void _showMemberAdd() async {
 | 
			
		||||
    final user = await showModalBottomSheet<SnAccount?>(
 | 
			
		||||
      context: context,
 | 
			
		||||
      builder: (context) => _NewChannelMemberWidget(
 | 
			
		||||
        channel: _channel!,
 | 
			
		||||
      builder: (context) => AccountSelect(
 | 
			
		||||
        title: 'channelMemberAdd'.tr(),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    if (!mounted) return;
 | 
			
		||||
    if (user == null) return;
 | 
			
		||||
    _addMember(user);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -221,11 +245,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
              Column(
 | 
			
		||||
                crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                children: [
 | 
			
		||||
                  Text('channelDetailPersonalRegion')
 | 
			
		||||
                      .bold()
 | 
			
		||||
                      .fontSize(17)
 | 
			
		||||
                      .tr()
 | 
			
		||||
                      .padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                  Text('channelDetailPersonalRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                  ListTile(
 | 
			
		||||
                    leading: const Icon(Symbols.notifications),
 | 
			
		||||
                    trailing: DropdownButtonHideUnderline(
 | 
			
		||||
@@ -264,8 +284,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
                  ),
 | 
			
		||||
                  ListTile(
 | 
			
		||||
                    leading: AccountImage(
 | 
			
		||||
                      content:
 | 
			
		||||
                          ud.getAccountFromCache(_profile!.accountId)?.avatar,
 | 
			
		||||
                      content: ud.getAccountFromCache(_profile!.accountId)?.avatar,
 | 
			
		||||
                      radius: 18,
 | 
			
		||||
                    ),
 | 
			
		||||
                    trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
@@ -284,8 +303,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
                      trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
                      title: Text('channelActionLeave').tr(),
 | 
			
		||||
                      subtitle: Text('channelActionLeaveDescription').tr(),
 | 
			
		||||
                      contentPadding:
 | 
			
		||||
                          const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
                      contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
                      onTap: _leaveChannel,
 | 
			
		||||
                    ),
 | 
			
		||||
                ],
 | 
			
		||||
@@ -293,11 +311,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
            Column(
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
              children: [
 | 
			
		||||
                Text('channelDetailMemberRegion')
 | 
			
		||||
                    .bold()
 | 
			
		||||
                    .fontSize(17)
 | 
			
		||||
                    .tr()
 | 
			
		||||
                    .padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                Text('channelDetailMemberRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                ListTile(
 | 
			
		||||
                  leading: const Icon(Symbols.group),
 | 
			
		||||
                  trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
@@ -319,11 +333,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
            Column(
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
              children: [
 | 
			
		||||
                Text('channelDetailAdminRegion')
 | 
			
		||||
                    .bold()
 | 
			
		||||
                    .fontSize(17)
 | 
			
		||||
                    .tr()
 | 
			
		||||
                    .padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                Text('channelDetailAdminRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                ListTile(
 | 
			
		||||
                  leading: const Icon(Symbols.edit),
 | 
			
		||||
                  trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
@@ -362,18 +372,17 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
class _ChannelProfileDetailDialog extends StatefulWidget {
 | 
			
		||||
  final SnChannel channel;
 | 
			
		||||
  final SnChannelMember current;
 | 
			
		||||
 | 
			
		||||
  const _ChannelProfileDetailDialog({
 | 
			
		||||
    required this.channel,
 | 
			
		||||
    required this.current,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_ChannelProfileDetailDialog> createState() =>
 | 
			
		||||
      _ChannelProfileDetailDialogState();
 | 
			
		||||
  State<_ChannelProfileDetailDialog> createState() => _ChannelProfileDetailDialogState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ChannelProfileDetailDialogState
 | 
			
		||||
    extends State<_ChannelProfileDetailDialog> {
 | 
			
		||||
class _ChannelProfileDetailDialogState extends State<_ChannelProfileDetailDialog> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
 | 
			
		||||
  final TextEditingController _nickController = TextEditingController();
 | 
			
		||||
@@ -444,11 +453,11 @@ class _ChannelProfileDetailDialogState
 | 
			
		||||
 | 
			
		||||
class _ChannelMemberListWidget extends StatefulWidget {
 | 
			
		||||
  final SnChannel channel;
 | 
			
		||||
 | 
			
		||||
  const _ChannelMemberListWidget({required this.channel});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_ChannelMemberListWidget> createState() =>
 | 
			
		||||
      _ChannelMemberListWidgetState();
 | 
			
		||||
  State<_ChannelMemberListWidget> createState() => _ChannelMemberListWidgetState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
			
		||||
@@ -463,9 +472,7 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
			
		||||
    try {
 | 
			
		||||
      final ud = context.read<UserDirectoryProvider>();
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get(
 | 
			
		||||
          '/cgi/im/channels/${widget.channel.keyPath}/members',
 | 
			
		||||
          queryParameters: {
 | 
			
		||||
      final resp = await sn.client.get('/cgi/im/channels/${widget.channel.keyPath}/members', queryParameters: {
 | 
			
		||||
        'take': 10,
 | 
			
		||||
        'offset': 0,
 | 
			
		||||
      });
 | 
			
		||||
@@ -526,9 +533,7 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
			
		||||
          children: [
 | 
			
		||||
            const Icon(Symbols.group, size: 24),
 | 
			
		||||
            const Gap(16),
 | 
			
		||||
            Text('channelMemberManage')
 | 
			
		||||
                .tr()
 | 
			
		||||
                .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
			
		||||
            Text('channelMemberManage').tr().textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
			
		||||
          ],
 | 
			
		||||
        ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
			
		||||
        Expanded(
 | 
			
		||||
@@ -539,8 +544,7 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
			
		||||
            },
 | 
			
		||||
            child: InfiniteList(
 | 
			
		||||
              itemCount: _members.length,
 | 
			
		||||
              hasReachedMax:
 | 
			
		||||
                  _totalCount != null && _members.length >= _totalCount!,
 | 
			
		||||
              hasReachedMax: _totalCount != null && _members.length >= _totalCount!,
 | 
			
		||||
              isLoading: _isBusy,
 | 
			
		||||
              onFetchData: _fetchMembers,
 | 
			
		||||
              itemBuilder: (context, index) {
 | 
			
		||||
@@ -551,8 +555,7 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
			
		||||
                    content: ud.getAccountFromCache(member.accountId)?.avatar,
 | 
			
		||||
                  ),
 | 
			
		||||
                  title: Text(
 | 
			
		||||
                    ud.getAccountFromCache(member.accountId)?.name ??
 | 
			
		||||
                        'unknown'.tr(),
 | 
			
		||||
                    ud.getAccountFromCache(member.accountId)?.name ?? 'unknown'.tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                  subtitle: Text(member.nick ?? 'unknown'.tr()),
 | 
			
		||||
                  trailing: SizedBox(
 | 
			
		||||
@@ -562,8 +565,7 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
			
		||||
                      mainAxisAlignment: MainAxisAlignment.end,
 | 
			
		||||
                      children: [
 | 
			
		||||
                        IconButton(
 | 
			
		||||
                          onPressed:
 | 
			
		||||
                              _isUpdating ? null : () => _deleteMember(member),
 | 
			
		||||
                          onPressed: _isUpdating ? null : () => _deleteMember(member),
 | 
			
		||||
                          icon: const Icon(Symbols.person_remove),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ],
 | 
			
		||||
@@ -578,83 +580,3 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _NewChannelMemberWidget extends StatefulWidget {
 | 
			
		||||
  final SnChannel channel;
 | 
			
		||||
  const _NewChannelMemberWidget({required this.channel});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_NewChannelMemberWidget> createState() =>
 | 
			
		||||
      _NewChannelMemberWidgetState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _NewChannelMemberWidgetState extends State<_NewChannelMemberWidget> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
 | 
			
		||||
  final TextEditingController _relatedController = TextEditingController();
 | 
			
		||||
 | 
			
		||||
  Future<void> _performAction() async {
 | 
			
		||||
    if (_relatedController.text.isEmpty) return;
 | 
			
		||||
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.post(
 | 
			
		||||
        '/cgi/im/channels/${widget.channel.keyPath}/members',
 | 
			
		||||
        data: {
 | 
			
		||||
          'related': _relatedController.text,
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      Navigator.pop(context, true);
 | 
			
		||||
      context.showSnackbar('channelMemberAdded'.tr());
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    super.dispose();
 | 
			
		||||
    _relatedController.dispose();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return StyledWidget(Column(
 | 
			
		||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
      children: [
 | 
			
		||||
        Text(
 | 
			
		||||
          'channelMemberAdd',
 | 
			
		||||
          style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
        ).tr(),
 | 
			
		||||
        const Gap(12),
 | 
			
		||||
        TextField(
 | 
			
		||||
          controller: _relatedController,
 | 
			
		||||
          readOnly: _isBusy,
 | 
			
		||||
          autocorrect: false,
 | 
			
		||||
          autofocus: true,
 | 
			
		||||
          textCapitalization: TextCapitalization.none,
 | 
			
		||||
          decoration: InputDecoration(
 | 
			
		||||
            labelText: 'fieldMemberRelatedName'.tr(),
 | 
			
		||||
            suffix: SizedBox(
 | 
			
		||||
              height: 24,
 | 
			
		||||
              child: IconButton(
 | 
			
		||||
                onPressed: _isBusy ? null : () => _performAction(),
 | 
			
		||||
                icon: Icon(Symbols.send),
 | 
			
		||||
                visualDensity:
 | 
			
		||||
                    const VisualDensity(horizontal: -4, vertical: -4),
 | 
			
		||||
                padding: EdgeInsets.zero,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
        )
 | 
			
		||||
      ],
 | 
			
		||||
    )).padding(all: 24);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:dio/dio.dart';
 | 
			
		||||
import 'package:dropdown_button2/dropdown_button2.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
@@ -17,6 +18,7 @@ import 'package:uuid/uuid.dart';
 | 
			
		||||
 | 
			
		||||
class ChatManageScreen extends StatefulWidget {
 | 
			
		||||
  final String? editingChannelAlias;
 | 
			
		||||
 | 
			
		||||
  const ChatManageScreen({super.key, this.editingChannelAlias});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -33,6 +35,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
  List<SnRealm>? _realms;
 | 
			
		||||
  SnRealm? _belongToRealm;
 | 
			
		||||
 | 
			
		||||
  SnChannel? _editingChannel;
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchRealms() async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
    try {
 | 
			
		||||
@@ -41,6 +45,9 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
      _realms = List<SnRealm>.from(
 | 
			
		||||
        resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
 | 
			
		||||
      );
 | 
			
		||||
      if (_editingChannel != null) {
 | 
			
		||||
        _belongToRealm = _realms?.firstWhereOrNull((e) => e.id == _editingChannel!.realmId);
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (mounted) context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
@@ -48,8 +55,6 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  SnChannel? _editingChannel;
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchChannel() async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
@@ -124,9 +129,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        title: widget.editingChannelAlias != null
 | 
			
		||||
            ? Text('screenChatManage').tr()
 | 
			
		||||
            : Text('screenChatNew').tr(),
 | 
			
		||||
        title: widget.editingChannelAlias != null ? Text('screenChatManage').tr() : Text('screenChatNew').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: SingleChildScrollView(
 | 
			
		||||
        child: Column(
 | 
			
		||||
@@ -138,8 +141,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                leadingPadding: const EdgeInsets.only(left: 10, right: 20),
 | 
			
		||||
                dividerColor: Colors.transparent,
 | 
			
		||||
                content: Text(
 | 
			
		||||
                  'channelEditingNotice'
 | 
			
		||||
                      .tr(args: ['#${_editingChannel!.alias}']),
 | 
			
		||||
                  'channelEditingNotice'.tr(args: ['#${_editingChannel!.alias}']),
 | 
			
		||||
                ),
 | 
			
		||||
                actions: [
 | 
			
		||||
                  TextButton(
 | 
			
		||||
@@ -162,6 +164,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                items: [
 | 
			
		||||
                  ...(_realms?.map(
 | 
			
		||||
                        (SnRealm item) => DropdownMenuItem<SnRealm>(
 | 
			
		||||
                          enabled: _editingChannel == null || _editingChannel?.realmId == item.id,
 | 
			
		||||
                          value: item,
 | 
			
		||||
                          child: Row(
 | 
			
		||||
                            children: [
 | 
			
		||||
@@ -179,15 +182,12 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                                  mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                                  children: [
 | 
			
		||||
                                    Text(item.name).textStyle(Theme.of(context)
 | 
			
		||||
                                        .textTheme
 | 
			
		||||
                                        .bodyMedium!),
 | 
			
		||||
                                    Text(item.name).textStyle(Theme.of(context).textTheme.bodyMedium!),
 | 
			
		||||
                                    Text(
 | 
			
		||||
                                      item.description,
 | 
			
		||||
                                      maxLines: 1,
 | 
			
		||||
                                      overflow: TextOverflow.ellipsis,
 | 
			
		||||
                                    ).textStyle(
 | 
			
		||||
                                        Theme.of(context).textTheme.bodySmall!),
 | 
			
		||||
                                    ).textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
			
		||||
                                  ],
 | 
			
		||||
                                ),
 | 
			
		||||
                              ),
 | 
			
		||||
@@ -197,14 +197,14 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                      ) ??
 | 
			
		||||
                      []),
 | 
			
		||||
                  DropdownMenuItem<SnRealm>(
 | 
			
		||||
                    enabled: _editingChannel == null,
 | 
			
		||||
                    value: null,
 | 
			
		||||
                    child: Row(
 | 
			
		||||
                      children: [
 | 
			
		||||
                        CircleAvatar(
 | 
			
		||||
                          radius: 16,
 | 
			
		||||
                          backgroundColor: Colors.transparent,
 | 
			
		||||
                          foregroundColor:
 | 
			
		||||
                              Theme.of(context).colorScheme.onSurface,
 | 
			
		||||
                          foregroundColor: Theme.of(context).colorScheme.onSurface,
 | 
			
		||||
                          child: const Icon(Symbols.clear),
 | 
			
		||||
                        ),
 | 
			
		||||
                        const Gap(12),
 | 
			
		||||
@@ -213,9 +213,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                            mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                            crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                            children: [
 | 
			
		||||
                              Text('fieldChatBelongToRealmUnset')
 | 
			
		||||
                                  .tr()
 | 
			
		||||
                                  .textStyle(
 | 
			
		||||
                              Text('fieldChatBelongToRealmUnset').tr().textStyle(
 | 
			
		||||
                                    Theme.of(context).textTheme.bodyMedium!,
 | 
			
		||||
                                  ),
 | 
			
		||||
                            ],
 | 
			
		||||
@@ -231,10 +229,10 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                },
 | 
			
		||||
                buttonStyleData: const ButtonStyleData(
 | 
			
		||||
                  padding: EdgeInsets.only(right: 16),
 | 
			
		||||
                  height: 60,
 | 
			
		||||
                  height: 48,
 | 
			
		||||
                ),
 | 
			
		||||
                menuItemStyleData: const MenuItemStyleData(
 | 
			
		||||
                  height: 60,
 | 
			
		||||
                  height: 48,
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
@@ -250,8 +248,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                    helperText: 'fieldChatAliasHint'.tr(),
 | 
			
		||||
                    helperMaxLines: 2,
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) =>
 | 
			
		||||
                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ),
 | 
			
		||||
                const Gap(4),
 | 
			
		||||
                TextField(
 | 
			
		||||
@@ -260,8 +257,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                    border: const UnderlineInputBorder(),
 | 
			
		||||
                    labelText: 'fieldChatName'.tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) =>
 | 
			
		||||
                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ),
 | 
			
		||||
                const Gap(4),
 | 
			
		||||
                TextField(
 | 
			
		||||
@@ -272,8 +268,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                    border: const UnderlineInputBorder(),
 | 
			
		||||
                    labelText: 'fieldChatDescription'.tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) =>
 | 
			
		||||
                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ),
 | 
			
		||||
                const Gap(12),
 | 
			
		||||
                Row(
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
import 'dart:async';
 | 
			
		||||
import 'dart:developer';
 | 
			
		||||
 | 
			
		||||
import 'package:dio/dio.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
@@ -9,9 +10,12 @@ import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/controllers/chat_message_controller.dart';
 | 
			
		||||
import 'package:surface/controllers/post_write_controller.dart';
 | 
			
		||||
import 'package:surface/providers/channel.dart';
 | 
			
		||||
import 'package:surface/providers/chat_call.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/user_directory.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/providers/websocket.dart';
 | 
			
		||||
import 'package:surface/types/chat.dart';
 | 
			
		||||
import 'package:surface/widgets/chat/call/call_prejoin.dart';
 | 
			
		||||
@@ -23,14 +27,19 @@ 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';
 | 
			
		||||
 | 
			
		||||
import '../../providers/user_directory.dart';
 | 
			
		||||
import '../../providers/userinfo.dart';
 | 
			
		||||
class ChatRoomScreenExtra {
 | 
			
		||||
  final String? initialText;
 | 
			
		||||
  final List<PostWriteMedia>? initialAttachments;
 | 
			
		||||
 | 
			
		||||
  ChatRoomScreenExtra({this.initialText, this.initialAttachments});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class ChatRoomScreen extends StatefulWidget {
 | 
			
		||||
  final String scope;
 | 
			
		||||
  final String alias;
 | 
			
		||||
  final ChatRoomScreenExtra? extra;
 | 
			
		||||
 | 
			
		||||
  const ChatRoomScreen({super.key, required this.scope, required this.alias});
 | 
			
		||||
  const ChatRoomScreen({super.key, required this.scope, required this.alias, this.extra});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<ChatRoomScreen> createState() => _ChatRoomScreenState();
 | 
			
		||||
@@ -177,12 +186,27 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
    _messageController = ChatMessageController(context);
 | 
			
		||||
    _fetchChannel().then((_) async {
 | 
			
		||||
      await _messageController.initialize(_channel!);
 | 
			
		||||
      await _messageController.checkUpdate();
 | 
			
		||||
      await _fetchOngoingCall();
 | 
			
		||||
 | 
			
		||||
      if (widget.extra != null) {
 | 
			
		||||
        WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
			
		||||
          log('[ChatInput] Setting initial text and attachments...');
 | 
			
		||||
          if (widget.extra!.initialText != null) {
 | 
			
		||||
            _inputGlobalKey.currentState?.setInitialText(widget.extra!.initialText!);
 | 
			
		||||
          }
 | 
			
		||||
          if (widget.extra!.initialAttachments != null) {
 | 
			
		||||
            _inputGlobalKey.currentState?.setInitialAttachments(widget.extra!.initialAttachments!);
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      await Future.wait([
 | 
			
		||||
        _messageController.checkUpdate(),
 | 
			
		||||
        _fetchOngoingCall(),
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    final ws = context.read<WebSocketProvider>();
 | 
			
		||||
    _wsSubscription = ws.stream.stream.listen((event) {
 | 
			
		||||
    _wsSubscription = ws.pk.stream.listen((event) {
 | 
			
		||||
      switch (event.method) {
 | 
			
		||||
        case 'calls.new':
 | 
			
		||||
          final payload = SnChatCall.fromJson(event.payload!);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
import 'package:animations/animations.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
 | 
			
		||||
@@ -7,10 +6,8 @@ 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/config.dart';
 | 
			
		||||
import 'package:surface/providers/post.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/screens/post/post_detail.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/widgets/app_bar_leading.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
@@ -97,8 +94,6 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final cfg = context.read<ConfigProvider>();
 | 
			
		||||
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      floatingActionButtonLocation: ExpandableFab.location,
 | 
			
		||||
      floatingActionButton: ExpandableFab(
 | 
			
		||||
@@ -166,6 +161,48 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
          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),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
      body: RefreshIndicator(
 | 
			
		||||
@@ -225,10 +262,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
			
		||||
              onFetchData: _fetchPosts,
 | 
			
		||||
              itemBuilder: (context, idx) {
 | 
			
		||||
                return Center(
 | 
			
		||||
                  child: OpenContainer(
 | 
			
		||||
                    closedBuilder: (_, __) => Container(
 | 
			
		||||
                      constraints: const BoxConstraints(maxWidth: 640),
 | 
			
		||||
                      child: PostItem(
 | 
			
		||||
                  child: OpenablePostItem(
 | 
			
		||||
                    data: _posts[idx],
 | 
			
		||||
                    maxWidth: 640,
 | 
			
		||||
                    onChanged: (data) {
 | 
			
		||||
@@ -238,22 +272,6 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
			
		||||
                      _refreshPosts();
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
                    ),
 | 
			
		||||
                    openBuilder: (_, close) => PostDetailScreen(
 | 
			
		||||
                      slug: _posts[idx].id.toString(),
 | 
			
		||||
                      preload: _posts[idx],
 | 
			
		||||
                      onBack: close,
 | 
			
		||||
                    ),
 | 
			
		||||
                    openColor: Colors.transparent,
 | 
			
		||||
                    openElevation: 0,
 | 
			
		||||
                    transitionType: ContainerTransitionType.fade,
 | 
			
		||||
                    closedColor: Theme.of(context).colorScheme.surfaceContainerLow.withOpacity(
 | 
			
		||||
                          cfg.prefs.getBool(kAppBackgroundStoreKey) == true ? 0.75 : 1,
 | 
			
		||||
                        ),
 | 
			
		||||
                    closedShape: const RoundedRectangleBorder(
 | 
			
		||||
                      borderRadius: BorderRadius.all(Radius.circular(16)),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                );
 | 
			
		||||
              },
 | 
			
		||||
              separatorBuilder: (_, __) => const Gap(8),
 | 
			
		||||
 
 | 
			
		||||
@@ -6,15 +6,15 @@ import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/relationship.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/types/account.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_select.dart';
 | 
			
		||||
import 'package:surface/widgets/app_bar_leading.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
 | 
			
		||||
import '../providers/userinfo.dart';
 | 
			
		||||
import '../widgets/unauthorized_hint.dart';
 | 
			
		||||
import 'package:surface/widgets/unauthorized_hint.dart';
 | 
			
		||||
 | 
			
		||||
const kFriendStatus = {
 | 
			
		||||
  0: 'friendStatusPending',
 | 
			
		||||
@@ -168,6 +168,24 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _sendRequest(SnAccount user) async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.post('/cgi/id/users/me/relations', data: {
 | 
			
		||||
        'related': user.name,
 | 
			
		||||
      });
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar('friendRequestSent'.tr());
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
@@ -199,11 +217,16 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
      ),
 | 
			
		||||
      floatingActionButton: FloatingActionButton(
 | 
			
		||||
        child: const Icon(Symbols.add),
 | 
			
		||||
        onPressed: () {
 | 
			
		||||
          showModalBottomSheet(
 | 
			
		||||
        onPressed: () async {
 | 
			
		||||
          final user = await showModalBottomSheet<SnAccount?>(
 | 
			
		||||
            context: context,
 | 
			
		||||
            builder: (context) => _NewFriendWidget(),
 | 
			
		||||
            builder: (context) => AccountSelect(
 | 
			
		||||
              title: 'friendNew'.tr(),
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
          if (!mounted) return;
 | 
			
		||||
          if (user == null) return;
 | 
			
		||||
          _sendRequest(user);
 | 
			
		||||
        },
 | 
			
		||||
      ),
 | 
			
		||||
      body: Column(
 | 
			
		||||
@@ -231,8 +254,7 @@ 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,
 | 
			
		||||
@@ -264,16 +286,12 @@ 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(),
 | 
			
		||||
                                ),
 | 
			
		||||
                              ],
 | 
			
		||||
@@ -293,83 +311,9 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _NewFriendWidget extends StatefulWidget {
 | 
			
		||||
  const _NewFriendWidget();
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_NewFriendWidget> createState() => _NewFriendWidgetState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _NewFriendWidgetState extends State<_NewFriendWidget> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
 | 
			
		||||
  final TextEditingController _relatedController = TextEditingController();
 | 
			
		||||
 | 
			
		||||
  Future<void> _sendRequest() async {
 | 
			
		||||
    if (_relatedController.text.isEmpty) return;
 | 
			
		||||
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.post('/cgi/id/users/me/relations', data: {
 | 
			
		||||
        'related': _relatedController.text,
 | 
			
		||||
      });
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      Navigator.pop(context, true);
 | 
			
		||||
      context.showSnackbar('friendRequestSent'.tr());
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    super.dispose();
 | 
			
		||||
    _relatedController.dispose();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return StyledWidget(Column(
 | 
			
		||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
      children: [
 | 
			
		||||
        Text(
 | 
			
		||||
          'friendNew',
 | 
			
		||||
          style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
        ).tr(),
 | 
			
		||||
        const Gap(12),
 | 
			
		||||
        TextField(
 | 
			
		||||
          controller: _relatedController,
 | 
			
		||||
          readOnly: _isBusy,
 | 
			
		||||
          autocorrect: false,
 | 
			
		||||
          autofocus: true,
 | 
			
		||||
          textCapitalization: TextCapitalization.none,
 | 
			
		||||
          decoration: InputDecoration(
 | 
			
		||||
            labelText: 'fieldFriendRelatedName'.tr(),
 | 
			
		||||
            suffix: SizedBox(
 | 
			
		||||
              height: 24,
 | 
			
		||||
              child: IconButton(
 | 
			
		||||
                onPressed: _isBusy ? null : () => _sendRequest(),
 | 
			
		||||
                icon: Icon(Symbols.send),
 | 
			
		||||
                visualDensity:
 | 
			
		||||
                    const VisualDensity(horizontal: -4, vertical: -4),
 | 
			
		||||
                padding: EdgeInsets.zero,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
        )
 | 
			
		||||
      ],
 | 
			
		||||
    )).padding(all: 24);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _FriendshipListWidget extends StatefulWidget {
 | 
			
		||||
  final List<SnRelationship> relations;
 | 
			
		||||
 | 
			
		||||
  const _FriendshipListWidget({required this.relations});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -476,9 +420,7 @@ 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,
 | 
			
		||||
@@ -499,8 +441,7 @@ 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),
 | 
			
		||||
 
 | 
			
		||||
@@ -288,6 +288,7 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
 | 
			
		||||
              child: InkWell(
 | 
			
		||||
                borderRadius: BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                child: Column(
 | 
			
		||||
                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                  spacing: 4,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Text(
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:relative_time/relative_time.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/notification.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/types/notification.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
@@ -21,6 +22,16 @@ import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
			
		||||
import '../providers/userinfo.dart';
 | 
			
		||||
import '../widgets/unauthorized_hint.dart';
 | 
			
		||||
 | 
			
		||||
const Map<String, IconData> kNotificationTopicIcons = {
 | 
			
		||||
  'general': Symbols.notifications,
 | 
			
		||||
  'passport.security.alert': Symbols.gpp_maybe,
 | 
			
		||||
  'passport.security.otp': Symbols.password,
 | 
			
		||||
  'interactive.subscription': Symbols.subscriptions,
 | 
			
		||||
  'interactive.feedback': Symbols.add_reaction,
 | 
			
		||||
  'messaging.callStart': Symbols.call_received,
 | 
			
		||||
  'wallet.transaction.new': Symbols.receipt,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
class NotificationScreen extends StatefulWidget {
 | 
			
		||||
  const NotificationScreen({super.key});
 | 
			
		||||
 | 
			
		||||
@@ -36,15 +47,6 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
  final List<SnNotification> _notifications = List.empty(growable: true);
 | 
			
		||||
  int? _totalCount;
 | 
			
		||||
 | 
			
		||||
  static const Map<String, IconData> kNotificationTopicIcons = {
 | 
			
		||||
    'passport.security.alert': Symbols.gpp_maybe,
 | 
			
		||||
    'passport.security.otp': Symbols.password,
 | 
			
		||||
    'interactive.subscription': Symbols.subscriptions,
 | 
			
		||||
    'interactive.feedback': Symbols.add_reaction,
 | 
			
		||||
    'messaging.callStart': Symbols.call_received,
 | 
			
		||||
    'wallet.transaction.new': Symbols.receipt,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchNotifications() async {
 | 
			
		||||
    final ua = context.read<UserProvider>();
 | 
			
		||||
    if (!ua.isAuthorized) return;
 | 
			
		||||
@@ -53,6 +55,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final nty = context.read<NotificationProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/id/notifications?take=10');
 | 
			
		||||
      _totalCount = resp.data['count'];
 | 
			
		||||
      _notifications.addAll(
 | 
			
		||||
@@ -61,6 +64,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
                .cast<SnNotification>() ??
 | 
			
		||||
            [],
 | 
			
		||||
      );
 | 
			
		||||
      nty.updateTray();
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
@@ -87,9 +91,11 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final nty = context.read<NotificationProvider>();
 | 
			
		||||
      final resp = await sn.client.put('/cgi/id/notifications/read/all');
 | 
			
		||||
      _notifications.clear();
 | 
			
		||||
      _fetchNotifications();
 | 
			
		||||
      nty.clear();
 | 
			
		||||
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar(
 | 
			
		||||
 
 | 
			
		||||
@@ -183,7 +183,7 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
 | 
			
		||||
            if (_data != null)
 | 
			
		||||
              PostCommentSliverList(
 | 
			
		||||
                key: _childListKey,
 | 
			
		||||
                parentPostId: _data!.id,
 | 
			
		||||
                parentPost: _data!,
 | 
			
		||||
                maxWidth: 640,
 | 
			
		||||
              ),
 | 
			
		||||
            SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)),
 | 
			
		||||
 
 | 
			
		||||
@@ -1,32 +1,46 @@
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:dropdown_button2/dropdown_button2.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter/gestures.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:flutter_context_menu/flutter_context_menu.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:hotkey_manager/hotkey_manager.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:pasteboard/pasteboard.dart';
 | 
			
		||||
import 'package:responsive_framework/responsive_framework.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
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/types/attachment.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/attachment/attachment_item.dart';
 | 
			
		||||
import 'package:surface/widgets/attachment/pending_attachment_alt.dart';
 | 
			
		||||
import 'package:surface/widgets/attachment/pending_attachment_boost.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/markdown_content.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_item.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_media_pending_list.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_meta_editor.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:uuid/uuid.dart';
 | 
			
		||||
 | 
			
		||||
class PostEditorExtraProps {
 | 
			
		||||
import '../../widgets/attachment/attachment_input.dart';
 | 
			
		||||
 | 
			
		||||
class PostEditorExtra {
 | 
			
		||||
  final String? text;
 | 
			
		||||
  final String? title;
 | 
			
		||||
  final String? description;
 | 
			
		||||
  final List<PostWriteMedia>? attachments;
 | 
			
		||||
 | 
			
		||||
  const PostEditorExtraProps({
 | 
			
		||||
  const PostEditorExtra({
 | 
			
		||||
    this.text,
 | 
			
		||||
    this.title,
 | 
			
		||||
    this.description,
 | 
			
		||||
@@ -39,7 +53,7 @@ class PostEditorScreen extends StatefulWidget {
 | 
			
		||||
  final int? postEditId;
 | 
			
		||||
  final int? postReplyId;
 | 
			
		||||
  final int? postRepostId;
 | 
			
		||||
  final PostEditorExtraProps? extraProps;
 | 
			
		||||
  final PostEditorExtra? extraProps;
 | 
			
		||||
 | 
			
		||||
  const PostEditorScreen({
 | 
			
		||||
    super.key,
 | 
			
		||||
@@ -94,15 +108,49 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  final HotKey _pasteHotKey = HotKey(
 | 
			
		||||
    key: PhysicalKeyboardKey.keyV,
 | 
			
		||||
    modifiers: [Platform.isMacOS ? HotKeyModifier.meta : HotKeyModifier.control],
 | 
			
		||||
    scope: HotKeyScope.inapp,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  void _registerHotKey() {
 | 
			
		||||
    if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
 | 
			
		||||
    hotKeyManager.register(_pasteHotKey, keyDownHandler: (_) async {
 | 
			
		||||
      final imageBytes = await Pasteboard.image;
 | 
			
		||||
      if (imageBytes == null) return;
 | 
			
		||||
      _writeController.addAttachments([
 | 
			
		||||
        PostWriteMedia.fromBytes(
 | 
			
		||||
          imageBytes,
 | 
			
		||||
          'attachmentPastedImage'.tr(),
 | 
			
		||||
          SnMediaType.image,
 | 
			
		||||
        ),
 | 
			
		||||
      ]);
 | 
			
		||||
      setState(() {});
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _showPublisherPopup() {
 | 
			
		||||
    showModalBottomSheet(
 | 
			
		||||
      context: context,
 | 
			
		||||
      builder: (context) => _PostPublisherPopup(
 | 
			
		||||
        controller: _writeController,
 | 
			
		||||
        publishers: _publishers,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    _writeController.dispose();
 | 
			
		||||
    if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) hotKeyManager.unregister(_pasteHotKey);
 | 
			
		||||
    super.dispose();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _registerHotKey();
 | 
			
		||||
    if (!PostWriteController.kTitleMap.keys.contains(widget.mode)) {
 | 
			
		||||
      context.showErrorDialog('Unknown post type');
 | 
			
		||||
      Navigator.pop(context);
 | 
			
		||||
@@ -153,6 +201,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                      ),
 | 
			
		||||
                ),
 | 
			
		||||
              ]),
 | 
			
		||||
              maxLines: 2,
 | 
			
		||||
            ),
 | 
			
		||||
            actions: [
 | 
			
		||||
              IconButton(
 | 
			
		||||
@@ -164,176 +213,57 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
          ),
 | 
			
		||||
          body: Column(
 | 
			
		||||
            children: [
 | 
			
		||||
              DropdownButtonHideUnderline(
 | 
			
		||||
                child: DropdownButton2<SnPublisher>(
 | 
			
		||||
                  isExpanded: true,
 | 
			
		||||
                  hint: Text(
 | 
			
		||||
                    'fieldPostPublisher',
 | 
			
		||||
                    style: TextStyle(
 | 
			
		||||
                      fontSize: 14,
 | 
			
		||||
                      color: Theme.of(context).hintColor,
 | 
			
		||||
                    ),
 | 
			
		||||
                  ).tr(),
 | 
			
		||||
                  items: <DropdownMenuItem<SnPublisher>>[
 | 
			
		||||
                    ...(_publishers?.map(
 | 
			
		||||
                          (item) => DropdownMenuItem<SnPublisher>(
 | 
			
		||||
                            enabled: _writeController.editingPost == null,
 | 
			
		||||
                            value: item,
 | 
			
		||||
                            child: Row(
 | 
			
		||||
                              children: [
 | 
			
		||||
                                AccountImage(content: item.avatar, radius: 16),
 | 
			
		||||
                                const Gap(8),
 | 
			
		||||
                                Expanded(
 | 
			
		||||
                                  child: Column(
 | 
			
		||||
                                    mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                                    crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                                    children: [
 | 
			
		||||
                                      Text(item.nick).textStyle(Theme.of(context).textTheme.bodyMedium!),
 | 
			
		||||
                                      Text('@${item.name}')
 | 
			
		||||
                                          .textStyle(Theme.of(context).textTheme.bodySmall!)
 | 
			
		||||
                                          .fontSize(12),
 | 
			
		||||
                                    ],
 | 
			
		||||
                                  ),
 | 
			
		||||
                                ),
 | 
			
		||||
                              ],
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ) ??
 | 
			
		||||
                        []),
 | 
			
		||||
                    DropdownMenuItem<SnPublisher>(
 | 
			
		||||
                      value: null,
 | 
			
		||||
                      child: Row(
 | 
			
		||||
                        children: [
 | 
			
		||||
                          CircleAvatar(
 | 
			
		||||
                            radius: 16,
 | 
			
		||||
                            backgroundColor: Colors.transparent,
 | 
			
		||||
                            foregroundColor: Theme.of(context).colorScheme.onSurface,
 | 
			
		||||
                            child: const Icon(Symbols.add),
 | 
			
		||||
                          ),
 | 
			
		||||
                          const Gap(8),
 | 
			
		||||
                          Expanded(
 | 
			
		||||
                            child: Column(
 | 
			
		||||
                              mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                              children: [
 | 
			
		||||
                                Text('publishersNew').tr().textStyle(Theme.of(context).textTheme.bodyMedium!),
 | 
			
		||||
                              ],
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ],
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ],
 | 
			
		||||
                  value: _writeController.publisher,
 | 
			
		||||
                  onChanged: (SnPublisher? value) {
 | 
			
		||||
                    if (value == null) {
 | 
			
		||||
                      GoRouter.of(context).pushNamed('accountPublisherNew').then((value) {
 | 
			
		||||
                        if (value == true) {
 | 
			
		||||
                          _publishers = null;
 | 
			
		||||
                          _fetchPublishers();
 | 
			
		||||
                        }
 | 
			
		||||
                      });
 | 
			
		||||
                    } else {
 | 
			
		||||
                      _writeController.setPublisher(value);
 | 
			
		||||
                      final config = context.read<ConfigProvider>();
 | 
			
		||||
                      config.prefs.setInt('int_last_publisher_id', value.id);
 | 
			
		||||
                    }
 | 
			
		||||
                  },
 | 
			
		||||
                  buttonStyleData: const ButtonStyleData(
 | 
			
		||||
                    padding: EdgeInsets.only(right: 16),
 | 
			
		||||
                    height: 48,
 | 
			
		||||
                  ),
 | 
			
		||||
                  menuItemStyleData: const MenuItemStyleData(
 | 
			
		||||
                    height: 48,
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              const Divider(height: 1),
 | 
			
		||||
              Expanded(
 | 
			
		||||
                child: SingleChildScrollView(
 | 
			
		||||
                  padding: EdgeInsets.only(bottom: 8),
 | 
			
		||||
                  child: Column(
 | 
			
		||||
                    children: [
 | 
			
		||||
                      // Replying Notice
 | 
			
		||||
                      if (_writeController.replyingPost != null)
 | 
			
		||||
                        Column(
 | 
			
		||||
                          children: [
 | 
			
		||||
                            ExpansionTile(
 | 
			
		||||
                              minTileHeight: 48,
 | 
			
		||||
                              leading: const Icon(Symbols.reply).padding(left: 4),
 | 
			
		||||
                              title: Text('postReplyingNotice')
 | 
			
		||||
                                  .fontSize(15)
 | 
			
		||||
                                  .tr(args: ['@${_writeController.replyingPost!.publisher.name}']),
 | 
			
		||||
                              children: <Widget>[PostItem(data: _writeController.replyingPost!)],
 | 
			
		||||
                            ),
 | 
			
		||||
                            const Divider(height: 1),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                      // Reposting Notice
 | 
			
		||||
                      if (_writeController.repostingPost != null)
 | 
			
		||||
                        Column(
 | 
			
		||||
                          children: [
 | 
			
		||||
                            ExpansionTile(
 | 
			
		||||
                              minTileHeight: 48,
 | 
			
		||||
                              leading: const Icon(Symbols.forward).padding(left: 4),
 | 
			
		||||
                              title: Text('postRepostingNotice')
 | 
			
		||||
                                  .fontSize(15)
 | 
			
		||||
                                  .tr(args: ['@${_writeController.repostingPost!.publisher.name}']),
 | 
			
		||||
                              children: <Widget>[
 | 
			
		||||
                                PostItem(
 | 
			
		||||
                                  data: _writeController.repostingPost!,
 | 
			
		||||
                                )
 | 
			
		||||
                              ],
 | 
			
		||||
                            ),
 | 
			
		||||
                            const Divider(height: 1),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                      // Editing Notice
 | 
			
		||||
              if (_writeController.editingPost != null)
 | 
			
		||||
                        Column(
 | 
			
		||||
                          children: [
 | 
			
		||||
                            ExpansionTile(
 | 
			
		||||
                              minTileHeight: 48,
 | 
			
		||||
                              leading: const Icon(Symbols.edit_note).padding(left: 4),
 | 
			
		||||
                              title: Text('postEditingNotice')
 | 
			
		||||
                                  .fontSize(15)
 | 
			
		||||
                                  .tr(args: ['@${_writeController.editingPost!.publisher.name}']),
 | 
			
		||||
                              children: <Widget>[PostItem(data: _writeController.editingPost!)],
 | 
			
		||||
                            ),
 | 
			
		||||
                            const Divider(height: 1),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                      // Content Input Area
 | 
			
		||||
                Container(
 | 
			
		||||
                        constraints: const BoxConstraints(maxWidth: 640),
 | 
			
		||||
                        child: TextField(
 | 
			
		||||
                          controller: _writeController.contentController,
 | 
			
		||||
                          maxLines: null,
 | 
			
		||||
                          decoration: InputDecoration(
 | 
			
		||||
                            hintText: 'fieldPostContent'.tr(),
 | 
			
		||||
                            hintStyle: TextStyle(fontSize: 14),
 | 
			
		||||
                            isCollapsed: true,
 | 
			
		||||
                            contentPadding: const EdgeInsets.symmetric(
 | 
			
		||||
                              horizontal: 16,
 | 
			
		||||
                            ),
 | 
			
		||||
                            border: InputBorder.none,
 | 
			
		||||
                          ),
 | 
			
		||||
                          onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                  padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20, right: 20),
 | 
			
		||||
                  decoration: BoxDecoration(
 | 
			
		||||
                    border: Border(
 | 
			
		||||
                      bottom: BorderSide(
 | 
			
		||||
                        color: Theme.of(context).dividerColor,
 | 
			
		||||
                        width: 1 / MediaQuery.of(context).devicePixelRatio,
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                    ]
 | 
			
		||||
                        .expandIndexed(
 | 
			
		||||
                          (idx, ele) => [
 | 
			
		||||
                            if (idx != 0 || _writeController.isRelatedNull) const Gap(8),
 | 
			
		||||
                            ele,
 | 
			
		||||
                  ),
 | 
			
		||||
                  child: Row(
 | 
			
		||||
                    crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      const Icon(Icons.edit, size: 16),
 | 
			
		||||
                      const Gap(10),
 | 
			
		||||
                      Text('postEditingNotice').tr(args: ['@${_writeController.editingPost!.publisher.name}']),
 | 
			
		||||
                    ],
 | 
			
		||||
                        )
 | 
			
		||||
                        .toList(),
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              Expanded(
 | 
			
		||||
                child: Stack(
 | 
			
		||||
                  children: [
 | 
			
		||||
                    SingleChildScrollView(
 | 
			
		||||
                      padding: EdgeInsets.only(bottom: 160),
 | 
			
		||||
                      child: switch (_writeController.mode) {
 | 
			
		||||
                        'stories' => _PostStoryEditor(
 | 
			
		||||
                            controller: _writeController,
 | 
			
		||||
                            onTapPublisher: _showPublisherPopup,
 | 
			
		||||
                          ),
 | 
			
		||||
                        'articles' => _PostArticleEditor(
 | 
			
		||||
                            controller: _writeController,
 | 
			
		||||
                            onTapPublisher: _showPublisherPopup,
 | 
			
		||||
                          ),
 | 
			
		||||
                        'questions' => _PostQuestionEditor(
 | 
			
		||||
                            controller: _writeController,
 | 
			
		||||
                            onTapPublisher: _showPublisherPopup,
 | 
			
		||||
                          ),
 | 
			
		||||
                        'videos' => _PostVideoEditor(
 | 
			
		||||
                            controller: _writeController,
 | 
			
		||||
                            onTapPublisher: _showPublisherPopup,
 | 
			
		||||
                          ),
 | 
			
		||||
                        _ => const Placeholder(),
 | 
			
		||||
                      },
 | 
			
		||||
                    ),
 | 
			
		||||
                    if (_writeController.attachments.isNotEmpty || _writeController.thumbnail != null)
 | 
			
		||||
                PostMediaPendingList(
 | 
			
		||||
                      Positioned(
 | 
			
		||||
                        bottom: 0,
 | 
			
		||||
                        left: 0,
 | 
			
		||||
                        right: 0,
 | 
			
		||||
                        child: PostMediaPendingList(
 | 
			
		||||
                          thumbnail: _writeController.thumbnail,
 | 
			
		||||
                          attachments: _writeController.attachments,
 | 
			
		||||
                          isBusy: _writeController.isBusy,
 | 
			
		||||
@@ -365,6 +295,10 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                          },
 | 
			
		||||
                          onUpdateBusy: (state) => _writeController.setIsBusy(state),
 | 
			
		||||
                        ).padding(bottom: 8),
 | 
			
		||||
                      ),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              Material(
 | 
			
		||||
                elevation: 2,
 | 
			
		||||
                child: Column(
 | 
			
		||||
@@ -466,3 +400,525 @@ class _PostEditorActionScrollBehavior extends MaterialScrollBehavior {
 | 
			
		||||
        PointerDeviceKind.mouse,
 | 
			
		||||
      };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostPublisherPopup extends StatelessWidget {
 | 
			
		||||
  final PostWriteController controller;
 | 
			
		||||
  final List<SnPublisher>? publishers;
 | 
			
		||||
 | 
			
		||||
  const _PostPublisherPopup({required this.controller, this.publishers});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Column(
 | 
			
		||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
      children: [
 | 
			
		||||
        Row(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
          children: [
 | 
			
		||||
            const Icon(Symbols.face, size: 24),
 | 
			
		||||
            const Gap(16),
 | 
			
		||||
            Text('accountPublishers', style: Theme.of(context).textTheme.titleLarge).tr(),
 | 
			
		||||
          ],
 | 
			
		||||
        ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
			
		||||
        Expanded(
 | 
			
		||||
          child: ListView.builder(
 | 
			
		||||
            itemCount: publishers?.length ?? 0,
 | 
			
		||||
            itemBuilder: (context, idx) {
 | 
			
		||||
              final publisher = publishers![idx];
 | 
			
		||||
              return ListTile(
 | 
			
		||||
                title: Text(publisher.nick),
 | 
			
		||||
                subtitle: Text('@${publisher.name}'),
 | 
			
		||||
                leading: AccountImage(content: publisher.avatar, radius: 18),
 | 
			
		||||
                onTap: () {
 | 
			
		||||
                  controller.setPublisher(publisher);
 | 
			
		||||
                  Navigator.pop(context, true);
 | 
			
		||||
                },
 | 
			
		||||
              );
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostStoryEditor extends StatelessWidget {
 | 
			
		||||
  final PostWriteController controller;
 | 
			
		||||
  final Function? onTapPublisher;
 | 
			
		||||
 | 
			
		||||
  const _PostStoryEditor({required this.controller, this.onTapPublisher});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Container(
 | 
			
		||||
      padding: const EdgeInsets.symmetric(horizontal: 12),
 | 
			
		||||
      constraints: const BoxConstraints(maxWidth: 640),
 | 
			
		||||
      child: Row(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
        children: [
 | 
			
		||||
          Material(
 | 
			
		||||
            elevation: 2,
 | 
			
		||||
            borderRadius: const BorderRadius.all(Radius.circular(24)),
 | 
			
		||||
            child: GestureDetector(
 | 
			
		||||
              onTap: () {
 | 
			
		||||
                onTapPublisher?.call();
 | 
			
		||||
              },
 | 
			
		||||
              child: AccountImage(
 | 
			
		||||
                content: controller.publisher?.avatar,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: Column(
 | 
			
		||||
              children: [
 | 
			
		||||
                const Gap(6),
 | 
			
		||||
                TextField(
 | 
			
		||||
                  controller: controller.titleController,
 | 
			
		||||
                  decoration: InputDecoration.collapsed(
 | 
			
		||||
                    hintText: 'fieldPostTitle'.tr(),
 | 
			
		||||
                    border: InputBorder.none,
 | 
			
		||||
                  ),
 | 
			
		||||
                  style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ).padding(horizontal: 16),
 | 
			
		||||
                const Gap(8),
 | 
			
		||||
                TextField(
 | 
			
		||||
                  controller: controller.contentController,
 | 
			
		||||
                  maxLines: null,
 | 
			
		||||
                  decoration: InputDecoration(
 | 
			
		||||
                    hintText: 'fieldPostContent'.tr(),
 | 
			
		||||
                    hintStyle: TextStyle(fontSize: 14),
 | 
			
		||||
                    isCollapsed: true,
 | 
			
		||||
                    contentPadding: const EdgeInsets.symmetric(
 | 
			
		||||
                      horizontal: 16,
 | 
			
		||||
                    ),
 | 
			
		||||
                    border: InputBorder.none,
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ).padding(bottom: 8),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostArticleEditor extends StatelessWidget {
 | 
			
		||||
  final PostWriteController controller;
 | 
			
		||||
  final Function? onTapPublisher;
 | 
			
		||||
 | 
			
		||||
  const _PostArticleEditor({required this.controller, this.onTapPublisher});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final editorWidgets = <Widget>[
 | 
			
		||||
      Material(
 | 
			
		||||
        color: Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
			
		||||
        child: InkWell(
 | 
			
		||||
          child: Row(
 | 
			
		||||
            children: [
 | 
			
		||||
              AccountImage(content: controller.publisher?.avatar, radius: 20),
 | 
			
		||||
              const Gap(8),
 | 
			
		||||
              Expanded(
 | 
			
		||||
                child: Column(
 | 
			
		||||
                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Text(controller.publisher?.nick ?? 'loading'.tr()).bold(),
 | 
			
		||||
                    Text('@${controller.publisher?.name}'),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ).padding(horizontal: 12, vertical: 8),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            onTapPublisher?.call();
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      const Gap(16),
 | 
			
		||||
      TextField(
 | 
			
		||||
        controller: controller.titleController,
 | 
			
		||||
        decoration: InputDecoration.collapsed(
 | 
			
		||||
          hintText: 'fieldPostTitle'.tr(),
 | 
			
		||||
          border: InputBorder.none,
 | 
			
		||||
        ),
 | 
			
		||||
        style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
        onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
      ).padding(horizontal: 16),
 | 
			
		||||
      const Gap(8),
 | 
			
		||||
      TextField(
 | 
			
		||||
        controller: controller.descriptionController,
 | 
			
		||||
        decoration: InputDecoration.collapsed(
 | 
			
		||||
          hintText: 'fieldPostDescription'.tr(),
 | 
			
		||||
          border: InputBorder.none,
 | 
			
		||||
        ),
 | 
			
		||||
        maxLines: null,
 | 
			
		||||
        keyboardType: TextInputType.multiline,
 | 
			
		||||
        style: Theme.of(context).textTheme.bodyLarge,
 | 
			
		||||
        onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
      ).padding(horizontal: 16),
 | 
			
		||||
      const Gap(4),
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    if (ResponsiveBreakpoints.of(context).largerThan(MOBILE)) {
 | 
			
		||||
      return Container(
 | 
			
		||||
        constraints: const BoxConstraints(maxWidth: 640 * 2 + 8),
 | 
			
		||||
        child: Column(
 | 
			
		||||
          children: [
 | 
			
		||||
            ...editorWidgets,
 | 
			
		||||
            Row(
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
              children: [
 | 
			
		||||
                Expanded(
 | 
			
		||||
                  child: TextField(
 | 
			
		||||
                    controller: controller.contentController,
 | 
			
		||||
                    maxLines: null,
 | 
			
		||||
                    decoration: InputDecoration(
 | 
			
		||||
                      hintText: 'fieldPostContent'.tr(),
 | 
			
		||||
                      hintStyle: TextStyle(fontSize: 14),
 | 
			
		||||
                      isCollapsed: true,
 | 
			
		||||
                      contentPadding: const EdgeInsets.symmetric(
 | 
			
		||||
                        horizontal: 16,
 | 
			
		||||
                      ),
 | 
			
		||||
                      border: InputBorder.none,
 | 
			
		||||
                    ),
 | 
			
		||||
                    onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
                const Gap(8),
 | 
			
		||||
                Expanded(
 | 
			
		||||
                  child: MarkdownTextContent(
 | 
			
		||||
                    content: controller.contentController.text,
 | 
			
		||||
                  ).padding(horizontal: 24),
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return Column(
 | 
			
		||||
      children: [
 | 
			
		||||
        ...editorWidgets,
 | 
			
		||||
        Container(
 | 
			
		||||
          padding: const EdgeInsets.only(top: 8),
 | 
			
		||||
          constraints: const BoxConstraints(maxWidth: 640),
 | 
			
		||||
          child: TextField(
 | 
			
		||||
            controller: controller.contentController,
 | 
			
		||||
            maxLines: null,
 | 
			
		||||
            decoration: InputDecoration(
 | 
			
		||||
              hintText: 'fieldPostContent'.tr(),
 | 
			
		||||
              hintStyle: TextStyle(fontSize: 14),
 | 
			
		||||
              isCollapsed: true,
 | 
			
		||||
              contentPadding: const EdgeInsets.symmetric(
 | 
			
		||||
                horizontal: 16,
 | 
			
		||||
              ),
 | 
			
		||||
              border: InputBorder.none,
 | 
			
		||||
            ),
 | 
			
		||||
            onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostQuestionEditor extends StatelessWidget {
 | 
			
		||||
  final PostWriteController controller;
 | 
			
		||||
  final Function? onTapPublisher;
 | 
			
		||||
 | 
			
		||||
  const _PostQuestionEditor({required this.controller, this.onTapPublisher});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Container(
 | 
			
		||||
      padding: const EdgeInsets.symmetric(horizontal: 12),
 | 
			
		||||
      constraints: const BoxConstraints(maxWidth: 640),
 | 
			
		||||
      child: Row(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
        children: [
 | 
			
		||||
          Material(
 | 
			
		||||
            elevation: 1,
 | 
			
		||||
            borderRadius: const BorderRadius.all(Radius.circular(24)),
 | 
			
		||||
            child: GestureDetector(
 | 
			
		||||
              onTap: () {
 | 
			
		||||
                onTapPublisher?.call();
 | 
			
		||||
              },
 | 
			
		||||
              child: AccountImage(
 | 
			
		||||
                content: controller.publisher?.avatar,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: Column(
 | 
			
		||||
              children: [
 | 
			
		||||
                const Gap(6),
 | 
			
		||||
                TextField(
 | 
			
		||||
                  controller: controller.titleController,
 | 
			
		||||
                  decoration: InputDecoration.collapsed(
 | 
			
		||||
                    hintText: 'fieldPostTitle'.tr(),
 | 
			
		||||
                    border: InputBorder.none,
 | 
			
		||||
                  ),
 | 
			
		||||
                  style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ).padding(horizontal: 16),
 | 
			
		||||
                const Gap(8),
 | 
			
		||||
                TextField(
 | 
			
		||||
                  controller: controller.rewardController,
 | 
			
		||||
                  decoration: InputDecoration(
 | 
			
		||||
                    hintText: 'fieldPostQuestionReward'.tr(),
 | 
			
		||||
                    suffixText: 'walletCurrencyShort'.tr(),
 | 
			
		||||
                    border: InputBorder.none,
 | 
			
		||||
                    isCollapsed: true,
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ).padding(horizontal: 16),
 | 
			
		||||
                const Gap(8),
 | 
			
		||||
                TextField(
 | 
			
		||||
                  controller: controller.contentController,
 | 
			
		||||
                  maxLines: null,
 | 
			
		||||
                  decoration: InputDecoration(
 | 
			
		||||
                    hintText: 'fieldPostContent'.tr(),
 | 
			
		||||
                    hintStyle: TextStyle(fontSize: 14),
 | 
			
		||||
                    isCollapsed: true,
 | 
			
		||||
                    contentPadding: const EdgeInsets.symmetric(
 | 
			
		||||
                      horizontal: 16,
 | 
			
		||||
                    ),
 | 
			
		||||
                    border: InputBorder.none,
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ).padding(top: 8),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostVideoEditor extends StatelessWidget {
 | 
			
		||||
  final PostWriteController controller;
 | 
			
		||||
  final Function? onTapPublisher;
 | 
			
		||||
 | 
			
		||||
  const _PostVideoEditor({required this.controller, this.onTapPublisher});
 | 
			
		||||
 | 
			
		||||
  void _selectVideo(BuildContext context) async {
 | 
			
		||||
    final video = await showDialog<SnAttachment?>(
 | 
			
		||||
      context: context,
 | 
			
		||||
      builder: (context) => AttachmentInputDialog(
 | 
			
		||||
        title: 'postVideoUpload'.tr(),
 | 
			
		||||
        pool: 'interactive',
 | 
			
		||||
        mediaType: SnMediaType.video,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    if (!context.mounted) return;
 | 
			
		||||
    if (video == null) return;
 | 
			
		||||
    controller.setVideoAttachment(video);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _setAlt(BuildContext context) async {
 | 
			
		||||
    if (controller.videoAttachment == null) return;
 | 
			
		||||
 | 
			
		||||
    final result = await showDialog<SnAttachment?>(
 | 
			
		||||
      context: context,
 | 
			
		||||
      builder: (context) => PendingAttachmentAltDialog(media: PostWriteMedia(controller.videoAttachment)),
 | 
			
		||||
    );
 | 
			
		||||
    if (result == null) return;
 | 
			
		||||
 | 
			
		||||
    controller.setVideoAttachment(result);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _createBoost(BuildContext context) async {
 | 
			
		||||
    if (controller.videoAttachment == null) return;
 | 
			
		||||
 | 
			
		||||
    final result = await showDialog<SnAttachmentBoost?>(
 | 
			
		||||
      context: context,
 | 
			
		||||
      builder: (context) => PendingAttachmentBoostDialog(media: PostWriteMedia(controller.videoAttachment)),
 | 
			
		||||
    );
 | 
			
		||||
    if (result == null) return;
 | 
			
		||||
 | 
			
		||||
    final newAttach = controller.videoAttachment!.copyWith(
 | 
			
		||||
      boosts: [...controller.videoAttachment!.boosts, result],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    controller.setVideoAttachment(newAttach);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _setThumbnail(BuildContext context) async {
 | 
			
		||||
    if (controller.videoAttachment == null) return;
 | 
			
		||||
 | 
			
		||||
    final thumbnail = await showDialog<SnAttachment?>(
 | 
			
		||||
      context: context,
 | 
			
		||||
      builder: (context) => AttachmentInputDialog(
 | 
			
		||||
        title: 'attachmentSetThumbnail'.tr(),
 | 
			
		||||
        pool: 'interactive',
 | 
			
		||||
        analyzeNow: true,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    if (thumbnail == null) return;
 | 
			
		||||
    if (!context.mounted) return;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final attach = context.read<SnAttachmentProvider>();
 | 
			
		||||
      final newAttach = await attach.updateOne(
 | 
			
		||||
        controller.videoAttachment!,
 | 
			
		||||
        thumbnailId: thumbnail.id,
 | 
			
		||||
      );
 | 
			
		||||
      controller.setVideoAttachment(newAttach);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!context.mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _deleteAttachment(BuildContext context) async {
 | 
			
		||||
    if (controller.videoAttachment == null) return;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.delete('/cgi/uc/attachments/${controller.videoAttachment!.id}');
 | 
			
		||||
      controller.setVideoAttachment(null);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!context.mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Column(
 | 
			
		||||
      children: [
 | 
			
		||||
        Material(
 | 
			
		||||
          color: Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
			
		||||
          child: InkWell(
 | 
			
		||||
            child: Row(
 | 
			
		||||
              children: [
 | 
			
		||||
                AccountImage(content: controller.publisher?.avatar, radius: 20),
 | 
			
		||||
                const Gap(8),
 | 
			
		||||
                Expanded(
 | 
			
		||||
                  child: Column(
 | 
			
		||||
                    crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      Text(controller.publisher?.nick ?? 'loading'.tr()).bold(),
 | 
			
		||||
                      Text('@${controller.publisher?.name}'),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ).padding(horizontal: 12, vertical: 8),
 | 
			
		||||
            onTap: () {
 | 
			
		||||
              onTapPublisher?.call();
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        const Gap(16),
 | 
			
		||||
        TextField(
 | 
			
		||||
          controller: controller.titleController,
 | 
			
		||||
          decoration: InputDecoration.collapsed(
 | 
			
		||||
            hintText: 'fieldPostTitle'.tr(),
 | 
			
		||||
            border: InputBorder.none,
 | 
			
		||||
          ),
 | 
			
		||||
          style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
          onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
        ).padding(horizontal: 16),
 | 
			
		||||
        const Gap(8),
 | 
			
		||||
        TextField(
 | 
			
		||||
          controller: controller.descriptionController,
 | 
			
		||||
          decoration: InputDecoration.collapsed(
 | 
			
		||||
            hintText: 'fieldPostDescription'.tr(),
 | 
			
		||||
            border: InputBorder.none,
 | 
			
		||||
          ),
 | 
			
		||||
          maxLines: null,
 | 
			
		||||
          keyboardType: TextInputType.multiline,
 | 
			
		||||
          style: Theme.of(context).textTheme.bodyLarge,
 | 
			
		||||
          onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
        ).padding(horizontal: 16),
 | 
			
		||||
        const Gap(12),
 | 
			
		||||
        Container(
 | 
			
		||||
          margin: const EdgeInsets.only(left: 16, right: 16),
 | 
			
		||||
          decoration: BoxDecoration(
 | 
			
		||||
            borderRadius: BorderRadius.circular(16),
 | 
			
		||||
            border: Border.all(color: Theme.of(context).dividerColor),
 | 
			
		||||
          ),
 | 
			
		||||
          child: ContextMenuRegion(
 | 
			
		||||
            contextMenu: ContextMenu(
 | 
			
		||||
              entries: [
 | 
			
		||||
                MenuItem(
 | 
			
		||||
                  label: 'attachmentSetAlt'.tr(),
 | 
			
		||||
                  icon: Symbols.description,
 | 
			
		||||
                  onSelected: () {
 | 
			
		||||
                    _setAlt(context);
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                MenuItem(
 | 
			
		||||
                  label: 'attachmentBoost'.tr(),
 | 
			
		||||
                  icon: Symbols.bolt,
 | 
			
		||||
                  onSelected: () {
 | 
			
		||||
                    _createBoost(context);
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                MenuItem(
 | 
			
		||||
                  label: 'attachmentSetThumbnail'.tr(),
 | 
			
		||||
                  icon: Symbols.image,
 | 
			
		||||
                  onSelected: () {
 | 
			
		||||
                    _setThumbnail(context);
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                MenuItem(
 | 
			
		||||
                  label: 'attachmentCopyRandomId'.tr(),
 | 
			
		||||
                  icon: Symbols.content_copy,
 | 
			
		||||
                  onSelected: () {
 | 
			
		||||
                    Clipboard.setData(ClipboardData(text: controller.videoAttachment!.rid));
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                MenuItem(
 | 
			
		||||
                  label: 'delete'.tr(),
 | 
			
		||||
                  icon: Symbols.delete,
 | 
			
		||||
                  onSelected: () => _deleteAttachment(context),
 | 
			
		||||
                ),
 | 
			
		||||
                MenuItem(
 | 
			
		||||
                  label: 'unlink'.tr(),
 | 
			
		||||
                  icon: Symbols.link_off,
 | 
			
		||||
                  onSelected: () {
 | 
			
		||||
                    controller.setVideoAttachment(null);
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
            child: InkWell(
 | 
			
		||||
              borderRadius: BorderRadius.circular(16),
 | 
			
		||||
              onTap: controller.videoAttachment != null ? () => _selectVideo(context) : null,
 | 
			
		||||
              child: AspectRatio(
 | 
			
		||||
                aspectRatio: 16 / 9,
 | 
			
		||||
                child: controller.videoAttachment == null
 | 
			
		||||
                    ? Center(
 | 
			
		||||
                        child: Row(
 | 
			
		||||
                          mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                          crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                          mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            const Icon(Icons.add),
 | 
			
		||||
                            const Gap(4),
 | 
			
		||||
                            Text('postVideoUpload'.tr()),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                      )
 | 
			
		||||
                    : ClipRRect(
 | 
			
		||||
                        borderRadius: BorderRadius.circular(16),
 | 
			
		||||
                        child: AttachmentItem(
 | 
			
		||||
                          data: controller.videoAttachment!,
 | 
			
		||||
                          heroTag: const Uuid().v4(),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,6 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
@@ -134,7 +133,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
 | 
			
		||||
      body: Stack(
 | 
			
		||||
        children: [
 | 
			
		||||
          InfiniteList(
 | 
			
		||||
            padding: const EdgeInsets.only(top: 100),
 | 
			
		||||
            padding: const EdgeInsets.only(top: 100 + 8),
 | 
			
		||||
            itemCount: _posts.length,
 | 
			
		||||
            isLoading: _isBusy,
 | 
			
		||||
            hasReachedMax: _postCount != null && _posts.length >= _postCount!,
 | 
			
		||||
@@ -142,8 +141,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
 | 
			
		||||
              _fetchPosts();
 | 
			
		||||
            },
 | 
			
		||||
            itemBuilder: (context, idx) {
 | 
			
		||||
              return GestureDetector(
 | 
			
		||||
                child: PostItem(
 | 
			
		||||
              return OpenablePostItem(
 | 
			
		||||
                data: _posts[idx],
 | 
			
		||||
                maxWidth: 640,
 | 
			
		||||
                onChanged: (data) {
 | 
			
		||||
@@ -152,17 +150,9 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
 | 
			
		||||
                onDeleted: () {
 | 
			
		||||
                  _refreshPosts();
 | 
			
		||||
                },
 | 
			
		||||
                ),
 | 
			
		||||
                onTap: () {
 | 
			
		||||
                  GoRouter.of(context).pushNamed(
 | 
			
		||||
                    'postDetail',
 | 
			
		||||
                    pathParameters: {'slug': _posts[idx].id.toString()},
 | 
			
		||||
                    extra: _posts[idx],
 | 
			
		||||
              );
 | 
			
		||||
            },
 | 
			
		||||
              );
 | 
			
		||||
            },
 | 
			
		||||
            separatorBuilder: (context, index) => const Divider(height: 1),
 | 
			
		||||
            separatorBuilder: (_, __) => const Gap(8),
 | 
			
		||||
          ),
 | 
			
		||||
          Positioned(
 | 
			
		||||
            top: 16,
 | 
			
		||||
 
 | 
			
		||||
@@ -597,25 +597,16 @@ class _PublisherPostList extends StatelessWidget {
 | 
			
		||||
      hasReachedMax: postCount != null && posts.length >= postCount!,
 | 
			
		||||
      onFetchData: fetchPosts,
 | 
			
		||||
      itemBuilder: (context, idx) {
 | 
			
		||||
        return GestureDetector(
 | 
			
		||||
          child: PostItem(
 | 
			
		||||
        return OpenablePostItem(
 | 
			
		||||
          data: posts[idx],
 | 
			
		||||
          maxWidth: 640,
 | 
			
		||||
          onChanged: (data) {
 | 
			
		||||
            onChanged(idx, data);
 | 
			
		||||
          },
 | 
			
		||||
          onDeleted: onDeleted,
 | 
			
		||||
          ),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            GoRouter.of(context).pushNamed(
 | 
			
		||||
              'postDetail',
 | 
			
		||||
              pathParameters: {'slug': posts[idx].id.toString()},
 | 
			
		||||
              extra: posts[idx],
 | 
			
		||||
        );
 | 
			
		||||
      },
 | 
			
		||||
        );
 | 
			
		||||
      },
 | 
			
		||||
      separatorBuilder: (context, index) => const Divider(height: 1),
 | 
			
		||||
      separatorBuilder: (_, __) => const Gap(8),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -8,9 +8,11 @@ import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/user_directory.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/types/account.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/account/account_select.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
			
		||||
@@ -229,13 +231,35 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _showMemberAdd() {
 | 
			
		||||
    showModalBottomSheet(
 | 
			
		||||
  Future<void> _addMember(SnAccount related) async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.post(
 | 
			
		||||
        '/cgi/id/realms/${widget.realm!.alias}/members',
 | 
			
		||||
        data: {'related': related.name},
 | 
			
		||||
      );
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar('realmMemberAdded'.tr());
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _showMemberAdd() async {
 | 
			
		||||
    final user = await showModalBottomSheet<SnAccount?>(
 | 
			
		||||
      context: context,
 | 
			
		||||
      builder: (context) => _NewRealmMemberWidget(
 | 
			
		||||
        realm: widget.realm!,
 | 
			
		||||
      builder: (context) => AccountSelect(
 | 
			
		||||
        title: 'realmMemberAdd'.tr(),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    if (!mounted) return;
 | 
			
		||||
    if (user == null) return;
 | 
			
		||||
    _addMember(user);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -293,85 +317,6 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _NewRealmMemberWidget extends StatefulWidget {
 | 
			
		||||
  final SnRealm realm;
 | 
			
		||||
 | 
			
		||||
  const _NewRealmMemberWidget({required this.realm});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_NewRealmMemberWidget> createState() => _NewRealmMemberWidgetState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _NewRealmMemberWidgetState extends State<_NewRealmMemberWidget> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
 | 
			
		||||
  final TextEditingController _relatedController = TextEditingController();
 | 
			
		||||
 | 
			
		||||
  Future<void> _performAction() async {
 | 
			
		||||
    if (_relatedController.text.isEmpty) return;
 | 
			
		||||
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.post(
 | 
			
		||||
        '/cgi/id/realms/${widget.realm.alias}/members',
 | 
			
		||||
        data: {
 | 
			
		||||
          'related': _relatedController.text,
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      Navigator.pop(context, true);
 | 
			
		||||
      context.showSnackbar('channelMemberAdded'.tr());
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    super.dispose();
 | 
			
		||||
    _relatedController.dispose();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return StyledWidget(Column(
 | 
			
		||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
      children: [
 | 
			
		||||
        Text(
 | 
			
		||||
          'realmMemberAdd',
 | 
			
		||||
          style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
        ).tr(),
 | 
			
		||||
        const Gap(12),
 | 
			
		||||
        TextField(
 | 
			
		||||
          controller: _relatedController,
 | 
			
		||||
          readOnly: _isBusy,
 | 
			
		||||
          autocorrect: false,
 | 
			
		||||
          autofocus: true,
 | 
			
		||||
          textCapitalization: TextCapitalization.none,
 | 
			
		||||
          decoration: InputDecoration(
 | 
			
		||||
            labelText: 'fieldMemberRelatedName'.tr(),
 | 
			
		||||
            suffix: SizedBox(
 | 
			
		||||
              height: 24,
 | 
			
		||||
              child: IconButton(
 | 
			
		||||
                onPressed: _isBusy ? null : () => _performAction(),
 | 
			
		||||
                icon: Icon(Symbols.send),
 | 
			
		||||
                visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
 | 
			
		||||
                padding: EdgeInsets.zero,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
        )
 | 
			
		||||
      ],
 | 
			
		||||
    )).padding(all: 24);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _RealmSettingsWidget extends StatefulWidget {
 | 
			
		||||
  final SnRealm? realm;
 | 
			
		||||
  final Function() onUpdate;
 | 
			
		||||
 
 | 
			
		||||
@@ -8,9 +8,20 @@ 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/material_symbols_icons.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/controllers/post_write_controller.dart';
 | 
			
		||||
import 'package:surface/providers/channel.dart';
 | 
			
		||||
import 'package:surface/providers/user_directory.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/screens/chat/room.dart';
 | 
			
		||||
import 'package:surface/screens/post/post_editor.dart';
 | 
			
		||||
import 'package:surface/types/chat.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
 | 
			
		||||
class AppSharingListener extends StatefulWidget {
 | 
			
		||||
  final Widget child;
 | 
			
		||||
@@ -51,20 +62,39 @@ class _AppSharingListenerState extends State<AppSharingListener> {
 | 
			
		||||
                          pathParameters: {
 | 
			
		||||
                            'mode': 'stories',
 | 
			
		||||
                          },
 | 
			
		||||
                          extra: PostEditorExtraProps(
 | 
			
		||||
                          extra: PostEditorExtra(
 | 
			
		||||
                            text: value
 | 
			
		||||
                                .where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type))
 | 
			
		||||
                                .map((e) => e.path).join('\n'),
 | 
			
		||||
                                .map((e) => e.path)
 | 
			
		||||
                                .join('\n'),
 | 
			
		||||
                            attachments: value
 | 
			
		||||
                                .where((e) => [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image].contains(e.type))
 | 
			
		||||
                                .map((e) => PostWriteMedia.fromFile(XFile(e.path))).toList(),
 | 
			
		||||
                                .where((e) => [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image]
 | 
			
		||||
                                    .contains(e.type))
 | 
			
		||||
                                .map((e) => PostWriteMedia.fromFile(XFile(e.path)))
 | 
			
		||||
                                .toList(),
 | 
			
		||||
                          ),
 | 
			
		||||
                        );
 | 
			
		||||
                        Navigator.pop(context);
 | 
			
		||||
                      },
 | 
			
		||||
                    ),
 | 
			
		||||
                  ],
 | 
			
		||||
                    ListTile(
 | 
			
		||||
                      contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
                      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
 | 
			
		||||
                      leading: Icon(Icons.chat_outlined),
 | 
			
		||||
                      trailing: const Icon(Icons.chevron_right),
 | 
			
		||||
                      title: Text('shareIntentSendChannel').tr(),
 | 
			
		||||
                      onTap: () {
 | 
			
		||||
                        showModalBottomSheet(
 | 
			
		||||
                          context: context,
 | 
			
		||||
                          builder: (context) => _ShareIntentChannelSelect(value: value),
 | 
			
		||||
                        ).then((val) {
 | 
			
		||||
                          if (!context.mounted) return;
 | 
			
		||||
                          if (val == true) Navigator.pop(context);
 | 
			
		||||
                        });
 | 
			
		||||
                      },
 | 
			
		||||
                    ),
 | 
			
		||||
                  ],
 | 
			
		||||
                ).width(280),
 | 
			
		||||
              )
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
@@ -103,7 +133,7 @@ class _AppSharingListenerState extends State<AppSharingListener> {
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    if(!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
 | 
			
		||||
    if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
 | 
			
		||||
      _initialize();
 | 
			
		||||
      _initialHandle();
 | 
			
		||||
    }
 | 
			
		||||
@@ -120,3 +150,193 @@ class _AppSharingListenerState extends State<AppSharingListener> {
 | 
			
		||||
    return widget.child;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ShareIntentChannelSelect extends StatefulWidget {
 | 
			
		||||
  final Iterable<SharedMediaFile> value;
 | 
			
		||||
 | 
			
		||||
  const _ShareIntentChannelSelect({required this.value});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_ShareIntentChannelSelect> createState() => _ShareIntentChannelSelectState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
 | 
			
		||||
  bool _isBusy = true;
 | 
			
		||||
 | 
			
		||||
  List<SnChannel>? _channels;
 | 
			
		||||
  Map<int, SnChatMessage>? _lastMessages;
 | 
			
		||||
 | 
			
		||||
  void _refreshChannels() {
 | 
			
		||||
    final ua = context.read<UserProvider>();
 | 
			
		||||
    if (!ua.isAuthorized) {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final chan = context.read<ChatChannelProvider>();
 | 
			
		||||
    chan.fetchChannels().listen((channels) async {
 | 
			
		||||
      final lastMessages = await chan.getLastMessages(channels);
 | 
			
		||||
      _lastMessages = {for (final val in lastMessages) val.channelId: val};
 | 
			
		||||
      channels.sort((a, b) {
 | 
			
		||||
        if (_lastMessages!.containsKey(a.id) && _lastMessages!.containsKey(b.id)) {
 | 
			
		||||
          return _lastMessages![b.id]!.createdAt.compareTo(_lastMessages![a.id]!.createdAt);
 | 
			
		||||
        }
 | 
			
		||||
        if (_lastMessages!.containsKey(a.id)) return -1;
 | 
			
		||||
        if (_lastMessages!.containsKey(b.id)) return 1;
 | 
			
		||||
        return 0;
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      final ud = context.read<UserDirectoryProvider>();
 | 
			
		||||
      for (final channel in channels) {
 | 
			
		||||
        if (channel.type == 1) {
 | 
			
		||||
          await ud.listAccount(
 | 
			
		||||
            channel.members
 | 
			
		||||
                    ?.cast<SnChannelMember?>()
 | 
			
		||||
                    .map((ele) => ele?.accountId)
 | 
			
		||||
                    .where((ele) => ele != null)
 | 
			
		||||
                    .toSet() ??
 | 
			
		||||
                {},
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (mounted) setState(() => _channels = channels);
 | 
			
		||||
    })
 | 
			
		||||
      ..onError((err) {
 | 
			
		||||
        if (!mounted) return;
 | 
			
		||||
        context.showErrorDialog(err);
 | 
			
		||||
        setState(() => _isBusy = false);
 | 
			
		||||
      })
 | 
			
		||||
      ..onDone(() {
 | 
			
		||||
        if (!mounted) return;
 | 
			
		||||
        setState(() => _isBusy = false);
 | 
			
		||||
      });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _refreshChannels();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final ua = context.read<UserProvider>();
 | 
			
		||||
    final ud = context.read<UserDirectoryProvider>();
 | 
			
		||||
 | 
			
		||||
    return Column(
 | 
			
		||||
      children: [
 | 
			
		||||
        Row(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
          children: [
 | 
			
		||||
            const Icon(Symbols.chat, size: 24),
 | 
			
		||||
            const Gap(16),
 | 
			
		||||
            Text('shareIntentSendChannel', style: Theme.of(context).textTheme.titleLarge).tr(),
 | 
			
		||||
          ],
 | 
			
		||||
        ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
			
		||||
        LoadingIndicator(isActive: _isBusy),
 | 
			
		||||
        Expanded(
 | 
			
		||||
          child: MediaQuery.removePadding(
 | 
			
		||||
            context: context,
 | 
			
		||||
            removeTop: true,
 | 
			
		||||
            child: RefreshIndicator(
 | 
			
		||||
              onRefresh: () => Future.sync(() => _refreshChannels()),
 | 
			
		||||
              child: ListView.builder(
 | 
			
		||||
                itemCount: _channels?.length ?? 0,
 | 
			
		||||
                itemBuilder: (context, idx) {
 | 
			
		||||
                  final channel = _channels![idx];
 | 
			
		||||
                  final lastMessage = _lastMessages?[channel.id];
 | 
			
		||||
 | 
			
		||||
                  if (channel.type == 1) {
 | 
			
		||||
                    final otherMember = channel.members?.cast<SnChannelMember?>().firstWhere(
 | 
			
		||||
                          (ele) => ele?.accountId != ua.user?.id,
 | 
			
		||||
                          orElse: () => null,
 | 
			
		||||
                        );
 | 
			
		||||
 | 
			
		||||
                    return ListTile(
 | 
			
		||||
                      title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name),
 | 
			
		||||
                      subtitle: lastMessage != null
 | 
			
		||||
                          ? Text(
 | 
			
		||||
                              '${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
 | 
			
		||||
                              maxLines: 1,
 | 
			
		||||
                              overflow: TextOverflow.ellipsis,
 | 
			
		||||
                            )
 | 
			
		||||
                          : Text(
 | 
			
		||||
                              'channelDirectMessageDescription'.tr(args: [
 | 
			
		||||
                                '@${ud.getAccountFromCache(otherMember?.accountId)?.name}',
 | 
			
		||||
                              ]),
 | 
			
		||||
                              maxLines: 1,
 | 
			
		||||
                              overflow: TextOverflow.ellipsis,
 | 
			
		||||
                            ),
 | 
			
		||||
                      contentPadding: const EdgeInsets.symmetric(horizontal: 16),
 | 
			
		||||
                      leading: AccountImage(
 | 
			
		||||
                        content: ud.getAccountFromCache(otherMember?.accountId)?.avatar,
 | 
			
		||||
                      ),
 | 
			
		||||
                      onTap: () {
 | 
			
		||||
                        GoRouter.of(context).pushNamed(
 | 
			
		||||
                          'chatRoom',
 | 
			
		||||
                          pathParameters: {
 | 
			
		||||
                            'scope': channel.realm?.alias ?? 'global',
 | 
			
		||||
                            'alias': channel.alias,
 | 
			
		||||
                          },
 | 
			
		||||
                        ).then((value) {
 | 
			
		||||
                          if (mounted) _refreshChannels();
 | 
			
		||||
                        });
 | 
			
		||||
                      },
 | 
			
		||||
                    );
 | 
			
		||||
                  }
 | 
			
		||||
 | 
			
		||||
                  return ListTile(
 | 
			
		||||
                    title: Text(channel.name),
 | 
			
		||||
                    subtitle: lastMessage != null
 | 
			
		||||
                        ? Text(
 | 
			
		||||
                            '${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
 | 
			
		||||
                            maxLines: 1,
 | 
			
		||||
                            overflow: TextOverflow.ellipsis,
 | 
			
		||||
                          )
 | 
			
		||||
                        : Text(
 | 
			
		||||
                            channel.description,
 | 
			
		||||
                            maxLines: 1,
 | 
			
		||||
                            overflow: TextOverflow.ellipsis,
 | 
			
		||||
                          ),
 | 
			
		||||
                    contentPadding: const EdgeInsets.symmetric(horizontal: 16),
 | 
			
		||||
                    leading: AccountImage(
 | 
			
		||||
                      content: null,
 | 
			
		||||
                      fallbackWidget: const Icon(Symbols.chat, size: 20),
 | 
			
		||||
                    ),
 | 
			
		||||
                    onTap: () {
 | 
			
		||||
                      Navigator.pop(context, true);
 | 
			
		||||
                      GoRouter.of(context)
 | 
			
		||||
                          .pushNamed(
 | 
			
		||||
                        'chatRoom',
 | 
			
		||||
                        pathParameters: {
 | 
			
		||||
                          'scope': channel.realm?.alias ?? 'global',
 | 
			
		||||
                          'alias': channel.alias,
 | 
			
		||||
                        },
 | 
			
		||||
                        extra: ChatRoomScreenExtra(
 | 
			
		||||
                          initialText: widget.value
 | 
			
		||||
                              .where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type))
 | 
			
		||||
                              .map((e) => e.path)
 | 
			
		||||
                              .join('\n'),
 | 
			
		||||
                          initialAttachments: widget.value
 | 
			
		||||
                              .where((e) =>
 | 
			
		||||
                                  [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image].contains(e.type))
 | 
			
		||||
                              .map((e) => PostWriteMedia.fromFile(XFile(e.path)))
 | 
			
		||||
                              .toList(),
 | 
			
		||||
                        ),
 | 
			
		||||
                      )
 | 
			
		||||
                          .then((value) {
 | 
			
		||||
                        if (value == true) _refreshChannels();
 | 
			
		||||
                      });
 | 
			
		||||
                    },
 | 
			
		||||
                  );
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -33,7 +33,7 @@ Future<ThemeData> createAppTheme(
 | 
			
		||||
    brightness: brightness,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  final hasAppBarBlurry = prefs.getBool(kAppbarTransparentStoreKey) ?? false;
 | 
			
		||||
  final hasAppBarTransparent = prefs.getBool(kAppbarTransparentStoreKey) ?? false;
 | 
			
		||||
  final useM3 = useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? true);
 | 
			
		||||
 | 
			
		||||
  return ThemeData(
 | 
			
		||||
@@ -51,9 +51,9 @@ Future<ThemeData> createAppTheme(
 | 
			
		||||
    ),
 | 
			
		||||
    appBarTheme: AppBarTheme(
 | 
			
		||||
      centerTitle: true,
 | 
			
		||||
      elevation: hasAppBarBlurry ? 0 : null,
 | 
			
		||||
      backgroundColor: hasAppBarBlurry ? colorScheme.primary.withOpacity(0.3) : colorScheme.primary,
 | 
			
		||||
      foregroundColor: hasAppBarBlurry ? colorScheme.onSurface : colorScheme.onPrimary,
 | 
			
		||||
      elevation: hasAppBarTransparent ? 0 : null,
 | 
			
		||||
      backgroundColor: hasAppBarTransparent ? Colors.transparent : colorScheme.primary,
 | 
			
		||||
      foregroundColor: hasAppBarTransparent ? colorScheme.onSurface : colorScheme.onPrimary,
 | 
			
		||||
    ),
 | 
			
		||||
    pageTransitionsTheme: PageTransitionsTheme(
 | 
			
		||||
      builders: {
 | 
			
		||||
 
 | 
			
		||||
@@ -21,6 +21,7 @@ class SnAccount with _$SnAccount {
 | 
			
		||||
    required String name,
 | 
			
		||||
    required String nick,
 | 
			
		||||
    required Map<String, dynamic> permNodes,
 | 
			
		||||
    required String language,
 | 
			
		||||
    required SnAccountProfile? profile,
 | 
			
		||||
    @Default([]) List<SnAccountBadge> badges,
 | 
			
		||||
    required DateTime? suspendedAt,
 | 
			
		||||
 
 | 
			
		||||
@@ -33,6 +33,7 @@ mixin _$SnAccount {
 | 
			
		||||
  String get name => throw _privateConstructorUsedError;
 | 
			
		||||
  String get nick => throw _privateConstructorUsedError;
 | 
			
		||||
  Map<String, dynamic> get permNodes => throw _privateConstructorUsedError;
 | 
			
		||||
  String get language => throw _privateConstructorUsedError;
 | 
			
		||||
  SnAccountProfile? get profile => throw _privateConstructorUsedError;
 | 
			
		||||
  List<SnAccountBadge> get badges => throw _privateConstructorUsedError;
 | 
			
		||||
  DateTime? get suspendedAt => throw _privateConstructorUsedError;
 | 
			
		||||
@@ -69,6 +70,7 @@ abstract class $SnAccountCopyWith<$Res> {
 | 
			
		||||
      String name,
 | 
			
		||||
      String nick,
 | 
			
		||||
      Map<String, dynamic> permNodes,
 | 
			
		||||
      String language,
 | 
			
		||||
      SnAccountProfile? profile,
 | 
			
		||||
      List<SnAccountBadge> badges,
 | 
			
		||||
      DateTime? suspendedAt,
 | 
			
		||||
@@ -107,6 +109,7 @@ class _$SnAccountCopyWithImpl<$Res, $Val extends SnAccount>
 | 
			
		||||
    Object? name = null,
 | 
			
		||||
    Object? nick = null,
 | 
			
		||||
    Object? permNodes = null,
 | 
			
		||||
    Object? language = null,
 | 
			
		||||
    Object? profile = freezed,
 | 
			
		||||
    Object? badges = null,
 | 
			
		||||
    Object? suspendedAt = freezed,
 | 
			
		||||
@@ -164,6 +167,10 @@ class _$SnAccountCopyWithImpl<$Res, $Val extends SnAccount>
 | 
			
		||||
          ? _value.permNodes
 | 
			
		||||
          : permNodes // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as Map<String, dynamic>,
 | 
			
		||||
      language: null == language
 | 
			
		||||
          ? _value.language
 | 
			
		||||
          : language // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      profile: freezed == profile
 | 
			
		||||
          ? _value.profile
 | 
			
		||||
          : profile // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
@@ -231,6 +238,7 @@ abstract class _$$SnAccountImplCopyWith<$Res>
 | 
			
		||||
      String name,
 | 
			
		||||
      String nick,
 | 
			
		||||
      Map<String, dynamic> permNodes,
 | 
			
		||||
      String language,
 | 
			
		||||
      SnAccountProfile? profile,
 | 
			
		||||
      List<SnAccountBadge> badges,
 | 
			
		||||
      DateTime? suspendedAt,
 | 
			
		||||
@@ -268,6 +276,7 @@ class __$$SnAccountImplCopyWithImpl<$Res>
 | 
			
		||||
    Object? name = null,
 | 
			
		||||
    Object? nick = null,
 | 
			
		||||
    Object? permNodes = null,
 | 
			
		||||
    Object? language = null,
 | 
			
		||||
    Object? profile = freezed,
 | 
			
		||||
    Object? badges = null,
 | 
			
		||||
    Object? suspendedAt = freezed,
 | 
			
		||||
@@ -325,6 +334,10 @@ class __$$SnAccountImplCopyWithImpl<$Res>
 | 
			
		||||
          ? _value._permNodes
 | 
			
		||||
          : permNodes // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as Map<String, dynamic>,
 | 
			
		||||
      language: null == language
 | 
			
		||||
          ? _value.language
 | 
			
		||||
          : language // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      profile: freezed == profile
 | 
			
		||||
          ? _value.profile
 | 
			
		||||
          : profile // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
@@ -373,6 +386,7 @@ class _$SnAccountImpl extends _SnAccount {
 | 
			
		||||
      required this.name,
 | 
			
		||||
      required this.nick,
 | 
			
		||||
      required final Map<String, dynamic> permNodes,
 | 
			
		||||
      required this.language,
 | 
			
		||||
      required this.profile,
 | 
			
		||||
      final List<SnAccountBadge> badges = const [],
 | 
			
		||||
      required this.suspendedAt,
 | 
			
		||||
@@ -429,6 +443,8 @@ class _$SnAccountImpl extends _SnAccount {
 | 
			
		||||
    return EqualUnmodifiableMapView(_permNodes);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  final String language;
 | 
			
		||||
  @override
 | 
			
		||||
  final SnAccountProfile? profile;
 | 
			
		||||
  final List<SnAccountBadge> _badges;
 | 
			
		||||
@@ -453,7 +469,7 @@ class _$SnAccountImpl extends _SnAccount {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnAccount(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, confirmedAt: $confirmedAt, contacts: $contacts, avatar: $avatar, banner: $banner, description: $description, name: $name, nick: $nick, permNodes: $permNodes, profile: $profile, badges: $badges, suspendedAt: $suspendedAt, affiliatedId: $affiliatedId, affiliatedTo: $affiliatedTo, automatedBy: $automatedBy, automatedId: $automatedId)';
 | 
			
		||||
    return 'SnAccount(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, confirmedAt: $confirmedAt, contacts: $contacts, avatar: $avatar, banner: $banner, description: $description, name: $name, nick: $nick, permNodes: $permNodes, language: $language, profile: $profile, badges: $badges, suspendedAt: $suspendedAt, affiliatedId: $affiliatedId, affiliatedTo: $affiliatedTo, automatedBy: $automatedBy, automatedId: $automatedId)';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -479,6 +495,8 @@ class _$SnAccountImpl extends _SnAccount {
 | 
			
		||||
            (identical(other.nick, nick) || other.nick == nick) &&
 | 
			
		||||
            const DeepCollectionEquality()
 | 
			
		||||
                .equals(other._permNodes, _permNodes) &&
 | 
			
		||||
            (identical(other.language, language) ||
 | 
			
		||||
                other.language == language) &&
 | 
			
		||||
            (identical(other.profile, profile) || other.profile == profile) &&
 | 
			
		||||
            const DeepCollectionEquality().equals(other._badges, _badges) &&
 | 
			
		||||
            (identical(other.suspendedAt, suspendedAt) ||
 | 
			
		||||
@@ -509,6 +527,7 @@ class _$SnAccountImpl extends _SnAccount {
 | 
			
		||||
        name,
 | 
			
		||||
        nick,
 | 
			
		||||
        const DeepCollectionEquality().hash(_permNodes),
 | 
			
		||||
        language,
 | 
			
		||||
        profile,
 | 
			
		||||
        const DeepCollectionEquality().hash(_badges),
 | 
			
		||||
        suspendedAt,
 | 
			
		||||
@@ -548,6 +567,7 @@ abstract class _SnAccount extends SnAccount {
 | 
			
		||||
      required final String name,
 | 
			
		||||
      required final String nick,
 | 
			
		||||
      required final Map<String, dynamic> permNodes,
 | 
			
		||||
      required final String language,
 | 
			
		||||
      required final SnAccountProfile? profile,
 | 
			
		||||
      final List<SnAccountBadge> badges,
 | 
			
		||||
      required final DateTime? suspendedAt,
 | 
			
		||||
@@ -586,6 +606,8 @@ abstract class _SnAccount extends SnAccount {
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, dynamic> get permNodes;
 | 
			
		||||
  @override
 | 
			
		||||
  String get language;
 | 
			
		||||
  @override
 | 
			
		||||
  SnAccountProfile? get profile;
 | 
			
		||||
  @override
 | 
			
		||||
  List<SnAccountBadge> get badges;
 | 
			
		||||
 
 | 
			
		||||
@@ -26,6 +26,7 @@ _$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      name: json['name'] as String,
 | 
			
		||||
      nick: json['nick'] as String,
 | 
			
		||||
      permNodes: json['perm_nodes'] as Map<String, dynamic>,
 | 
			
		||||
      language: json['language'] as String,
 | 
			
		||||
      profile: json['profile'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : SnAccountProfile.fromJson(json['profile'] as Map<String, dynamic>),
 | 
			
		||||
@@ -56,6 +57,7 @@ Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) =>
 | 
			
		||||
      'name': instance.name,
 | 
			
		||||
      'nick': instance.nick,
 | 
			
		||||
      'perm_nodes': instance.permNodes,
 | 
			
		||||
      'language': instance.language,
 | 
			
		||||
      'profile': instance.profile?.toJson(),
 | 
			
		||||
      'badges': instance.badges.map((e) => e.toJson()).toList(),
 | 
			
		||||
      'suspended_at': instance.suspendedAt?.toIso8601String(),
 | 
			
		||||
 
 | 
			
		||||
@@ -89,6 +89,7 @@ class SnPostPreload with _$SnPostPreload {
 | 
			
		||||
  const factory SnPostPreload({
 | 
			
		||||
    required SnAttachment? thumbnail,
 | 
			
		||||
    required List<SnAttachment?>? attachments,
 | 
			
		||||
    required SnAttachment? video,
 | 
			
		||||
  }) = _SnPostPreload;
 | 
			
		||||
 | 
			
		||||
  factory SnPostPreload.fromJson(Map<String, Object?> json) =>
 | 
			
		||||
 
 | 
			
		||||
@@ -1567,6 +1567,7 @@ SnPostPreload _$SnPostPreloadFromJson(Map<String, dynamic> json) {
 | 
			
		||||
mixin _$SnPostPreload {
 | 
			
		||||
  SnAttachment? get thumbnail => throw _privateConstructorUsedError;
 | 
			
		||||
  List<SnAttachment?>? get attachments => throw _privateConstructorUsedError;
 | 
			
		||||
  SnAttachment? get video => throw _privateConstructorUsedError;
 | 
			
		||||
 | 
			
		||||
  /// Serializes this SnPostPreload to a JSON map.
 | 
			
		||||
  Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
 | 
			
		||||
@@ -1584,9 +1585,13 @@ abstract class $SnPostPreloadCopyWith<$Res> {
 | 
			
		||||
          SnPostPreload value, $Res Function(SnPostPreload) then) =
 | 
			
		||||
      _$SnPostPreloadCopyWithImpl<$Res, SnPostPreload>;
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call({SnAttachment? thumbnail, List<SnAttachment?>? attachments});
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {SnAttachment? thumbnail,
 | 
			
		||||
      List<SnAttachment?>? attachments,
 | 
			
		||||
      SnAttachment? video});
 | 
			
		||||
 | 
			
		||||
  $SnAttachmentCopyWith<$Res>? get thumbnail;
 | 
			
		||||
  $SnAttachmentCopyWith<$Res>? get video;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@@ -1606,6 +1611,7 @@ class _$SnPostPreloadCopyWithImpl<$Res, $Val extends SnPostPreload>
 | 
			
		||||
  $Res call({
 | 
			
		||||
    Object? thumbnail = freezed,
 | 
			
		||||
    Object? attachments = freezed,
 | 
			
		||||
    Object? video = freezed,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_value.copyWith(
 | 
			
		||||
      thumbnail: freezed == thumbnail
 | 
			
		||||
@@ -1616,6 +1622,10 @@ class _$SnPostPreloadCopyWithImpl<$Res, $Val extends SnPostPreload>
 | 
			
		||||
          ? _value.attachments
 | 
			
		||||
          : attachments // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as List<SnAttachment?>?,
 | 
			
		||||
      video: freezed == video
 | 
			
		||||
          ? _value.video
 | 
			
		||||
          : video // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as SnAttachment?,
 | 
			
		||||
    ) as $Val);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -1632,6 +1642,20 @@ class _$SnPostPreloadCopyWithImpl<$Res, $Val extends SnPostPreload>
 | 
			
		||||
      return _then(_value.copyWith(thumbnail: value) as $Val);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnPostPreload
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  $SnAttachmentCopyWith<$Res>? get video {
 | 
			
		||||
    if (_value.video == null) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return $SnAttachmentCopyWith<$Res>(_value.video!, (value) {
 | 
			
		||||
      return _then(_value.copyWith(video: value) as $Val);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@@ -1642,10 +1666,15 @@ abstract class _$$SnPostPreloadImplCopyWith<$Res>
 | 
			
		||||
      __$$SnPostPreloadImplCopyWithImpl<$Res>;
 | 
			
		||||
  @override
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call({SnAttachment? thumbnail, List<SnAttachment?>? attachments});
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {SnAttachment? thumbnail,
 | 
			
		||||
      List<SnAttachment?>? attachments,
 | 
			
		||||
      SnAttachment? video});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  $SnAttachmentCopyWith<$Res>? get thumbnail;
 | 
			
		||||
  @override
 | 
			
		||||
  $SnAttachmentCopyWith<$Res>? get video;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@@ -1663,6 +1692,7 @@ class __$$SnPostPreloadImplCopyWithImpl<$Res>
 | 
			
		||||
  $Res call({
 | 
			
		||||
    Object? thumbnail = freezed,
 | 
			
		||||
    Object? attachments = freezed,
 | 
			
		||||
    Object? video = freezed,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_$SnPostPreloadImpl(
 | 
			
		||||
      thumbnail: freezed == thumbnail
 | 
			
		||||
@@ -1673,6 +1703,10 @@ class __$$SnPostPreloadImplCopyWithImpl<$Res>
 | 
			
		||||
          ? _value._attachments
 | 
			
		||||
          : attachments // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as List<SnAttachment?>?,
 | 
			
		||||
      video: freezed == video
 | 
			
		||||
          ? _value.video
 | 
			
		||||
          : video // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as SnAttachment?,
 | 
			
		||||
    ));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1682,7 +1716,8 @@ class __$$SnPostPreloadImplCopyWithImpl<$Res>
 | 
			
		||||
class _$SnPostPreloadImpl implements _SnPostPreload {
 | 
			
		||||
  const _$SnPostPreloadImpl(
 | 
			
		||||
      {required this.thumbnail,
 | 
			
		||||
      required final List<SnAttachment?>? attachments})
 | 
			
		||||
      required final List<SnAttachment?>? attachments,
 | 
			
		||||
      required this.video})
 | 
			
		||||
      : _attachments = attachments;
 | 
			
		||||
 | 
			
		||||
  factory _$SnPostPreloadImpl.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
@@ -1700,9 +1735,12 @@ class _$SnPostPreloadImpl implements _SnPostPreload {
 | 
			
		||||
    return EqualUnmodifiableListView(value);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  final SnAttachment? video;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnPostPreload(thumbnail: $thumbnail, attachments: $attachments)';
 | 
			
		||||
    return 'SnPostPreload(thumbnail: $thumbnail, attachments: $attachments, video: $video)';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -1713,13 +1751,14 @@ class _$SnPostPreloadImpl implements _SnPostPreload {
 | 
			
		||||
            (identical(other.thumbnail, thumbnail) ||
 | 
			
		||||
                other.thumbnail == thumbnail) &&
 | 
			
		||||
            const DeepCollectionEquality()
 | 
			
		||||
                .equals(other._attachments, _attachments));
 | 
			
		||||
                .equals(other._attachments, _attachments) &&
 | 
			
		||||
            (identical(other.video, video) || other.video == video));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => Object.hash(runtimeType, thumbnail,
 | 
			
		||||
      const DeepCollectionEquality().hash(_attachments));
 | 
			
		||||
      const DeepCollectionEquality().hash(_attachments), video);
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnPostPreload
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
@@ -1740,7 +1779,8 @@ class _$SnPostPreloadImpl implements _SnPostPreload {
 | 
			
		||||
abstract class _SnPostPreload implements SnPostPreload {
 | 
			
		||||
  const factory _SnPostPreload(
 | 
			
		||||
      {required final SnAttachment? thumbnail,
 | 
			
		||||
      required final List<SnAttachment?>? attachments}) = _$SnPostPreloadImpl;
 | 
			
		||||
      required final List<SnAttachment?>? attachments,
 | 
			
		||||
      required final SnAttachment? video}) = _$SnPostPreloadImpl;
 | 
			
		||||
 | 
			
		||||
  factory _SnPostPreload.fromJson(Map<String, dynamic> json) =
 | 
			
		||||
      _$SnPostPreloadImpl.fromJson;
 | 
			
		||||
@@ -1749,6 +1789,8 @@ abstract class _SnPostPreload implements SnPostPreload {
 | 
			
		||||
  SnAttachment? get thumbnail;
 | 
			
		||||
  @override
 | 
			
		||||
  List<SnAttachment?>? get attachments;
 | 
			
		||||
  @override
 | 
			
		||||
  SnAttachment? get video;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnPostPreload
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
 
 | 
			
		||||
@@ -165,12 +165,16 @@ _$SnPostPreloadImpl _$$SnPostPreloadImplFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
              ? null
 | 
			
		||||
              : SnAttachment.fromJson(e as Map<String, dynamic>))
 | 
			
		||||
          .toList(),
 | 
			
		||||
      video: json['video'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : SnAttachment.fromJson(json['video'] as Map<String, dynamic>),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$$SnPostPreloadImplToJson(_$SnPostPreloadImpl instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'thumbnail': instance.thumbnail?.toJson(),
 | 
			
		||||
      'attachments': instance.attachments?.map((e) => e?.toJson()).toList(),
 | 
			
		||||
      'video': instance.video?.toJson(),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
_$SnBodyImpl _$$SnBodyImplFromJson(Map<String, dynamic> json) => _$SnBodyImpl(
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,12 @@
 | 
			
		||||
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/user_directory.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/types/account.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
 | 
			
		||||
@@ -47,10 +50,19 @@ class _AccountSelectState extends State<AccountSelect> {
 | 
			
		||||
  Future<void> _getFriends() async {
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
    final resp = await sn.client.get('/cgi/id/users/me/relations?status=1');
 | 
			
		||||
    if (!mounted) return;
 | 
			
		||||
    final ua = context.read<UserProvider>();
 | 
			
		||||
 | 
			
		||||
    setState(() {
 | 
			
		||||
      _relativeUsers.addAll(
 | 
			
		||||
        resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [],
 | 
			
		||||
        resp.data?.map((e) {
 | 
			
		||||
          final rel = SnRelationship.fromJson(e);
 | 
			
		||||
          if (rel.relatedId == ua.user?.id) {
 | 
			
		||||
            return rel.account!;
 | 
			
		||||
          } else {
 | 
			
		||||
            return rel.related!;
 | 
			
		||||
          }
 | 
			
		||||
        }).cast<SnAccount>(),
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
@@ -96,10 +108,14 @@ class _AccountSelectState extends State<AccountSelect> {
 | 
			
		||||
      child: Column(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
        children: [
 | 
			
		||||
          Text(
 | 
			
		||||
            widget.title,
 | 
			
		||||
            style: Theme.of(context).textTheme.headlineSmall,
 | 
			
		||||
          ).padding(left: 24, right: 24, top: 16, bottom: 16),
 | 
			
		||||
          Row(
 | 
			
		||||
            crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
            children: [
 | 
			
		||||
              const Icon(Symbols.group, size: 24),
 | 
			
		||||
              const Gap(16),
 | 
			
		||||
              Text(widget.title, style: Theme.of(context).textTheme.titleLarge),
 | 
			
		||||
            ],
 | 
			
		||||
          ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
			
		||||
          Container(
 | 
			
		||||
            color: Theme.of(context).colorScheme.secondaryContainer,
 | 
			
		||||
            padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
 | 
			
		||||
@@ -117,13 +133,9 @@ class _AccountSelectState extends State<AccountSelect> {
 | 
			
		||||
          ),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: ListView.builder(
 | 
			
		||||
              itemCount: _pendingUsers.isEmpty
 | 
			
		||||
                  ? _relativeUsers.length
 | 
			
		||||
                  : _pendingUsers.length,
 | 
			
		||||
              itemCount: _pendingUsers.isEmpty ? _relativeUsers.length : _pendingUsers.length,
 | 
			
		||||
              itemBuilder: (context, index) {
 | 
			
		||||
                var user = _pendingUsers.isEmpty
 | 
			
		||||
                    ? _relativeUsers[index]
 | 
			
		||||
                    : _pendingUsers[index];
 | 
			
		||||
                var user = _pendingUsers.isEmpty ? _relativeUsers[index] : _pendingUsers[index];
 | 
			
		||||
                return ListTile(
 | 
			
		||||
                  title: Text(user.nick),
 | 
			
		||||
                  subtitle: Text(user.name),
 | 
			
		||||
@@ -142,8 +154,7 @@ class _AccountSelectState extends State<AccountSelect> {
 | 
			
		||||
                          }
 | 
			
		||||
 | 
			
		||||
                          setState(() {
 | 
			
		||||
                            final idx = _selectedUsers
 | 
			
		||||
                                .indexWhere((x) => x.id == user.id);
 | 
			
		||||
                            final idx = _selectedUsers.indexWhere((x) => x.id == user.id);
 | 
			
		||||
                            if (idx != -1) {
 | 
			
		||||
                              _selectedUsers.removeAt(idx);
 | 
			
		||||
                            } else {
 | 
			
		||||
 
 | 
			
		||||
@@ -6,12 +6,22 @@ import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/sn_attachment.dart';
 | 
			
		||||
import 'package:surface/types/attachment.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
 | 
			
		||||
class AttachmentInputDialog extends StatefulWidget {
 | 
			
		||||
  final String? title;
 | 
			
		||||
final bool? analyzeNow;
 | 
			
		||||
  const AttachmentInputDialog({super.key, required this.title, this.analyzeNow = false});
 | 
			
		||||
  final bool? analyzeNow;
 | 
			
		||||
  final SnMediaType? mediaType;
 | 
			
		||||
  final String pool;
 | 
			
		||||
 | 
			
		||||
  const AttachmentInputDialog({
 | 
			
		||||
    super.key,
 | 
			
		||||
    required this.title,
 | 
			
		||||
    required this.pool,
 | 
			
		||||
    this.analyzeNow = false,
 | 
			
		||||
    this.mediaType = SnMediaType.image,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<AttachmentInputDialog> createState() => _AttachmentInputDialogState();
 | 
			
		||||
@@ -20,13 +30,18 @@ final bool? analyzeNow;
 | 
			
		||||
class _AttachmentInputDialogState extends State<AttachmentInputDialog> {
 | 
			
		||||
  final _randomIdController = TextEditingController();
 | 
			
		||||
 | 
			
		||||
  XFile? _thumbnailFile;
 | 
			
		||||
  XFile? _file;
 | 
			
		||||
  double? _progress;
 | 
			
		||||
 | 
			
		||||
  void _pickImage() async {
 | 
			
		||||
  void _pickMedia() async {
 | 
			
		||||
    final picker = ImagePicker();
 | 
			
		||||
    final result = await picker.pickImage(source: ImageSource.gallery);
 | 
			
		||||
    final result = switch (widget.mediaType) {
 | 
			
		||||
      SnMediaType.image => await picker.pickImage(source: ImageSource.gallery),
 | 
			
		||||
      SnMediaType.video => await picker.pickVideo(source: ImageSource.gallery),
 | 
			
		||||
      _ => await picker.pickMedia(),
 | 
			
		||||
    };
 | 
			
		||||
    if (result == null) return;
 | 
			
		||||
    setState(() => _thumbnailFile = result);
 | 
			
		||||
    setState(() => _file = result);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
@@ -46,15 +61,20 @@ class _AttachmentInputDialogState extends State<AttachmentInputDialog> {
 | 
			
		||||
        if (!mounted) return;
 | 
			
		||||
        context.showErrorDialog(err);
 | 
			
		||||
      }
 | 
			
		||||
    } else if (_thumbnailFile != null) {
 | 
			
		||||
    } else if (_file != null) {
 | 
			
		||||
      try {
 | 
			
		||||
        final attachment = await attach.directUploadOne(
 | 
			
		||||
          (await _thumbnailFile!.readAsBytes()).buffer.asUint8List(),
 | 
			
		||||
          _thumbnailFile!.path,
 | 
			
		||||
          'interactive',
 | 
			
		||||
          null,
 | 
			
		||||
        final place = await attach.chunkedUploadInitialize(await _file!.length(), _file!.name, widget.pool, null);
 | 
			
		||||
 | 
			
		||||
        final attachment = await attach.chunkedUploadParts(
 | 
			
		||||
          _file!,
 | 
			
		||||
          place.$1,
 | 
			
		||||
          place.$2,
 | 
			
		||||
          analyzeNow: widget.analyzeNow ?? false,
 | 
			
		||||
          onProgress: (value) {
 | 
			
		||||
            setState(() => _progress = value);
 | 
			
		||||
          },
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        if (!mounted) return;
 | 
			
		||||
        Navigator.pop(context, attachment);
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
@@ -67,7 +87,7 @@ class _AttachmentInputDialogState extends State<AttachmentInputDialog> {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AlertDialog(
 | 
			
		||||
      title: Text(widget.title ?? 'attachmentInputDialog').tr(),
 | 
			
		||||
      title: Text(widget.title ?? 'attachmentInputDialog'.tr()),
 | 
			
		||||
      content: Column(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
        mainAxisSize: MainAxisSize.min,
 | 
			
		||||
@@ -86,22 +106,33 @@ class _AttachmentInputDialogState extends State<AttachmentInputDialog> {
 | 
			
		||||
          const Gap(24),
 | 
			
		||||
          Text('attachmentInputNew').tr().fontSize(14),
 | 
			
		||||
          Card(
 | 
			
		||||
            child: ListTile(
 | 
			
		||||
            child: Column(
 | 
			
		||||
              children: [
 | 
			
		||||
                ListTile(
 | 
			
		||||
                  contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
 | 
			
		||||
                  leading: const Icon(Symbols.add_photo_alternate),
 | 
			
		||||
                  trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
                  title: Text('addAttachmentFromAlbum').tr(),
 | 
			
		||||
              subtitle: _thumbnailFile == null ? Text('unset').tr() : Text('waitingForUpload').tr(),
 | 
			
		||||
                  subtitle: _file == null ? Text('unset').tr() : Text('waitingForUpload').tr(),
 | 
			
		||||
                  onTap: () {
 | 
			
		||||
                _pickImage();
 | 
			
		||||
                    _pickMedia();
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          if (_isBusy)
 | 
			
		||||
            LinearProgressIndicator(
 | 
			
		||||
              value: _progress,
 | 
			
		||||
              borderRadius: BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
            ).padding(top: 16),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
      actions: [
 | 
			
		||||
        TextButton(
 | 
			
		||||
          onPressed: _isBusy ? null : () {
 | 
			
		||||
          onPressed: _isBusy
 | 
			
		||||
              ? null
 | 
			
		||||
              : () {
 | 
			
		||||
                  Navigator.pop(context);
 | 
			
		||||
                },
 | 
			
		||||
          child: Text('dialogDismiss').tr(),
 | 
			
		||||
 
 | 
			
		||||
@@ -152,6 +152,7 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
 | 
			
		||||
      child: GestureDetector(
 | 
			
		||||
        behavior: HitTestBehavior.translucent,
 | 
			
		||||
        child: Scaffold(
 | 
			
		||||
          backgroundColor: Colors.transparent,
 | 
			
		||||
          body: Stack(
 | 
			
		||||
            children: [
 | 
			
		||||
              Builder(builder: (context) {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,17 +1,28 @@
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
import 'dart:math' show min;
 | 
			
		||||
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:google_fonts/google_fonts.dart';
 | 
			
		||||
import 'package:hotkey_manager/hotkey_manager.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:pasteboard/pasteboard.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/controllers/chat_message_controller.dart';
 | 
			
		||||
import 'package:surface/controllers/post_write_controller.dart';
 | 
			
		||||
import 'package:surface/providers/sn_attachment.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/sn_sticker.dart';
 | 
			
		||||
import 'package:surface/providers/user_directory.dart';
 | 
			
		||||
import 'package:surface/types/attachment.dart';
 | 
			
		||||
import 'package:surface/types/chat.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_media_pending_list.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
 | 
			
		||||
class ChatMessageInput extends StatefulWidget {
 | 
			
		||||
  final ChatMessageController controller;
 | 
			
		||||
@@ -32,9 +43,30 @@ class ChatMessageInputState extends State<ChatMessageInput> {
 | 
			
		||||
  final TextEditingController _contentController = TextEditingController();
 | 
			
		||||
  final FocusNode _focusNode = FocusNode();
 | 
			
		||||
 | 
			
		||||
  final HotKey _pasteHotKey = HotKey(
 | 
			
		||||
    key: PhysicalKeyboardKey.keyV,
 | 
			
		||||
    modifiers: [Platform.isMacOS ? HotKeyModifier.meta : HotKeyModifier.control],
 | 
			
		||||
    scope: HotKeyScope.inapp,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  void _registerHotKey() {
 | 
			
		||||
    if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
 | 
			
		||||
    hotKeyManager.register(_pasteHotKey, keyDownHandler: (_) async {
 | 
			
		||||
      final imageBytes = await Pasteboard.image;
 | 
			
		||||
      if (imageBytes == null) return;
 | 
			
		||||
      _attachments.add(PostWriteMedia.fromBytes(
 | 
			
		||||
        imageBytes,
 | 
			
		||||
        'attachmentPastedImage'.tr(),
 | 
			
		||||
        SnMediaType.image,
 | 
			
		||||
      ));
 | 
			
		||||
      setState(() {});
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _registerHotKey();
 | 
			
		||||
    _contentController.addListener(() {
 | 
			
		||||
      if (_contentController.text.isNotEmpty) {
 | 
			
		||||
        widget.controller.pingTypingStatus();
 | 
			
		||||
@@ -46,6 +78,16 @@ class ChatMessageInputState extends State<ChatMessageInput> {
 | 
			
		||||
    setState(() => _replyingMessage = value);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setInitialText(String? value) {
 | 
			
		||||
    _contentController.text = value ?? '';
 | 
			
		||||
    setState(() {});
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setInitialAttachments(List<PostWriteMedia>? value) {
 | 
			
		||||
    _attachments.addAll(value ?? []);
 | 
			
		||||
    setState(() {});
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setEdit(SnChatMessage? value) {
 | 
			
		||||
    _contentController.text = value?.body['text'] ?? '';
 | 
			
		||||
    _attachments.clear();
 | 
			
		||||
@@ -134,10 +176,35 @@ class ChatMessageInputState extends State<ChatMessageInput> {
 | 
			
		||||
 | 
			
		||||
  final List<PostWriteMedia> _attachments = List.empty(growable: true);
 | 
			
		||||
 | 
			
		||||
  OverlayEntry? _overlayEntry;
 | 
			
		||||
 | 
			
		||||
  void _showEmojiPicker(BuildContext context) {
 | 
			
		||||
    final overlay = Overlay.of(context);
 | 
			
		||||
    _overlayEntry = OverlayEntry(
 | 
			
		||||
      builder: (context) => Positioned(
 | 
			
		||||
        bottom: 16 + MediaQuery.of(context).padding.bottom,
 | 
			
		||||
        right: 16,
 | 
			
		||||
        child: _StickerPicker(
 | 
			
		||||
          originalText: _contentController.text,
 | 
			
		||||
          onDismiss: () => _dismissEmojiPicker(),
 | 
			
		||||
          onInsert: (str) => _contentController.text = str,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    overlay.insert(_overlayEntry!);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _dismissEmojiPicker() {
 | 
			
		||||
    _overlayEntry?.remove();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    _contentController.dispose();
 | 
			
		||||
    _focusNode.dispose();
 | 
			
		||||
    _dismissEmojiPicker();
 | 
			
		||||
    if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) hotKeyManager.unregister(_pasteHotKey);
 | 
			
		||||
    super.dispose();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -270,6 +337,7 @@ class ChatMessageInputState extends State<ChatMessageInput> {
 | 
			
		||||
                        : 'fieldChatMessage'.tr(args: [widget.controller.channel?.name ?? 'loading'.tr()]),
 | 
			
		||||
                    border: InputBorder.none,
 | 
			
		||||
                  ),
 | 
			
		||||
                  textInputAction: TextInputAction.send,
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                  onSubmitted: (_) {
 | 
			
		||||
                    if (_isBusy) return;
 | 
			
		||||
@@ -279,6 +347,19 @@ class ChatMessageInputState extends State<ChatMessageInput> {
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              const Gap(8),
 | 
			
		||||
              IconButton(
 | 
			
		||||
                icon: Icon(
 | 
			
		||||
                  Symbols.mood,
 | 
			
		||||
                  color: Theme.of(context).colorScheme.primary,
 | 
			
		||||
                ),
 | 
			
		||||
                visualDensity: const VisualDensity(
 | 
			
		||||
                  horizontal: -4,
 | 
			
		||||
                  vertical: -4,
 | 
			
		||||
                ),
 | 
			
		||||
                onPressed: () {
 | 
			
		||||
                  _showEmojiPicker(context);
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
              AddPostMediaButton(
 | 
			
		||||
                onAdd: (items) {
 | 
			
		||||
                  setState(() {
 | 
			
		||||
@@ -304,3 +385,107 @@ class ChatMessageInputState extends State<ChatMessageInput> {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _StickerPicker extends StatelessWidget {
 | 
			
		||||
  final String originalText;
 | 
			
		||||
  final Function? onDismiss;
 | 
			
		||||
  final Function(String)? onInsert;
 | 
			
		||||
 | 
			
		||||
  const _StickerPicker({this.onDismiss, required this.originalText, this.onInsert});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final sticker = context.read<SnStickerProvider>();
 | 
			
		||||
    return GestureDetector(
 | 
			
		||||
      onTap: () {
 | 
			
		||||
        onDismiss?.call();
 | 
			
		||||
      },
 | 
			
		||||
      child: Container(
 | 
			
		||||
        constraints: BoxConstraints(maxWidth: min(360, MediaQuery.of(context).size.width), maxHeight: 240),
 | 
			
		||||
        child: Material(
 | 
			
		||||
          elevation: 8,
 | 
			
		||||
          borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
          child: ClipRRect(
 | 
			
		||||
            borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
            child: ListView(
 | 
			
		||||
              padding: EdgeInsets.zero,
 | 
			
		||||
              children: sticker.stickersByPack.entries
 | 
			
		||||
                  .map((e) {
 | 
			
		||||
                    return <Widget>[
 | 
			
		||||
                      Container(
 | 
			
		||||
                        margin: EdgeInsets.only(bottom: 8),
 | 
			
		||||
                        padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
 | 
			
		||||
                        color: Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
			
		||||
                        child: Column(
 | 
			
		||||
                          mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            Text(e.value.first.pack.name).bold(),
 | 
			
		||||
                            Text(e.value.first.pack.description),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                      GridView.builder(
 | 
			
		||||
                        physics: const NeverScrollableScrollPhysics(),
 | 
			
		||||
                        padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
 | 
			
		||||
                        shrinkWrap: true,
 | 
			
		||||
                        gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
 | 
			
		||||
                          maxCrossAxisExtent: 48,
 | 
			
		||||
                          childAspectRatio: 1.0,
 | 
			
		||||
                          mainAxisSpacing: 8,
 | 
			
		||||
                          crossAxisSpacing: 8,
 | 
			
		||||
                        ),
 | 
			
		||||
                        itemCount: e.value.length,
 | 
			
		||||
                        itemBuilder: (context, index) {
 | 
			
		||||
                          final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
                          final element = e.value[index];
 | 
			
		||||
                          return GestureDetector(
 | 
			
		||||
                            onTap: () {
 | 
			
		||||
                              final withSpace = originalText.isNotEmpty;
 | 
			
		||||
                              onInsert?.call(
 | 
			
		||||
                                  '$originalText${withSpace ? ' ' : ''}:${element.pack.prefix}${element.alias}:');
 | 
			
		||||
                              onDismiss?.call();
 | 
			
		||||
                            },
 | 
			
		||||
                            child: Tooltip(
 | 
			
		||||
                              richMessage: TextSpan(
 | 
			
		||||
                                children: [
 | 
			
		||||
                                  TextSpan(
 | 
			
		||||
                                      text: ':${element.pack.prefix}${element.alias}:\n',
 | 
			
		||||
                                      style: GoogleFonts.robotoMono()),
 | 
			
		||||
                                  TextSpan(text: element.name).bold(),
 | 
			
		||||
                                ],
 | 
			
		||||
                              ),
 | 
			
		||||
                              child: Container(
 | 
			
		||||
                                width: 48,
 | 
			
		||||
                                height: 48,
 | 
			
		||||
                                decoration: BoxDecoration(
 | 
			
		||||
                                  borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                                  color: Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
			
		||||
                                ),
 | 
			
		||||
                                child: ClipRRect(
 | 
			
		||||
                                  borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                                  child: UniversalImage(
 | 
			
		||||
                                    sn.getAttachmentUrl(element.attachment.rid),
 | 
			
		||||
                                    width: 48,
 | 
			
		||||
                                    height: 48,
 | 
			
		||||
                                    cacheHeight: 48,
 | 
			
		||||
                                    cacheWidth: 48,
 | 
			
		||||
                                    fit: BoxFit.contain,
 | 
			
		||||
                                  ),
 | 
			
		||||
                                ),
 | 
			
		||||
                              ),
 | 
			
		||||
                            ),
 | 
			
		||||
                          );
 | 
			
		||||
                        },
 | 
			
		||||
                      ),
 | 
			
		||||
                    ];
 | 
			
		||||
                  })
 | 
			
		||||
                  .expand((ele) => ele)
 | 
			
		||||
                  .toList(),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@ 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/config.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/providers/websocket.dart';
 | 
			
		||||
 | 
			
		||||
@@ -13,6 +14,9 @@ class ConnectionIndicator extends StatelessWidget {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final ws = context.watch<WebSocketProvider>();
 | 
			
		||||
    final cfg = context.watch<ConfigProvider>();
 | 
			
		||||
 | 
			
		||||
    final marginLeft = cfg.drawerIsCollapsed ? 0.0 : cfg.drawerIsExpanded ? 304.0 : 80.0;
 | 
			
		||||
 | 
			
		||||
    return ListenableBuilder(
 | 
			
		||||
      listenable: ws,
 | 
			
		||||
@@ -22,6 +26,7 @@ class ConnectionIndicator extends StatelessWidget {
 | 
			
		||||
 | 
			
		||||
        return IgnorePointer(
 | 
			
		||||
          ignoring: !show,
 | 
			
		||||
          child: Center(
 | 
			
		||||
            child: GestureDetector(
 | 
			
		||||
              child: Material(
 | 
			
		||||
                elevation: 2,
 | 
			
		||||
@@ -29,13 +34,16 @@ class ConnectionIndicator extends StatelessWidget {
 | 
			
		||||
                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)
 | 
			
		||||
                            Text('serverDisconnected')
 | 
			
		||||
                                .tr()
 | 
			
		||||
                                .textColor(Theme.of(context).colorScheme.onSecondaryContainer)
 | 
			
		||||
                          else
 | 
			
		||||
                            Text('serverConnected').tr().textColor(Theme.of(context).colorScheme.onSecondaryContainer),
 | 
			
		||||
                          const Gap(8),
 | 
			
		||||
@@ -61,6 +69,7 @@ class ConnectionIndicator extends StatelessWidget {
 | 
			
		||||
                }
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
          ).padding(left: marginLeft),
 | 
			
		||||
        );
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
 
 | 
			
		||||
@@ -28,7 +28,7 @@ class ContextMenuArea extends StatelessWidget {
 | 
			
		||||
          // Leave padding for side navigation
 | 
			
		||||
          mousePosition = cfg.drawerIsExpanded
 | 
			
		||||
              ? mousePosition.copyWith(dx: mousePosition.dx - 304 * 2)
 | 
			
		||||
              : mousePosition.copyWith(dx: mousePosition.dx - 72 * 2);
 | 
			
		||||
              : mousePosition.copyWith(dx: mousePosition.dx - 80 * 2);
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      child: GestureDetector(
 | 
			
		||||
 
 | 
			
		||||
@@ -20,6 +20,7 @@ class MarkdownTextContent extends StatelessWidget {
 | 
			
		||||
  final bool isAutoWarp;
 | 
			
		||||
  final bool isEnlargeSticker;
 | 
			
		||||
  final TextScaler? textScaler;
 | 
			
		||||
  final Color? textColor;
 | 
			
		||||
  final List<SnAttachment?>? attachments;
 | 
			
		||||
 | 
			
		||||
  const MarkdownTextContent({
 | 
			
		||||
@@ -28,6 +29,7 @@ class MarkdownTextContent extends StatelessWidget {
 | 
			
		||||
    this.isAutoWarp = false,
 | 
			
		||||
    this.isEnlargeSticker = false,
 | 
			
		||||
    this.textScaler,
 | 
			
		||||
    this.textColor,
 | 
			
		||||
    this.attachments,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
@@ -42,6 +44,7 @@ class MarkdownTextContent extends StatelessWidget {
 | 
			
		||||
        Theme.of(context),
 | 
			
		||||
      ).copyWith(
 | 
			
		||||
        textScaler: textScaler,
 | 
			
		||||
        p: textColor != null ? Theme.of(context).textTheme.bodyMedium!.copyWith(color: textColor) : null,
 | 
			
		||||
        blockquote: TextStyle(
 | 
			
		||||
          color: Theme.of(context).colorScheme.onSurfaceVariant,
 | 
			
		||||
        ),
 | 
			
		||||
@@ -126,14 +129,27 @@ class MarkdownTextContent extends StatelessWidget {
 | 
			
		||||
                    future: st.lookupSticker(alias),
 | 
			
		||||
                    builder: (context, snapshot) {
 | 
			
		||||
                      if (snapshot.hasData) {
 | 
			
		||||
                        return UniversalImage(
 | 
			
		||||
                        return GestureDetector(
 | 
			
		||||
                            child: UniversalImage(
 | 
			
		||||
                              sn.getAttachmentUrl(snapshot.data!.attachment.rid),
 | 
			
		||||
                          fit: BoxFit.cover,
 | 
			
		||||
                              fit: BoxFit.contain,
 | 
			
		||||
                              width: size,
 | 
			
		||||
                              height: size,
 | 
			
		||||
                              cacheHeight: size,
 | 
			
		||||
                              cacheWidth: size,
 | 
			
		||||
                            ),
 | 
			
		||||
                            onTap: () {
 | 
			
		||||
                              if (snapshot.data == null) return;
 | 
			
		||||
                              context.pushTransparentRoute(
 | 
			
		||||
                                AttachmentZoomView(
 | 
			
		||||
                                  data: [snapshot.data!.attachment],
 | 
			
		||||
                                  initialIndex: 0,
 | 
			
		||||
                                  heroTags: [const Uuid().v4()],
 | 
			
		||||
                                ),
 | 
			
		||||
                                backgroundColor: Colors.black.withOpacity(0.7),
 | 
			
		||||
                                rootNavigator: true,
 | 
			
		||||
                              );
 | 
			
		||||
                            });
 | 
			
		||||
                      }
 | 
			
		||||
                      return const SizedBox.shrink();
 | 
			
		||||
                    },
 | 
			
		||||
 
 | 
			
		||||
@@ -31,7 +31,9 @@ class _AppRailNavigationState extends State<AppRailNavigation> {
 | 
			
		||||
      builder: (context, _) {
 | 
			
		||||
        final destinations = nav.destinations.where((ele) => ele.isPinned).toList();
 | 
			
		||||
 | 
			
		||||
        return NavigationRail(
 | 
			
		||||
        return SizedBox(
 | 
			
		||||
          width: 80,
 | 
			
		||||
          child: NavigationRail(
 | 
			
		||||
            selectedIndex:
 | 
			
		||||
                nav.currentIndex != null && nav.currentIndex! < nav.pinnedDestinationCount ? nav.currentIndex : null,
 | 
			
		||||
            destinations: [
 | 
			
		||||
@@ -59,6 +61,7 @@ class _AppRailNavigationState extends State<AppRailNavigation> {
 | 
			
		||||
              nav.setIndex(idx);
 | 
			
		||||
              GoRouter.of(context).goNamed(destinations[idx].screen);
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
 
 | 
			
		||||
@@ -140,6 +140,7 @@ class AppRootScaffold extends StatelessWidget {
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    final safeTop = MediaQuery.of(context).padding.top;
 | 
			
		||||
    final safeBottom = MediaQuery.of(context).padding.bottom;
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
      key: globalRootScaffoldKey,
 | 
			
		||||
@@ -191,7 +192,10 @@ class AppRootScaffold extends StatelessWidget {
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
          Positioned(top: safeTop > 0 ? safeTop : 16, right: 8, child: NotifyIndicator()),
 | 
			
		||||
          Positioned(top: safeTop > 0 ? safeTop : 16, left: 8, child: ConnectionIndicator()),
 | 
			
		||||
          if (ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE))
 | 
			
		||||
            Positioned(bottom: safeBottom > 0 ? safeBottom : 16, left: 0, right: 0, child: ConnectionIndicator())
 | 
			
		||||
          else
 | 
			
		||||
            Positioned(top: safeTop > 0 ? safeTop : 16, left: 0, right: 0, child: ConnectionIndicator()),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
      drawer: !isExpandedDrawer ? AppNavigationDrawer() : null,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,60 +1,181 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_animate/flutter_animate.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:responsive_framework/responsive_framework.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/notification.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/screens/notification.dart';
 | 
			
		||||
import 'package:surface/types/notification.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
 | 
			
		||||
class NotifyIndicator extends StatelessWidget {
 | 
			
		||||
import 'markdown_content.dart';
 | 
			
		||||
 | 
			
		||||
class NotifyIndicator extends StatefulWidget {
 | 
			
		||||
  const NotifyIndicator({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<NotifyIndicator> createState() => _NotifyIndicatorState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _NotifyIndicatorState extends State<NotifyIndicator> with SingleTickerProviderStateMixin {
 | 
			
		||||
  late final AnimationController _animationController = AnimationController(
 | 
			
		||||
    vsync: this,
 | 
			
		||||
    duration: const Duration(milliseconds: 300),
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  void _markOneAsRead(SnNotification notification) async {
 | 
			
		||||
    final ua = context.read<UserProvider>();
 | 
			
		||||
    if (!ua.isAuthorized) return;
 | 
			
		||||
 | 
			
		||||
    if (notification.id == 0) return;
 | 
			
		||||
    if (notification.readAt != null) return;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.put('/cgi/id/notifications/read/${notification.id}');
 | 
			
		||||
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar(
 | 
			
		||||
        'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']),
 | 
			
		||||
      );
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    _animationController.dispose();
 | 
			
		||||
    super.dispose();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
    final ua = context.read<UserProvider>();
 | 
			
		||||
    final nty = context.watch<NotificationProvider>();
 | 
			
		||||
 | 
			
		||||
    final show = nty.notifications.isNotEmpty && ua.isAuthorized;
 | 
			
		||||
    final isMobile = ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE);
 | 
			
		||||
 | 
			
		||||
    final show = nty.showingCount > 0 && ua.isAuthorized;
 | 
			
		||||
 | 
			
		||||
    if (show) {
 | 
			
		||||
      _animationController.animateTo(1);
 | 
			
		||||
    } else {
 | 
			
		||||
      _animationController.animateTo(0);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return ListenableBuilder(
 | 
			
		||||
        listenable: nty,
 | 
			
		||||
        builder: (context, _) {
 | 
			
		||||
          final current = nty.notifications.lastOrNull;
 | 
			
		||||
 | 
			
		||||
          return IgnorePointer(
 | 
			
		||||
            ignoring: !show,
 | 
			
		||||
            child: GestureDetector(
 | 
			
		||||
              child: Animate(
 | 
			
		||||
                autoPlay: false,
 | 
			
		||||
                controller: _animationController,
 | 
			
		||||
                effects: [
 | 
			
		||||
                  SlideEffect(
 | 
			
		||||
                    begin: isMobile ? Offset(0, -1) : Offset(1, 0),
 | 
			
		||||
                    end: Offset(0, 0),
 | 
			
		||||
                    duration: Duration(milliseconds: 300),
 | 
			
		||||
                    curve: Curves.fastEaseInToSlowEaseOut,
 | 
			
		||||
                  ),
 | 
			
		||||
                  FadeEffect(
 | 
			
		||||
                    begin: 0.0,
 | 
			
		||||
                    end: 1.0,
 | 
			
		||||
                    duration: Duration(milliseconds: 300),
 | 
			
		||||
                    curve: Curves.easeInOut,
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
                child: Container(
 | 
			
		||||
                  padding: const EdgeInsets.symmetric(vertical: 16),
 | 
			
		||||
                  width: double.infinity,
 | 
			
		||||
                  constraints: BoxConstraints(
 | 
			
		||||
                    maxWidth: isMobile ? MediaQuery.of(context).size.width - 16 : 360,
 | 
			
		||||
                  ),
 | 
			
		||||
                  child: Material(
 | 
			
		||||
                    elevation: 2,
 | 
			
		||||
                shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16))),
 | 
			
		||||
                color: Theme.of(context).colorScheme.secondaryContainer,
 | 
			
		||||
                child: ua.isAuthorized
 | 
			
		||||
                    ? Row(
 | 
			
		||||
                        mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
                        crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                    borderRadius: BorderRadius.circular(8),
 | 
			
		||||
                    color: Theme.of(context).colorScheme.surfaceContainer,
 | 
			
		||||
                    child: Row(
 | 
			
		||||
                      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                      children: [
 | 
			
		||||
                        if (current?.metadata['avatar'] != null)
 | 
			
		||||
                          CircleAvatar(
 | 
			
		||||
                            radius: 14,
 | 
			
		||||
                            backgroundImage: UniversalImage.provider(
 | 
			
		||||
                              sn.getAttachmentUrl(current!.metadata['avatar']),
 | 
			
		||||
                            ),
 | 
			
		||||
                          )
 | 
			
		||||
                        else
 | 
			
		||||
                          Icon(kNotificationTopicIcons[current?.topic] ?? Symbols.notifications),
 | 
			
		||||
                        const Gap(16),
 | 
			
		||||
                        Expanded(
 | 
			
		||||
                          child: Column(
 | 
			
		||||
                            crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                            children: [
 | 
			
		||||
                              Text(
 | 
			
		||||
                            nty.notifications.lastOrNull?.title ??
 | 
			
		||||
                                'notificationUnreadCount'.plural(nty.notifications.length),
 | 
			
		||||
                            maxLines: 1,
 | 
			
		||||
                            overflow: TextOverflow.ellipsis,
 | 
			
		||||
                                current?.title ?? 'Notification',
 | 
			
		||||
                                style: Theme.of(context).textTheme.bodyMedium!.copyWith(
 | 
			
		||||
                                      fontWeight: FontWeight.bold,
 | 
			
		||||
                                    ),
 | 
			
		||||
                          if (nty.notifications.lastOrNull?.body != null)
 | 
			
		||||
                              ),
 | 
			
		||||
                              if (current?.subtitle?.isNotEmpty ?? false)
 | 
			
		||||
                                Text(
 | 
			
		||||
                              nty.notifications.lastOrNull!.body,
 | 
			
		||||
                              maxLines: 1,
 | 
			
		||||
                              overflow: TextOverflow.ellipsis,
 | 
			
		||||
                            ).padding(left: 4),
 | 
			
		||||
                          const Gap(8),
 | 
			
		||||
                          const Icon(Symbols.notifications_unread, size: 18),
 | 
			
		||||
                                  current!.subtitle!,
 | 
			
		||||
                                  style: Theme.of(context).textTheme.bodyMedium!.copyWith(
 | 
			
		||||
                                        fontWeight: FontWeight.bold,
 | 
			
		||||
                                      ),
 | 
			
		||||
                                ),
 | 
			
		||||
                              MarkdownTextContent(
 | 
			
		||||
                                content: current?.body ?? '',
 | 
			
		||||
                                isAutoWarp: true,
 | 
			
		||||
                              ),
 | 
			
		||||
                            ],
 | 
			
		||||
                      ).padding(horizontal: 8, vertical: 4)
 | 
			
		||||
                    : const SizedBox.shrink(),
 | 
			
		||||
              ).opacity(show ? 1 : 0, animate: true).animate(
 | 
			
		||||
                    const Duration(milliseconds: 300),
 | 
			
		||||
                    Curves.easeInOut,
 | 
			
		||||
                          ),
 | 
			
		||||
                        ),
 | 
			
		||||
                        const Gap(16),
 | 
			
		||||
                        Column(
 | 
			
		||||
                          crossAxisAlignment: CrossAxisAlignment.end,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            Text(DateFormat('HH:mm').format(current?.createdAt.toLocal() ?? DateTime.now()))
 | 
			
		||||
                                .fontSize(12)
 | 
			
		||||
                                .padding(right: 2),
 | 
			
		||||
                            const Gap(6),
 | 
			
		||||
                            if (current?.metadata['image'] != null)
 | 
			
		||||
                              SizedBox(
 | 
			
		||||
                                width: 40,
 | 
			
		||||
                                height: 40,
 | 
			
		||||
                                child: ClipRRect(
 | 
			
		||||
                                  borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                                  child: AutoResizeUniversalImage(
 | 
			
		||||
                                    sn.getAttachmentUrl(current?.metadata['image']),
 | 
			
		||||
                                    fit: BoxFit.cover,
 | 
			
		||||
                                  ),
 | 
			
		||||
                                ),
 | 
			
		||||
                              ),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                      ],
 | 
			
		||||
                    ).padding(horizontal: 16, vertical: 12),
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              onTap: () {
 | 
			
		||||
                nty.clear();
 | 
			
		||||
                if (current != null) {
 | 
			
		||||
                  _markOneAsRead(current);
 | 
			
		||||
                }
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
 
 | 
			
		||||
@@ -8,17 +8,23 @@ import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/post.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
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 PostCommentSliverList extends StatefulWidget {
 | 
			
		||||
  final int parentPostId;
 | 
			
		||||
  final SnPost parentPost;
 | 
			
		||||
  final double? maxWidth;
 | 
			
		||||
  final Function(SnPost)? onSelectAnswer;
 | 
			
		||||
 | 
			
		||||
  const PostCommentSliverList({
 | 
			
		||||
    super.key,
 | 
			
		||||
    required this.parentPostId,
 | 
			
		||||
    required this.parentPost,
 | 
			
		||||
    this.maxWidth,
 | 
			
		||||
    this.onSelectAnswer,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -37,7 +43,7 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    final pt = context.read<SnPostContentProvider>();
 | 
			
		||||
    final result = await pt.listPostReplies(widget.parentPostId);
 | 
			
		||||
    final result = await pt.listPostReplies(widget.parentPost.id);
 | 
			
		||||
    final List<SnPost> out = result.$1;
 | 
			
		||||
 | 
			
		||||
    if (!mounted) return;
 | 
			
		||||
@@ -48,6 +54,21 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
 | 
			
		||||
    if (mounted) setState(() => _isBusy = false);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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,
 | 
			
		||||
        'answer_id': answer.id,
 | 
			
		||||
      });
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      await refresh();
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> refresh() async {
 | 
			
		||||
    _posts.clear();
 | 
			
		||||
    _fetchPosts();
 | 
			
		||||
@@ -71,6 +92,7 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
 | 
			
		||||
          child: PostItem(
 | 
			
		||||
            data: _posts[idx],
 | 
			
		||||
            maxWidth: widget.maxWidth,
 | 
			
		||||
            onSelectAnswer: widget.parentPost.type == 'question' ? () => _selectAnswer(_posts[idx]) : null,
 | 
			
		||||
            onChanged: (data) {
 | 
			
		||||
              setState(() => _posts[idx] = data);
 | 
			
		||||
            },
 | 
			
		||||
@@ -94,11 +116,12 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class PostCommentListPopup extends StatefulWidget {
 | 
			
		||||
  final int postId;
 | 
			
		||||
  final SnPost post;
 | 
			
		||||
  final int commentCount;
 | 
			
		||||
 | 
			
		||||
  const PostCommentListPopup({
 | 
			
		||||
    super.key,
 | 
			
		||||
    required this.postId,
 | 
			
		||||
    required this.post,
 | 
			
		||||
    this.commentCount = 0,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
@@ -122,9 +145,7 @@ class _PostCommentListPopupState extends State<PostCommentListPopup> {
 | 
			
		||||
          children: [
 | 
			
		||||
            const Icon(Symbols.comment, size: 24),
 | 
			
		||||
            const Gap(16),
 | 
			
		||||
            Text('postCommentsDetailed')
 | 
			
		||||
                .plural(widget.commentCount)
 | 
			
		||||
                .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
			
		||||
            Text('postCommentsDetailed').plural(widget.commentCount).textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
			
		||||
          ],
 | 
			
		||||
        ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
			
		||||
        Expanded(
 | 
			
		||||
@@ -143,7 +164,7 @@ class _PostCommentListPopupState extends State<PostCommentListPopup> {
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                    child: PostMiniEditor(
 | 
			
		||||
                      postReplyId: widget.postId,
 | 
			
		||||
                      postReplyId: widget.post.id,
 | 
			
		||||
                      onPost: () {
 | 
			
		||||
                        _childListKey.currentState!.refresh();
 | 
			
		||||
                      },
 | 
			
		||||
@@ -151,8 +172,8 @@ class _PostCommentListPopupState extends State<PostCommentListPopup> {
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              PostCommentSliverList(
 | 
			
		||||
                parentPost: widget.post,
 | 
			
		||||
                key: _childListKey,
 | 
			
		||||
                parentPostId: widget.postId,
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,13 @@
 | 
			
		||||
import 'dart:developer';
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
import 'dart:math' as math;
 | 
			
		||||
 | 
			
		||||
import 'package:animations/animations.dart';
 | 
			
		||||
import 'package:dio/dio.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:file_saver/file_saver.dart';
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_animate/flutter_animate.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:google_fonts/google_fonts.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
@@ -22,10 +23,12 @@ import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/config.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/screens/post/post_detail.dart';
 | 
			
		||||
import 'package:surface/types/attachment.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/types/reaction.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/attachment/attachment_item.dart';
 | 
			
		||||
import 'package:surface/widgets/attachment/attachment_list.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/link_preview.dart';
 | 
			
		||||
@@ -38,6 +41,65 @@ import 'package:surface/widgets/post/publisher_popover.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
import 'package:xml/xml.dart';
 | 
			
		||||
 | 
			
		||||
class OpenablePostItem extends StatelessWidget {
 | 
			
		||||
  final SnPost data;
 | 
			
		||||
  final bool showReactions;
 | 
			
		||||
  final bool showComments;
 | 
			
		||||
  final bool showMenu;
 | 
			
		||||
  final bool showFullPost;
 | 
			
		||||
  final double? maxWidth;
 | 
			
		||||
  final Function(SnPost data)? onChanged;
 | 
			
		||||
  final Function()? onDeleted;
 | 
			
		||||
  final Function()? onSelectAnswer;
 | 
			
		||||
 | 
			
		||||
  const OpenablePostItem({
 | 
			
		||||
    super.key,
 | 
			
		||||
    required this.data,
 | 
			
		||||
    this.showReactions = true,
 | 
			
		||||
    this.showComments = true,
 | 
			
		||||
    this.showMenu = true,
 | 
			
		||||
    this.showFullPost = false,
 | 
			
		||||
    this.maxWidth,
 | 
			
		||||
    this.onChanged,
 | 
			
		||||
    this.onDeleted,
 | 
			
		||||
    this.onSelectAnswer,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final cfg = context.read<ConfigProvider>();
 | 
			
		||||
 | 
			
		||||
    return OpenContainer(
 | 
			
		||||
      closedBuilder: (_, __) => Container(
 | 
			
		||||
        constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
 | 
			
		||||
        child: PostItem(
 | 
			
		||||
          data: data,
 | 
			
		||||
          maxWidth: maxWidth,
 | 
			
		||||
          showComments: showComments,
 | 
			
		||||
          showFullPost: showFullPost,
 | 
			
		||||
          onChanged: onChanged,
 | 
			
		||||
          onDeleted: onDeleted,
 | 
			
		||||
          onSelectAnswer: onSelectAnswer,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      openBuilder: (_, close) => PostDetailScreen(
 | 
			
		||||
        slug: data.id.toString(),
 | 
			
		||||
        preload: data,
 | 
			
		||||
        onBack: close,
 | 
			
		||||
      ),
 | 
			
		||||
      openColor: Colors.transparent,
 | 
			
		||||
      openElevation: 0,
 | 
			
		||||
      transitionType: ContainerTransitionType.fade,
 | 
			
		||||
      closedColor: Theme.of(context).colorScheme.surfaceContainerLow.withOpacity(
 | 
			
		||||
            cfg.prefs.getBool(kAppBackgroundStoreKey) == true ? 0.75 : 1,
 | 
			
		||||
          ),
 | 
			
		||||
      closedShape: const RoundedRectangleBorder(
 | 
			
		||||
        borderRadius: BorderRadius.all(Radius.circular(16)),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class PostItem extends StatelessWidget {
 | 
			
		||||
  final SnPost data;
 | 
			
		||||
  final bool showReactions;
 | 
			
		||||
@@ -47,6 +109,7 @@ class PostItem extends StatelessWidget {
 | 
			
		||||
  final double? maxWidth;
 | 
			
		||||
  final Function(SnPost data)? onChanged;
 | 
			
		||||
  final Function()? onDeleted;
 | 
			
		||||
  final Function()? onSelectAnswer;
 | 
			
		||||
 | 
			
		||||
  const PostItem({
 | 
			
		||||
    super.key,
 | 
			
		||||
@@ -58,6 +121,7 @@ class PostItem extends StatelessWidget {
 | 
			
		||||
    this.maxWidth,
 | 
			
		||||
    this.onChanged,
 | 
			
		||||
    this.onDeleted,
 | 
			
		||||
    this.onSelectAnswer,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  void _onChanged(SnPost data) {
 | 
			
		||||
@@ -142,10 +206,12 @@ class PostItem extends StatelessWidget {
 | 
			
		||||
              isRelativeDate: !showFullPost,
 | 
			
		||||
              onShare: () => _doShare(context),
 | 
			
		||||
              onShareImage: () => _doShareViaPicture(context),
 | 
			
		||||
              onSelectAnswer: onSelectAnswer,
 | 
			
		||||
              onDeleted: () {
 | 
			
		||||
                if (onDeleted != null) {}
 | 
			
		||||
              },
 | 
			
		||||
            ).padding(horizontal: 12, top: 8, bottom: 8),
 | 
			
		||||
            if (data.preload?.video != null) _PostVideoPlayer(data: data).padding(horizontal: 12, bottom: 8),
 | 
			
		||||
            Container(
 | 
			
		||||
              width: double.infinity,
 | 
			
		||||
              margin: const EdgeInsets.only(bottom: 4, left: 12, right: 12),
 | 
			
		||||
@@ -189,6 +255,7 @@ class PostItem extends StatelessWidget {
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
            Text('postArticle').tr().fontSize(13).opacity(0.75).padding(horizontal: 24, bottom: 8),
 | 
			
		||||
            _PostFeaturedComment(data: data, maxWidth: maxWidth).padding(horizontal: 12),
 | 
			
		||||
            _PostBottomAction(
 | 
			
		||||
              data: data,
 | 
			
		||||
              showComments: showComments,
 | 
			
		||||
@@ -223,10 +290,13 @@ class PostItem extends StatelessWidget {
 | 
			
		||||
                showMenu: showMenu,
 | 
			
		||||
                onShare: () => _doShare(context),
 | 
			
		||||
                onShareImage: () => _doShareViaPicture(context),
 | 
			
		||||
                onSelectAnswer: onSelectAnswer,
 | 
			
		||||
                onDeleted: () {
 | 
			
		||||
                  if (onDeleted != null) onDeleted!();
 | 
			
		||||
                },
 | 
			
		||||
              ).padding(horizontal: 12, vertical: 8),
 | 
			
		||||
              if (data.preload?.video != null) _PostVideoPlayer(data: data).padding(horizontal: 12, bottom: 8),
 | 
			
		||||
              if (data.type == 'question') _PostQuestionHint(data: data).padding(horizontal: 16, bottom: 8),
 | 
			
		||||
              if (data.body['title'] != null || data.body['description'] != null)
 | 
			
		||||
                _PostHeadline(
 | 
			
		||||
                  data: data,
 | 
			
		||||
@@ -270,6 +340,7 @@ class PostItem extends StatelessWidget {
 | 
			
		||||
          LinkPreviewWidget(
 | 
			
		||||
            text: data.body['content'],
 | 
			
		||||
          ).padding(horizontal: 4),
 | 
			
		||||
        _PostFeaturedComment(data: data, maxWidth: maxWidth).padding(horizontal: 12),
 | 
			
		||||
        Container(
 | 
			
		||||
          constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
 | 
			
		||||
          child: Column(
 | 
			
		||||
@@ -331,6 +402,7 @@ class PostShareImageWidget extends StatelessWidget {
 | 
			
		||||
            showMenu: false,
 | 
			
		||||
            isRelativeDate: false,
 | 
			
		||||
          ).padding(horizontal: 16, bottom: 8),
 | 
			
		||||
          if (data.type == 'question') _PostQuestionHint(data: data).padding(horizontal: 16, bottom: 8),
 | 
			
		||||
          _PostHeadline(
 | 
			
		||||
            data: data,
 | 
			
		||||
            isEnlarge: data.type == 'article',
 | 
			
		||||
@@ -436,6 +508,30 @@ class PostShareImageWidget extends StatelessWidget {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostQuestionHint extends StatelessWidget {
 | 
			
		||||
  final SnPost data;
 | 
			
		||||
 | 
			
		||||
  const _PostQuestionHint({required this.data});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Row(
 | 
			
		||||
      children: [
 | 
			
		||||
        Icon(data.body['answer'] == null ? Symbols.help : Symbols.check_circle, size: 20),
 | 
			
		||||
        const Gap(4),
 | 
			
		||||
        if (data.body['answer'] == null && data.body['reward']?.toDouble() != null)
 | 
			
		||||
          Text('postQuestionUnansweredWithReward'.tr(args: [
 | 
			
		||||
            '${data.body['reward']}',
 | 
			
		||||
          ])).opacity(0.75)
 | 
			
		||||
        else if (data.body['answer'] == null)
 | 
			
		||||
          Text('postQuestionUnanswered'.tr()).opacity(0.75)
 | 
			
		||||
        else
 | 
			
		||||
          Text('postQuestionAnswered'.tr()).opacity(0.75),
 | 
			
		||||
      ],
 | 
			
		||||
    ).opacity(0.75);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostBottomAction extends StatelessWidget {
 | 
			
		||||
  final SnPost data;
 | 
			
		||||
  final bool showComments;
 | 
			
		||||
@@ -527,7 +623,7 @@ class _PostBottomAction extends StatelessWidget {
 | 
			
		||||
                      context: context,
 | 
			
		||||
                      useRootNavigator: true,
 | 
			
		||||
                      builder: (context) => PostCommentListPopup(
 | 
			
		||||
                        postId: data.id,
 | 
			
		||||
                        post: data,
 | 
			
		||||
                        commentCount: data.metric.replyCount,
 | 
			
		||||
                      ),
 | 
			
		||||
                    );
 | 
			
		||||
@@ -650,6 +746,7 @@ class _PostContentHeader extends StatelessWidget {
 | 
			
		||||
  final bool showMenu;
 | 
			
		||||
  final Function onDeleted;
 | 
			
		||||
  final Function() onShare, onShareImage;
 | 
			
		||||
  final Function()? onSelectAnswer;
 | 
			
		||||
 | 
			
		||||
  const _PostContentHeader({
 | 
			
		||||
    required this.data,
 | 
			
		||||
@@ -660,6 +757,7 @@ class _PostContentHeader extends StatelessWidget {
 | 
			
		||||
    required this.onDeleted,
 | 
			
		||||
    required this.onShare,
 | 
			
		||||
    required this.onShareImage,
 | 
			
		||||
    this.onSelectAnswer,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  Future<void> _deletePost(BuildContext context) async {
 | 
			
		||||
@@ -758,6 +856,20 @@ class _PostContentHeader extends StatelessWidget {
 | 
			
		||||
              visualDensity: VisualDensity(horizontal: -4, vertical: -4),
 | 
			
		||||
            ),
 | 
			
		||||
            itemBuilder: (BuildContext context) => <PopupMenuEntry>[
 | 
			
		||||
              if (isAuthor && onSelectAnswer != null)
 | 
			
		||||
                PopupMenuItem(
 | 
			
		||||
                  child: Row(
 | 
			
		||||
                    children: [
 | 
			
		||||
                      const Icon(Symbols.check_circle),
 | 
			
		||||
                      const Gap(16),
 | 
			
		||||
                      Text('postQuestionAnswerSelect').tr(),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTap: () {
 | 
			
		||||
                    onSelectAnswer?.call();
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              if (isAuthor && onSelectAnswer != null) PopupMenuDivider(),
 | 
			
		||||
              if (isAuthor)
 | 
			
		||||
                PopupMenuItem(
 | 
			
		||||
                  child: Row(
 | 
			
		||||
@@ -831,7 +943,7 @@ class _PostContentHeader extends StatelessWidget {
 | 
			
		||||
                onTap: () {
 | 
			
		||||
                  showModalBottomSheet(
 | 
			
		||||
                    context: context,
 | 
			
		||||
                    builder: (context) => _PostGetInsightSheet(postId: data.id),
 | 
			
		||||
                    builder: (context) => _PostGetInsightPopup(postId: data.id),
 | 
			
		||||
                  );
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
@@ -1125,6 +1237,118 @@ class _PostTruncatedHint extends StatelessWidget {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostFeaturedComment extends StatefulWidget {
 | 
			
		||||
  final SnPost data;
 | 
			
		||||
  final double? maxWidth;
 | 
			
		||||
 | 
			
		||||
  const _PostFeaturedComment({required this.data, this.maxWidth});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_PostFeaturedComment> createState() => _PostFeaturedCommentState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostFeaturedCommentState extends State<_PostFeaturedComment> {
 | 
			
		||||
  SnPost? _featuredComment;
 | 
			
		||||
  bool _isAnswer = false;
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchComments() async {
 | 
			
		||||
    // If this is a answered question, fetch the answer instead
 | 
			
		||||
    if (widget.data.type == 'question' && widget.data.body['answer'] != null) {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/co/posts/${widget.data.body['answer']}');
 | 
			
		||||
      _isAnswer = true;
 | 
			
		||||
      setState(() => _featuredComment = SnPost.fromJson(resp.data));
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/co/posts/${widget.data.id}/replies/featured', queryParameters: {
 | 
			
		||||
        'take': 1,
 | 
			
		||||
      });
 | 
			
		||||
      setState(() => _featuredComment = SnPost.fromJson(resp.data[0]));
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    if (widget.data.metric.replyCount > 0) {
 | 
			
		||||
      _fetchComments();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    if (widget.data.metric.replyCount == 0) return const SizedBox.shrink();
 | 
			
		||||
    if (_featuredComment == null) return const SizedBox.shrink();
 | 
			
		||||
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
 | 
			
		||||
    return AnimateWidgetExtensions(Container(
 | 
			
		||||
      constraints: BoxConstraints(maxWidth: widget.maxWidth ?? double.infinity),
 | 
			
		||||
      margin: const EdgeInsets.only(top: 8),
 | 
			
		||||
      width: double.infinity,
 | 
			
		||||
      child: Material(
 | 
			
		||||
        borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
        color: _isAnswer ? Colors.green.withOpacity(0.5) : Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
			
		||||
        child: InkWell(
 | 
			
		||||
          borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            showModalBottomSheet(
 | 
			
		||||
              context: context,
 | 
			
		||||
              useRootNavigator: true,
 | 
			
		||||
              builder: (context) => PostCommentListPopup(
 | 
			
		||||
                post: widget.data,
 | 
			
		||||
                commentCount: widget.data.metric.replyCount,
 | 
			
		||||
              ),
 | 
			
		||||
            );
 | 
			
		||||
          },
 | 
			
		||||
          child: Column(
 | 
			
		||||
            crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
            children: [
 | 
			
		||||
              Row(
 | 
			
		||||
                crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                children: [
 | 
			
		||||
                  const Gap(2),
 | 
			
		||||
                  Icon(_isAnswer ? Symbols.task_alt : Symbols.prompt_suggestion, size: 20),
 | 
			
		||||
                  const Gap(10),
 | 
			
		||||
                  Text(
 | 
			
		||||
                    _isAnswer ? 'postQuestionAnswerTitle' : 'postFeaturedComment',
 | 
			
		||||
                    style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 15),
 | 
			
		||||
                  ).tr(),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
              const Gap(4),
 | 
			
		||||
              Row(
 | 
			
		||||
                crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                children: [
 | 
			
		||||
                  CircleAvatar(
 | 
			
		||||
                    radius: 12,
 | 
			
		||||
                    backgroundImage: UniversalImage.provider(
 | 
			
		||||
                      sn.getAttachmentUrl(_featuredComment!.publisher.avatar),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                  const Gap(8),
 | 
			
		||||
                  Expanded(
 | 
			
		||||
                    child: MarkdownTextContent(
 | 
			
		||||
                      content: _featuredComment!.body['content'],
 | 
			
		||||
                      isAutoWarp: true,
 | 
			
		||||
                    ),
 | 
			
		||||
                  )
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ).padding(horizontal: 16, vertical: 8),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    )).animate().fadeIn(duration: 300.ms, curve: Curves.easeInOut);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostAbuseReportDialog extends StatefulWidget {
 | 
			
		||||
  final SnPost data;
 | 
			
		||||
 | 
			
		||||
@@ -1201,16 +1425,16 @@ class _PostAbuseReportDialogState extends State<_PostAbuseReportDialog> {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostGetInsightSheet extends StatefulWidget {
 | 
			
		||||
class _PostGetInsightPopup extends StatefulWidget {
 | 
			
		||||
  final int postId;
 | 
			
		||||
 | 
			
		||||
  const _PostGetInsightSheet({required this.postId});
 | 
			
		||||
  const _PostGetInsightPopup({required this.postId});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_PostGetInsightSheet> createState() => _PostGetInsightSheetState();
 | 
			
		||||
  State<_PostGetInsightPopup> createState() => _PostGetInsightPopupState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostGetInsightSheetState extends State<_PostGetInsightSheet> {
 | 
			
		||||
class _PostGetInsightPopupState extends State<_PostGetInsightPopup> {
 | 
			
		||||
  String? _response;
 | 
			
		||||
  String? _thinkingProcess;
 | 
			
		||||
 | 
			
		||||
@@ -1223,8 +1447,14 @@ class _PostGetInsightSheetState extends State<_PostGetInsightSheet> {
 | 
			
		||||
            receiveTimeout: const Duration(minutes: 10),
 | 
			
		||||
          ));
 | 
			
		||||
      final out = resp.data['response'] as String;
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        final document = XmlDocument.parse(out);
 | 
			
		||||
        _thinkingProcess = document.getElement('think')?.innerText.trim();
 | 
			
		||||
      } catch (_) {
 | 
			
		||||
        // ignore
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      RegExp cleanThinkingRegExp = RegExp(r'<think>[\s\S]*?</think>');
 | 
			
		||||
      setState(() => _response = out.replaceAll(cleanThinkingRegExp, '').trim());
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
@@ -1293,3 +1523,29 @@ class _PostGetInsightSheetState extends State<_PostGetInsightSheet> {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostVideoPlayer extends StatelessWidget {
 | 
			
		||||
  final SnPost data;
 | 
			
		||||
 | 
			
		||||
  const _PostVideoPlayer({required this.data});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Container(
 | 
			
		||||
      decoration: BoxDecoration(
 | 
			
		||||
        borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
        border: Border.all(
 | 
			
		||||
          color: Theme.of(context).dividerColor,
 | 
			
		||||
          width: 1,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      child: AspectRatio(
 | 
			
		||||
        aspectRatio: 16 / 9,
 | 
			
		||||
        child: ClipRRect(
 | 
			
		||||
          borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
          child: AttachmentItem(data: data.preload!.video!, heroTag: 'post-video-${data.id}'),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -95,6 +95,7 @@ class PostMediaPendingList extends StatelessWidget {
 | 
			
		||||
      context: context,
 | 
			
		||||
      builder: (context) => AttachmentInputDialog(
 | 
			
		||||
        title: 'attachmentSetThumbnail'.tr(),
 | 
			
		||||
        pool: 'interactive',
 | 
			
		||||
        analyzeNow: true,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
@@ -292,7 +293,7 @@ class PostMediaPendingList extends StatelessWidget {
 | 
			
		||||
      constraints: const BoxConstraints(maxHeight: 120),
 | 
			
		||||
      child: Row(
 | 
			
		||||
        children: [
 | 
			
		||||
          const Gap(8),
 | 
			
		||||
          const Gap(16),
 | 
			
		||||
          if (thumbnail != null)
 | 
			
		||||
            ContextMenuArea(
 | 
			
		||||
              contextMenu: _createContextMenu(context, -1, thumbnail!),
 | 
			
		||||
@@ -337,15 +338,10 @@ class _PostMediaPendingItem extends StatelessWidget {
 | 
			
		||||
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
 | 
			
		||||
    return Container(
 | 
			
		||||
      decoration: BoxDecoration(
 | 
			
		||||
        border: Border.all(
 | 
			
		||||
          color: Theme.of(context).dividerColor,
 | 
			
		||||
          width: 1,
 | 
			
		||||
        ),
 | 
			
		||||
        borderRadius: BorderRadius.circular(8),
 | 
			
		||||
    return Material(
 | 
			
		||||
      elevation: 4,
 | 
			
		||||
      color: Theme.of(context).colorScheme.surfaceContainer,
 | 
			
		||||
      ),
 | 
			
		||||
      borderRadius: BorderRadius.circular(8),
 | 
			
		||||
      child: ClipRRect(
 | 
			
		||||
        borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
        child: Row(
 | 
			
		||||
 
 | 
			
		||||
@@ -19,6 +19,7 @@ const Map<int, String> kPostVisibilityLevel = {
 | 
			
		||||
 | 
			
		||||
class PostMetaEditor extends StatelessWidget {
 | 
			
		||||
  final PostWriteController controller;
 | 
			
		||||
 | 
			
		||||
  const PostMetaEditor({super.key, required this.controller});
 | 
			
		||||
 | 
			
		||||
  Future<DateTime?> _selectDate(
 | 
			
		||||
@@ -87,28 +88,6 @@ class PostMetaEditor extends StatelessWidget {
 | 
			
		||||
          padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
 | 
			
		||||
          child: Column(
 | 
			
		||||
            children: [
 | 
			
		||||
              TextField(
 | 
			
		||||
                controller: controller.titleController,
 | 
			
		||||
                decoration: InputDecoration(
 | 
			
		||||
                  labelText: 'fieldPostTitle'.tr(),
 | 
			
		||||
                  border: UnderlineInputBorder(),
 | 
			
		||||
                ),
 | 
			
		||||
                onTapOutside: (_) =>
 | 
			
		||||
                    FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
              ).padding(horizontal: 24),
 | 
			
		||||
              if (controller.mode == 'articles') const Gap(4),
 | 
			
		||||
              if (controller.mode == 'articles')
 | 
			
		||||
                TextField(
 | 
			
		||||
                  controller: controller.descriptionController,
 | 
			
		||||
                  maxLines: null,
 | 
			
		||||
                  decoration: InputDecoration(
 | 
			
		||||
                    labelText: 'fieldPostDescription'.tr(),
 | 
			
		||||
                    border: UnderlineInputBorder(),
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) =>
 | 
			
		||||
                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ).padding(horizontal: 24),
 | 
			
		||||
              const Gap(4),
 | 
			
		||||
              PostTagsField(
 | 
			
		||||
                initialTags: controller.tags,
 | 
			
		||||
                labelText: 'fieldPostTags'.tr(),
 | 
			
		||||
@@ -133,8 +112,7 @@ class PostMetaEditor extends StatelessWidget {
 | 
			
		||||
                  helperMaxLines: 2,
 | 
			
		||||
                  border: UnderlineInputBorder(),
 | 
			
		||||
                ),
 | 
			
		||||
                onTapOutside: (_) =>
 | 
			
		||||
                    FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
              ).padding(horizontal: 24),
 | 
			
		||||
              const Gap(12),
 | 
			
		||||
              ListTile(
 | 
			
		||||
@@ -182,8 +160,7 @@ class PostMetaEditor extends StatelessWidget {
 | 
			
		||||
                  leading: Icon(Symbols.person),
 | 
			
		||||
                  trailing: Icon(Symbols.chevron_right),
 | 
			
		||||
                  title: Text('postVisibleUsers').tr(),
 | 
			
		||||
                  subtitle: Text('postSelectedUsers')
 | 
			
		||||
                      .plural(controller.visibleUsers.length),
 | 
			
		||||
                  subtitle: Text('postSelectedUsers').plural(controller.visibleUsers.length),
 | 
			
		||||
                  onTap: () {
 | 
			
		||||
                    _selectVisibleUser(context);
 | 
			
		||||
                  },
 | 
			
		||||
@@ -194,8 +171,7 @@ class PostMetaEditor extends StatelessWidget {
 | 
			
		||||
                  leading: Icon(Symbols.person),
 | 
			
		||||
                  trailing: Icon(Symbols.chevron_right),
 | 
			
		||||
                  title: Text('postInvisibleUsers').tr(),
 | 
			
		||||
                  subtitle: Text('postSelectedUsers')
 | 
			
		||||
                      .plural(controller.invisibleUsers.length),
 | 
			
		||||
                  subtitle: Text('postSelectedUsers').plural(controller.invisibleUsers.length),
 | 
			
		||||
                  onTap: () {
 | 
			
		||||
                    _selectInvisibleUser(context);
 | 
			
		||||
                  },
 | 
			
		||||
@@ -204,9 +180,7 @@ class PostMetaEditor extends StatelessWidget {
 | 
			
		||||
                leading: const Icon(Symbols.event_available),
 | 
			
		||||
                title: Text('postPublishedAt').tr(),
 | 
			
		||||
                subtitle: Text(
 | 
			
		||||
                  controller.publishedAt != null
 | 
			
		||||
                      ? dateFormatter.format(controller.publishedAt!)
 | 
			
		||||
                      : 'unset'.tr(),
 | 
			
		||||
                  controller.publishedAt != null ? dateFormatter.format(controller.publishedAt!) : 'unset'.tr(),
 | 
			
		||||
                ),
 | 
			
		||||
                trailing: controller.publishedAt != null
 | 
			
		||||
                    ? IconButton(
 | 
			
		||||
@@ -230,9 +204,7 @@ class PostMetaEditor extends StatelessWidget {
 | 
			
		||||
                leading: const Icon(Symbols.event_busy),
 | 
			
		||||
                title: Text('postPublishedUntil').tr(),
 | 
			
		||||
                subtitle: Text(
 | 
			
		||||
                  controller.publishedUntil != null
 | 
			
		||||
                      ? dateFormatter.format(controller.publishedUntil!)
 | 
			
		||||
                      : 'unset'.tr(),
 | 
			
		||||
                  controller.publishedUntil != null ? dateFormatter.format(controller.publishedUntil!) : 'unset'.tr(),
 | 
			
		||||
                ),
 | 
			
		||||
                trailing: controller.publishedUntil != null
 | 
			
		||||
                    ? IconButton(
 | 
			
		||||
 
 | 
			
		||||
@@ -61,7 +61,7 @@ class _PostReactionPopupState extends State<PostReactionPopup> {
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      HapticFeedback.mediumImpact();
 | 
			
		||||
      HapticFeedback.heavyImpact();
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      // ignore: use_build_context_synchronously
 | 
			
		||||
      if (context.mounted) context.showErrorDialog(err);
 | 
			
		||||
 
 | 
			
		||||
@@ -11,9 +11,11 @@
 | 
			
		||||
#include <file_selector_linux/file_selector_plugin.h>
 | 
			
		||||
#include <flutter_udid/flutter_udid_plugin.h>
 | 
			
		||||
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
 | 
			
		||||
#include <hotkey_manager_linux/hotkey_manager_linux_plugin.h>
 | 
			
		||||
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
 | 
			
		||||
#include <media_kit_video/media_kit_video_plugin.h>
 | 
			
		||||
#include <pasteboard/pasteboard_plugin.h>
 | 
			
		||||
#include <tray_manager/tray_manager_plugin.h>
 | 
			
		||||
#include <url_launcher_linux/url_launcher_plugin.h>
 | 
			
		||||
 | 
			
		||||
void fl_register_plugins(FlPluginRegistry* registry) {
 | 
			
		||||
@@ -32,6 +34,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
 | 
			
		||||
  g_autoptr(FlPluginRegistrar) flutter_webrtc_registrar =
 | 
			
		||||
      fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin");
 | 
			
		||||
  flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_registrar);
 | 
			
		||||
  g_autoptr(FlPluginRegistrar) hotkey_manager_linux_registrar =
 | 
			
		||||
      fl_plugin_registry_get_registrar_for_plugin(registry, "HotkeyManagerLinuxPlugin");
 | 
			
		||||
  hotkey_manager_linux_plugin_register_with_registrar(hotkey_manager_linux_registrar);
 | 
			
		||||
  g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
 | 
			
		||||
      fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
 | 
			
		||||
  media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar);
 | 
			
		||||
@@ -41,6 +46,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
 | 
			
		||||
  g_autoptr(FlPluginRegistrar) pasteboard_registrar =
 | 
			
		||||
      fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin");
 | 
			
		||||
  pasteboard_plugin_register_with_registrar(pasteboard_registrar);
 | 
			
		||||
  g_autoptr(FlPluginRegistrar) tray_manager_registrar =
 | 
			
		||||
      fl_plugin_registry_get_registrar_for_plugin(registry, "TrayManagerPlugin");
 | 
			
		||||
  tray_manager_plugin_register_with_registrar(tray_manager_registrar);
 | 
			
		||||
  g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
 | 
			
		||||
      fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
 | 
			
		||||
  url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
 | 
			
		||||
 
 | 
			
		||||
@@ -8,9 +8,11 @@ list(APPEND FLUTTER_PLUGIN_LIST
 | 
			
		||||
  file_selector_linux
 | 
			
		||||
  flutter_udid
 | 
			
		||||
  flutter_webrtc
 | 
			
		||||
  hotkey_manager_linux
 | 
			
		||||
  media_kit_libs_linux
 | 
			
		||||
  media_kit_video
 | 
			
		||||
  pasteboard
 | 
			
		||||
  tray_manager
 | 
			
		||||
  url_launcher_linux
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -18,6 +18,7 @@ import flutter_inappwebview_macos
 | 
			
		||||
import flutter_udid
 | 
			
		||||
import flutter_webrtc
 | 
			
		||||
import gal
 | 
			
		||||
import hotkey_manager_macos
 | 
			
		||||
import in_app_review
 | 
			
		||||
import livekit_client
 | 
			
		||||
import media_kit_libs_macos_video
 | 
			
		||||
@@ -29,6 +30,7 @@ import screen_brightness_macos
 | 
			
		||||
import share_plus
 | 
			
		||||
import shared_preferences_foundation
 | 
			
		||||
import sqflite_darwin
 | 
			
		||||
import tray_manager
 | 
			
		||||
import url_launcher_macos
 | 
			
		||||
import video_compress
 | 
			
		||||
import wakelock_plus
 | 
			
		||||
@@ -47,6 +49,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
 | 
			
		||||
  FlutterUdidPlugin.register(with: registry.registrar(forPlugin: "FlutterUdidPlugin"))
 | 
			
		||||
  FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
 | 
			
		||||
  GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
 | 
			
		||||
  HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin"))
 | 
			
		||||
  InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin"))
 | 
			
		||||
  LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin"))
 | 
			
		||||
  MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
 | 
			
		||||
@@ -58,6 +61,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
 | 
			
		||||
  SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
 | 
			
		||||
  SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
 | 
			
		||||
  SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
 | 
			
		||||
  TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin"))
 | 
			
		||||
  UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
 | 
			
		||||
  VideoCompressPlugin.register(with: registry.registrar(forPlugin: "VideoCompressPlugin"))
 | 
			
		||||
  WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,6 @@ PODS:
 | 
			
		||||
  - bitsdojo_window_macos (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - connectivity_plus (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - croppy (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
@@ -14,59 +13,59 @@ PODS:
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - file_selector_macos (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - Firebase/Analytics (11.6.0):
 | 
			
		||||
  - Firebase/Analytics (11.7.0):
 | 
			
		||||
    - Firebase/Core
 | 
			
		||||
  - Firebase/Core (11.6.0):
 | 
			
		||||
  - Firebase/Core (11.7.0):
 | 
			
		||||
    - Firebase/CoreOnly
 | 
			
		||||
    - FirebaseAnalytics (~> 11.6.0)
 | 
			
		||||
  - Firebase/CoreOnly (11.6.0):
 | 
			
		||||
    - FirebaseCore (~> 11.6.0)
 | 
			
		||||
  - Firebase/Messaging (11.6.0):
 | 
			
		||||
    - FirebaseAnalytics (~> 11.7.0)
 | 
			
		||||
  - Firebase/CoreOnly (11.7.0):
 | 
			
		||||
    - FirebaseCore (~> 11.7.0)
 | 
			
		||||
  - Firebase/Messaging (11.7.0):
 | 
			
		||||
    - Firebase/CoreOnly
 | 
			
		||||
    - FirebaseMessaging (~> 11.6.0)
 | 
			
		||||
  - firebase_analytics (11.4.1):
 | 
			
		||||
    - Firebase/Analytics (= 11.6.0)
 | 
			
		||||
    - FirebaseMessaging (~> 11.7.0)
 | 
			
		||||
  - firebase_analytics (11.4.2):
 | 
			
		||||
    - Firebase/Analytics (= 11.7.0)
 | 
			
		||||
    - firebase_core
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - firebase_core (3.10.1):
 | 
			
		||||
    - Firebase/CoreOnly (~> 11.6.0)
 | 
			
		||||
  - firebase_core (3.11.0):
 | 
			
		||||
    - Firebase/CoreOnly (~> 11.7.0)
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - firebase_messaging (15.2.1):
 | 
			
		||||
    - Firebase/CoreOnly (~> 11.6.0)
 | 
			
		||||
    - Firebase/Messaging (~> 11.6.0)
 | 
			
		||||
  - firebase_messaging (15.2.2):
 | 
			
		||||
    - Firebase/CoreOnly (~> 11.7.0)
 | 
			
		||||
    - Firebase/Messaging (~> 11.7.0)
 | 
			
		||||
    - firebase_core
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - FirebaseAnalytics (11.6.0):
 | 
			
		||||
    - FirebaseAnalytics/AdIdSupport (= 11.6.0)
 | 
			
		||||
    - FirebaseCore (~> 11.6.0)
 | 
			
		||||
  - FirebaseAnalytics (11.7.0):
 | 
			
		||||
    - FirebaseAnalytics/AdIdSupport (= 11.7.0)
 | 
			
		||||
    - FirebaseCore (~> 11.7.0)
 | 
			
		||||
    - FirebaseInstallations (~> 11.0)
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/Network (~> 8.0)
 | 
			
		||||
    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
			
		||||
    - nanopb (~> 3.30910.0)
 | 
			
		||||
  - FirebaseAnalytics/AdIdSupport (11.6.0):
 | 
			
		||||
    - FirebaseCore (~> 11.6.0)
 | 
			
		||||
  - FirebaseAnalytics/AdIdSupport (11.7.0):
 | 
			
		||||
    - FirebaseCore (~> 11.7.0)
 | 
			
		||||
    - FirebaseInstallations (~> 11.0)
 | 
			
		||||
    - GoogleAppMeasurement (= 11.6.0)
 | 
			
		||||
    - GoogleAppMeasurement (= 11.7.0)
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/Network (~> 8.0)
 | 
			
		||||
    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
			
		||||
    - nanopb (~> 3.30910.0)
 | 
			
		||||
  - FirebaseCore (11.6.0):
 | 
			
		||||
    - FirebaseCoreInternal (~> 11.6.0)
 | 
			
		||||
  - FirebaseCore (11.7.0):
 | 
			
		||||
    - FirebaseCoreInternal (~> 11.7.0)
 | 
			
		||||
    - GoogleUtilities/Environment (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/Logger (~> 8.0)
 | 
			
		||||
  - FirebaseCoreInternal (11.6.0):
 | 
			
		||||
  - FirebaseCoreInternal (11.7.0):
 | 
			
		||||
    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
			
		||||
  - FirebaseInstallations (11.6.0):
 | 
			
		||||
    - FirebaseCore (~> 11.6.0)
 | 
			
		||||
  - FirebaseInstallations (11.7.0):
 | 
			
		||||
    - FirebaseCore (~> 11.7.0)
 | 
			
		||||
    - GoogleUtilities/Environment (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/UserDefaults (~> 8.0)
 | 
			
		||||
    - PromisesObjC (~> 2.4)
 | 
			
		||||
  - FirebaseMessaging (11.6.0):
 | 
			
		||||
    - FirebaseCore (~> 11.6.0)
 | 
			
		||||
  - FirebaseMessaging (11.7.0):
 | 
			
		||||
    - FirebaseCore (~> 11.7.0)
 | 
			
		||||
    - FirebaseInstallations (~> 11.0)
 | 
			
		||||
    - GoogleDataTransport (~> 10.0)
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
			
		||||
@@ -87,21 +86,21 @@ PODS:
 | 
			
		||||
  - gal (1.0.0):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - GoogleAppMeasurement (11.6.0):
 | 
			
		||||
    - GoogleAppMeasurement/AdIdSupport (= 11.6.0)
 | 
			
		||||
  - GoogleAppMeasurement (11.7.0):
 | 
			
		||||
    - GoogleAppMeasurement/AdIdSupport (= 11.7.0)
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/Network (~> 8.0)
 | 
			
		||||
    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
			
		||||
    - nanopb (~> 3.30910.0)
 | 
			
		||||
  - GoogleAppMeasurement/AdIdSupport (11.6.0):
 | 
			
		||||
    - GoogleAppMeasurement/WithoutAdIdSupport (= 11.6.0)
 | 
			
		||||
  - GoogleAppMeasurement/AdIdSupport (11.7.0):
 | 
			
		||||
    - GoogleAppMeasurement/WithoutAdIdSupport (= 11.7.0)
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/Network (~> 8.0)
 | 
			
		||||
    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
			
		||||
    - nanopb (~> 3.30910.0)
 | 
			
		||||
  - GoogleAppMeasurement/WithoutAdIdSupport (11.6.0):
 | 
			
		||||
  - GoogleAppMeasurement/WithoutAdIdSupport (11.7.0):
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/Network (~> 8.0)
 | 
			
		||||
@@ -137,9 +136,13 @@ PODS:
 | 
			
		||||
  - GoogleUtilities/UserDefaults (8.0.2):
 | 
			
		||||
    - GoogleUtilities/Logger
 | 
			
		||||
    - GoogleUtilities/Privacy
 | 
			
		||||
  - HotKey (0.2.1)
 | 
			
		||||
  - hotkey_manager_macos (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
    - HotKey
 | 
			
		||||
  - in_app_review (2.0.0):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - livekit_client (2.3.5):
 | 
			
		||||
  - livekit_client (2.3.6):
 | 
			
		||||
    - flutter_webrtc
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
    - WebRTC-SDK (= 125.6422.06)
 | 
			
		||||
@@ -174,6 +177,8 @@ PODS:
 | 
			
		||||
  - sqflite_darwin (0.0.4):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - tray_manager (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - url_launcher_macos (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - video_compress (0.3.0):
 | 
			
		||||
@@ -184,7 +189,7 @@ PODS:
 | 
			
		||||
 | 
			
		||||
DEPENDENCIES:
 | 
			
		||||
  - bitsdojo_window_macos (from `Flutter/ephemeral/.symlinks/plugins/bitsdojo_window_macos/macos`)
 | 
			
		||||
  - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/darwin`)
 | 
			
		||||
  - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`)
 | 
			
		||||
  - croppy (from `Flutter/ephemeral/.symlinks/plugins/croppy/macos`)
 | 
			
		||||
  - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
 | 
			
		||||
  - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
 | 
			
		||||
@@ -198,6 +203,7 @@ DEPENDENCIES:
 | 
			
		||||
  - flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`)
 | 
			
		||||
  - FlutterMacOS (from `Flutter/ephemeral`)
 | 
			
		||||
  - gal (from `Flutter/ephemeral/.symlinks/plugins/gal/darwin`)
 | 
			
		||||
  - hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
 | 
			
		||||
  - in_app_review (from `Flutter/ephemeral/.symlinks/plugins/in_app_review/macos`)
 | 
			
		||||
  - livekit_client (from `Flutter/ephemeral/.symlinks/plugins/livekit_client/macos`)
 | 
			
		||||
  - media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`)
 | 
			
		||||
@@ -210,6 +216,7 @@ DEPENDENCIES:
 | 
			
		||||
  - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
 | 
			
		||||
  - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
 | 
			
		||||
  - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
 | 
			
		||||
  - tray_manager (from `Flutter/ephemeral/.symlinks/plugins/tray_manager/macos`)
 | 
			
		||||
  - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
 | 
			
		||||
  - video_compress (from `Flutter/ephemeral/.symlinks/plugins/video_compress/macos`)
 | 
			
		||||
  - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
 | 
			
		||||
@@ -225,6 +232,7 @@ SPEC REPOS:
 | 
			
		||||
    - GoogleAppMeasurement
 | 
			
		||||
    - GoogleDataTransport
 | 
			
		||||
    - GoogleUtilities
 | 
			
		||||
    - HotKey
 | 
			
		||||
    - nanopb
 | 
			
		||||
    - OrderedSet
 | 
			
		||||
    - PromisesObjC
 | 
			
		||||
@@ -235,7 +243,7 @@ EXTERNAL SOURCES:
 | 
			
		||||
  bitsdojo_window_macos:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/bitsdojo_window_macos/macos
 | 
			
		||||
  connectivity_plus:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/darwin
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos
 | 
			
		||||
  croppy:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/croppy/macos
 | 
			
		||||
  device_info_plus:
 | 
			
		||||
@@ -262,6 +270,8 @@ EXTERNAL SOURCES:
 | 
			
		||||
    :path: Flutter/ephemeral
 | 
			
		||||
  gal:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/gal/darwin
 | 
			
		||||
  hotkey_manager_macos:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos
 | 
			
		||||
  in_app_review:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/in_app_review/macos
 | 
			
		||||
  livekit_client:
 | 
			
		||||
@@ -286,6 +296,8 @@ EXTERNAL SOURCES:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
 | 
			
		||||
  sqflite_darwin:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
 | 
			
		||||
  tray_manager:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/tray_manager/macos
 | 
			
		||||
  url_launcher_macos:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
 | 
			
		||||
  video_compress:
 | 
			
		||||
@@ -295,31 +307,33 @@ EXTERNAL SOURCES:
 | 
			
		||||
 | 
			
		||||
SPEC CHECKSUMS:
 | 
			
		||||
  bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00
 | 
			
		||||
  connectivity_plus: 18382e7311ba19efcaee94442b23b32507b20695
 | 
			
		||||
  connectivity_plus: 0a976dfd033b59192912fa3c6c7b54aab5093802
 | 
			
		||||
  croppy: 25a638bd7d05411d8c697f481568f261037694fc
 | 
			
		||||
  device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215
 | 
			
		||||
  file_picker: e716a70a9fe5fd9e09ebc922d7541464289443af
 | 
			
		||||
  file_saver: 44e6fbf666677faf097302460e214e977fdd977b
 | 
			
		||||
  file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
 | 
			
		||||
  Firebase: 374a441a91ead896215703a674d58cdb3e9d772b
 | 
			
		||||
  firebase_analytics: 91efc58e8e37964469fdd59ad11ba36bc97e75d6
 | 
			
		||||
  firebase_core: 75e003524565fb5bd80c9960bc5892e8475821cd
 | 
			
		||||
  firebase_messaging: 082a385eb98b5bb843a566cb30404859c4bd6e25
 | 
			
		||||
  FirebaseAnalytics: 7114c698cac995602e3b1b96663473e50d54d6e7
 | 
			
		||||
  FirebaseCore: 48b0dd707581cf9c1a1220da68223fb0a562afaa
 | 
			
		||||
  FirebaseCoreInternal: d98ab91e2d80a56d7b246856a8885443b302c0c2
 | 
			
		||||
  FirebaseInstallations: efc0946fc756e4d22d8113f7c761948120322e8c
 | 
			
		||||
  FirebaseMessaging: e1aca1fcc23e8b9eddb0e33f375ff90944623021
 | 
			
		||||
  Firebase: a64bf6a8546e6eab54f1c715cd6151f39d2329f4
 | 
			
		||||
  firebase_analytics: 41d88c024a7756462a803e36236ba74f24cdc2c5
 | 
			
		||||
  firebase_core: 751d3d919b95d4ae46ab049d0d64d42d4eec086b
 | 
			
		||||
  firebase_messaging: cc174f19945e9541e140e3cb0118448e59b38c6c
 | 
			
		||||
  FirebaseAnalytics: bc9e565af9044ba8d6c6e4157e4edca8e5fdf7ec
 | 
			
		||||
  FirebaseCore: 3227e35f4197a924206fbcdc0349325baf4f5de4
 | 
			
		||||
  FirebaseCoreInternal: d6c17dafc8dc33614733a8b52df78fcb4394c881
 | 
			
		||||
  FirebaseInstallations: 9347e719c3d52d8d7b9074b2c32407dd027305e9
 | 
			
		||||
  FirebaseMessaging: 00ece041b71ddb52a2862ffdee73fb6e9824bd0c
 | 
			
		||||
  flutter_inappwebview_macos: bdf207b8f4ebd58e86ae06cd96b147de99a67c9b
 | 
			
		||||
  flutter_udid: 2e7b3da4b5fdfba86a396b97898f5fe8f4ec1a52
 | 
			
		||||
  flutter_webrtc: d55fd3f5c75b42940b6b4b2cf376a5797398d1f8
 | 
			
		||||
  FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
 | 
			
		||||
  gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
 | 
			
		||||
  GoogleAppMeasurement: 6a9e6317b6a6d810ad03d4a66564ca6c4c5818a3
 | 
			
		||||
  GoogleAppMeasurement: 0471a5b5bff51f3a91b1e76df22c952d04c63967
 | 
			
		||||
  GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
 | 
			
		||||
  GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
 | 
			
		||||
  HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
 | 
			
		||||
  hotkey_manager_macos: 1e2edb0c7ae4fe67108af44a9d3445de41404160
 | 
			
		||||
  in_app_review: a6a031b9acd03c7d103e341aa334adf2c493fb93
 | 
			
		||||
  livekit_client: 91c68237edede55f8891a166a28c1daec8a1e4b1
 | 
			
		||||
  livekit_client: 0ad107154753a5a76802d2222c040223ad049499
 | 
			
		||||
  media_kit_libs_macos_video: b3e2bbec2eef97c285f2b1baa7963c67c753fb82
 | 
			
		||||
  media_kit_native_event_loop: 81fd5b45192b72f8b5b69eaf5b540f45777eb8d5
 | 
			
		||||
  media_kit_video: c75b07f14d59706c775778e4dd47dd027de8d1e5
 | 
			
		||||
@@ -334,6 +348,7 @@ SPEC CHECKSUMS:
 | 
			
		||||
  share_plus: 1fa619de8392a4398bfaf176d441853922614e89
 | 
			
		||||
  shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
 | 
			
		||||
  sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
 | 
			
		||||
  tray_manager: 9064e219c56d75c476e46b9a21182087930baf90
 | 
			
		||||
  url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
 | 
			
		||||
  video_compress: c896234f100791b5fef7f049afa38f6d2ef7b42f
 | 
			
		||||
  wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										168
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										168
									
								
								pubspec.lock
									
									
									
									
									
								
							@@ -13,10 +13,10 @@ packages:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: _flutterfire_internals
 | 
			
		||||
      sha256: e4f2a7ef31b0ab2c89d2bde35ef3e6e6aff1dce5e66069c6540b0e9cfe33ee6b
 | 
			
		||||
      sha256: e051259913915ea5bc8fe18664596bea08592fd123930605d562969cd7315fcd
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "1.3.50"
 | 
			
		||||
    version: "1.3.51"
 | 
			
		||||
  _macros:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description: dart
 | 
			
		||||
@@ -214,6 +214,14 @@ packages:
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.4.3"
 | 
			
		||||
  chalkdart:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: chalkdart
 | 
			
		||||
      sha256: "08c910ee341fcdd1e46f20ddce59b13c1d85f5d97f2fd2f12014c46ede670e40"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.3.2"
 | 
			
		||||
  characters:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -266,10 +274,10 @@ packages:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: connectivity_plus
 | 
			
		||||
      sha256: "8a68739d3ee113e51ad35583fdf9ab82c55d09d693d3c39da1aebab87c938412"
 | 
			
		||||
      sha256: "04bf81bb0b77de31557b58d052b24b3eee33f09a6e7a8c68a3e247c7df19ec27"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "6.1.2"
 | 
			
		||||
    version: "6.1.3"
 | 
			
		||||
  connectivity_plus_platform_interface:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -362,10 +370,10 @@ packages:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: device_info_plus
 | 
			
		||||
      sha256: e3fc9a65820fef83035af8ee8c09004a719d5d1d54e6de978fcb0d84bbeb241a
 | 
			
		||||
      sha256: "72d146c6d7098689ff5c5f66bcf593ac11efc530095385356e131070333e64da"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "11.2.2"
 | 
			
		||||
    version: "11.3.0"
 | 
			
		||||
  device_info_plus_platform_interface:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -490,10 +498,10 @@ packages:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: file_picker
 | 
			
		||||
      sha256: c9943dd7d702ab4199d199bc151a2d79c86db031a02ad84566dab58c494d2adc
 | 
			
		||||
      sha256: cacfdc5abe93e64d418caa9256eef663499ad791bb688d9fd12c85a311968fba
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "8.3.1"
 | 
			
		||||
    version: "8.3.2"
 | 
			
		||||
  file_saver:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
@@ -538,34 +546,34 @@ packages:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: firebase_analytics
 | 
			
		||||
      sha256: eac382bbcd5ae78c1d1ce5619d13f5a7424429f4bf55df9e3ad5110da34d1060
 | 
			
		||||
      sha256: "47428047a0778f72af53a3c7cb5d556e1cb25e2327cc8aa40d544971dc6245b2"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "11.4.1"
 | 
			
		||||
    version: "11.4.2"
 | 
			
		||||
  firebase_analytics_platform_interface:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: firebase_analytics_platform_interface
 | 
			
		||||
      sha256: a34db46c367265c4c961626e4b128bfb7b7e50958e7add4c27ba103f5f81b9b0
 | 
			
		||||
      sha256: "1076f4b041f76143e14878c70f0758f17fe5910c0cd992db9e93bd3c3584512b"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "4.3.1"
 | 
			
		||||
    version: "4.3.2"
 | 
			
		||||
  firebase_analytics_web:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: firebase_analytics_web
 | 
			
		||||
      sha256: b6b4cef08e45e4c7d48476d9fc49fe9577081809a59026fe95b1a1b1eea165fa
 | 
			
		||||
      sha256: "8f6dd64ea6d28b7f5b9e739d183a9e1c7f17027794a3e9aba1879621d42426ef"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.5.10+7"
 | 
			
		||||
    version: "0.5.10+8"
 | 
			
		||||
  firebase_core:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: firebase_core
 | 
			
		||||
      sha256: d851c1ca98fd5a4c07c747f8c65dacc2edd84a4d9ac055d32a5f0342529069f5
 | 
			
		||||
      sha256: "93dc4dd12f9b02c5767f235307f609e61ed9211047132d07f9e02c668f0bfc33"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "3.10.1"
 | 
			
		||||
    version: "3.11.0"
 | 
			
		||||
  firebase_core_platform_interface:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -578,34 +586,34 @@ packages:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: firebase_core_web
 | 
			
		||||
      sha256: fbc008cf390d909b823763064b63afefe9f02d8afdb13eb3f485b871afee956b
 | 
			
		||||
      sha256: "0e13c80f0de8acaa5d0519cbe23c8b4cc138a2d5d508b5755c861bdfc9762678"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.19.0"
 | 
			
		||||
    version: "2.20.0"
 | 
			
		||||
  firebase_messaging:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: firebase_messaging
 | 
			
		||||
      sha256: e20ea2a0ecf9b0971575ab3ab42a6e285a94e50092c555b090c1a588a81b4d54
 | 
			
		||||
      sha256: "3dee3b0cbfe719e64773cb7d1cad57c58b2235a8c136f5715fe733a54058c783"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "15.2.1"
 | 
			
		||||
    version: "15.2.2"
 | 
			
		||||
  firebase_messaging_platform_interface:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: firebase_messaging_platform_interface
 | 
			
		||||
      sha256: c57a92b5ae1857ef4fe4ae2e73452b44d32e984e15ab8b53415ea1bb514bdabd
 | 
			
		||||
      sha256: e9ea726b9bb864fc6223bb66422bd9877b9973ae51967754a769b0d01e201c1e
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "4.6.1"
 | 
			
		||||
    version: "4.6.2"
 | 
			
		||||
  firebase_messaging_web:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: firebase_messaging_web
 | 
			
		||||
      sha256: "83694a990d8525d6b01039240b97757298369622ca0253ad0ebcfed221bf8ee0"
 | 
			
		||||
      sha256: "5f7b40e8bf861a37f8b8196e347d8a919750421a45f0b45d1bb74e98fa72726e"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "3.10.1"
 | 
			
		||||
    version: "3.10.2"
 | 
			
		||||
  fixnum:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -764,10 +772,10 @@ packages:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: flutter_markdown
 | 
			
		||||
      sha256: e37f4c69a07b07bb92622ef6b131a53c9aae48f64b176340af9e8e5238718487
 | 
			
		||||
      sha256: b3ff1ef5fb3924ee02b4d38b974ffae3969d50603e68787684ee9dd45f6f144a
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.7.5"
 | 
			
		||||
    version: "0.7.6+1"
 | 
			
		||||
  flutter_native_splash:
 | 
			
		||||
    dependency: "direct dev"
 | 
			
		||||
    description:
 | 
			
		||||
@@ -830,10 +838,10 @@ packages:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: flutter_webrtc
 | 
			
		||||
      sha256: "4c76069f724f79dc6e8739c8f16c2ee00ca30a1f8e3aa75c0e830f8183278f03"
 | 
			
		||||
      sha256: e917067abeef2400e6a7a03db53a6e1418551e54809f18ab80447ac323eb77e4
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.12.7"
 | 
			
		||||
    version: "0.12.8"
 | 
			
		||||
  freezed:
 | 
			
		||||
    dependency: "direct dev"
 | 
			
		||||
    description:
 | 
			
		||||
@@ -886,10 +894,10 @@ packages:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: go_router
 | 
			
		||||
      sha256: "9b736a9fa879d8ad6df7932cbdcc58237c173ab004ef90d8377923d7ad731eaa"
 | 
			
		||||
      sha256: "04539267a740931c6d4479a10d466717ca5901c6fdfd3fcda09391bbb8ebd651"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "14.7.2"
 | 
			
		||||
    version: "14.8.0"
 | 
			
		||||
  google_fonts:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
@@ -934,10 +942,50 @@ packages:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: home_widget
 | 
			
		||||
      sha256: b313e3304c0429669fddf1286e1fbf61a64b873f38ba30b3eb890ef0d7560b12
 | 
			
		||||
      sha256: "7430f7549d42cef2e729bd3c779de748b93f1eb78b1abfe6bca8fffd1cfce3e9"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.7.0"
 | 
			
		||||
    version: "0.7.0+1"
 | 
			
		||||
  hotkey_manager:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: hotkey_manager
 | 
			
		||||
      sha256: "06f0655b76c8dd322fb7101dc615afbdbf39c3d3414df9e059c33892104479cd"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.2.3"
 | 
			
		||||
  hotkey_manager_linux:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: hotkey_manager_linux
 | 
			
		||||
      sha256: "83676bda8210a3377bc6f1977f193bc1dbdd4c46f1bdd02875f44b6eff9a8473"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.2.0"
 | 
			
		||||
  hotkey_manager_macos:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: hotkey_manager_macos
 | 
			
		||||
      sha256: "03b5967e64357b9ac05188ea4a5df6fe4ed4205762cb80aaccf8916ee1713c96"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.2.0"
 | 
			
		||||
  hotkey_manager_platform_interface:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: hotkey_manager_platform_interface
 | 
			
		||||
      sha256: "98ffca25b8cc9081552902747b2942e3bc37855389a4218c9d50ca316b653b13"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.2.0"
 | 
			
		||||
  hotkey_manager_windows:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: hotkey_manager_windows
 | 
			
		||||
      sha256: "0d03ced9fe563ed0b68f0a0e1b22c9ffe26eb8053cb960e401f68a4f070e0117"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.2.0"
 | 
			
		||||
  html:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
@@ -1150,10 +1198,10 @@ packages:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: livekit_client
 | 
			
		||||
      sha256: "02b4653d903852d0ae86b15fbe4324747606dae6410fe860d0c07a11c79988de"
 | 
			
		||||
      sha256: "0cfb2f48eff7a93ea8927696dc6f218aebd2fcd1fcc1b1a7b2f53ff3597fdb52"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.3.5"
 | 
			
		||||
    version: "2.3.6"
 | 
			
		||||
  logging:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -1206,10 +1254,10 @@ packages:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: material_symbols_icons
 | 
			
		||||
      sha256: "89aac72d25dd49303f71b3b1e70f8374791846729365b25bebc2a2531e5b86cd"
 | 
			
		||||
      sha256: "1403944c2a68dbdf934fca2b2115a372e70a4a185c136ab71a9d16e2108d0b14"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "4.2801.1"
 | 
			
		||||
    version: "4.2805.1"
 | 
			
		||||
  media_kit:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
@@ -1282,6 +1330,14 @@ packages:
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "1.2.5"
 | 
			
		||||
  menu_base:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: menu_base
 | 
			
		||||
      sha256: "820368014a171bd1241030278e6c2617354f492f5c703d7b7d4570a6b8b84405"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.1.1"
 | 
			
		||||
  meta:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -1334,18 +1390,18 @@ packages:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: package_info_plus
 | 
			
		||||
      sha256: b15fad91c4d3d1f2b48c053dd41cb82da007c27407dc9ab5f9aa59881d0e39d4
 | 
			
		||||
      sha256: "67eae327b1b0faf761964a1d2e5d323c797f3799db0e85aa232db8d9e922bc35"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "8.1.4"
 | 
			
		||||
    version: "8.2.1"
 | 
			
		||||
  package_info_plus_platform_interface:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: package_info_plus_platform_interface
 | 
			
		||||
      sha256: a5ef9986efc7bf772f2696183a3992615baa76c1ffb1189318dd8803778fb05b
 | 
			
		||||
      sha256: "205ec83335c2ab9107bbba3f8997f9356d72ca3c715d2f038fc773d0366b4c76"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "3.0.2"
 | 
			
		||||
    version: "3.1.0"
 | 
			
		||||
  pasteboard:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
@@ -1778,6 +1834,14 @@ packages:
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.0.1"
 | 
			
		||||
  shortid:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: shortid
 | 
			
		||||
      sha256: d0b40e3dbb50497dad107e19c54ca7de0d1a274eb9b4404991e443dadb9ebedb
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.1.2"
 | 
			
		||||
  sky_engine:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description: flutter
 | 
			
		||||
@@ -1951,6 +2015,14 @@ packages:
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "1.0.2"
 | 
			
		||||
  tray_manager:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: tray_manager
 | 
			
		||||
      sha256: "80be6c508159a6f3c57983de795209ac13453e9832fd574143b06dceee188ed2"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.3.2"
 | 
			
		||||
  typed_data:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -1959,6 +2031,14 @@ packages:
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "1.4.0"
 | 
			
		||||
  uni_platform:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: uni_platform
 | 
			
		||||
      sha256: e02213a7ee5352212412ca026afd41d269eb00d982faa552f419ffc2debfad84
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.1.3"
 | 
			
		||||
  universal_io:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -2059,10 +2139,10 @@ packages:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: vector_graphics
 | 
			
		||||
      sha256: "7ed22c21d7fdcc88dd6ba7860384af438cd220b251ad65dfc142ab722fabef61"
 | 
			
		||||
      sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "1.1.16"
 | 
			
		||||
    version: "1.1.18"
 | 
			
		||||
  vector_graphics_codec:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -2179,10 +2259,10 @@ packages:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: webrtc_interface
 | 
			
		||||
      sha256: abec3ab7956bd5ac539cf34a42fa0c82ea26675847c0966bb85160400eea9388
 | 
			
		||||
      sha256: "10fc6dc0ac16f909f5e434c18902415211d759313c87261f1e4ec5b4f6a04c26"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "1.2.0"
 | 
			
		||||
    version: "1.2.1"
 | 
			
		||||
  win32:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
 
 | 
			
		||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
 | 
			
		||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
 | 
			
		||||
# In Windows, build-name is used as the major, minor, and patch parts
 | 
			
		||||
# of the product and file versions while build-number is used as the build suffix.
 | 
			
		||||
version: 2.2.2+60
 | 
			
		||||
version: 2.3.2+65
 | 
			
		||||
 | 
			
		||||
environment:
 | 
			
		||||
  sdk: ^3.5.4
 | 
			
		||||
@@ -118,6 +118,8 @@ dependencies:
 | 
			
		||||
  flutter_inappwebview: ^6.1.5
 | 
			
		||||
  html: ^0.15.5
 | 
			
		||||
  xml: ^6.5.0
 | 
			
		||||
  tray_manager: ^0.3.2
 | 
			
		||||
  hotkey_manager: ^0.2.3
 | 
			
		||||
 | 
			
		||||
dev_dependencies:
 | 
			
		||||
  flutter_test:
 | 
			
		||||
@@ -152,6 +154,8 @@ flutter:
 | 
			
		||||
    - assets/icon/icon.png
 | 
			
		||||
    - assets/icon/icon-dark.png
 | 
			
		||||
    - assets/icon/icon-light-radius.png
 | 
			
		||||
    - assets/icon/tray-icon.ico
 | 
			
		||||
    - assets/icon/tray-icon.png
 | 
			
		||||
    - assets/translations/
 | 
			
		||||
 | 
			
		||||
  # An image asset can refer to one or more resolution-specific "variants", see
 | 
			
		||||
 
 | 
			
		||||
@@ -15,6 +15,7 @@
 | 
			
		||||
#include <flutter_udid/flutter_udid_plugin_c_api.h>
 | 
			
		||||
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
 | 
			
		||||
#include <gal/gal_plugin_c_api.h>
 | 
			
		||||
#include <hotkey_manager_windows/hotkey_manager_windows_plugin_c_api.h>
 | 
			
		||||
#include <livekit_client/live_kit_plugin.h>
 | 
			
		||||
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
 | 
			
		||||
#include <media_kit_video/media_kit_video_plugin_c_api.h>
 | 
			
		||||
@@ -22,6 +23,7 @@
 | 
			
		||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
 | 
			
		||||
#include <screen_brightness_windows/screen_brightness_windows_plugin.h>
 | 
			
		||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
 | 
			
		||||
#include <tray_manager/tray_manager_plugin.h>
 | 
			
		||||
#include <url_launcher_windows/url_launcher_windows.h>
 | 
			
		||||
 | 
			
		||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
 | 
			
		||||
@@ -43,6 +45,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
 | 
			
		||||
      registry->GetRegistrarForPlugin("FlutterWebRTCPlugin"));
 | 
			
		||||
  GalPluginCApiRegisterWithRegistrar(
 | 
			
		||||
      registry->GetRegistrarForPlugin("GalPluginCApi"));
 | 
			
		||||
  HotkeyManagerWindowsPluginCApiRegisterWithRegistrar(
 | 
			
		||||
      registry->GetRegistrarForPlugin("HotkeyManagerWindowsPluginCApi"));
 | 
			
		||||
  LiveKitPluginRegisterWithRegistrar(
 | 
			
		||||
      registry->GetRegistrarForPlugin("LiveKitPlugin"));
 | 
			
		||||
  MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(
 | 
			
		||||
@@ -57,6 +61,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
 | 
			
		||||
      registry->GetRegistrarForPlugin("ScreenBrightnessWindowsPlugin"));
 | 
			
		||||
  SharePlusWindowsPluginCApiRegisterWithRegistrar(
 | 
			
		||||
      registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
 | 
			
		||||
  TrayManagerPluginRegisterWithRegistrar(
 | 
			
		||||
      registry->GetRegistrarForPlugin("TrayManagerPlugin"));
 | 
			
		||||
  UrlLauncherWindowsRegisterWithRegistrar(
 | 
			
		||||
      registry->GetRegistrarForPlugin("UrlLauncherWindows"));
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -12,6 +12,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
 | 
			
		||||
  flutter_udid
 | 
			
		||||
  flutter_webrtc
 | 
			
		||||
  gal
 | 
			
		||||
  hotkey_manager_windows
 | 
			
		||||
  livekit_client
 | 
			
		||||
  media_kit_libs_windows_video
 | 
			
		||||
  media_kit_video
 | 
			
		||||
@@ -19,6 +20,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
 | 
			
		||||
  permission_handler_windows
 | 
			
		||||
  screen_brightness_windows
 | 
			
		||||
  share_plus
 | 
			
		||||
  tray_manager
 | 
			
		||||
  url_launcher_windows
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user