Compare commits
	
		
			138 Commits
		
	
	
		
			2.3.2+66
			...
			54c098c274
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 54c098c274 | |||
| 29731728cd | |||
| 9e8882c580 | |||
| 6042e57e7a | |||
| 6235e736b9 | |||
| e075804782 | |||
| d40a6ca1c4 | |||
| 5ac657e526 | |||
| 97ddc18b8e | |||
| b835c8edea | |||
| 288c0399f9 | |||
| 1478933cf1 | |||
| 93c6fa6e53 | |||
| ce6e9c185a | |||
| cdaa8cfe58 | |||
| 76d8cd943d | |||
| d6f3ffc655 | |||
| 5a6b841253 | |||
| cb2de52bee | |||
| 64e2644745 | |||
| 56711889ab | |||
| 4f47cd2c0c | |||
| 2b61c372f5 | |||
| 73777fe74e | |||
| 33a4bd7e71 | |||
| 17e6b81f76 | |||
| 22fde6b400 | |||
| 6e03a00280 | |||
| 72e6a6a1f6 | |||
| 66aef44281 | |||
| 7bb73c80b0 | |||
| d043ef2410 | |||
| 1d0e2f7591 | |||
| e9ef28d764 | |||
| 289aa17a7a | |||
| 93f41bb523 | |||
| 09ec9d4a0c | |||
| 1153fbdeee | |||
| e933058338 | |||
| ae9743c84f | |||
| 32bf834108 | |||
| 1b41c847a6 | |||
| b1af6c2c97 | |||
| 8e76ff3f84 | |||
| bd26602299 | |||
| 52ab1d0d10 | |||
| f746e06f65 | |||
| d11069a2be | |||
| d6dc487d9e | |||
| a07c7cdede | |||
| acbc125dec | |||
| ad0ee971c1 | |||
| 52d6bb083e | |||
| 2027eab49b | |||
| 566ebde1dd | |||
| 9e039cc532 | |||
| c4b95d7084 | |||
| a66129a9ba | |||
| 44e1a8bf67 | |||
| efcfd3f57d | |||
| 84759715a4 | |||
| fda09382dd | |||
| 2c5dd0563a | |||
| 5bdd8e94fa | |||
| 2a53031c9a | |||
| e8bc7261f3 | |||
| 997934f680 | |||
| 26e69d6264 | |||
| 153eabcbf2 | |||
| 6d0145c335 | |||
| 81a79f9476 | |||
| 537f404fe0 | |||
| eb29f76b9a | |||
| 56816dc060 | |||
| 899d5f3e5e | |||
| c8c455bb57 | |||
| 5468fc0748 | |||
| 78516abf2e | |||
| 0424f98eb5 | |||
| 2188b8b2e2 | |||
| 0bf614a75c | |||
| 9f21f744a4 | |||
| b94cda6205 | |||
| 3c0e4046a4 | |||
| 338c22a606 | |||
| 25dd895e0d | |||
| ea9ef9e82a | |||
| edd86eda77 | |||
| 671b857a79 | |||
| 408fd0f35e | |||
| 30184d08b1 | |||
| 
						 | 
					95f257c47a | ||
| 
						 | 
					41297c6712 | ||
| a8e0ade0c8 | |||
| 3338e699c4 | |||
| e07da3efa5 | |||
| 4f7f015250 | |||
| 2a4c15d0dc | |||
| 70ef894ec5 | |||
| bb9179d5f9 | |||
| e2ecb573a2 | |||
| 8cb5dff498 | |||
| a5629975ed | |||
| 972b304969 | |||
| e8ded55055 | |||
| 04875eb164 | |||
| 54a59aa470 | |||
| 365f330629 | |||
| a7829d15b2 | |||
| a3868a4281 | |||
| 
						 | 
					1d1d61d60c | ||
| 03c2491587 | |||
| 2c1adc988c | |||
| c0fbee55e4 | |||
| 6e544c0b6c | |||
| 7d56c5ef31 | |||
| c2df1af16d | |||
| a8143c6453 | |||
| 04065061e0 | |||
| 226eb452e5 | |||
| a6715b0872 | |||
| 43e3404dbb | |||
| c91cf7c813 | |||
| 
						 | 
					9cd1cad695 | ||
| 
						 | 
					dde280833b | ||
| 42ac12b53e | |||
| 63567bf708 | |||
| 5d3cadefef | |||
| 251fbb2503 | |||
| 0b31d32217 | |||
| 5ddd4fed2e | |||
| 48b6d5f6c1 | |||
| b83b0b5efb | |||
| cb24bd953d | |||
| 4937dee182 | |||
| d612097bb1 | |||
| 058d668b6b | |||
| 8b19462c3a | 
							
								
								
									
										87
									
								
								.github/ISSUE_TEMPLATE/bug_report.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								.github/ISSUE_TEMPLATE/bug_report.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,87 @@
 | 
				
			|||||||
 | 
					name: Bug report
 | 
				
			||||||
 | 
					description: Create a report to help us address issues you are facing
 | 
				
			||||||
 | 
					title: "[Bug] "
 | 
				
			||||||
 | 
					labels: [Bug]
 | 
				
			||||||
 | 
					body:
 | 
				
			||||||
 | 
					  - type: markdown
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      value: |
 | 
				
			||||||
 | 
					        Thanks for taking the time to make us better!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: checkboxes
 | 
				
			||||||
 | 
					    id: duplication
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: ⠀
 | 
				
			||||||
 | 
					      options:
 | 
				
			||||||
 | 
					        - label: This issue is not duplicated with any other open or closed issues
 | 
				
			||||||
 | 
					          required: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: textarea
 | 
				
			||||||
 | 
					    id: description
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: Describe the bug
 | 
				
			||||||
 | 
					      description: A clear and concise description of what the bug is
 | 
				
			||||||
 | 
					      placeholder: |
 | 
				
			||||||
 | 
					        Example:
 | 
				
			||||||
 | 
					          App crashes on startup every time after changing settings.
 | 
				
			||||||
 | 
					    validations:
 | 
				
			||||||
 | 
					      required: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: textarea
 | 
				
			||||||
 | 
					    id: expected
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: Expected behavior
 | 
				
			||||||
 | 
					      description: A clear and concise description of what you expected to happen
 | 
				
			||||||
 | 
					      placeholder: |
 | 
				
			||||||
 | 
					        Example:
 | 
				
			||||||
 | 
					          App started normally, everything worked fine.
 | 
				
			||||||
 | 
					    validations:
 | 
				
			||||||
 | 
					      required: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: textarea
 | 
				
			||||||
 | 
					    id: reproduce
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: Steps to reproduce
 | 
				
			||||||
 | 
					      description: Steps to reproduce the bug
 | 
				
			||||||
 | 
					      placeholder: |
 | 
				
			||||||
 | 
					        Example:
 | 
				
			||||||
 | 
					          1. Change "HyperNet Server" to "127.0.1" in "Network" settings
 | 
				
			||||||
 | 
					          2. Restart the app
 | 
				
			||||||
 | 
					          3. Crash
 | 
				
			||||||
 | 
					    validations:
 | 
				
			||||||
 | 
					      required: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: textarea
 | 
				
			||||||
 | 
					    id: environment
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: Device information
 | 
				
			||||||
 | 
					      description: Provide details about your system environment
 | 
				
			||||||
 | 
					      placeholder: |
 | 
				
			||||||
 | 
					        Example:
 | 
				
			||||||
 | 
					          Device: Google Pixel 8 Pro
 | 
				
			||||||
 | 
					          System: Baklava (BP22.250124.009)
 | 
				
			||||||
 | 
					          Version*: 2.3.2
 | 
				
			||||||
 | 
					    validations:
 | 
				
			||||||
 | 
					      required: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: textarea
 | 
				
			||||||
 | 
					    id: screenshots
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: Screenshots
 | 
				
			||||||
 | 
					      description: If applicable, add screenshots to help explain your problem
 | 
				
			||||||
 | 
					      placeholder: |
 | 
				
			||||||
 | 
					        Example:
 | 
				
			||||||
 | 
					          setting_items.jpg
 | 
				
			||||||
 | 
					          crash_screen.jpg
 | 
				
			||||||
 | 
					    validations:
 | 
				
			||||||
 | 
					      required: false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: textarea
 | 
				
			||||||
 | 
					    id: additional
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: Additional context
 | 
				
			||||||
 | 
					      description: Add any other context about the problem here
 | 
				
			||||||
 | 
					      placeholder: |
 | 
				
			||||||
 | 
					        Crash report or other useful informations
 | 
				
			||||||
 | 
					    validations:
 | 
				
			||||||
 | 
					      required: false
 | 
				
			||||||
							
								
								
									
										83
									
								
								.github/ISSUE_TEMPLATE/bug_report_zh.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								.github/ISSUE_TEMPLATE/bug_report_zh.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,83 @@
 | 
				
			|||||||
 | 
					name: 问题反馈
 | 
				
			||||||
 | 
					description: 提交 Bug 或其它问题的反馈
 | 
				
			||||||
 | 
					title: "[Bug] 标题"
 | 
				
			||||||
 | 
					labels: [Bug]
 | 
				
			||||||
 | 
					body:
 | 
				
			||||||
 | 
					  - type: markdown
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      value: |
 | 
				
			||||||
 | 
					        非常感谢,你将要提交的反馈会让我们变得更好!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: checkboxes
 | 
				
			||||||
 | 
					    id: duplication
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: ⠀
 | 
				
			||||||
 | 
					      options:
 | 
				
			||||||
 | 
					        - label: 我已经搜索并确认此 issue 不与其它任何 issue 重复
 | 
				
			||||||
 | 
					          required: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: textarea
 | 
				
			||||||
 | 
					    id: description
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: 问题描述
 | 
				
			||||||
 | 
					      description: 清楚且详细地描述你遇到的 Bug 或问题
 | 
				
			||||||
 | 
					      placeholder: |
 | 
				
			||||||
 | 
					        发生了什么?生动地描述你所看到的一切
 | 
				
			||||||
 | 
					    validations:
 | 
				
			||||||
 | 
					      required: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: textarea
 | 
				
			||||||
 | 
					    id: expected
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: 期望表现
 | 
				
			||||||
 | 
					      description: 清楚且详细地描述你期望发生的事
 | 
				
			||||||
 | 
					      placeholder: |
 | 
				
			||||||
 | 
					        什么功能应该正常运行,运行后会有什么结果
 | 
				
			||||||
 | 
					        什么界面应该正常显示,应该会显示什么内容
 | 
				
			||||||
 | 
					    validations:
 | 
				
			||||||
 | 
					      required: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: textarea
 | 
				
			||||||
 | 
					    id: reproduce
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: 复现步骤
 | 
				
			||||||
 | 
					      description: 能够复现问题的每一步
 | 
				
			||||||
 | 
					      placeholder: |
 | 
				
			||||||
 | 
					        1. 尽可能详细地描述每一步
 | 
				
			||||||
 | 
					        2. 更改的设置、添加的好友...
 | 
				
			||||||
 | 
					        3. 这里也可以描述你看到的界面
 | 
				
			||||||
 | 
					    validations:
 | 
				
			||||||
 | 
					      required: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: textarea
 | 
				
			||||||
 | 
					    id: environment
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: 环境/版本
 | 
				
			||||||
 | 
					      description: 提供运行时的环境信息
 | 
				
			||||||
 | 
					      placeholder: |
 | 
				
			||||||
 | 
					        示例:
 | 
				
			||||||
 | 
					          设备型号: Google Pixel 8 Pro
 | 
				
			||||||
 | 
					          系统板本: Baklava (BP22.250124.009)
 | 
				
			||||||
 | 
					          程序版本: 2.3.2
 | 
				
			||||||
 | 
					    validations:
 | 
				
			||||||
 | 
					      required: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: textarea
 | 
				
			||||||
 | 
					    id: screenshots
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: 屏幕截图/录制
 | 
				
			||||||
 | 
					      description: 提供截屏或录屏来更好地描述问题
 | 
				
			||||||
 | 
					      placeholder: |
 | 
				
			||||||
 | 
					        错误显示的界面/崩溃时的界面、先前改动的设置
 | 
				
			||||||
 | 
					    validations:
 | 
				
			||||||
 | 
					      required: false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: textarea
 | 
				
			||||||
 | 
					    id: additional
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: 更多信息
 | 
				
			||||||
 | 
					      description: 任何与问题有关且有用的信息
 | 
				
			||||||
 | 
					      placeholder: |
 | 
				
			||||||
 | 
					        崩溃报告、日志,或是你的用户名
 | 
				
			||||||
 | 
					    validations:
 | 
				
			||||||
 | 
					      required: false
 | 
				
			||||||
							
								
								
									
										5
									
								
								.github/ISSUE_TEMPLATE/config.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.github/ISSUE_TEMPLATE/config.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
				
			|||||||
 | 
					blank_issues_enabled: true
 | 
				
			||||||
 | 
					contact_links:
 | 
				
			||||||
 | 
					  - name: Solsynth Releases
 | 
				
			||||||
 | 
					    url: https://files.solsynth.dev/production01/solian
 | 
				
			||||||
 | 
					    about: Another place to download released apps
 | 
				
			||||||
							
								
								
									
										59
									
								
								.github/ISSUE_TEMPLATE/feature_request.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								.github/ISSUE_TEMPLATE/feature_request.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,59 @@
 | 
				
			|||||||
 | 
					name: Feature request
 | 
				
			||||||
 | 
					description: Suggest features you want to add or suggest to modify existing features
 | 
				
			||||||
 | 
					title: "[Feature] "
 | 
				
			||||||
 | 
					labels: [Feature]
 | 
				
			||||||
 | 
					body:
 | 
				
			||||||
 | 
					  - type: markdown
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      value: |
 | 
				
			||||||
 | 
					        Thanks for taking the time to make us better!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: checkboxes
 | 
				
			||||||
 | 
					    id: duplication
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: ⠀
 | 
				
			||||||
 | 
					      options:
 | 
				
			||||||
 | 
					        - label: This issue is not duplicated with any other open or closed issues
 | 
				
			||||||
 | 
					          required: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: textarea
 | 
				
			||||||
 | 
					    id: description
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: Describe the feature
 | 
				
			||||||
 | 
					      description: A clear and concise description of what the feature is
 | 
				
			||||||
 | 
					      placeholder: |
 | 
				
			||||||
 | 
					        Example:
 | 
				
			||||||
 | 
					          A Quick Settings tile to start the service, long press to launch the app.
 | 
				
			||||||
 | 
					    validations:
 | 
				
			||||||
 | 
					      required: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: textarea
 | 
				
			||||||
 | 
					    id: reasons
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: Reason for adding
 | 
				
			||||||
 | 
					      description: Explain why this feature would be useful to you
 | 
				
			||||||
 | 
					      placeholder: |
 | 
				
			||||||
 | 
					        Example:
 | 
				
			||||||
 | 
					          Start the service quickly from the Quick Settings tile and save lots of time.
 | 
				
			||||||
 | 
					    validations:
 | 
				
			||||||
 | 
					      required: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: textarea
 | 
				
			||||||
 | 
					    id: examples
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: Example(s)
 | 
				
			||||||
 | 
					      description: Post screenshots/drawings/links/etc of the feature request, or proof-of-concept images about the feature
 | 
				
			||||||
 | 
					      placeholder: |
 | 
				
			||||||
 | 
					        Example:
 | 
				
			||||||
 | 
					          shazam_toggle.jpg
 | 
				
			||||||
 | 
					          nekobox_switch.jpg
 | 
				
			||||||
 | 
					    validations:
 | 
				
			||||||
 | 
					      required: false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: textarea
 | 
				
			||||||
 | 
					    id: additional
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: Additional context
 | 
				
			||||||
 | 
					      description: Add any other context about the feature here
 | 
				
			||||||
 | 
					    validations:
 | 
				
			||||||
 | 
					      required: false
 | 
				
			||||||
							
								
								
									
										49
									
								
								.github/ISSUE_TEMPLATE/feature_request_zh.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								.github/ISSUE_TEMPLATE/feature_request_zh.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,49 @@
 | 
				
			|||||||
 | 
					name: 功能建议
 | 
				
			||||||
 | 
					description: 提出你想要添加或更改的功能
 | 
				
			||||||
 | 
					title: "[Feature] 标题"
 | 
				
			||||||
 | 
					labels: [Feature]
 | 
				
			||||||
 | 
					body:
 | 
				
			||||||
 | 
					  - type: markdown
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      value: |
 | 
				
			||||||
 | 
					        非常感谢,你将要提交的请求会让我们变得更好!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: checkboxes
 | 
				
			||||||
 | 
					    id: duplication
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: ⠀
 | 
				
			||||||
 | 
					      options:
 | 
				
			||||||
 | 
					        - label: 我已经搜索并确认此 issue 不与其它任何 issue 重复
 | 
				
			||||||
 | 
					          required: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: textarea
 | 
				
			||||||
 | 
					    id: description
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: 功能描述
 | 
				
			||||||
 | 
					      description: 清楚且详细地描述要添加/更改后的功能
 | 
				
			||||||
 | 
					    validations:
 | 
				
			||||||
 | 
					      required: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: textarea
 | 
				
			||||||
 | 
					    id: reasons
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: 添加/更改理由
 | 
				
			||||||
 | 
					      description: 解释为什么要这样做,对用户有什么好处
 | 
				
			||||||
 | 
					    validations:
 | 
				
			||||||
 | 
					      required: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: textarea
 | 
				
			||||||
 | 
					    id: examples
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: 功能示例
 | 
				
			||||||
 | 
					      description: 相似/已存在功能的截图,或画出大致的界面
 | 
				
			||||||
 | 
					    validations:
 | 
				
			||||||
 | 
					      required: false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - type: textarea
 | 
				
			||||||
 | 
					    id: additional
 | 
				
			||||||
 | 
					    attributes:
 | 
				
			||||||
 | 
					      label: 更多信息
 | 
				
			||||||
 | 
					      description: 任何与功能有关且有用的信息,或已存在功能的代码/仓库
 | 
				
			||||||
 | 
					    validations:
 | 
				
			||||||
 | 
					      required: false
 | 
				
			||||||
							
								
								
									
										24
									
								
								.github/workflows/nightly.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										24
									
								
								.github/workflows/nightly.yml
									
									
									
									
										vendored
									
									
								
							@@ -39,3 +39,27 @@ jobs:
 | 
				
			|||||||
        with:
 | 
					        with:
 | 
				
			||||||
          name: build-output-windows
 | 
					          name: build-output-windows
 | 
				
			||||||
          path: build/windows/x64/runner/Release
 | 
					          path: build/windows/x64/runner/Release
 | 
				
			||||||
 | 
					  build-linux:
 | 
				
			||||||
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					    steps:
 | 
				
			||||||
 | 
					      - name: Clone repository
 | 
				
			||||||
 | 
					        uses: actions/checkout@v4
 | 
				
			||||||
 | 
					      - name: Set up Flutter
 | 
				
			||||||
 | 
					        uses: subosito/flutter-action@v2
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          channel: stable
 | 
				
			||||||
 | 
					          cache: true
 | 
				
			||||||
 | 
					      - run: |
 | 
				
			||||||
 | 
					          sudo apt-get update -y
 | 
				
			||||||
 | 
					          sudo apt-get install -y ninja-build libgtk-3-dev
 | 
				
			||||||
 | 
					          sudo apt-get install libmpv-dev mpv
 | 
				
			||||||
 | 
					          sudo apt-get install libayatana-appindicator3-dev
 | 
				
			||||||
 | 
					          sudo apt-get install keybinder-3.0
 | 
				
			||||||
 | 
					          sudo apt-get install libnotify-dev
 | 
				
			||||||
 | 
					      - run: flutter pub get
 | 
				
			||||||
 | 
					      - run: flutter build linux
 | 
				
			||||||
 | 
					      - name: Archive production artifacts
 | 
				
			||||||
 | 
					        uses: actions/upload-artifact@v4
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          name: build-output-linux
 | 
				
			||||||
 | 
					          path: build/linux/x64/release/bundle
 | 
				
			||||||
							
								
								
									
										27
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
				
			|||||||
 | 
					# Solar Network
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Hello there! Welcome to the main repository of the HyperNet (also known as the Solar Network). The code here is mainly about the frontend app (also known as Solian). But you can still post issues here to get help and request new features!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Sub Projects
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					HyperNet, the Solar Network is a microservices project in which the backends are stored in separate repositories. Here is a simple index for it.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- The Core, Gateway: [Nexus](https://github.com/Solsynth/HyperNet.Nexus)
 | 
				
			||||||
 | 
					- The Auth Service: [Passport](https://github.com/Solsynth/HyperNet.Passport)
 | 
				
			||||||
 | 
					- The Posting Service: [Interactive](https://github.com/Solsynth/HyperNet.Interactive)
 | 
				
			||||||
 | 
					- The Messaging Service: [Messaging](https://github.com/Solsynth/HyperNet.Messaging)
 | 
				
			||||||
 | 
					- The Wallet Service: [Wallet](https://github.com/Solsynth/HyperNet.Wallet)
 | 
				
			||||||
 | 
					- The Crawler: [Reader](https://github.com/Solsynth/HyperNet.Reader)
 | 
				
			||||||
 | 
					- Some others may not be listed, you can search in the organization with `HyperNet.` the prefix of all HyperNet projects.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Tech Stack
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					For those people who want to know the tech stack of this project, the frontend was built by Flutter, which provides the cross-platform ability.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The backend was built in Go and PostgreSQL with our very own microservice framework included in the nexus.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-----
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The readme will be updated in the future, to be determined. For now, you can check out the link of this repository to learn more on our official website.
 | 
				
			||||||
@@ -17,7 +17,6 @@
 | 
				
			|||||||
        android:label="Solian"
 | 
					        android:label="Solian"
 | 
				
			||||||
        android:name="${applicationName}"
 | 
					        android:name="${applicationName}"
 | 
				
			||||||
        android:icon="@mipmap/ic_launcher"
 | 
					        android:icon="@mipmap/ic_launcher"
 | 
				
			||||||
        android:enableOnBackInvokedCallback="true"
 | 
					 | 
				
			||||||
        android:requestLegacyExternalStorage="true">
 | 
					        android:requestLegacyExternalStorage="true">
 | 
				
			||||||
        <meta-data
 | 
					        <meta-data
 | 
				
			||||||
            android:name="flutterEmbedding"
 | 
					            android:name="flutterEmbedding"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -54,7 +54,7 @@ class CheckInWidget : GlanceAppWidget() {
 | 
				
			|||||||
                .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
 | 
					                .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
 | 
				
			||||||
                .registerTypeAdapter(Instant::class.java, InstantAdapter())
 | 
					                .registerTypeAdapter(Instant::class.java, InstantAdapter())
 | 
				
			||||||
                .create()
 | 
					                .create()
 | 
				
			||||||
        val resultTierSymbols = listOf("大凶", "凶", "中平", "吉", "大吉")
 | 
					        val resultTierSymbols = listOf("Bad", "Poor", "Medium", "Good", "Great")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        val prefs = currentState.preferences
 | 
					        val prefs = currentState.preferences
 | 
				
			||||||
        val checkInRaw: String? = prefs.getString("pas_check_in_record", null)
 | 
					        val checkInRaw: String? = prefs.getString("pas_check_in_record", null)
 | 
				
			||||||
@@ -120,7 +120,7 @@ class CheckInWidget : GlanceAppWidget() {
 | 
				
			|||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            Text(
 | 
					            Text(
 | 
				
			||||||
                text = "You haven't checked in today",
 | 
					                text = "You haven't divined today",
 | 
				
			||||||
                style = TextStyle(fontSize = 15.sp, color = GlanceTheme.colors.onSurface)
 | 
					                style = TextStyle(fontSize = 15.sp, color = GlanceTheme.colors.onSurface)
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										11
									
								
								api/Nexus/Check Status.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								api/Nexus/Check Status.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
				
			|||||||
 | 
					meta {
 | 
				
			||||||
 | 
					  name: Check Status
 | 
				
			||||||
 | 
					  type: http
 | 
				
			||||||
 | 
					  seq: 1
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					get {
 | 
				
			||||||
 | 
					  url: {{endpoint}}/directory/status
 | 
				
			||||||
 | 
					  body: none
 | 
				
			||||||
 | 
					  auth: none
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										11
									
								
								api/Nexus/List Services.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								api/Nexus/List Services.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
				
			|||||||
 | 
					meta {
 | 
				
			||||||
 | 
					  name: List Services
 | 
				
			||||||
 | 
					  type: http
 | 
				
			||||||
 | 
					  seq: 2
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					get {
 | 
				
			||||||
 | 
					  url: {{endpoint}}/directory/services
 | 
				
			||||||
 | 
					  body: none
 | 
				
			||||||
 | 
					  auth: none
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -12,9 +12,9 @@ post {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
body:json {
 | 
					body:json {
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
    "alias": "BaLoading",
 | 
					    "alias": "Deadge",
 | 
				
			||||||
    "name": "BaLoading",
 | 
					    "name": "Dead",
 | 
				
			||||||
    "attachment_id": "2JCI2uh21mKkfk9P",
 | 
					    "attachment_id": "pcbFd0u4zgdM39HM",
 | 
				
			||||||
    "pack_id": 3
 | 
					    "pack_id": 4
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										18
									
								
								api/Passport/Deal Abuse Report.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								api/Passport/Deal Abuse Report.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
				
			|||||||
 | 
					meta {
 | 
				
			||||||
 | 
					  name: Deal Abuse Report
 | 
				
			||||||
 | 
					  type: http
 | 
				
			||||||
 | 
					  seq: 3
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					put {
 | 
				
			||||||
 | 
					  url: {{endpoint}}/cgi/id/reports/abuse/3/status
 | 
				
			||||||
 | 
					  body: json
 | 
				
			||||||
 | 
					  auth: inherit
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body:json {
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "status": "processed",
 | 
				
			||||||
 | 
					    "message": "相关附件已经进行评级处理,未来会将该项权限下放到帖主以及社区成员。"
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -5,7 +5,7 @@ meta {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
post {
 | 
					post {
 | 
				
			||||||
  url: {{endpoint}}/cgi/id/dev/notify/1
 | 
					  url: {{endpoint}}/cgi/id/dev/notify/328
 | 
				
			||||||
  body: json
 | 
					  body: json
 | 
				
			||||||
  auth: inherit
 | 
					  auth: inherit
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -15,12 +15,9 @@ body:json {
 | 
				
			|||||||
    "client_id": "{{third_client_id}}",
 | 
					    "client_id": "{{third_client_id}}",
 | 
				
			||||||
    "client_secret":"{{third_client_tk}}",
 | 
					    "client_secret":"{{third_client_tk}}",
 | 
				
			||||||
    "type": "general",
 | 
					    "type": "general",
 | 
				
			||||||
    "subject": "测试",
 | 
					    "subject": "处理该发布者 @vedal987 的决定",
 | 
				
			||||||
    "subtitle": "Alphabot です",
 | 
					    "subtitle": "一条来自 Solar Network 客户支持的信息",
 | 
				
			||||||
    "content": "全新通知动画",
 | 
					    "content": "您的发布者违反了我们用户协议中的「禁止冒充他人」的相关条例,经管理决定,将相关内容隐藏。冒充他人的判定无论作者是否有主观意志,只要造成了误解我们就有责任处理。希望您能理解,本次决定未作出任何帐号相关的连带处罚。",
 | 
				
			||||||
    "metadata": {
 | 
					 | 
				
			||||||
      "image": "D2EDbcrsTugs3xk5"
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    "priority": 10
 | 
					    "priority": 10
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -15,7 +15,7 @@ body:json {
 | 
				
			|||||||
    "client_id": "alphabot",
 | 
					    "client_id": "alphabot",
 | 
				
			||||||
    "client_secret": "_uR0sVnHTh",
 | 
					    "client_secret": "_uR0sVnHTh",
 | 
				
			||||||
    "remark": "新年红包",
 | 
					    "remark": "新年红包",
 | 
				
			||||||
    "amount": 9705,
 | 
					    "amount": 150,
 | 
				
			||||||
    "payee_id": 2
 | 
					    "payee_id": 18
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Bold.ttf
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Bold.ttf
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Italic.ttf
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Italic.ttf
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Regular.ttf
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Regular.ttf
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							@@ -203,6 +203,11 @@
 | 
				
			|||||||
    "other": "{} comments"
 | 
					    "other": "{} comments"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "settingsAppearance": "Appearance",
 | 
					  "settingsAppearance": "Appearance",
 | 
				
			||||||
 | 
					  "settingsCustomFonts": "Custom Fonts",
 | 
				
			||||||
 | 
					  "settingsCustomFontsDescription": "Set custom fonts for the application.",
 | 
				
			||||||
 | 
					  "settingsCustomFontFamily": "Custom Font Family",
 | 
				
			||||||
 | 
					  "settingsCustomFontFamilyHint": "Use comma to separate fonts, higher priority comes first",
 | 
				
			||||||
 | 
					  "settingsCustomFontApplied": "Custom font has been applied.",
 | 
				
			||||||
  "settingsDisplayLanguage": "Display Language",
 | 
					  "settingsDisplayLanguage": "Display Language",
 | 
				
			||||||
  "settingsDisplayLanguageDescription": "Set the application language.",
 | 
					  "settingsDisplayLanguageDescription": "Set the application language.",
 | 
				
			||||||
  "settingsDisplayLanguageSystem": "Follow System",
 | 
					  "settingsDisplayLanguageSystem": "Follow System",
 | 
				
			||||||
@@ -333,6 +338,7 @@
 | 
				
			|||||||
  "addAttachmentFromRandomId": "Link via RID",
 | 
					  "addAttachmentFromRandomId": "Link via RID",
 | 
				
			||||||
  "attachmentDetailInfo": "Attachment details",
 | 
					  "attachmentDetailInfo": "Attachment details",
 | 
				
			||||||
  "attachmentPastedImage": "Pasted Image",
 | 
					  "attachmentPastedImage": "Pasted Image",
 | 
				
			||||||
 | 
					  "attachmentInsertedImage": "Inserted Image",
 | 
				
			||||||
  "attachmentInsertLink": "Insert Link",
 | 
					  "attachmentInsertLink": "Insert Link",
 | 
				
			||||||
  "attachmentSetAsPostThumbnail": "Set as post thumbnail",
 | 
					  "attachmentSetAsPostThumbnail": "Set as post thumbnail",
 | 
				
			||||||
  "attachmentUnsetAsPostThumbnail": "Unset as post thumbnail",
 | 
					  "attachmentUnsetAsPostThumbnail": "Unset as post thumbnail",
 | 
				
			||||||
@@ -419,7 +425,7 @@
 | 
				
			|||||||
  "callMessageEnded": "Call lasted {}",
 | 
					  "callMessageEnded": "Call lasted {}",
 | 
				
			||||||
  "callMessageStarted": "Call started",
 | 
					  "callMessageStarted": "Call started",
 | 
				
			||||||
  "dailyCheckIn": "Check In",
 | 
					  "dailyCheckIn": "Check In",
 | 
				
			||||||
  "dailyCheckInNone": "You haven't checked in today",
 | 
					  "dailyCheckInNone": "You haven't divined today",
 | 
				
			||||||
  "dailyCheckAction": "Check in right now!",
 | 
					  "dailyCheckAction": "Check in right now!",
 | 
				
			||||||
  "dailyCheckDetail": "Can't understand the symbol? Master, help me understand it!",
 | 
					  "dailyCheckDetail": "Can't understand the symbol? Master, help me understand it!",
 | 
				
			||||||
  "dailyCheckDetailTitle": "{}'s fortune details",
 | 
					  "dailyCheckDetailTitle": "{}'s fortune details",
 | 
				
			||||||
@@ -511,8 +517,13 @@
 | 
				
			|||||||
  "accountBirthday": "Born on {}",
 | 
					  "accountBirthday": "Born on {}",
 | 
				
			||||||
  "accountBadge": "Badge",
 | 
					  "accountBadge": "Badge",
 | 
				
			||||||
  "accountCheckInNoRecords": "No check-in records",
 | 
					  "accountCheckInNoRecords": "No check-in records",
 | 
				
			||||||
  "badgeCompanyStaff": "Solsynth Staff",
 | 
					  "badgeCompanyStaff": "Staff",
 | 
				
			||||||
  "badgeSiteMigration": "Solar Network Native",
 | 
					  "badgeSiteMigration": "Solar Network Native",
 | 
				
			||||||
 | 
					  "badgeCommunitySurvey": "Survey Participant",
 | 
				
			||||||
 | 
					  "badgeCommunityVerified": "Verified User",
 | 
				
			||||||
 | 
					  "badgeCommunityContributor": "Great Contributor",
 | 
				
			||||||
 | 
					  "badgeSiteAnniversary": "Anniversary",
 | 
				
			||||||
 | 
					  "badgeUserBirthday": "Birthday",
 | 
				
			||||||
  "accountStatus": "Status",
 | 
					  "accountStatus": "Status",
 | 
				
			||||||
  "accountStatusOnline": "Online",
 | 
					  "accountStatusOnline": "Online",
 | 
				
			||||||
  "accountStatusOffline": "Offline",
 | 
					  "accountStatusOffline": "Offline",
 | 
				
			||||||
@@ -547,6 +558,7 @@
 | 
				
			|||||||
  "termAcceptNextWithAgree": "By clicking the \"Next\", it means you agree to our terms and its updates.",
 | 
					  "termAcceptNextWithAgree": "By clicking the \"Next\", it means you agree to our terms and its updates.",
 | 
				
			||||||
  "unauthorized": "Unauthorized",
 | 
					  "unauthorized": "Unauthorized",
 | 
				
			||||||
  "unauthorizedDescription": "Login to explore the entire Solar Network.",
 | 
					  "unauthorizedDescription": "Login to explore the entire Solar Network.",
 | 
				
			||||||
 | 
					  "projectDetail": "Project Details",
 | 
				
			||||||
  "serviceStatus": "Service Status",
 | 
					  "serviceStatus": "Service Status",
 | 
				
			||||||
  "termRelated": "Related Terms",
 | 
					  "termRelated": "Related Terms",
 | 
				
			||||||
  "appDetails": "App Details",
 | 
					  "appDetails": "App Details",
 | 
				
			||||||
@@ -582,6 +594,7 @@
 | 
				
			|||||||
  "colorSchemeBlack": "Black",
 | 
					  "colorSchemeBlack": "Black",
 | 
				
			||||||
  "colorSchemeApplied": "Color scheme has been applied, may need restart the app to take effect.",
 | 
					  "colorSchemeApplied": "Color scheme has been applied, may need restart the app to take effect.",
 | 
				
			||||||
  "postFeaturedComment": "Featured Comment",
 | 
					  "postFeaturedComment": "Featured Comment",
 | 
				
			||||||
 | 
					  "postCategory": "Category",
 | 
				
			||||||
  "postCategoryTechnology": "Technology",
 | 
					  "postCategoryTechnology": "Technology",
 | 
				
			||||||
  "postCategoryGaming": "Gaming",
 | 
					  "postCategoryGaming": "Gaming",
 | 
				
			||||||
  "postCategoryLife": "Life",
 | 
					  "postCategoryLife": "Life",
 | 
				
			||||||
@@ -624,6 +637,131 @@
 | 
				
			|||||||
  "realmJoin": "Join Realm",
 | 
					  "realmJoin": "Join Realm",
 | 
				
			||||||
  "realmCommunityHint": "This realm is a community realm, you can freely join.",
 | 
					  "realmCommunityHint": "This realm is a community realm, you can freely join.",
 | 
				
			||||||
  "realmCommunityPublicChannelsHint": "The public channels in this realm",
 | 
					  "realmCommunityPublicChannelsHint": "The public channels in this realm",
 | 
				
			||||||
 | 
					  "realmCommunityPublishersHint": "The publishers in this realm",
 | 
				
			||||||
  "realmJoined": "Joined realm {}.",
 | 
					  "realmJoined": "Joined realm {}.",
 | 
				
			||||||
  "join": "Join"
 | 
					  "join": "Join",
 | 
				
			||||||
 | 
					  "pollEditorNew": "New Poll",
 | 
				
			||||||
 | 
					  "pollEditorEdit": "Edit Poll",
 | 
				
			||||||
 | 
					  "pollEditorDelete": "Delete Poll",
 | 
				
			||||||
 | 
					  "pollEditorDeleteDescription": "Are you sure you want to delete this poll? This operation is irreversible.",
 | 
				
			||||||
 | 
					  "pollEditorUnlink": "Unlink Poll",
 | 
				
			||||||
 | 
					  "pollOptionAdd": "Add Option",
 | 
				
			||||||
 | 
					  "pollOptionName": "Option Name",
 | 
				
			||||||
 | 
					  "pollLinkExisting": "Link existing poll",
 | 
				
			||||||
 | 
					  "pollAnswered": "Answered the poll.",
 | 
				
			||||||
 | 
					  "pollVotes": {
 | 
				
			||||||
 | 
					    "one": "{} vote",
 | 
				
			||||||
 | 
					    "other": "{} votes"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "publisherDelete": "Delete Publisher {}",
 | 
				
			||||||
 | 
					  "publisherDeleteDescription": "Are you sure you want to delete this publisher? This operation is irreversible.",
 | 
				
			||||||
 | 
					  "channelIsPublic": "Public Channel",
 | 
				
			||||||
 | 
					  "channelIsPublicDescription": "The channel is public, anyone can join.",
 | 
				
			||||||
 | 
					  "channelIsCommunity": "Community Channel",
 | 
				
			||||||
 | 
					  "channelIsCommunityDescription": "Currently, community channel has nothing special yet.",
 | 
				
			||||||
 | 
					  "realmIsPublic": "Public Realm",
 | 
				
			||||||
 | 
					  "realmIsPublicDescription": "The realm is public, anyone can join.",
 | 
				
			||||||
 | 
					  "realmIsCommunity": "Community Realm",
 | 
				
			||||||
 | 
					  "realmIsCommunityDescription": "Community realm will be displayed on the discover page.",
 | 
				
			||||||
 | 
					  "realmLeave": "Leave Realm",
 | 
				
			||||||
 | 
					  "realmLeaveDescription": "Leave the current realm and delete the realm's identity.",
 | 
				
			||||||
 | 
					  "checkInResultTier1": "Worst",
 | 
				
			||||||
 | 
					  "checkInResultTier2": "Worse",
 | 
				
			||||||
 | 
					  "checkInResultTier3": "Normal",
 | 
				
			||||||
 | 
					  "checkInResultTier4": "Better",
 | 
				
			||||||
 | 
					  "checkInResultTier5": "Best",
 | 
				
			||||||
 | 
					  "flagPostAction": "Flag the Post",
 | 
				
			||||||
 | 
					  "flagPost": "Flag objectionable content",
 | 
				
			||||||
 | 
					  "flagPostDescription": "If flagged users takes 50% or more of the views, the post will be collapsed. You cannot revoke the action.",
 | 
				
			||||||
 | 
					  "flaggedPost": "Post has been flagged.",
 | 
				
			||||||
 | 
					  "postViews": {
 | 
				
			||||||
 | 
					    "zero": "No views",
 | 
				
			||||||
 | 
					    "one": "{} view",
 | 
				
			||||||
 | 
					    "other": "{} views"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "attachmentBillingUploaded": "Used space",
 | 
				
			||||||
 | 
					  "attachmentBillingDiscount": "Free space",
 | 
				
			||||||
 | 
					  "attachmentBillingRatio": "Usage",
 | 
				
			||||||
 | 
					  "attachmentBillingHint": "Sliding Window Pricing®\nFees will only apply if the size of the file uploaded within 24 hours exceeds the free space.",
 | 
				
			||||||
 | 
					  "postThumbnail": "Post Thumbnail",
 | 
				
			||||||
 | 
					  "accountRealms": "Realms",
 | 
				
			||||||
 | 
					  "postInGlobal": "Global",
 | 
				
			||||||
 | 
					  "postInGlobalDescription": "Do not link this post with any realm.",
 | 
				
			||||||
 | 
					  "postChannelGlobal": "Global",
 | 
				
			||||||
 | 
					  "postChannelFriends": "Friends",
 | 
				
			||||||
 | 
					  "postChannelFollowing": "Following",
 | 
				
			||||||
 | 
					  "postChannelRealm": "Realms",
 | 
				
			||||||
 | 
					  "postFilterReset": "Reset Filter",
 | 
				
			||||||
 | 
					  "postFilterResetDescription": "Clear filter and show all posts.",
 | 
				
			||||||
 | 
					  "postFilterWithCategory": "Viewing posts in {}",
 | 
				
			||||||
 | 
					  "databaseSize": "Database Size",
 | 
				
			||||||
 | 
					  "databaseDelete": "Delete Database",
 | 
				
			||||||
 | 
					  "databaseDeleteDescription": "Remove the database on your local disk, the content will be fetched from server again.",
 | 
				
			||||||
 | 
					  "databaseDeleted": "The local database has been deleted.",
 | 
				
			||||||
 | 
					  "settingsEnablePushNotifications": "Enable Push Notifications",
 | 
				
			||||||
 | 
					  "settingsEnablePushNotificationsDescription": "Re-enable and request permission to receive push notifications. Just in case it didn't run automatically.",
 | 
				
			||||||
 | 
					  "settingsEnabledPushNotifications": "Push notification has been enabled.",
 | 
				
			||||||
 | 
					  "screenStickers": "Stickers",
 | 
				
			||||||
 | 
					  "stickersDiscovery": "Discovery",
 | 
				
			||||||
 | 
					  "stickersOwned": "Owned",
 | 
				
			||||||
 | 
					  "stickersCreated": "Created",
 | 
				
			||||||
 | 
					  "stickersAdd": "Add Sticker Pack",
 | 
				
			||||||
 | 
					  "stickersAdded": "Sticker pack has been added.",
 | 
				
			||||||
 | 
					  "add": "Add",
 | 
				
			||||||
 | 
					  "stickersRemoved": "Sticker pack has been removed, you can add it again anytime.",
 | 
				
			||||||
 | 
					  "stickersReload": "Reload Stickers",
 | 
				
			||||||
 | 
					  "stickersReloadDescription": "Reload stickers from the server, update the sticker picker.",
 | 
				
			||||||
 | 
					  "stickersReloaded": "Sticker packs has been reloaded.",
 | 
				
			||||||
 | 
					  "stickersPackDelete": "Delete Pack {}",
 | 
				
			||||||
 | 
					  "stickersPackDeleteDescription": "Are you sure you want to delete this sticker pack? This operation is irreversible.",
 | 
				
			||||||
 | 
					  "stickersPackDeleted": "Sticker pack has been deleted.",
 | 
				
			||||||
 | 
					  "stickersDelete": "Delete Sticker {}",
 | 
				
			||||||
 | 
					  "stickersDeleteDescription": "Are you sure you want to delete this sticker? This operation is irreversible.",
 | 
				
			||||||
 | 
					  "stickersDeleted": "Sticker has been deleted.",
 | 
				
			||||||
 | 
					  "fieldStickerName": "Sticker Name",
 | 
				
			||||||
 | 
					  "fieldStickerAlias": "Sticker Alias",
 | 
				
			||||||
 | 
					  "fieldStickerAliasHint": "The unique sticker placeholder with the pack prefix.",
 | 
				
			||||||
 | 
					  "fieldStickerPackName": "Name",
 | 
				
			||||||
 | 
					  "fieldStickerPackDescription": "Description",
 | 
				
			||||||
 | 
					  "fieldStickerPackPrefix": "Prefix",
 | 
				
			||||||
 | 
					  "fieldStickerAttachment": "Attachment",
 | 
				
			||||||
 | 
					  "stickersNew": "New Sticker",
 | 
				
			||||||
 | 
					  "stickersNewDescription": "Create a new sticker belongs to this pack.",
 | 
				
			||||||
 | 
					  "stickersPackNew": "New Sticker Pack",
 | 
				
			||||||
 | 
					  "trayMenuShow": "Show",
 | 
				
			||||||
 | 
					  "trayMenuMuteNotification": "Do Not Disturb",
 | 
				
			||||||
 | 
					  "update": "Update",
 | 
				
			||||||
 | 
					  "forceUpdate": "Force Update",
 | 
				
			||||||
 | 
					  "forceUpdateDescription": "Force to show the application update popup, even the new version is not available.",
 | 
				
			||||||
 | 
					  "debugLogging": "Runtime Logs",
 | 
				
			||||||
 | 
					  "runtimeLogsOpen": "Open Logs",
 | 
				
			||||||
 | 
					  "runtimeLogsDescription": "Show the runtime logs to help debugging.",
 | 
				
			||||||
 | 
					  "signinResetPasswordHint": "Please enter the username / email address to help us to find your account and reset your password.",
 | 
				
			||||||
 | 
					  "cacheSize": "Cache Size",
 | 
				
			||||||
 | 
					  "cacheDelete": "Clean Cache",
 | 
				
			||||||
 | 
					  "cacheDeleteDescription": "Remove the cached images and other resources from your disk, the content will be downloaded from server again.",
 | 
				
			||||||
 | 
					  "cacheDeleted": "All cache has been cleaned up.",
 | 
				
			||||||
 | 
					  "userNoDescription": "No description.",
 | 
				
			||||||
 | 
					  "fieldTimeZone": "Time Zone",
 | 
				
			||||||
 | 
					  "fieldGender": "Gender",
 | 
				
			||||||
 | 
					  "fieldPronouns": "Pronouns",
 | 
				
			||||||
 | 
					  "fieldLocation": "Location",
 | 
				
			||||||
 | 
					  "fieldLinks": "Links",
 | 
				
			||||||
 | 
					  "fieldLinkName": "Name",
 | 
				
			||||||
 | 
					  "fieldLinkUrl": "URL",
 | 
				
			||||||
 | 
					  "screenAccountBadges": "Badges",
 | 
				
			||||||
 | 
					  "accountBadges": "Badges",
 | 
				
			||||||
 | 
					  "accountBadgesDescription": "View and manage your badges.",
 | 
				
			||||||
 | 
					  "badgeActivated": "Activated badge {}.",
 | 
				
			||||||
 | 
					  "viewDetailedAttachment": "Details",
 | 
				
			||||||
 | 
					  "screenKeyPairs": "Key Pairs",
 | 
				
			||||||
 | 
					  "accountKeyPairs": "Key Pairs",
 | 
				
			||||||
 | 
					  "accountKeyPairsDescription": "Manage the key pairs which used to encrypt messages.",
 | 
				
			||||||
 | 
					  "enrollNewKeyPair": "Enroll New One",
 | 
				
			||||||
 | 
					  "enrollNewKeyPairDescription": "Generate a new key pair.",
 | 
				
			||||||
 | 
					  "keyPairHasPrivateKey": "With private key",
 | 
				
			||||||
 | 
					  "decrypting": "Decrypting……",
 | 
				
			||||||
 | 
					  "decryptingKeyNotFound": "Key not found or exchange failed, the other party may not be online",
 | 
				
			||||||
 | 
					  "messageUnablePreview": "Unable preview",
 | 
				
			||||||
 | 
					  "messageUnablePreviewEncrypted": "Unable preview encrypted message"
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -201,6 +201,11 @@
 | 
				
			|||||||
    "other": "{} 条评论"
 | 
					    "other": "{} 条评论"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "settingsAppearance": "外观",
 | 
					  "settingsAppearance": "外观",
 | 
				
			||||||
 | 
					  "settingsCustomFonts": "自定义字体",
 | 
				
			||||||
 | 
					  "settingsCustomFontsDescription": "设置应用程序使用的字体。",
 | 
				
			||||||
 | 
					  "settingsCustomFontFamily": "应用字体",
 | 
				
			||||||
 | 
					  "settingsCustomFontFamilyHint": "使用英文逗号分割每一种字体,越前优先级越高",
 | 
				
			||||||
 | 
					  "settingsCustomFontApplied": "自定义字体已经应用。",
 | 
				
			||||||
  "settingsDisplayLanguage": "显示语言",
 | 
					  "settingsDisplayLanguage": "显示语言",
 | 
				
			||||||
  "settingsDisplayLanguageDescription": "设置应用程序使用的语言",
 | 
					  "settingsDisplayLanguageDescription": "设置应用程序使用的语言",
 | 
				
			||||||
  "settingsDisplayLanguageSystem": "跟随系统",
 | 
					  "settingsDisplayLanguageSystem": "跟随系统",
 | 
				
			||||||
@@ -331,6 +336,7 @@
 | 
				
			|||||||
  "addAttachmentFromRandomId": "通过访问 ID 链接",
 | 
					  "addAttachmentFromRandomId": "通过访问 ID 链接",
 | 
				
			||||||
  "attachmentDetailInfo": "附件详细信息",
 | 
					  "attachmentDetailInfo": "附件详细信息",
 | 
				
			||||||
  "attachmentPastedImage": "粘贴的图片",
 | 
					  "attachmentPastedImage": "粘贴的图片",
 | 
				
			||||||
 | 
					  "attachmentInsertedImage": "插入的图片",
 | 
				
			||||||
  "attachmentInsertLink": "插入连接",
 | 
					  "attachmentInsertLink": "插入连接",
 | 
				
			||||||
  "attachmentSetAsPostThumbnail": "设置为帖子缩略图",
 | 
					  "attachmentSetAsPostThumbnail": "设置为帖子缩略图",
 | 
				
			||||||
  "attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图",
 | 
					  "attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图",
 | 
				
			||||||
@@ -509,8 +515,13 @@
 | 
				
			|||||||
  "accountBirthday": "出生于 {}",
 | 
					  "accountBirthday": "出生于 {}",
 | 
				
			||||||
  "accountBadge": "徽章",
 | 
					  "accountBadge": "徽章",
 | 
				
			||||||
  "accountCheckInNoRecords": "暂无运势记录",
 | 
					  "accountCheckInNoRecords": "暂无运势记录",
 | 
				
			||||||
  "badgeCompanyStaff": "索尔辛茨士大夫 · 员工",
 | 
					  "badgeCompanyStaff": "工作人员",
 | 
				
			||||||
  "badgeSiteMigration": "Solar Network 原住民",
 | 
					  "badgeSiteMigration": "Solar Network 原住民",
 | 
				
			||||||
 | 
					  "badgeCommunitySurvey": "调研参与者",
 | 
				
			||||||
 | 
					  "badgeCommunityVerified": "认证用户",
 | 
				
			||||||
 | 
					  "badgeCommunityContributor": "优秀社区贡献者",
 | 
				
			||||||
 | 
					  "badgeSiteAnniversary": "周年纪念",
 | 
				
			||||||
 | 
					  "badgeUserBirthday": "生日纪念",
 | 
				
			||||||
  "accountStatus": "状态",
 | 
					  "accountStatus": "状态",
 | 
				
			||||||
  "accountStatusOnline": "在线",
 | 
					  "accountStatusOnline": "在线",
 | 
				
			||||||
  "accountStatusOffline": "离线",
 | 
					  "accountStatusOffline": "离线",
 | 
				
			||||||
@@ -545,6 +556,7 @@
 | 
				
			|||||||
  "termAcceptNextWithAgree": "点击 “下一步”,即表示你同意我们的各项条款,包括其之后的更新。",
 | 
					  "termAcceptNextWithAgree": "点击 “下一步”,即表示你同意我们的各项条款,包括其之后的更新。",
 | 
				
			||||||
  "unauthorized": "未登陆",
 | 
					  "unauthorized": "未登陆",
 | 
				
			||||||
  "unauthorizedDescription": "登陆以探索整个 Solar Network。",
 | 
					  "unauthorizedDescription": "登陆以探索整个 Solar Network。",
 | 
				
			||||||
 | 
					  "projectDetail": "项目详情",
 | 
				
			||||||
  "serviceStatus": "服务状态",
 | 
					  "serviceStatus": "服务状态",
 | 
				
			||||||
  "termRelated": "相关条款",
 | 
					  "termRelated": "相关条款",
 | 
				
			||||||
  "appDetails": "应用程序详情",
 | 
					  "appDetails": "应用程序详情",
 | 
				
			||||||
@@ -580,6 +592,7 @@
 | 
				
			|||||||
  "colorSchemeBlack": "黑色",
 | 
					  "colorSchemeBlack": "黑色",
 | 
				
			||||||
  "colorSchemeApplied": "主题色已应用,可能需要重启来生效。",
 | 
					  "colorSchemeApplied": "主题色已应用,可能需要重启来生效。",
 | 
				
			||||||
  "postFeaturedComment": "精选评论",
 | 
					  "postFeaturedComment": "精选评论",
 | 
				
			||||||
 | 
					  "postCategory": "分类",
 | 
				
			||||||
  "postCategoryTechnology": "技术",
 | 
					  "postCategoryTechnology": "技术",
 | 
				
			||||||
  "postCategoryGaming": "游戏",
 | 
					  "postCategoryGaming": "游戏",
 | 
				
			||||||
  "postCategoryLife": "生活",
 | 
					  "postCategoryLife": "生活",
 | 
				
			||||||
@@ -623,6 +636,130 @@
 | 
				
			|||||||
  "realmJoin": "加入领域",
 | 
					  "realmJoin": "加入领域",
 | 
				
			||||||
  "realmCommunityHint": "该领域是一个社区领域,你可以自由加入。",
 | 
					  "realmCommunityHint": "该领域是一个社区领域,你可以自由加入。",
 | 
				
			||||||
  "realmCommunityPublicChannelsHint": "该领域包含的公共频道",
 | 
					  "realmCommunityPublicChannelsHint": "该领域包含的公共频道",
 | 
				
			||||||
 | 
					  "realmCommunityPublishersHint": "该领域的发布者",
 | 
				
			||||||
  "realmJoined": "已加入领域 {}。",
 | 
					  "realmJoined": "已加入领域 {}。",
 | 
				
			||||||
  "join": "加入"
 | 
					  "join": "加入",
 | 
				
			||||||
 | 
					  "pollEditorNew": "新投票",
 | 
				
			||||||
 | 
					  "pollEditorEdit": "编辑投票",
 | 
				
			||||||
 | 
					  "pollEditorDelete": "删除投票",
 | 
				
			||||||
 | 
					  "pollEditorDeleteDescription": "你确定要删除这个投票吗?该操作不可撤销。",
 | 
				
			||||||
 | 
					  "pollEditorUnlink": "解除链接",
 | 
				
			||||||
 | 
					  "pollOptionAdd": "添加选项",
 | 
				
			||||||
 | 
					  "pollOptionName": "选项名称",
 | 
				
			||||||
 | 
					  "pollLinkExisting": "链接现有投票",
 | 
				
			||||||
 | 
					  "pollAnswered": "答案已经反馈。",
 | 
				
			||||||
 | 
					  "pollVotes": {
 | 
				
			||||||
 | 
					    "one": "{} 票",
 | 
				
			||||||
 | 
					    "other": "{} 票"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "publisherDelete": "删除发布者 {}",
 | 
				
			||||||
 | 
					  "publisherDeleteDescription": "你确定要删除这个发布者吗?该操作不可撤销。",
 | 
				
			||||||
 | 
					  "channelIsPublic": "公开频道",
 | 
				
			||||||
 | 
					  "channelIsPublicDescription": "该频道是公开的,任何人都可以加入。",
 | 
				
			||||||
 | 
					  "channelIsCommunity": "社区频道",
 | 
				
			||||||
 | 
					  "channelIsCommunityDescription": "目前来说,社区频道还没有什么特别之处。",
 | 
				
			||||||
 | 
					  "realmIsPublic": "公开领域",
 | 
				
			||||||
 | 
					  "realmIsPublicDescription": "该领域是公开的,任何人都可以加入。",
 | 
				
			||||||
 | 
					  "realmIsCommunity": "社区领域",
 | 
				
			||||||
 | 
					  "realmIsCommunityDescription": "社区领域会显示在发现页面上。",
 | 
				
			||||||
 | 
					  "realmLeave": "离开领域",
 | 
				
			||||||
 | 
					  "realmLeaveDescription": "离开当前领域,并且删除领域中的身份。",
 | 
				
			||||||
 | 
					  "checkInResultTier1": "大凶",
 | 
				
			||||||
 | 
					  "checkInResultTier2": "凶",
 | 
				
			||||||
 | 
					  "checkInResultTier3": "中平",
 | 
				
			||||||
 | 
					  "checkInResultTier4": "吉",
 | 
				
			||||||
 | 
					  "checkInResultTier5": "大吉",
 | 
				
			||||||
 | 
					  "flagPostAction": "吹哨",
 | 
				
			||||||
 | 
					  "flagPost": "吹哨不良内容",
 | 
				
			||||||
 | 
					  "flagPostDescription": "吹哨不良内容,如果吹哨用户占浏览量的 50% 或以上,则帖子会被折叠。吹哨后不可撤销。",
 | 
				
			||||||
 | 
					  "flaggedPost": "哨子已经吹响。",
 | 
				
			||||||
 | 
					  "postViews": {
 | 
				
			||||||
 | 
					    "zero": "{} 次浏览",
 | 
				
			||||||
 | 
					    "one": "{} 次浏览",
 | 
				
			||||||
 | 
					    "other": "{} 次浏览"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "attachmentBillingUploaded": "已占用的字节数",
 | 
				
			||||||
 | 
					  "attachmentBillingDiscount": "免费的字节数",
 | 
				
			||||||
 | 
					  "attachmentBillingHint": "滑动窗口计价®\n在24小时内上传的文件大小超出免费空间才会适用扣费。",
 | 
				
			||||||
 | 
					  "postThumbnail": "帖子缩略图",
 | 
				
			||||||
 | 
					  "accountRealms": "领域",
 | 
				
			||||||
 | 
					  "postInGlobal": "全站",
 | 
				
			||||||
 | 
					  "postInGlobalDescription": "不关联此帖子与任何领域。",
 | 
				
			||||||
 | 
					  "postChannelGlobal": "全站",
 | 
				
			||||||
 | 
					  "postChannelFriends": "好友",
 | 
				
			||||||
 | 
					  "postChannelFollowing": "关注",
 | 
				
			||||||
 | 
					  "postChannelRealm": "领域",
 | 
				
			||||||
 | 
					  "postFilterReset": "重置过滤器",
 | 
				
			||||||
 | 
					  "postFilterResetDescription": "清除过滤器并显示所有帖子。",
 | 
				
			||||||
 | 
					  "postFilterWithCategory": "查看{}区中的帖子",
 | 
				
			||||||
 | 
					  "databaseSize": "数据库大小",
 | 
				
			||||||
 | 
					  "databaseDelete": "删除数据库",
 | 
				
			||||||
 | 
					  "databaseDeleteDescription": "删除本地数据库,内容将从服务器重新获取。",
 | 
				
			||||||
 | 
					  "databaseDeleted": "本地数据库已被删除。",
 | 
				
			||||||
 | 
					  "settingsEnablePushNotifications": "启用推送数据",
 | 
				
			||||||
 | 
					  "settingsEnablePushNotificationsDescription": "重新启用并请求推送权限,以防自动激活失败。",
 | 
				
			||||||
 | 
					  "settingsEnabledPushNotifications": "推送通知已经注册。",
 | 
				
			||||||
 | 
					  "screenStickers": "贴图",
 | 
				
			||||||
 | 
					  "stickersDiscovery": "发现",
 | 
				
			||||||
 | 
					  "stickersOwned": "由我拥有",
 | 
				
			||||||
 | 
					  "stickersCreated": "由我发布",
 | 
				
			||||||
 | 
					  "stickersAdd": "添加贴图包",
 | 
				
			||||||
 | 
					  "stickersAdded": "贴图包已添加。",
 | 
				
			||||||
 | 
					  "add": "添加",
 | 
				
			||||||
 | 
					  "stickersRemoved": "贴图包已被移除,你可以随时再次添加回来。",
 | 
				
			||||||
 | 
					  "stickersReload": "重载贴图包",
 | 
				
			||||||
 | 
					  "stickersReloadDescription": "从服务器重新加载添加过的贴图,更新贴图选择器。",
 | 
				
			||||||
 | 
					  "stickersReloaded": "贴图包已重载。",
 | 
				
			||||||
 | 
					  "stickersPackDelete": "删除贴图包 {}",
 | 
				
			||||||
 | 
					  "stickersPackDeleteDescription": "你确定要删除这个贴图包吗?这个操作不可撤销。",
 | 
				
			||||||
 | 
					  "stickersPackDeleted": "贴图包已被删除。",
 | 
				
			||||||
 | 
					  "stickersDelete": "删除贴图 {}",
 | 
				
			||||||
 | 
					  "stickersDeleteDescription": "你确定要删除这个贴图吗?这个操作不可撤销。",
 | 
				
			||||||
 | 
					  "stickersDeleted": "贴图已被删除。",
 | 
				
			||||||
 | 
					  "fieldStickerName": "贴图名称",
 | 
				
			||||||
 | 
					  "fieldStickerAlias": "贴图别名",
 | 
				
			||||||
 | 
					  "fieldStickerAliasHint": "和贴图包前缀组合成为本贴图的唯一占位符。",
 | 
				
			||||||
 | 
					  "fieldStickerPackName": "名称",
 | 
				
			||||||
 | 
					  "fieldStickerPackDescription": "描述",
 | 
				
			||||||
 | 
					  "fieldStickerPackPrefix": "贴图包前缀",
 | 
				
			||||||
 | 
					  "fieldStickerAttachment": "附件",
 | 
				
			||||||
 | 
					  "stickersNew": "新建贴图",
 | 
				
			||||||
 | 
					  "stickersNewDescription": "创建一个新的贴图。",
 | 
				
			||||||
 | 
					  "stickersPackNew": "新建贴图包",
 | 
				
			||||||
 | 
					  "trayMenuShow": "显示",
 | 
				
			||||||
 | 
					  "trayMenuMuteNotification": "静音通知",
 | 
				
			||||||
 | 
					  "update": "更新",
 | 
				
			||||||
 | 
					  "forceUpdate": "强制更新",
 | 
				
			||||||
 | 
					  "forceUpdateDescription": "强制更新应用程序,即使有更新的版本可能不可用。",
 | 
				
			||||||
 | 
					  "runtimeLogs": "运行时日志",
 | 
				
			||||||
 | 
					  "runtimeLogsOpen": "打开日志文件",
 | 
				
			||||||
 | 
					  "runtimeLogsDescription": "显示运行时的日志记录。",
 | 
				
			||||||
 | 
					  "signinResetPasswordHint": "请输入用户名/电子邮箱地址以帮助我们找到您的帐户并重置密码。",
 | 
				
			||||||
 | 
					  "cacheSize": "缓存资源大小",
 | 
				
			||||||
 | 
					  "cacheDelete": "清除缓存",
 | 
				
			||||||
 | 
					  "cacheDeleteDescription": "从磁盘中移除缓存的图片和其他资源,内容将从服务器重新下载。",
 | 
				
			||||||
 | 
					  "cacheDeleted": "所有缓存已被清除。",
 | 
				
			||||||
 | 
					  "userNoDescription": "这个人很懒,没有留下什么……",
 | 
				
			||||||
 | 
					  "fieldTimeZone": "时区",
 | 
				
			||||||
 | 
					  "fieldGender": "性别",
 | 
				
			||||||
 | 
					  "fieldPronouns": "人称代词",
 | 
				
			||||||
 | 
					  "fieldLocation": "位置",
 | 
				
			||||||
 | 
					  "fieldLinks": "链接",
 | 
				
			||||||
 | 
					  "fieldLinkName": "名称",
 | 
				
			||||||
 | 
					  "fieldLinkUrl": "链接",
 | 
				
			||||||
 | 
					  "screenAccountBadges": "徽章",
 | 
				
			||||||
 | 
					  "accountBadges": "徽章",
 | 
				
			||||||
 | 
					  "accountBadgesDescription": "查看并管理你的徽章。",
 | 
				
			||||||
 | 
					  "badgeActivated": "已佩戴徽章 {}。",
 | 
				
			||||||
 | 
					  "viewDetailedAttachment": "查看附件详情",
 | 
				
			||||||
 | 
					  "screenKeyPairs": "密钥对",
 | 
				
			||||||
 | 
					  "accountKeyPairs": "密钥对",
 | 
				
			||||||
 | 
					  "accountKeyPairsDescription": "管理用于加密信息的密钥对。",
 | 
				
			||||||
 | 
					  "enrollNewKeyPair": "新建密钥对",
 | 
				
			||||||
 | 
					  "enrollNewKeyPairDescription": "生成一对新密钥对。",
 | 
				
			||||||
 | 
					  "keyPairHasPrivateKey": "有私钥",
 | 
				
			||||||
 | 
					  "decrypting": "解密中……",
 | 
				
			||||||
 | 
					  "decryptingKeyNotFound": "未找到密钥对或交换失败,对方可能不在线",
 | 
				
			||||||
 | 
					  "messageUnablePreview": "无法预览消息",
 | 
				
			||||||
 | 
					  "messageUnablePreviewEncrypted": "无法预览加密消息"
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -201,6 +201,11 @@
 | 
				
			|||||||
    "other": "{} 條評論"
 | 
					    "other": "{} 條評論"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "settingsAppearance": "外觀",
 | 
					  "settingsAppearance": "外觀",
 | 
				
			||||||
 | 
					  "settingsCustomFonts": "自定義字體",
 | 
				
			||||||
 | 
					  "settingsCustomFontsDescription": "設置應用程序使用的字體。",
 | 
				
			||||||
 | 
					  "settingsCustomFontFamily": "應用字體",
 | 
				
			||||||
 | 
					  "settingsCustomFontFamilyHint": "使用英文逗號分割每一種字體,越前優先級越高",
 | 
				
			||||||
 | 
					  "settingsCustomFontApplied": "自定義字體已經應用。",
 | 
				
			||||||
  "settingsDisplayLanguage": "顯示語言",
 | 
					  "settingsDisplayLanguage": "顯示語言",
 | 
				
			||||||
  "settingsDisplayLanguageDescription": "設置應用程序使用的語言",
 | 
					  "settingsDisplayLanguageDescription": "設置應用程序使用的語言",
 | 
				
			||||||
  "settingsDisplayLanguageSystem": "跟隨系統",
 | 
					  "settingsDisplayLanguageSystem": "跟隨系統",
 | 
				
			||||||
@@ -331,6 +336,7 @@
 | 
				
			|||||||
  "addAttachmentFromRandomId": "通過訪問 ID 鏈接",
 | 
					  "addAttachmentFromRandomId": "通過訪問 ID 鏈接",
 | 
				
			||||||
  "attachmentDetailInfo": "附件詳細信息",
 | 
					  "attachmentDetailInfo": "附件詳細信息",
 | 
				
			||||||
  "attachmentPastedImage": "粘貼的圖片",
 | 
					  "attachmentPastedImage": "粘貼的圖片",
 | 
				
			||||||
 | 
					  "attachmentInsertedImage": "插入的圖片",
 | 
				
			||||||
  "attachmentInsertLink": "插入連接",
 | 
					  "attachmentInsertLink": "插入連接",
 | 
				
			||||||
  "attachmentSetAsPostThumbnail": "設置為帖子縮略圖",
 | 
					  "attachmentSetAsPostThumbnail": "設置為帖子縮略圖",
 | 
				
			||||||
  "attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖",
 | 
					  "attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖",
 | 
				
			||||||
@@ -509,8 +515,13 @@
 | 
				
			|||||||
  "accountBirthday": "出生於 {}",
 | 
					  "accountBirthday": "出生於 {}",
 | 
				
			||||||
  "accountBadge": "徽章",
 | 
					  "accountBadge": "徽章",
 | 
				
			||||||
  "accountCheckInNoRecords": "暫無運勢記錄",
 | 
					  "accountCheckInNoRecords": "暫無運勢記錄",
 | 
				
			||||||
  "badgeCompanyStaff": "索爾辛茨士大夫 · 員工",
 | 
					  "badgeCompanyStaff": "工作人員",
 | 
				
			||||||
  "badgeSiteMigration": "Solar Network 原住民",
 | 
					  "badgeSiteMigration": "Solar Network 原住民",
 | 
				
			||||||
 | 
					  "badgeCommunitySurvey": "調研參與者",
 | 
				
			||||||
 | 
					  "badgeCommunityVerified": "認證用户",
 | 
				
			||||||
 | 
					  "badgeCommunityContributor": "優秀社區貢獻者",
 | 
				
			||||||
 | 
					  "badgeSiteAnniversary": "週年紀念",
 | 
				
			||||||
 | 
					  "badgeUserBirthday": "生日紀念",
 | 
				
			||||||
  "accountStatus": "狀態",
 | 
					  "accountStatus": "狀態",
 | 
				
			||||||
  "accountStatusOnline": "在線",
 | 
					  "accountStatusOnline": "在線",
 | 
				
			||||||
  "accountStatusOffline": "離線",
 | 
					  "accountStatusOffline": "離線",
 | 
				
			||||||
@@ -545,6 +556,7 @@
 | 
				
			|||||||
  "termAcceptNextWithAgree": "點擊 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。",
 | 
					  "termAcceptNextWithAgree": "點擊 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。",
 | 
				
			||||||
  "unauthorized": "未登陸",
 | 
					  "unauthorized": "未登陸",
 | 
				
			||||||
  "unauthorizedDescription": "登陸以探索整個 Solar Network。",
 | 
					  "unauthorizedDescription": "登陸以探索整個 Solar Network。",
 | 
				
			||||||
 | 
					  "projectDetail": "項目詳情",
 | 
				
			||||||
  "serviceStatus": "服務狀態",
 | 
					  "serviceStatus": "服務狀態",
 | 
				
			||||||
  "termRelated": "相關條款",
 | 
					  "termRelated": "相關條款",
 | 
				
			||||||
  "appDetails": "應用程序詳情",
 | 
					  "appDetails": "應用程序詳情",
 | 
				
			||||||
@@ -580,6 +592,7 @@
 | 
				
			|||||||
  "colorSchemeBlack": "黑色",
 | 
					  "colorSchemeBlack": "黑色",
 | 
				
			||||||
  "colorSchemeApplied": "主題色已應用,可能需要重啓來生效。",
 | 
					  "colorSchemeApplied": "主題色已應用,可能需要重啓來生效。",
 | 
				
			||||||
  "postFeaturedComment": "精選評論",
 | 
					  "postFeaturedComment": "精選評論",
 | 
				
			||||||
 | 
					  "postCategory": "分類",
 | 
				
			||||||
  "postCategoryTechnology": "技術",
 | 
					  "postCategoryTechnology": "技術",
 | 
				
			||||||
  "postCategoryGaming": "遊戲",
 | 
					  "postCategoryGaming": "遊戲",
 | 
				
			||||||
  "postCategoryLife": "生活",
 | 
					  "postCategoryLife": "生活",
 | 
				
			||||||
@@ -623,6 +636,130 @@
 | 
				
			|||||||
  "realmJoin": "加入領域",
 | 
					  "realmJoin": "加入領域",
 | 
				
			||||||
  "realmCommunityHint": "該領域是一個社區領域,你可以自由加入。",
 | 
					  "realmCommunityHint": "該領域是一個社區領域,你可以自由加入。",
 | 
				
			||||||
  "realmCommunityPublicChannelsHint": "該領域包含的公共頻道",
 | 
					  "realmCommunityPublicChannelsHint": "該領域包含的公共頻道",
 | 
				
			||||||
 | 
					  "realmCommunityPublishersHint": "該領域的發佈者",
 | 
				
			||||||
  "realmJoined": "已加入領域 {}。",
 | 
					  "realmJoined": "已加入領域 {}。",
 | 
				
			||||||
  "join": "加入"
 | 
					  "join": "加入",
 | 
				
			||||||
 | 
					  "pollEditorNew": "新投票",
 | 
				
			||||||
 | 
					  "pollEditorEdit": "編輯投票",
 | 
				
			||||||
 | 
					  "pollEditorDelete": "刪除投票",
 | 
				
			||||||
 | 
					  "pollEditorDeleteDescription": "你確定要刪除這個投票嗎?該操作不可撤銷。",
 | 
				
			||||||
 | 
					  "pollEditorUnlink": "解除鏈接",
 | 
				
			||||||
 | 
					  "pollOptionAdd": "添加選項",
 | 
				
			||||||
 | 
					  "pollOptionName": "選項名稱",
 | 
				
			||||||
 | 
					  "pollLinkExisting": "鏈接現有投票",
 | 
				
			||||||
 | 
					  "pollAnswered": "答案已經反饋。",
 | 
				
			||||||
 | 
					  "pollVotes": {
 | 
				
			||||||
 | 
					    "one": "{} 票",
 | 
				
			||||||
 | 
					    "other": "{} 票"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "publisherDelete": "刪除發佈者 {}",
 | 
				
			||||||
 | 
					  "publisherDeleteDescription": "你確定要刪除這個發佈者嗎?該操作不可撤銷。",
 | 
				
			||||||
 | 
					  "channelIsPublic": "公開頻道",
 | 
				
			||||||
 | 
					  "channelIsPublicDescription": "該頻道是公開的,任何人都可以加入。",
 | 
				
			||||||
 | 
					  "channelIsCommunity": "社區頻道",
 | 
				
			||||||
 | 
					  "channelIsCommunityDescription": "目前來説,社區頻道還沒有什麼特別之處。",
 | 
				
			||||||
 | 
					  "realmIsPublic": "公開領域",
 | 
				
			||||||
 | 
					  "realmIsPublicDescription": "該領域是公開的,任何人都可以加入。",
 | 
				
			||||||
 | 
					  "realmIsCommunity": "社區領域",
 | 
				
			||||||
 | 
					  "realmIsCommunityDescription": "社區領域會顯示在發現頁面上。",
 | 
				
			||||||
 | 
					  "realmLeave": "離開領域",
 | 
				
			||||||
 | 
					  "realmLeaveDescription": "離開當前領域,並且刪除領域中的身份。",
 | 
				
			||||||
 | 
					  "checkInResultTier1": "大凶",
 | 
				
			||||||
 | 
					  "checkInResultTier2": "兇",
 | 
				
			||||||
 | 
					  "checkInResultTier3": "中平",
 | 
				
			||||||
 | 
					  "checkInResultTier4": "吉",
 | 
				
			||||||
 | 
					  "checkInResultTier5": "大吉",
 | 
				
			||||||
 | 
					  "flagPostAction": "吹哨",
 | 
				
			||||||
 | 
					  "flagPost": "吹哨不良內容",
 | 
				
			||||||
 | 
					  "flagPostDescription": "吹哨不良內容,如果吹哨用户佔瀏覽量的 50% 或以上,則帖子會被摺疊。吹哨後不可撤銷。",
 | 
				
			||||||
 | 
					  "flaggedPost": "哨子已經吹響。",
 | 
				
			||||||
 | 
					  "postViews": {
 | 
				
			||||||
 | 
					    "zero": "{} 次瀏覽",
 | 
				
			||||||
 | 
					    "one": "{} 次瀏覽",
 | 
				
			||||||
 | 
					    "other": "{} 次瀏覽"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "attachmentBillingUploaded": "已佔用的字節數",
 | 
				
			||||||
 | 
					  "attachmentBillingDiscount": "免費的字節數",
 | 
				
			||||||
 | 
					  "attachmentBillingHint": "滑動窗口計價®\n在24小時內上傳的文件大小超出免費空間才會適用扣費。",
 | 
				
			||||||
 | 
					  "postThumbnail": "帖子縮略圖",
 | 
				
			||||||
 | 
					  "accountRealms": "領域",
 | 
				
			||||||
 | 
					  "postInGlobal": "全站",
 | 
				
			||||||
 | 
					  "postInGlobalDescription": "不關聯此帖子與任何領域。",
 | 
				
			||||||
 | 
					  "postChannelGlobal": "全站",
 | 
				
			||||||
 | 
					  "postChannelFriends": "好友",
 | 
				
			||||||
 | 
					  "postChannelFollowing": "關注",
 | 
				
			||||||
 | 
					  "postChannelRealm": "領域",
 | 
				
			||||||
 | 
					  "postFilterReset": "重置過濾器",
 | 
				
			||||||
 | 
					  "postFilterResetDescription": "清除過濾器並顯示所有帖子。",
 | 
				
			||||||
 | 
					  "postFilterWithCategory": "查看{}區中的帖子",
 | 
				
			||||||
 | 
					  "databaseSize": "數據庫大小",
 | 
				
			||||||
 | 
					  "databaseDelete": "刪除數據庫",
 | 
				
			||||||
 | 
					  "databaseDeleteDescription": "刪除本地數據庫,內容將從服務器重新獲取。",
 | 
				
			||||||
 | 
					  "databaseDeleted": "本地數據庫已被刪除。",
 | 
				
			||||||
 | 
					  "settingsEnablePushNotifications": "啓用推送數據",
 | 
				
			||||||
 | 
					  "settingsEnablePushNotificationsDescription": "重新啓用並請求推送權限,以防自動激活失敗。",
 | 
				
			||||||
 | 
					  "settingsEnabledPushNotifications": "推送通知已經註冊。",
 | 
				
			||||||
 | 
					  "screenStickers": "貼圖",
 | 
				
			||||||
 | 
					  "stickersDiscovery": "發現",
 | 
				
			||||||
 | 
					  "stickersOwned": "由我擁有",
 | 
				
			||||||
 | 
					  "stickersCreated": "由我發佈",
 | 
				
			||||||
 | 
					  "stickersAdd": "添加貼圖包",
 | 
				
			||||||
 | 
					  "stickersAdded": "貼圖包已添加。",
 | 
				
			||||||
 | 
					  "add": "添加",
 | 
				
			||||||
 | 
					  "stickersRemoved": "貼圖包已被移除,你可以隨時再次添加回來。",
 | 
				
			||||||
 | 
					  "stickersReload": "重載貼圖包",
 | 
				
			||||||
 | 
					  "stickersReloadDescription": "從服務器重新加載添加過的貼圖,更新貼圖選擇器。",
 | 
				
			||||||
 | 
					  "stickersReloaded": "貼圖包已重載。",
 | 
				
			||||||
 | 
					  "stickersPackDelete": "刪除貼圖包 {}",
 | 
				
			||||||
 | 
					  "stickersPackDeleteDescription": "你確定要刪除這個貼圖包嗎?這個操作不可撤銷。",
 | 
				
			||||||
 | 
					  "stickersPackDeleted": "貼圖包已被刪除。",
 | 
				
			||||||
 | 
					  "stickersDelete": "刪除貼圖 {}",
 | 
				
			||||||
 | 
					  "stickersDeleteDescription": "你確定要刪除這個貼圖嗎?這個操作不可撤銷。",
 | 
				
			||||||
 | 
					  "stickersDeleted": "貼圖已被刪除。",
 | 
				
			||||||
 | 
					  "fieldStickerName": "貼圖名稱",
 | 
				
			||||||
 | 
					  "fieldStickerAlias": "貼圖別名",
 | 
				
			||||||
 | 
					  "fieldStickerAliasHint": "和貼圖包前綴組合成為本貼圖的唯一佔位符。",
 | 
				
			||||||
 | 
					  "fieldStickerPackName": "名稱",
 | 
				
			||||||
 | 
					  "fieldStickerPackDescription": "描述",
 | 
				
			||||||
 | 
					  "fieldStickerPackPrefix": "貼圖包前綴",
 | 
				
			||||||
 | 
					  "fieldStickerAttachment": "附件",
 | 
				
			||||||
 | 
					  "stickersNew": "新建貼圖",
 | 
				
			||||||
 | 
					  "stickersNewDescription": "創建一個新的貼圖。",
 | 
				
			||||||
 | 
					  "stickersPackNew": "新建貼圖包",
 | 
				
			||||||
 | 
					  "trayMenuShow": "顯示",
 | 
				
			||||||
 | 
					  "trayMenuMuteNotification": "靜音通知",
 | 
				
			||||||
 | 
					  "update": "更新",
 | 
				
			||||||
 | 
					  "forceUpdate": "強制更新",
 | 
				
			||||||
 | 
					  "forceUpdateDescription": "強制更新應用程序,即使有更新的版本可能不可用。",
 | 
				
			||||||
 | 
					  "runtimeLogs": "運行時日誌",
 | 
				
			||||||
 | 
					  "runtimeLogsOpen": "打開日誌文件",
 | 
				
			||||||
 | 
					  "runtimeLogsDescription": "顯示運行時的日誌記錄。",
 | 
				
			||||||
 | 
					  "signinResetPasswordHint": "請輸入用户名/電子郵箱地址以幫助我們找到您的帳户並重置密碼。",
 | 
				
			||||||
 | 
					  "cacheSize": "緩存資源大小",
 | 
				
			||||||
 | 
					  "cacheDelete": "清除緩存",
 | 
				
			||||||
 | 
					  "cacheDeleteDescription": "從磁盤中移除緩存的圖片和其他資源,內容將從服務器重新下載。",
 | 
				
			||||||
 | 
					  "cacheDeleted": "所有緩存已被清除。",
 | 
				
			||||||
 | 
					  "userNoDescription": "這個人很懶,沒有留下什麼……",
 | 
				
			||||||
 | 
					  "fieldTimeZone": "時區",
 | 
				
			||||||
 | 
					  "fieldGender": "性別",
 | 
				
			||||||
 | 
					  "fieldPronouns": "人稱代詞",
 | 
				
			||||||
 | 
					  "fieldLocation": "位置",
 | 
				
			||||||
 | 
					  "fieldLinks": "鏈接",
 | 
				
			||||||
 | 
					  "fieldLinkName": "名稱",
 | 
				
			||||||
 | 
					  "fieldLinkUrl": "鏈接",
 | 
				
			||||||
 | 
					  "screenAccountBadges": "徽章",
 | 
				
			||||||
 | 
					  "accountBadges": "徽章",
 | 
				
			||||||
 | 
					  "accountBadgesDescription": "查看並管理你的徽章。",
 | 
				
			||||||
 | 
					  "badgeActivated": "已佩戴徽章 {}。",
 | 
				
			||||||
 | 
					  "viewDetailedAttachment": "查看附件詳情",
 | 
				
			||||||
 | 
					  "screenKeyPairs": "密鑰對",
 | 
				
			||||||
 | 
					  "accountKeyPairs": "密鑰對",
 | 
				
			||||||
 | 
					  "accountKeyPairsDescription": "管理用於加密信息的密鑰對。",
 | 
				
			||||||
 | 
					  "enrollNewKeyPair": "新建密鑰對",
 | 
				
			||||||
 | 
					  "enrollNewKeyPairDescription": "生成一對新密鑰對。",
 | 
				
			||||||
 | 
					  "keyPairHasPrivateKey": "有私鑰",
 | 
				
			||||||
 | 
					  "decrypting": "解密中……",
 | 
				
			||||||
 | 
					  "decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線",
 | 
				
			||||||
 | 
					  "messageUnablePreview": "無法預覽消息",
 | 
				
			||||||
 | 
					  "messageUnablePreviewEncrypted": "無法預覽加密消息"
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -201,6 +201,11 @@
 | 
				
			|||||||
    "other": "{} 條評論"
 | 
					    "other": "{} 條評論"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "settingsAppearance": "外觀",
 | 
					  "settingsAppearance": "外觀",
 | 
				
			||||||
 | 
					  "settingsCustomFonts": "自定義字體",
 | 
				
			||||||
 | 
					  "settingsCustomFontsDescription": "設置應用程序使用的字體。",
 | 
				
			||||||
 | 
					  "settingsCustomFontFamily": "應用字體",
 | 
				
			||||||
 | 
					  "settingsCustomFontFamilyHint": "使用英文逗號分割每一種字體,越前優先級越高",
 | 
				
			||||||
 | 
					  "settingsCustomFontApplied": "自定義字體已經應用。",
 | 
				
			||||||
  "settingsDisplayLanguage": "顯示語言",
 | 
					  "settingsDisplayLanguage": "顯示語言",
 | 
				
			||||||
  "settingsDisplayLanguageDescription": "設置應用程序使用的語言",
 | 
					  "settingsDisplayLanguageDescription": "設置應用程序使用的語言",
 | 
				
			||||||
  "settingsDisplayLanguageSystem": "跟隨系統",
 | 
					  "settingsDisplayLanguageSystem": "跟隨系統",
 | 
				
			||||||
@@ -331,6 +336,7 @@
 | 
				
			|||||||
  "addAttachmentFromRandomId": "通過訪問 ID 鏈接",
 | 
					  "addAttachmentFromRandomId": "通過訪問 ID 鏈接",
 | 
				
			||||||
  "attachmentDetailInfo": "附件詳細信息",
 | 
					  "attachmentDetailInfo": "附件詳細信息",
 | 
				
			||||||
  "attachmentPastedImage": "粘貼的圖片",
 | 
					  "attachmentPastedImage": "粘貼的圖片",
 | 
				
			||||||
 | 
					  "attachmentInsertedImage": "插入的圖片",
 | 
				
			||||||
  "attachmentInsertLink": "插入連接",
 | 
					  "attachmentInsertLink": "插入連接",
 | 
				
			||||||
  "attachmentSetAsPostThumbnail": "設置為帖子縮略圖",
 | 
					  "attachmentSetAsPostThumbnail": "設置為帖子縮略圖",
 | 
				
			||||||
  "attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖",
 | 
					  "attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖",
 | 
				
			||||||
@@ -509,8 +515,13 @@
 | 
				
			|||||||
  "accountBirthday": "出生於 {}",
 | 
					  "accountBirthday": "出生於 {}",
 | 
				
			||||||
  "accountBadge": "徽章",
 | 
					  "accountBadge": "徽章",
 | 
				
			||||||
  "accountCheckInNoRecords": "暫無運勢記錄",
 | 
					  "accountCheckInNoRecords": "暫無運勢記錄",
 | 
				
			||||||
  "badgeCompanyStaff": "索爾辛茨士大夫 · 員工",
 | 
					  "badgeCompanyStaff": "工作人員",
 | 
				
			||||||
  "badgeSiteMigration": "Solar Network 原住民",
 | 
					  "badgeSiteMigration": "Solar Network 原住民",
 | 
				
			||||||
 | 
					  "badgeCommunitySurvey": "調研參與者",
 | 
				
			||||||
 | 
					  "badgeCommunityVerified": "認證用戶",
 | 
				
			||||||
 | 
					  "badgeCommunityContributor": "優秀社區貢獻者",
 | 
				
			||||||
 | 
					  "badgeSiteAnniversary": "週年紀念",
 | 
				
			||||||
 | 
					  "badgeUserBirthday": "生日紀念",
 | 
				
			||||||
  "accountStatus": "狀態",
 | 
					  "accountStatus": "狀態",
 | 
				
			||||||
  "accountStatusOnline": "在線",
 | 
					  "accountStatusOnline": "在線",
 | 
				
			||||||
  "accountStatusOffline": "離線",
 | 
					  "accountStatusOffline": "離線",
 | 
				
			||||||
@@ -545,6 +556,7 @@
 | 
				
			|||||||
  "termAcceptNextWithAgree": "點擊 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。",
 | 
					  "termAcceptNextWithAgree": "點擊 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。",
 | 
				
			||||||
  "unauthorized": "未登陸",
 | 
					  "unauthorized": "未登陸",
 | 
				
			||||||
  "unauthorizedDescription": "登陸以探索整個 Solar Network。",
 | 
					  "unauthorizedDescription": "登陸以探索整個 Solar Network。",
 | 
				
			||||||
 | 
					  "projectDetail": "項目詳情",
 | 
				
			||||||
  "serviceStatus": "服務狀態",
 | 
					  "serviceStatus": "服務狀態",
 | 
				
			||||||
  "termRelated": "相關條款",
 | 
					  "termRelated": "相關條款",
 | 
				
			||||||
  "appDetails": "應用程序詳情",
 | 
					  "appDetails": "應用程序詳情",
 | 
				
			||||||
@@ -580,6 +592,7 @@
 | 
				
			|||||||
  "colorSchemeBlack": "黑色",
 | 
					  "colorSchemeBlack": "黑色",
 | 
				
			||||||
  "colorSchemeApplied": "主題色已應用,可能需要重啟來生效。",
 | 
					  "colorSchemeApplied": "主題色已應用,可能需要重啟來生效。",
 | 
				
			||||||
  "postFeaturedComment": "精選評論",
 | 
					  "postFeaturedComment": "精選評論",
 | 
				
			||||||
 | 
					  "postCategory": "分類",
 | 
				
			||||||
  "postCategoryTechnology": "技術",
 | 
					  "postCategoryTechnology": "技術",
 | 
				
			||||||
  "postCategoryGaming": "遊戲",
 | 
					  "postCategoryGaming": "遊戲",
 | 
				
			||||||
  "postCategoryLife": "生活",
 | 
					  "postCategoryLife": "生活",
 | 
				
			||||||
@@ -623,6 +636,130 @@
 | 
				
			|||||||
  "realmJoin": "加入領域",
 | 
					  "realmJoin": "加入領域",
 | 
				
			||||||
  "realmCommunityHint": "該領域是一個社區領域,你可以自由加入。",
 | 
					  "realmCommunityHint": "該領域是一個社區領域,你可以自由加入。",
 | 
				
			||||||
  "realmCommunityPublicChannelsHint": "該領域包含的公共頻道",
 | 
					  "realmCommunityPublicChannelsHint": "該領域包含的公共頻道",
 | 
				
			||||||
 | 
					  "realmCommunityPublishersHint": "該領域的發佈者",
 | 
				
			||||||
  "realmJoined": "已加入領域 {}。",
 | 
					  "realmJoined": "已加入領域 {}。",
 | 
				
			||||||
  "join": "加入"
 | 
					  "join": "加入",
 | 
				
			||||||
 | 
					  "pollEditorNew": "新投票",
 | 
				
			||||||
 | 
					  "pollEditorEdit": "編輯投票",
 | 
				
			||||||
 | 
					  "pollEditorDelete": "刪除投票",
 | 
				
			||||||
 | 
					  "pollEditorDeleteDescription": "你確定要刪除這個投票嗎?該操作不可撤銷。",
 | 
				
			||||||
 | 
					  "pollEditorUnlink": "解除鏈接",
 | 
				
			||||||
 | 
					  "pollOptionAdd": "添加選項",
 | 
				
			||||||
 | 
					  "pollOptionName": "選項名稱",
 | 
				
			||||||
 | 
					  "pollLinkExisting": "鏈接現有投票",
 | 
				
			||||||
 | 
					  "pollAnswered": "答案已經反饋。",
 | 
				
			||||||
 | 
					  "pollVotes": {
 | 
				
			||||||
 | 
					    "one": "{} 票",
 | 
				
			||||||
 | 
					    "other": "{} 票"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "publisherDelete": "刪除發佈者 {}",
 | 
				
			||||||
 | 
					  "publisherDeleteDescription": "你確定要刪除這個發佈者嗎?該操作不可撤銷。",
 | 
				
			||||||
 | 
					  "channelIsPublic": "公開頻道",
 | 
				
			||||||
 | 
					  "channelIsPublicDescription": "該頻道是公開的,任何人都可以加入。",
 | 
				
			||||||
 | 
					  "channelIsCommunity": "社區頻道",
 | 
				
			||||||
 | 
					  "channelIsCommunityDescription": "目前來說,社區頻道還沒有什麼特別之處。",
 | 
				
			||||||
 | 
					  "realmIsPublic": "公開領域",
 | 
				
			||||||
 | 
					  "realmIsPublicDescription": "該領域是公開的,任何人都可以加入。",
 | 
				
			||||||
 | 
					  "realmIsCommunity": "社區領域",
 | 
				
			||||||
 | 
					  "realmIsCommunityDescription": "社區領域會顯示在發現頁面上。",
 | 
				
			||||||
 | 
					  "realmLeave": "離開領域",
 | 
				
			||||||
 | 
					  "realmLeaveDescription": "離開當前領域,並且刪除領域中的身份。",
 | 
				
			||||||
 | 
					  "checkInResultTier1": "大凶",
 | 
				
			||||||
 | 
					  "checkInResultTier2": "兇",
 | 
				
			||||||
 | 
					  "checkInResultTier3": "中平",
 | 
				
			||||||
 | 
					  "checkInResultTier4": "吉",
 | 
				
			||||||
 | 
					  "checkInResultTier5": "大吉",
 | 
				
			||||||
 | 
					  "flagPostAction": "吹哨",
 | 
				
			||||||
 | 
					  "flagPost": "吹哨不良內容",
 | 
				
			||||||
 | 
					  "flagPostDescription": "吹哨不良內容,如果吹哨用戶佔瀏覽量的 50% 或以上,則帖子會被摺疊。吹哨後不可撤銷。",
 | 
				
			||||||
 | 
					  "flaggedPost": "哨子已經吹響。",
 | 
				
			||||||
 | 
					  "postViews": {
 | 
				
			||||||
 | 
					    "zero": "{} 次瀏覽",
 | 
				
			||||||
 | 
					    "one": "{} 次瀏覽",
 | 
				
			||||||
 | 
					    "other": "{} 次瀏覽"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "attachmentBillingUploaded": "已佔用的字節數",
 | 
				
			||||||
 | 
					  "attachmentBillingDiscount": "免費的字節數",
 | 
				
			||||||
 | 
					  "attachmentBillingHint": "滑動窗口計價®\n在24小時內上傳的文件大小超出免費空間才會適用扣費。",
 | 
				
			||||||
 | 
					  "postThumbnail": "帖子縮略圖",
 | 
				
			||||||
 | 
					  "accountRealms": "領域",
 | 
				
			||||||
 | 
					  "postInGlobal": "全站",
 | 
				
			||||||
 | 
					  "postInGlobalDescription": "不關聯此帖子與任何領域。",
 | 
				
			||||||
 | 
					  "postChannelGlobal": "全站",
 | 
				
			||||||
 | 
					  "postChannelFriends": "好友",
 | 
				
			||||||
 | 
					  "postChannelFollowing": "關注",
 | 
				
			||||||
 | 
					  "postChannelRealm": "領域",
 | 
				
			||||||
 | 
					  "postFilterReset": "重置過濾器",
 | 
				
			||||||
 | 
					  "postFilterResetDescription": "清除過濾器並顯示所有帖子。",
 | 
				
			||||||
 | 
					  "postFilterWithCategory": "查看{}區中的帖子",
 | 
				
			||||||
 | 
					  "databaseSize": "數據庫大小",
 | 
				
			||||||
 | 
					  "databaseDelete": "刪除數據庫",
 | 
				
			||||||
 | 
					  "databaseDeleteDescription": "刪除本地數據庫,內容將從服務器重新獲取。",
 | 
				
			||||||
 | 
					  "databaseDeleted": "本地數據庫已被刪除。",
 | 
				
			||||||
 | 
					  "settingsEnablePushNotifications": "啟用推送數據",
 | 
				
			||||||
 | 
					  "settingsEnablePushNotificationsDescription": "重新啟用並請求推送權限,以防自動激活失敗。",
 | 
				
			||||||
 | 
					  "settingsEnabledPushNotifications": "推送通知已經註冊。",
 | 
				
			||||||
 | 
					  "screenStickers": "貼圖",
 | 
				
			||||||
 | 
					  "stickersDiscovery": "發現",
 | 
				
			||||||
 | 
					  "stickersOwned": "由我擁有",
 | 
				
			||||||
 | 
					  "stickersCreated": "由我發佈",
 | 
				
			||||||
 | 
					  "stickersAdd": "添加貼圖包",
 | 
				
			||||||
 | 
					  "stickersAdded": "貼圖包已添加。",
 | 
				
			||||||
 | 
					  "add": "添加",
 | 
				
			||||||
 | 
					  "stickersRemoved": "貼圖包已被移除,你可以隨時再次添加回來。",
 | 
				
			||||||
 | 
					  "stickersReload": "重載貼圖包",
 | 
				
			||||||
 | 
					  "stickersReloadDescription": "從服務器重新加載添加過的貼圖,更新貼圖選擇器。",
 | 
				
			||||||
 | 
					  "stickersReloaded": "貼圖包已重載。",
 | 
				
			||||||
 | 
					  "stickersPackDelete": "刪除貼圖包 {}",
 | 
				
			||||||
 | 
					  "stickersPackDeleteDescription": "你確定要刪除這個貼圖包嗎?這個操作不可撤銷。",
 | 
				
			||||||
 | 
					  "stickersPackDeleted": "貼圖包已被刪除。",
 | 
				
			||||||
 | 
					  "stickersDelete": "刪除貼圖 {}",
 | 
				
			||||||
 | 
					  "stickersDeleteDescription": "你確定要刪除這個貼圖嗎?這個操作不可撤銷。",
 | 
				
			||||||
 | 
					  "stickersDeleted": "貼圖已被刪除。",
 | 
				
			||||||
 | 
					  "fieldStickerName": "貼圖名稱",
 | 
				
			||||||
 | 
					  "fieldStickerAlias": "貼圖別名",
 | 
				
			||||||
 | 
					  "fieldStickerAliasHint": "和貼圖包前綴組合成為本貼圖的唯一佔位符。",
 | 
				
			||||||
 | 
					  "fieldStickerPackName": "名稱",
 | 
				
			||||||
 | 
					  "fieldStickerPackDescription": "描述",
 | 
				
			||||||
 | 
					  "fieldStickerPackPrefix": "貼圖包前綴",
 | 
				
			||||||
 | 
					  "fieldStickerAttachment": "附件",
 | 
				
			||||||
 | 
					  "stickersNew": "新建貼圖",
 | 
				
			||||||
 | 
					  "stickersNewDescription": "創建一個新的貼圖。",
 | 
				
			||||||
 | 
					  "stickersPackNew": "新建貼圖包",
 | 
				
			||||||
 | 
					  "trayMenuShow": "顯示",
 | 
				
			||||||
 | 
					  "trayMenuMuteNotification": "靜音通知",
 | 
				
			||||||
 | 
					  "update": "更新",
 | 
				
			||||||
 | 
					  "forceUpdate": "強制更新",
 | 
				
			||||||
 | 
					  "forceUpdateDescription": "強制更新應用程序,即使有更新的版本可能不可用。",
 | 
				
			||||||
 | 
					  "runtimeLogs": "運行時日誌",
 | 
				
			||||||
 | 
					  "runtimeLogsOpen": "打開日誌文件",
 | 
				
			||||||
 | 
					  "runtimeLogsDescription": "顯示運行時的日誌記錄。",
 | 
				
			||||||
 | 
					  "signinResetPasswordHint": "請輸入用戶名/電子郵箱地址以幫助我們找到您的帳戶並重置密碼。",
 | 
				
			||||||
 | 
					  "cacheSize": "緩存資源大小",
 | 
				
			||||||
 | 
					  "cacheDelete": "清除緩存",
 | 
				
			||||||
 | 
					  "cacheDeleteDescription": "從磁盤中移除緩存的圖片和其他資源,內容將從服務器重新下載。",
 | 
				
			||||||
 | 
					  "cacheDeleted": "所有緩存已被清除。",
 | 
				
			||||||
 | 
					  "userNoDescription": "這個人很懶,沒有留下什麼……",
 | 
				
			||||||
 | 
					  "fieldTimeZone": "時區",
 | 
				
			||||||
 | 
					  "fieldGender": "性別",
 | 
				
			||||||
 | 
					  "fieldPronouns": "人稱代詞",
 | 
				
			||||||
 | 
					  "fieldLocation": "位置",
 | 
				
			||||||
 | 
					  "fieldLinks": "鏈接",
 | 
				
			||||||
 | 
					  "fieldLinkName": "名稱",
 | 
				
			||||||
 | 
					  "fieldLinkUrl": "鏈接",
 | 
				
			||||||
 | 
					  "screenAccountBadges": "徽章",
 | 
				
			||||||
 | 
					  "accountBadges": "徽章",
 | 
				
			||||||
 | 
					  "accountBadgesDescription": "查看並管理你的徽章。",
 | 
				
			||||||
 | 
					  "badgeActivated": "已佩戴徽章 {}。",
 | 
				
			||||||
 | 
					  "viewDetailedAttachment": "查看附件詳情",
 | 
				
			||||||
 | 
					  "screenKeyPairs": "密鑰對",
 | 
				
			||||||
 | 
					  "accountKeyPairs": "密鑰對",
 | 
				
			||||||
 | 
					  "accountKeyPairsDescription": "管理用於加密信息的密鑰對。",
 | 
				
			||||||
 | 
					  "enrollNewKeyPair": "新建密鑰對",
 | 
				
			||||||
 | 
					  "enrollNewKeyPairDescription": "生成一對新密鑰對。",
 | 
				
			||||||
 | 
					  "keyPairHasPrivateKey": "有私鑰",
 | 
				
			||||||
 | 
					  "decrypting": "解密中……",
 | 
				
			||||||
 | 
					  "decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線",
 | 
				
			||||||
 | 
					  "messageUnablePreview": "無法預覽消息",
 | 
				
			||||||
 | 
					  "messageUnablePreviewEncrypted": "無法預覽加密消息"
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,3 +5,7 @@ targets:
 | 
				
			|||||||
        options:
 | 
					        options:
 | 
				
			||||||
          explicit_to_json: true
 | 
					          explicit_to_json: true
 | 
				
			||||||
          field_rename: snake
 | 
					          field_rename: snake
 | 
				
			||||||
 | 
					      drift_dev:
 | 
				
			||||||
 | 
					        options:
 | 
				
			||||||
 | 
					          databases:
 | 
				
			||||||
 | 
					            my_database: lib/database/database.dart
 | 
				
			||||||
							
								
								
									
										14
									
								
								debian/debian.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								debian/debian.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
				
			|||||||
 | 
					flutter_app: 
 | 
				
			||||||
 | 
					  command: surface
 | 
				
			||||||
 | 
					  arch: x64
 | 
				
			||||||
 | 
					  parent: /usr/local/lib
 | 
				
			||||||
 | 
					  nonInteractive: false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					control:
 | 
				
			||||||
 | 
					  Package: solian
 | 
				
			||||||
 | 
					  Version: 2.3.2
 | 
				
			||||||
 | 
					  Architecture: amd64
 | 
				
			||||||
 | 
					  Priority: optional
 | 
				
			||||||
 | 
					  Depends: mpv keybinder-3.0
 | 
				
			||||||
 | 
					  Maintainer: Solsynth LLC
 | 
				
			||||||
 | 
					  Description: The Solar Network Desktop Application
 | 
				
			||||||
							
								
								
									
										9
									
								
								debian/gui/surface.desktop
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								debian/gui/surface.desktop
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					[Desktop Entry]
 | 
				
			||||||
 | 
					Version=2.3.2
 | 
				
			||||||
 | 
					Name=Solian
 | 
				
			||||||
 | 
					GenericName=Solian
 | 
				
			||||||
 | 
					Comment=The Solar Network Desktop Application
 | 
				
			||||||
 | 
					Terminal=false
 | 
				
			||||||
 | 
					Type=Application
 | 
				
			||||||
 | 
					Categories=Social Networking
 | 
				
			||||||
 | 
					Keywords=social;social network;chat;solar network
 | 
				
			||||||
							
								
								
									
										23
									
								
								debian/gui/surface.svg
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								debian/gui/surface.svg
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| 
		 After Width: | Height: | Size: 232 KiB  | 
							
								
								
									
										1
									
								
								drift_schemas/my_database/drift_schema_v1.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								drift_schemas/my_database/drift_schema_v1.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"sn_local_chat_channel","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"alias","getter_name":"alias","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnChannelConverter()","dart_type_name":"SnChannel"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"sn_local_chat_message","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"channel_id","getter_name":"channelId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnMessageConverter()","dart_type_name":"SnChatMessage"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}}]}
 | 
				
			||||||
							
								
								
									
										1
									
								
								drift_schemas/my_database/drift_schema_v2.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								drift_schemas/my_database/drift_schema_v2.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"sn_local_chat_channel","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"alias","getter_name":"alias","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnChannelConverter()","dart_type_name":"SnChannel"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"sn_local_chat_message","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"channel_id","getter_name":"channelId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnMessageConverter()","dart_type_name":"SnChatMessage"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":2,"references":[],"type":"table","data":{"name":"sn_local_key_pair","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"account_id","getter_name":"accountId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"public_key","getter_name":"publicKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"private_key","getter_name":"privateKey","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_active","getter_name":"isActive","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_active\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_active\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["id"]}}]}
 | 
				
			||||||
							
								
								
									
										1
									
								
								drift_schemas/my_database/drift_schema_v3.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								drift_schemas/my_database/drift_schema_v3.json
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										139
									
								
								ios/Podfile.lock
									
									
									
									
									
								
							
							
						
						
									
										139
									
								
								ios/Podfile.lock
									
									
									
									
									
								
							@@ -37,63 +37,65 @@ PODS:
 | 
				
			|||||||
  - DKPhotoGallery/Resource (0.0.19):
 | 
					  - DKPhotoGallery/Resource (0.0.19):
 | 
				
			||||||
    - SDWebImage
 | 
					    - SDWebImage
 | 
				
			||||||
    - SwiftyGif
 | 
					    - SwiftyGif
 | 
				
			||||||
 | 
					  - fast_rsa (0.6.0):
 | 
				
			||||||
 | 
					    - Flutter
 | 
				
			||||||
  - file_picker (0.0.1):
 | 
					  - file_picker (0.0.1):
 | 
				
			||||||
    - DKImagePickerController/PhotoGallery
 | 
					    - DKImagePickerController/PhotoGallery
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
  - file_saver (0.0.1):
 | 
					  - file_saver (0.0.1):
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
  - Firebase/Analytics (11.7.0):
 | 
					  - Firebase/Analytics (11.8.0):
 | 
				
			||||||
    - Firebase/Core
 | 
					    - Firebase/Core
 | 
				
			||||||
  - Firebase/Core (11.7.0):
 | 
					  - Firebase/Core (11.8.0):
 | 
				
			||||||
    - Firebase/CoreOnly
 | 
					    - Firebase/CoreOnly
 | 
				
			||||||
    - FirebaseAnalytics (~> 11.7.0)
 | 
					    - FirebaseAnalytics (~> 11.8.0)
 | 
				
			||||||
  - Firebase/CoreOnly (11.7.0):
 | 
					  - Firebase/CoreOnly (11.8.0):
 | 
				
			||||||
    - FirebaseCore (~> 11.7.0)
 | 
					    - FirebaseCore (~> 11.8.0)
 | 
				
			||||||
  - Firebase/Messaging (11.7.0):
 | 
					  - Firebase/Messaging (11.8.0):
 | 
				
			||||||
    - Firebase/CoreOnly
 | 
					    - Firebase/CoreOnly
 | 
				
			||||||
    - FirebaseMessaging (~> 11.7.0)
 | 
					    - FirebaseMessaging (~> 11.8.0)
 | 
				
			||||||
  - firebase_analytics (11.4.2):
 | 
					  - firebase_analytics (11.4.4):
 | 
				
			||||||
    - Firebase/Analytics (= 11.7.0)
 | 
					    - Firebase/Analytics (= 11.8.0)
 | 
				
			||||||
    - firebase_core
 | 
					    - firebase_core
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
  - firebase_core (3.11.0):
 | 
					  - firebase_core (3.12.1):
 | 
				
			||||||
    - Firebase/CoreOnly (= 11.7.0)
 | 
					    - Firebase/CoreOnly (= 11.8.0)
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
  - firebase_messaging (15.2.2):
 | 
					  - firebase_messaging (15.2.4):
 | 
				
			||||||
    - Firebase/Messaging (= 11.7.0)
 | 
					    - Firebase/Messaging (= 11.8.0)
 | 
				
			||||||
    - firebase_core
 | 
					    - firebase_core
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
  - FirebaseAnalytics (11.7.0):
 | 
					  - FirebaseAnalytics (11.8.0):
 | 
				
			||||||
    - FirebaseAnalytics/AdIdSupport (= 11.7.0)
 | 
					    - FirebaseAnalytics/AdIdSupport (= 11.8.0)
 | 
				
			||||||
    - FirebaseCore (~> 11.7.0)
 | 
					    - FirebaseCore (~> 11.8.0)
 | 
				
			||||||
    - FirebaseInstallations (~> 11.0)
 | 
					    - FirebaseInstallations (~> 11.0)
 | 
				
			||||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
					    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
				
			||||||
    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
					    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
				
			||||||
    - GoogleUtilities/Network (~> 8.0)
 | 
					    - GoogleUtilities/Network (~> 8.0)
 | 
				
			||||||
    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
					    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
				
			||||||
    - nanopb (~> 3.30910.0)
 | 
					    - nanopb (~> 3.30910.0)
 | 
				
			||||||
  - FirebaseAnalytics/AdIdSupport (11.7.0):
 | 
					  - FirebaseAnalytics/AdIdSupport (11.8.0):
 | 
				
			||||||
    - FirebaseCore (~> 11.7.0)
 | 
					    - FirebaseCore (~> 11.8.0)
 | 
				
			||||||
    - FirebaseInstallations (~> 11.0)
 | 
					    - FirebaseInstallations (~> 11.0)
 | 
				
			||||||
    - GoogleAppMeasurement (= 11.7.0)
 | 
					    - GoogleAppMeasurement (= 11.8.0)
 | 
				
			||||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
					    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
				
			||||||
    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
					    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
				
			||||||
    - GoogleUtilities/Network (~> 8.0)
 | 
					    - GoogleUtilities/Network (~> 8.0)
 | 
				
			||||||
    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
					    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
				
			||||||
    - nanopb (~> 3.30910.0)
 | 
					    - nanopb (~> 3.30910.0)
 | 
				
			||||||
  - FirebaseCore (11.7.0):
 | 
					  - FirebaseCore (11.8.1):
 | 
				
			||||||
    - FirebaseCoreInternal (~> 11.7.0)
 | 
					    - FirebaseCoreInternal (~> 11.8.0)
 | 
				
			||||||
    - GoogleUtilities/Environment (~> 8.0)
 | 
					    - GoogleUtilities/Environment (~> 8.0)
 | 
				
			||||||
    - GoogleUtilities/Logger (~> 8.0)
 | 
					    - GoogleUtilities/Logger (~> 8.0)
 | 
				
			||||||
  - FirebaseCoreInternal (11.7.0):
 | 
					  - FirebaseCoreInternal (11.8.0):
 | 
				
			||||||
    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
					    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
				
			||||||
  - FirebaseInstallations (11.7.0):
 | 
					  - FirebaseInstallations (11.8.0):
 | 
				
			||||||
    - FirebaseCore (~> 11.7.0)
 | 
					    - FirebaseCore (~> 11.8.0)
 | 
				
			||||||
    - GoogleUtilities/Environment (~> 8.0)
 | 
					    - GoogleUtilities/Environment (~> 8.0)
 | 
				
			||||||
    - GoogleUtilities/UserDefaults (~> 8.0)
 | 
					    - GoogleUtilities/UserDefaults (~> 8.0)
 | 
				
			||||||
    - PromisesObjC (~> 2.4)
 | 
					    - PromisesObjC (~> 2.4)
 | 
				
			||||||
  - FirebaseMessaging (11.7.0):
 | 
					  - FirebaseMessaging (11.8.0):
 | 
				
			||||||
    - FirebaseCore (~> 11.7.0)
 | 
					    - FirebaseCore (~> 11.8.0)
 | 
				
			||||||
    - FirebaseInstallations (~> 11.0)
 | 
					    - FirebaseInstallations (~> 11.0)
 | 
				
			||||||
    - GoogleDataTransport (~> 10.0)
 | 
					    - GoogleDataTransport (~> 10.0)
 | 
				
			||||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
					    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
				
			||||||
@@ -113,6 +115,8 @@ PODS:
 | 
				
			|||||||
    - OrderedSet (~> 6.0.3)
 | 
					    - OrderedSet (~> 6.0.3)
 | 
				
			||||||
  - flutter_native_splash (2.4.3):
 | 
					  - flutter_native_splash (2.4.3):
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
 | 
					  - flutter_timezone (0.0.1):
 | 
				
			||||||
 | 
					    - Flutter
 | 
				
			||||||
  - flutter_udid (0.0.1):
 | 
					  - flutter_udid (0.0.1):
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
    - SAMKeychain
 | 
					    - SAMKeychain
 | 
				
			||||||
@@ -122,21 +126,23 @@ PODS:
 | 
				
			|||||||
  - gal (1.0.0):
 | 
					  - gal (1.0.0):
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
    - FlutterMacOS
 | 
					    - FlutterMacOS
 | 
				
			||||||
  - GoogleAppMeasurement (11.7.0):
 | 
					  - geolocator_apple (1.2.0):
 | 
				
			||||||
    - GoogleAppMeasurement/AdIdSupport (= 11.7.0)
 | 
					    - Flutter
 | 
				
			||||||
 | 
					  - GoogleAppMeasurement (11.8.0):
 | 
				
			||||||
 | 
					    - GoogleAppMeasurement/AdIdSupport (= 11.8.0)
 | 
				
			||||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
					    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
				
			||||||
    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
					    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
				
			||||||
    - GoogleUtilities/Network (~> 8.0)
 | 
					    - GoogleUtilities/Network (~> 8.0)
 | 
				
			||||||
    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
					    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
				
			||||||
    - nanopb (~> 3.30910.0)
 | 
					    - nanopb (~> 3.30910.0)
 | 
				
			||||||
  - GoogleAppMeasurement/AdIdSupport (11.7.0):
 | 
					  - GoogleAppMeasurement/AdIdSupport (11.8.0):
 | 
				
			||||||
    - GoogleAppMeasurement/WithoutAdIdSupport (= 11.7.0)
 | 
					    - GoogleAppMeasurement/WithoutAdIdSupport (= 11.8.0)
 | 
				
			||||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
					    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
				
			||||||
    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
					    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
				
			||||||
    - GoogleUtilities/Network (~> 8.0)
 | 
					    - GoogleUtilities/Network (~> 8.0)
 | 
				
			||||||
    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
					    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
				
			||||||
    - nanopb (~> 3.30910.0)
 | 
					    - nanopb (~> 3.30910.0)
 | 
				
			||||||
  - GoogleAppMeasurement/WithoutAdIdSupport (11.7.0):
 | 
					  - GoogleAppMeasurement/WithoutAdIdSupport (11.8.0):
 | 
				
			||||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
					    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
				
			||||||
    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
					    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
				
			||||||
    - GoogleUtilities/Network (~> 8.0)
 | 
					    - GoogleUtilities/Network (~> 8.0)
 | 
				
			||||||
@@ -179,7 +185,7 @@ PODS:
 | 
				
			|||||||
  - in_app_review (2.0.0):
 | 
					  - in_app_review (2.0.0):
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
  - Kingfisher (8.2.0)
 | 
					  - Kingfisher (8.2.0)
 | 
				
			||||||
  - livekit_client (2.3.6):
 | 
					  - livekit_client (2.4.0):
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
    - flutter_webrtc
 | 
					    - flutter_webrtc
 | 
				
			||||||
    - WebRTC-SDK (= 125.6422.06)
 | 
					    - WebRTC-SDK (= 125.6422.06)
 | 
				
			||||||
@@ -210,9 +216,9 @@ PODS:
 | 
				
			|||||||
  - SAMKeychain (1.5.3)
 | 
					  - SAMKeychain (1.5.3)
 | 
				
			||||||
  - screen_brightness_ios (0.1.0):
 | 
					  - screen_brightness_ios (0.1.0):
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
  - SDWebImage (5.20.0):
 | 
					  - SDWebImage (5.20.1):
 | 
				
			||||||
    - SDWebImage/Core (= 5.20.0)
 | 
					    - SDWebImage/Core (= 5.20.1)
 | 
				
			||||||
  - SDWebImage/Core (5.20.0)
 | 
					  - SDWebImage/Core (5.20.1)
 | 
				
			||||||
  - share_plus (0.0.1):
 | 
					  - share_plus (0.0.1):
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
  - shared_preferences_foundation (0.0.1):
 | 
					  - shared_preferences_foundation (0.0.1):
 | 
				
			||||||
@@ -221,6 +227,25 @@ PODS:
 | 
				
			|||||||
  - sqflite_darwin (0.0.4):
 | 
					  - sqflite_darwin (0.0.4):
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
    - FlutterMacOS
 | 
					    - FlutterMacOS
 | 
				
			||||||
 | 
					  - sqlite3 (3.49.1):
 | 
				
			||||||
 | 
					    - sqlite3/common (= 3.49.1)
 | 
				
			||||||
 | 
					  - sqlite3/common (3.49.1)
 | 
				
			||||||
 | 
					  - sqlite3/dbstatvtab (3.49.1):
 | 
				
			||||||
 | 
					    - sqlite3/common
 | 
				
			||||||
 | 
					  - sqlite3/fts5 (3.49.1):
 | 
				
			||||||
 | 
					    - sqlite3/common
 | 
				
			||||||
 | 
					  - sqlite3/perf-threadsafe (3.49.1):
 | 
				
			||||||
 | 
					    - sqlite3/common
 | 
				
			||||||
 | 
					  - sqlite3/rtree (3.49.1):
 | 
				
			||||||
 | 
					    - sqlite3/common
 | 
				
			||||||
 | 
					  - sqlite3_flutter_libs (0.0.1):
 | 
				
			||||||
 | 
					    - Flutter
 | 
				
			||||||
 | 
					    - FlutterMacOS
 | 
				
			||||||
 | 
					    - sqlite3 (~> 3.49.1)
 | 
				
			||||||
 | 
					    - sqlite3/dbstatvtab
 | 
				
			||||||
 | 
					    - sqlite3/fts5
 | 
				
			||||||
 | 
					    - sqlite3/perf-threadsafe
 | 
				
			||||||
 | 
					    - sqlite3/rtree
 | 
				
			||||||
  - SwiftyGif (5.4.5)
 | 
					  - SwiftyGif (5.4.5)
 | 
				
			||||||
  - url_launcher_ios (0.0.1):
 | 
					  - url_launcher_ios (0.0.1):
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
@@ -239,6 +264,7 @@ DEPENDENCIES:
 | 
				
			|||||||
  - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
 | 
					  - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
 | 
				
			||||||
  - croppy (from `.symlinks/plugins/croppy/ios`)
 | 
					  - croppy (from `.symlinks/plugins/croppy/ios`)
 | 
				
			||||||
  - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
 | 
					  - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
 | 
				
			||||||
 | 
					  - fast_rsa (from `.symlinks/plugins/fast_rsa/ios`)
 | 
				
			||||||
  - file_picker (from `.symlinks/plugins/file_picker/ios`)
 | 
					  - file_picker (from `.symlinks/plugins/file_picker/ios`)
 | 
				
			||||||
  - file_saver (from `.symlinks/plugins/file_saver/ios`)
 | 
					  - file_saver (from `.symlinks/plugins/file_saver/ios`)
 | 
				
			||||||
  - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
 | 
					  - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
 | 
				
			||||||
@@ -248,9 +274,11 @@ DEPENDENCIES:
 | 
				
			|||||||
  - flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`)
 | 
					  - flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`)
 | 
				
			||||||
  - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
 | 
					  - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
 | 
				
			||||||
  - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
 | 
					  - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
 | 
				
			||||||
 | 
					  - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`)
 | 
				
			||||||
  - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
 | 
					  - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
 | 
				
			||||||
  - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
 | 
					  - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
 | 
				
			||||||
  - gal (from `.symlinks/plugins/gal/darwin`)
 | 
					  - gal (from `.symlinks/plugins/gal/darwin`)
 | 
				
			||||||
 | 
					  - geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`)
 | 
				
			||||||
  - home_widget (from `.symlinks/plugins/home_widget/ios`)
 | 
					  - home_widget (from `.symlinks/plugins/home_widget/ios`)
 | 
				
			||||||
  - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
 | 
					  - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
 | 
				
			||||||
  - in_app_review (from `.symlinks/plugins/in_app_review/ios`)
 | 
					  - in_app_review (from `.symlinks/plugins/in_app_review/ios`)
 | 
				
			||||||
@@ -268,6 +296,7 @@ DEPENDENCIES:
 | 
				
			|||||||
  - share_plus (from `.symlinks/plugins/share_plus/ios`)
 | 
					  - share_plus (from `.symlinks/plugins/share_plus/ios`)
 | 
				
			||||||
  - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
 | 
					  - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
 | 
				
			||||||
  - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
 | 
					  - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
 | 
				
			||||||
 | 
					  - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
 | 
				
			||||||
  - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
 | 
					  - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
 | 
				
			||||||
  - video_compress (from `.symlinks/plugins/video_compress/ios`)
 | 
					  - video_compress (from `.symlinks/plugins/video_compress/ios`)
 | 
				
			||||||
  - volume_controller (from `.symlinks/plugins/volume_controller/ios`)
 | 
					  - volume_controller (from `.symlinks/plugins/volume_controller/ios`)
 | 
				
			||||||
@@ -294,6 +323,7 @@ SPEC REPOS:
 | 
				
			|||||||
    - PromisesObjC
 | 
					    - PromisesObjC
 | 
				
			||||||
    - SAMKeychain
 | 
					    - SAMKeychain
 | 
				
			||||||
    - SDWebImage
 | 
					    - SDWebImage
 | 
				
			||||||
 | 
					    - sqlite3
 | 
				
			||||||
    - SwiftyGif
 | 
					    - SwiftyGif
 | 
				
			||||||
    - WebRTC-SDK
 | 
					    - WebRTC-SDK
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -304,6 +334,8 @@ EXTERNAL SOURCES:
 | 
				
			|||||||
    :path: ".symlinks/plugins/croppy/ios"
 | 
					    :path: ".symlinks/plugins/croppy/ios"
 | 
				
			||||||
  device_info_plus:
 | 
					  device_info_plus:
 | 
				
			||||||
    :path: ".symlinks/plugins/device_info_plus/ios"
 | 
					    :path: ".symlinks/plugins/device_info_plus/ios"
 | 
				
			||||||
 | 
					  fast_rsa:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/fast_rsa/ios"
 | 
				
			||||||
  file_picker:
 | 
					  file_picker:
 | 
				
			||||||
    :path: ".symlinks/plugins/file_picker/ios"
 | 
					    :path: ".symlinks/plugins/file_picker/ios"
 | 
				
			||||||
  file_saver:
 | 
					  file_saver:
 | 
				
			||||||
@@ -322,12 +354,16 @@ EXTERNAL SOURCES:
 | 
				
			|||||||
    :path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
 | 
					    :path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
 | 
				
			||||||
  flutter_native_splash:
 | 
					  flutter_native_splash:
 | 
				
			||||||
    :path: ".symlinks/plugins/flutter_native_splash/ios"
 | 
					    :path: ".symlinks/plugins/flutter_native_splash/ios"
 | 
				
			||||||
 | 
					  flutter_timezone:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/flutter_timezone/ios"
 | 
				
			||||||
  flutter_udid:
 | 
					  flutter_udid:
 | 
				
			||||||
    :path: ".symlinks/plugins/flutter_udid/ios"
 | 
					    :path: ".symlinks/plugins/flutter_udid/ios"
 | 
				
			||||||
  flutter_webrtc:
 | 
					  flutter_webrtc:
 | 
				
			||||||
    :path: ".symlinks/plugins/flutter_webrtc/ios"
 | 
					    :path: ".symlinks/plugins/flutter_webrtc/ios"
 | 
				
			||||||
  gal:
 | 
					  gal:
 | 
				
			||||||
    :path: ".symlinks/plugins/gal/darwin"
 | 
					    :path: ".symlinks/plugins/gal/darwin"
 | 
				
			||||||
 | 
					  geolocator_apple:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/geolocator_apple/ios"
 | 
				
			||||||
  home_widget:
 | 
					  home_widget:
 | 
				
			||||||
    :path: ".symlinks/plugins/home_widget/ios"
 | 
					    :path: ".symlinks/plugins/home_widget/ios"
 | 
				
			||||||
  image_picker_ios:
 | 
					  image_picker_ios:
 | 
				
			||||||
@@ -360,6 +396,8 @@ EXTERNAL SOURCES:
 | 
				
			|||||||
    :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
 | 
					    :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
 | 
				
			||||||
  sqflite_darwin:
 | 
					  sqflite_darwin:
 | 
				
			||||||
    :path: ".symlinks/plugins/sqflite_darwin/darwin"
 | 
					    :path: ".symlinks/plugins/sqflite_darwin/darwin"
 | 
				
			||||||
 | 
					  sqlite3_flutter_libs:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
 | 
				
			||||||
  url_launcher_ios:
 | 
					  url_launcher_ios:
 | 
				
			||||||
    :path: ".symlinks/plugins/url_launcher_ios/ios"
 | 
					    :path: ".symlinks/plugins/url_launcher_ios/ios"
 | 
				
			||||||
  video_compress:
 | 
					  video_compress:
 | 
				
			||||||
@@ -378,32 +416,35 @@ SPEC CHECKSUMS:
 | 
				
			|||||||
  device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
 | 
					  device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
 | 
				
			||||||
  DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
 | 
					  DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
 | 
				
			||||||
  DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
 | 
					  DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
 | 
				
			||||||
 | 
					  fast_rsa: dc48fb05f26bb108863de122b2a9f5554e8e2591
 | 
				
			||||||
  file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
 | 
					  file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
 | 
				
			||||||
  file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
 | 
					  file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
 | 
				
			||||||
  Firebase: a64bf6a8546e6eab54f1c715cd6151f39d2329f4
 | 
					  Firebase: d80354ed7f6df5f9aca55e9eb47cc4b634735eaf
 | 
				
			||||||
  firebase_analytics: 7236e6115c1b4e62c2270faa29c052a317e31107
 | 
					  firebase_analytics: e3b6782e70e32b7fa18f7cd233e3201975dd86aa
 | 
				
			||||||
  firebase_core: aa979ae726f00b3ef4ccf59dfb96170af84efbd4
 | 
					  firebase_core: ac395f994af4e28f6a38b59e05a88ca57abeb874
 | 
				
			||||||
  firebase_messaging: 3af84b6a90aeac4d7a67fbf4c43a91e7083bea1f
 | 
					  firebase_messaging: 7e223f4ee7ca053bf4ce43748e84a6d774ec9728
 | 
				
			||||||
  FirebaseAnalytics: bc9e565af9044ba8d6c6e4157e4edca8e5fdf7ec
 | 
					  FirebaseAnalytics: 4fd42def128146e24e480e89f310e3d8534ea42b
 | 
				
			||||||
  FirebaseCore: 3227e35f4197a924206fbcdc0349325baf4f5de4
 | 
					  FirebaseCore: 99fe0c4b44a39f37d99e6404e02009d2db5d718d
 | 
				
			||||||
  FirebaseCoreInternal: d6c17dafc8dc33614733a8b52df78fcb4394c881
 | 
					  FirebaseCoreInternal: df24ce5af28864660ecbd13596fc8dd3a8c34629
 | 
				
			||||||
  FirebaseInstallations: 9347e719c3d52d8d7b9074b2c32407dd027305e9
 | 
					  FirebaseInstallations: 6c963bd2a86aca0481eef4f48f5a4df783ae5917
 | 
				
			||||||
  FirebaseMessaging: 00ece041b71ddb52a2862ffdee73fb6e9824bd0c
 | 
					  FirebaseMessaging: 487b634ccdf6f7b7ff180fdcb2a9935490f764e8
 | 
				
			||||||
  Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
 | 
					  Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
 | 
				
			||||||
  flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc
 | 
					  flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc
 | 
				
			||||||
  flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4
 | 
					  flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4
 | 
				
			||||||
  flutter_native_splash: f71420956eb811e6d310720fee915f1d42852e7a
 | 
					  flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
 | 
				
			||||||
 | 
					  flutter_timezone: ac3da59ac941ff1c98a2e1f0293420e020120282
 | 
				
			||||||
  flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab
 | 
					  flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab
 | 
				
			||||||
  flutter_webrtc: 90260f83024b1b96d239a575ea4e3708e79344d1
 | 
					  flutter_webrtc: 90260f83024b1b96d239a575ea4e3708e79344d1
 | 
				
			||||||
  gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
 | 
					  gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
 | 
				
			||||||
  GoogleAppMeasurement: 0471a5b5bff51f3a91b1e76df22c952d04c63967
 | 
					  geolocator_apple: 9bcea1918ff7f0062d98345d238ae12718acfbc1
 | 
				
			||||||
 | 
					  GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896
 | 
				
			||||||
  GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
 | 
					  GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
 | 
				
			||||||
  GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
 | 
					  GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
 | 
				
			||||||
  home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
 | 
					  home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
 | 
				
			||||||
  image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
 | 
					  image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
 | 
				
			||||||
  in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
 | 
					  in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
 | 
				
			||||||
  Kingfisher: 323e5c4ec7983aaace12af655a7b51a7f88a599d
 | 
					  Kingfisher: 323e5c4ec7983aaace12af655a7b51a7f88a599d
 | 
				
			||||||
  livekit_client: 148b2cf67a09aaf475ba8e5bf1667fe10dc35f81
 | 
					  livekit_client: 9819ebc8be8ef00ed0fae7d806bf8938ec689573
 | 
				
			||||||
  media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
 | 
					  media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
 | 
				
			||||||
  media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
 | 
					  media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
 | 
				
			||||||
  media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
 | 
					  media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
 | 
				
			||||||
@@ -417,10 +458,12 @@ SPEC CHECKSUMS:
 | 
				
			|||||||
  receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1
 | 
					  receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1
 | 
				
			||||||
  SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
 | 
					  SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
 | 
				
			||||||
  screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
 | 
					  screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
 | 
				
			||||||
  SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8
 | 
					  SDWebImage: 33d0f23bddeb5d209ae959153883247be6703713
 | 
				
			||||||
  share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
 | 
					  share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
 | 
				
			||||||
  shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
 | 
					  shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
 | 
				
			||||||
  sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
 | 
					  sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
 | 
				
			||||||
 | 
					  sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
 | 
				
			||||||
 | 
					  sqlite3_flutter_libs: cc304edcb8e1d8c595d1b08c7aeb46a47691d9db
 | 
				
			||||||
  SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
 | 
					  SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
 | 
				
			||||||
  url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
 | 
					  url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
 | 
				
			||||||
  video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe
 | 
					  video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -59,6 +59,7 @@
 | 
				
			|||||||
      ignoresPersistentStateOnLaunch = "NO"
 | 
					      ignoresPersistentStateOnLaunch = "NO"
 | 
				
			||||||
      debugDocumentVersioning = "YES"
 | 
					      debugDocumentVersioning = "YES"
 | 
				
			||||||
      debugServiceExtension = "internal"
 | 
					      debugServiceExtension = "internal"
 | 
				
			||||||
 | 
					      enableGPUValidationMode = "1"
 | 
				
			||||||
      allowLocationSimulation = "YES">
 | 
					      allowLocationSimulation = "YES">
 | 
				
			||||||
      <BuildableProductRunnable
 | 
					      <BuildableProductRunnable
 | 
				
			||||||
         runnableDebuggingMode = "0">
 | 
					         runnableDebuggingMode = "0">
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -123,48 +123,59 @@ class NotificationService: UNNotificationServiceExtension {
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        if let imageIdentifier = metadata["image"] as? String {
 | 
					        if let imageIdentifier = metadata["image"] as? String {
 | 
				
			||||||
            attachMedia(to: content, withIdentifier: imageIdentifier, fileType: UTType.jpeg, doScaleDown: true)
 | 
					            attachMedia(to: content, withIdentifier: [imageIdentifier], fileType: UTType.jpeg, doScaleDown: true)
 | 
				
			||||||
        } else if let avatarIdentifier = metadata["avatar"] as? String {
 | 
					        } else if let avatarIdentifier = metadata["avatar"] as? String {
 | 
				
			||||||
            attachMedia(to: content, withIdentifier: avatarIdentifier, fileType: UTType.jpeg, doScaleDown: true)
 | 
					            attachMedia(to: content, withIdentifier: [avatarIdentifier], fileType: UTType.jpeg, doScaleDown: true)
 | 
				
			||||||
 | 
					        } else if let imagesIdentifier = metadata["images"] as? Array<String> {
 | 
				
			||||||
 | 
					            attachMedia(to: content, withIdentifier: imagesIdentifier, fileType: UTType.jpeg, doScaleDown: true)
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
            contentHandler?(content)
 | 
					            contentHandler?(content)
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    private func attachMedia(to content: UNMutableNotificationContent, withIdentifier identifier: String, fileType type: UTType?, doScaleDown scaleDown: Bool = false) {
 | 
					    private func attachMedia(to content: UNMutableNotificationContent, withIdentifier identifier: Array<String>, fileType type: UTType?, doScaleDown scaleDown: Bool = false) {
 | 
				
			||||||
        let attachmentUrl = getAttachmentUrl(for: identifier)
 | 
					        let attachmentUrls = identifier.compactMap { element in
 | 
				
			||||||
 | 
					            return getAttachmentUrl(for: element)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        guard let remoteUrl = URL(string: attachmentUrl) else {
 | 
					        guard !attachmentUrls.isEmpty else {
 | 
				
			||||||
            print("Invalid URL for attachment: \(attachmentUrl)")
 | 
					            print("Invalid URLs for attachments: \(attachmentUrls)")
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        let targetSize = 800
 | 
					        let targetSize = 800
 | 
				
			||||||
        let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit)
 | 
					        let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        KingfisherManager.shared.retrieveImage(with: remoteUrl, options: scaleDown ? [
 | 
					        for attachmentUrl in attachmentUrls {
 | 
				
			||||||
            .processor(scaleProcessor)
 | 
					            guard let remoteUrl = URL(string: attachmentUrl) else {
 | 
				
			||||||
        ] : nil) { [weak self] result in
 | 
					                print("Invalid URL for attachment: \(attachmentUrl)")
 | 
				
			||||||
            guard let self = self else { return }
 | 
					                continue // Skip this URL and move to the next one
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            switch result {
 | 
					            KingfisherManager.shared.retrieveImage(with: remoteUrl, options: scaleDown ? [
 | 
				
			||||||
            case .success(let retrievalResult):
 | 
					                .processor(scaleProcessor)
 | 
				
			||||||
                // The image is either retrieved from cache or downloaded
 | 
					            ] : nil) { [weak self] result in
 | 
				
			||||||
                let tempDirectory = FileManager.default.temporaryDirectory
 | 
					                guard let self = self else { return }
 | 
				
			||||||
                let cachedFileUrl = tempDirectory.appendingPathComponent(identifier)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
                do {
 | 
					                switch result {
 | 
				
			||||||
                    // Write the image data to a temporary file for UNNotificationAttachment
 | 
					                case .success(let retrievalResult):
 | 
				
			||||||
                    try retrievalResult.image.pngData()?.write(to: cachedFileUrl)
 | 
					                    // The image is either retrieved from cache or downloaded
 | 
				
			||||||
                    self.attachLocalMedia(to: content, fileType: type?.identifier, from: cachedFileUrl, withIdentifier: identifier)
 | 
					                    let tempDirectory = FileManager.default.temporaryDirectory
 | 
				
			||||||
                } catch {
 | 
					                    let cachedFileUrl = tempDirectory.appendingPathComponent(UUID().uuidString) // Unique identifier for each file
 | 
				
			||||||
                    print("Failed to write media to temporary file: \(error.localizedDescription)")
 | 
					
 | 
				
			||||||
 | 
					                    do {
 | 
				
			||||||
 | 
					                        // Write the image data to a temporary file for UNNotificationAttachment
 | 
				
			||||||
 | 
					                        try retrievalResult.image.pngData()?.write(to: cachedFileUrl)
 | 
				
			||||||
 | 
					                        self.attachLocalMedia(to: content, fileType: type?.identifier, from: cachedFileUrl, withIdentifier: attachmentUrl)
 | 
				
			||||||
 | 
					                    } catch {
 | 
				
			||||||
 | 
					                        print("Failed to write media to temporary file: \(error.localizedDescription)")
 | 
				
			||||||
 | 
					                        self.contentHandler?(content)
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                case .failure(let error):
 | 
				
			||||||
 | 
					                    print("Failed to retrieve image: \(error.localizedDescription)")
 | 
				
			||||||
                    self.contentHandler?(content)
 | 
					                    self.contentHandler?(content)
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
                
 | 
					 | 
				
			||||||
            case .failure(let error):
 | 
					 | 
				
			||||||
                print("Failed to retrieve image: \(error.localizedDescription)")
 | 
					 | 
				
			||||||
                self.contentHandler?(content)
 | 
					 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -55,7 +55,7 @@ struct CheckInEntry: TimelineEntry {
 | 
				
			|||||||
struct CheckInWidgetEntryView : View {
 | 
					struct CheckInWidgetEntryView : View {
 | 
				
			||||||
    var entry: CheckInProvider.Entry
 | 
					    var entry: CheckInProvider.Entry
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private let resultTierSymbols: [String] = ["大凶", "凶", "中平", "吉", "大吉"]
 | 
					    private let resultTierSymbols: [String] = ["Bad", "Poor", "Medium", "Good", "Great"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    func checkIn() -> Void {}
 | 
					    func checkIn() -> Void {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -91,7 +91,7 @@ struct CheckInWidgetEntryView : View {
 | 
				
			|||||||
            } else {
 | 
					            } else {
 | 
				
			||||||
                VStack(alignment: .leading) {
 | 
					                VStack(alignment: .leading) {
 | 
				
			||||||
                    Text("Check In").font(.system(size: 19, weight: .bold))
 | 
					                    Text("Check In").font(.system(size: 19, weight: .bold))
 | 
				
			||||||
                    Text("You haven't check in today").font(.system(size: 15))
 | 
					                    Text("You haven't divined today").font(.system(size: 15))
 | 
				
			||||||
                }.padding(.horizontal, 4)
 | 
					                }.padding(.horizontal, 4)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                Spacer()
 | 
					                Spacer()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,11 +2,15 @@ import 'dart:async';
 | 
				
			|||||||
import 'dart:convert';
 | 
					import 'dart:convert';
 | 
				
			||||||
import 'dart:math' as math;
 | 
					import 'dart:math' as math;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import 'package:collection/collection.dart';
 | 
					 | 
				
			||||||
import 'package:dio/dio.dart';
 | 
					import 'package:dio/dio.dart';
 | 
				
			||||||
 | 
					import 'package:drift/drift.dart';
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:hive/hive.dart';
 | 
					 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/database/database.dart';
 | 
				
			||||||
 | 
					import 'package:surface/logger.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/channel.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/database.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/keypair.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_attachment.dart';
 | 
					import 'package:surface/providers/sn_attachment.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/providers/user_directory.dart';
 | 
					import 'package:surface/providers/user_directory.dart';
 | 
				
			||||||
@@ -16,13 +20,15 @@ import 'package:surface/types/websocket.dart';
 | 
				
			|||||||
import 'package:uuid/uuid.dart';
 | 
					import 'package:uuid/uuid.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ChatMessageController extends ChangeNotifier {
 | 
					class ChatMessageController extends ChangeNotifier {
 | 
				
			||||||
  static const kChatMessageBoxPrefix = 'nex_chat_messages_';
 | 
					 | 
				
			||||||
  static const kSingleBatchLoadLimit = 100;
 | 
					  static const kSingleBatchLoadLimit = 100;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  late final SnNetworkProvider _sn;
 | 
					  late final SnNetworkProvider _sn;
 | 
				
			||||||
  late final UserDirectoryProvider _ud;
 | 
					  late final UserDirectoryProvider _ud;
 | 
				
			||||||
  late final WebSocketProvider _ws;
 | 
					  late final WebSocketProvider _ws;
 | 
				
			||||||
  late final SnAttachmentProvider _attach;
 | 
					  late final SnAttachmentProvider _attach;
 | 
				
			||||||
 | 
					  late final DatabaseProvider _dt;
 | 
				
			||||||
 | 
					  late final ChatChannelProvider _ct;
 | 
				
			||||||
 | 
					  late final KeyPairProvider _kp;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  StreamSubscription? _wsSubscription;
 | 
					  StreamSubscription? _wsSubscription;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -31,16 +37,20 @@ class ChatMessageController extends ChangeNotifier {
 | 
				
			|||||||
    _ud = context.read<UserDirectoryProvider>();
 | 
					    _ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
    _ws = context.read<WebSocketProvider>();
 | 
					    _ws = context.read<WebSocketProvider>();
 | 
				
			||||||
    _attach = context.read<SnAttachmentProvider>();
 | 
					    _attach = context.read<SnAttachmentProvider>();
 | 
				
			||||||
 | 
					    _ct = context.read<ChatChannelProvider>();
 | 
				
			||||||
 | 
					    _dt = context.read<DatabaseProvider>();
 | 
				
			||||||
 | 
					    _kp = context.read<KeyPairProvider>();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  bool isPending = true;
 | 
					  bool isPending = true;
 | 
				
			||||||
  bool isLoading = false;
 | 
					  bool isLoading = false;
 | 
				
			||||||
 | 
					  bool isAggressiveLoading = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  int? messageTotal;
 | 
					  int? messageTotal;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  bool get isAllLoaded => messageTotal != null && messages.length >= messageTotal!;
 | 
					  bool get isAllLoaded =>
 | 
				
			||||||
 | 
					      messageTotal != null && messages.length >= messageTotal!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  String? _boxKey;
 | 
					 | 
				
			||||||
  SnChannel? channel;
 | 
					  SnChannel? channel;
 | 
				
			||||||
  SnChannelMember? profile;
 | 
					  SnChannelMember? profile;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -51,25 +61,14 @@ class ChatMessageController extends ChangeNotifier {
 | 
				
			|||||||
  /// Stored as a list of nonce to provide the loading state
 | 
					  /// Stored as a list of nonce to provide the loading state
 | 
				
			||||||
  final List<String> unconfirmedMessages = List.empty(growable: true);
 | 
					  final List<String> unconfirmedMessages = List.empty(growable: true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Box<SnChatMessage>? get _box => (_boxKey == null || isPending) ? null : Hive.box<SnChatMessage>(_boxKey!);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  final List<SnChannelMember> typingMembers = List.empty(growable: true);
 | 
					  final List<SnChannelMember> typingMembers = List.empty(growable: true);
 | 
				
			||||||
  final Map<int, Timer> typingInactiveTimer = {};
 | 
					  final Map<int, Timer> typingInactiveTimer = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> initialize(SnChannel chan) async {
 | 
					  Future<void> initialize(SnChannel chan) async {
 | 
				
			||||||
    channel = chan;
 | 
					    channel = chan;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Initialize local data
 | 
					 | 
				
			||||||
    _boxKey = '$kChatMessageBoxPrefix${chan.id}';
 | 
					 | 
				
			||||||
    await Hive.openBox<SnChatMessage>(_boxKey!);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Fetch channel profile
 | 
					    // Fetch channel profile
 | 
				
			||||||
    final resp = await _sn.client.get(
 | 
					    profile = await _ct.getChannelProfile(channel!);
 | 
				
			||||||
      '/cgi/im/channels/${chan.keyPath}/me',
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    profile = SnChannelMember.fromJson(
 | 
					 | 
				
			||||||
      resp.data as Map<String, dynamic>,
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    _wsSubscription = _ws.pk.stream.listen((event) {
 | 
					    _wsSubscription = _ws.pk.stream.listen((event) {
 | 
				
			||||||
      switch (event.method) {
 | 
					      switch (event.method) {
 | 
				
			||||||
@@ -87,7 +86,8 @@ class ChatMessageController extends ChangeNotifier {
 | 
				
			|||||||
            notifyListeners();
 | 
					            notifyListeners();
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
          typingInactiveTimer[member.id]?.cancel();
 | 
					          typingInactiveTimer[member.id]?.cancel();
 | 
				
			||||||
          typingInactiveTimer[member.id] = Timer(const Duration(seconds: 3), () {
 | 
					          typingInactiveTimer[member.id] =
 | 
				
			||||||
 | 
					              Timer(const Duration(seconds: 3), () {
 | 
				
			||||||
            typingMembers.removeWhere((x) => x.id == member.id);
 | 
					            typingMembers.removeWhere((x) => x.id == member.id);
 | 
				
			||||||
            typingInactiveTimer.remove(member.id);
 | 
					            typingInactiveTimer.remove(member.id);
 | 
				
			||||||
            notifyListeners();
 | 
					            notifyListeners();
 | 
				
			||||||
@@ -129,10 +129,16 @@ class ChatMessageController extends ChangeNotifier {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> _saveMessageToLocal(Iterable<SnChatMessage> messages) async {
 | 
					  Future<void> _saveMessageToLocal(Iterable<SnChatMessage> messages) async {
 | 
				
			||||||
    if (_box == null) return;
 | 
					    await _dt.db.snLocalChatMessage.insertAll(
 | 
				
			||||||
    await _box!.putAll({
 | 
					        messages.map(
 | 
				
			||||||
      for (final message in messages) message.id: message,
 | 
					          (ele) => SnLocalChatMessageCompanion.insert(
 | 
				
			||||||
    });
 | 
					            id: Value(ele.id),
 | 
				
			||||||
 | 
					            content: ele,
 | 
				
			||||||
 | 
					            channelId: channel!.id,
 | 
				
			||||||
 | 
					            createdAt: Value(ele.createdAt),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        onConflict: DoNothing());
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> _addUnconfirmedMessage(SnChatMessage message) async {
 | 
					  Future<void> _addUnconfirmedMessage(SnChatMessage message) async {
 | 
				
			||||||
@@ -181,11 +187,27 @@ class ChatMessageController extends ChangeNotifier {
 | 
				
			|||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      messages.insert(0, message);
 | 
					      messages.insert(0, message);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
    await _applyMessage(message);
 | 
					    await _applyMessage(message);
 | 
				
			||||||
    notifyListeners();
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (_box == null) return;
 | 
					    if (isCheckedUpdate) {
 | 
				
			||||||
    await _box!.put(message.id, message);
 | 
					      await _dt.db.snLocalChatMessage.insertOne(
 | 
				
			||||||
 | 
					        SnLocalChatMessageCompanion.insert(
 | 
				
			||||||
 | 
					          id: Value(message.id),
 | 
				
			||||||
 | 
					          content: message,
 | 
				
			||||||
 | 
					          channelId: channel!.id,
 | 
				
			||||||
 | 
					          createdAt: Value(message.createdAt),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        onConflict: DoUpdate(
 | 
				
			||||||
 | 
					          (_) => SnLocalChatMessageCompanion.custom(
 | 
				
			||||||
 | 
					            content: Constant(jsonEncode(message.toJson())),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      incomeStrandedQueue.add(message);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> _applyMessage(SnChatMessage message) async {
 | 
					  Future<void> _applyMessage(SnChatMessage message) async {
 | 
				
			||||||
@@ -194,29 +216,56 @@ class ChatMessageController extends ChangeNotifier {
 | 
				
			|||||||
    switch (message.type) {
 | 
					    switch (message.type) {
 | 
				
			||||||
      case 'messages.edit':
 | 
					      case 'messages.edit':
 | 
				
			||||||
        if (message.relatedEventId != null) {
 | 
					        if (message.relatedEventId != null) {
 | 
				
			||||||
          final idx = messages.indexWhere((x) => x.id == message.relatedEventId);
 | 
					          final idx =
 | 
				
			||||||
 | 
					              messages.indexWhere((x) => x.id == message.relatedEventId);
 | 
				
			||||||
          if (idx != -1) {
 | 
					          if (idx != -1) {
 | 
				
			||||||
            final newBody = message.body;
 | 
					            final newBody = Map<String, dynamic>.from(message.body);
 | 
				
			||||||
            newBody.remove('related_event');
 | 
					            newBody.remove('related_event');
 | 
				
			||||||
            messages[idx] = messages[idx].copyWith(
 | 
					            messages[idx] = messages[idx].copyWith(
 | 
				
			||||||
              body: newBody,
 | 
					              body: newBody,
 | 
				
			||||||
              updatedAt: message.updatedAt,
 | 
					              updatedAt: message.updatedAt,
 | 
				
			||||||
            );
 | 
					            );
 | 
				
			||||||
            if (_box!.containsKey(message.relatedEventId)) {
 | 
					          }
 | 
				
			||||||
              await _box!.put(message.relatedEventId, messages[idx]);
 | 
					          if (message.relatedEventId != null) {
 | 
				
			||||||
            }
 | 
					            await (_dt.db.snLocalChatMessage.update()
 | 
				
			||||||
 | 
					                  ..where((e) => e.id.equals(message.relatedEventId!)))
 | 
				
			||||||
 | 
					                .write(
 | 
				
			||||||
 | 
					              SnLocalChatMessageCompanion.custom(
 | 
				
			||||||
 | 
					                content: Constant(jsonEncode(messages[idx].toJson())),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      case 'messages.delete':
 | 
					      case 'messages.delete':
 | 
				
			||||||
        if (message.relatedEventId != null) {
 | 
					        if (message.relatedEventId != null) {
 | 
				
			||||||
          messages.removeWhere((x) => x.id == message.relatedEventId);
 | 
					          messages.removeWhere((x) => x.id == message.relatedEventId);
 | 
				
			||||||
          if (_box!.containsKey(message.relatedEventId)) {
 | 
					          if (message.relatedEventId != null) {
 | 
				
			||||||
            await _box!.delete(message.relatedEventId);
 | 
					            await (_dt.db.snLocalChatMessage.delete()
 | 
				
			||||||
 | 
					                  ..where((e) => e.id.equals(message.relatedEventId!)))
 | 
				
			||||||
 | 
					                .go();
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<Map<String, dynamic>> _encodeMessageBody(
 | 
				
			||||||
 | 
					    String text,
 | 
				
			||||||
 | 
					    bool isEncrypted,
 | 
				
			||||||
 | 
					  ) async {
 | 
				
			||||||
 | 
					    if (!isEncrypted || _kp.activeKp == null) {
 | 
				
			||||||
 | 
					      return {
 | 
				
			||||||
 | 
					        'text': text,
 | 
				
			||||||
 | 
					        'algorithm': 'plain',
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      return {
 | 
				
			||||||
 | 
					        'text': await _kp.encryptText(text),
 | 
				
			||||||
 | 
					        'algorithm': 'rsa',
 | 
				
			||||||
 | 
					        'keypair_id': _kp.activeKp!.id,
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> sendMessage(
 | 
					  Future<void> sendMessage(
 | 
				
			||||||
    String type,
 | 
					    String type,
 | 
				
			||||||
    String content, {
 | 
					    String content, {
 | 
				
			||||||
@@ -224,36 +273,40 @@ class ChatMessageController extends ChangeNotifier {
 | 
				
			|||||||
    int? relatedId,
 | 
					    int? relatedId,
 | 
				
			||||||
    List<String>? attachments,
 | 
					    List<String>? attachments,
 | 
				
			||||||
    SnChatMessage? editingMessage,
 | 
					    SnChatMessage? editingMessage,
 | 
				
			||||||
 | 
					    bool isEncrypted = false,
 | 
				
			||||||
  }) async {
 | 
					  }) async {
 | 
				
			||||||
    if (channel == null) return;
 | 
					    if (channel == null) return;
 | 
				
			||||||
    const uuid = Uuid();
 | 
					    const uuid = Uuid();
 | 
				
			||||||
    final nonce = uuid.v4();
 | 
					    final nonce = uuid.v4();
 | 
				
			||||||
    final body = {
 | 
					    final body = {
 | 
				
			||||||
      'text': content,
 | 
					      ...(await _encodeMessageBody(content, isEncrypted)),
 | 
				
			||||||
      'algorithm': 'plain',
 | 
					 | 
				
			||||||
      if (quoteId != null) 'quote_event': quoteId,
 | 
					      if (quoteId != null) 'quote_event': quoteId,
 | 
				
			||||||
      if (relatedId != null) 'related_event': relatedId,
 | 
					      if (relatedId != null) 'related_event': relatedId,
 | 
				
			||||||
      if (attachments != null && attachments.isNotEmpty) 'attachments': attachments,
 | 
					      if (attachments != null && attachments.isNotEmpty)
 | 
				
			||||||
 | 
					        'attachments': attachments,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Mock the message locally
 | 
					    // Mock the message locally
 | 
				
			||||||
    final createdAt = DateTime.now();
 | 
					    // Do not mock the editing message
 | 
				
			||||||
    final message = SnChatMessage(
 | 
					    if (editingMessage == null) {
 | 
				
			||||||
      id: 0,
 | 
					      final createdAt = DateTime.now();
 | 
				
			||||||
      createdAt: createdAt,
 | 
					      final message = SnChatMessage(
 | 
				
			||||||
      updatedAt: createdAt,
 | 
					        id: 0,
 | 
				
			||||||
      deletedAt: null,
 | 
					        createdAt: createdAt,
 | 
				
			||||||
      uuid: nonce,
 | 
					        updatedAt: createdAt,
 | 
				
			||||||
      body: body,
 | 
					        deletedAt: null,
 | 
				
			||||||
      type: type,
 | 
					        uuid: nonce,
 | 
				
			||||||
      channel: channel!,
 | 
					        body: body,
 | 
				
			||||||
      channelId: channel!.id,
 | 
					        type: type,
 | 
				
			||||||
      sender: profile!,
 | 
					        channel: channel!,
 | 
				
			||||||
      senderId: profile!.id,
 | 
					        channelId: channel!.id,
 | 
				
			||||||
      quoteEventId: quoteId,
 | 
					        sender: profile!,
 | 
				
			||||||
      relatedEventId: relatedId,
 | 
					        senderId: profile!.id,
 | 
				
			||||||
    );
 | 
					        quoteEventId: quoteId,
 | 
				
			||||||
    _addUnconfirmedMessage(message);
 | 
					        relatedEventId: relatedId,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      _addUnconfirmedMessage(message);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Send to server
 | 
					    // Send to server
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
@@ -287,20 +340,36 @@ class ChatMessageController extends ChangeNotifier {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool isCheckedUpdate = false;
 | 
				
			||||||
 | 
					  List<SnChatMessage> incomeStrandedQueue = List.empty(growable: true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /// Check the local storage is up to date with the server.
 | 
					  /// Check the local storage is up to date with the server.
 | 
				
			||||||
  /// If the local storage is not up to date, it will be updated.
 | 
					  /// If the local storage is not up to date, it will be updated.
 | 
				
			||||||
  Future<void> checkUpdate() async {
 | 
					  Future<void> checkUpdate() async {
 | 
				
			||||||
    if (_box == null) return;
 | 
					    isAggressiveLoading = true;
 | 
				
			||||||
    if (_box!.isEmpty) return;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    isLoading = true;
 | 
					 | 
				
			||||||
    notifyListeners();
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final mostRecentMessage = await (_dt.db.snLocalChatMessage.select()
 | 
				
			||||||
 | 
					          ..where((e) => e.channelId.equals(channel!.id))
 | 
				
			||||||
 | 
					          ..limit(1)
 | 
				
			||||||
 | 
					          ..orderBy([
 | 
				
			||||||
 | 
					            (e) =>
 | 
				
			||||||
 | 
					                OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc)
 | 
				
			||||||
 | 
					          ]))
 | 
				
			||||||
 | 
					        .getSingleOrNull();
 | 
				
			||||||
 | 
					    if (mostRecentMessage == null) {
 | 
				
			||||||
 | 
					      // Initial load
 | 
				
			||||||
 | 
					      await loadMessages(take: 20);
 | 
				
			||||||
 | 
					      isAggressiveLoading = false;
 | 
				
			||||||
 | 
					      isCheckedUpdate = true;
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      final resp = await _sn.client.get(
 | 
					      final resp = await _sn.client.get(
 | 
				
			||||||
        '/cgi/im/channels/${channel!.keyPath}/events/update',
 | 
					        '/cgi/im/channels/${channel!.keyPath}/events/update',
 | 
				
			||||||
        queryParameters: {
 | 
					        queryParameters: {
 | 
				
			||||||
          'pivot': _box!.values.last.id,
 | 
					          'pivot': mostRecentMessage.content.id,
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
      if (resp.data['up_to_date'] == true) return;
 | 
					      if (resp.data['up_to_date'] == true) return;
 | 
				
			||||||
@@ -309,13 +378,25 @@ class ChatMessageController extends ChangeNotifier {
 | 
				
			|||||||
      final countToFetch = math.min(resp.data['count'] as int, 100);
 | 
					      final countToFetch = math.min(resp.data['count'] as int, 100);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      for (int idx = 0; idx < countToFetch; idx += kSingleBatchLoadLimit) {
 | 
					      for (int idx = 0; idx < countToFetch; idx += kSingleBatchLoadLimit) {
 | 
				
			||||||
        await getMessages(kSingleBatchLoadLimit, idx, forceRemote: true);
 | 
					        final out = await getMessages(
 | 
				
			||||||
 | 
					          kSingleBatchLoadLimit,
 | 
				
			||||||
 | 
					          idx,
 | 
				
			||||||
 | 
					          forceRemote: true,
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        messages.insertAll(0, out);
 | 
				
			||||||
 | 
					        notifyListeners();
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      rethrow;
 | 
					      rethrow;
 | 
				
			||||||
    } finally {
 | 
					    } finally {
 | 
				
			||||||
      await loadMessages();
 | 
					      await loadMessages();
 | 
				
			||||||
      isLoading = false;
 | 
					      isAggressiveLoading = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      isCheckedUpdate = true;
 | 
				
			||||||
 | 
					      _saveMessageToLocal(incomeStrandedQueue).then((_) {
 | 
				
			||||||
 | 
					        incomeStrandedQueue.clear();
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      notifyListeners();
 | 
					      notifyListeners();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@@ -324,13 +405,18 @@ class ChatMessageController extends ChangeNotifier {
 | 
				
			|||||||
  /// If it was not found in local storage we will look it up in remote
 | 
					  /// If it was not found in local storage we will look it up in remote
 | 
				
			||||||
  Future<SnChatMessage?> getMessage(int id) async {
 | 
					  Future<SnChatMessage?> getMessage(int id) async {
 | 
				
			||||||
    SnChatMessage? out;
 | 
					    SnChatMessage? out;
 | 
				
			||||||
    if (_box != null && _box!.containsKey(id)) {
 | 
					    final local = await (_dt.db.snLocalChatMessage.select()
 | 
				
			||||||
      out = _box!.get(id);
 | 
					          ..limit(1)
 | 
				
			||||||
 | 
					          ..where((e) => e.id.equals(id)))
 | 
				
			||||||
 | 
					        .getSingleOrNull();
 | 
				
			||||||
 | 
					    if (local != null) {
 | 
				
			||||||
 | 
					      out = local.content;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (out == null) {
 | 
					    if (out == null) {
 | 
				
			||||||
      try {
 | 
					      try {
 | 
				
			||||||
        final resp = await _sn.client.get('/cgi/im/channels/${channel!.keyPath}/events/$id');
 | 
					        final resp = await _sn.client
 | 
				
			||||||
 | 
					            .get('/cgi/im/channels/${channel!.keyPath}/events/$id');
 | 
				
			||||||
        out = SnChatMessage.fromJson(resp.data);
 | 
					        out = SnChatMessage.fromJson(resp.data);
 | 
				
			||||||
        _saveMessageToLocal([out]);
 | 
					        _saveMessageToLocal([out]);
 | 
				
			||||||
      } catch (_) {
 | 
					      } catch (_) {
 | 
				
			||||||
@@ -364,16 +450,21 @@ class ChatMessageController extends ChangeNotifier {
 | 
				
			|||||||
    bool forceLocal = false,
 | 
					    bool forceLocal = false,
 | 
				
			||||||
    bool forceRemote = false,
 | 
					    bool forceRemote = false,
 | 
				
			||||||
  }) async {
 | 
					  }) async {
 | 
				
			||||||
 | 
					    final localTotal = await _dt.db.snLocalChatMessage
 | 
				
			||||||
 | 
					        .count(where: (e) => e.channelId.equals(channel!.id))
 | 
				
			||||||
 | 
					        .getSingle();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    late List<SnChatMessage> out;
 | 
					    late List<SnChatMessage> out;
 | 
				
			||||||
    if (_box != null && (_box!.length >= take + offset || forceLocal) && !forceRemote) {
 | 
					    if ((localTotal >= take + offset || forceLocal) && !forceRemote) {
 | 
				
			||||||
      out = _box!.keys
 | 
					      final result = await (_dt.db.snLocalChatMessage.select()
 | 
				
			||||||
          .toList()
 | 
					            ..where((e) => e.channelId.equals(channel!.id))
 | 
				
			||||||
          .cast<int>()
 | 
					            ..orderBy([
 | 
				
			||||||
          .sorted((a, b) => b.compareTo(a))
 | 
					              (e) =>
 | 
				
			||||||
          .skip(offset)
 | 
					                  OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc)
 | 
				
			||||||
          .take(take)
 | 
					            ])
 | 
				
			||||||
          .map((key) => _box!.get(key)!)
 | 
					            ..limit(take, offset: offset))
 | 
				
			||||||
          .toList();
 | 
					          .get();
 | 
				
			||||||
 | 
					      out = result.map((e) => e.content).toList();
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      final resp = await _sn.client.get(
 | 
					      final resp = await _sn.client.get(
 | 
				
			||||||
        '/cgi/im/channels/${channel!.keyPath}/events',
 | 
					        '/cgi/im/channels/${channel!.keyPath}/events',
 | 
				
			||||||
@@ -408,7 +499,8 @@ class ChatMessageController extends ChangeNotifier {
 | 
				
			|||||||
          quoteEvent: quoteEvent,
 | 
					          quoteEvent: quoteEvent,
 | 
				
			||||||
          attachments: attachments
 | 
					          attachments: attachments
 | 
				
			||||||
              .where(
 | 
					              .where(
 | 
				
			||||||
                (ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false,
 | 
					                (ele) =>
 | 
				
			||||||
 | 
					                    out[i].body['attachments']?.contains(ele?.rid) ?? false,
 | 
				
			||||||
              )
 | 
					              )
 | 
				
			||||||
              .toList(),
 | 
					              .toList(),
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
@@ -416,7 +508,10 @@ class ChatMessageController extends ChangeNotifier {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Preload sender accounts
 | 
					    // Preload sender accounts
 | 
				
			||||||
    final accountId = out.where((ele) => ele.sender.accountId >= 0).map((ele) => ele.sender.accountId).toSet();
 | 
					    final accountId = out
 | 
				
			||||||
 | 
					        .where((ele) => ele.sender.accountId >= 0)
 | 
				
			||||||
 | 
					        .map((ele) => ele.sender.accountId)
 | 
				
			||||||
 | 
					        .toSet();
 | 
				
			||||||
    await _ud.listAccount(accountId);
 | 
					    await _ud.listAccount(accountId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return out;
 | 
					    return out;
 | 
				
			||||||
@@ -441,10 +536,45 @@ class ChatMessageController extends ChangeNotifier {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Timer? _readEventDebounce;
 | 
				
			||||||
 | 
					  int? _readEventAnchor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void readEvent(int id) {
 | 
				
			||||||
 | 
					    if (_readEventAnchor != null) {
 | 
				
			||||||
 | 
					      _readEventAnchor = math.max(_readEventAnchor!, id);
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      _readEventAnchor = id;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (_readEventDebounce?.isActive ?? false) {
 | 
				
			||||||
 | 
					      _readEventDebounce?.cancel();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _readEventDebounce = Timer(const Duration(milliseconds: 500), () {
 | 
				
			||||||
 | 
					      _sendReadEvent();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _sendReadEvent() {
 | 
				
			||||||
 | 
					    _ws.conn?.sink.add(jsonEncode(
 | 
				
			||||||
 | 
					      WebSocketPackage(
 | 
				
			||||||
 | 
					        method: 'events.read',
 | 
				
			||||||
 | 
					        endpoint: 'im',
 | 
				
			||||||
 | 
					        payload: {
 | 
				
			||||||
 | 
					          'channel_member_id': profile!.id,
 | 
				
			||||||
 | 
					          'event_id': _readEventAnchor,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      ).toJson(),
 | 
				
			||||||
 | 
					    ));
 | 
				
			||||||
 | 
					    logging.debug('[Messaging] Send read event request: $_readEventAnchor');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  void dispose() {
 | 
					  void dispose() {
 | 
				
			||||||
    _box?.close();
 | 
					 | 
				
			||||||
    _wsSubscription?.cancel();
 | 
					    _wsSubscription?.cancel();
 | 
				
			||||||
 | 
					    if (_readEventDebounce?.isActive ?? false) {
 | 
				
			||||||
 | 
					      _sendReadEvent();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    _readEventDebounce?.cancel();
 | 
				
			||||||
    super.dispose();
 | 
					    super.dispose();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,7 +16,9 @@ import 'package:surface/providers/post.dart';
 | 
				
			|||||||
import 'package:surface/providers/sn_attachment.dart';
 | 
					import 'package:surface/providers/sn_attachment.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/types/attachment.dart';
 | 
					import 'package:surface/types/attachment.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/poll.dart';
 | 
				
			||||||
import 'package:surface/types/post.dart';
 | 
					import 'package:surface/types/post.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/realm.dart';
 | 
				
			||||||
import 'package:surface/widgets/dialog.dart';
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
import 'package:surface/widgets/universal_image.dart';
 | 
					import 'package:surface/widgets/universal_image.dart';
 | 
				
			||||||
import 'package:video_compress/video_compress.dart';
 | 
					import 'package:video_compress/video_compress.dart';
 | 
				
			||||||
@@ -157,6 +159,15 @@ class PostWriteController extends ChangeNotifier {
 | 
				
			|||||||
  final TextEditingController aliasController = TextEditingController();
 | 
					  final TextEditingController aliasController = TextEditingController();
 | 
				
			||||||
  final TextEditingController rewardController = TextEditingController();
 | 
					  final TextEditingController rewardController = TextEditingController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ContentInsertionConfiguration get contentInsertionConfiguration => ContentInsertionConfiguration(
 | 
				
			||||||
 | 
					        onContentInserted: (KeyboardInsertedContent content) {
 | 
				
			||||||
 | 
					          if (content.hasData) {
 | 
				
			||||||
 | 
					            addAttachments(
 | 
				
			||||||
 | 
					                [PostWriteMedia.fromBytes(content.data!, 'attachmentInsertedImage'.tr(), SnMediaType.image)]);
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  bool _temporarySaveActive = false;
 | 
					  bool _temporarySaveActive = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  PostWriteController({bool doLoadFromTemporary = true}) {
 | 
					  PostWriteController({bool doLoadFromTemporary = true}) {
 | 
				
			||||||
@@ -187,6 +198,7 @@ class PostWriteController extends ChangeNotifier {
 | 
				
			|||||||
  bool isLoading = false, isBusy = false;
 | 
					  bool isLoading = false, isBusy = false;
 | 
				
			||||||
  double? progress;
 | 
					  double? progress;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  SnRealm? realm;
 | 
				
			||||||
  SnPublisher? publisher;
 | 
					  SnPublisher? publisher;
 | 
				
			||||||
  SnPost? editingPost, repostingPost, replyingPost;
 | 
					  SnPost? editingPost, repostingPost, replyingPost;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -199,6 +211,7 @@ class PostWriteController extends ChangeNotifier {
 | 
				
			|||||||
  List<PostWriteMedia> attachments = List.empty(growable: true);
 | 
					  List<PostWriteMedia> attachments = List.empty(growable: true);
 | 
				
			||||||
  DateTime? publishedAt, publishedUntil;
 | 
					  DateTime? publishedAt, publishedUntil;
 | 
				
			||||||
  SnAttachment? videoAttachment;
 | 
					  SnAttachment? videoAttachment;
 | 
				
			||||||
 | 
					  SnPoll? poll;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> fetchRelatedPost(
 | 
					  Future<void> fetchRelatedPost(
 | 
				
			||||||
    BuildContext context, {
 | 
					    BuildContext context, {
 | 
				
			||||||
@@ -229,10 +242,14 @@ class PostWriteController extends ChangeNotifier {
 | 
				
			|||||||
        tags = List.from(post.tags.map((ele) => ele.alias), growable: true);
 | 
					        tags = List.from(post.tags.map((ele) => ele.alias), growable: true);
 | 
				
			||||||
        categories = List.from(post.categories.map((ele) => ele.alias), growable: true);
 | 
					        categories = List.from(post.categories.map((ele) => ele.alias), growable: true);
 | 
				
			||||||
        attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
 | 
					        attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
 | 
				
			||||||
 | 
					        poll = post.preload?.poll;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
 | 
					        if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
 | 
				
			||||||
          thumbnail = PostWriteMedia(post.preload!.thumbnail);
 | 
					          thumbnail = PostWriteMedia(post.preload!.thumbnail);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					        if (post.preload?.realm != null) {
 | 
				
			||||||
 | 
					          realm = post.preload!.realm!;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        editingPost = post;
 | 
					        editingPost = post;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
@@ -367,6 +384,8 @@ class PostWriteController extends ChangeNotifier {
 | 
				
			|||||||
          if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
 | 
					          if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
 | 
				
			||||||
          if (replyingPost != null) 'reply_to': replyingPost!.toJson(),
 | 
					          if (replyingPost != null) 'reply_to': replyingPost!.toJson(),
 | 
				
			||||||
          if (repostingPost != null) 'repost_to': repostingPost!.toJson(),
 | 
					          if (repostingPost != null) 'repost_to': repostingPost!.toJson(),
 | 
				
			||||||
 | 
					          if (poll != null) 'poll': poll!.toJson(),
 | 
				
			||||||
 | 
					          if (realm != null) 'realm': realm!.toJson(),
 | 
				
			||||||
        }),
 | 
					        }),
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
@@ -396,6 +415,8 @@ class PostWriteController extends ChangeNotifier {
 | 
				
			|||||||
      if (data['published_until'] != null) publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal();
 | 
					      if (data['published_until'] != null) publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal();
 | 
				
			||||||
      replyingPost = data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null;
 | 
					      replyingPost = data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null;
 | 
				
			||||||
      repostingPost = data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null;
 | 
					      repostingPost = data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null;
 | 
				
			||||||
 | 
					      poll = data['poll'] != null ? SnPoll.fromJson(data['poll']) : null;
 | 
				
			||||||
 | 
					      realm = data['realm'] != null ? SnRealm.fromJson(data['realm']) : null;
 | 
				
			||||||
      temporaryRestored = true;
 | 
					      temporaryRestored = true;
 | 
				
			||||||
      notifyListeners();
 | 
					      notifyListeners();
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
@@ -511,6 +532,8 @@ class PostWriteController extends ChangeNotifier {
 | 
				
			|||||||
          if (repostingPost != null) 'repost_to': repostingPost!.id,
 | 
					          if (repostingPost != null) 'repost_to': repostingPost!.id,
 | 
				
			||||||
          if (reward != null) 'reward': reward,
 | 
					          if (reward != null) 'reward': reward,
 | 
				
			||||||
          if (videoAttachment != null) 'video': videoAttachment!.rid,
 | 
					          if (videoAttachment != null) 'video': videoAttachment!.rid,
 | 
				
			||||||
 | 
					          if (poll != null) 'poll': poll!.id,
 | 
				
			||||||
 | 
					          if (realm != null) 'realm': realm!.id,
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        onSendProgress: (count, total) {
 | 
					        onSendProgress: (count, total) {
 | 
				
			||||||
          progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
 | 
					          progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
 | 
				
			||||||
@@ -557,17 +580,8 @@ class PostWriteController extends ChangeNotifier {
 | 
				
			|||||||
    notifyListeners();
 | 
					    notifyListeners();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void setThumbnail(int? idx) {
 | 
					  void setThumbnail(SnAttachment? value) {
 | 
				
			||||||
    if (idx == null) {
 | 
					    thumbnail = value == null ? null : PostWriteMedia(value);
 | 
				
			||||||
      attachments.add(thumbnail!);
 | 
					 | 
				
			||||||
      thumbnail = null;
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      if (thumbnail != null) {
 | 
					 | 
				
			||||||
        attachments.add(thumbnail!);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      thumbnail = attachments[idx];
 | 
					 | 
				
			||||||
      attachments.removeAt(idx);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    notifyListeners();
 | 
					    notifyListeners();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -619,6 +633,11 @@ class PostWriteController extends ChangeNotifier {
 | 
				
			|||||||
    notifyListeners();
 | 
					    notifyListeners();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void setRealm(SnRealm? value) {
 | 
				
			||||||
 | 
					    realm = value;
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void setProgress(double? value) {
 | 
					  void setProgress(double? value) {
 | 
				
			||||||
    progress = value;
 | 
					    progress = value;
 | 
				
			||||||
    _temporaryPlanSave();
 | 
					    _temporaryPlanSave();
 | 
				
			||||||
@@ -642,6 +661,11 @@ class PostWriteController extends ChangeNotifier {
 | 
				
			|||||||
    notifyListeners();
 | 
					    notifyListeners();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void setPoll(SnPoll? value) {
 | 
				
			||||||
 | 
					    poll = value;
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void reset() {
 | 
					  void reset() {
 | 
				
			||||||
    publishedAt = null;
 | 
					    publishedAt = null;
 | 
				
			||||||
    publishedUntil = null;
 | 
					    publishedUntil = null;
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										42
									
								
								lib/database/account.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								lib/database/account.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
				
			|||||||
 | 
					import 'dart:convert';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:drift/drift.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/account.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SnAccountConverter extends TypeConverter<SnAccount, String>
 | 
				
			||||||
 | 
					    with JsonTypeConverter2<SnAccount, String, Map<String, Object?>> {
 | 
				
			||||||
 | 
					  const SnAccountConverter();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  SnAccount fromSql(String fromDb) {
 | 
				
			||||||
 | 
					    return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  String toSql(SnAccount value) {
 | 
				
			||||||
 | 
					    return jsonEncode(toJson(value));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  SnAccount fromJson(Map<String, Object?> json) {
 | 
				
			||||||
 | 
					    return SnAccount.fromJson(json);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Map<String, Object?> toJson(SnAccount value) {
 | 
				
			||||||
 | 
					    return value.toJson();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@TableIndex(name: 'idx_account_name', columns: {#name})
 | 
				
			||||||
 | 
					class SnLocalAccount extends Table {
 | 
				
			||||||
 | 
					  IntColumn get id => integer().autoIncrement()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  TextColumn get name => text()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  TextColumn get content => text().map(const SnAccountConverter())();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DateTimeColumn get cacheExpiredAt => dateTime()();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										47
									
								
								lib/database/attachment.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								lib/database/attachment.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,47 @@
 | 
				
			|||||||
 | 
					import 'dart:convert';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:drift/drift.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/attachment.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SnAttachmentConverter extends TypeConverter<SnAttachment, String>
 | 
				
			||||||
 | 
					    with JsonTypeConverter2<SnAttachment, String, Map<String, Object?>> {
 | 
				
			||||||
 | 
					  const SnAttachmentConverter();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  SnAttachment fromSql(String fromDb) {
 | 
				
			||||||
 | 
					    return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  String toSql(SnAttachment value) {
 | 
				
			||||||
 | 
					    return jsonEncode(toJson(value));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  SnAttachment fromJson(Map<String, Object?> json) {
 | 
				
			||||||
 | 
					    return SnAttachment.fromJson(json);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Map<String, Object?> toJson(SnAttachment value) {
 | 
				
			||||||
 | 
					    return value.toJson();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@TableIndex(name: 'idx_attachment_rid', columns: {#rid})
 | 
				
			||||||
 | 
					@TableIndex(name: 'idx_attachment_account', columns: {#accountId})
 | 
				
			||||||
 | 
					class SnLocalAttachment extends Table {
 | 
				
			||||||
 | 
					  IntColumn get id => integer().autoIncrement()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  TextColumn get rid => text().unique()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  TextColumn get uuid => text().unique()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  TextColumn get content => text().map(const SnAttachmentConverter())();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  IntColumn get accountId => integer()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DateTimeColumn get cacheExpiredAt => dateTime()();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										117
									
								
								lib/database/chat.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								lib/database/chat.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,117 @@
 | 
				
			|||||||
 | 
					import 'dart:convert';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:drift/drift.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/chat.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SnChannelConverter extends TypeConverter<SnChannel, String>
 | 
				
			||||||
 | 
					    with JsonTypeConverter2<SnChannel, String, Map<String, Object?>> {
 | 
				
			||||||
 | 
					  const SnChannelConverter();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  SnChannel fromSql(String fromDb) {
 | 
				
			||||||
 | 
					    return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  String toSql(SnChannel value) {
 | 
				
			||||||
 | 
					    return jsonEncode(toJson(value));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  SnChannel fromJson(Map<String, Object?> json) {
 | 
				
			||||||
 | 
					    return SnChannel.fromJson(json);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Map<String, Object?> toJson(SnChannel value) {
 | 
				
			||||||
 | 
					    return value.toJson();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@TableIndex(name: 'idx_channel_alias', columns: {#alias})
 | 
				
			||||||
 | 
					class SnLocalChatChannel extends Table {
 | 
				
			||||||
 | 
					  IntColumn get id => integer().autoIncrement()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  TextColumn get alias => text()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  TextColumn get content => text().map(const SnChannelConverter())();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SnMessageConverter extends TypeConverter<SnChatMessage, String>
 | 
				
			||||||
 | 
					    with JsonTypeConverter2<SnChatMessage, String, Map<String, Object?>> {
 | 
				
			||||||
 | 
					  const SnMessageConverter();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  SnChatMessage fromSql(String fromDb) {
 | 
				
			||||||
 | 
					    return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  String toSql(SnChatMessage value) {
 | 
				
			||||||
 | 
					    return jsonEncode(toJson(value));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  SnChatMessage fromJson(Map<String, Object?> json) {
 | 
				
			||||||
 | 
					    return SnChatMessage.fromJson(json);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Map<String, Object?> toJson(SnChatMessage value) {
 | 
				
			||||||
 | 
					    return value.toJson();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@TableIndex(name: 'idx_chat_channel', columns: {#channelId})
 | 
				
			||||||
 | 
					class SnLocalChatMessage extends Table {
 | 
				
			||||||
 | 
					  IntColumn get id => integer().autoIncrement()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  IntColumn get channelId => integer()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  IntColumn get senderId => integer().nullable()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  TextColumn get content => text().map(const SnMessageConverter())();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SnChannelMemberConverter extends TypeConverter<SnChannelMember, String>
 | 
				
			||||||
 | 
					    with JsonTypeConverter2<SnChannelMember, String, Map<String, Object?>> {
 | 
				
			||||||
 | 
					  const SnChannelMemberConverter();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  SnChannelMember fromSql(String fromDb) {
 | 
				
			||||||
 | 
					    return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  String toSql(SnChannelMember value) {
 | 
				
			||||||
 | 
					    return jsonEncode(toJson(value));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  SnChannelMember fromJson(Map<String, Object?> json) {
 | 
				
			||||||
 | 
					    return SnChannelMember.fromJson(json);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Map<String, Object?> toJson(SnChannelMember value) {
 | 
				
			||||||
 | 
					    return value.toJson();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SnLocalChannelMember extends Table {
 | 
				
			||||||
 | 
					  IntColumn get id => integer().autoIncrement()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  IntColumn get channelId => integer()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  IntColumn get accountId => integer()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  TextColumn get content => text().map(SnChannelMemberConverter())();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DateTimeColumn get cacheExpiredAt => dateTime()();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										55
									
								
								lib/database/database.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								lib/database/database.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,55 @@
 | 
				
			|||||||
 | 
					import 'package:drift/drift.dart';
 | 
				
			||||||
 | 
					import 'package:drift_flutter/drift_flutter.dart';
 | 
				
			||||||
 | 
					import 'package:path_provider/path_provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/database/account.dart';
 | 
				
			||||||
 | 
					import 'package:surface/database/attachment.dart';
 | 
				
			||||||
 | 
					import 'package:surface/database/chat.dart';
 | 
				
			||||||
 | 
					import 'package:surface/database/database.steps.dart';
 | 
				
			||||||
 | 
					import 'package:surface/database/keypair.dart';
 | 
				
			||||||
 | 
					import 'package:surface/database/sticker.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/chat.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/attachment.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/account.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					part 'database.g.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@DriftDatabase(tables: [
 | 
				
			||||||
 | 
					  SnLocalChatChannel,
 | 
				
			||||||
 | 
					  SnLocalChatMessage,
 | 
				
			||||||
 | 
					  SnLocalChannelMember,
 | 
				
			||||||
 | 
					  SnLocalKeyPair,
 | 
				
			||||||
 | 
					  SnLocalAccount,
 | 
				
			||||||
 | 
					  SnLocalAttachment,
 | 
				
			||||||
 | 
					  SnLocalSticker,
 | 
				
			||||||
 | 
					  SnLocalStickerPack,
 | 
				
			||||||
 | 
					])
 | 
				
			||||||
 | 
					class AppDatabase extends _$AppDatabase {
 | 
				
			||||||
 | 
					  AppDatabase([QueryExecutor? e]) : super(e ?? _openConnection());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  int get schemaVersion => 3;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static QueryExecutor _openConnection() {
 | 
				
			||||||
 | 
					    return driftDatabase(
 | 
				
			||||||
 | 
					      name: 'solar_network_data',
 | 
				
			||||||
 | 
					      native: const DriftNativeOptions(
 | 
				
			||||||
 | 
					        databaseDirectory: getApplicationSupportDirectory,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      web: DriftWebOptions(
 | 
				
			||||||
 | 
					        sqlite3Wasm: Uri.parse('sqlite3.wasm'),
 | 
				
			||||||
 | 
					        driftWorker: Uri.parse('drift_worker.dart.js'),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  MigrationStrategy get migration {
 | 
				
			||||||
 | 
					    return MigrationStrategy(
 | 
				
			||||||
 | 
					      onUpgrade: stepByStep(from1To2: (m, schema) async {
 | 
				
			||||||
 | 
					        // Nothing else to do here
 | 
				
			||||||
 | 
					      }, from2To3: (m, schema) async {
 | 
				
			||||||
 | 
					        // Nothing else to do here, too
 | 
				
			||||||
 | 
					      }),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										3932
									
								
								lib/database/database.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3932
									
								
								lib/database/database.g.dart
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										445
									
								
								lib/database/database.steps.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										445
									
								
								lib/database/database.steps.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,445 @@
 | 
				
			|||||||
 | 
					// dart format width=80
 | 
				
			||||||
 | 
					import 'package:drift/internal/versioned_schema.dart' as i0;
 | 
				
			||||||
 | 
					import 'package:drift/drift.dart' as i1;
 | 
				
			||||||
 | 
					import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// GENERATED BY drift_dev, DO NOT MODIFY.
 | 
				
			||||||
 | 
					final class Schema2 extends i0.VersionedSchema {
 | 
				
			||||||
 | 
					  Schema2({required super.database}) : super(version: 2);
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  late final List<i1.DatabaseSchemaEntity> entities = [
 | 
				
			||||||
 | 
					    snLocalChatChannel,
 | 
				
			||||||
 | 
					    snLocalChatMessage,
 | 
				
			||||||
 | 
					    snLocalKeyPair,
 | 
				
			||||||
 | 
					  ];
 | 
				
			||||||
 | 
					  late final Shape0 snLocalChatChannel = Shape0(
 | 
				
			||||||
 | 
					      source: i0.VersionedTable(
 | 
				
			||||||
 | 
					        entityName: 'sn_local_chat_channel',
 | 
				
			||||||
 | 
					        withoutRowId: false,
 | 
				
			||||||
 | 
					        isStrict: false,
 | 
				
			||||||
 | 
					        tableConstraints: [],
 | 
				
			||||||
 | 
					        columns: [
 | 
				
			||||||
 | 
					          _column_0,
 | 
				
			||||||
 | 
					          _column_1,
 | 
				
			||||||
 | 
					          _column_2,
 | 
				
			||||||
 | 
					          _column_3,
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        attachedDatabase: database,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      alias: null);
 | 
				
			||||||
 | 
					  late final Shape1 snLocalChatMessage = Shape1(
 | 
				
			||||||
 | 
					      source: i0.VersionedTable(
 | 
				
			||||||
 | 
					        entityName: 'sn_local_chat_message',
 | 
				
			||||||
 | 
					        withoutRowId: false,
 | 
				
			||||||
 | 
					        isStrict: false,
 | 
				
			||||||
 | 
					        tableConstraints: [],
 | 
				
			||||||
 | 
					        columns: [
 | 
				
			||||||
 | 
					          _column_0,
 | 
				
			||||||
 | 
					          _column_4,
 | 
				
			||||||
 | 
					          _column_2,
 | 
				
			||||||
 | 
					          _column_3,
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        attachedDatabase: database,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      alias: null);
 | 
				
			||||||
 | 
					  late final Shape2 snLocalKeyPair = Shape2(
 | 
				
			||||||
 | 
					      source: i0.VersionedTable(
 | 
				
			||||||
 | 
					        entityName: 'sn_local_key_pair',
 | 
				
			||||||
 | 
					        withoutRowId: false,
 | 
				
			||||||
 | 
					        isStrict: false,
 | 
				
			||||||
 | 
					        tableConstraints: [
 | 
				
			||||||
 | 
					          'PRIMARY KEY(id)',
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        columns: [
 | 
				
			||||||
 | 
					          _column_5,
 | 
				
			||||||
 | 
					          _column_6,
 | 
				
			||||||
 | 
					          _column_7,
 | 
				
			||||||
 | 
					          _column_8,
 | 
				
			||||||
 | 
					          _column_9,
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        attachedDatabase: database,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      alias: null);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Shape0 extends i0.VersionedTable {
 | 
				
			||||||
 | 
					  Shape0({required super.source, required super.alias}) : super.aliased();
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<int> get id =>
 | 
				
			||||||
 | 
					      columnsByName['id']! as i1.GeneratedColumn<int>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<String> get alias =>
 | 
				
			||||||
 | 
					      columnsByName['alias']! as i1.GeneratedColumn<String>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<String> get content =>
 | 
				
			||||||
 | 
					      columnsByName['content']! as i1.GeneratedColumn<String>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<DateTime> get createdAt =>
 | 
				
			||||||
 | 
					      columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					i1.GeneratedColumn<int> _column_0(String aliasedName) =>
 | 
				
			||||||
 | 
					    i1.GeneratedColumn<int>('id', aliasedName, false,
 | 
				
			||||||
 | 
					        hasAutoIncrement: true,
 | 
				
			||||||
 | 
					        type: i1.DriftSqlType.int,
 | 
				
			||||||
 | 
					        defaultConstraints:
 | 
				
			||||||
 | 
					            i1.GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
 | 
				
			||||||
 | 
					i1.GeneratedColumn<String> _column_1(String aliasedName) =>
 | 
				
			||||||
 | 
					    i1.GeneratedColumn<String>('alias', aliasedName, false,
 | 
				
			||||||
 | 
					        type: i1.DriftSqlType.string);
 | 
				
			||||||
 | 
					i1.GeneratedColumn<String> _column_2(String aliasedName) =>
 | 
				
			||||||
 | 
					    i1.GeneratedColumn<String>('content', aliasedName, false,
 | 
				
			||||||
 | 
					        type: i1.DriftSqlType.string);
 | 
				
			||||||
 | 
					i1.GeneratedColumn<DateTime> _column_3(String aliasedName) =>
 | 
				
			||||||
 | 
					    i1.GeneratedColumn<DateTime>('created_at', aliasedName, false,
 | 
				
			||||||
 | 
					        type: i1.DriftSqlType.dateTime,
 | 
				
			||||||
 | 
					        defaultValue: const CustomExpression(
 | 
				
			||||||
 | 
					            'CAST(strftime(\'%s\', CURRENT_TIMESTAMP) AS INTEGER)'));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Shape1 extends i0.VersionedTable {
 | 
				
			||||||
 | 
					  Shape1({required super.source, required super.alias}) : super.aliased();
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<int> get id =>
 | 
				
			||||||
 | 
					      columnsByName['id']! as i1.GeneratedColumn<int>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<int> get channelId =>
 | 
				
			||||||
 | 
					      columnsByName['channel_id']! as i1.GeneratedColumn<int>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<String> get content =>
 | 
				
			||||||
 | 
					      columnsByName['content']! as i1.GeneratedColumn<String>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<DateTime> get createdAt =>
 | 
				
			||||||
 | 
					      columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					i1.GeneratedColumn<int> _column_4(String aliasedName) =>
 | 
				
			||||||
 | 
					    i1.GeneratedColumn<int>('channel_id', aliasedName, false,
 | 
				
			||||||
 | 
					        type: i1.DriftSqlType.int);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Shape2 extends i0.VersionedTable {
 | 
				
			||||||
 | 
					  Shape2({required super.source, required super.alias}) : super.aliased();
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<String> get id =>
 | 
				
			||||||
 | 
					      columnsByName['id']! as i1.GeneratedColumn<String>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<int> get accountId =>
 | 
				
			||||||
 | 
					      columnsByName['account_id']! as i1.GeneratedColumn<int>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<String> get publicKey =>
 | 
				
			||||||
 | 
					      columnsByName['public_key']! as i1.GeneratedColumn<String>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<String> get privateKey =>
 | 
				
			||||||
 | 
					      columnsByName['private_key']! as i1.GeneratedColumn<String>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<bool> get isActive =>
 | 
				
			||||||
 | 
					      columnsByName['is_active']! as i1.GeneratedColumn<bool>;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					i1.GeneratedColumn<String> _column_5(String aliasedName) =>
 | 
				
			||||||
 | 
					    i1.GeneratedColumn<String>('id', aliasedName, false,
 | 
				
			||||||
 | 
					        type: i1.DriftSqlType.string);
 | 
				
			||||||
 | 
					i1.GeneratedColumn<int> _column_6(String aliasedName) =>
 | 
				
			||||||
 | 
					    i1.GeneratedColumn<int>('account_id', aliasedName, false,
 | 
				
			||||||
 | 
					        type: i1.DriftSqlType.int);
 | 
				
			||||||
 | 
					i1.GeneratedColumn<String> _column_7(String aliasedName) =>
 | 
				
			||||||
 | 
					    i1.GeneratedColumn<String>('public_key', aliasedName, false,
 | 
				
			||||||
 | 
					        type: i1.DriftSqlType.string);
 | 
				
			||||||
 | 
					i1.GeneratedColumn<String> _column_8(String aliasedName) =>
 | 
				
			||||||
 | 
					    i1.GeneratedColumn<String>('private_key', aliasedName, true,
 | 
				
			||||||
 | 
					        type: i1.DriftSqlType.string);
 | 
				
			||||||
 | 
					i1.GeneratedColumn<bool> _column_9(String aliasedName) =>
 | 
				
			||||||
 | 
					    i1.GeneratedColumn<bool>('is_active', aliasedName, false,
 | 
				
			||||||
 | 
					        type: i1.DriftSqlType.bool,
 | 
				
			||||||
 | 
					        defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
 | 
				
			||||||
 | 
					            'CHECK ("is_active" IN (0, 1))'),
 | 
				
			||||||
 | 
					        defaultValue: const CustomExpression('0'));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					final class Schema3 extends i0.VersionedSchema {
 | 
				
			||||||
 | 
					  Schema3({required super.database}) : super(version: 3);
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  late final List<i1.DatabaseSchemaEntity> entities = [
 | 
				
			||||||
 | 
					    snLocalChatChannel,
 | 
				
			||||||
 | 
					    snLocalChatMessage,
 | 
				
			||||||
 | 
					    snLocalChannelMember,
 | 
				
			||||||
 | 
					    snLocalKeyPair,
 | 
				
			||||||
 | 
					    snLocalAccount,
 | 
				
			||||||
 | 
					    snLocalAttachment,
 | 
				
			||||||
 | 
					    snLocalSticker,
 | 
				
			||||||
 | 
					    snLocalStickerPack,
 | 
				
			||||||
 | 
					    idxChannelAlias,
 | 
				
			||||||
 | 
					    idxChatChannel,
 | 
				
			||||||
 | 
					    idxAccountName,
 | 
				
			||||||
 | 
					    idxAttachmentRid,
 | 
				
			||||||
 | 
					    idxAttachmentAccount,
 | 
				
			||||||
 | 
					  ];
 | 
				
			||||||
 | 
					  late final Shape0 snLocalChatChannel = Shape0(
 | 
				
			||||||
 | 
					      source: i0.VersionedTable(
 | 
				
			||||||
 | 
					        entityName: 'sn_local_chat_channel',
 | 
				
			||||||
 | 
					        withoutRowId: false,
 | 
				
			||||||
 | 
					        isStrict: false,
 | 
				
			||||||
 | 
					        tableConstraints: [],
 | 
				
			||||||
 | 
					        columns: [
 | 
				
			||||||
 | 
					          _column_0,
 | 
				
			||||||
 | 
					          _column_1,
 | 
				
			||||||
 | 
					          _column_2,
 | 
				
			||||||
 | 
					          _column_3,
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        attachedDatabase: database,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      alias: null);
 | 
				
			||||||
 | 
					  late final Shape3 snLocalChatMessage = Shape3(
 | 
				
			||||||
 | 
					      source: i0.VersionedTable(
 | 
				
			||||||
 | 
					        entityName: 'sn_local_chat_message',
 | 
				
			||||||
 | 
					        withoutRowId: false,
 | 
				
			||||||
 | 
					        isStrict: false,
 | 
				
			||||||
 | 
					        tableConstraints: [],
 | 
				
			||||||
 | 
					        columns: [
 | 
				
			||||||
 | 
					          _column_0,
 | 
				
			||||||
 | 
					          _column_4,
 | 
				
			||||||
 | 
					          _column_10,
 | 
				
			||||||
 | 
					          _column_2,
 | 
				
			||||||
 | 
					          _column_3,
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        attachedDatabase: database,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      alias: null);
 | 
				
			||||||
 | 
					  late final Shape4 snLocalChannelMember = Shape4(
 | 
				
			||||||
 | 
					      source: i0.VersionedTable(
 | 
				
			||||||
 | 
					        entityName: 'sn_local_channel_member',
 | 
				
			||||||
 | 
					        withoutRowId: false,
 | 
				
			||||||
 | 
					        isStrict: false,
 | 
				
			||||||
 | 
					        tableConstraints: [],
 | 
				
			||||||
 | 
					        columns: [
 | 
				
			||||||
 | 
					          _column_0,
 | 
				
			||||||
 | 
					          _column_4,
 | 
				
			||||||
 | 
					          _column_6,
 | 
				
			||||||
 | 
					          _column_2,
 | 
				
			||||||
 | 
					          _column_3,
 | 
				
			||||||
 | 
					          _column_11,
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        attachedDatabase: database,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      alias: null);
 | 
				
			||||||
 | 
					  late final Shape2 snLocalKeyPair = Shape2(
 | 
				
			||||||
 | 
					      source: i0.VersionedTable(
 | 
				
			||||||
 | 
					        entityName: 'sn_local_key_pair',
 | 
				
			||||||
 | 
					        withoutRowId: false,
 | 
				
			||||||
 | 
					        isStrict: false,
 | 
				
			||||||
 | 
					        tableConstraints: [
 | 
				
			||||||
 | 
					          'PRIMARY KEY(id)',
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        columns: [
 | 
				
			||||||
 | 
					          _column_5,
 | 
				
			||||||
 | 
					          _column_6,
 | 
				
			||||||
 | 
					          _column_7,
 | 
				
			||||||
 | 
					          _column_8,
 | 
				
			||||||
 | 
					          _column_9,
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        attachedDatabase: database,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      alias: null);
 | 
				
			||||||
 | 
					  late final Shape5 snLocalAccount = Shape5(
 | 
				
			||||||
 | 
					      source: i0.VersionedTable(
 | 
				
			||||||
 | 
					        entityName: 'sn_local_account',
 | 
				
			||||||
 | 
					        withoutRowId: false,
 | 
				
			||||||
 | 
					        isStrict: false,
 | 
				
			||||||
 | 
					        tableConstraints: [],
 | 
				
			||||||
 | 
					        columns: [
 | 
				
			||||||
 | 
					          _column_0,
 | 
				
			||||||
 | 
					          _column_12,
 | 
				
			||||||
 | 
					          _column_2,
 | 
				
			||||||
 | 
					          _column_3,
 | 
				
			||||||
 | 
					          _column_11,
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        attachedDatabase: database,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      alias: null);
 | 
				
			||||||
 | 
					  late final Shape6 snLocalAttachment = Shape6(
 | 
				
			||||||
 | 
					      source: i0.VersionedTable(
 | 
				
			||||||
 | 
					        entityName: 'sn_local_attachment',
 | 
				
			||||||
 | 
					        withoutRowId: false,
 | 
				
			||||||
 | 
					        isStrict: false,
 | 
				
			||||||
 | 
					        tableConstraints: [],
 | 
				
			||||||
 | 
					        columns: [
 | 
				
			||||||
 | 
					          _column_0,
 | 
				
			||||||
 | 
					          _column_13,
 | 
				
			||||||
 | 
					          _column_14,
 | 
				
			||||||
 | 
					          _column_2,
 | 
				
			||||||
 | 
					          _column_6,
 | 
				
			||||||
 | 
					          _column_3,
 | 
				
			||||||
 | 
					          _column_11,
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        attachedDatabase: database,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      alias: null);
 | 
				
			||||||
 | 
					  late final Shape7 snLocalSticker = Shape7(
 | 
				
			||||||
 | 
					      source: i0.VersionedTable(
 | 
				
			||||||
 | 
					        entityName: 'sn_local_sticker',
 | 
				
			||||||
 | 
					        withoutRowId: false,
 | 
				
			||||||
 | 
					        isStrict: false,
 | 
				
			||||||
 | 
					        tableConstraints: [],
 | 
				
			||||||
 | 
					        columns: [
 | 
				
			||||||
 | 
					          _column_0,
 | 
				
			||||||
 | 
					          _column_1,
 | 
				
			||||||
 | 
					          _column_15,
 | 
				
			||||||
 | 
					          _column_2,
 | 
				
			||||||
 | 
					          _column_3,
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        attachedDatabase: database,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      alias: null);
 | 
				
			||||||
 | 
					  late final Shape8 snLocalStickerPack = Shape8(
 | 
				
			||||||
 | 
					      source: i0.VersionedTable(
 | 
				
			||||||
 | 
					        entityName: 'sn_local_sticker_pack',
 | 
				
			||||||
 | 
					        withoutRowId: false,
 | 
				
			||||||
 | 
					        isStrict: false,
 | 
				
			||||||
 | 
					        tableConstraints: [],
 | 
				
			||||||
 | 
					        columns: [
 | 
				
			||||||
 | 
					          _column_0,
 | 
				
			||||||
 | 
					          _column_2,
 | 
				
			||||||
 | 
					          _column_3,
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        attachedDatabase: database,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      alias: null);
 | 
				
			||||||
 | 
					  final i1.Index idxChannelAlias = i1.Index('idx_channel_alias',
 | 
				
			||||||
 | 
					      'CREATE INDEX idx_channel_alias ON sn_local_chat_channel (alias)');
 | 
				
			||||||
 | 
					  final i1.Index idxChatChannel = i1.Index('idx_chat_channel',
 | 
				
			||||||
 | 
					      'CREATE INDEX idx_chat_channel ON sn_local_chat_message (channel_id)');
 | 
				
			||||||
 | 
					  final i1.Index idxAccountName = i1.Index('idx_account_name',
 | 
				
			||||||
 | 
					      'CREATE INDEX idx_account_name ON sn_local_account (name)');
 | 
				
			||||||
 | 
					  final i1.Index idxAttachmentRid = i1.Index('idx_attachment_rid',
 | 
				
			||||||
 | 
					      'CREATE INDEX idx_attachment_rid ON sn_local_attachment (rid)');
 | 
				
			||||||
 | 
					  final i1.Index idxAttachmentAccount = i1.Index('idx_attachment_account',
 | 
				
			||||||
 | 
					      'CREATE INDEX idx_attachment_account ON sn_local_attachment (account_id)');
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Shape3 extends i0.VersionedTable {
 | 
				
			||||||
 | 
					  Shape3({required super.source, required super.alias}) : super.aliased();
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<int> get id =>
 | 
				
			||||||
 | 
					      columnsByName['id']! as i1.GeneratedColumn<int>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<int> get channelId =>
 | 
				
			||||||
 | 
					      columnsByName['channel_id']! as i1.GeneratedColumn<int>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<int> get senderId =>
 | 
				
			||||||
 | 
					      columnsByName['sender_id']! as i1.GeneratedColumn<int>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<String> get content =>
 | 
				
			||||||
 | 
					      columnsByName['content']! as i1.GeneratedColumn<String>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<DateTime> get createdAt =>
 | 
				
			||||||
 | 
					      columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					i1.GeneratedColumn<int> _column_10(String aliasedName) =>
 | 
				
			||||||
 | 
					    i1.GeneratedColumn<int>('sender_id', aliasedName, true,
 | 
				
			||||||
 | 
					        type: i1.DriftSqlType.int);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Shape4 extends i0.VersionedTable {
 | 
				
			||||||
 | 
					  Shape4({required super.source, required super.alias}) : super.aliased();
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<int> get id =>
 | 
				
			||||||
 | 
					      columnsByName['id']! as i1.GeneratedColumn<int>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<int> get channelId =>
 | 
				
			||||||
 | 
					      columnsByName['channel_id']! as i1.GeneratedColumn<int>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<int> get accountId =>
 | 
				
			||||||
 | 
					      columnsByName['account_id']! as i1.GeneratedColumn<int>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<String> get content =>
 | 
				
			||||||
 | 
					      columnsByName['content']! as i1.GeneratedColumn<String>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<DateTime> get createdAt =>
 | 
				
			||||||
 | 
					      columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
 | 
				
			||||||
 | 
					      columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					i1.GeneratedColumn<DateTime> _column_11(String aliasedName) =>
 | 
				
			||||||
 | 
					    i1.GeneratedColumn<DateTime>('cache_expired_at', aliasedName, false,
 | 
				
			||||||
 | 
					        type: i1.DriftSqlType.dateTime);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Shape5 extends i0.VersionedTable {
 | 
				
			||||||
 | 
					  Shape5({required super.source, required super.alias}) : super.aliased();
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<int> get id =>
 | 
				
			||||||
 | 
					      columnsByName['id']! as i1.GeneratedColumn<int>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<String> get name =>
 | 
				
			||||||
 | 
					      columnsByName['name']! as i1.GeneratedColumn<String>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<String> get content =>
 | 
				
			||||||
 | 
					      columnsByName['content']! as i1.GeneratedColumn<String>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<DateTime> get createdAt =>
 | 
				
			||||||
 | 
					      columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
 | 
				
			||||||
 | 
					      columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					i1.GeneratedColumn<String> _column_12(String aliasedName) =>
 | 
				
			||||||
 | 
					    i1.GeneratedColumn<String>('name', aliasedName, false,
 | 
				
			||||||
 | 
					        type: i1.DriftSqlType.string);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Shape6 extends i0.VersionedTable {
 | 
				
			||||||
 | 
					  Shape6({required super.source, required super.alias}) : super.aliased();
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<int> get id =>
 | 
				
			||||||
 | 
					      columnsByName['id']! as i1.GeneratedColumn<int>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<String> get rid =>
 | 
				
			||||||
 | 
					      columnsByName['rid']! as i1.GeneratedColumn<String>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<String> get uuid =>
 | 
				
			||||||
 | 
					      columnsByName['uuid']! as i1.GeneratedColumn<String>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<String> get content =>
 | 
				
			||||||
 | 
					      columnsByName['content']! as i1.GeneratedColumn<String>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<int> get accountId =>
 | 
				
			||||||
 | 
					      columnsByName['account_id']! as i1.GeneratedColumn<int>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<DateTime> get createdAt =>
 | 
				
			||||||
 | 
					      columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
 | 
				
			||||||
 | 
					      columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					i1.GeneratedColumn<String> _column_13(String aliasedName) =>
 | 
				
			||||||
 | 
					    i1.GeneratedColumn<String>('rid', aliasedName, false,
 | 
				
			||||||
 | 
					        type: i1.DriftSqlType.string,
 | 
				
			||||||
 | 
					        defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'));
 | 
				
			||||||
 | 
					i1.GeneratedColumn<String> _column_14(String aliasedName) =>
 | 
				
			||||||
 | 
					    i1.GeneratedColumn<String>('uuid', aliasedName, false,
 | 
				
			||||||
 | 
					        type: i1.DriftSqlType.string,
 | 
				
			||||||
 | 
					        defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Shape7 extends i0.VersionedTable {
 | 
				
			||||||
 | 
					  Shape7({required super.source, required super.alias}) : super.aliased();
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<int> get id =>
 | 
				
			||||||
 | 
					      columnsByName['id']! as i1.GeneratedColumn<int>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<String> get alias =>
 | 
				
			||||||
 | 
					      columnsByName['alias']! as i1.GeneratedColumn<String>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<String> get fullAlias =>
 | 
				
			||||||
 | 
					      columnsByName['full_alias']! as i1.GeneratedColumn<String>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<String> get content =>
 | 
				
			||||||
 | 
					      columnsByName['content']! as i1.GeneratedColumn<String>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<DateTime> get createdAt =>
 | 
				
			||||||
 | 
					      columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					i1.GeneratedColumn<String> _column_15(String aliasedName) =>
 | 
				
			||||||
 | 
					    i1.GeneratedColumn<String>('full_alias', aliasedName, false,
 | 
				
			||||||
 | 
					        type: i1.DriftSqlType.string);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Shape8 extends i0.VersionedTable {
 | 
				
			||||||
 | 
					  Shape8({required super.source, required super.alias}) : super.aliased();
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<int> get id =>
 | 
				
			||||||
 | 
					      columnsByName['id']! as i1.GeneratedColumn<int>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<String> get content =>
 | 
				
			||||||
 | 
					      columnsByName['content']! as i1.GeneratedColumn<String>;
 | 
				
			||||||
 | 
					  i1.GeneratedColumn<DateTime> get createdAt =>
 | 
				
			||||||
 | 
					      columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					i0.MigrationStepWithVersion migrationSteps({
 | 
				
			||||||
 | 
					  required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
 | 
				
			||||||
 | 
					  required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
 | 
				
			||||||
 | 
					}) {
 | 
				
			||||||
 | 
					  return (currentVersion, database) async {
 | 
				
			||||||
 | 
					    switch (currentVersion) {
 | 
				
			||||||
 | 
					      case 1:
 | 
				
			||||||
 | 
					        final schema = Schema2(database: database);
 | 
				
			||||||
 | 
					        final migrator = i1.Migrator(database, schema);
 | 
				
			||||||
 | 
					        await from1To2(migrator, schema);
 | 
				
			||||||
 | 
					        return 2;
 | 
				
			||||||
 | 
					      case 2:
 | 
				
			||||||
 | 
					        final schema = Schema3(database: database);
 | 
				
			||||||
 | 
					        final migrator = i1.Migrator(database, schema);
 | 
				
			||||||
 | 
					        await from2To3(migrator, schema);
 | 
				
			||||||
 | 
					        return 3;
 | 
				
			||||||
 | 
					      default:
 | 
				
			||||||
 | 
					        throw ArgumentError.value('Unknown migration from $currentVersion');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					i1.OnUpgrade stepByStep({
 | 
				
			||||||
 | 
					  required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
 | 
				
			||||||
 | 
					  required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
 | 
				
			||||||
 | 
					}) =>
 | 
				
			||||||
 | 
					    i0.VersionedSchema.stepByStepHelper(
 | 
				
			||||||
 | 
					        step: migrationSteps(
 | 
				
			||||||
 | 
					      from1To2: from1To2,
 | 
				
			||||||
 | 
					      from2To3: from2To3,
 | 
				
			||||||
 | 
					    ));
 | 
				
			||||||
							
								
								
									
										8
									
								
								lib/database/drift_worker.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								lib/database/drift_worker.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
				
			|||||||
 | 
					import 'package:drift/wasm.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Use `dart compile js -O4 ./drift_worker.dart` to compile this file.
 | 
				
			||||||
 | 
					// And place it in the web/ directory.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// When compiled with dart2js, this file defines a dedicated or shared web
 | 
				
			||||||
 | 
					// worker used by drift.
 | 
				
			||||||
 | 
					void main() => WasmDatabase.workerMainForOpen();
 | 
				
			||||||
							
								
								
									
										16
									
								
								lib/database/keypair.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								lib/database/keypair.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
				
			|||||||
 | 
					import 'package:drift/drift.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SnLocalKeyPair extends Table {
 | 
				
			||||||
 | 
					  TextColumn get id => text()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  IntColumn get accountId => integer()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  TextColumn get publicKey => text()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  TextColumn get privateKey => text().nullable()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  BoolColumn get isActive => boolean().withDefault(Constant(false))();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Set<Column<Object>> get primaryKey => {id};
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										74
									
								
								lib/database/sticker.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								lib/database/sticker.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,74 @@
 | 
				
			|||||||
 | 
					import 'dart:convert';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:drift/drift.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/attachment.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SnStickerConverter extends TypeConverter<SnSticker, String>
 | 
				
			||||||
 | 
					    with JsonTypeConverter2<SnSticker, String, Map<String, Object?>> {
 | 
				
			||||||
 | 
					  const SnStickerConverter();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  SnSticker fromSql(String fromDb) {
 | 
				
			||||||
 | 
					    return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  String toSql(SnSticker value) {
 | 
				
			||||||
 | 
					    return jsonEncode(toJson(value));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  SnSticker fromJson(Map<String, Object?> json) {
 | 
				
			||||||
 | 
					    return SnSticker.fromJson(json);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Map<String, Object?> toJson(SnSticker value) {
 | 
				
			||||||
 | 
					    return value.toJson();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SnLocalSticker extends Table {
 | 
				
			||||||
 | 
					  IntColumn get id => integer().autoIncrement()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  TextColumn get alias => text()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  TextColumn get fullAlias => text()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  TextColumn get content => text().map(const SnStickerConverter())();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SnStickerPackConverter extends TypeConverter<SnStickerPack, String>
 | 
				
			||||||
 | 
					    with JsonTypeConverter2<SnStickerPack, String, Map<String, Object?>> {
 | 
				
			||||||
 | 
					  const SnStickerPackConverter();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  SnStickerPack fromSql(String fromDb) {
 | 
				
			||||||
 | 
					    return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  String toSql(SnStickerPack value) {
 | 
				
			||||||
 | 
					    return jsonEncode(toJson(value));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  SnStickerPack fromJson(Map<String, Object?> json) {
 | 
				
			||||||
 | 
					    return SnStickerPack.fromJson(json);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Map<String, Object?> toJson(SnStickerPack value) {
 | 
				
			||||||
 | 
					    return value.toJson();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SnLocalStickerPack extends Table {
 | 
				
			||||||
 | 
					  IntColumn get id => integer().autoIncrement()();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  TextColumn get content => text().map(const SnStickerPackConverter())();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										10
									
								
								lib/logger.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								lib/logger.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					import 'package:talker/talker.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					final logging = Talker(
 | 
				
			||||||
 | 
					  settings: TalkerSettings(
 | 
				
			||||||
 | 
					    enabled: true,
 | 
				
			||||||
 | 
					    useHistory: true,
 | 
				
			||||||
 | 
					    maxHistoryItems: 1000,
 | 
				
			||||||
 | 
					    useConsoleLogs: true,
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
							
								
								
									
										183
									
								
								lib/main.dart
									
									
									
									
									
								
							
							
						
						
									
										183
									
								
								lib/main.dart
									
									
									
									
									
								
							@@ -13,7 +13,6 @@ import 'package:flutter/foundation.dart';
 | 
				
			|||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:flutter/services.dart';
 | 
					import 'package:flutter/services.dart';
 | 
				
			||||||
import 'package:go_router/go_router.dart';
 | 
					import 'package:go_router/go_router.dart';
 | 
				
			||||||
import 'package:hive_flutter/hive_flutter.dart';
 | 
					 | 
				
			||||||
import 'package:hotkey_manager/hotkey_manager.dart';
 | 
					import 'package:hotkey_manager/hotkey_manager.dart';
 | 
				
			||||||
import 'package:package_info_plus/package_info_plus.dart';
 | 
					import 'package:package_info_plus/package_info_plus.dart';
 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
@@ -21,9 +20,12 @@ import 'package:relative_time/relative_time.dart';
 | 
				
			|||||||
import 'package:responsive_framework/responsive_framework.dart';
 | 
					import 'package:responsive_framework/responsive_framework.dart';
 | 
				
			||||||
import 'package:shared_preferences/shared_preferences.dart';
 | 
					import 'package:shared_preferences/shared_preferences.dart';
 | 
				
			||||||
import 'package:surface/firebase_options.dart';
 | 
					import 'package:surface/firebase_options.dart';
 | 
				
			||||||
 | 
					import 'package:surface/logger.dart';
 | 
				
			||||||
import 'package:surface/providers/channel.dart';
 | 
					import 'package:surface/providers/channel.dart';
 | 
				
			||||||
import 'package:surface/providers/chat_call.dart';
 | 
					import 'package:surface/providers/chat_call.dart';
 | 
				
			||||||
import 'package:surface/providers/config.dart';
 | 
					import 'package:surface/providers/config.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/database.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/keypair.dart';
 | 
				
			||||||
import 'package:surface/providers/link_preview.dart';
 | 
					import 'package:surface/providers/link_preview.dart';
 | 
				
			||||||
import 'package:surface/providers/navigation.dart';
 | 
					import 'package:surface/providers/navigation.dart';
 | 
				
			||||||
import 'package:surface/providers/notification.dart';
 | 
					import 'package:surface/providers/notification.dart';
 | 
				
			||||||
@@ -31,6 +33,7 @@ import 'package:surface/providers/post.dart';
 | 
				
			|||||||
import 'package:surface/providers/relationship.dart';
 | 
					import 'package:surface/providers/relationship.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_attachment.dart';
 | 
					import 'package:surface/providers/sn_attachment.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_realm.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_sticker.dart';
 | 
					import 'package:surface/providers/sn_sticker.dart';
 | 
				
			||||||
import 'package:surface/providers/special_day.dart';
 | 
					import 'package:surface/providers/special_day.dart';
 | 
				
			||||||
import 'package:surface/providers/theme.dart';
 | 
					import 'package:surface/providers/theme.dart';
 | 
				
			||||||
@@ -39,14 +42,15 @@ import 'package:surface/providers/userinfo.dart';
 | 
				
			|||||||
import 'package:surface/providers/websocket.dart';
 | 
					import 'package:surface/providers/websocket.dart';
 | 
				
			||||||
import 'package:surface/providers/widget.dart';
 | 
					import 'package:surface/providers/widget.dart';
 | 
				
			||||||
import 'package:surface/router.dart';
 | 
					import 'package:surface/router.dart';
 | 
				
			||||||
import 'package:surface/types/chat.dart';
 | 
					 | 
				
			||||||
import 'package:surface/types/realm.dart';
 | 
					 | 
				
			||||||
import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
 | 
					import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
 | 
				
			||||||
import 'package:surface/widgets/dialog.dart';
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
import 'package:tray_manager/tray_manager.dart';
 | 
					import 'package:tray_manager/tray_manager.dart';
 | 
				
			||||||
import 'package:version/version.dart';
 | 
					import 'package:version/version.dart';
 | 
				
			||||||
import 'package:workmanager/workmanager.dart';
 | 
					import 'package:workmanager/workmanager.dart';
 | 
				
			||||||
import 'package:in_app_review/in_app_review.dart';
 | 
					import 'package:in_app_review/in_app_review.dart';
 | 
				
			||||||
 | 
					import 'package:image_picker_android/image_picker_android.dart';
 | 
				
			||||||
 | 
					import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
 | 
				
			||||||
 | 
					import 'package:local_notifier/local_notifier.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@pragma('vm:entry-point')
 | 
					@pragma('vm:entry-point')
 | 
				
			||||||
void appBackgroundDispatcher() {
 | 
					void appBackgroundDispatcher() {
 | 
				
			||||||
@@ -67,20 +71,6 @@ void appBackgroundDispatcher() {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
void main() async {
 | 
					void main() async {
 | 
				
			||||||
  WidgetsFlutterBinding.ensureInitialized();
 | 
					  WidgetsFlutterBinding.ensureInitialized();
 | 
				
			||||||
  await EasyLocalization.ensureInitialized();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await Hive.initFlutter();
 | 
					 | 
				
			||||||
  Hive.registerAdapter(SnChannelImplAdapter());
 | 
					 | 
				
			||||||
  Hive.registerAdapter(SnRealmImplAdapter());
 | 
					 | 
				
			||||||
  Hive.registerAdapter(SnChannelMemberImplAdapter());
 | 
					 | 
				
			||||||
  Hive.registerAdapter(SnChatMessageImplAdapter());
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await Firebase.initializeApp(
 | 
					 | 
				
			||||||
    options: DefaultFirebaseOptions.currentPlatform,
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  GoRouter.optionURLReflectsImperativeAPIs = true;
 | 
					 | 
				
			||||||
  usePathUrlStrategy();
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
 | 
					  if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
 | 
				
			||||||
    doWhenWindowReady(() {
 | 
					    doWhenWindowReady(() {
 | 
				
			||||||
@@ -91,6 +81,17 @@ void main() async {
 | 
				
			|||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  await EasyLocalization.ensureInitialized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!kIsWeb && !Platform.isLinux) {
 | 
				
			||||||
 | 
					    await Firebase.initializeApp(
 | 
				
			||||||
 | 
					      options: DefaultFirebaseOptions.currentPlatform,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  GoRouter.optionURLReflectsImperativeAPIs = true;
 | 
				
			||||||
 | 
					  usePathUrlStrategy();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
 | 
					  if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
 | 
				
			||||||
    Workmanager().initialize(
 | 
					    Workmanager().initialize(
 | 
				
			||||||
      appBackgroundDispatcher,
 | 
					      appBackgroundDispatcher,
 | 
				
			||||||
@@ -107,6 +108,14 @@ void main() async {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!kIsWeb && Platform.isAndroid) {
 | 
				
			||||||
 | 
					    final ImagePickerPlatform imagePickerImplementation =
 | 
				
			||||||
 | 
					        ImagePickerPlatform.instance;
 | 
				
			||||||
 | 
					    if (imagePickerImplementation is ImagePickerAndroid) {
 | 
				
			||||||
 | 
					      imagePickerImplementation.useAndroidPhotoPicker = true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  runApp(const SolianApp());
 | 
					  runApp(const SolianApp());
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -129,6 +138,9 @@ class SolianApp extends StatelessWidget {
 | 
				
			|||||||
        assetLoader: JsonAssetLoader(),
 | 
					        assetLoader: JsonAssetLoader(),
 | 
				
			||||||
        child: MultiProvider(
 | 
					        child: MultiProvider(
 | 
				
			||||||
          providers: [
 | 
					          providers: [
 | 
				
			||||||
 | 
					            // Infrastructure layer
 | 
				
			||||||
 | 
					            Provider(create: (ctx) => DatabaseProvider(ctx)),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // System extensions layer
 | 
					            // System extensions layer
 | 
				
			||||||
            Provider(create: (ctx) => HomeWidgetProvider(ctx)),
 | 
					            Provider(create: (ctx) => HomeWidgetProvider(ctx)),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -143,12 +155,14 @@ class SolianApp extends StatelessWidget {
 | 
				
			|||||||
            Provider(create: (ctx) => SnNetworkProvider(ctx)),
 | 
					            Provider(create: (ctx) => SnNetworkProvider(ctx)),
 | 
				
			||||||
            Provider(create: (ctx) => UserDirectoryProvider(ctx)),
 | 
					            Provider(create: (ctx) => UserDirectoryProvider(ctx)),
 | 
				
			||||||
            Provider(create: (ctx) => SnAttachmentProvider(ctx)),
 | 
					            Provider(create: (ctx) => SnAttachmentProvider(ctx)),
 | 
				
			||||||
 | 
					            Provider(create: (ctx) => SnRealmProvider(ctx)),
 | 
				
			||||||
            Provider(create: (ctx) => SnPostContentProvider(ctx)),
 | 
					            Provider(create: (ctx) => SnPostContentProvider(ctx)),
 | 
				
			||||||
            Provider(create: (ctx) => SnRelationshipProvider(ctx)),
 | 
					            Provider(create: (ctx) => SnRelationshipProvider(ctx)),
 | 
				
			||||||
            Provider(create: (ctx) => SnLinkPreviewProvider(ctx)),
 | 
					            Provider(create: (ctx) => SnLinkPreviewProvider(ctx)),
 | 
				
			||||||
            Provider(create: (ctx) => SnStickerProvider(ctx)),
 | 
					            Provider(create: (ctx) => SnStickerProvider(ctx)),
 | 
				
			||||||
            ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)),
 | 
					            ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)),
 | 
				
			||||||
            ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)),
 | 
					            ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)),
 | 
				
			||||||
 | 
					            Provider(create: (ctx) => KeyPairProvider(ctx)),
 | 
				
			||||||
            ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
 | 
					            ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
 | 
				
			||||||
            ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
 | 
					            ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
 | 
				
			||||||
            ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
 | 
					            ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
 | 
				
			||||||
@@ -160,8 +174,8 @@ class SolianApp extends StatelessWidget {
 | 
				
			|||||||
        ),
 | 
					        ),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
      breakpoints: [
 | 
					      breakpoints: [
 | 
				
			||||||
        const Breakpoint(start: 0, end: 450, name: MOBILE),
 | 
					        const Breakpoint(start: 0, end: 600, name: MOBILE),
 | 
				
			||||||
        const Breakpoint(start: 451, end: 800, name: TABLET),
 | 
					        const Breakpoint(start: 601, end: 800, name: TABLET),
 | 
				
			||||||
        const Breakpoint(start: 801, end: 1920, name: DESKTOP),
 | 
					        const Breakpoint(start: 801, end: 1920, name: DESKTOP),
 | 
				
			||||||
      ],
 | 
					      ],
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
@@ -216,14 +230,15 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
				
			|||||||
    if (prefs.containsKey('first_boot_time')) {
 | 
					    if (prefs.containsKey('first_boot_time')) {
 | 
				
			||||||
      final rawTime = prefs.getString('first_boot_time');
 | 
					      final rawTime = prefs.getString('first_boot_time');
 | 
				
			||||||
      final time = DateTime.tryParse(rawTime ?? '');
 | 
					      final time = DateTime.tryParse(rawTime ?? '');
 | 
				
			||||||
      if (time != null && time.isBefore(DateTime.now().subtract(const Duration(days: 3)))) {
 | 
					      if (time != null &&
 | 
				
			||||||
 | 
					          time.isBefore(DateTime.now().subtract(const Duration(days: 3)))) {
 | 
				
			||||||
        final inAppReview = InAppReview.instance;
 | 
					        final inAppReview = InAppReview.instance;
 | 
				
			||||||
        if (prefs.getBool('rating_requested') == true) return;
 | 
					        if (prefs.getBool('rating_requested') == true) return;
 | 
				
			||||||
        if (await inAppReview.isAvailable()) {
 | 
					        if (await inAppReview.isAvailable()) {
 | 
				
			||||||
          await inAppReview.requestReview();
 | 
					          await inAppReview.requestReview();
 | 
				
			||||||
          prefs.setBool('rating_requested', true);
 | 
					          prefs.setBool('rating_requested', true);
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
          log('Unable request app review, unavailable');
 | 
					          logging.error('Unable request app review, unavailable');
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
@@ -242,20 +257,27 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
				
			|||||||
          receiveTimeout: const Duration(seconds: 60),
 | 
					          receiveTimeout: const Duration(seconds: 60),
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
      ).get(
 | 
					      ).get(
 | 
				
			||||||
        'https://git.solsynth.dev/api/v1/repos/HyperNet/Surface/tags?page=1&limit=1',
 | 
					        'https://api.github.com/repos/Solsynth/HyperNet.Surface/releases/latest',
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
      final remoteVersionString = (resp.data as List).firstOrNull?['name'] ?? '0.0.0+0';
 | 
					      final remoteVersionString = resp.data?['tag_name'] ?? '0.0.0+0';
 | 
				
			||||||
      final remoteVersion = Version.parse(remoteVersionString.split('+').first);
 | 
					      final remoteVersion = Version.parse(remoteVersionString.split('+').first);
 | 
				
			||||||
      final localVersion = Version.parse(localVersionString.split('+').first);
 | 
					      final localVersion = Version.parse(localVersionString.split('+').first);
 | 
				
			||||||
      final remoteBuildNumber = int.tryParse(remoteVersionString.split('+').last) ?? 0;
 | 
					      final remoteBuildNumber =
 | 
				
			||||||
      final localBuildNumber = int.tryParse(localVersionString.split('+').last) ?? 0;
 | 
					          int.tryParse(remoteVersionString.split('+').last) ?? 0;
 | 
				
			||||||
      log("[Update] Local: $localVersionString, Remote: $remoteVersionString");
 | 
					      final localBuildNumber =
 | 
				
			||||||
      if ((remoteVersion > localVersion || remoteBuildNumber > localBuildNumber) && mounted) {
 | 
					          int.tryParse(localVersionString.split('+').last) ?? 0;
 | 
				
			||||||
 | 
					      logging.info(
 | 
				
			||||||
 | 
					          "[Update] Local: $localVersionString, Remote: $remoteVersionString");
 | 
				
			||||||
 | 
					      if ((remoteVersion > localVersion ||
 | 
				
			||||||
 | 
					              remoteBuildNumber > localBuildNumber) &&
 | 
				
			||||||
 | 
					          mounted) {
 | 
				
			||||||
        final config = context.read<ConfigProvider>();
 | 
					        final config = context.read<ConfigProvider>();
 | 
				
			||||||
        config.setUpdate(remoteVersionString);
 | 
					        config.setUpdate(
 | 
				
			||||||
        log("[Update] Update available: $remoteVersionString");
 | 
					            remoteVersionString, resp.data?['body'] ?? 'No changelog');
 | 
				
			||||||
 | 
					        logging.info("[Update] Update available: $remoteVersionString");
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    } catch (e) {
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      logging.error('[Error] Unable to check update...', e);
 | 
				
			||||||
      if (mounted) context.showErrorDialog('Unable to check update: $e');
 | 
					      if (mounted) context.showErrorDialog('Unable to check update: $e');
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@@ -286,8 +308,17 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
				
			|||||||
      notify.listen();
 | 
					      notify.listen();
 | 
				
			||||||
      await notify.registerPushNotifications();
 | 
					      await notify.registerPushNotifications();
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      final kp = context.read<KeyPairProvider>();
 | 
				
			||||||
 | 
					      await kp.reloadActive();
 | 
				
			||||||
 | 
					      kp.listen();
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
      final sticker = context.read<SnStickerProvider>();
 | 
					      final sticker = context.read<SnStickerProvider>();
 | 
				
			||||||
      await sticker.listStickerEagerly();
 | 
					      await sticker.listSticker();
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      final ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
 | 
					      final userCacheSize = await ud.loadAccountCache();
 | 
				
			||||||
 | 
					      logging.info('[Users] Loaded local user cache, size: $userCacheSize');
 | 
				
			||||||
 | 
					      logging.info('[Bootstrap] Everything initialized!');
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      await context.showErrorDialog(err);
 | 
					      await context.showErrorDialog(err);
 | 
				
			||||||
@@ -314,30 +345,58 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final Menu _appTrayMenu = Menu(
 | 
				
			||||||
 | 
					    items: [
 | 
				
			||||||
 | 
					      MenuItem(
 | 
				
			||||||
 | 
					        key: 'version_label',
 | 
				
			||||||
 | 
					        label: 'Solian',
 | 
				
			||||||
 | 
					        disabled: true,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      MenuItem.separator(),
 | 
				
			||||||
 | 
					      MenuItem.checkbox(
 | 
				
			||||||
 | 
					        checked: false,
 | 
				
			||||||
 | 
					        key: 'mute_notification',
 | 
				
			||||||
 | 
					        label: 'trayMenuMuteNotification'.tr(),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      MenuItem.separator(),
 | 
				
			||||||
 | 
					      MenuItem(
 | 
				
			||||||
 | 
					        key: 'window_show',
 | 
				
			||||||
 | 
					        label: 'trayMenuShow'.tr(),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      MenuItem(
 | 
				
			||||||
 | 
					        key: 'exit',
 | 
				
			||||||
 | 
					        label: 'trayMenuExit'.tr(),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> _trayInitialization() async {
 | 
					  Future<void> _trayInitialization() async {
 | 
				
			||||||
    if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
 | 
					    if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final icon = Platform.isWindows ? 'assets/icon/tray-icon.ico' : 'assets/icon/tray-icon.png';
 | 
					    final icon = Platform.isWindows
 | 
				
			||||||
 | 
					        ? 'assets/icon/tray-icon.ico'
 | 
				
			||||||
 | 
					        : 'assets/icon/tray-icon.png';
 | 
				
			||||||
    final appVersion = await PackageInfo.fromPlatform();
 | 
					    final appVersion = await PackageInfo.fromPlatform();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    trayManager.addListener(this);
 | 
					    trayManager.addListener(this);
 | 
				
			||||||
    await trayManager.setIcon(icon);
 | 
					    await trayManager.setIcon(icon);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Menu menu = Menu(
 | 
					    _appTrayMenu.items![0] = MenuItem(
 | 
				
			||||||
      items: [
 | 
					      key: 'version_label',
 | 
				
			||||||
        MenuItem(
 | 
					      label: 'Solian ${appVersion.version}+${appVersion.buildNumber}',
 | 
				
			||||||
          key: 'version_label',
 | 
					      disabled: true,
 | 
				
			||||||
          label: 'Solian ${appVersion.version}+${appVersion.buildNumber}',
 | 
					    );
 | 
				
			||||||
          disabled: true,
 | 
					
 | 
				
			||||||
        ),
 | 
					    await trayManager.setContextMenu(_appTrayMenu);
 | 
				
			||||||
        MenuItem.separator(),
 | 
					  }
 | 
				
			||||||
        MenuItem(
 | 
					
 | 
				
			||||||
          key: 'exit',
 | 
					  Future<void> _notifyInitialization() async {
 | 
				
			||||||
          label: 'trayMenuExit'.tr(),
 | 
					    if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
 | 
				
			||||||
        ),
 | 
					
 | 
				
			||||||
      ],
 | 
					    await localNotifier.setup(
 | 
				
			||||||
 | 
					      appName: 'Solian',
 | 
				
			||||||
 | 
					      shortcutPolicy: ShortcutPolicy.requireCreate,
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
    await trayManager.setContextMenu(menu);
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  AppLifecycleListener? _appLifecycleListener;
 | 
					  AppLifecycleListener? _appLifecycleListener;
 | 
				
			||||||
@@ -354,6 +413,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    _trayInitialization();
 | 
					    _trayInitialization();
 | 
				
			||||||
    _hotkeyInitialization();
 | 
					    _hotkeyInitialization();
 | 
				
			||||||
 | 
					    _notifyInitialization();
 | 
				
			||||||
    _initialize().then((_) {
 | 
					    _initialize().then((_) {
 | 
				
			||||||
      _postInitialization();
 | 
					      _postInitialization();
 | 
				
			||||||
      _tryRequestRating();
 | 
					      _tryRequestRating();
 | 
				
			||||||
@@ -389,9 +449,23 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
				
			|||||||
  @override
 | 
					  @override
 | 
				
			||||||
  void onTrayMenuItemClick(MenuItem menuItem) {
 | 
					  void onTrayMenuItemClick(MenuItem menuItem) {
 | 
				
			||||||
    switch (menuItem.key) {
 | 
					    switch (menuItem.key) {
 | 
				
			||||||
 | 
					      case 'mute_notification':
 | 
				
			||||||
 | 
					        final nty = context.read<NotificationProvider>();
 | 
				
			||||||
 | 
					        nty.isMuted = !nty.isMuted;
 | 
				
			||||||
 | 
					        _appTrayMenu.items![2].checked = nty.isMuted;
 | 
				
			||||||
 | 
					        trayManager.setContextMenu(_appTrayMenu);
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      case 'window_show':
 | 
				
			||||||
 | 
					        // To prevent the window from being hide after just show on macOS
 | 
				
			||||||
 | 
					        Timer(const Duration(milliseconds: 100), () => appWindow.show());
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
      case 'exit':
 | 
					      case 'exit':
 | 
				
			||||||
        _appLifecycleListener?.dispose();
 | 
					        _appLifecycleListener?.dispose();
 | 
				
			||||||
        SystemChannels.platform.invokeMethod('SystemNavigator.pop');
 | 
					        if (Platform.isWindows) {
 | 
				
			||||||
 | 
					          appWindow.close();
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          SystemChannels.platform.invokeMethod('SystemNavigator.pop');
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
        break;
 | 
					        break;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@@ -415,8 +489,21 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
				
			|||||||
        });
 | 
					        });
 | 
				
			||||||
        return false;
 | 
					        return false;
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      child: SizeChangedLayoutNotifier(
 | 
					      child: OrientationBuilder(
 | 
				
			||||||
        child: widget.child,
 | 
					        builder: (context, orientation) {
 | 
				
			||||||
 | 
					          final cfg = context.read<ConfigProvider>();
 | 
				
			||||||
 | 
					          WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
				
			||||||
 | 
					            cfg.calcDrawerSize(context);
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
 | 
					          Future.delayed(const Duration(milliseconds: 300), () {
 | 
				
			||||||
 | 
					            if (context.mounted) {
 | 
				
			||||||
 | 
					              cfg.calcDrawerSize(context);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
 | 
					          return SizeChangedLayoutNotifier(
 | 
				
			||||||
 | 
					            child: widget.child,
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,48 +1,57 @@
 | 
				
			|||||||
 | 
					import 'dart:convert';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:drift/drift.dart';
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:hive_flutter/hive_flutter.dart';
 | 
					 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
import 'package:surface/controllers/chat_message_controller.dart';
 | 
					import 'package:surface/database/database.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/database.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_realm.dart';
 | 
				
			||||||
import 'package:surface/providers/user_directory.dart';
 | 
					import 'package:surface/providers/user_directory.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/userinfo.dart';
 | 
				
			||||||
import 'package:surface/types/chat.dart';
 | 
					import 'package:surface/types/chat.dart';
 | 
				
			||||||
import 'package:surface/types/realm.dart';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ChatChannelProvider extends ChangeNotifier {
 | 
					class ChatChannelProvider extends ChangeNotifier {
 | 
				
			||||||
  static const kChatChannelBoxName = 'nex_chat_channels';
 | 
					  static const kChatChannelBoxName = 'nex_chat_channels';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  late final SnNetworkProvider _sn;
 | 
					  late final SnNetworkProvider _sn;
 | 
				
			||||||
  late final UserDirectoryProvider _ud;
 | 
					  late final UserDirectoryProvider _ud;
 | 
				
			||||||
 | 
					  late final UserProvider _ua;
 | 
				
			||||||
  Box<SnChannel>? get _channelBox => Hive.box<SnChannel>(kChatChannelBoxName);
 | 
					  late final DatabaseProvider _dt;
 | 
				
			||||||
 | 
					  late final SnRealmProvider _rels;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  ChatChannelProvider(BuildContext context) {
 | 
					  ChatChannelProvider(BuildContext context) {
 | 
				
			||||||
    _sn = context.read<SnNetworkProvider>();
 | 
					    _sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
    _ud = context.read<UserDirectoryProvider>();
 | 
					    _ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
    _initializeLocalData();
 | 
					    _ua = context.read<UserProvider>();
 | 
				
			||||||
  }
 | 
					    _dt = context.read<DatabaseProvider>();
 | 
				
			||||||
 | 
					    _rels = context.read<SnRealmProvider>();
 | 
				
			||||||
  Future<void> _initializeLocalData() async {
 | 
					 | 
				
			||||||
    await Hive.openBox<SnChannel>(kChatChannelBoxName);
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async {
 | 
					  Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async {
 | 
				
			||||||
    if (_channelBox == null) return;
 | 
					    await Future.wait(
 | 
				
			||||||
    await _channelBox!.putAll({
 | 
					      channels.map(
 | 
				
			||||||
      for (final channel in channels) channel.key: channel,
 | 
					        (ele) => _dt.db.snLocalChatChannel.insertOne(
 | 
				
			||||||
    });
 | 
					          SnLocalChatChannelCompanion.insert(
 | 
				
			||||||
 | 
					            id: Value(ele.id),
 | 
				
			||||||
 | 
					            alias: ele.key,
 | 
				
			||||||
 | 
					            content: ele,
 | 
				
			||||||
 | 
					            createdAt: Value(ele.createdAt),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          onConflict: DoUpdate(
 | 
				
			||||||
 | 
					            (_) => SnLocalChatChannelCompanion.custom(
 | 
				
			||||||
 | 
					              content: Constant(jsonEncode(ele.toJson())),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<List<SnChannel>> _fetchChannelsFromServer({
 | 
					  Future<List<SnChannel>> _fetchChannelsFromServer({
 | 
				
			||||||
    String scope = 'global',
 | 
					 | 
				
			||||||
    bool direct = false,
 | 
					 | 
				
			||||||
    bool doNotSave = false,
 | 
					    bool doNotSave = false,
 | 
				
			||||||
  }) async {
 | 
					  }) async {
 | 
				
			||||||
    final resp = await _sn.client.get(
 | 
					    final resp = await _sn.client.get('/cgi/im/channels/me/available');
 | 
				
			||||||
      '/cgi/im/channels/$scope/me/available',
 | 
					 | 
				
			||||||
      queryParameters: {
 | 
					 | 
				
			||||||
        'direct': direct,
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    final out = List<SnChannel>.from(
 | 
					    final out = List<SnChannel>.from(
 | 
				
			||||||
      resp.data?.map((e) => SnChannel.fromJson(e)) ?? [],
 | 
					      resp.data?.map((e) => SnChannel.fromJson(e)) ?? [],
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
@@ -54,18 +63,25 @@ class ChatChannelProvider extends ChangeNotifier {
 | 
				
			|||||||
  /// It will use the local storage as much as possible.
 | 
					  /// It will use the local storage as much as possible.
 | 
				
			||||||
  /// The alias should include the scope, formatted as `scope:alias`.
 | 
					  /// The alias should include the scope, formatted as `scope:alias`.
 | 
				
			||||||
  Future<SnChannel> getChannel(String key) async {
 | 
					  Future<SnChannel> getChannel(String key) async {
 | 
				
			||||||
    if (_channelBox != null) {
 | 
					    final local = await (_dt.db.snLocalChatChannel.select()
 | 
				
			||||||
      final local = _channelBox!.get(key);
 | 
					          ..where((e) => e.alias.equals(key)))
 | 
				
			||||||
      if (local != null) return local;
 | 
					        .getSingleOrNull();
 | 
				
			||||||
 | 
					    if (local != null) {
 | 
				
			||||||
 | 
					      final out = local.content;
 | 
				
			||||||
 | 
					      if (out.realmId != null) {
 | 
				
			||||||
 | 
					        return out.copyWith(realm: await _rels.getRealm(out.realmId!));
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        return out;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    var resp = await _sn.client.get('/cgi/im/channels/$key');
 | 
					    var resp =
 | 
				
			||||||
 | 
					        await _sn.client.get('/cgi/im/channels/${key.replaceAll(':', '/')}');
 | 
				
			||||||
    var out = SnChannel.fromJson(resp.data);
 | 
					    var out = SnChannel.fromJson(resp.data);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Preload realm of the channel
 | 
					    // Preload realm of the channel
 | 
				
			||||||
    if (out.realmId != null) {
 | 
					    if (out.realmId != null) {
 | 
				
			||||||
      resp = await _sn.client.get('/cgi/id/realms/${out.realmId}');
 | 
					      out = out.copyWith(realm: await _rels.getRealm(out.realmId!));
 | 
				
			||||||
      out = out.copyWith(realm: SnRealm.fromJson(resp.data));
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    _saveChannelToLocal([out]);
 | 
					    _saveChannelToLocal([out]);
 | 
				
			||||||
@@ -77,66 +93,119 @@ class ChatChannelProvider extends ChangeNotifier {
 | 
				
			|||||||
  /// And the second time is when the data was fetched from the server.
 | 
					  /// And the second time is when the data was fetched from the server.
 | 
				
			||||||
  /// But there is some exception that will only cause one of them to be emitted.
 | 
					  /// But there is some exception that will only cause one of them to be emitted.
 | 
				
			||||||
  /// Like the local storage is broken or the server is down.
 | 
					  /// Like the local storage is broken or the server is down.
 | 
				
			||||||
  Stream<List<SnChannel>> fetchChannels() async* {
 | 
					  Stream<List<SnChannel>> fetchChannels(
 | 
				
			||||||
    if (_channelBox != null) yield _channelBox!.values.toList();
 | 
					      {bool noRemote = false, bool noLocal = false}) async* {
 | 
				
			||||||
 | 
					    if (!noLocal) {
 | 
				
			||||||
    var resp = await _sn.client.get('/cgi/id/realms/me/available');
 | 
					      final local = await (_dt.db.snLocalChatChannel.select()
 | 
				
			||||||
    final realms = List<SnRealm>.from(
 | 
					            ..orderBy([
 | 
				
			||||||
      resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
 | 
					              (e) =>
 | 
				
			||||||
    );
 | 
					                  OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc)
 | 
				
			||||||
    final realmMap = {
 | 
					            ]))
 | 
				
			||||||
      for (final realm in realms) realm.alias: realm,
 | 
					          .get();
 | 
				
			||||||
    };
 | 
					      final out = local.map((e) => e.content).toList();
 | 
				
			||||||
 | 
					      for (var idx = 0; idx < out.length; idx++) {
 | 
				
			||||||
    final scopeToFetch = {'global', ...realms.map((e) => e.alias)};
 | 
					        final channel = out[idx];
 | 
				
			||||||
 | 
					        if (channel.realmId != null) {
 | 
				
			||||||
    final List<SnChannel> result = List.empty(growable: true);
 | 
					          out[idx] = out[idx].copyWith(
 | 
				
			||||||
    final directMessages = await _fetchChannelsFromServer(
 | 
					            realm: await _rels.getRealm(channel.realmId!),
 | 
				
			||||||
      scope: scopeToFetch.first,
 | 
					          );
 | 
				
			||||||
      direct: true,
 | 
					        }
 | 
				
			||||||
    );
 | 
					      }
 | 
				
			||||||
    result.addAll(directMessages);
 | 
					      yield out;
 | 
				
			||||||
 | 
					 | 
				
			||||||
    final nonBelongsChannels = await _fetchChannelsFromServer(
 | 
					 | 
				
			||||||
      scope: scopeToFetch.first,
 | 
					 | 
				
			||||||
      direct: false,
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    result.addAll(nonBelongsChannels);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    for (final scope in scopeToFetch.skip(1)) {
 | 
					 | 
				
			||||||
      final channel = await _fetchChannelsFromServer(
 | 
					 | 
				
			||||||
        scope: scope,
 | 
					 | 
				
			||||||
        direct: false,
 | 
					 | 
				
			||||||
        doNotSave: true,
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
      final out = channel.map((ele) => ele.copyWith(realm: realmMap[scope]));
 | 
					 | 
				
			||||||
      _saveChannelToLocal(out);
 | 
					 | 
				
			||||||
      result.addAll(out);
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (noRemote) return;
 | 
				
			||||||
 | 
					    final List<SnChannel> result = List.empty(growable: true);
 | 
				
			||||||
 | 
					    final channels = await _fetchChannelsFromServer();
 | 
				
			||||||
 | 
					    for (var idx = 0; idx < channels.length; idx++) {
 | 
				
			||||||
 | 
					      final channel = channels[idx];
 | 
				
			||||||
 | 
					      if (channel.realmId != null) {
 | 
				
			||||||
 | 
					        channels[idx] = channels[idx].copyWith(
 | 
				
			||||||
 | 
					          realm: await _rels.getRealm(channel.realmId!),
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    result.addAll(channels);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    yield result;
 | 
					    yield result;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<List<SnChatMessage>> getLastMessages(
 | 
					  Future<List<SnChatMessage>> getLastMessages(
 | 
				
			||||||
    Iterable<SnChannel> channels,
 | 
					    Iterable<SnChannel> channels,
 | 
				
			||||||
  ) async {
 | 
					  ) async {
 | 
				
			||||||
    final result = List<SnChatMessage>.empty(growable: true);
 | 
					    final result = List<Future<SnLocalChatMessageData?>>.empty(growable: true);
 | 
				
			||||||
    for (final channel in channels) {
 | 
					    for (final channel in channels) {
 | 
				
			||||||
      final channelBox = await Hive.openBox<SnChatMessage>(
 | 
					      final out = (_dt.db.snLocalChatMessage.select()
 | 
				
			||||||
        '${ChatMessageController.kChatMessageBoxPrefix}${channel.id}',
 | 
					            ..where((e) => e.channelId.equals(channel.id))
 | 
				
			||||||
      );
 | 
					            ..orderBy([
 | 
				
			||||||
      final lastMessage =
 | 
					              (e) =>
 | 
				
			||||||
          channelBox.isNotEmpty ? channelBox.values.reduce((a, b) => a.createdAt.isAfter(b.createdAt) ? a : b) : null;
 | 
					                  OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc)
 | 
				
			||||||
      if (lastMessage != null) result.add(lastMessage);
 | 
					            ])
 | 
				
			||||||
      channelBox.close();
 | 
					            ..limit(1))
 | 
				
			||||||
 | 
					          .getSingleOrNull();
 | 
				
			||||||
 | 
					      result.add(out);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    await _ud.listAccount(result.map((ele) => ele.sender.accountId).toSet());
 | 
					    final out = (await Future.wait(result))
 | 
				
			||||||
    return result;
 | 
					        .where((e) => e != null)
 | 
				
			||||||
 | 
					        .map((e) => e!.content)
 | 
				
			||||||
 | 
					        .toList();
 | 
				
			||||||
 | 
					    await _ud.listAccount(out.map((ele) => ele.sender.accountId).toSet());
 | 
				
			||||||
 | 
					    return out;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  Future<void> _saveMemberToLocal(Iterable<SnChannelMember> members) async {
 | 
				
			||||||
  void dispose() {
 | 
					    final queries = members.map((ele) {
 | 
				
			||||||
    _channelBox?.close();
 | 
					      return _dt.db.snLocalChannelMember.insertOne(
 | 
				
			||||||
    super.dispose();
 | 
					        SnLocalChannelMemberCompanion.insert(
 | 
				
			||||||
 | 
					          id: Value(ele.id),
 | 
				
			||||||
 | 
					          channelId: ele.channelId,
 | 
				
			||||||
 | 
					          accountId: ele.accountId,
 | 
				
			||||||
 | 
					          content: ele,
 | 
				
			||||||
 | 
					          cacheExpiredAt: DateTime.now().add(const Duration(days: 7)),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        onConflict: DoUpdate(
 | 
				
			||||||
 | 
					          (_) => SnLocalChannelMemberCompanion.custom(
 | 
				
			||||||
 | 
					            content: Constant(jsonEncode(ele.toJson())),
 | 
				
			||||||
 | 
					            cacheExpiredAt:
 | 
				
			||||||
 | 
					                Constant(DateTime.now().add(const Duration(days: 7))),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    await Future.wait(queries);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> removeLocalChannel(SnChannel channel) async {
 | 
				
			||||||
 | 
					    await _dt.db.transaction(() async {
 | 
				
			||||||
 | 
					      await (_dt.db.snLocalChannelMember.delete()
 | 
				
			||||||
 | 
					            ..where((e) => e.channelId.equals(channel.id)))
 | 
				
			||||||
 | 
					          .go();
 | 
				
			||||||
 | 
					      await (_dt.db.snLocalChatChannel.delete()
 | 
				
			||||||
 | 
					            ..where((e) => e.id.equals(channel.id)))
 | 
				
			||||||
 | 
					          .go();
 | 
				
			||||||
 | 
					      await (_dt.db.snLocalChatMessage.delete()
 | 
				
			||||||
 | 
					            ..where((e) => e.channelId.equals(channel.id)))
 | 
				
			||||||
 | 
					          .go();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> updateChannelProfile(SnChannelMember member) {
 | 
				
			||||||
 | 
					    return _saveMemberToLocal([member]);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<SnChannelMember> getChannelProfile(SnChannel channel) async {
 | 
				
			||||||
 | 
					    if (_ua.user == null) throw Exception('User not logged in');
 | 
				
			||||||
 | 
					    final local = await (_dt.db.snLocalChannelMember.select()
 | 
				
			||||||
 | 
					          ..where((e) => e.channelId.equals(channel.id))
 | 
				
			||||||
 | 
					          ..where((e) => e.accountId.equals(_ua.user!.id)))
 | 
				
			||||||
 | 
					        .getSingleOrNull();
 | 
				
			||||||
 | 
					    if (local != null) {
 | 
				
			||||||
 | 
					      return local.content;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final resp = await _sn.client.get('/cgi/im/channels/${channel.keyPath}/me');
 | 
				
			||||||
 | 
					    final out = SnChannelMember.fromJson(resp.data);
 | 
				
			||||||
 | 
					    _saveMemberToLocal([out]);
 | 
				
			||||||
 | 
					    return out;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,6 +17,8 @@ const kAppDrawerPreferCollapse = 'app_drawer_prefer_collapse';
 | 
				
			|||||||
const kAppNotifyWithHaptic = 'app_notify_with_haptic';
 | 
					const kAppNotifyWithHaptic = 'app_notify_with_haptic';
 | 
				
			||||||
const kAppExpandPostLink = 'app_expand_post_link';
 | 
					const kAppExpandPostLink = 'app_expand_post_link';
 | 
				
			||||||
const kAppExpandChatLink = 'app_expand_chat_link';
 | 
					const kAppExpandChatLink = 'app_expand_chat_link';
 | 
				
			||||||
 | 
					const kAppRealmCompactView = 'app_realm_compact_view';
 | 
				
			||||||
 | 
					const kAppCustomFonts = 'app_custom_fonts';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const Map<String, FilterQuality> kImageQualityLevel = {
 | 
					const Map<String, FilterQuality> kImageQualityLevel = {
 | 
				
			||||||
  'settingsImageQualityLowest': FilterQuality.none,
 | 
					  'settingsImageQualityLowest': FilterQuality.none,
 | 
				
			||||||
@@ -45,8 +47,8 @@ class ConfigProvider extends ChangeNotifier {
 | 
				
			|||||||
    bool newDrawerIsCollapsed = false;
 | 
					    bool newDrawerIsCollapsed = false;
 | 
				
			||||||
    bool newDrawerIsExpanded = false;
 | 
					    bool newDrawerIsExpanded = false;
 | 
				
			||||||
    if (withMediaQuery) {
 | 
					    if (withMediaQuery) {
 | 
				
			||||||
      newDrawerIsCollapsed = MediaQuery.of(context).size.width < 450;
 | 
					      newDrawerIsCollapsed = MediaQuery.of(context).size.width < 600;
 | 
				
			||||||
      newDrawerIsExpanded = MediaQuery.of(context).size.width >= 451;
 | 
					      newDrawerIsExpanded = MediaQuery.of(context).size.width >= 601;
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      final rpb = ResponsiveBreakpoints.of(context);
 | 
					      final rpb = ResponsiveBreakpoints.of(context);
 | 
				
			||||||
      newDrawerIsCollapsed = rpb.smallerOrEqualTo(MOBILE);
 | 
					      newDrawerIsCollapsed = rpb.smallerOrEqualTo(MOBILE);
 | 
				
			||||||
@@ -57,7 +59,8 @@ class ConfigProvider extends ChangeNotifier {
 | 
				
			|||||||
          : false;
 | 
					          : false;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (newDrawerIsExpanded != drawerIsExpanded || newDrawerIsCollapsed != drawerIsCollapsed) {
 | 
					    if (newDrawerIsExpanded != drawerIsExpanded ||
 | 
				
			||||||
 | 
					        newDrawerIsCollapsed != drawerIsCollapsed) {
 | 
				
			||||||
      drawerIsExpanded = newDrawerIsExpanded;
 | 
					      drawerIsExpanded = newDrawerIsExpanded;
 | 
				
			||||||
      drawerIsCollapsed = newDrawerIsCollapsed;
 | 
					      drawerIsCollapsed = newDrawerIsCollapsed;
 | 
				
			||||||
      notifyListeners();
 | 
					      notifyListeners();
 | 
				
			||||||
@@ -65,22 +68,34 @@ class ConfigProvider extends ChangeNotifier {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  FilterQuality get imageQuality {
 | 
					  FilterQuality get imageQuality {
 | 
				
			||||||
    return kImageQualityLevel.values.elementAtOrNull(prefs.getInt('app_image_quality') ?? 3) ?? FilterQuality.high;
 | 
					    return kImageQualityLevel.values
 | 
				
			||||||
 | 
					            .elementAtOrNull(prefs.getInt('app_image_quality') ?? 3) ??
 | 
				
			||||||
 | 
					        FilterQuality.high;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  String get serverUrl {
 | 
					  String get serverUrl {
 | 
				
			||||||
    return prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
 | 
					    return prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool get realmCompactView {
 | 
				
			||||||
 | 
					    return prefs.getBool(kAppRealmCompactView) ?? false;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  set realmCompactView(bool value) {
 | 
				
			||||||
 | 
					    prefs.setBool(kAppRealmCompactView, value);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  set serverUrl(String url) {
 | 
					  set serverUrl(String url) {
 | 
				
			||||||
    prefs.setString(kNetworkServerStoreKey, url);
 | 
					    prefs.setString(kNetworkServerStoreKey, url);
 | 
				
			||||||
    _home.saveWidgetData("nex_server_url", url);
 | 
					    _home.saveWidgetData("nex_server_url", url);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  String? updatableVersion;
 | 
					  String? updatableVersion;
 | 
				
			||||||
 | 
					  String? updatableChangelog;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void setUpdate(String newVersion) {
 | 
					  void setUpdate(String newVersion, String newChangelog) {
 | 
				
			||||||
    updatableVersion = newVersion;
 | 
					    updatableVersion = newVersion;
 | 
				
			||||||
 | 
					    updatableChangelog = newChangelog;
 | 
				
			||||||
    notifyListeners();
 | 
					    notifyListeners();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										31
									
								
								lib/providers/database.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								lib/providers/database.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,31 @@
 | 
				
			|||||||
 | 
					import 'dart:io';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:flutter/foundation.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:path/path.dart' show join;
 | 
				
			||||||
 | 
					import 'package:path_provider/path_provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/database/database.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DatabaseProvider {
 | 
				
			||||||
 | 
					  late AppDatabase db;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DatabaseProvider(BuildContext context) {
 | 
				
			||||||
 | 
					    db = AppDatabase();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<int> getDatabaseSize() async {
 | 
				
			||||||
 | 
					    if (kIsWeb) return 0;
 | 
				
			||||||
 | 
					    final basepath = await getApplicationSupportDirectory();
 | 
				
			||||||
 | 
					    return await File(join(basepath.path, 'solar_network_data.sqlite'))
 | 
				
			||||||
 | 
					        .length();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> removeDatabase() async {
 | 
				
			||||||
 | 
					    if (kIsWeb) return;
 | 
				
			||||||
 | 
					    final basepath = await getApplicationSupportDirectory();
 | 
				
			||||||
 | 
					    final file = File(join(basepath.path, 'solar_network_data.sqlite'));
 | 
				
			||||||
 | 
					    db.close();
 | 
				
			||||||
 | 
					    await file.delete();
 | 
				
			||||||
 | 
					    db = AppDatabase();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										245
									
								
								lib/providers/keypair.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										245
									
								
								lib/providers/keypair.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,245 @@
 | 
				
			|||||||
 | 
					import 'dart:async';
 | 
				
			||||||
 | 
					import 'dart:convert';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:drift/drift.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/database/database.dart';
 | 
				
			||||||
 | 
					import 'package:surface/logger.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/database.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/userinfo.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/websocket.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/keypair.dart';
 | 
				
			||||||
 | 
					import 'package:fast_rsa/fast_rsa.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/websocket.dart';
 | 
				
			||||||
 | 
					import 'package:uuid/uuid.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Currently the keypair only provide RSA encryption
 | 
				
			||||||
 | 
					// Supported by the `fast_rsa` package
 | 
				
			||||||
 | 
					class KeyPairProvider {
 | 
				
			||||||
 | 
					  late final DatabaseProvider _dt;
 | 
				
			||||||
 | 
					  late final UserProvider _ua;
 | 
				
			||||||
 | 
					  late final WebSocketProvider _ws;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  SnKeyPair? activeKp;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  KeyPairProvider(BuildContext context) {
 | 
				
			||||||
 | 
					    _dt = context.read<DatabaseProvider>();
 | 
				
			||||||
 | 
					    _ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					    _ws = context.read<WebSocketProvider>();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void listen() {
 | 
				
			||||||
 | 
					    _ws.pk.stream.listen((event) {
 | 
				
			||||||
 | 
					      switch (event.method) {
 | 
				
			||||||
 | 
					        case 'kex.ack':
 | 
				
			||||||
 | 
					          ackKeyExchange(event);
 | 
				
			||||||
 | 
					          break;
 | 
				
			||||||
 | 
					        case 'kex.ask':
 | 
				
			||||||
 | 
					          replyAskKeyExchange(event);
 | 
				
			||||||
 | 
					          break;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<String> decryptText(String text, String kpId, {int? kpOwner}) async {
 | 
				
			||||||
 | 
					    String? publicKey;
 | 
				
			||||||
 | 
					    final kp = await (_dt.db.snLocalKeyPair.select()
 | 
				
			||||||
 | 
					          ..where((e) => e.id.equals(kpId)))
 | 
				
			||||||
 | 
					        .getSingleOrNull();
 | 
				
			||||||
 | 
					    if (kp == null) {
 | 
				
			||||||
 | 
					      if (kpOwner != null) {
 | 
				
			||||||
 | 
					        final out = await askKeyExchange(kpOwner, kpId);
 | 
				
			||||||
 | 
					        publicKey = out.publicKey;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      publicKey = kp.publicKey;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (publicKey == null) {
 | 
				
			||||||
 | 
					      throw Exception('Key pair not found');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return await RSA.decryptPKCS1v15(text, publicKey);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<String> encryptText(String text) async {
 | 
				
			||||||
 | 
					    if (activeKp == null) throw Exception('No active key pair');
 | 
				
			||||||
 | 
					    return await RSA.encryptPKCS1v15(text, activeKp!.privateKey!);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final Map<String, Completer<SnKeyPair>> _requests = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<SnKeyPair> askKeyExchange(int kpOwner, String kpId) async {
 | 
				
			||||||
 | 
					    if (_requests.containsKey(kpId)) return await _requests[kpId]!.future;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final completer = Completer<SnKeyPair>();
 | 
				
			||||||
 | 
					    _requests[kpId] = completer;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _ws.conn?.sink.add(
 | 
				
			||||||
 | 
					      jsonEncode(WebSocketPackage(
 | 
				
			||||||
 | 
					        method: 'kex.ask',
 | 
				
			||||||
 | 
					        endpoint: 'id',
 | 
				
			||||||
 | 
					        payload: {
 | 
				
			||||||
 | 
					          'keypair_id': kpId,
 | 
				
			||||||
 | 
					          'user_id': kpOwner,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      )),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Future.any([
 | 
				
			||||||
 | 
					      _requests[kpId]!.future,
 | 
				
			||||||
 | 
					      Future.delayed(const Duration(seconds: 60), () {
 | 
				
			||||||
 | 
					        _requests.remove(kpId);
 | 
				
			||||||
 | 
					        throw TimeoutException("Key exchange timed out");
 | 
				
			||||||
 | 
					      }),
 | 
				
			||||||
 | 
					    ]);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> ackKeyExchange(WebSocketPackage pkt) async {
 | 
				
			||||||
 | 
					    if (pkt.payload == null) return;
 | 
				
			||||||
 | 
					    final kpMeta = SnKeyPair(
 | 
				
			||||||
 | 
					      id: pkt.payload!['keypair_id'] as String,
 | 
				
			||||||
 | 
					      accountId: pkt.payload!['user_id'] as int,
 | 
				
			||||||
 | 
					      publicKey: pkt.payload!['public_key'] as String,
 | 
				
			||||||
 | 
					      privateKey: pkt.payload?['private_key'] as String?,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (_requests.containsKey(kpMeta.id)) {
 | 
				
			||||||
 | 
					      _requests[kpMeta.id]!.complete(kpMeta);
 | 
				
			||||||
 | 
					      _requests.remove(kpMeta.id);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Save the keypair to the local database
 | 
				
			||||||
 | 
					    await _dt.db.snLocalKeyPair.insertOne(
 | 
				
			||||||
 | 
					      SnLocalKeyPairCompanion.insert(
 | 
				
			||||||
 | 
					        id: kpMeta.id,
 | 
				
			||||||
 | 
					        accountId: kpMeta.accountId,
 | 
				
			||||||
 | 
					        publicKey: kpMeta.publicKey,
 | 
				
			||||||
 | 
					        privateKey: Value(kpMeta.privateKey),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      onConflict: DoNothing(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> replyAskKeyExchange(WebSocketPackage pkt) async {
 | 
				
			||||||
 | 
					    final kpId = pkt.payload!['keypair_id'] as String;
 | 
				
			||||||
 | 
					    final userId = pkt.payload!['user_id'] as int;
 | 
				
			||||||
 | 
					    final clientId = pkt.payload!['client_id'] as String;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final localKp = await (_dt.db.snLocalKeyPair.select()
 | 
				
			||||||
 | 
					          ..where((e) => e.id.equals(kpId))
 | 
				
			||||||
 | 
					          ..limit(1))
 | 
				
			||||||
 | 
					        .getSingleOrNull();
 | 
				
			||||||
 | 
					    if (localKp == null) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    logging.info(
 | 
				
			||||||
 | 
					      '[Kex] Reply to key exchange request of $kpId from user $userId',
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // We do not give the private key to the client
 | 
				
			||||||
 | 
					    _ws.conn?.sink.add(jsonEncode(
 | 
				
			||||||
 | 
					      WebSocketPackage(
 | 
				
			||||||
 | 
					        method: 'kex.ack',
 | 
				
			||||||
 | 
					        endpoint: 'id',
 | 
				
			||||||
 | 
					        payload: {
 | 
				
			||||||
 | 
					          'keypair_id': localKp.id,
 | 
				
			||||||
 | 
					          'user_id': localKp.accountId,
 | 
				
			||||||
 | 
					          'public_key': localKp.publicKey,
 | 
				
			||||||
 | 
					          'client_id': clientId,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      ).toJson(),
 | 
				
			||||||
 | 
					    ));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<SnKeyPair?> reloadActive({bool autoEnroll = true}) async {
 | 
				
			||||||
 | 
					    final kp = await (_dt.db.snLocalKeyPair.select()
 | 
				
			||||||
 | 
					          ..where((e) => e.accountId.equals(_ua.user!.id))
 | 
				
			||||||
 | 
					          ..where((e) => e.privateKey.isNotNull())
 | 
				
			||||||
 | 
					          ..where((e) => e.isActive.equals(true))
 | 
				
			||||||
 | 
					          ..limit(1))
 | 
				
			||||||
 | 
					        .getSingleOrNull();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (kp != null) {
 | 
				
			||||||
 | 
					      activeKp = SnKeyPair(
 | 
				
			||||||
 | 
					        id: kp.id,
 | 
				
			||||||
 | 
					        accountId: kp.accountId,
 | 
				
			||||||
 | 
					        publicKey: kp.publicKey,
 | 
				
			||||||
 | 
					        privateKey: kp.privateKey,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (kp == null && autoEnroll) {
 | 
				
			||||||
 | 
					      return await enrollNew();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return activeKp;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<List<SnKeyPair>> listKeyPair() async {
 | 
				
			||||||
 | 
					    final kps = await (_dt.db.snLocalKeyPair.select()).get();
 | 
				
			||||||
 | 
					    return kps
 | 
				
			||||||
 | 
					        .map((e) => SnKeyPair(
 | 
				
			||||||
 | 
					              id: e.id,
 | 
				
			||||||
 | 
					              accountId: e.accountId,
 | 
				
			||||||
 | 
					              publicKey: e.publicKey,
 | 
				
			||||||
 | 
					              privateKey: e.privateKey,
 | 
				
			||||||
 | 
					              isActive: e.isActive,
 | 
				
			||||||
 | 
					            ))
 | 
				
			||||||
 | 
					        .toList();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> activeKeyPair(String kpId) async {
 | 
				
			||||||
 | 
					    final kp = await (_dt.db.snLocalKeyPair.select()
 | 
				
			||||||
 | 
					          ..where((e) => e.id.equals(kpId))
 | 
				
			||||||
 | 
					          ..where((e) => e.privateKey.isNotNull())
 | 
				
			||||||
 | 
					          ..limit(1))
 | 
				
			||||||
 | 
					        .getSingleOrNull();
 | 
				
			||||||
 | 
					    if (kp == null) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await _dt.db.transaction(() async {
 | 
				
			||||||
 | 
					      await (_dt.db.update(_dt.db.snLocalKeyPair)
 | 
				
			||||||
 | 
					            ..where((e) => e.isActive.equals(true)))
 | 
				
			||||||
 | 
					          .write(SnLocalKeyPairCompanion(isActive: Value(false)));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await (_dt.db.update(_dt.db.snLocalKeyPair)
 | 
				
			||||||
 | 
					            ..where((e) => e.id.equals(kp.id)))
 | 
				
			||||||
 | 
					          .write(SnLocalKeyPairCompanion(isActive: Value(true)));
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<SnKeyPair> enrollNew() async {
 | 
				
			||||||
 | 
					    if (!_ua.isAuthorized) throw Exception('Unauthorized');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final id = const Uuid().v4();
 | 
				
			||||||
 | 
					    final kp = await RSA.generate(2048);
 | 
				
			||||||
 | 
					    final kpMeta = SnKeyPair(
 | 
				
			||||||
 | 
					      id: id,
 | 
				
			||||||
 | 
					      accountId: _ua.user!.id,
 | 
				
			||||||
 | 
					      // This is work as expected
 | 
				
			||||||
 | 
					      // We need to share private key to let everyone can decode the message
 | 
				
			||||||
 | 
					      publicKey: kp.privateKey,
 | 
				
			||||||
 | 
					      privateKey: kp.publicKey,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Save the keypair to the local database
 | 
				
			||||||
 | 
					    // If there is already one with private key, it will be overwritten
 | 
				
			||||||
 | 
					    await _dt.db.transaction(() async {
 | 
				
			||||||
 | 
					      await (_dt.db.update(_dt.db.snLocalKeyPair)
 | 
				
			||||||
 | 
					            ..where((e) => e.isActive.equals(true)))
 | 
				
			||||||
 | 
					          .write(SnLocalKeyPairCompanion(isActive: Value(false)));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await _dt.db.snLocalKeyPair.insertOne(
 | 
				
			||||||
 | 
					        SnLocalKeyPairCompanion.insert(
 | 
				
			||||||
 | 
					          id: kpMeta.id,
 | 
				
			||||||
 | 
					          accountId: kpMeta.accountId,
 | 
				
			||||||
 | 
					          publicKey: kpMeta.publicKey,
 | 
				
			||||||
 | 
					          privateKey: Value(kpMeta.privateKey),
 | 
				
			||||||
 | 
					          isActive: Value(true),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await reloadActive(autoEnroll: false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return kpMeta;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,8 +1,8 @@
 | 
				
			|||||||
import 'dart:convert';
 | 
					import 'dart:convert';
 | 
				
			||||||
import 'dart:developer';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/logger.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/types/link.dart';
 | 
					import 'package:surface/types/link.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -20,7 +20,7 @@ class SnLinkPreviewProvider {
 | 
				
			|||||||
    final target = b64.encode(url);
 | 
					    final target = b64.encode(url);
 | 
				
			||||||
    if (_cache.containsKey(target)) return _cache[target];
 | 
					    if (_cache.containsKey(target)) return _cache[target];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    log('[LinkPreview] Fetching $url ($target)');
 | 
					    logging.debug('[LinkPreview] Fetching $url ($target)');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      final resp = await _sn.client.get('/cgi/re/link/$target');
 | 
					      final resp = await _sn.client.get('/cgi/re/link/$target');
 | 
				
			||||||
@@ -28,7 +28,7 @@ class SnLinkPreviewProvider {
 | 
				
			|||||||
      _cache[url] = meta;
 | 
					      _cache[url] = meta;
 | 
				
			||||||
      return meta;
 | 
					      return meta;
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      log('[LinkPreview] Failed to fetch $url ($target)...');
 | 
					      logging.warning('[LinkPreview] Failed to fetch $url ($target)...', err);
 | 
				
			||||||
      return null;
 | 
					      return null;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -63,6 +63,11 @@ class NavigationProvider extends ChangeNotifier {
 | 
				
			|||||||
      screen: 'news',
 | 
					      screen: 'news',
 | 
				
			||||||
      label: 'screenNews',
 | 
					      label: 'screenNews',
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
 | 
					    AppNavDestination(
 | 
				
			||||||
 | 
					      icon: Icon(Symbols.emoji_emotions, weight: 400, opticalSize: 20),
 | 
				
			||||||
 | 
					      screen: 'stickers',
 | 
				
			||||||
 | 
					      label: 'screenStickers',
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
    AppNavDestination(
 | 
					    AppNavDestination(
 | 
				
			||||||
      icon: Icon(Symbols.photo_library, weight: 400, opticalSize: 20),
 | 
					      icon: Icon(Symbols.photo_library, weight: 400, opticalSize: 20),
 | 
				
			||||||
      screen: 'album',
 | 
					      screen: 'album',
 | 
				
			||||||
@@ -88,7 +93,8 @@ class NavigationProvider extends ChangeNotifier {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  List<AppNavDestination> destinations = [];
 | 
					  List<AppNavDestination> destinations = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  int get pinnedDestinationCount => destinations.where((ele) => ele.isPinned).length;
 | 
					  int get pinnedDestinationCount =>
 | 
				
			||||||
 | 
					      destinations.where((ele) => ele.isPinned).length;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  NavigationProvider() {
 | 
					  NavigationProvider() {
 | 
				
			||||||
    buildDestinations(kDefaultPinnedDestination);
 | 
					    buildDestinations(kDefaultPinnedDestination);
 | 
				
			||||||
@@ -117,13 +123,17 @@ class NavigationProvider extends ChangeNotifier {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  bool isIndexInRange(int min, int max) {
 | 
					  bool isIndexInRange(int min, int max) {
 | 
				
			||||||
    return _currentIndex != null && _currentIndex! >= min && _currentIndex! < max;
 | 
					    return _currentIndex != null &&
 | 
				
			||||||
 | 
					        _currentIndex! >= min &&
 | 
				
			||||||
 | 
					        _currentIndex! < max;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void autoDetectIndex(GoRouter? state) {
 | 
					  void autoDetectIndex(GoRouter? state) {
 | 
				
			||||||
    if (state == null) return;
 | 
					    if (state == null) return;
 | 
				
			||||||
    final idx = destinations.indexWhere(
 | 
					    final idx = destinations.indexWhere(
 | 
				
			||||||
      (ele) => ele.screen == state.routerDelegate.currentConfiguration.last.route.name,
 | 
					      (ele) =>
 | 
				
			||||||
 | 
					          ele.screen ==
 | 
				
			||||||
 | 
					          state.routerDelegate.currentConfiguration.last.route.name,
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
    _currentIndex = idx == -1 ? null : idx;
 | 
					    _currentIndex = idx == -1 ? null : idx;
 | 
				
			||||||
    notifyListeners();
 | 
					    notifyListeners();
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,12 +1,14 @@
 | 
				
			|||||||
import 'dart:developer';
 | 
					 | 
				
			||||||
import 'dart:io';
 | 
					import 'dart:io';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:bitsdojo_window/bitsdojo_window.dart';
 | 
				
			||||||
import 'package:firebase_messaging/firebase_messaging.dart';
 | 
					import 'package:firebase_messaging/firebase_messaging.dart';
 | 
				
			||||||
import 'package:flutter/foundation.dart';
 | 
					import 'package:flutter/foundation.dart';
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:flutter/services.dart';
 | 
					import 'package:flutter/services.dart';
 | 
				
			||||||
import 'package:flutter_udid/flutter_udid.dart';
 | 
					import 'package:flutter_udid/flutter_udid.dart';
 | 
				
			||||||
 | 
					import 'package:local_notifier/local_notifier.dart';
 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/logger.dart';
 | 
				
			||||||
import 'package:surface/providers/config.dart';
 | 
					import 'package:surface/providers/config.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/providers/userinfo.dart';
 | 
					import 'package:surface/providers/userinfo.dart';
 | 
				
			||||||
@@ -46,11 +48,13 @@ class NotificationProvider extends ChangeNotifier {
 | 
				
			|||||||
    var deviceUuid = await FlutterUdid.consistentUdid;
 | 
					    var deviceUuid = await FlutterUdid.consistentUdid;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (deviceUuid.isEmpty) {
 | 
					    if (deviceUuid.isEmpty) {
 | 
				
			||||||
      log("Unable to active push notifications, couldn't get device uuid");
 | 
					      logging.warning(
 | 
				
			||||||
 | 
					          '[Push Notification] Unable to active push notifications, couldn\'t get device uuid');
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      log('Device UUID is $deviceUuid');
 | 
					      logging.info('[Push Notification] Device UUID is $deviceUuid');
 | 
				
			||||||
      log('Registering device push notifications...');
 | 
					      logging
 | 
				
			||||||
 | 
					          .info('[Push Notification] Registering device push notifications...');
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (Platform.isIOS || Platform.isMacOS) {
 | 
					    if (Platform.isIOS || Platform.isMacOS) {
 | 
				
			||||||
@@ -60,7 +64,7 @@ class NotificationProvider extends ChangeNotifier {
 | 
				
			|||||||
      provider = 'fcm';
 | 
					      provider = 'fcm';
 | 
				
			||||||
      token = await FirebaseMessaging.instance.getToken();
 | 
					      token = await FirebaseMessaging.instance.getToken();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    log('Device Push Token is $token');
 | 
					    logging.info('[Push Notification] Device Push Token is $token');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    await _sn.client.post(
 | 
					    await _sn.client.post(
 | 
				
			||||||
      '/cgi/id/notifications/subscription',
 | 
					      '/cgi/id/notifications/subscription',
 | 
				
			||||||
@@ -76,22 +80,49 @@ class NotificationProvider extends ChangeNotifier {
 | 
				
			|||||||
  int showingTrayCount = 0;
 | 
					  int showingTrayCount = 0;
 | 
				
			||||||
  List<SnNotification> notifications = List.empty(growable: true);
 | 
					  List<SnNotification> notifications = List.empty(growable: true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  int? skippableNotifyChannel;
 | 
				
			||||||
 | 
					  bool isMuted = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void listen() {
 | 
					  void listen() {
 | 
				
			||||||
    _ws.pk.stream.listen((event) {
 | 
					    _ws.pk.stream.listen((event) {
 | 
				
			||||||
      if (event.method == 'notifications.new') {
 | 
					      if (event.method == 'notifications.new') {
 | 
				
			||||||
        final notification = SnNotification.fromJson(event.payload!);
 | 
					        final notification = SnNotification.fromJson(event.payload!);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true;
 | 
				
			||||||
 | 
					        if (doHaptic) HapticFeedback.mediumImpact();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (notification.topic == 'messaging.message' &&
 | 
				
			||||||
 | 
					            skippableNotifyChannel != null) {
 | 
				
			||||||
 | 
					          if (notification.metadata['channel_id'] != null &&
 | 
				
			||||||
 | 
					              notification.metadata['channel_id'] == skippableNotifyChannel) {
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (showingCount < 0) showingCount = 0;
 | 
					        if (showingCount < 0) showingCount = 0;
 | 
				
			||||||
        showingCount++;
 | 
					        showingCount++;
 | 
				
			||||||
        showingTrayCount++;
 | 
					        showingTrayCount++;
 | 
				
			||||||
        notifications.add(notification);
 | 
					        notifications.add(notification);
 | 
				
			||||||
        Future.delayed(const Duration(seconds: 3), () {
 | 
					        Future.delayed(const Duration(seconds: 5), () {
 | 
				
			||||||
          if (showingCount >= 0) showingCount--;
 | 
					          if (showingCount >= 0) showingCount--;
 | 
				
			||||||
          notifyListeners();
 | 
					          notifyListeners();
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
        notifyListeners();
 | 
					        notifyListeners();
 | 
				
			||||||
        updateTray();
 | 
					        updateTray();
 | 
				
			||||||
        final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true;
 | 
					
 | 
				
			||||||
        if (doHaptic) HapticFeedback.mediumImpact();
 | 
					        if (!kIsWeb && !isMuted) {
 | 
				
			||||||
 | 
					          if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
 | 
				
			||||||
 | 
					            LocalNotification notify = LocalNotification(
 | 
				
			||||||
 | 
					              title: notification.title,
 | 
				
			||||||
 | 
					              subtitle: notification.subtitle,
 | 
				
			||||||
 | 
					              body: notification.body,
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					            notify.onClick = () {
 | 
				
			||||||
 | 
					              appWindow.show();
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					            notify.show();
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,22 +2,33 @@ import 'package:flutter/material.dart';
 | 
				
			|||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_attachment.dart';
 | 
					import 'package:surface/providers/sn_attachment.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_realm.dart';
 | 
				
			||||||
import 'package:surface/providers/user_directory.dart';
 | 
					import 'package:surface/providers/user_directory.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/poll.dart';
 | 
				
			||||||
import 'package:surface/types/post.dart';
 | 
					import 'package:surface/types/post.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/realm.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SnPostContentProvider {
 | 
					class SnPostContentProvider {
 | 
				
			||||||
  late final SnNetworkProvider _sn;
 | 
					  late final SnNetworkProvider _sn;
 | 
				
			||||||
  late final UserDirectoryProvider _ud;
 | 
					  late final UserDirectoryProvider _ud;
 | 
				
			||||||
  late final SnAttachmentProvider _attach;
 | 
					  late final SnAttachmentProvider _attach;
 | 
				
			||||||
 | 
					  late final SnRealmProvider _realm;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  SnPostContentProvider(BuildContext context) {
 | 
					  SnPostContentProvider(BuildContext context) {
 | 
				
			||||||
    _sn = context.read<SnNetworkProvider>();
 | 
					    _sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
    _ud = context.read<UserDirectoryProvider>();
 | 
					    _ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
    _attach = context.read<SnAttachmentProvider>();
 | 
					    _attach = context.read<SnAttachmentProvider>();
 | 
				
			||||||
 | 
					    _realm = context.read<SnRealmProvider>();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<SnPoll> _fetchPoll(int id) async {
 | 
				
			||||||
 | 
					    final resp = await _sn.client.get('/cgi/co/polls/$id');
 | 
				
			||||||
 | 
					    return SnPoll.fromJson(resp.data);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<List<SnPost>> _preloadRelatedDataInBatch(List<SnPost> out) async {
 | 
					  Future<List<SnPost>> _preloadRelatedDataInBatch(List<SnPost> out) async {
 | 
				
			||||||
    Set<String> rids = {};
 | 
					    Set<String> rids = {};
 | 
				
			||||||
 | 
					    Set<int> uids = {};
 | 
				
			||||||
    for (var i = 0; i < out.length; i++) {
 | 
					    for (var i = 0; i < out.length; i++) {
 | 
				
			||||||
      rids.addAll(out[i].body['attachments']?.cast<String>() ?? []);
 | 
					      rids.addAll(out[i].body['attachments']?.cast<String>() ?? []);
 | 
				
			||||||
      if (out[i].body['thumbnail'] != null) {
 | 
					      if (out[i].body['thumbnail'] != null) {
 | 
				
			||||||
@@ -31,28 +42,42 @@ class SnPostContentProvider {
 | 
				
			|||||||
          repostTo: await _preloadRelatedDataSingle(out[i].repostTo!),
 | 
					          repostTo: await _preloadRelatedDataSingle(out[i].repostTo!),
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					      if (out[i].publisher.type == 0) {
 | 
				
			||||||
 | 
					        uids.add(out[i].publisher.accountId);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final attachments = await _attach.getMultiple(rids.toList());
 | 
					    final attachments = await _attach.getMultiple(rids.toList());
 | 
				
			||||||
    for (var i = 0; i < out.length; i++) {
 | 
					    for (var i = 0; i < out.length; i++) {
 | 
				
			||||||
 | 
					      SnPoll? poll;
 | 
				
			||||||
 | 
					      SnRealm? realm;
 | 
				
			||||||
 | 
					      if (out[i].pollId != null) {
 | 
				
			||||||
 | 
					        poll = await _fetchPoll(out[i].pollId!);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      if (out[i].realmId != null) {
 | 
				
			||||||
 | 
					        realm = await _realm.getRealm(out[i].realmId!);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      out[i] = out[i].copyWith(
 | 
					      out[i] = out[i].copyWith(
 | 
				
			||||||
        preload: SnPostPreload(
 | 
					        preload: SnPostPreload(
 | 
				
			||||||
          thumbnail: attachments.where((ele) => ele?.rid == out[i].body['thumbnail']).firstOrNull,
 | 
					          thumbnail: attachments.where((ele) => ele?.rid == out[i].body['thumbnail']).firstOrNull,
 | 
				
			||||||
          attachments: attachments.where((ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false).toList(),
 | 
					          attachments: attachments.where((ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false).toList(),
 | 
				
			||||||
          video: attachments.where((ele) => ele?.rid == out[i].body['video']).firstOrNull,
 | 
					          video: attachments.where((ele) => ele?.rid == out[i].body['video']).firstOrNull,
 | 
				
			||||||
 | 
					          poll: poll,
 | 
				
			||||||
 | 
					          realm: realm,
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    await _ud.listAccount(
 | 
					    uids.addAll(attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
 | 
				
			||||||
      attachments.where((ele) => ele != null).map((ele) => ele!.accountId).toSet(),
 | 
					    await _ud.listAccount(uids);
 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return out;
 | 
					    return out;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<SnPost> _preloadRelatedDataSingle(SnPost out) async {
 | 
					  Future<SnPost> _preloadRelatedDataSingle(SnPost out) async {
 | 
				
			||||||
    Set<String> rids = {};
 | 
					    Set<String> rids = {};
 | 
				
			||||||
 | 
					    Set<int> uids = {};
 | 
				
			||||||
    rids.addAll(out.body['attachments']?.cast<String>() ?? []);
 | 
					    rids.addAll(out.body['attachments']?.cast<String>() ?? []);
 | 
				
			||||||
    if (out.body['thumbnail'] != null) {
 | 
					    if (out.body['thumbnail'] != null) {
 | 
				
			||||||
      rids.add(out.body['thumbnail']);
 | 
					      rids.add(out.body['thumbnail']);
 | 
				
			||||||
@@ -65,16 +90,34 @@ class SnPostContentProvider {
 | 
				
			|||||||
        repostTo: await _preloadRelatedDataSingle(out.repostTo!),
 | 
					        repostTo: await _preloadRelatedDataSingle(out.repostTo!),
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					    if (out.publisher.type == 0) {
 | 
				
			||||||
 | 
					      uids.add(out.publisher.accountId);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final attachments = await _attach.getMultiple(rids.toList());
 | 
					    final attachments = await _attach.getMultiple(rids.toList());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    SnPoll? poll;
 | 
				
			||||||
 | 
					    SnRealm? realm;
 | 
				
			||||||
 | 
					    if (out.pollId != null) {
 | 
				
			||||||
 | 
					      poll = await _fetchPoll(out.pollId!);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (out.realmId != null) {
 | 
				
			||||||
 | 
					      realm = await _realm.getRealm(out.realmId!);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    out = out.copyWith(
 | 
					    out = out.copyWith(
 | 
				
			||||||
      preload: SnPostPreload(
 | 
					      preload: SnPostPreload(
 | 
				
			||||||
        thumbnail: attachments.where((ele) => ele?.rid == out.body['thumbnail']).firstOrNull,
 | 
					        thumbnail: attachments.where((ele) => ele?.rid == out.body['thumbnail']).firstOrNull,
 | 
				
			||||||
        attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(),
 | 
					        attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(),
 | 
				
			||||||
        video: attachments.where((ele) => ele?.rid == out.body['video']).firstOrNull,
 | 
					        video: attachments.where((ele) => ele?.rid == out.body['video']).firstOrNull,
 | 
				
			||||||
 | 
					        poll: poll,
 | 
				
			||||||
 | 
					        realm: realm,
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    uids.addAll(attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
 | 
				
			||||||
 | 
					    await _ud.listAccount(uids);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return out;
 | 
					    return out;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -93,6 +136,8 @@ class SnPostContentProvider {
 | 
				
			|||||||
    String? author,
 | 
					    String? author,
 | 
				
			||||||
    Iterable<String>? categories,
 | 
					    Iterable<String>? categories,
 | 
				
			||||||
    Iterable<String>? tags,
 | 
					    Iterable<String>? tags,
 | 
				
			||||||
 | 
					    String? realm,
 | 
				
			||||||
 | 
					    String? channel,
 | 
				
			||||||
  }) async {
 | 
					  }) async {
 | 
				
			||||||
    final resp = await _sn.client.get('/cgi/co/posts', queryParameters: {
 | 
					    final resp = await _sn.client.get('/cgi/co/posts', queryParameters: {
 | 
				
			||||||
      'take': take,
 | 
					      'take': take,
 | 
				
			||||||
@@ -101,6 +146,8 @@ class SnPostContentProvider {
 | 
				
			|||||||
      if (author != null) 'author': author,
 | 
					      if (author != null) 'author': author,
 | 
				
			||||||
      if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
 | 
					      if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
 | 
				
			||||||
      if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','),
 | 
					      if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','),
 | 
				
			||||||
 | 
					      if (realm != null) 'realm': realm,
 | 
				
			||||||
 | 
					      if (channel != null) 'channel': channel,
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    final List<SnPost> out = await _preloadRelatedDataInBatch(
 | 
					    final List<SnPost> out = await _preloadRelatedDataInBatch(
 | 
				
			||||||
      List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
 | 
					      List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,11 +1,14 @@
 | 
				
			|||||||
import 'dart:collection';
 | 
					import 'dart:collection';
 | 
				
			||||||
 | 
					import 'dart:convert';
 | 
				
			||||||
import 'dart:math' as math;
 | 
					import 'dart:math' as math;
 | 
				
			||||||
import 'dart:typed_data';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
import 'package:dio/dio.dart';
 | 
					import 'package:dio/dio.dart';
 | 
				
			||||||
 | 
					import 'package:drift/drift.dart';
 | 
				
			||||||
import 'package:flutter/widgets.dart';
 | 
					import 'package:flutter/widgets.dart';
 | 
				
			||||||
import 'package:cross_file/cross_file.dart';
 | 
					import 'package:cross_file/cross_file.dart';
 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/database/database.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/database.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/types/attachment.dart';
 | 
					import 'package:surface/types/attachment.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -13,10 +16,12 @@ const kConcurrentUploadChunks = 5;
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
class SnAttachmentProvider {
 | 
					class SnAttachmentProvider {
 | 
				
			||||||
  late final SnNetworkProvider _sn;
 | 
					  late final SnNetworkProvider _sn;
 | 
				
			||||||
 | 
					  late final DatabaseProvider _dt;
 | 
				
			||||||
  final Map<String, SnAttachment> _cache = {};
 | 
					  final Map<String, SnAttachment> _cache = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  SnAttachmentProvider(BuildContext context) {
 | 
					  SnAttachmentProvider(BuildContext context) {
 | 
				
			||||||
    _sn = context.read<SnNetworkProvider>();
 | 
					    _sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					    _dt = context.read<DatabaseProvider>();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) {
 | 
					  void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) {
 | 
				
			||||||
@@ -28,20 +33,33 @@ class SnAttachmentProvider {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<SnAttachment> getOne(String rid, {noCache = false}) async {
 | 
					  Future<SnAttachment> getOne(String rid, {noCache = false}) async {
 | 
				
			||||||
 | 
					    // In-memory cache
 | 
				
			||||||
    if (!noCache && _cache.containsKey(rid)) {
 | 
					    if (!noCache && _cache.containsKey(rid)) {
 | 
				
			||||||
      return _cache[rid]!;
 | 
					      return _cache[rid]!;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					    // On-disk cache
 | 
				
			||||||
 | 
					    final dbResp = await (_dt.db.snLocalAttachment.select()
 | 
				
			||||||
 | 
					          ..where((e) => e.rid.equals(rid))
 | 
				
			||||||
 | 
					          ..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())))
 | 
				
			||||||
 | 
					        .getSingleOrNull();
 | 
				
			||||||
 | 
					    if (dbResp != null) {
 | 
				
			||||||
 | 
					      _cache[rid] = dbResp.content;
 | 
				
			||||||
 | 
					      return dbResp.content;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    // Remote server
 | 
				
			||||||
    final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta');
 | 
					    final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta');
 | 
				
			||||||
    final out = SnAttachment.fromJson(resp.data);
 | 
					    final out = SnAttachment.fromJson(resp.data);
 | 
				
			||||||
    if (out.isAnalyzed) {
 | 
					    if (out.isAnalyzed) {
 | 
				
			||||||
      _cache[rid] = out;
 | 
					      _cache[rid] = out;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					    _saveToLocal([out]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return out;
 | 
					    return out;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<List<SnAttachment?>> getMultiple(List<String> rids, {noCache = false}) async {
 | 
					  Future<List<SnAttachment?>> getMultiple(List<String> rids,
 | 
				
			||||||
 | 
					      {bool noCache = false}) async {
 | 
				
			||||||
 | 
					    // In-memory cache
 | 
				
			||||||
    final result = List<SnAttachment?>.filled(rids.length, null);
 | 
					    final result = List<SnAttachment?>.filled(rids.length, null);
 | 
				
			||||||
    final Map<String, int> randomMapping = {};
 | 
					    final Map<String, int> randomMapping = {};
 | 
				
			||||||
    for (int i = 0; i < rids.length; i++) {
 | 
					    for (int i = 0; i < rids.length; i++) {
 | 
				
			||||||
@@ -52,32 +70,55 @@ class SnAttachmentProvider {
 | 
				
			|||||||
        result[i] = _cache[rid]!;
 | 
					        result[i] = _cache[rid]!;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    final pendingFetch = randomMapping.keys;
 | 
					    var pendingFetch = randomMapping.keys;
 | 
				
			||||||
 | 
					    // On-disk cache
 | 
				
			||||||
    if (pendingFetch.isNotEmpty) {
 | 
					    if (pendingFetch.isEmpty) return result;
 | 
				
			||||||
      final resp = await _sn.client.get(
 | 
					    if (!noCache) {
 | 
				
			||||||
        '/cgi/uc/attachments',
 | 
					      final dbResp = await (_dt.db.snLocalAttachment.select()
 | 
				
			||||||
        queryParameters: {
 | 
					            ..where((e) => e.rid.isIn(pendingFetch))
 | 
				
			||||||
          'take': pendingFetch.length,
 | 
					            ..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())))
 | 
				
			||||||
          'id': pendingFetch.join(','),
 | 
					          .get();
 | 
				
			||||||
        },
 | 
					      for (final item in dbResp) {
 | 
				
			||||||
      );
 | 
					        if (item.content.isAnalyzed) {
 | 
				
			||||||
      final List<SnAttachment?> out =
 | 
					          _cache[item.rid] = item.content;
 | 
				
			||||||
          resp.data['data'].map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e)).cast<SnAttachment?>().toList();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      for (final item in out) {
 | 
					 | 
				
			||||||
        if (item == null) continue;
 | 
					 | 
				
			||||||
        if (item.isAnalyzed) {
 | 
					 | 
				
			||||||
          _cache[item.rid] = item;
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        result[randomMapping[item.rid]!] = item;
 | 
					        result[randomMapping[item.rid]!] = item.content;
 | 
				
			||||||
 | 
					        randomMapping.remove(item.rid);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					      pendingFetch = randomMapping.keys;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					    // Remote server
 | 
				
			||||||
 | 
					    if (pendingFetch.isEmpty) return result;
 | 
				
			||||||
 | 
					    final resp = await _sn.client.get(
 | 
				
			||||||
 | 
					      '/cgi/uc/attachments',
 | 
				
			||||||
 | 
					      queryParameters: {
 | 
				
			||||||
 | 
					        'take': pendingFetch.length,
 | 
				
			||||||
 | 
					        'id': pendingFetch.join(','),
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    final List<SnAttachment?> out = resp.data['data']
 | 
				
			||||||
 | 
					        .map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e))
 | 
				
			||||||
 | 
					        .cast<SnAttachment?>()
 | 
				
			||||||
 | 
					        .toList();
 | 
				
			||||||
 | 
					    for (final item in out) {
 | 
				
			||||||
 | 
					      if (item == null) continue;
 | 
				
			||||||
 | 
					      if (item.isAnalyzed) {
 | 
				
			||||||
 | 
					        _cache[item.rid] = item;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      result[randomMapping[item.rid]!] = item;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    _saveToLocal(out.where((ele) => ele != null).cast());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return result;
 | 
					    return result;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static Map<String, String> mimetypeOverrides = {'mov': 'video/quicktime', 'mp4': 'video/mp4'};
 | 
					  static Map<String, String> mimetypeOverrides = {
 | 
				
			||||||
 | 
					    'mov': 'video/quicktime',
 | 
				
			||||||
 | 
					    'mp4': 'video/mp4',
 | 
				
			||||||
 | 
					    'm4a': 'audio/mp4',
 | 
				
			||||||
 | 
					    'apng': 'image/apng',
 | 
				
			||||||
 | 
					    'webp': 'image/webp',
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<SnAttachment> directUploadOne(
 | 
					  Future<SnAttachment> directUploadOne(
 | 
				
			||||||
    Uint8List data,
 | 
					    Uint8List data,
 | 
				
			||||||
@@ -89,8 +130,11 @@ class SnAttachmentProvider {
 | 
				
			|||||||
    bool analyzeNow = false,
 | 
					    bool analyzeNow = false,
 | 
				
			||||||
  }) async {
 | 
					  }) async {
 | 
				
			||||||
    final filePayload = MultipartFile.fromBytes(data, filename: filename);
 | 
					    final filePayload = MultipartFile.fromBytes(data, filename: filename);
 | 
				
			||||||
    final fileAlt = filename.contains('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename;
 | 
					    final fileAlt = filename.contains('.')
 | 
				
			||||||
    final fileExt = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
 | 
					        ? filename.substring(0, filename.lastIndexOf('.'))
 | 
				
			||||||
 | 
					        : filename;
 | 
				
			||||||
 | 
					    final fileExt =
 | 
				
			||||||
 | 
					        filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    String? mimetypeOverride;
 | 
					    String? mimetypeOverride;
 | 
				
			||||||
    if (mimetype != null) {
 | 
					    if (mimetype != null) {
 | 
				
			||||||
@@ -127,8 +171,11 @@ class SnAttachmentProvider {
 | 
				
			|||||||
    Map<String, dynamic>? metadata, {
 | 
					    Map<String, dynamic>? metadata, {
 | 
				
			||||||
    String? mimetype,
 | 
					    String? mimetype,
 | 
				
			||||||
  }) async {
 | 
					  }) async {
 | 
				
			||||||
    final fileAlt = filename.contains('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename;
 | 
					    final fileAlt = filename.contains('.')
 | 
				
			||||||
    final fileExt = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
 | 
					        ? filename.substring(0, filename.lastIndexOf('.'))
 | 
				
			||||||
 | 
					        : filename;
 | 
				
			||||||
 | 
					    final fileExt =
 | 
				
			||||||
 | 
					        filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    String? mimetypeOverride;
 | 
					    String? mimetypeOverride;
 | 
				
			||||||
    if (mimetype == null && mimetypeOverrides.keys.contains(fileExt)) {
 | 
					    if (mimetype == null && mimetypeOverrides.keys.contains(fileExt)) {
 | 
				
			||||||
@@ -146,7 +193,10 @@ class SnAttachmentProvider {
 | 
				
			|||||||
      if (mimetypeOverride != null) 'mimetype': mimetypeOverride,
 | 
					      if (mimetypeOverride != null) 'mimetype': mimetypeOverride,
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (SnAttachmentFragment.fromJson(resp.data['meta']), resp.data['chunk_size'] as int);
 | 
					    return (
 | 
				
			||||||
 | 
					      SnAttachmentFragment.fromJson(resp.data['meta']),
 | 
				
			||||||
 | 
					      resp.data['chunk_size'] as int
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<dynamic> _chunkedUploadOnePart(
 | 
					  Future<dynamic> _chunkedUploadOnePart(
 | 
				
			||||||
@@ -197,7 +247,10 @@ class SnAttachmentProvider {
 | 
				
			|||||||
          (entry.value + 1) * chunkSize,
 | 
					          (entry.value + 1) * chunkSize,
 | 
				
			||||||
          await file.length(),
 | 
					          await file.length(),
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
        final data = Uint8List.fromList(await file.openRead(beginCursor, endCursor).expand((chunk) => chunk).toList());
 | 
					        final data = Uint8List.fromList(await file
 | 
				
			||||||
 | 
					            .openRead(beginCursor, endCursor)
 | 
				
			||||||
 | 
					            .expand((chunk) => chunk)
 | 
				
			||||||
 | 
					            .toList());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        final result = await _chunkedUploadOnePart(
 | 
					        final result = await _chunkedUploadOnePart(
 | 
				
			||||||
          data,
 | 
					          data,
 | 
				
			||||||
@@ -253,6 +306,31 @@ class SnAttachmentProvider {
 | 
				
			|||||||
      'metadata': metadata ?? item.usermeta,
 | 
					      'metadata': metadata ?? item.usermeta,
 | 
				
			||||||
      'is_indexable': isIndexable ?? item.isIndexable,
 | 
					      'is_indexable': isIndexable ?? item.isIndexable,
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    return SnAttachment.fromJson(resp.data);
 | 
					    final out = SnAttachment.fromJson(resp.data);
 | 
				
			||||||
 | 
					    _saveToLocal([out]);
 | 
				
			||||||
 | 
					    return out;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _saveToLocal(Iterable<SnAttachment> out) async {
 | 
				
			||||||
 | 
					    for (final ele in out) {
 | 
				
			||||||
 | 
					      if (!ele.isAnalyzed || ele.destination == 0) continue;
 | 
				
			||||||
 | 
					      await _dt.db.snLocalAttachment.insertOne(
 | 
				
			||||||
 | 
					        SnLocalAttachmentCompanion.insert(
 | 
				
			||||||
 | 
					          id: Value(ele.id),
 | 
				
			||||||
 | 
					          rid: ele.rid,
 | 
				
			||||||
 | 
					          uuid: ele.uuid,
 | 
				
			||||||
 | 
					          content: ele,
 | 
				
			||||||
 | 
					          accountId: ele.accountId,
 | 
				
			||||||
 | 
					          cacheExpiredAt: DateTime.now().add(const Duration(days: 7)),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        onConflict: DoUpdate(
 | 
				
			||||||
 | 
					          (_) => SnLocalAttachmentCompanion.custom(
 | 
				
			||||||
 | 
					            content: Constant(jsonEncode(ele.toJson())),
 | 
				
			||||||
 | 
					            cacheExpiredAt:
 | 
				
			||||||
 | 
					                Constant(DateTime.now().add(const Duration(days: 7))),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,5 @@
 | 
				
			|||||||
import 'dart:async';
 | 
					import 'dart:async';
 | 
				
			||||||
import 'dart:convert';
 | 
					import 'dart:convert';
 | 
				
			||||||
import 'dart:developer';
 | 
					 | 
				
			||||||
import 'dart:io';
 | 
					import 'dart:io';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import 'package:dio/dio.dart';
 | 
					import 'package:dio/dio.dart';
 | 
				
			||||||
@@ -11,9 +10,12 @@ import 'package:package_info_plus/package_info_plus.dart';
 | 
				
			|||||||
import 'package:device_info_plus/device_info_plus.dart';
 | 
					import 'package:device_info_plus/device_info_plus.dart';
 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
import 'package:shared_preferences/shared_preferences.dart';
 | 
					import 'package:shared_preferences/shared_preferences.dart';
 | 
				
			||||||
 | 
					import 'package:surface/logger.dart';
 | 
				
			||||||
import 'package:surface/providers/config.dart';
 | 
					import 'package:surface/providers/config.dart';
 | 
				
			||||||
import 'package:surface/providers/widget.dart';
 | 
					import 'package:surface/providers/widget.dart';
 | 
				
			||||||
import 'package:synchronized/synchronized.dart';
 | 
					import 'package:synchronized/synchronized.dart';
 | 
				
			||||||
 | 
					import 'package:talker_dio_logger/talker_dio_logger_interceptor.dart';
 | 
				
			||||||
 | 
					import 'package:talker_dio_logger/talker_dio_logger_settings.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const kNetworkServerDirectory = [
 | 
					const kNetworkServerDirectory = [
 | 
				
			||||||
  ('Solar Network', 'https://api.sn.solsynth.dev'),
 | 
					  ('Solar Network', 'https://api.sn.solsynth.dev'),
 | 
				
			||||||
@@ -36,6 +38,19 @@ class SnNetworkProvider {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    client = Dio();
 | 
					    client = Dio();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    client.interceptors.add(
 | 
				
			||||||
 | 
					      TalkerDioLogger(
 | 
				
			||||||
 | 
					        talker: logging,
 | 
				
			||||||
 | 
					        settings: const TalkerDioLoggerSettings(
 | 
				
			||||||
 | 
					          printRequestHeaders: false,
 | 
				
			||||||
 | 
					          printResponseHeaders: false,
 | 
				
			||||||
 | 
					          printResponseMessage: false,
 | 
				
			||||||
 | 
					          printResponseData: false,
 | 
				
			||||||
 | 
					          printRequestData: false,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    client.interceptors.add(RetryInterceptor(
 | 
					    client.interceptors.add(RetryInterceptor(
 | 
				
			||||||
      dio: client,
 | 
					      dio: client,
 | 
				
			||||||
      retries: 3,
 | 
					      retries: 3,
 | 
				
			||||||
@@ -69,7 +84,6 @@ class SnNetworkProvider {
 | 
				
			|||||||
      _prefs = _config.prefs;
 | 
					      _prefs = _config.prefs;
 | 
				
			||||||
      client.options.baseUrl = _config.serverUrl;
 | 
					      client.options.baseUrl = _config.serverUrl;
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static Future<Dio> createOffContextClient() async {
 | 
					  static Future<Dio> createOffContextClient() async {
 | 
				
			||||||
@@ -91,7 +105,8 @@ class SnNetworkProvider {
 | 
				
			|||||||
          RequestOptions options,
 | 
					          RequestOptions options,
 | 
				
			||||||
          RequestInterceptorHandler handler,
 | 
					          RequestInterceptorHandler handler,
 | 
				
			||||||
        ) async {
 | 
					        ) async {
 | 
				
			||||||
          final atk = await _getFreshAtk(client, prefs.getString(kAtkStoreKey), prefs.getString(kRtkStoreKey), (atk, rtk) {
 | 
					          final atk = await _getFreshAtk(client, prefs.getString(kAtkStoreKey),
 | 
				
			||||||
 | 
					              prefs.getString(kRtkStoreKey), (atk, rtk) {
 | 
				
			||||||
            prefs.setString(kAtkStoreKey, atk);
 | 
					            prefs.setString(kAtkStoreKey, atk);
 | 
				
			||||||
            prefs.setString(kRtkStoreKey, rtk);
 | 
					            prefs.setString(kRtkStoreKey, rtk);
 | 
				
			||||||
          });
 | 
					          });
 | 
				
			||||||
@@ -103,7 +118,8 @@ class SnNetworkProvider {
 | 
				
			|||||||
        },
 | 
					        },
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
    client.options.baseUrl = prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
 | 
					    client.options.baseUrl =
 | 
				
			||||||
 | 
					        prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return client;
 | 
					    return client;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@@ -119,7 +135,8 @@ class SnNetworkProvider {
 | 
				
			|||||||
      platformInfo = 'Web; ${deviceInfo.vendor}';
 | 
					      platformInfo = 'Web; ${deviceInfo.vendor}';
 | 
				
			||||||
    } else if (Platform.isAndroid) {
 | 
					    } else if (Platform.isAndroid) {
 | 
				
			||||||
      final deviceInfo = await DeviceInfoPlugin().androidInfo;
 | 
					      final deviceInfo = await DeviceInfoPlugin().androidInfo;
 | 
				
			||||||
      platformInfo = 'Android; ${deviceInfo.brand} ${deviceInfo.model}; ${deviceInfo.id}';
 | 
					      platformInfo =
 | 
				
			||||||
 | 
					          'Android; ${deviceInfo.brand} ${deviceInfo.model}; ${deviceInfo.id}';
 | 
				
			||||||
    } else if (Platform.isIOS) {
 | 
					    } else if (Platform.isIOS) {
 | 
				
			||||||
      final deviceInfo = await DeviceInfoPlugin().iosInfo;
 | 
					      final deviceInfo = await DeviceInfoPlugin().iosInfo;
 | 
				
			||||||
      platformInfo = 'iOS; ${deviceInfo.model}; ${deviceInfo.name}';
 | 
					      platformInfo = 'iOS; ${deviceInfo.model}; ${deviceInfo.name}';
 | 
				
			||||||
@@ -128,7 +145,8 @@ class SnNetworkProvider {
 | 
				
			|||||||
      platformInfo = 'MacOS; ${deviceInfo.model}; ${deviceInfo.hostName}';
 | 
					      platformInfo = 'MacOS; ${deviceInfo.model}; ${deviceInfo.hostName}';
 | 
				
			||||||
    } else if (Platform.isWindows) {
 | 
					    } else if (Platform.isWindows) {
 | 
				
			||||||
      final deviceInfo = await DeviceInfoPlugin().windowsInfo;
 | 
					      final deviceInfo = await DeviceInfoPlugin().windowsInfo;
 | 
				
			||||||
      platformInfo = 'Windows NT; ${deviceInfo.productName}; ${deviceInfo.computerName}';
 | 
					      platformInfo =
 | 
				
			||||||
 | 
					          'Windows NT; ${deviceInfo.productName}; ${deviceInfo.computerName}';
 | 
				
			||||||
    } else if (Platform.isLinux) {
 | 
					    } else if (Platform.isLinux) {
 | 
				
			||||||
      final deviceInfo = await DeviceInfoPlugin().linuxInfo;
 | 
					      final deviceInfo = await DeviceInfoPlugin().linuxInfo;
 | 
				
			||||||
      platformInfo = 'Linux; ${deviceInfo.prettyName}';
 | 
					      platformInfo = 'Linux; ${deviceInfo.prettyName}';
 | 
				
			||||||
@@ -148,12 +166,15 @@ class SnNetworkProvider {
 | 
				
			|||||||
  final tkLock = Lock();
 | 
					  final tkLock = Lock();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<String?> getFreshAtk() async {
 | 
					  Future<String?> getFreshAtk() async {
 | 
				
			||||||
    return await _getFreshAtk(client, _prefs.getString(kAtkStoreKey), _prefs.getString(kRtkStoreKey), (atk, rtk) {
 | 
					    return await _getFreshAtk(
 | 
				
			||||||
 | 
					        client, _prefs.getString(kAtkStoreKey), _prefs.getString(kRtkStoreKey),
 | 
				
			||||||
 | 
					        (atk, rtk) {
 | 
				
			||||||
      setTokenPair(atk, rtk);
 | 
					      setTokenPair(atk, rtk);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static Future<String?> _getFreshAtk(Dio client, String? atk, String? rtk, Function(String atk, String rtk)? onRefresh) async {
 | 
					  static Future<String?> _getFreshAtk(Dio client, String? atk, String? rtk,
 | 
				
			||||||
 | 
					      Function(String atk, String rtk)? onRefresh) async {
 | 
				
			||||||
    if (_refreshCompleter != null) {
 | 
					    if (_refreshCompleter != null) {
 | 
				
			||||||
      return await _refreshCompleter!.future;
 | 
					      return await _refreshCompleter!.future;
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
@@ -185,7 +206,8 @@ class SnNetworkProvider {
 | 
				
			|||||||
        final payload = b64.decode(rawPayload);
 | 
					        final payload = b64.decode(rawPayload);
 | 
				
			||||||
        final exp = jsonDecode(payload)['exp'];
 | 
					        final exp = jsonDecode(payload)['exp'];
 | 
				
			||||||
        if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) {
 | 
					        if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) {
 | 
				
			||||||
          log('Access token need refresh, doing it at ${DateTime.now()}');
 | 
					          logging.debug(
 | 
				
			||||||
 | 
					              '[Auth] Access token need refresh, doing it at ${DateTime.now()}');
 | 
				
			||||||
          final result = await _refreshToken(client.options.baseUrl, rtk);
 | 
					          final result = await _refreshToken(client.options.baseUrl, rtk);
 | 
				
			||||||
          if (result == null) {
 | 
					          if (result == null) {
 | 
				
			||||||
            atk = null;
 | 
					            atk = null;
 | 
				
			||||||
@@ -199,12 +221,12 @@ class SnNetworkProvider {
 | 
				
			|||||||
          _refreshCompleter!.complete(atk);
 | 
					          _refreshCompleter!.complete(atk);
 | 
				
			||||||
          return atk;
 | 
					          return atk;
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
          log('Access token refresh failed...');
 | 
					          logging.error('[Auth] Access token refresh failed...');
 | 
				
			||||||
          _refreshCompleter!.complete(null);
 | 
					          _refreshCompleter!.complete(null);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      log('Failed to authenticate user: $err');
 | 
					      logging.error('[Auth] Failed to authenticate user...', err);
 | 
				
			||||||
      _refreshCompleter!.completeError(err);
 | 
					      _refreshCompleter!.completeError(err);
 | 
				
			||||||
    } finally {
 | 
					    } finally {
 | 
				
			||||||
      _refreshCompleter = null;
 | 
					      _refreshCompleter = null;
 | 
				
			||||||
@@ -237,7 +259,8 @@ class SnNetworkProvider {
 | 
				
			|||||||
    return result.$1;
 | 
					    return result.$1;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static Future<(String, String)?> _refreshToken(String baseUrl, String? rtk) async {
 | 
					  static Future<(String, String)?> _refreshToken(
 | 
				
			||||||
 | 
					      String baseUrl, String? rtk) async {
 | 
				
			||||||
    if (rtk == null) return null;
 | 
					    if (rtk == null) return null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final dio = Dio();
 | 
					    final dio = Dio();
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										37
									
								
								lib/providers/sn_realm.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								lib/providers/sn_realm.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
				
			|||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/realm.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SnRealmProvider {
 | 
				
			||||||
 | 
					  late final SnNetworkProvider _sn;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  SnRealmProvider(BuildContext context) {
 | 
				
			||||||
 | 
					    _sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final Map<String, SnRealm> _cache = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<List<SnRealm>> listAvailableRealms() async {
 | 
				
			||||||
 | 
					    final resp = await _sn.client.get('/cgi/id/realms/me/available');
 | 
				
			||||||
 | 
					    final out = List<SnRealm>.from(
 | 
				
			||||||
 | 
					      resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    for (final realm in out) {
 | 
				
			||||||
 | 
					      _cache[realm.alias] = realm;
 | 
				
			||||||
 | 
					      _cache[realm.id.toString()] = realm;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return out;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<SnRealm> getRealm(dynamic aliasOrId) async {
 | 
				
			||||||
 | 
					    if (_cache.containsKey(aliasOrId.toString())) {
 | 
				
			||||||
 | 
					      return _cache[aliasOrId.toString()]!;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    final resp = await _sn.client.get('/cgi/id/realms/$aliasOrId');
 | 
				
			||||||
 | 
					    final out = SnRealm.fromJson(resp.data);
 | 
				
			||||||
 | 
					    _cache[out.alias] = out;
 | 
				
			||||||
 | 
					    _cache[out.id.toString()] = out;
 | 
				
			||||||
 | 
					    return out;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,20 +1,27 @@
 | 
				
			|||||||
import 'dart:developer';
 | 
					import 'dart:convert';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:drift/drift.dart';
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/database/database.dart';
 | 
				
			||||||
 | 
					import 'package:surface/logger.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/database.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/types/attachment.dart';
 | 
					import 'package:surface/types/attachment.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SnStickerProvider {
 | 
					class SnStickerProvider {
 | 
				
			||||||
  late final SnNetworkProvider _sn;
 | 
					  late final SnNetworkProvider _sn;
 | 
				
			||||||
 | 
					  late final DatabaseProvider _dt;
 | 
				
			||||||
  final Map<String, SnSticker?> _cache = {};
 | 
					  final Map<String, SnSticker?> _cache = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  final Map<int, List<SnSticker>> stickersByPack = {};
 | 
					  final Map<int, List<SnSticker>> stickersByPack = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  List<SnSticker> get stickers => _cache.values.where((ele) => ele != null).cast<SnSticker>().toList();
 | 
					  List<SnSticker> get stickers =>
 | 
				
			||||||
 | 
					      _cache.values.where((ele) => ele != null).cast<SnSticker>().toList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  SnStickerProvider(BuildContext context) {
 | 
					  SnStickerProvider(BuildContext context) {
 | 
				
			||||||
    _sn = context.read<SnNetworkProvider>();
 | 
					    _sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					    _dt = context.read<DatabaseProvider>();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  bool hasNotSticker(String alias) {
 | 
					  bool hasNotSticker(String alias) {
 | 
				
			||||||
@@ -23,52 +30,103 @@ class SnStickerProvider {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  void _cacheSticker(SnSticker sticker) {
 | 
					  void _cacheSticker(SnSticker sticker) {
 | 
				
			||||||
    _cache['${sticker.pack.prefix}:${sticker.alias}'] = 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] == null) {
 | 
				
			||||||
    if (!stickersByPack[sticker.pack.id]!.contains(sticker)) stickersByPack[sticker.pack.id]!.add(sticker);
 | 
					      stickersByPack[sticker.pack.id] = List.empty(growable: true);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (!stickersByPack[sticker.pack.id]!.contains(sticker)) {
 | 
				
			||||||
 | 
					      stickersByPack[sticker.pack.id]!.add(sticker);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void putSticker(Iterable<SnSticker> stickers) {
 | 
				
			||||||
 | 
					    for (final ele in stickers) {
 | 
				
			||||||
 | 
					      _cacheSticker(ele);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    _saveStickerToLocal(stickers);
 | 
				
			||||||
 | 
					    _saveStickerPackToLocal(stickers.map((ele) => ele.pack).toSet());
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<SnSticker?> lookupSticker(String alias) async {
 | 
					  Future<SnSticker?> lookupSticker(String alias) async {
 | 
				
			||||||
 | 
					    // In-memory cache
 | 
				
			||||||
    if (_cache.containsKey(alias)) {
 | 
					    if (_cache.containsKey(alias)) {
 | 
				
			||||||
      return _cache[alias];
 | 
					      return _cache[alias];
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					    // On-disk cache
 | 
				
			||||||
 | 
					    final localStickers = await (_dt.db.snLocalSticker.select()
 | 
				
			||||||
 | 
					          ..where((e) => e.fullAlias.equals(alias)))
 | 
				
			||||||
 | 
					        .getSingleOrNull();
 | 
				
			||||||
 | 
					    if (localStickers != null) {
 | 
				
			||||||
 | 
					      _cache[alias] = localStickers.content;
 | 
				
			||||||
 | 
					      return localStickers.content;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    // Remote server
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      final resp = await _sn.client.get('/cgi/uc/stickers/lookup/$alias');
 | 
					      final resp = await _sn.client.get('/cgi/uc/stickers/lookup/$alias');
 | 
				
			||||||
      final sticker = SnSticker.fromJson(resp.data);
 | 
					      final sticker = SnSticker.fromJson(resp.data);
 | 
				
			||||||
      _cacheSticker(sticker);
 | 
					      putSticker([sticker]);
 | 
				
			||||||
 | 
					 | 
				
			||||||
      return sticker;
 | 
					      return sticker;
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      _cache[alias] = null;
 | 
					      _cache[alias] = null;
 | 
				
			||||||
      log('[Sticker] Failed to lookup sticker $alias: $err');
 | 
					      logging.warning('[Sticker] Failed to lookup sticker $alias', err);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return null;
 | 
					    return null;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> listStickerEagerly() async {
 | 
					  Future<void> listSticker() async {
 | 
				
			||||||
    var count = await listSticker();
 | 
					    final localPacks = await _dt.db.snLocalStickerPack.select().get();
 | 
				
			||||||
    for (var page = 1; count > 0; count -= 10) {
 | 
					    final localStickers = await _dt.db.snLocalSticker.select().get();
 | 
				
			||||||
      await listSticker(page: page);
 | 
					    final local = localStickers.map((ele) {
 | 
				
			||||||
      page++;
 | 
					      return ele.content.copyWith(
 | 
				
			||||||
 | 
					        pack: localPacks
 | 
				
			||||||
 | 
					            .firstWhere((pk) => pk.content.id == ele.content.packId)
 | 
				
			||||||
 | 
					            .content,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    for (final sticker in local) {
 | 
				
			||||||
 | 
					      _cacheSticker(sticker);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  Future<int> listSticker({int page = 0}) async {
 | 
					 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      final resp = await _sn.client.get('/cgi/uc/stickers', queryParameters: {
 | 
					      final resp = await _sn.client.get('/cgi/uc/stickers');
 | 
				
			||||||
        'take': 10,
 | 
					 | 
				
			||||||
        'offset': page * 10,
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
      final data = resp.data;
 | 
					      final data = resp.data;
 | 
				
			||||||
      final stickers = List.from(data['data']).map((ele) => SnSticker.fromJson(ele));
 | 
					      final stickers = List.from(data).map((ele) => SnSticker.fromJson(ele));
 | 
				
			||||||
      for (final sticker in stickers) {
 | 
					      for (final sticker in stickers) {
 | 
				
			||||||
        _cacheSticker(sticker);
 | 
					        _cacheSticker(sticker);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      return data['count'] as int;
 | 
					 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      log('[Sticker] Failed to list stickers: $err');
 | 
					      logging.error('[Sticker] Failed to list stickers...', err);
 | 
				
			||||||
      rethrow;
 | 
					      rethrow;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _saveStickerToLocal(Iterable<SnSticker> stickers) async {
 | 
				
			||||||
 | 
					    await _dt.db.snLocalSticker.insertAll(
 | 
				
			||||||
 | 
					      stickers.map(
 | 
				
			||||||
 | 
					        (ele) => SnLocalStickerCompanion.insert(
 | 
				
			||||||
 | 
					          id: Value(ele.id),
 | 
				
			||||||
 | 
					          alias: ele.alias,
 | 
				
			||||||
 | 
					          fullAlias: '${ele.pack.prefix}${ele.alias}',
 | 
				
			||||||
 | 
					          content: ele,
 | 
				
			||||||
 | 
					          createdAt: Value(ele.createdAt),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      onConflict: DoNothing(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _saveStickerPackToLocal(Iterable<SnStickerPack> packs) async {
 | 
				
			||||||
 | 
					    final queries = packs
 | 
				
			||||||
 | 
					        .map(
 | 
				
			||||||
 | 
					          (ele) => _dt.db.snLocalStickerPack.insertOne(
 | 
				
			||||||
 | 
					              SnLocalStickerPackCompanion.insert(
 | 
				
			||||||
 | 
					                id: Value(ele.id),
 | 
				
			||||||
 | 
					                content: ele,
 | 
				
			||||||
 | 
					                createdAt: Value(ele.createdAt),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              onConflict: DoUpdate((_) => SnLocalStickerPackCompanion.custom(
 | 
				
			||||||
 | 
					                  content: Constant(jsonEncode(ele.toJson()))))),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .toList();
 | 
				
			||||||
 | 
					    await Future.wait(queries);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,8 +13,16 @@ class ThemeProvider extends ChangeNotifier {
 | 
				
			|||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void reloadTheme({Color? seedColorOverride, bool? useMaterial3}) {
 | 
					  void reloadTheme({
 | 
				
			||||||
    createAppThemeSet(seedColorOverride: seedColorOverride, useMaterial3: useMaterial3).then((value) {
 | 
					    Color? seedColorOverride,
 | 
				
			||||||
 | 
					    bool? useMaterial3,
 | 
				
			||||||
 | 
					    String? customFonts,
 | 
				
			||||||
 | 
					  }) {
 | 
				
			||||||
 | 
					    createAppThemeSet(
 | 
				
			||||||
 | 
					      seedColorOverride: seedColorOverride,
 | 
				
			||||||
 | 
					      useMaterial3: useMaterial3,
 | 
				
			||||||
 | 
					      customFonts: customFonts,
 | 
				
			||||||
 | 
					    ).then((value) {
 | 
				
			||||||
      theme = value;
 | 
					      theme = value;
 | 
				
			||||||
      notifyListeners();
 | 
					      notifyListeners();
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,33 +1,106 @@
 | 
				
			|||||||
 | 
					import 'dart:convert';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:drift/drift.dart';
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/database/database.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/database.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/types/account.dart';
 | 
					import 'package:surface/types/account.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class UserDirectoryProvider {
 | 
					class UserDirectoryProvider {
 | 
				
			||||||
  late final SnNetworkProvider _sn;
 | 
					  late final SnNetworkProvider _sn;
 | 
				
			||||||
 | 
					  late final DatabaseProvider _dt;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  UserDirectoryProvider(BuildContext context) {
 | 
					  UserDirectoryProvider(BuildContext context) {
 | 
				
			||||||
    _sn = context.read<SnNetworkProvider>();
 | 
					    _sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					    _dt = context.read<DatabaseProvider>();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  final Map<String, int> _idCache = {};
 | 
					  final Map<String, int> _idCache = {};
 | 
				
			||||||
  final Map<int, SnAccount> _cache = {};
 | 
					  final Map<int, SnAccount> _cache = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<int> loadAccountCache({int max = 100}) async {
 | 
				
			||||||
 | 
					    final out = await (_dt.db.snLocalAccount.select()..limit(max)).get();
 | 
				
			||||||
 | 
					    for (final ele in out) {
 | 
				
			||||||
 | 
					      _cache[ele.id] = ele.content;
 | 
				
			||||||
 | 
					      _idCache[ele.name] = ele.id;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return out.length;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async {
 | 
					  Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async {
 | 
				
			||||||
    final out = await Future.wait(
 | 
					    // In-memory cache
 | 
				
			||||||
      id.map((e) => getAccount(e)),
 | 
					    final out = List<SnAccount?>.generate(id.length, (e) => null);
 | 
				
			||||||
    );
 | 
					    final plannedQuery = <int>{};
 | 
				
			||||||
 | 
					    for (var idx = 0; idx < out.length; idx++) {
 | 
				
			||||||
 | 
					      var item = id.elementAt(idx);
 | 
				
			||||||
 | 
					      if (item is String && _idCache.containsKey(item)) {
 | 
				
			||||||
 | 
					        item = _idCache[item];
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      if (_cache.containsKey(item)) {
 | 
				
			||||||
 | 
					        out[idx] = _cache[item];
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        plannedQuery.add(item);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    // On-disk cache
 | 
				
			||||||
 | 
					    if (plannedQuery.isEmpty) return out;
 | 
				
			||||||
 | 
					    final dbResp = await (_dt.db.snLocalAccount.select()
 | 
				
			||||||
 | 
					          ..where((e) => e.id.isIn(plannedQuery))
 | 
				
			||||||
 | 
					          ..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now()))
 | 
				
			||||||
 | 
					          ..limit(plannedQuery.length))
 | 
				
			||||||
 | 
					        .get();
 | 
				
			||||||
 | 
					    for (var idx = 0; idx < out.length; idx++) {
 | 
				
			||||||
 | 
					      if (out[idx] != null) continue;
 | 
				
			||||||
 | 
					      if (dbResp.length <= idx) {
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      out[idx] = dbResp[idx].content;
 | 
				
			||||||
 | 
					      _cache[dbResp[idx].id] = dbResp[idx].content;
 | 
				
			||||||
 | 
					      _idCache[dbResp[idx].name] = dbResp[idx].id;
 | 
				
			||||||
 | 
					      plannedQuery.remove(dbResp[idx].id);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    // Remote server
 | 
				
			||||||
 | 
					    if (plannedQuery.isEmpty) return out;
 | 
				
			||||||
 | 
					    final resp = await _sn.client
 | 
				
			||||||
 | 
					        .get('/cgi/id/users', queryParameters: {'id': plannedQuery.join(',')});
 | 
				
			||||||
 | 
					    final respDecoded =
 | 
				
			||||||
 | 
					        resp.data.map((e) => SnAccount.fromJson(e)).cast<SnAccount>().toList();
 | 
				
			||||||
 | 
					    var sideIdx = 0;
 | 
				
			||||||
 | 
					    for (var idx = 0; idx < out.length; idx++) {
 | 
				
			||||||
 | 
					      if (out[idx] != null) continue;
 | 
				
			||||||
 | 
					      if (respDecoded.length <= sideIdx) {
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      out[idx] = respDecoded[sideIdx];
 | 
				
			||||||
 | 
					      _cache[respDecoded[sideIdx].id] = out[idx]!;
 | 
				
			||||||
 | 
					      _idCache[respDecoded[sideIdx].name] = respDecoded[sideIdx].id;
 | 
				
			||||||
 | 
					      sideIdx++;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (respDecoded.isNotEmpty) _saveToLocal(respDecoded);
 | 
				
			||||||
    return out;
 | 
					    return out;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<SnAccount?> getAccount(dynamic id) async {
 | 
					  Future<SnAccount?> getAccount(dynamic id) async {
 | 
				
			||||||
 | 
					    // In-memory cache
 | 
				
			||||||
    if (id is String && _idCache.containsKey(id)) {
 | 
					    if (id is String && _idCache.containsKey(id)) {
 | 
				
			||||||
      id = _idCache[id];
 | 
					      id = _idCache[id];
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    if (_cache.containsKey(id)) {
 | 
					    if (_cache.containsKey(id)) {
 | 
				
			||||||
      return _cache[id];
 | 
					      return _cache[id];
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					    // On-disk cache
 | 
				
			||||||
 | 
					    final dbResp = await (_dt.db.snLocalAccount.select()
 | 
				
			||||||
 | 
					          ..where((e) => e.id.equals(id))
 | 
				
			||||||
 | 
					          ..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())))
 | 
				
			||||||
 | 
					        .getSingleOrNull();
 | 
				
			||||||
 | 
					    if (dbResp != null) {
 | 
				
			||||||
 | 
					      _cache[dbResp.id] = dbResp.content;
 | 
				
			||||||
 | 
					      _idCache[dbResp.name] = dbResp.id;
 | 
				
			||||||
 | 
					      return dbResp.content;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    // Remote server
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      final resp = await _sn.client.get('/cgi/id/users/$id');
 | 
					      final resp = await _sn.client.get('/cgi/id/users/$id');
 | 
				
			||||||
      final account = SnAccount.fromJson(
 | 
					      final account = SnAccount.fromJson(
 | 
				
			||||||
@@ -35,16 +108,42 @@ class UserDirectoryProvider {
 | 
				
			|||||||
      );
 | 
					      );
 | 
				
			||||||
      _cache[account.id] = account;
 | 
					      _cache[account.id] = account;
 | 
				
			||||||
      if (id is String) _idCache[id] = account.id;
 | 
					      if (id is String) _idCache[id] = account.id;
 | 
				
			||||||
 | 
					      _saveToLocal([account]);
 | 
				
			||||||
      return account;
 | 
					      return account;
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      return null;
 | 
					      return null;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  SnAccount? getAccountFromCache(dynamic id) {
 | 
					  SnAccount? getFromCache(dynamic id) {
 | 
				
			||||||
    if (id is String && _idCache.containsKey(id)) {
 | 
					    if (id is String && _idCache.containsKey(id)) {
 | 
				
			||||||
      id = _idCache[id];
 | 
					      id = _idCache[id];
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    return _cache[id];
 | 
					    return _cache[id];
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _saveToLocal(Iterable<SnAccount> out) async {
 | 
				
			||||||
 | 
					    // For better on conflict resolution
 | 
				
			||||||
 | 
					    // And consider the method usually called with usually small amount of data
 | 
				
			||||||
 | 
					    // Use for to insert each record instead of bulk insert
 | 
				
			||||||
 | 
					    List<Future<int>> queries = out.map((ele) {
 | 
				
			||||||
 | 
					      return _dt.db.snLocalAccount.insertOne(
 | 
				
			||||||
 | 
					        SnLocalAccountCompanion.insert(
 | 
				
			||||||
 | 
					          id: Value(ele.id),
 | 
				
			||||||
 | 
					          name: ele.name,
 | 
				
			||||||
 | 
					          content: ele,
 | 
				
			||||||
 | 
					          cacheExpiredAt: DateTime.now().add(const Duration(hours: 1)),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        onConflict: DoUpdate(
 | 
				
			||||||
 | 
					          (_) => SnLocalAccountCompanion.custom(
 | 
				
			||||||
 | 
					            name: Constant(ele.name),
 | 
				
			||||||
 | 
					            content: Constant(jsonEncode(ele.toJson())),
 | 
				
			||||||
 | 
					            cacheExpiredAt:
 | 
				
			||||||
 | 
					                Constant(DateTime.now().add(const Duration(hours: 1))),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }).toList();
 | 
				
			||||||
 | 
					    await Future.wait(queries);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,8 +1,7 @@
 | 
				
			|||||||
import 'dart:developer';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
import 'package:shared_preferences/shared_preferences.dart';
 | 
					import 'package:shared_preferences/shared_preferences.dart';
 | 
				
			||||||
 | 
					import 'package:surface/logger.dart';
 | 
				
			||||||
import 'package:surface/providers/config.dart';
 | 
					import 'package:surface/providers/config.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/types/account.dart';
 | 
					import 'package:surface/types/account.dart';
 | 
				
			||||||
@@ -30,8 +29,8 @@ class UserProvider extends ChangeNotifier {
 | 
				
			|||||||
    notifyListeners();
 | 
					    notifyListeners();
 | 
				
			||||||
    refreshUser().then((value) async {
 | 
					    refreshUser().then((value) async {
 | 
				
			||||||
      if (value != null) {
 | 
					      if (value != null) {
 | 
				
			||||||
        log('Logged in as @${value.name}');
 | 
					        logging.info('[Auth] Logged in as @${value.name}');
 | 
				
			||||||
        log('Atk: ${await atk}');
 | 
					        logging.debug('[Auth] Access token: ${await atk}');
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,12 +1,15 @@
 | 
				
			|||||||
import 'dart:async';
 | 
					import 'dart:async';
 | 
				
			||||||
import 'dart:convert';
 | 
					import 'dart:convert';
 | 
				
			||||||
import 'dart:developer';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:flutter/foundation.dart';
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:flutter_udid/flutter_udid.dart';
 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/logger.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/providers/userinfo.dart';
 | 
					import 'package:surface/providers/userinfo.dart';
 | 
				
			||||||
import 'package:surface/types/websocket.dart';
 | 
					import 'package:surface/types/websocket.dart';
 | 
				
			||||||
 | 
					import 'package:web_socket_channel/io.dart';
 | 
				
			||||||
import 'package:web_socket_channel/web_socket_channel.dart';
 | 
					import 'package:web_socket_channel/web_socket_channel.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class WebSocketProvider extends ChangeNotifier {
 | 
					class WebSocketProvider extends ChangeNotifier {
 | 
				
			||||||
@@ -30,7 +33,7 @@ class WebSocketProvider extends ChangeNotifier {
 | 
				
			|||||||
    if (isConnected) return;
 | 
					    if (isConnected) return;
 | 
				
			||||||
    if (!_ua.isAuthorized) return;
 | 
					    if (!_ua.isAuthorized) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    log('[WebSocket] Connecting to the server...');
 | 
					    logging.debug('[WebSocket] Connecting to the server...');
 | 
				
			||||||
    await connect();
 | 
					    await connect();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -39,40 +42,52 @@ class WebSocketProvider extends ChangeNotifier {
 | 
				
			|||||||
  Future<void> connect({noRetry = false}) async {
 | 
					  Future<void> connect({noRetry = false}) async {
 | 
				
			||||||
    if (_connectCompleter != null) {
 | 
					    if (_connectCompleter != null) {
 | 
				
			||||||
      await _connectCompleter!.future;
 | 
					      await _connectCompleter!.future;
 | 
				
			||||||
      _connectCompleter = null;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    _connectCompleter = Completer<void>();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (!_ua.isAuthorized) return;
 | 
					    if (!_ua.isAuthorized) return;
 | 
				
			||||||
    if (isConnected || conn != null) {
 | 
					    if (isConnected || conn != null) {
 | 
				
			||||||
      disconnect();
 | 
					      disconnect();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final atk = await _sn.getFreshAtk();
 | 
					 | 
				
			||||||
    final uri = Uri.parse(
 | 
					 | 
				
			||||||
      '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk',
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    isBusy = true;
 | 
					 | 
				
			||||||
    notifyListeners();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      conn = WebSocketChannel.connect(uri);
 | 
					      _connectCompleter = Completer<void>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final atk = await _sn.getFreshAtk();
 | 
				
			||||||
 | 
					      final uri = Uri.parse(
 | 
				
			||||||
 | 
					        kIsWeb
 | 
				
			||||||
 | 
					            ? '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk'
 | 
				
			||||||
 | 
					            : '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?clientId=${await FlutterUdid.consistentUdid}tk=$atk',
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      isBusy = true;
 | 
				
			||||||
 | 
					      notifyListeners();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      conn = kIsWeb
 | 
				
			||||||
 | 
					          ? WebSocketChannel.connect(uri)
 | 
				
			||||||
 | 
					          : IOWebSocketChannel.connect(
 | 
				
			||||||
 | 
					              uri,
 | 
				
			||||||
 | 
					              headers: {'Authorization': 'Bearer $atk'},
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
      await conn!.ready;
 | 
					      await conn!.ready;
 | 
				
			||||||
      _wsStream = conn!.stream.asBroadcastStream();
 | 
					      _wsStream = conn!.stream.asBroadcastStream();
 | 
				
			||||||
      listen();
 | 
					      listen();
 | 
				
			||||||
      log('[WebSocket] Connected to server!');
 | 
					      logging.info('[WebSocket] Connected to server!');
 | 
				
			||||||
      isConnected = true;
 | 
					      isConnected = true;
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      if (err is WebSocketChannelException) {
 | 
					      if (err is WebSocketChannelException) {
 | 
				
			||||||
        log('Failed to connect to websocket: ${(err.inner as dynamic).message}');
 | 
					        logging.error(
 | 
				
			||||||
 | 
					          '[WebSocket] Failed to connect to websocket...',
 | 
				
			||||||
 | 
					          err.inner,
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
        log('Failed to connect to websocket: $err');
 | 
					        logging.error('[WebSocket] Failed to connect to websocket...', err);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (!noRetry) {
 | 
					      if (!noRetry) {
 | 
				
			||||||
        log('Retry connecting to websocket in 3 seconds...');
 | 
					        logging.warning(
 | 
				
			||||||
 | 
					          '[WebSocket] Retry connecting to websocket in 3 seconds...',
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
        return Future.delayed(
 | 
					        return Future.delayed(
 | 
				
			||||||
          const Duration(seconds: 3),
 | 
					          const Duration(seconds: 3),
 | 
				
			||||||
          () => connect(noRetry: true),
 | 
					          () => connect(noRetry: true),
 | 
				
			||||||
@@ -82,6 +97,7 @@ class WebSocketProvider extends ChangeNotifier {
 | 
				
			|||||||
      isBusy = false;
 | 
					      isBusy = false;
 | 
				
			||||||
      notifyListeners();
 | 
					      notifyListeners();
 | 
				
			||||||
      _connectCompleter!.complete();
 | 
					      _connectCompleter!.complete();
 | 
				
			||||||
 | 
					      _connectCompleter = null;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -99,7 +115,9 @@ class WebSocketProvider extends ChangeNotifier {
 | 
				
			|||||||
    _wsStream!.listen(
 | 
					    _wsStream!.listen(
 | 
				
			||||||
      (event) {
 | 
					      (event) {
 | 
				
			||||||
        final packet = WebSocketPackage.fromJson(jsonDecode(event));
 | 
					        final packet = WebSocketPackage.fromJson(jsonDecode(event));
 | 
				
			||||||
        log('Websocket incoming message: ${packet.method} ${packet.message}');
 | 
					        logging.debug(
 | 
				
			||||||
 | 
					          '[Websocket] Incoming message: ${packet.method} ${packet.message}',
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
        pk.sink.add(packet);
 | 
					        pk.sink.add(packet);
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      onDone: () {
 | 
					      onDone: () {
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										161
									
								
								lib/router.dart
									
									
									
									
									
								
							
							
						
						
									
										161
									
								
								lib/router.dart
									
									
									
									
									
								
							@@ -4,7 +4,9 @@ import 'package:go_router/go_router.dart';
 | 
				
			|||||||
import 'package:surface/screens/abuse_report.dart';
 | 
					import 'package:surface/screens/abuse_report.dart';
 | 
				
			||||||
import 'package:surface/screens/account.dart';
 | 
					import 'package:surface/screens/account.dart';
 | 
				
			||||||
import 'package:surface/screens/account/account_settings.dart';
 | 
					import 'package:surface/screens/account/account_settings.dart';
 | 
				
			||||||
 | 
					import 'package:surface/screens/account/badges.dart';
 | 
				
			||||||
import 'package:surface/screens/account/factor_settings.dart';
 | 
					import 'package:surface/screens/account/factor_settings.dart';
 | 
				
			||||||
 | 
					import 'package:surface/screens/account/keypairs.dart';
 | 
				
			||||||
import 'package:surface/screens/account/profile_page.dart';
 | 
					import 'package:surface/screens/account/profile_page.dart';
 | 
				
			||||||
import 'package:surface/screens/account/profile_edit.dart';
 | 
					import 'package:surface/screens/account/profile_edit.dart';
 | 
				
			||||||
import 'package:surface/screens/account/publishers/publisher_edit.dart';
 | 
					import 'package:surface/screens/account/publishers/publisher_edit.dart';
 | 
				
			||||||
@@ -21,6 +23,7 @@ import 'package:surface/screens/chat/room.dart';
 | 
				
			|||||||
import 'package:surface/screens/explore.dart';
 | 
					import 'package:surface/screens/explore.dart';
 | 
				
			||||||
import 'package:surface/screens/friend.dart';
 | 
					import 'package:surface/screens/friend.dart';
 | 
				
			||||||
import 'package:surface/screens/home.dart';
 | 
					import 'package:surface/screens/home.dart';
 | 
				
			||||||
 | 
					import 'package:surface/screens/logging.dart';
 | 
				
			||||||
import 'package:surface/screens/news/news_detail.dart';
 | 
					import 'package:surface/screens/news/news_detail.dart';
 | 
				
			||||||
import 'package:surface/screens/news/news_list.dart';
 | 
					import 'package:surface/screens/news/news_list.dart';
 | 
				
			||||||
import 'package:surface/screens/notification.dart';
 | 
					import 'package:surface/screens/notification.dart';
 | 
				
			||||||
@@ -34,13 +37,15 @@ import 'package:surface/screens/realm/realm_detail.dart';
 | 
				
			|||||||
import 'package:surface/screens/realm/realm_discovery.dart';
 | 
					import 'package:surface/screens/realm/realm_discovery.dart';
 | 
				
			||||||
import 'package:surface/screens/settings.dart';
 | 
					import 'package:surface/screens/settings.dart';
 | 
				
			||||||
import 'package:surface/screens/sharing.dart';
 | 
					import 'package:surface/screens/sharing.dart';
 | 
				
			||||||
 | 
					import 'package:surface/screens/stickers.dart';
 | 
				
			||||||
 | 
					import 'package:surface/screens/stickers/pack_detail.dart';
 | 
				
			||||||
import 'package:surface/screens/wallet.dart';
 | 
					import 'package:surface/screens/wallet.dart';
 | 
				
			||||||
import 'package:surface/types/post.dart';
 | 
					import 'package:surface/types/post.dart';
 | 
				
			||||||
import 'package:surface/widgets/about.dart';
 | 
					import 'package:surface/widgets/about.dart';
 | 
				
			||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
					import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Widget _fadeThroughTransition(
 | 
					Widget _fadeThroughTransition(BuildContext context, Animation<double> animation,
 | 
				
			||||||
    BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
 | 
					    Animation<double> secondaryAnimation, Widget child) {
 | 
				
			||||||
  return FadeThroughTransition(
 | 
					  return FadeThroughTransition(
 | 
				
			||||||
    animation: animation,
 | 
					    animation: animation,
 | 
				
			||||||
    secondaryAnimation: secondaryAnimation,
 | 
					    secondaryAnimation: secondaryAnimation,
 | 
				
			||||||
@@ -82,13 +87,15 @@ final _appRoutes = [
 | 
				
			|||||||
        name: 'postSearch',
 | 
					        name: 'postSearch',
 | 
				
			||||||
        builder: (context, state) => PostSearchScreen(
 | 
					        builder: (context, state) => PostSearchScreen(
 | 
				
			||||||
          initialTags: state.uri.queryParameters['tags']?.split(','),
 | 
					          initialTags: state.uri.queryParameters['tags']?.split(','),
 | 
				
			||||||
          initialCategories: state.uri.queryParameters['categories']?.split(','),
 | 
					          initialCategories:
 | 
				
			||||||
 | 
					              state.uri.queryParameters['categories']?.split(','),
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
      GoRoute(
 | 
					      GoRoute(
 | 
				
			||||||
        path: '/publishers/:name',
 | 
					        path: '/publishers/:name',
 | 
				
			||||||
        name: 'postPublisher',
 | 
					        name: 'postPublisher',
 | 
				
			||||||
        builder: (context, state) => PostPublisherScreen(name: state.pathParameters['name']!),
 | 
					        builder: (context, state) =>
 | 
				
			||||||
 | 
					            PostPublisherScreen(name: state.pathParameters['name']!),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
      GoRoute(
 | 
					      GoRoute(
 | 
				
			||||||
        path: '/:slug',
 | 
					        path: '/:slug',
 | 
				
			||||||
@@ -100,52 +107,67 @@ final _appRoutes = [
 | 
				
			|||||||
      ),
 | 
					      ),
 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
  GoRoute(path: '/account', name: 'account', builder: (context, state) => const AccountScreen(), routes: [
 | 
					  GoRoute(
 | 
				
			||||||
    GoRoute(
 | 
					    path: '/account',
 | 
				
			||||||
      path: '/wallet',
 | 
					    name: 'account',
 | 
				
			||||||
      name: 'accountWallet',
 | 
					    builder: (context, state) => const AccountScreen(),
 | 
				
			||||||
      builder: (context, state) => const WalletScreen(),
 | 
					    routes: [
 | 
				
			||||||
    ),
 | 
					      GoRoute(
 | 
				
			||||||
    GoRoute(
 | 
					        path: '/badges',
 | 
				
			||||||
      path: '/settings',
 | 
					        name: 'accountBadges',
 | 
				
			||||||
      name: 'accountSettings',
 | 
					        builder: (context, state) => const AccountBadgesScreen(),
 | 
				
			||||||
      builder: (context, state) => AccountSettingsScreen(),
 | 
					 | 
				
			||||||
    ),
 | 
					 | 
				
			||||||
    GoRoute(
 | 
					 | 
				
			||||||
      path: '/settings/factors',
 | 
					 | 
				
			||||||
      name: 'factorSettings',
 | 
					 | 
				
			||||||
      builder: (context, state) => FactorSettingsScreen(),
 | 
					 | 
				
			||||||
    ),
 | 
					 | 
				
			||||||
    GoRoute(
 | 
					 | 
				
			||||||
      path: '/profile/edit',
 | 
					 | 
				
			||||||
      name: 'accountProfileEdit',
 | 
					 | 
				
			||||||
      builder: (context, state) => ProfileEditScreen(),
 | 
					 | 
				
			||||||
    ),
 | 
					 | 
				
			||||||
    GoRoute(
 | 
					 | 
				
			||||||
      path: '/publishers',
 | 
					 | 
				
			||||||
      name: 'accountPublishers',
 | 
					 | 
				
			||||||
      builder: (context, state) => PublisherScreen(),
 | 
					 | 
				
			||||||
    ),
 | 
					 | 
				
			||||||
    GoRoute(
 | 
					 | 
				
			||||||
      path: '/publishers/new',
 | 
					 | 
				
			||||||
      name: 'accountPublisherNew',
 | 
					 | 
				
			||||||
      builder: (context, state) => AccountPublisherNewScreen(),
 | 
					 | 
				
			||||||
    ),
 | 
					 | 
				
			||||||
    GoRoute(
 | 
					 | 
				
			||||||
      path: '/publishers/edit/:name',
 | 
					 | 
				
			||||||
      name: 'accountPublisherEdit',
 | 
					 | 
				
			||||||
      builder: (context, state) => AccountPublisherEditScreen(
 | 
					 | 
				
			||||||
        name: state.pathParameters['name']!,
 | 
					 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
    ),
 | 
					      GoRoute(
 | 
				
			||||||
    GoRoute(
 | 
					        path: '/wallet',
 | 
				
			||||||
      path: '/:name',
 | 
					        name: 'accountWallet',
 | 
				
			||||||
      name: 'accountProfilePage',
 | 
					        builder: (context, state) => const WalletScreen(),
 | 
				
			||||||
      pageBuilder: (context, state) => NoTransitionPage(
 | 
					 | 
				
			||||||
        child: UserScreen(name: state.pathParameters['name']!),
 | 
					 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
    ),
 | 
					      GoRoute(
 | 
				
			||||||
  ]),
 | 
					        path: '/keypairs',
 | 
				
			||||||
 | 
					        name: 'accountKeyPairs',
 | 
				
			||||||
 | 
					        builder: (context, state) => const KeyPairScreen(),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/settings',
 | 
				
			||||||
 | 
					        name: 'accountSettings',
 | 
				
			||||||
 | 
					        builder: (context, state) => AccountSettingsScreen(),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/settings/factors',
 | 
				
			||||||
 | 
					        name: 'factorSettings',
 | 
				
			||||||
 | 
					        builder: (context, state) => FactorSettingsScreen(),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/profile/edit',
 | 
				
			||||||
 | 
					        name: 'accountProfileEdit',
 | 
				
			||||||
 | 
					        builder: (context, state) => ProfileEditScreen(),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/publishers',
 | 
				
			||||||
 | 
					        name: 'accountPublishers',
 | 
				
			||||||
 | 
					        builder: (context, state) => PublisherScreen(),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/publishers/new',
 | 
				
			||||||
 | 
					        name: 'accountPublisherNew',
 | 
				
			||||||
 | 
					        builder: (context, state) => AccountPublisherNewScreen(),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/publishers/edit/:name',
 | 
				
			||||||
 | 
					        name: 'accountPublisherEdit',
 | 
				
			||||||
 | 
					        builder: (context, state) => AccountPublisherEditScreen(
 | 
				
			||||||
 | 
					          name: state.pathParameters['name']!,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/:name',
 | 
				
			||||||
 | 
					        name: 'accountProfilePage',
 | 
				
			||||||
 | 
					        pageBuilder: (context, state) => NoTransitionPage(
 | 
				
			||||||
 | 
					          child: UserScreen(name: state.pathParameters['name']!),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
  GoRoute(
 | 
					  GoRoute(
 | 
				
			||||||
    path: '/chat',
 | 
					    path: '/chat',
 | 
				
			||||||
    name: 'chat',
 | 
					    name: 'chat',
 | 
				
			||||||
@@ -208,19 +230,44 @@ final _appRoutes = [
 | 
				
			|||||||
      GoRoute(
 | 
					      GoRoute(
 | 
				
			||||||
        path: '/:alias',
 | 
					        path: '/:alias',
 | 
				
			||||||
        name: 'realmDetail',
 | 
					        name: 'realmDetail',
 | 
				
			||||||
        builder: (context, state) => RealmDetailScreen(alias: state.pathParameters['alias']!),
 | 
					        builder: (context, state) =>
 | 
				
			||||||
 | 
					            RealmDetailScreen(alias: state.pathParameters['alias']!),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
  GoRoute(path: '/news', name: 'news', builder: (context, state) => const NewsScreen(), routes: [
 | 
					  GoRoute(
 | 
				
			||||||
    GoRoute(
 | 
					    path: '/news',
 | 
				
			||||||
      path: '/:hash',
 | 
					    name: 'news',
 | 
				
			||||||
      name: 'newsDetail',
 | 
					    builder: (context, state) => const NewsScreen(),
 | 
				
			||||||
      builder: (context, state) => NewsDetailScreen(
 | 
					    routes: [
 | 
				
			||||||
        hash: state.pathParameters['hash']!,
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/:hash',
 | 
				
			||||||
 | 
					        name: 'newsDetail',
 | 
				
			||||||
 | 
					        builder: (context, state) => NewsDetailScreen(
 | 
				
			||||||
 | 
					          hash: state.pathParameters['hash']!,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
    ),
 | 
					    ],
 | 
				
			||||||
  ]),
 | 
					  ),
 | 
				
			||||||
 | 
					  GoRoute(
 | 
				
			||||||
 | 
					    path: '/stickers',
 | 
				
			||||||
 | 
					    name: 'stickers',
 | 
				
			||||||
 | 
					    builder: (context, state) => const StickerScreen(),
 | 
				
			||||||
 | 
					    routes: [
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/packs/:id',
 | 
				
			||||||
 | 
					        name: 'stickerPack',
 | 
				
			||||||
 | 
					        builder: (context, state) => StickerPackScreen(
 | 
				
			||||||
 | 
					          id: int.tryParse(state.pathParameters['id']!)!,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
 | 
					  GoRoute(
 | 
				
			||||||
 | 
					    path: '/debug/logging',
 | 
				
			||||||
 | 
					    name: 'debugLogging',
 | 
				
			||||||
 | 
					    builder: (context, state) => const DebugLoggingScreen(),
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
  GoRoute(
 | 
					  GoRoute(
 | 
				
			||||||
    path: '/album',
 | 
					    path: '/album',
 | 
				
			||||||
    name: 'album',
 | 
					    name: 'album',
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -74,7 +74,10 @@ class _AbuseReportScreenState extends State<AbuseReportScreen> {
 | 
				
			|||||||
          ),
 | 
					          ),
 | 
				
			||||||
          const Divider(height: 1),
 | 
					          const Divider(height: 1),
 | 
				
			||||||
          if (_isBusy)
 | 
					          if (_isBusy)
 | 
				
			||||||
            const CircularProgressIndicator().padding(all: 24).center()
 | 
					            Padding(
 | 
				
			||||||
 | 
					              padding: const EdgeInsets.all(24),
 | 
				
			||||||
 | 
					              child: const CircularProgressIndicator(),
 | 
				
			||||||
 | 
					            ).center()
 | 
				
			||||||
          else
 | 
					          else
 | 
				
			||||||
            Expanded(
 | 
					            Expanded(
 | 
				
			||||||
              child: ListView.builder(
 | 
					              child: ListView.builder(
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,10 +4,10 @@ import 'package:easy_localization/easy_localization.dart';
 | 
				
			|||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:gap/gap.dart';
 | 
					import 'package:gap/gap.dart';
 | 
				
			||||||
import 'package:go_router/go_router.dart';
 | 
					import 'package:go_router/go_router.dart';
 | 
				
			||||||
import 'package:hive_flutter/hive_flutter.dart';
 | 
					 | 
				
			||||||
import 'package:material_symbols_icons/symbols.dart';
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
import 'package:styled_widget/styled_widget.dart';
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/database.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/providers/userinfo.dart';
 | 
					import 'package:surface/providers/userinfo.dart';
 | 
				
			||||||
import 'package:surface/providers/websocket.dart';
 | 
					import 'package:surface/providers/websocket.dart';
 | 
				
			||||||
@@ -45,7 +45,8 @@ class AccountScreen extends StatelessWidget {
 | 
				
			|||||||
            ? Stack(
 | 
					            ? Stack(
 | 
				
			||||||
                fit: StackFit.expand,
 | 
					                fit: StackFit.expand,
 | 
				
			||||||
                children: [
 | 
					                children: [
 | 
				
			||||||
                  AutoResizeUniversalImage(sn.getAttachmentUrl(ua.user!.banner), fit: BoxFit.cover),
 | 
					                  AutoResizeUniversalImage(sn.getAttachmentUrl(ua.user!.banner),
 | 
				
			||||||
 | 
					                      fit: BoxFit.cover),
 | 
				
			||||||
                  Positioned(
 | 
					                  Positioned(
 | 
				
			||||||
                    top: 0,
 | 
					                    top: 0,
 | 
				
			||||||
                    left: 0,
 | 
					                    left: 0,
 | 
				
			||||||
@@ -79,7 +80,9 @@ class AccountScreen extends StatelessWidget {
 | 
				
			|||||||
        ],
 | 
					        ],
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
      body: SingleChildScrollView(
 | 
					      body: SingleChildScrollView(
 | 
				
			||||||
        child: ua.isAuthorized ? _AuthorizedAccountScreen() : _UnauthorizedAccountScreen(),
 | 
					        child: ua.isAuthorized
 | 
				
			||||||
 | 
					            ? _AuthorizedAccountScreen()
 | 
				
			||||||
 | 
					            : _UnauthorizedAccountScreen(),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@@ -115,12 +118,21 @@ class _AuthorizedAccountScreen extends StatelessWidget {
 | 
				
			|||||||
                    crossAxisAlignment: CrossAxisAlignment.baseline,
 | 
					                    crossAxisAlignment: CrossAxisAlignment.baseline,
 | 
				
			||||||
                    textBaseline: TextBaseline.alphabetic,
 | 
					                    textBaseline: TextBaseline.alphabetic,
 | 
				
			||||||
                    children: [
 | 
					                    children: [
 | 
				
			||||||
                      Text(ua.user!.nick).textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
					                      Text(ua.user!.nick)
 | 
				
			||||||
 | 
					                          .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
				
			||||||
                      const Gap(4),
 | 
					                      const Gap(4),
 | 
				
			||||||
                      Text('@${ua.user!.name}').textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
					                      Text('@${ua.user!.name}')
 | 
				
			||||||
 | 
					                          .textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
				
			||||||
                    ],
 | 
					                    ],
 | 
				
			||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
                  Text(ua.user!.description).textStyle(Theme.of(context).textTheme.bodyMedium!),
 | 
					                  Text(
 | 
				
			||||||
 | 
					                    (ua.user!.profile?.description.isNotEmpty ?? false)
 | 
				
			||||||
 | 
					                        ? ua.user!.profile!.description
 | 
				
			||||||
 | 
					                        : 'userNoDescription'.tr(),
 | 
				
			||||||
 | 
					                    style: (ua.user!.profile?.description.isEmpty ?? true)
 | 
				
			||||||
 | 
					                        ? TextStyle(fontStyle: FontStyle.italic)
 | 
				
			||||||
 | 
					                        : null,
 | 
				
			||||||
 | 
					                  ).textStyle(Theme.of(context).textTheme.bodyMedium!),
 | 
				
			||||||
                ],
 | 
					                ],
 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
            );
 | 
					            );
 | 
				
			||||||
@@ -166,6 +178,26 @@ class _AuthorizedAccountScreen extends StatelessWidget {
 | 
				
			|||||||
            GoRouter.of(context).pushNamed('accountWallet');
 | 
					            GoRouter.of(context).pushNamed('accountWallet');
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
 | 
					        ListTile(
 | 
				
			||||||
 | 
					          title: Text('accountBadges').tr(),
 | 
				
			||||||
 | 
					          subtitle: Text('accountBadgesDescription').tr(),
 | 
				
			||||||
 | 
					          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					          leading: const Icon(Symbols.award_star),
 | 
				
			||||||
 | 
					          trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					          onTap: () {
 | 
				
			||||||
 | 
					            GoRouter.of(context).pushNamed('accountBadges');
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        ListTile(
 | 
				
			||||||
 | 
					          title: Text('accountKeyPairs').tr(),
 | 
				
			||||||
 | 
					          subtitle: Text('accountKeyPairsDescription').tr(),
 | 
				
			||||||
 | 
					          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					          leading: const Icon(Symbols.key),
 | 
				
			||||||
 | 
					          trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					          onTap: () {
 | 
				
			||||||
 | 
					            GoRouter.of(context).pushNamed('accountKeyPairs');
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
        ListTile(
 | 
					        ListTile(
 | 
				
			||||||
          title: Text('accountSettings').tr(),
 | 
					          title: Text('accountSettings').tr(),
 | 
				
			||||||
          subtitle: Text('accountSettingsSubtitle').tr(),
 | 
					          subtitle: Text('accountSettingsSubtitle').tr(),
 | 
				
			||||||
@@ -193,8 +225,7 @@ class _AuthorizedAccountScreen extends StatelessWidget {
 | 
				
			|||||||
            ua.logoutUser();
 | 
					            ua.logoutUser();
 | 
				
			||||||
            final ws = context.read<WebSocketProvider>();
 | 
					            final ws = context.read<WebSocketProvider>();
 | 
				
			||||||
            ws.disconnect();
 | 
					            ws.disconnect();
 | 
				
			||||||
            await Hive.deleteFromDisk();
 | 
					            context.read<DatabaseProvider>().removeDatabase();
 | 
				
			||||||
            await Hive.initFlutter();
 | 
					 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
      ],
 | 
					      ],
 | 
				
			||||||
@@ -220,7 +251,9 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
 | 
				
			|||||||
                  child: Icon(Symbols.waving_hand, size: 28),
 | 
					                  child: Icon(Symbols.waving_hand, size: 28),
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
                const Gap(8),
 | 
					                const Gap(8),
 | 
				
			||||||
                Text('accountIntroTitle').tr().textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
					                Text('accountIntroTitle')
 | 
				
			||||||
 | 
					                    .tr()
 | 
				
			||||||
 | 
					                    .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
				
			||||||
                Text('accountIntroSubtitle').tr(),
 | 
					                Text('accountIntroSubtitle').tr(),
 | 
				
			||||||
              ],
 | 
					              ],
 | 
				
			||||||
            ).padding(all: 20),
 | 
					            ).padding(all: 20),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -54,14 +54,20 @@ class AccountSettingsScreen extends StatelessWidget {
 | 
				
			|||||||
                child: DropdownButton2<Locale?>(
 | 
					                child: DropdownButton2<Locale?>(
 | 
				
			||||||
                  isExpanded: true,
 | 
					                  isExpanded: true,
 | 
				
			||||||
                  items: [
 | 
					                  items: [
 | 
				
			||||||
                    ...EasyLocalization.of(context)!.supportedLocales.mapIndexed((idx, ele) {
 | 
					                    ...EasyLocalization.of(context)!
 | 
				
			||||||
 | 
					                        .supportedLocales
 | 
				
			||||||
 | 
					                        .mapIndexed((idx, ele) {
 | 
				
			||||||
                      return DropdownMenuItem<Locale?>(
 | 
					                      return DropdownMenuItem<Locale?>(
 | 
				
			||||||
                        value: Locale.parse(ele.toString()),
 | 
					                        value: Locale.parse(ele.toString()),
 | 
				
			||||||
                        child: Text('${ele.languageCode}-${ele.countryCode}').fontSize(14),
 | 
					                        child: Text('${ele.languageCode}-${ele.countryCode}')
 | 
				
			||||||
 | 
					                            .fontSize(14),
 | 
				
			||||||
                      );
 | 
					                      );
 | 
				
			||||||
                    }),
 | 
					                    }),
 | 
				
			||||||
                  ],
 | 
					                  ],
 | 
				
			||||||
                  value: ua.user?.language != null ? Locale.parse(ua.user!.language) : Locale.parse('en-US'),
 | 
					                  value: ua.user?.language != null
 | 
				
			||||||
 | 
					                      ? (Locale.tryParse(ua.user!.language) ??
 | 
				
			||||||
 | 
					                          Locale.parse('en-US'))
 | 
				
			||||||
 | 
					                      : Locale.parse('en-US'),
 | 
				
			||||||
                  onChanged: (Locale? value) {
 | 
					                  onChanged: (Locale? value) {
 | 
				
			||||||
                    if (value == null) return;
 | 
					                    if (value == null) return;
 | 
				
			||||||
                    _setAccountLanguage(context, value);
 | 
					                    _setAccountLanguage(context, value);
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										140
									
								
								lib/screens/account/badges.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										140
									
								
								lib/screens/account/badges.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,140 @@
 | 
				
			|||||||
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:google_fonts/google_fonts.dart';
 | 
				
			||||||
 | 
					import 'package:material_symbols_icons/material_symbols_icons.dart';
 | 
				
			||||||
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/screens/account/profile_page.dart' show kBadgesMeta;
 | 
				
			||||||
 | 
					import 'package:surface/theme.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/account.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/loading_indicator.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AccountBadgesScreen extends StatefulWidget {
 | 
				
			||||||
 | 
					  const AccountBadgesScreen({super.key});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<AccountBadgesScreen> createState() => _AccountBadgesScreenState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _AccountBadgesScreenState extends State<AccountBadgesScreen> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					  List<SnAccountBadge>? _badges;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchBadges() async {
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get('/cgi/id/badges/me');
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      setState(
 | 
				
			||||||
 | 
					        () => _badges = List<SnAccountBadge>.from(
 | 
				
			||||||
 | 
					          resp.data?.map((e) => SnAccountBadge.fromJson(e)) ?? [],
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _isActivating = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _activateBadge(SnAccountBadge badge) async {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      setState(() => _isActivating = true);
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.post('/cgi/id/badges/${badge.id}/active');
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showSnackbar('badgeActivated'
 | 
				
			||||||
 | 
					          .tr(args: [(kBadgesMeta[badge.type]?.$1 ?? 'unknown').tr()]));
 | 
				
			||||||
 | 
					      await _fetchBadges();
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isActivating = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _fetchBadges();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return AppScaffold(
 | 
				
			||||||
 | 
					      appBar: AppBar(
 | 
				
			||||||
 | 
					        title: Text('screenAccountBadges').tr(),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      body: Column(
 | 
				
			||||||
 | 
					        children: [
 | 
				
			||||||
 | 
					          LoadingIndicator(isActive: _isBusy),
 | 
				
			||||||
 | 
					          if (_badges != null)
 | 
				
			||||||
 | 
					            Expanded(
 | 
				
			||||||
 | 
					              child: MediaQuery.removePadding(
 | 
				
			||||||
 | 
					                context: context,
 | 
				
			||||||
 | 
					                removeTop: true,
 | 
				
			||||||
 | 
					                child: RefreshIndicator(
 | 
				
			||||||
 | 
					                  onRefresh: _fetchBadges,
 | 
				
			||||||
 | 
					                  child: ListView.builder(
 | 
				
			||||||
 | 
					                    itemCount: _badges!.length,
 | 
				
			||||||
 | 
					                    itemBuilder: (context, idx) {
 | 
				
			||||||
 | 
					                      final badge = _badges![idx];
 | 
				
			||||||
 | 
					                      return ListTile(
 | 
				
			||||||
 | 
					                        title: Text(
 | 
				
			||||||
 | 
					                          kBadgesMeta[badge.type]?.$1 ?? 'unknown',
 | 
				
			||||||
 | 
					                        ).tr(),
 | 
				
			||||||
 | 
					                        contentPadding: const EdgeInsets.only(
 | 
				
			||||||
 | 
					                          left: 24,
 | 
				
			||||||
 | 
					                          right: 16,
 | 
				
			||||||
 | 
					                          top: 4,
 | 
				
			||||||
 | 
					                          bottom: 4,
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                        subtitle: Column(
 | 
				
			||||||
 | 
					                          crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                          children: [
 | 
				
			||||||
 | 
					                            if (badge.metadata['title'] != null)
 | 
				
			||||||
 | 
					                              Text(badge.metadata['title']).fontSize(14).bold()
 | 
				
			||||||
 | 
					                            else
 | 
				
			||||||
 | 
					                              Text(
 | 
				
			||||||
 | 
					                                '#${badge.id.toString().padLeft(8, '0')}',
 | 
				
			||||||
 | 
					                                style: GoogleFonts.robotoMono(),
 | 
				
			||||||
 | 
					                              ).fontSize(14).bold(),
 | 
				
			||||||
 | 
					                            Text(
 | 
				
			||||||
 | 
					                              DateFormat('y/M/d').format(badge.createdAt),
 | 
				
			||||||
 | 
					                            )
 | 
				
			||||||
 | 
					                          ],
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                        trailing: IconButton(
 | 
				
			||||||
 | 
					                          icon: const Icon(Symbols.check),
 | 
				
			||||||
 | 
					                          onPressed: (badge.isActive || _isActivating)
 | 
				
			||||||
 | 
					                              ? null
 | 
				
			||||||
 | 
					                              : () {
 | 
				
			||||||
 | 
					                                  _activateBadge(badge);
 | 
				
			||||||
 | 
					                                },
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                        leading: Icon(
 | 
				
			||||||
 | 
					                          kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark,
 | 
				
			||||||
 | 
					                          color: badge.metadata['color'] != null
 | 
				
			||||||
 | 
					                              ? HexColor.fromHex(badge.metadata['color']!)
 | 
				
			||||||
 | 
					                              : kBadgesMeta[badge.type]?.$3,
 | 
				
			||||||
 | 
					                          fill: 1,
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      );
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										106
									
								
								lib/screens/account/keypairs.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								lib/screens/account/keypairs.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,106 @@
 | 
				
			|||||||
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:google_fonts/google_fonts.dart';
 | 
				
			||||||
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/keypair.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/keypair.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/loading_indicator.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class KeyPairScreen extends StatefulWidget {
 | 
				
			||||||
 | 
					  const KeyPairScreen({super.key});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<KeyPairScreen> createState() => _KeyPairScreenState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _KeyPairScreenState extends State<KeyPairScreen> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					  List<SnKeyPair>? _keyPairs;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _loadKeyPairs() async {
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					    final kps = await context.read<KeyPairProvider>().listKeyPair();
 | 
				
			||||||
 | 
					    setState(() {
 | 
				
			||||||
 | 
					      _keyPairs = kps;
 | 
				
			||||||
 | 
					      _isBusy = false;
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _loadKeyPairs();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return AppScaffold(
 | 
				
			||||||
 | 
					      appBar: AppBar(
 | 
				
			||||||
 | 
					        title: Text('screenKeyPairs').tr(),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      body: Column(
 | 
				
			||||||
 | 
					        children: [
 | 
				
			||||||
 | 
					          LoadingIndicator(isActive: _isBusy),
 | 
				
			||||||
 | 
					          ListTile(
 | 
				
			||||||
 | 
					            leading: const Icon(Symbols.add),
 | 
				
			||||||
 | 
					            title: Text('enrollNewKeyPair').tr(),
 | 
				
			||||||
 | 
					            subtitle: Text('enrollNewKeyPairDescription').tr(),
 | 
				
			||||||
 | 
					            onTap: () async {
 | 
				
			||||||
 | 
					              await context.read<KeyPairProvider>().enrollNew();
 | 
				
			||||||
 | 
					              _loadKeyPairs();
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          const Divider(height: 1),
 | 
				
			||||||
 | 
					          if (_keyPairs != null)
 | 
				
			||||||
 | 
					            Expanded(
 | 
				
			||||||
 | 
					              child: MediaQuery.removePadding(
 | 
				
			||||||
 | 
					                context: context,
 | 
				
			||||||
 | 
					                removeTop: true,
 | 
				
			||||||
 | 
					                child: RefreshIndicator(
 | 
				
			||||||
 | 
					                  onRefresh: _loadKeyPairs,
 | 
				
			||||||
 | 
					                  child: ListView.builder(
 | 
				
			||||||
 | 
					                    itemCount: _keyPairs!.length,
 | 
				
			||||||
 | 
					                    itemBuilder: (context, index) {
 | 
				
			||||||
 | 
					                      final kp = _keyPairs![index];
 | 
				
			||||||
 | 
					                      return ListTile(
 | 
				
			||||||
 | 
					                        title: Text(kp.id.toUpperCase()),
 | 
				
			||||||
 | 
					                        subtitle: Row(
 | 
				
			||||||
 | 
					                          spacing: 8,
 | 
				
			||||||
 | 
					                          children: [
 | 
				
			||||||
 | 
					                            if (kp.privateKey != null)
 | 
				
			||||||
 | 
					                              Text(
 | 
				
			||||||
 | 
					                                'keyPairHasPrivateKey'.tr(),
 | 
				
			||||||
 | 
					                              ),
 | 
				
			||||||
 | 
					                            if (kp.privateKey != null) Text('·'),
 | 
				
			||||||
 | 
					                            Flexible(
 | 
				
			||||||
 | 
					                              flex: 1,
 | 
				
			||||||
 | 
					                              child: Text(
 | 
				
			||||||
 | 
					                                'UID #${kp.accountId.toString().padLeft(8, '0')}',
 | 
				
			||||||
 | 
					                                style: GoogleFonts.robotoMono(),
 | 
				
			||||||
 | 
					                              ),
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                          ],
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                        trailing: IconButton(
 | 
				
			||||||
 | 
					                          icon: const Icon(Symbols.check),
 | 
				
			||||||
 | 
					                          onPressed: kp.isActive == true
 | 
				
			||||||
 | 
					                              ? null
 | 
				
			||||||
 | 
					                              : () async {
 | 
				
			||||||
 | 
					                                  final k = context.read<KeyPairProvider>();
 | 
				
			||||||
 | 
					                                  await k.activeKeyPair(kp.id);
 | 
				
			||||||
 | 
					                                  _loadKeyPairs();
 | 
				
			||||||
 | 
					                                },
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      );
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -6,6 +6,7 @@ import 'package:easy_localization/easy_localization.dart';
 | 
				
			|||||||
import 'package:flutter/cupertino.dart';
 | 
					import 'package:flutter/cupertino.dart';
 | 
				
			||||||
import 'package:flutter/foundation.dart';
 | 
					import 'package:flutter/foundation.dart';
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:flutter_timezone/flutter_timezone.dart';
 | 
				
			||||||
import 'package:gap/gap.dart';
 | 
					import 'package:gap/gap.dart';
 | 
				
			||||||
import 'package:image_picker/image_picker.dart';
 | 
					import 'package:image_picker/image_picker.dart';
 | 
				
			||||||
import 'package:material_symbols_icons/symbols.dart';
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
@@ -36,11 +37,16 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
				
			|||||||
  final _firstNameController = TextEditingController();
 | 
					  final _firstNameController = TextEditingController();
 | 
				
			||||||
  final _lastNameController = TextEditingController();
 | 
					  final _lastNameController = TextEditingController();
 | 
				
			||||||
  final _descriptionController = TextEditingController();
 | 
					  final _descriptionController = TextEditingController();
 | 
				
			||||||
 | 
					  final _timezoneController = TextEditingController();
 | 
				
			||||||
 | 
					  final _genderController = TextEditingController();
 | 
				
			||||||
 | 
					  final _pronounsController = TextEditingController();
 | 
				
			||||||
 | 
					  final _locationController = TextEditingController();
 | 
				
			||||||
  final _birthdayController = TextEditingController();
 | 
					  final _birthdayController = TextEditingController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  String? _avatar;
 | 
					  String? _avatar;
 | 
				
			||||||
  String? _banner;
 | 
					  String? _banner;
 | 
				
			||||||
  DateTime? _birthday;
 | 
					  DateTime? _birthday;
 | 
				
			||||||
 | 
					  List<(String, String)>? _links;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  bool _isBusy = false;
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -51,43 +57,46 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
				
			|||||||
    final prof = ua.user!;
 | 
					    final prof = ua.user!;
 | 
				
			||||||
    _usernameController.text = prof.name;
 | 
					    _usernameController.text = prof.name;
 | 
				
			||||||
    _nicknameController.text = prof.nick;
 | 
					    _nicknameController.text = prof.nick;
 | 
				
			||||||
    _descriptionController.text = prof.description;
 | 
					    _descriptionController.text = prof.profile!.description;
 | 
				
			||||||
    _firstNameController.text = prof.profile!.firstName;
 | 
					    _firstNameController.text = prof.profile!.firstName;
 | 
				
			||||||
    _lastNameController.text = prof.profile!.lastName;
 | 
					    _lastNameController.text = prof.profile!.lastName;
 | 
				
			||||||
 | 
					    _timezoneController.text = prof.profile!.timeZone;
 | 
				
			||||||
 | 
					    _genderController.text = prof.profile!.gender;
 | 
				
			||||||
 | 
					    _pronounsController.text = prof.profile!.pronouns;
 | 
				
			||||||
 | 
					    _locationController.text = prof.profile!.location;
 | 
				
			||||||
    _avatar = prof.avatar;
 | 
					    _avatar = prof.avatar;
 | 
				
			||||||
    _banner = prof.banner;
 | 
					    _banner = prof.banner;
 | 
				
			||||||
    if (prof.profile!.birthday != null) {
 | 
					    _links = prof.profile!.links.entries.map((ele) => (ele.key, ele.value)).toList();
 | 
				
			||||||
      _birthdayController.text = DateFormat(_kDateFormat).format(
 | 
					    _birthday = prof.profile!.birthday?.toLocal();
 | 
				
			||||||
        prof.profile!.birthday!.toLocal(),
 | 
					    if (_birthday != null) {
 | 
				
			||||||
      );
 | 
					      _birthdayController.text = DateFormat(_kDateFormat).format(prof.profile!.birthday!.toLocal());
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void _selectBirthday() async {
 | 
					  void _selectBirthday() async {
 | 
				
			||||||
    await showCupertinoModalPopup<DateTime?>(
 | 
					    await showCupertinoModalPopup<DateTime?>(
 | 
				
			||||||
      context: context,
 | 
					      context: context,
 | 
				
			||||||
      builder: (BuildContext context) => Container(
 | 
					      builder:
 | 
				
			||||||
        height: 216,
 | 
					          (BuildContext context) => Container(
 | 
				
			||||||
        padding: const EdgeInsets.only(top: 6.0),
 | 
					            height: 216,
 | 
				
			||||||
        margin: EdgeInsets.only(
 | 
					            padding: const EdgeInsets.only(top: 6.0),
 | 
				
			||||||
          bottom: MediaQuery.of(context).viewInsets.bottom,
 | 
					            margin: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
 | 
				
			||||||
        ),
 | 
					            color: Theme.of(context).colorScheme.surface,
 | 
				
			||||||
        color: Theme.of(context).colorScheme.surface,
 | 
					            child: SafeArea(
 | 
				
			||||||
        child: SafeArea(
 | 
					              top: false,
 | 
				
			||||||
          top: false,
 | 
					              child: CupertinoDatePicker(
 | 
				
			||||||
          child: CupertinoDatePicker(
 | 
					                initialDateTime: _birthday?.toLocal(),
 | 
				
			||||||
            initialDateTime: _birthday?.toLocal(),
 | 
					                mode: CupertinoDatePickerMode.date,
 | 
				
			||||||
            mode: CupertinoDatePickerMode.date,
 | 
					                use24hFormat: true,
 | 
				
			||||||
            use24hFormat: true,
 | 
					                onDateTimeChanged: (DateTime newDate) {
 | 
				
			||||||
            onDateTimeChanged: (DateTime newDate) {
 | 
					                  setState(() {
 | 
				
			||||||
              setState(() {
 | 
					                    _birthday = newDate;
 | 
				
			||||||
                _birthday = newDate;
 | 
					                    _birthdayController.text = DateFormat(_kDateFormat).format(_birthday!);
 | 
				
			||||||
                _birthdayController.text = DateFormat(_kDateFormat).format(_birthday!);
 | 
					                  });
 | 
				
			||||||
              });
 | 
					                },
 | 
				
			||||||
            },
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
          ),
 | 
					          ),
 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
      ),
 | 
					 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -96,32 +105,42 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
				
			|||||||
    if (image == null) return;
 | 
					    if (image == null) return;
 | 
				
			||||||
    if (!mounted) return;
 | 
					    if (!mounted) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
 | 
					    final skipCrop = image.path.endsWith('.gif');
 | 
				
			||||||
    final aspectRatios =
 | 
					 | 
				
			||||||
        place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
 | 
					 | 
				
			||||||
    final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
 | 
					 | 
				
			||||||
        ? await showCupertinoImageCropper(
 | 
					 | 
				
			||||||
            // ignore: use_build_context_synchronously
 | 
					 | 
				
			||||||
            context,
 | 
					 | 
				
			||||||
            allowedAspectRatios: aspectRatios,
 | 
					 | 
				
			||||||
            imageProvider: imageProvider,
 | 
					 | 
				
			||||||
          )
 | 
					 | 
				
			||||||
        : await showMaterialImageCropper(
 | 
					 | 
				
			||||||
            // ignore: use_build_context_synchronously
 | 
					 | 
				
			||||||
            context,
 | 
					 | 
				
			||||||
            allowedAspectRatios: aspectRatios,
 | 
					 | 
				
			||||||
            imageProvider: imageProvider,
 | 
					 | 
				
			||||||
          );
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (result == null) return;
 | 
					    Uint8List? rawBytes;
 | 
				
			||||||
 | 
					    if (!skipCrop) {
 | 
				
			||||||
 | 
					      final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
 | 
				
			||||||
 | 
					      final aspectRatios =
 | 
				
			||||||
 | 
					          place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
 | 
				
			||||||
 | 
					      final result =
 | 
				
			||||||
 | 
					          (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
 | 
				
			||||||
 | 
					              ? await showCupertinoImageCropper(
 | 
				
			||||||
 | 
					                // ignore: use_build_context_synchronously
 | 
				
			||||||
 | 
					                context,
 | 
				
			||||||
 | 
					                allowedAspectRatios: aspectRatios,
 | 
				
			||||||
 | 
					                imageProvider: imageProvider,
 | 
				
			||||||
 | 
					              )
 | 
				
			||||||
 | 
					              : await showMaterialImageCropper(
 | 
				
			||||||
 | 
					                // ignore: use_build_context_synchronously
 | 
				
			||||||
 | 
					                context,
 | 
				
			||||||
 | 
					                allowedAspectRatios: aspectRatios,
 | 
				
			||||||
 | 
					                imageProvider: imageProvider,
 | 
				
			||||||
 | 
					              );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (result == null) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					      rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					      rawBytes = await image.readAsBytes();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!mounted) return;
 | 
					    if (!mounted) return;
 | 
				
			||||||
    final attach = context.read<SnAttachmentProvider>();
 | 
					    final attach = context.read<SnAttachmentProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    setState(() => _isBusy = true);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      final attachment = await attach.directUploadOne(
 | 
					      final attachment = await attach.directUploadOne(
 | 
				
			||||||
        rawBytes,
 | 
					        rawBytes,
 | 
				
			||||||
@@ -133,10 +152,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      final sn = context.read<SnNetworkProvider>();
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
      await sn.client.put(
 | 
					      await sn.client.put('/cgi/id/users/me/$place', data: {'attachment': attachment.rid});
 | 
				
			||||||
        '/cgi/id/users/me/$place',
 | 
					 | 
				
			||||||
        data: {'attachment': attachment.rid},
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      final ua = context.read<UserProvider>();
 | 
					      final ua = context.read<UserProvider>();
 | 
				
			||||||
@@ -166,7 +182,14 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
				
			|||||||
          'description': _descriptionController.value.text,
 | 
					          'description': _descriptionController.value.text,
 | 
				
			||||||
          'first_name': _firstNameController.value.text,
 | 
					          'first_name': _firstNameController.value.text,
 | 
				
			||||||
          'last_name': _lastNameController.value.text,
 | 
					          'last_name': _lastNameController.value.text,
 | 
				
			||||||
 | 
					          'time_zone': _timezoneController.value.text,
 | 
				
			||||||
 | 
					          'gender': _genderController.value.text,
 | 
				
			||||||
 | 
					          'pronouns': _pronounsController.value.text,
 | 
				
			||||||
 | 
					          'location': _locationController.value.text,
 | 
				
			||||||
          'birthday': _birthday?.toUtc().toIso8601String(),
 | 
					          'birthday': _birthday?.toUtc().toIso8601String(),
 | 
				
			||||||
 | 
					          'links': {
 | 
				
			||||||
 | 
					            for (final link in _links!.where((ele) => ele.$1.isNotEmpty && ele.$2.isNotEmpty)) link.$1: link.$2,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -197,6 +220,10 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
				
			|||||||
    _firstNameController.dispose();
 | 
					    _firstNameController.dispose();
 | 
				
			||||||
    _lastNameController.dispose();
 | 
					    _lastNameController.dispose();
 | 
				
			||||||
    _descriptionController.dispose();
 | 
					    _descriptionController.dispose();
 | 
				
			||||||
 | 
					    _timezoneController.dispose();
 | 
				
			||||||
 | 
					    _genderController.dispose();
 | 
				
			||||||
 | 
					    _pronounsController.dispose();
 | 
				
			||||||
 | 
					    _locationController.dispose();
 | 
				
			||||||
    _birthdayController.dispose();
 | 
					    _birthdayController.dispose();
 | 
				
			||||||
    super.dispose();
 | 
					    super.dispose();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@@ -208,10 +235,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
				
			|||||||
    final sn = context.read<SnNetworkProvider>();
 | 
					    final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return AppScaffold(
 | 
					    return AppScaffold(
 | 
				
			||||||
      appBar: AppBar(
 | 
					      appBar: AppBar(leading: const PageBackButton(), title: Text('screenAccountProfileEdit').tr()),
 | 
				
			||||||
        leading: const PageBackButton(),
 | 
					 | 
				
			||||||
        title: Text('screenAccountProfileEdit').tr(),
 | 
					 | 
				
			||||||
      ),
 | 
					 | 
				
			||||||
      body: SingleChildScrollView(
 | 
					      body: SingleChildScrollView(
 | 
				
			||||||
        child: Column(
 | 
					        child: Column(
 | 
				
			||||||
          crossAxisAlignment: CrossAxisAlignment.start,
 | 
					          crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
@@ -230,12 +254,10 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
				
			|||||||
                        aspectRatio: 16 / 9,
 | 
					                        aspectRatio: 16 / 9,
 | 
				
			||||||
                        child: Container(
 | 
					                        child: Container(
 | 
				
			||||||
                          color: Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
					                          color: Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
				
			||||||
                          child: _banner != null
 | 
					                          child:
 | 
				
			||||||
                              ? AutoResizeUniversalImage(
 | 
					                              _banner != null
 | 
				
			||||||
                                  sn.getAttachmentUrl(_banner!),
 | 
					                                  ? AutoResizeUniversalImage(sn.getAttachmentUrl(_banner!), fit: BoxFit.cover)
 | 
				
			||||||
                                  fit: BoxFit.cover,
 | 
					                                  : const SizedBox.shrink(),
 | 
				
			||||||
                                )
 | 
					 | 
				
			||||||
                              : const SizedBox.shrink(),
 | 
					 | 
				
			||||||
                        ),
 | 
					                        ),
 | 
				
			||||||
                      ),
 | 
					                      ),
 | 
				
			||||||
                    ),
 | 
					                    ),
 | 
				
			||||||
@@ -262,6 +284,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
				
			|||||||
            ).padding(horizontal: padding),
 | 
					            ).padding(horizontal: padding),
 | 
				
			||||||
            const Gap(8 + 28),
 | 
					            const Gap(8 + 28),
 | 
				
			||||||
            Column(
 | 
					            Column(
 | 
				
			||||||
 | 
					              spacing: 4,
 | 
				
			||||||
              children: [
 | 
					              children: [
 | 
				
			||||||
                TextField(
 | 
					                TextField(
 | 
				
			||||||
                  readOnly: true,
 | 
					                  readOnly: true,
 | 
				
			||||||
@@ -271,16 +294,13 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
				
			|||||||
                    labelText: 'fieldUsername'.tr(),
 | 
					                    labelText: 'fieldUsername'.tr(),
 | 
				
			||||||
                    helperText: 'fieldUsernameCannotEditHint'.tr(),
 | 
					                    helperText: 'fieldUsernameCannotEditHint'.tr(),
 | 
				
			||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
 | 
					                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
                const Gap(4),
 | 
					 | 
				
			||||||
                TextField(
 | 
					                TextField(
 | 
				
			||||||
                  controller: _nicknameController,
 | 
					                  controller: _nicknameController,
 | 
				
			||||||
                  decoration: InputDecoration(
 | 
					                  decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldNickname'.tr()),
 | 
				
			||||||
                    border: const UnderlineInputBorder(),
 | 
					                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
                    labelText: 'fieldNickname'.tr(),
 | 
					 | 
				
			||||||
                  ),
 | 
					 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
                const Gap(4),
 | 
					 | 
				
			||||||
                Row(
 | 
					                Row(
 | 
				
			||||||
                  children: [
 | 
					                  children: [
 | 
				
			||||||
                    Flexible(
 | 
					                    Flexible(
 | 
				
			||||||
@@ -291,6 +311,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
				
			|||||||
                          border: const UnderlineInputBorder(),
 | 
					                          border: const UnderlineInputBorder(),
 | 
				
			||||||
                          labelText: 'fieldFirstName'.tr(),
 | 
					                          labelText: 'fieldFirstName'.tr(),
 | 
				
			||||||
                        ),
 | 
					                        ),
 | 
				
			||||||
 | 
					                        onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
                      ),
 | 
					                      ),
 | 
				
			||||||
                    ),
 | 
					                    ),
 | 
				
			||||||
                    const Gap(8),
 | 
					                    const Gap(8),
 | 
				
			||||||
@@ -302,31 +323,165 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
				
			|||||||
                          border: const UnderlineInputBorder(),
 | 
					                          border: const UnderlineInputBorder(),
 | 
				
			||||||
                          labelText: 'fieldLastName'.tr(),
 | 
					                          labelText: 'fieldLastName'.tr(),
 | 
				
			||||||
                        ),
 | 
					                        ),
 | 
				
			||||||
 | 
					                        onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ],
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                Row(
 | 
				
			||||||
 | 
					                  children: [
 | 
				
			||||||
 | 
					                    Flexible(
 | 
				
			||||||
 | 
					                      flex: 1,
 | 
				
			||||||
 | 
					                      child: TextField(
 | 
				
			||||||
 | 
					                        controller: _genderController,
 | 
				
			||||||
 | 
					                        decoration: InputDecoration(
 | 
				
			||||||
 | 
					                          border: const UnderlineInputBorder(),
 | 
				
			||||||
 | 
					                          labelText: 'fieldGender'.tr(),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                        onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    const Gap(4),
 | 
				
			||||||
 | 
					                    Flexible(
 | 
				
			||||||
 | 
					                      flex: 1,
 | 
				
			||||||
 | 
					                      child: TextField(
 | 
				
			||||||
 | 
					                        controller: _pronounsController,
 | 
				
			||||||
 | 
					                        decoration: InputDecoration(
 | 
				
			||||||
 | 
					                          border: const UnderlineInputBorder(),
 | 
				
			||||||
 | 
					                          labelText: 'fieldPronouns'.tr(),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                        onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
                      ),
 | 
					                      ),
 | 
				
			||||||
                    ),
 | 
					                    ),
 | 
				
			||||||
                  ],
 | 
					                  ],
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
                const Gap(4),
 | 
					 | 
				
			||||||
                TextField(
 | 
					                TextField(
 | 
				
			||||||
                  controller: _descriptionController,
 | 
					                  controller: _descriptionController,
 | 
				
			||||||
                  keyboardType: TextInputType.multiline,
 | 
					                  keyboardType: TextInputType.multiline,
 | 
				
			||||||
                  maxLines: null,
 | 
					                  maxLines: null,
 | 
				
			||||||
                  minLines: 3,
 | 
					                  minLines: 3,
 | 
				
			||||||
                  decoration: InputDecoration(
 | 
					                  decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldDescription'.tr()),
 | 
				
			||||||
                    border: const UnderlineInputBorder(),
 | 
					                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
                    labelText: 'fieldDescription'.tr(),
 | 
					                ),
 | 
				
			||||||
                  ),
 | 
					                Row(
 | 
				
			||||||
 | 
					                  crossAxisAlignment: CrossAxisAlignment.center,
 | 
				
			||||||
 | 
					                  children: [
 | 
				
			||||||
 | 
					                    Expanded(
 | 
				
			||||||
 | 
					                      child: TextField(
 | 
				
			||||||
 | 
					                        controller: _timezoneController,
 | 
				
			||||||
 | 
					                        decoration: InputDecoration(
 | 
				
			||||||
 | 
					                          border: const UnderlineInputBorder(),
 | 
				
			||||||
 | 
					                          labelText: 'fieldTimeZone'.tr(),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                        onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    const Gap(4),
 | 
				
			||||||
 | 
					                    StyledWidget(
 | 
				
			||||||
 | 
					                      IconButton(
 | 
				
			||||||
 | 
					                        icon: const Icon(Symbols.calendar_month),
 | 
				
			||||||
 | 
					                        visualDensity: VisualDensity(horizontal: -4, vertical: -4),
 | 
				
			||||||
 | 
					                        padding: EdgeInsets.zero,
 | 
				
			||||||
 | 
					                        constraints: const BoxConstraints(),
 | 
				
			||||||
 | 
					                        onPressed: () async {
 | 
				
			||||||
 | 
					                          _timezoneController.text = await FlutterTimezone.getLocalTimezone();
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ).padding(top: 6),
 | 
				
			||||||
 | 
					                    const Gap(4),
 | 
				
			||||||
 | 
					                    StyledWidget(
 | 
				
			||||||
 | 
					                      IconButton(
 | 
				
			||||||
 | 
					                        icon: const Icon(Symbols.clear),
 | 
				
			||||||
 | 
					                        visualDensity: VisualDensity(horizontal: -4, vertical: -4),
 | 
				
			||||||
 | 
					                        padding: EdgeInsets.zero,
 | 
				
			||||||
 | 
					                        constraints: const BoxConstraints(),
 | 
				
			||||||
 | 
					                        onPressed: () {
 | 
				
			||||||
 | 
					                          _timezoneController.clear();
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ).padding(top: 6),
 | 
				
			||||||
 | 
					                  ],
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                TextField(
 | 
				
			||||||
 | 
					                  controller: _locationController,
 | 
				
			||||||
 | 
					                  decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldLocation'.tr()),
 | 
				
			||||||
 | 
					                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
                const Gap(4),
 | 
					 | 
				
			||||||
                TextField(
 | 
					                TextField(
 | 
				
			||||||
                  controller: _birthdayController,
 | 
					                  controller: _birthdayController,
 | 
				
			||||||
                  readOnly: true,
 | 
					                  readOnly: true,
 | 
				
			||||||
                  decoration: InputDecoration(
 | 
					                  decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldBirthday'.tr()),
 | 
				
			||||||
                    border: const UnderlineInputBorder(),
 | 
					 | 
				
			||||||
                    labelText: 'fieldBirthday'.tr(),
 | 
					 | 
				
			||||||
                  ),
 | 
					 | 
				
			||||||
                  onTap: () => _selectBirthday(),
 | 
					                  onTap: () => _selectBirthday(),
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
 | 
					                if (_links != null)
 | 
				
			||||||
 | 
					                  Card(
 | 
				
			||||||
 | 
					                    margin: const EdgeInsets.only(top: 16, bottom: 4),
 | 
				
			||||||
 | 
					                    child: Container(
 | 
				
			||||||
 | 
					                      width: double.infinity,
 | 
				
			||||||
 | 
					                      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
 | 
				
			||||||
 | 
					                      child: Column(
 | 
				
			||||||
 | 
					                        crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                        children: [
 | 
				
			||||||
 | 
					                          Row(
 | 
				
			||||||
 | 
					                            children: [
 | 
				
			||||||
 | 
					                              Expanded(
 | 
				
			||||||
 | 
					                                child: Text(
 | 
				
			||||||
 | 
					                                  'fieldLinks'.tr(),
 | 
				
			||||||
 | 
					                                  style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 17),
 | 
				
			||||||
 | 
					                                ),
 | 
				
			||||||
 | 
					                              ),
 | 
				
			||||||
 | 
					                              IconButton(
 | 
				
			||||||
 | 
					                                padding: EdgeInsets.zero,
 | 
				
			||||||
 | 
					                                constraints: const BoxConstraints(),
 | 
				
			||||||
 | 
					                                visualDensity: VisualDensity(horizontal: -4, vertical: -4),
 | 
				
			||||||
 | 
					                                icon: const Icon(Symbols.add),
 | 
				
			||||||
 | 
					                                onPressed: () {
 | 
				
			||||||
 | 
					                                  setState(() => _links!.add(('', '')));
 | 
				
			||||||
 | 
					                                },
 | 
				
			||||||
 | 
					                              ),
 | 
				
			||||||
 | 
					                            ],
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                          const Gap(8),
 | 
				
			||||||
 | 
					                          for (var idx = 0; idx < _links!.length; idx++)
 | 
				
			||||||
 | 
					                            Row(
 | 
				
			||||||
 | 
					                              children: [
 | 
				
			||||||
 | 
					                                Flexible(
 | 
				
			||||||
 | 
					                                  flex: 1,
 | 
				
			||||||
 | 
					                                  child: TextFormField(
 | 
				
			||||||
 | 
					                                    initialValue: _links![idx].$1,
 | 
				
			||||||
 | 
					                                    decoration: InputDecoration(
 | 
				
			||||||
 | 
					                                      isDense: true,
 | 
				
			||||||
 | 
					                                      border: const OutlineInputBorder(),
 | 
				
			||||||
 | 
					                                      labelText: 'fieldLinkName'.tr(),
 | 
				
			||||||
 | 
					                                    ),
 | 
				
			||||||
 | 
					                                    onChanged: (value) {
 | 
				
			||||||
 | 
					                                      _links![idx] = (value, _links![idx].$2);
 | 
				
			||||||
 | 
					                                    },
 | 
				
			||||||
 | 
					                                    onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					                                  ),
 | 
				
			||||||
 | 
					                                ),
 | 
				
			||||||
 | 
					                                const Gap(8),
 | 
				
			||||||
 | 
					                                Flexible(
 | 
				
			||||||
 | 
					                                  flex: 1,
 | 
				
			||||||
 | 
					                                  child: TextFormField(
 | 
				
			||||||
 | 
					                                    initialValue: _links![idx].$2,
 | 
				
			||||||
 | 
					                                    decoration: InputDecoration(
 | 
				
			||||||
 | 
					                                      isDense: true,
 | 
				
			||||||
 | 
					                                      border: const OutlineInputBorder(),
 | 
				
			||||||
 | 
					                                      labelText: 'fieldLinkUrl'.tr(),
 | 
				
			||||||
 | 
					                                    ),
 | 
				
			||||||
 | 
					                                    onChanged: (value) {
 | 
				
			||||||
 | 
					                                      _links![idx] = (_links![idx].$1, value);
 | 
				
			||||||
 | 
					                                    },
 | 
				
			||||||
 | 
					                                    onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					                                  ),
 | 
				
			||||||
 | 
					                                ),
 | 
				
			||||||
 | 
					                              ],
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                        ],
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
              ],
 | 
					              ],
 | 
				
			||||||
            ).padding(horizontal: padding + 8),
 | 
					            ).padding(horizontal: padding + 8),
 | 
				
			||||||
            const Gap(12),
 | 
					            const Gap(12),
 | 
				
			||||||
@@ -340,6 +495,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
				
			|||||||
                ),
 | 
					                ),
 | 
				
			||||||
              ],
 | 
					              ],
 | 
				
			||||||
            ).padding(horizontal: padding),
 | 
					            ).padding(horizontal: padding),
 | 
				
			||||||
 | 
					            Gap(MediaQuery.of(context).padding.bottom),
 | 
				
			||||||
          ],
 | 
					          ],
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -20,8 +20,10 @@ import 'package:surface/types/post.dart';
 | 
				
			|||||||
import 'package:surface/widgets/account/account_image.dart';
 | 
					import 'package:surface/widgets/account/account_image.dart';
 | 
				
			||||||
import 'package:surface/widgets/dialog.dart';
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
import 'package:surface/widgets/universal_image.dart';
 | 
					import 'package:surface/widgets/universal_image.dart';
 | 
				
			||||||
 | 
					import 'package:surface/theme.dart';
 | 
				
			||||||
 | 
					import 'package:url_launcher/url_launcher_string.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const Map<String, (String, IconData, Color)> kBadgesMeta = {
 | 
					final Map<String, (String, IconData, Color)> kBadgesMeta = {
 | 
				
			||||||
  'company.staff': (
 | 
					  'company.staff': (
 | 
				
			||||||
    'badgeCompanyStaff',
 | 
					    'badgeCompanyStaff',
 | 
				
			||||||
    Symbols.tools_wrench,
 | 
					    Symbols.tools_wrench,
 | 
				
			||||||
@@ -32,6 +34,31 @@ const Map<String, (String, IconData, Color)> kBadgesMeta = {
 | 
				
			|||||||
    Symbols.flag,
 | 
					    Symbols.flag,
 | 
				
			||||||
    Colors.orange,
 | 
					    Colors.orange,
 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
 | 
					  'site.anniversary': (
 | 
				
			||||||
 | 
					    'badgeSiteAnniversary',
 | 
				
			||||||
 | 
					    Symbols.celebration,
 | 
				
			||||||
 | 
					    Colors.orangeAccent,
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
 | 
					  'user.birthday': (
 | 
				
			||||||
 | 
					    'badgeUserBirthday',
 | 
				
			||||||
 | 
					    Symbols.cake,
 | 
				
			||||||
 | 
					    Colors.red[400]!,
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
 | 
					  'community.survey': (
 | 
				
			||||||
 | 
					    'badgeCommunitySurvey',
 | 
				
			||||||
 | 
					    Symbols.star,
 | 
				
			||||||
 | 
					    Colors.yellow[700]!,
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
 | 
					  'community.verified': (
 | 
				
			||||||
 | 
					    'badgeCommunityVerified',
 | 
				
			||||||
 | 
					    Symbols.verified,
 | 
				
			||||||
 | 
					    Colors.blue,
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
 | 
					  'community.contributor': (
 | 
				
			||||||
 | 
					    'badgeCommunityContributor',
 | 
				
			||||||
 | 
					    Symbols.thumb_up,
 | 
				
			||||||
 | 
					    Colors.lightGreen,
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class UserScreen extends StatefulWidget {
 | 
					class UserScreen extends StatefulWidget {
 | 
				
			||||||
@@ -43,7 +70,8 @@ class UserScreen extends StatefulWidget {
 | 
				
			|||||||
  State<UserScreen> createState() => _UserScreenState();
 | 
					  State<UserScreen> createState() => _UserScreenState();
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateMixin {
 | 
					class _UserScreenState extends State<UserScreen>
 | 
				
			||||||
 | 
					    with SingleTickerProviderStateMixin {
 | 
				
			||||||
  late final ScrollController _scrollController = ScrollController();
 | 
					  late final ScrollController _scrollController = ScrollController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  SnAccount? _account;
 | 
					  SnAccount? _account;
 | 
				
			||||||
@@ -64,13 +92,18 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<List<SnCheckInRecord>> _getCheckInRecords() async {
 | 
					  List<SnCheckInRecord>? _records;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _getCheckInRecords() async {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      final sn = context.read<SnNetworkProvider>();
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
      final resp = await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14');
 | 
					      final resp =
 | 
				
			||||||
      return List.from(
 | 
					          await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14');
 | 
				
			||||||
        resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [],
 | 
					      setState(() {
 | 
				
			||||||
      );
 | 
					        _records = List.from(
 | 
				
			||||||
 | 
					          resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [],
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      if (mounted) context.showErrorDialog(err);
 | 
					      if (mounted) context.showErrorDialog(err);
 | 
				
			||||||
      rethrow;
 | 
					      rethrow;
 | 
				
			||||||
@@ -98,7 +131,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
				
			|||||||
  Future<void> _fetchPublishers() async {
 | 
					  Future<void> _fetchPublishers() async {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      final sn = context.read<SnNetworkProvider>();
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
      final resp = await sn.client.get('/cgi/co/publishers?user=${widget.name}');
 | 
					      final resp =
 | 
				
			||||||
 | 
					          await sn.client.get('/cgi/co/publishers?user=${widget.name}');
 | 
				
			||||||
      _publishers = List<SnPublisher>.from(
 | 
					      _publishers = List<SnPublisher>.from(
 | 
				
			||||||
        resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
 | 
					        resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
@@ -144,7 +178,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
				
			|||||||
        'related': _account!.name,
 | 
					        'related': _account!.name,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      context.showSnackbar('userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
 | 
					      context.showSnackbar(
 | 
				
			||||||
 | 
					          'userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      context.showErrorDialog(err);
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
@@ -160,9 +195,11 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      final rel = context.read<SnRelationshipProvider>();
 | 
					      final rel = context.read<SnRelationshipProvider>();
 | 
				
			||||||
      await rel.updateRelationship(_account!.id, 1, _accountRelationship?.permNodes ?? {});
 | 
					      await rel.updateRelationship(
 | 
				
			||||||
 | 
					          _account!.id, 1, _accountRelationship?.permNodes ?? {});
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      context.showSnackbar('userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
 | 
					      context.showSnackbar(
 | 
				
			||||||
 | 
					          'userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      context.showErrorDialog(err);
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
@@ -188,12 +225,14 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
				
			|||||||
  double _appBarBlur = 0.0;
 | 
					  double _appBarBlur = 0.0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  late final _appBarWidth = MediaQuery.of(context).size.width;
 | 
					  late final _appBarWidth = MediaQuery.of(context).size.width;
 | 
				
			||||||
  late final _appBarHeight = (_appBarWidth * kBannerAspectRatio).roundToDouble();
 | 
					  late final _appBarHeight =
 | 
				
			||||||
 | 
					      (_appBarWidth * kBannerAspectRatio).roundToDouble();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void _updateAppBarBlur() {
 | 
					  void _updateAppBarBlur() {
 | 
				
			||||||
    if (_scrollController.offset > _appBarHeight) return;
 | 
					    if (_scrollController.offset > _appBarHeight) return;
 | 
				
			||||||
    setState(() {
 | 
					    setState(() {
 | 
				
			||||||
      _appBarBlur = (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
 | 
					      _appBarBlur =
 | 
				
			||||||
 | 
					          (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -205,6 +244,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      _fetchStatus();
 | 
					      _fetchStatus();
 | 
				
			||||||
      _fetchPublishers();
 | 
					      _fetchPublishers();
 | 
				
			||||||
 | 
					      _getCheckInRecords();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      try {
 | 
					      try {
 | 
				
			||||||
        final rel = context.read<SnRelationshipProvider>();
 | 
					        final rel = context.read<SnRelationshipProvider>();
 | 
				
			||||||
@@ -260,18 +300,20 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
				
			|||||||
                      text: TextSpan(children: [
 | 
					                      text: TextSpan(children: [
 | 
				
			||||||
                        TextSpan(
 | 
					                        TextSpan(
 | 
				
			||||||
                          text: _account!.nick,
 | 
					                          text: _account!.nick,
 | 
				
			||||||
                          style: Theme.of(context).textTheme.titleLarge!.copyWith(
 | 
					                          style:
 | 
				
			||||||
                                color: Colors.white,
 | 
					                              Theme.of(context).textTheme.titleLarge!.copyWith(
 | 
				
			||||||
                                shadows: labelShadows,
 | 
					                                    color: Colors.white,
 | 
				
			||||||
                              ),
 | 
					                                    shadows: labelShadows,
 | 
				
			||||||
 | 
					                                  ),
 | 
				
			||||||
                        ),
 | 
					                        ),
 | 
				
			||||||
                        const TextSpan(text: '\n'),
 | 
					                        const TextSpan(text: '\n'),
 | 
				
			||||||
                        TextSpan(
 | 
					                        TextSpan(
 | 
				
			||||||
                          text: '@${_account!.name}',
 | 
					                          text: '@${_account!.name}',
 | 
				
			||||||
                          style: Theme.of(context).textTheme.bodySmall!.copyWith(
 | 
					                          style:
 | 
				
			||||||
                                color: Colors.white,
 | 
					                              Theme.of(context).textTheme.bodySmall!.copyWith(
 | 
				
			||||||
                                shadows: labelShadows,
 | 
					                                    color: Colors.white,
 | 
				
			||||||
                              ),
 | 
					                                    shadows: labelShadows,
 | 
				
			||||||
 | 
					                                  ),
 | 
				
			||||||
                        ),
 | 
					                        ),
 | 
				
			||||||
                      ]),
 | 
					                      ]),
 | 
				
			||||||
                    ),
 | 
					                    ),
 | 
				
			||||||
@@ -280,14 +322,21 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
				
			|||||||
                  ? Stack(
 | 
					                  ? Stack(
 | 
				
			||||||
                      fit: StackFit.expand,
 | 
					                      fit: StackFit.expand,
 | 
				
			||||||
                      children: [
 | 
					                      children: [
 | 
				
			||||||
                        UniversalImage(
 | 
					                        if (_account!.banner.isNotEmpty)
 | 
				
			||||||
                          sn.getAttachmentUrl(_account!.banner),
 | 
					                          UniversalImage(
 | 
				
			||||||
                          fit: BoxFit.cover,
 | 
					                            sn.getAttachmentUrl(_account!.banner),
 | 
				
			||||||
                          height: imageHeight,
 | 
					                            fit: BoxFit.cover,
 | 
				
			||||||
                          width: _appBarWidth,
 | 
					                            height: imageHeight,
 | 
				
			||||||
                          cacheHeight: imageHeight,
 | 
					                            width: _appBarWidth,
 | 
				
			||||||
                          cacheWidth: _appBarWidth,
 | 
					                            cacheHeight: imageHeight,
 | 
				
			||||||
                        ),
 | 
					                            cacheWidth: _appBarWidth,
 | 
				
			||||||
 | 
					                          )
 | 
				
			||||||
 | 
					                        else
 | 
				
			||||||
 | 
					                          Container(
 | 
				
			||||||
 | 
					                            color: Theme.of(context)
 | 
				
			||||||
 | 
					                                .colorScheme
 | 
				
			||||||
 | 
					                                .surfaceContainerHigh,
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
                        Positioned(
 | 
					                        Positioned(
 | 
				
			||||||
                          top: 0,
 | 
					                          top: 0,
 | 
				
			||||||
                          left: 0,
 | 
					                          left: 0,
 | 
				
			||||||
@@ -339,7 +388,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
				
			|||||||
                      PopupMenuButton(
 | 
					                      PopupMenuButton(
 | 
				
			||||||
                        padding: EdgeInsets.zero,
 | 
					                        padding: EdgeInsets.zero,
 | 
				
			||||||
                        style: ButtonStyle(
 | 
					                        style: ButtonStyle(
 | 
				
			||||||
                          visualDensity: VisualDensity(horizontal: -4, vertical: -4),
 | 
					                          visualDensity:
 | 
				
			||||||
 | 
					                              VisualDensity(horizontal: -4, vertical: -4),
 | 
				
			||||||
                        ),
 | 
					                        ),
 | 
				
			||||||
                        itemBuilder: (context) => [
 | 
					                        itemBuilder: (context) => [
 | 
				
			||||||
                          PopupMenuItem(
 | 
					                          PopupMenuItem(
 | 
				
			||||||
@@ -389,8 +439,12 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
				
			|||||||
                      ),
 | 
					                      ),
 | 
				
			||||||
                    ],
 | 
					                    ],
 | 
				
			||||||
                  ).padding(right: 8),
 | 
					                  ).padding(right: 8),
 | 
				
			||||||
                  const Gap(12),
 | 
					                  if (_account!.profile!.description.isNotEmpty)
 | 
				
			||||||
                  Text(_account!.description).padding(horizontal: 8),
 | 
					                    const Gap(12)
 | 
				
			||||||
 | 
					                  else
 | 
				
			||||||
 | 
					                    const Gap(8),
 | 
				
			||||||
 | 
					                  if (_account!.profile!.description.isNotEmpty)
 | 
				
			||||||
 | 
					                    Text(_account!.profile!.description).padding(horizontal: 8),
 | 
				
			||||||
                  const Gap(4),
 | 
					                  const Gap(4),
 | 
				
			||||||
                  Card(
 | 
					                  Card(
 | 
				
			||||||
                    child: Row(
 | 
					                    child: Row(
 | 
				
			||||||
@@ -399,7 +453,9 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
				
			|||||||
                          Symbols.circle,
 | 
					                          Symbols.circle,
 | 
				
			||||||
                          fill: 1,
 | 
					                          fill: 1,
 | 
				
			||||||
                          size: 16,
 | 
					                          size: 16,
 | 
				
			||||||
                          color: (_status?.isOnline ?? false) ? Colors.green : Colors.grey,
 | 
					                          color: (_status?.isOnline ?? false)
 | 
				
			||||||
 | 
					                              ? Colors.green
 | 
				
			||||||
 | 
					                              : Colors.grey,
 | 
				
			||||||
                        ).padding(all: 4),
 | 
					                        ).padding(all: 4),
 | 
				
			||||||
                        const Gap(8),
 | 
					                        const Gap(8),
 | 
				
			||||||
                        Text(
 | 
					                        Text(
 | 
				
			||||||
@@ -409,7 +465,9 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
				
			|||||||
                                  : 'accountStatusOffline'.tr()
 | 
					                                  : 'accountStatusOffline'.tr()
 | 
				
			||||||
                              : 'loading'.tr(),
 | 
					                              : 'loading'.tr(),
 | 
				
			||||||
                        ),
 | 
					                        ),
 | 
				
			||||||
                        if (_status != null && !_status!.isOnline && _status!.lastSeenAt != null)
 | 
					                        if (_status != null &&
 | 
				
			||||||
 | 
					                            !_status!.isOnline &&
 | 
				
			||||||
 | 
					                            _status!.lastSeenAt != null)
 | 
				
			||||||
                          Text(
 | 
					                          Text(
 | 
				
			||||||
                            'accountStatusLastSeen'.tr(args: [
 | 
					                            'accountStatusLastSeen'.tr(args: [
 | 
				
			||||||
                              _status!.lastSeenAt != null
 | 
					                              _status!.lastSeenAt != null
 | 
				
			||||||
@@ -429,11 +487,15 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
				
			|||||||
                          (ele) => Tooltip(
 | 
					                          (ele) => Tooltip(
 | 
				
			||||||
                            richMessage: TextSpan(
 | 
					                            richMessage: TextSpan(
 | 
				
			||||||
                              children: [
 | 
					                              children: [
 | 
				
			||||||
                                TextSpan(text: kBadgesMeta[ele.type]?.$1.tr() ?? 'unknown'.tr()),
 | 
					                                TextSpan(
 | 
				
			||||||
 | 
					                                  text: kBadgesMeta[ele.type]?.$1.tr() ??
 | 
				
			||||||
 | 
					                                      'unknown'.tr(),
 | 
				
			||||||
 | 
					                                ),
 | 
				
			||||||
                                if (ele.metadata['title'] != null)
 | 
					                                if (ele.metadata['title'] != null)
 | 
				
			||||||
                                  TextSpan(
 | 
					                                  TextSpan(
 | 
				
			||||||
                                    text: '\n${ele.metadata['title']}',
 | 
					                                    text: '\n${ele.metadata['title']}',
 | 
				
			||||||
                                    style: const TextStyle(fontWeight: FontWeight.bold),
 | 
					                                    style: const TextStyle(
 | 
				
			||||||
 | 
					                                        fontWeight: FontWeight.bold),
 | 
				
			||||||
                                  ),
 | 
					                                  ),
 | 
				
			||||||
                                TextSpan(text: '\n'),
 | 
					                                TextSpan(text: '\n'),
 | 
				
			||||||
                                TextSpan(
 | 
					                                TextSpan(
 | 
				
			||||||
@@ -442,8 +504,11 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
				
			|||||||
                              ],
 | 
					                              ],
 | 
				
			||||||
                            ),
 | 
					                            ),
 | 
				
			||||||
                            child: Icon(
 | 
					                            child: Icon(
 | 
				
			||||||
                              kBadgesMeta[ele.type]?.$2 ?? Symbols.question_mark,
 | 
					                              kBadgesMeta[ele.type]?.$2 ??
 | 
				
			||||||
                              color: kBadgesMeta[ele.type]?.$3,
 | 
					                                  Symbols.question_mark,
 | 
				
			||||||
 | 
					                              color: ele.metadata['color'] != null
 | 
				
			||||||
 | 
					                                  ? HexColor.fromHex(ele.metadata['color']!)
 | 
				
			||||||
 | 
					                                  : kBadgesMeta[ele.type]?.$3,
 | 
				
			||||||
                              fill: 1,
 | 
					                              fill: 1,
 | 
				
			||||||
                            ),
 | 
					                            ),
 | 
				
			||||||
                          ),
 | 
					                          ),
 | 
				
			||||||
@@ -458,7 +523,9 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
				
			|||||||
                        children: [
 | 
					                        children: [
 | 
				
			||||||
                          const Icon(Symbols.calendar_add_on),
 | 
					                          const Icon(Symbols.calendar_add_on),
 | 
				
			||||||
                          const Gap(8),
 | 
					                          const Gap(8),
 | 
				
			||||||
                          Text('publisherJoinedAt').tr(args: [DateFormat('y/M/d').format(_account!.createdAt)]),
 | 
					                          Text('publisherJoinedAt').tr(args: [
 | 
				
			||||||
 | 
					                            DateFormat('y/M/d').format(_account!.createdAt)
 | 
				
			||||||
 | 
					                          ]),
 | 
				
			||||||
                        ],
 | 
					                        ],
 | 
				
			||||||
                      ),
 | 
					                      ),
 | 
				
			||||||
                      Row(
 | 
					                      Row(
 | 
				
			||||||
@@ -475,6 +542,44 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
				
			|||||||
                          ]),
 | 
					                          ]),
 | 
				
			||||||
                        ],
 | 
					                        ],
 | 
				
			||||||
                      ),
 | 
					                      ),
 | 
				
			||||||
 | 
					                      if (_account!.profile!.gender.isNotEmpty ||
 | 
				
			||||||
 | 
					                          _account!.profile!.pronouns.isNotEmpty)
 | 
				
			||||||
 | 
					                        Row(
 | 
				
			||||||
 | 
					                          crossAxisAlignment: CrossAxisAlignment.center,
 | 
				
			||||||
 | 
					                          children: [
 | 
				
			||||||
 | 
					                            const Icon(Symbols.wc),
 | 
				
			||||||
 | 
					                            const Gap(8),
 | 
				
			||||||
 | 
					                            Text(
 | 
				
			||||||
 | 
					                              _account!.profile!.gender.isNotEmpty
 | 
				
			||||||
 | 
					                                  ? _account!.profile!.gender
 | 
				
			||||||
 | 
					                                  : 'unknown'.tr(),
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                            Text(' · ').padding(horizontal: 4),
 | 
				
			||||||
 | 
					                            Text(
 | 
				
			||||||
 | 
					                              _account!.profile!.pronouns.isNotEmpty
 | 
				
			||||||
 | 
					                                  ? _account!.profile!.pronouns
 | 
				
			||||||
 | 
					                                  : 'unknown'.tr(),
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                          ],
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      if (_account!.profile!.timeZone.isNotEmpty)
 | 
				
			||||||
 | 
					                        Row(
 | 
				
			||||||
 | 
					                          crossAxisAlignment: CrossAxisAlignment.center,
 | 
				
			||||||
 | 
					                          children: [
 | 
				
			||||||
 | 
					                            const Icon(Symbols.schedule),
 | 
				
			||||||
 | 
					                            const Gap(8),
 | 
				
			||||||
 | 
					                            Text(_account!.profile!.timeZone),
 | 
				
			||||||
 | 
					                          ],
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      if (_account!.profile!.location.isNotEmpty)
 | 
				
			||||||
 | 
					                        Row(
 | 
				
			||||||
 | 
					                          crossAxisAlignment: CrossAxisAlignment.center,
 | 
				
			||||||
 | 
					                          children: [
 | 
				
			||||||
 | 
					                            const Icon(Symbols.location_on),
 | 
				
			||||||
 | 
					                            const Gap(8),
 | 
				
			||||||
 | 
					                            Text(_account!.profile!.location),
 | 
				
			||||||
 | 
					                          ],
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
                      Row(
 | 
					                      Row(
 | 
				
			||||||
                        crossAxisAlignment: CrossAxisAlignment.center,
 | 
					                        crossAxisAlignment: CrossAxisAlignment.center,
 | 
				
			||||||
                        children: [
 | 
					                        children: [
 | 
				
			||||||
@@ -491,17 +596,24 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
				
			|||||||
                        children: [
 | 
					                        children: [
 | 
				
			||||||
                          const Icon(Symbols.star),
 | 
					                          const Icon(Symbols.star),
 | 
				
			||||||
                          const Gap(8),
 | 
					                          const Gap(8),
 | 
				
			||||||
                          Text('Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'),
 | 
					                          Text(
 | 
				
			||||||
 | 
					                              'Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'),
 | 
				
			||||||
                          const Gap(8),
 | 
					                          const Gap(8),
 | 
				
			||||||
                          Text(calcLevelUpProgressLevel(_account?.profile?.experience ?? 0)).fontSize(11).opacity(0.5),
 | 
					                          Text(calcLevelUpProgressLevel(
 | 
				
			||||||
 | 
					                                  _account?.profile?.experience ?? 0))
 | 
				
			||||||
 | 
					                              .fontSize(11)
 | 
				
			||||||
 | 
					                              .opacity(0.5),
 | 
				
			||||||
                          const Gap(8),
 | 
					                          const Gap(8),
 | 
				
			||||||
                          Container(
 | 
					                          Container(
 | 
				
			||||||
                            width: double.infinity,
 | 
					                            width: double.infinity,
 | 
				
			||||||
                            constraints: const BoxConstraints(maxWidth: 160),
 | 
					                            constraints: const BoxConstraints(maxWidth: 160),
 | 
				
			||||||
                            child: LinearProgressIndicator(
 | 
					                            child: LinearProgressIndicator(
 | 
				
			||||||
                              value: calcLevelUpProgress(_account?.profile?.experience ?? 0),
 | 
					                              value: calcLevelUpProgress(
 | 
				
			||||||
 | 
					                                  _account?.profile?.experience ?? 0),
 | 
				
			||||||
                              borderRadius: BorderRadius.circular(8),
 | 
					                              borderRadius: BorderRadius.circular(8),
 | 
				
			||||||
                              backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
 | 
					                              backgroundColor: Theme.of(context)
 | 
				
			||||||
 | 
					                                  .colorScheme
 | 
				
			||||||
 | 
					                                  .surfaceContainer,
 | 
				
			||||||
                            ).alignment(Alignment.centerLeft),
 | 
					                            ).alignment(Alignment.centerLeft),
 | 
				
			||||||
                          ),
 | 
					                          ),
 | 
				
			||||||
                        ],
 | 
					                        ],
 | 
				
			||||||
@@ -511,24 +623,46 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
				
			|||||||
                ],
 | 
					                ],
 | 
				
			||||||
              ).padding(all: 16),
 | 
					              ).padding(all: 16),
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
 | 
					          if (_account?.profile?.links.isNotEmpty ?? false)
 | 
				
			||||||
 | 
					            SliverToBoxAdapter(child: const Divider()),
 | 
				
			||||||
 | 
					          if (_account?.profile?.links.isNotEmpty ?? false)
 | 
				
			||||||
 | 
					            SliverToBoxAdapter(
 | 
				
			||||||
 | 
					              child: Column(
 | 
				
			||||||
 | 
					                mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					                crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                children: _account!.profile!.links.entries.map((ele) {
 | 
				
			||||||
 | 
					                  return ListTile(
 | 
				
			||||||
 | 
					                    leading: const Icon(Symbols.link),
 | 
				
			||||||
 | 
					                    title: Text(ele.key),
 | 
				
			||||||
 | 
					                    subtitle: Text(ele.value),
 | 
				
			||||||
 | 
					                    contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					                    onTap: () {
 | 
				
			||||||
 | 
					                      launchUrlString(ele.value);
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                  );
 | 
				
			||||||
 | 
					                }).toList(),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
          SliverToBoxAdapter(child: const Divider()),
 | 
					          SliverToBoxAdapter(child: const Divider()),
 | 
				
			||||||
          const SliverGap(12),
 | 
					          const SliverGap(12),
 | 
				
			||||||
          SliverToBoxAdapter(
 | 
					          SliverToBoxAdapter(
 | 
				
			||||||
            child: FutureBuilder<List<SnCheckInRecord>>(
 | 
					            child: Builder(
 | 
				
			||||||
              future: _getCheckInRecords(),
 | 
					              builder: (context) {
 | 
				
			||||||
              builder: (context, snapshot) {
 | 
					                if (_records == null) return const SizedBox.shrink();
 | 
				
			||||||
                if (!snapshot.hasData) return const SizedBox.shrink();
 | 
					                if (_records!.length <= 1) {
 | 
				
			||||||
                if (snapshot.data!.length <= 1) {
 | 
					 | 
				
			||||||
                  return Text(
 | 
					                  return Text(
 | 
				
			||||||
                    'accountCheckInNoRecords',
 | 
					                    'accountCheckInNoRecords',
 | 
				
			||||||
                    textAlign: TextAlign.center,
 | 
					                    textAlign: TextAlign.center,
 | 
				
			||||||
                  ).tr().fontWeight(FontWeight.bold).center().padding(horizontal: 20, vertical: 8);
 | 
					                  )
 | 
				
			||||||
 | 
					                      .tr()
 | 
				
			||||||
 | 
					                      .fontWeight(FontWeight.bold)
 | 
				
			||||||
 | 
					                      .center()
 | 
				
			||||||
 | 
					                      .padding(horizontal: 20, vertical: 8);
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
                final records = snapshot.data!;
 | 
					 | 
				
			||||||
                return SizedBox(
 | 
					                return SizedBox(
 | 
				
			||||||
                  width: double.infinity,
 | 
					                  width: double.infinity,
 | 
				
			||||||
                  height: 240,
 | 
					                  height: 240,
 | 
				
			||||||
                  child: CheckInRecordChart(records: records),
 | 
					                  child: CheckInRecordChart(records: _records!),
 | 
				
			||||||
                ).padding(
 | 
					                ).padding(
 | 
				
			||||||
                  right: 24,
 | 
					                  right: 24,
 | 
				
			||||||
                  left: 16,
 | 
					                  left: 16,
 | 
				
			||||||
@@ -540,45 +674,55 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
				
			|||||||
          const SliverGap(12),
 | 
					          const SliverGap(12),
 | 
				
			||||||
          SliverToBoxAdapter(child: const Divider()),
 | 
					          SliverToBoxAdapter(child: const Divider()),
 | 
				
			||||||
          const SliverGap(12),
 | 
					          const SliverGap(12),
 | 
				
			||||||
          SliverToBoxAdapter(
 | 
					          if (_account?.badges.isNotEmpty ?? false)
 | 
				
			||||||
            child: Column(
 | 
					            SliverToBoxAdapter(
 | 
				
			||||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
					              child: Column(
 | 
				
			||||||
              children: [
 | 
					                crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
                Text('accountBadge').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
					                children: [
 | 
				
			||||||
                SizedBox(
 | 
					                  Text('accountBadge')
 | 
				
			||||||
                  height: 80,
 | 
					                      .bold()
 | 
				
			||||||
                  width: double.infinity,
 | 
					                      .fontSize(17)
 | 
				
			||||||
                  child: ListView(
 | 
					                      .tr()
 | 
				
			||||||
                    padding: EdgeInsets.symmetric(horizontal: 8),
 | 
					                      .padding(horizontal: 20, bottom: 4),
 | 
				
			||||||
                    scrollDirection: Axis.horizontal,
 | 
					                  SizedBox(
 | 
				
			||||||
                    children: [
 | 
					                    height: 80,
 | 
				
			||||||
                      for (final badge in _account?.badges ?? [])
 | 
					                    width: double.infinity,
 | 
				
			||||||
                        SizedBox(
 | 
					                    child: ListView(
 | 
				
			||||||
                          width: 280,
 | 
					                      padding: EdgeInsets.symmetric(horizontal: 8),
 | 
				
			||||||
                          child: Card(
 | 
					                      scrollDirection: Axis.horizontal,
 | 
				
			||||||
                            child: ListTile(
 | 
					                      children: [
 | 
				
			||||||
                              leading: Icon(
 | 
					                        for (final badge in _account?.badges ?? [])
 | 
				
			||||||
                                kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark,
 | 
					                          SizedBox(
 | 
				
			||||||
                                color: kBadgesMeta[badge.type]?.$3,
 | 
					                            width: 280,
 | 
				
			||||||
                                fill: 1,
 | 
					                            child: Card(
 | 
				
			||||||
 | 
					                              child: ListTile(
 | 
				
			||||||
 | 
					                                leading: Icon(
 | 
				
			||||||
 | 
					                                  kBadgesMeta[badge.type]?.$2 ??
 | 
				
			||||||
 | 
					                                      Symbols.question_mark,
 | 
				
			||||||
 | 
					                                  color: badge.metadata['color'] != null
 | 
				
			||||||
 | 
					                                      ? HexColor.fromHex(
 | 
				
			||||||
 | 
					                                          badge.metadata['color']!)
 | 
				
			||||||
 | 
					                                      : kBadgesMeta[badge.type]?.$3,
 | 
				
			||||||
 | 
					                                  fill: 1,
 | 
				
			||||||
 | 
					                                ),
 | 
				
			||||||
 | 
					                                title: Text(
 | 
				
			||||||
 | 
					                                  kBadgesMeta[badge.type]?.$1 ?? 'unknown',
 | 
				
			||||||
 | 
					                                ).tr(),
 | 
				
			||||||
 | 
					                                subtitle: badge.metadata['title'] != null
 | 
				
			||||||
 | 
					                                    ? Text(badge.metadata['title'])
 | 
				
			||||||
 | 
					                                    : Text(
 | 
				
			||||||
 | 
					                                        DateFormat('y/M/d')
 | 
				
			||||||
 | 
					                                            .format(badge.createdAt),
 | 
				
			||||||
 | 
					                                      ),
 | 
				
			||||||
                              ),
 | 
					                              ),
 | 
				
			||||||
                              title: Text(
 | 
					 | 
				
			||||||
                                kBadgesMeta[badge.type]?.$1 ?? 'unknown',
 | 
					 | 
				
			||||||
                              ).tr(),
 | 
					 | 
				
			||||||
                              subtitle: badge.metadata['title'] != null
 | 
					 | 
				
			||||||
                                  ? Text(badge.metadata['title'])
 | 
					 | 
				
			||||||
                                  : Text(
 | 
					 | 
				
			||||||
                                      DateFormat('y/M/d').format(badge.createdAt),
 | 
					 | 
				
			||||||
                                    ),
 | 
					 | 
				
			||||||
                            ),
 | 
					                            ),
 | 
				
			||||||
                          ),
 | 
					                          ),
 | 
				
			||||||
                        ),
 | 
					                      ],
 | 
				
			||||||
                    ],
 | 
					                    ),
 | 
				
			||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
                ),
 | 
					                ],
 | 
				
			||||||
              ],
 | 
					              ),
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
          ),
 | 
					 | 
				
			||||||
          const SliverGap(8),
 | 
					          const SliverGap(8),
 | 
				
			||||||
          SliverToBoxAdapter(child: const Divider()),
 | 
					          SliverToBoxAdapter(child: const Divider()),
 | 
				
			||||||
          SliverList.builder(
 | 
					          SliverList.builder(
 | 
				
			||||||
@@ -664,7 +808,8 @@ class CheckInRecordChart extends StatelessWidget {
 | 
				
			|||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
                .toList(),
 | 
					                .toList(),
 | 
				
			||||||
            getTooltipColor: (_) => Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
					            getTooltipColor: (_) =>
 | 
				
			||||||
 | 
					                Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
				
			||||||
          ),
 | 
					          ),
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        titlesData: FlTitlesData(
 | 
					        titlesData: FlTitlesData(
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -68,16 +68,19 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
 | 
				
			|||||||
    setState(() => _isBusy = true);
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      await sn.client.put('/cgi/co/publishers/${widget.name}', data: {
 | 
					      await sn.client.put(
 | 
				
			||||||
        'avatar': _avatar,
 | 
					        '/cgi/co/publishers/${widget.name}',
 | 
				
			||||||
        'banner': _banner,
 | 
					        data: {
 | 
				
			||||||
        'nick': _nickController.text,
 | 
					          'avatar': _avatar,
 | 
				
			||||||
        'name': _nameController.text,
 | 
					          'banner': _banner,
 | 
				
			||||||
        'description': _descriptionController.text,
 | 
					          'nick': _nickController.text,
 | 
				
			||||||
      });
 | 
					          'name': _nameController.text,
 | 
				
			||||||
 | 
					          'description': _descriptionController.text,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
      if (mounted) Navigator.pop(context, true);
 | 
					      if (mounted) Navigator.pop(context, true);
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      if(mounted) context.showErrorDialog(err);
 | 
					      if (mounted) context.showErrorDialog(err);
 | 
				
			||||||
    } finally {
 | 
					    } finally {
 | 
				
			||||||
      setState(() => _isBusy = false);
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -97,7 +100,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
 | 
				
			|||||||
    _banner = ua.user!.banner;
 | 
					    _banner = ua.user!.banner;
 | 
				
			||||||
    _nickController.text = ua.user!.nick;
 | 
					    _nickController.text = ua.user!.nick;
 | 
				
			||||||
    _nameController.text = ua.user!.name;
 | 
					    _nameController.text = ua.user!.name;
 | 
				
			||||||
    _descriptionController.text = ua.user!.description;
 | 
					    _descriptionController.text = ua.user!.profile!.description;
 | 
				
			||||||
    setState(() {});
 | 
					    setState(() {});
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -108,32 +111,42 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
 | 
				
			|||||||
    if (image == null) return;
 | 
					    if (image == null) return;
 | 
				
			||||||
    if (!mounted) return;
 | 
					    if (!mounted) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
 | 
					    final skipCrop = image.path.endsWith('.gif');
 | 
				
			||||||
    final aspectRatios =
 | 
					 | 
				
			||||||
        place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
 | 
					 | 
				
			||||||
    final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
 | 
					 | 
				
			||||||
        ? await showCupertinoImageCropper(
 | 
					 | 
				
			||||||
            // ignore: use_build_context_synchronously
 | 
					 | 
				
			||||||
            context,
 | 
					 | 
				
			||||||
            allowedAspectRatios: aspectRatios,
 | 
					 | 
				
			||||||
            imageProvider: imageProvider,
 | 
					 | 
				
			||||||
          )
 | 
					 | 
				
			||||||
        : await showMaterialImageCropper(
 | 
					 | 
				
			||||||
            // ignore: use_build_context_synchronously
 | 
					 | 
				
			||||||
            context,
 | 
					 | 
				
			||||||
            allowedAspectRatios: aspectRatios,
 | 
					 | 
				
			||||||
            imageProvider: imageProvider,
 | 
					 | 
				
			||||||
          );
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (result == null) return;
 | 
					    Uint8List? rawBytes;
 | 
				
			||||||
 | 
					    if (!skipCrop) {
 | 
				
			||||||
 | 
					      final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
 | 
				
			||||||
 | 
					      final aspectRatios =
 | 
				
			||||||
 | 
					          place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
 | 
				
			||||||
 | 
					      final result =
 | 
				
			||||||
 | 
					          (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
 | 
				
			||||||
 | 
					              ? await showCupertinoImageCropper(
 | 
				
			||||||
 | 
					                // ignore: use_build_context_synchronously
 | 
				
			||||||
 | 
					                context,
 | 
				
			||||||
 | 
					                allowedAspectRatios: aspectRatios,
 | 
				
			||||||
 | 
					                imageProvider: imageProvider,
 | 
				
			||||||
 | 
					              )
 | 
				
			||||||
 | 
					              : await showMaterialImageCropper(
 | 
				
			||||||
 | 
					                // ignore: use_build_context_synchronously
 | 
				
			||||||
 | 
					                context,
 | 
				
			||||||
 | 
					                allowedAspectRatios: aspectRatios,
 | 
				
			||||||
 | 
					                imageProvider: imageProvider,
 | 
				
			||||||
 | 
					              );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (result == null) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					      rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					      rawBytes = await image.readAsBytes();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!mounted) return;
 | 
					    if (!mounted) return;
 | 
				
			||||||
    final attach = context.read<SnAttachmentProvider>();
 | 
					    final attach = context.read<SnAttachmentProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    setState(() => _isBusy = true);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      final attachment = await attach.directUploadOne(
 | 
					      final attachment = await attach.directUploadOne(
 | 
				
			||||||
        rawBytes,
 | 
					        rawBytes,
 | 
				
			||||||
@@ -178,6 +191,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
 | 
				
			|||||||
    final sn = context.read<SnNetworkProvider>();
 | 
					    final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return AppScaffold(
 | 
					    return AppScaffold(
 | 
				
			||||||
 | 
					      appBar: AppBar(leading: PageBackButton(), title: Text('screenAccountPublisherEdit').tr()),
 | 
				
			||||||
      body: SingleChildScrollView(
 | 
					      body: SingleChildScrollView(
 | 
				
			||||||
        child: Column(
 | 
					        child: Column(
 | 
				
			||||||
          children: [
 | 
					          children: [
 | 
				
			||||||
@@ -195,12 +209,10 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
 | 
				
			|||||||
                        aspectRatio: 16 / 9,
 | 
					                        aspectRatio: 16 / 9,
 | 
				
			||||||
                        child: Container(
 | 
					                        child: Container(
 | 
				
			||||||
                          color: Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
					                          color: Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
				
			||||||
                          child: _banner != null
 | 
					                          child:
 | 
				
			||||||
                              ? AutoResizeUniversalImage(
 | 
					                              _banner != null
 | 
				
			||||||
                                  sn.getAttachmentUrl(_banner!),
 | 
					                                  ? AutoResizeUniversalImage(sn.getAttachmentUrl(_banner!), fit: BoxFit.cover)
 | 
				
			||||||
                                  fit: BoxFit.cover,
 | 
					                                  : const SizedBox.shrink(),
 | 
				
			||||||
                                )
 | 
					 | 
				
			||||||
                              : const SizedBox.shrink(),
 | 
					 | 
				
			||||||
                        ),
 | 
					                        ),
 | 
				
			||||||
                      ),
 | 
					                      ),
 | 
				
			||||||
                    ),
 | 
					                    ),
 | 
				
			||||||
@@ -238,9 +250,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
 | 
				
			|||||||
            const Gap(4),
 | 
					            const Gap(4),
 | 
				
			||||||
            TextField(
 | 
					            TextField(
 | 
				
			||||||
              controller: _nickController,
 | 
					              controller: _nickController,
 | 
				
			||||||
              decoration: InputDecoration(
 | 
					              decoration: InputDecoration(labelText: 'fieldNickname'.tr()),
 | 
				
			||||||
                labelText: 'fieldNickname'.tr(),
 | 
					 | 
				
			||||||
              ),
 | 
					 | 
				
			||||||
              onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
					              onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
            const Gap(4),
 | 
					            const Gap(4),
 | 
				
			||||||
@@ -248,9 +258,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
 | 
				
			|||||||
              controller: _descriptionController,
 | 
					              controller: _descriptionController,
 | 
				
			||||||
              maxLines: null,
 | 
					              maxLines: null,
 | 
				
			||||||
              minLines: 3,
 | 
					              minLines: 3,
 | 
				
			||||||
              decoration: InputDecoration(
 | 
					              decoration: InputDecoration(labelText: 'fieldDescription'.tr()),
 | 
				
			||||||
                labelText: 'fieldDescription'.tr(),
 | 
					 | 
				
			||||||
              ),
 | 
					 | 
				
			||||||
              onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
					              onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
            const Gap(12),
 | 
					            const Gap(12),
 | 
				
			||||||
@@ -271,7 +279,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
 | 
				
			|||||||
                  icon: const Icon(Symbols.save),
 | 
					                  icon: const Icon(Symbols.save),
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
              ],
 | 
					              ],
 | 
				
			||||||
            )
 | 
					            ),
 | 
				
			||||||
          ],
 | 
					          ],
 | 
				
			||||||
        ).padding(horizontal: 24, vertical: 12),
 | 
					        ).padding(horizontal: 24, vertical: 12),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -109,7 +109,7 @@ class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    _nameController.text = ua.user!.name;
 | 
					    _nameController.text = ua.user!.name;
 | 
				
			||||||
    _nickController.text = ua.user!.nick;
 | 
					    _nickController.text = ua.user!.nick;
 | 
				
			||||||
    _descriptionController.text = ua.user!.description;
 | 
					    _descriptionController.text = ua.user!.profile!.description;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -45,6 +45,33 @@ class _PublisherScreenState extends State<PublisherScreen> {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _deletePublisher(SnPublisher publisher) async {
 | 
				
			||||||
 | 
					    final confirm = await context.showConfirmDialog(
 | 
				
			||||||
 | 
					      'publisherDelete'.tr(args: ['#${publisher.name}']),
 | 
				
			||||||
 | 
					      'publisherDeleteDescription'.tr(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    if (!confirm) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!mounted) return;
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await context
 | 
				
			||||||
 | 
					          .read<SnNetworkProvider>()
 | 
				
			||||||
 | 
					          .client
 | 
				
			||||||
 | 
					          .delete('/cgi/co/publishers/${publisher.name}');
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showSnackbar('publisherDeleted'.tr(args: ['#${publisher.name}']));
 | 
				
			||||||
 | 
					      _publishers.remove(publisher);
 | 
				
			||||||
 | 
					      _fetchPublishers();
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  void initState() {
 | 
					  void initState() {
 | 
				
			||||||
    super.initState();
 | 
					    super.initState();
 | 
				
			||||||
@@ -118,6 +145,18 @@ class _PublisherScreenState extends State<PublisherScreen> {
 | 
				
			|||||||
                              });
 | 
					                              });
 | 
				
			||||||
                            },
 | 
					                            },
 | 
				
			||||||
                          ),
 | 
					                          ),
 | 
				
			||||||
 | 
					                          PopupMenuItem(
 | 
				
			||||||
 | 
					                            child: Row(
 | 
				
			||||||
 | 
					                              children: [
 | 
				
			||||||
 | 
					                                const Icon(Symbols.delete),
 | 
				
			||||||
 | 
					                                const Gap(16),
 | 
				
			||||||
 | 
					                                Text('delete').tr(),
 | 
				
			||||||
 | 
					                              ],
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                            onTap: () {
 | 
				
			||||||
 | 
					                              _deletePublisher(publisher);
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
                        ],
 | 
					                        ],
 | 
				
			||||||
                      ),
 | 
					                      ),
 | 
				
			||||||
                    );
 | 
					                    );
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,6 +2,9 @@ import 'package:dismissible_page/dismissible_page.dart';
 | 
				
			|||||||
import 'package:easy_localization/easy_localization.dart';
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
 | 
					import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
 | 
				
			||||||
 | 
					import 'package:gap/gap.dart';
 | 
				
			||||||
 | 
					import 'package:google_fonts/google_fonts.dart';
 | 
				
			||||||
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
import 'package:styled_widget/styled_widget.dart';
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
@@ -27,9 +30,23 @@ class _AlbumScreenState extends State<AlbumScreen> {
 | 
				
			|||||||
  bool _isBusy = false;
 | 
					  bool _isBusy = false;
 | 
				
			||||||
  int? _totalCount;
 | 
					  int? _totalCount;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  SnAttachmentBilling? _billing;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  final List<SnAttachment> _attachments = List.empty(growable: true);
 | 
					  final List<SnAttachment> _attachments = List.empty(growable: true);
 | 
				
			||||||
  final List<String> _heroTags = List.empty(growable: true);
 | 
					  final List<String> _heroTags = List.empty(growable: true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchBillingStatus() async {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get('/cgi/uc/billing');
 | 
				
			||||||
 | 
					      final out = SnAttachmentBilling.fromJson(resp.data);
 | 
				
			||||||
 | 
					      setState(() => _billing = out);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> _fetchAttachments() async {
 | 
					  Future<void> _fetchAttachments() async {
 | 
				
			||||||
    setState(() => _isBusy = true);
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -62,6 +79,7 @@ class _AlbumScreenState extends State<AlbumScreen> {
 | 
				
			|||||||
  @override
 | 
					  @override
 | 
				
			||||||
  void initState() {
 | 
					  void initState() {
 | 
				
			||||||
    super.initState();
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _fetchBillingStatus();
 | 
				
			||||||
    _fetchAttachments();
 | 
					    _fetchAttachments();
 | 
				
			||||||
    _scrollController.addListener(() {
 | 
					    _scrollController.addListener(() {
 | 
				
			||||||
      if (_scrollController.position.atEdge) {
 | 
					      if (_scrollController.position.atEdge) {
 | 
				
			||||||
@@ -91,6 +109,48 @@ class _AlbumScreenState extends State<AlbumScreen> {
 | 
				
			|||||||
            leading: AutoAppBarLeading(),
 | 
					            leading: AutoAppBarLeading(),
 | 
				
			||||||
            title: Text('screenAlbum').tr(),
 | 
					            title: Text('screenAlbum').tr(),
 | 
				
			||||||
          ),
 | 
					          ),
 | 
				
			||||||
 | 
					          SliverToBoxAdapter(
 | 
				
			||||||
 | 
					            child: Card(
 | 
				
			||||||
 | 
					              child: Row(
 | 
				
			||||||
 | 
					                children: [
 | 
				
			||||||
 | 
					                  SizedBox(
 | 
				
			||||||
 | 
					                    width: 80,
 | 
				
			||||||
 | 
					                    height: 80,
 | 
				
			||||||
 | 
					                    child: CircularProgressIndicator(
 | 
				
			||||||
 | 
					                      value: _billing?.includedRatio ?? 0,
 | 
				
			||||||
 | 
					                      strokeWidth: 8,
 | 
				
			||||||
 | 
					                      backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ).padding(all: 12),
 | 
				
			||||||
 | 
					                  const Gap(24),
 | 
				
			||||||
 | 
					                  Expanded(
 | 
				
			||||||
 | 
					                    child: Column(
 | 
				
			||||||
 | 
					                      crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                      children: [
 | 
				
			||||||
 | 
					                        Text('attachmentBillingUploaded').tr().bold(),
 | 
				
			||||||
 | 
					                        Text(
 | 
				
			||||||
 | 
					                          (_billing?.currentBytes ?? 0).formatBytes(decimals: 4),
 | 
				
			||||||
 | 
					                          style: GoogleFonts.robotoMono(),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                        Text('attachmentBillingDiscount').tr().bold(),
 | 
				
			||||||
 | 
					                        Text(
 | 
				
			||||||
 | 
					                          '${(_billing?.discountFileSize ?? 0).formatBytes(decimals: 2)} · ${((_billing?.includedRatio ?? 0) * 100).toStringAsFixed(2)}%',
 | 
				
			||||||
 | 
					                          style: GoogleFonts.robotoMono(),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      ],
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                  Tooltip(
 | 
				
			||||||
 | 
					                    message: 'attachmentBillingHint'.tr(),
 | 
				
			||||||
 | 
					                    child: IconButton(
 | 
				
			||||||
 | 
					                      icon: const Icon(Symbols.info),
 | 
				
			||||||
 | 
					                      onPressed: () {},
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					              ).padding(horizontal: 24, vertical: 8),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
          SliverMasonryGrid.extent(
 | 
					          SliverMasonryGrid.extent(
 | 
				
			||||||
            childCount: _attachments.length,
 | 
					            childCount: _attachments.length,
 | 
				
			||||||
            maxCrossAxisExtent: 320,
 | 
					            maxCrossAxisExtent: 320,
 | 
				
			||||||
@@ -123,8 +183,10 @@ class _AlbumScreenState extends State<AlbumScreen> {
 | 
				
			|||||||
          ),
 | 
					          ),
 | 
				
			||||||
          if (_isBusy)
 | 
					          if (_isBusy)
 | 
				
			||||||
            SliverToBoxAdapter(
 | 
					            SliverToBoxAdapter(
 | 
				
			||||||
              child:
 | 
					              child: Padding(
 | 
				
			||||||
                  const CircularProgressIndicator().padding(all: 24).center(),
 | 
					                padding: const EdgeInsets.all(24),
 | 
				
			||||||
 | 
					                child: const CircularProgressIndicator(),
 | 
				
			||||||
 | 
					              ).center(),
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,23 +3,26 @@ import 'package:flutter/material.dart';
 | 
				
			|||||||
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
 | 
					import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
 | 
				
			||||||
import 'package:gap/gap.dart';
 | 
					import 'package:gap/gap.dart';
 | 
				
			||||||
import 'package:go_router/go_router.dart';
 | 
					import 'package:go_router/go_router.dart';
 | 
				
			||||||
 | 
					import 'package:google_fonts/google_fonts.dart';
 | 
				
			||||||
import 'package:material_symbols_icons/symbols.dart';
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:responsive_framework/responsive_framework.dart';
 | 
				
			||||||
import 'package:surface/providers/channel.dart';
 | 
					import 'package:surface/providers/channel.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/providers/user_directory.dart';
 | 
					import 'package:surface/providers/user_directory.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/userinfo.dart';
 | 
				
			||||||
 | 
					import 'package:surface/screens/chat/room.dart';
 | 
				
			||||||
import 'package:surface/types/chat.dart';
 | 
					import 'package:surface/types/chat.dart';
 | 
				
			||||||
import 'package:surface/widgets/account/account_image.dart';
 | 
					import 'package:surface/widgets/account/account_image.dart';
 | 
				
			||||||
import 'package:surface/widgets/account/account_select.dart';
 | 
					import 'package:surface/widgets/account/account_select.dart';
 | 
				
			||||||
import 'package:surface/widgets/app_bar_leading.dart';
 | 
					import 'package:surface/widgets/app_bar_leading.dart';
 | 
				
			||||||
import 'package:surface/widgets/dialog.dart';
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
					import 'package:surface/widgets/loading_indicator.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/navigation/app_background.dart';
 | 
				
			||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
					import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
				
			||||||
import 'package:surface/widgets/unauthorized_hint.dart';
 | 
					import 'package:surface/widgets/unauthorized_hint.dart';
 | 
				
			||||||
import 'package:uuid/uuid.dart';
 | 
					import 'package:uuid/uuid.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import '../providers/sn_network.dart';
 | 
					 | 
				
			||||||
import '../providers/userinfo.dart';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class ChatScreen extends StatefulWidget {
 | 
					class ChatScreen extends StatefulWidget {
 | 
				
			||||||
  const ChatScreen({super.key});
 | 
					  const ChatScreen({super.key});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -34,8 +37,19 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  List<SnChannel>? _channels;
 | 
					  List<SnChannel>? _channels;
 | 
				
			||||||
  Map<int, SnChatMessage>? _lastMessages;
 | 
					  Map<int, SnChatMessage>? _lastMessages;
 | 
				
			||||||
 | 
					  Map<int, int>? _unreadCounts;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void _refreshChannels() {
 | 
					  Future<void> _fetchWhatsNew() async {
 | 
				
			||||||
 | 
					    final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					    final resp = await sn.client.get('/cgi/im/whats-new');
 | 
				
			||||||
 | 
					    if (resp.data == null) return;
 | 
				
			||||||
 | 
					    final List<dynamic> out = resp.data;
 | 
				
			||||||
 | 
					    setState(() {
 | 
				
			||||||
 | 
					      _unreadCounts = {for (var v in out) v['channel_id']: v['count']};
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _refreshChannels({bool noRemote = false}) {
 | 
				
			||||||
    final ua = context.read<UserProvider>();
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
    if (!ua.isAuthorized) {
 | 
					    if (!ua.isAuthorized) {
 | 
				
			||||||
      setState(() => _isBusy = false);
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
@@ -43,12 +57,15 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final chan = context.read<ChatChannelProvider>();
 | 
					    final chan = context.read<ChatChannelProvider>();
 | 
				
			||||||
    chan.fetchChannels().listen((channels) async {
 | 
					    chan.fetchChannels(noRemote: noRemote).listen((channels) async {
 | 
				
			||||||
      final lastMessages = await chan.getLastMessages(channels);
 | 
					      final lastMessages = await chan.getLastMessages(channels);
 | 
				
			||||||
      _lastMessages = {for (final val in lastMessages) val.channelId: val};
 | 
					      _lastMessages = {for (final val in lastMessages) val.channelId: val};
 | 
				
			||||||
      channels.sort((a, b) {
 | 
					      channels.sort((a, b) {
 | 
				
			||||||
        if (_lastMessages!.containsKey(a.id) && _lastMessages!.containsKey(b.id)) {
 | 
					        if (_lastMessages!.containsKey(a.id) &&
 | 
				
			||||||
          return _lastMessages![b.id]!.createdAt.compareTo(_lastMessages![a.id]!.createdAt);
 | 
					            _lastMessages!.containsKey(b.id)) {
 | 
				
			||||||
 | 
					          return _lastMessages![b.id]!
 | 
				
			||||||
 | 
					              .createdAt
 | 
				
			||||||
 | 
					              .compareTo(_lastMessages![a.id]!.createdAt);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        if (_lastMessages!.containsKey(a.id)) return -1;
 | 
					        if (_lastMessages!.containsKey(a.id)) return -1;
 | 
				
			||||||
        if (_lastMessages!.containsKey(b.id)) return 1;
 | 
					        if (_lastMessages!.containsKey(b.id)) return 1;
 | 
				
			||||||
@@ -57,18 +74,20 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      final ud = context.read<UserDirectoryProvider>();
 | 
					      final ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
 | 
					      final idSet = <int>{};
 | 
				
			||||||
      for (final channel in channels) {
 | 
					      for (final channel in channels) {
 | 
				
			||||||
        if (channel.type == 1) {
 | 
					        if (channel.type == 1) {
 | 
				
			||||||
          await ud.listAccount(
 | 
					          idSet.addAll(
 | 
				
			||||||
            channel.members
 | 
					            channel.members
 | 
				
			||||||
                    ?.cast<SnChannelMember?>()
 | 
					                    ?.cast<SnChannelMember?>()
 | 
				
			||||||
                    .map((ele) => ele?.accountId)
 | 
					                    .map((ele) => ele?.accountId)
 | 
				
			||||||
                    .where((ele) => ele != null)
 | 
					                    .where((ele) => ele != null)
 | 
				
			||||||
                    .toSet() ??
 | 
					                    .cast<int>() ??
 | 
				
			||||||
                {},
 | 
					                [],
 | 
				
			||||||
          );
 | 
					          );
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					      if (idSet.isNotEmpty) await ud.listAccount(idSet);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (mounted) setState(() => _channels = channels);
 | 
					      if (mounted) setState(() => _channels = channels);
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
@@ -86,7 +105,8 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
				
			|||||||
  void _newDirectMessage() async {
 | 
					  void _newDirectMessage() async {
 | 
				
			||||||
    final user = await showModalBottomSheet(
 | 
					    final user = await showModalBottomSheet(
 | 
				
			||||||
      context: context,
 | 
					      context: context,
 | 
				
			||||||
      builder: (context) => AccountSelect(title: 'channelNewDirectMessage'.tr()),
 | 
					      builder: (context) =>
 | 
				
			||||||
 | 
					          AccountSelect(title: 'channelNewDirectMessage'.tr()),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
    if (user == null) return;
 | 
					    if (user == null) return;
 | 
				
			||||||
    if (!mounted) return;
 | 
					    if (!mounted) return;
 | 
				
			||||||
@@ -98,7 +118,8 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
				
			|||||||
      await sn.client.post('/cgi/im/channels/global/dm', data: {
 | 
					      await sn.client.post('/cgi/im/channels/global/dm', data: {
 | 
				
			||||||
        'alias': uuid.v4().replaceAll('-', '').substring(0, 12),
 | 
					        'alias': uuid.v4().replaceAll('-', '').substring(0, 12),
 | 
				
			||||||
        'name': 'DM',
 | 
					        'name': 'DM',
 | 
				
			||||||
        'description': 'A direct message channel between @${ua.user?.name} and @${user.name}',
 | 
					        'description':
 | 
				
			||||||
 | 
					            'A direct message channel between @${ua.user?.name} and @${user.name}',
 | 
				
			||||||
        'related_user': user.id,
 | 
					        'related_user': user.id,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
      _fabKey.currentState!.toggle();
 | 
					      _fabKey.currentState!.toggle();
 | 
				
			||||||
@@ -109,15 +130,39 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  SnChannel? _focusChannel;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  void initState() {
 | 
					  void initState() {
 | 
				
			||||||
    super.initState();
 | 
					    super.initState();
 | 
				
			||||||
    _refreshChannels();
 | 
					    _refreshChannels();
 | 
				
			||||||
 | 
					    _fetchWhatsNew();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _onTapChannel(SnChannel channel) {
 | 
				
			||||||
 | 
					    final doExpand = ResponsiveBreakpoints.of(context).largerOrEqualTo(DESKTOP);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (doExpand) {
 | 
				
			||||||
 | 
					      setState(() => _focusChannel = channel);
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    GoRouter.of(context).pushNamed(
 | 
				
			||||||
 | 
					      'chatRoom',
 | 
				
			||||||
 | 
					      pathParameters: {
 | 
				
			||||||
 | 
					        'scope': channel.realm?.alias ?? 'global',
 | 
				
			||||||
 | 
					        'alias': channel.alias,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    ).then((value) {
 | 
				
			||||||
 | 
					      if (mounted) {
 | 
				
			||||||
 | 
					        _unreadCounts?[channel.id] = 0;
 | 
				
			||||||
 | 
					        setState(() => _unreadCounts?[channel.id] = 0);
 | 
				
			||||||
 | 
					        _refreshChannels(noRemote: true);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    final ud = context.read<UserDirectoryProvider>();
 | 
					 | 
				
			||||||
    final ua = context.read<UserProvider>();
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!ua.isAuthorized) {
 | 
					    if (!ua.isAuthorized) {
 | 
				
			||||||
@@ -132,7 +177,10 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
				
			|||||||
      );
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return AppScaffold(
 | 
					    final doExpand = ResponsiveBreakpoints.of(context).largerOrEqualTo(DESKTOP);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final chatList = AppScaffold(
 | 
				
			||||||
 | 
					      noBackground: doExpand,
 | 
				
			||||||
      appBar: AppBar(
 | 
					      appBar: AppBar(
 | 
				
			||||||
        leading: AutoAppBarLeading(),
 | 
					        leading: AutoAppBarLeading(),
 | 
				
			||||||
        title: Text('screenChat').tr(),
 | 
					        title: Text('screenChat').tr(),
 | 
				
			||||||
@@ -144,20 +192,27 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
				
			|||||||
        type: ExpandableFabType.up,
 | 
					        type: ExpandableFabType.up,
 | 
				
			||||||
        childrenAnimation: ExpandableFabAnimation.none,
 | 
					        childrenAnimation: ExpandableFabAnimation.none,
 | 
				
			||||||
        overlayStyle: ExpandableFabOverlayStyle(
 | 
					        overlayStyle: ExpandableFabOverlayStyle(
 | 
				
			||||||
          color: Theme.of(context).colorScheme.surface.withAlpha((255 * 0.5).round()),
 | 
					          color: Theme.of(context)
 | 
				
			||||||
 | 
					              .colorScheme
 | 
				
			||||||
 | 
					              .surface
 | 
				
			||||||
 | 
					              .withAlpha((255 * 0.5).round()),
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        openButtonBuilder: RotateFloatingActionButtonBuilder(
 | 
					        openButtonBuilder: RotateFloatingActionButtonBuilder(
 | 
				
			||||||
          child: const Icon(Symbols.add, size: 28),
 | 
					          child: const Icon(Symbols.add, size: 28),
 | 
				
			||||||
          fabSize: ExpandableFabSize.regular,
 | 
					          fabSize: ExpandableFabSize.regular,
 | 
				
			||||||
          foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
 | 
					          foregroundColor:
 | 
				
			||||||
          backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
 | 
					              Theme.of(context).floatingActionButtonTheme.foregroundColor,
 | 
				
			||||||
 | 
					          backgroundColor:
 | 
				
			||||||
 | 
					              Theme.of(context).floatingActionButtonTheme.backgroundColor,
 | 
				
			||||||
          shape: const CircleBorder(),
 | 
					          shape: const CircleBorder(),
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        closeButtonBuilder: DefaultFloatingActionButtonBuilder(
 | 
					        closeButtonBuilder: DefaultFloatingActionButtonBuilder(
 | 
				
			||||||
          child: const Icon(Symbols.close, size: 28),
 | 
					          child: const Icon(Symbols.close, size: 28),
 | 
				
			||||||
          fabSize: ExpandableFabSize.regular,
 | 
					          fabSize: ExpandableFabSize.regular,
 | 
				
			||||||
          foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
 | 
					          foregroundColor:
 | 
				
			||||||
          backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
 | 
					              Theme.of(context).floatingActionButtonTheme.foregroundColor,
 | 
				
			||||||
 | 
					          backgroundColor:
 | 
				
			||||||
 | 
					              Theme.of(context).floatingActionButtonTheme.backgroundColor,
 | 
				
			||||||
          shape: const CircleBorder(),
 | 
					          shape: const CircleBorder(),
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        children: [
 | 
					        children: [
 | 
				
			||||||
@@ -200,80 +255,27 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
				
			|||||||
              context: context,
 | 
					              context: context,
 | 
				
			||||||
              removeTop: true,
 | 
					              removeTop: true,
 | 
				
			||||||
              child: RefreshIndicator(
 | 
					              child: RefreshIndicator(
 | 
				
			||||||
                onRefresh: () => Future.sync(() => _refreshChannels()),
 | 
					                onRefresh: () => Future.wait([
 | 
				
			||||||
 | 
					                  Future.sync(() => _refreshChannels()),
 | 
				
			||||||
 | 
					                  _fetchWhatsNew(),
 | 
				
			||||||
 | 
					                ]),
 | 
				
			||||||
                child: ListView.builder(
 | 
					                child: ListView.builder(
 | 
				
			||||||
                  itemCount: _channels?.length ?? 0,
 | 
					                  itemCount: _channels?.length ?? 0,
 | 
				
			||||||
                  itemBuilder: (context, idx) {
 | 
					                  itemBuilder: (context, idx) {
 | 
				
			||||||
                    final channel = _channels![idx];
 | 
					                    final channel = _channels![idx];
 | 
				
			||||||
                    final lastMessage = _lastMessages?[channel.id];
 | 
					                    final lastMessage = _lastMessages?[channel.id];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    if (channel.type == 1) {
 | 
					                    return _ChatChannelEntry(
 | 
				
			||||||
                      final otherMember = channel.members?.cast<SnChannelMember?>().firstWhere(
 | 
					                      channel: channel,
 | 
				
			||||||
                            (ele) => ele?.accountId != ua.user?.id,
 | 
					                      lastMessage: lastMessage,
 | 
				
			||||||
                            orElse: () => null,
 | 
					                      unreadCount: _unreadCounts?[channel.id],
 | 
				
			||||||
                          );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                      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: () {
 | 
					                      onTap: () {
 | 
				
			||||||
                        GoRouter.of(context).pushNamed(
 | 
					                        if (doExpand) {
 | 
				
			||||||
                          'chatRoom',
 | 
					                          _unreadCounts?[channel.id] = 0;
 | 
				
			||||||
                          pathParameters: {
 | 
					                          setState(() => _focusChannel = channel);
 | 
				
			||||||
                            'scope': channel.realm?.alias ?? 'global',
 | 
					                          return;
 | 
				
			||||||
                            'alias': channel.alias,
 | 
					                        }
 | 
				
			||||||
                          },
 | 
					                        _onTapChannel(channel);
 | 
				
			||||||
                        ).then((value) {
 | 
					 | 
				
			||||||
                          if (value == true) _refreshChannels();
 | 
					 | 
				
			||||||
                        });
 | 
					 | 
				
			||||||
                      },
 | 
					                      },
 | 
				
			||||||
                    );
 | 
					                    );
 | 
				
			||||||
                  },
 | 
					                  },
 | 
				
			||||||
@@ -284,5 +286,124 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
				
			|||||||
        ],
 | 
					        ],
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (doExpand) {
 | 
				
			||||||
 | 
					      return AppBackground(
 | 
				
			||||||
 | 
					        isRoot: true,
 | 
				
			||||||
 | 
					        child: Row(
 | 
				
			||||||
 | 
					          children: [
 | 
				
			||||||
 | 
					            SizedBox(width: 340, child: chatList),
 | 
				
			||||||
 | 
					            const VerticalDivider(width: 1),
 | 
				
			||||||
 | 
					            if (_focusChannel != null)
 | 
				
			||||||
 | 
					              Expanded(
 | 
				
			||||||
 | 
					                child: ChatRoomScreen(
 | 
				
			||||||
 | 
					                  key: ValueKey(_focusChannel!.id),
 | 
				
			||||||
 | 
					                  scope: _focusChannel!.realm?.alias ?? 'global',
 | 
				
			||||||
 | 
					                  alias: _focusChannel!.alias,
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return chatList;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _ChatChannelEntry extends StatelessWidget {
 | 
				
			||||||
 | 
					  final SnChannel channel;
 | 
				
			||||||
 | 
					  final int? unreadCount;
 | 
				
			||||||
 | 
					  final SnChatMessage? lastMessage;
 | 
				
			||||||
 | 
					  final Function? onTap;
 | 
				
			||||||
 | 
					  const _ChatChannelEntry({
 | 
				
			||||||
 | 
					    required this.channel,
 | 
				
			||||||
 | 
					    this.unreadCount,
 | 
				
			||||||
 | 
					    this.lastMessage,
 | 
				
			||||||
 | 
					    this.onTap,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    final ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final otherMember = channel.type == 1
 | 
				
			||||||
 | 
					        ? channel.members?.cast<SnChannelMember?>().firstWhere(
 | 
				
			||||||
 | 
					              (ele) => ele?.accountId != ua.user?.id,
 | 
				
			||||||
 | 
					              orElse: () => null,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        : null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final title = otherMember != null
 | 
				
			||||||
 | 
					        ? ud.getFromCache(otherMember.accountId)?.nick ?? channel.name
 | 
				
			||||||
 | 
					        : channel.name;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return ListTile(
 | 
				
			||||||
 | 
					      title: Row(
 | 
				
			||||||
 | 
					        children: [
 | 
				
			||||||
 | 
					          Expanded(child: Text(title)),
 | 
				
			||||||
 | 
					          const Gap(8),
 | 
				
			||||||
 | 
					          if (unreadCount != null && unreadCount! > 0)
 | 
				
			||||||
 | 
					            Badge(
 | 
				
			||||||
 | 
					              label: Text(unreadCount.toString()),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      subtitle: lastMessage != null
 | 
				
			||||||
 | 
					          ? Row(
 | 
				
			||||||
 | 
					              children: [
 | 
				
			||||||
 | 
					                Badge(
 | 
				
			||||||
 | 
					                  label: Text(
 | 
				
			||||||
 | 
					                      ud.getFromCache(lastMessage!.sender.accountId)?.nick ??
 | 
				
			||||||
 | 
					                          'unknown'.tr()),
 | 
				
			||||||
 | 
					                  backgroundColor: Theme.of(context).colorScheme.primary,
 | 
				
			||||||
 | 
					                  textColor: Theme.of(context).colorScheme.onPrimary,
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                const Gap(6),
 | 
				
			||||||
 | 
					                Expanded(
 | 
				
			||||||
 | 
					                  child: Text(
 | 
				
			||||||
 | 
					                    lastMessage!.body['algorithm'] == 'plain'
 | 
				
			||||||
 | 
					                        ? lastMessage!.body['text'] ??
 | 
				
			||||||
 | 
					                            'messageUnablePreview'.tr()
 | 
				
			||||||
 | 
					                        : 'messageUnablePreviewEncrypted'.tr(),
 | 
				
			||||||
 | 
					                    maxLines: 1,
 | 
				
			||||||
 | 
					                    overflow: TextOverflow.ellipsis,
 | 
				
			||||||
 | 
					                    style: lastMessage!.body['algorithm'] != 'plain' ||
 | 
				
			||||||
 | 
					                            lastMessage!.body['text'] == null
 | 
				
			||||||
 | 
					                        ? TextStyle(fontStyle: FontStyle.italic)
 | 
				
			||||||
 | 
					                        : null,
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                const Gap(4),
 | 
				
			||||||
 | 
					                Text(
 | 
				
			||||||
 | 
					                  DateFormat(
 | 
				
			||||||
 | 
					                    lastMessage!.createdAt.toLocal().day == DateTime.now().day
 | 
				
			||||||
 | 
					                        ? 'HH:mm'
 | 
				
			||||||
 | 
					                        : lastMessage!.createdAt.toLocal().year ==
 | 
				
			||||||
 | 
					                                DateTime.now().year
 | 
				
			||||||
 | 
					                            ? 'MM/dd'
 | 
				
			||||||
 | 
					                            : 'yy/MM/dd',
 | 
				
			||||||
 | 
					                  ).format(lastMessage!.createdAt.toLocal()),
 | 
				
			||||||
 | 
					                  style: GoogleFonts.robotoMono(
 | 
				
			||||||
 | 
					                    fontSize: 12,
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ],
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					          : Text(
 | 
				
			||||||
 | 
					              channel.description,
 | 
				
			||||||
 | 
					              maxLines: 1,
 | 
				
			||||||
 | 
					              overflow: TextOverflow.ellipsis,
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					      contentPadding: const EdgeInsets.symmetric(horizontal: 16),
 | 
				
			||||||
 | 
					      leading: AccountImage(
 | 
				
			||||||
 | 
					        content: otherMember != null
 | 
				
			||||||
 | 
					            ? ud.getFromCache(otherMember.accountId)?.avatar
 | 
				
			||||||
 | 
					            : channel.realm?.avatar,
 | 
				
			||||||
 | 
					        fallbackWidget: const Icon(Symbols.chat, size: 20),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      onTap: () => onTap?.call(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -57,10 +57,10 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
				
			|||||||
    setState(() => _isBusy = true);
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      final sn = context.read<SnNetworkProvider>();
 | 
					      final ct = context.read<ChatChannelProvider>();
 | 
				
			||||||
      final resp = await sn.client.get('/cgi/im/channels/${_channel!.keyPath}/members/me');
 | 
					      final resp = await ct.getChannelProfile(_channel!);
 | 
				
			||||||
      _profile = SnChannelMember.fromJson(resp.data);
 | 
					      _profile = resp;
 | 
				
			||||||
      _notifyLevel = _profile!.notify;
 | 
					      _notifyLevel = resp.notify;
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      final ud = context.read<UserDirectoryProvider>();
 | 
					      final ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
      await ud.getAccount(_profile!.accountId);
 | 
					      await ud.getAccount(_profile!.accountId);
 | 
				
			||||||
@@ -102,10 +102,12 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
				
			|||||||
    if (!mounted) return;
 | 
					    if (!mounted) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
 | 
					      final ct = context.read<ChatChannelProvider>();
 | 
				
			||||||
      final sn = context.read<SnNetworkProvider>();
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
      await sn.client.delete(
 | 
					      await sn.client.delete(
 | 
				
			||||||
        '/cgi/im/channels/${_channel!.realm?.alias ?? 'global'}/${_channel!.id}/members/me',
 | 
					        '/cgi/im/channels/${_channel!.realm?.alias ?? 'global'}/${_channel!.alias}/me',
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
 | 
					      await ct.removeLocalChannel(_channel!);
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      Navigator.pop(context, false);
 | 
					      Navigator.pop(context, false);
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
@@ -129,12 +131,15 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
				
			|||||||
    setState(() => _isUpdatingNotifyLevel = true);
 | 
					    setState(() => _isUpdatingNotifyLevel = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
 | 
					      final ct = context.read<ChatChannelProvider>();
 | 
				
			||||||
      final sn = context.read<SnNetworkProvider>();
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
      await sn.client.put(
 | 
					      final resp = await sn.client.put(
 | 
				
			||||||
        '/cgi/im/channels/${_channel!.keyPath}/members/me/notify',
 | 
					        '/cgi/im/channels/${_channel!.keyPath}/members/me/notify',
 | 
				
			||||||
        data: {'notify_level': value},
 | 
					        data: {'notify_level': value},
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
 | 
					      _profile = SnChannelMember.fromJson(resp.data);
 | 
				
			||||||
      _notifyLevel = value;
 | 
					      _notifyLevel = value;
 | 
				
			||||||
 | 
					      await ct.updateChannelProfile(_profile!);
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      context.showSnackbar('channelNotifyLevelApplied'.tr());
 | 
					      context.showSnackbar('channelNotifyLevelApplied'.tr());
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
@@ -245,7 +250,11 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
				
			|||||||
              Column(
 | 
					              Column(
 | 
				
			||||||
                crossAxisAlignment: CrossAxisAlignment.start,
 | 
					                crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
                children: [
 | 
					                children: [
 | 
				
			||||||
                  Text('channelDetailPersonalRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
					                  Text('channelDetailPersonalRegion')
 | 
				
			||||||
 | 
					                      .bold()
 | 
				
			||||||
 | 
					                      .fontSize(17)
 | 
				
			||||||
 | 
					                      .tr()
 | 
				
			||||||
 | 
					                      .padding(horizontal: 20, bottom: 4),
 | 
				
			||||||
                  ListTile(
 | 
					                  ListTile(
 | 
				
			||||||
                    leading: const Icon(Symbols.notifications),
 | 
					                    leading: const Icon(Symbols.notifications),
 | 
				
			||||||
                    trailing: DropdownButtonHideUnderline(
 | 
					                    trailing: DropdownButtonHideUnderline(
 | 
				
			||||||
@@ -284,14 +293,14 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
				
			|||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
                  ListTile(
 | 
					                  ListTile(
 | 
				
			||||||
                    leading: AccountImage(
 | 
					                    leading: AccountImage(
 | 
				
			||||||
                      content: ud.getAccountFromCache(_profile!.accountId)?.avatar,
 | 
					                      content: ud.getFromCache(_profile!.accountId)?.avatar,
 | 
				
			||||||
                      radius: 18,
 | 
					                      radius: 18,
 | 
				
			||||||
                    ),
 | 
					                    ),
 | 
				
			||||||
                    trailing: const Icon(Symbols.chevron_right),
 | 
					                    trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
                    title: Text('channelEditProfile').tr(),
 | 
					                    title: Text('channelEditProfile').tr(),
 | 
				
			||||||
                    subtitle: Text(
 | 
					                    subtitle: Text(
 | 
				
			||||||
                      (_profile?.nick?.isEmpty ?? true)
 | 
					                      (_profile?.nick?.isEmpty ?? true)
 | 
				
			||||||
                          ? ud.getAccountFromCache(_profile!.accountId)!.nick
 | 
					                          ? ud.getFromCache(_profile!.accountId)!.nick
 | 
				
			||||||
                          : _profile!.nick!,
 | 
					                          : _profile!.nick!,
 | 
				
			||||||
                    ),
 | 
					                    ),
 | 
				
			||||||
                    contentPadding: const EdgeInsets.only(left: 20, right: 20),
 | 
					                    contentPadding: const EdgeInsets.only(left: 20, right: 20),
 | 
				
			||||||
@@ -303,7 +312,8 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
				
			|||||||
                      trailing: const Icon(Symbols.chevron_right),
 | 
					                      trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
                      title: Text('channelActionLeave').tr(),
 | 
					                      title: Text('channelActionLeave').tr(),
 | 
				
			||||||
                      subtitle: Text('channelActionLeaveDescription').tr(),
 | 
					                      subtitle: Text('channelActionLeaveDescription').tr(),
 | 
				
			||||||
                      contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
					                      contentPadding:
 | 
				
			||||||
 | 
					                          const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
                      onTap: _leaveChannel,
 | 
					                      onTap: _leaveChannel,
 | 
				
			||||||
                    ),
 | 
					                    ),
 | 
				
			||||||
                ],
 | 
					                ],
 | 
				
			||||||
@@ -311,7 +321,11 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
				
			|||||||
            Column(
 | 
					            Column(
 | 
				
			||||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
					              crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
              children: [
 | 
					              children: [
 | 
				
			||||||
                Text('channelDetailMemberRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
					                Text('channelDetailMemberRegion')
 | 
				
			||||||
 | 
					                    .bold()
 | 
				
			||||||
 | 
					                    .fontSize(17)
 | 
				
			||||||
 | 
					                    .tr()
 | 
				
			||||||
 | 
					                    .padding(horizontal: 20, bottom: 4),
 | 
				
			||||||
                ListTile(
 | 
					                ListTile(
 | 
				
			||||||
                  leading: const Icon(Symbols.group),
 | 
					                  leading: const Icon(Symbols.group),
 | 
				
			||||||
                  trailing: const Icon(Symbols.chevron_right),
 | 
					                  trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
@@ -333,7 +347,11 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
				
			|||||||
            Column(
 | 
					            Column(
 | 
				
			||||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
					              crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
              children: [
 | 
					              children: [
 | 
				
			||||||
                Text('channelDetailAdminRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
					                Text('channelDetailAdminRegion')
 | 
				
			||||||
 | 
					                    .bold()
 | 
				
			||||||
 | 
					                    .fontSize(17)
 | 
				
			||||||
 | 
					                    .tr()
 | 
				
			||||||
 | 
					                    .padding(horizontal: 20, bottom: 4),
 | 
				
			||||||
                ListTile(
 | 
					                ListTile(
 | 
				
			||||||
                  leading: const Icon(Symbols.edit),
 | 
					                  leading: const Icon(Symbols.edit),
 | 
				
			||||||
                  trailing: const Icon(Symbols.chevron_right),
 | 
					                  trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
@@ -379,10 +397,12 @@ class _ChannelProfileDetailDialog extends StatefulWidget {
 | 
				
			|||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  State<_ChannelProfileDetailDialog> createState() => _ChannelProfileDetailDialogState();
 | 
					  State<_ChannelProfileDetailDialog> createState() =>
 | 
				
			||||||
 | 
					      _ChannelProfileDetailDialogState();
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class _ChannelProfileDetailDialogState extends State<_ChannelProfileDetailDialog> {
 | 
					class _ChannelProfileDetailDialogState
 | 
				
			||||||
 | 
					    extends State<_ChannelProfileDetailDialog> {
 | 
				
			||||||
  bool _isBusy = false;
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  final TextEditingController _nickController = TextEditingController();
 | 
					  final TextEditingController _nickController = TextEditingController();
 | 
				
			||||||
@@ -391,11 +411,14 @@ class _ChannelProfileDetailDialogState extends State<_ChannelProfileDetailDialog
 | 
				
			|||||||
    setState(() => _isBusy = true);
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
 | 
					      final ct = context.read<ChatChannelProvider>();
 | 
				
			||||||
      final sn = context.read<SnNetworkProvider>();
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
      await sn.client.put(
 | 
					      final resp = await sn.client.put(
 | 
				
			||||||
        '/cgi/im/channels/${widget.channel.keyPath}/members/me',
 | 
					        '/cgi/im/channels/${widget.channel.keyPath}/members/me',
 | 
				
			||||||
        data: {'nick': _nickController.text},
 | 
					        data: {'nick': _nickController.text},
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
 | 
					      final out = SnChannelMember.fromJson(resp.data);
 | 
				
			||||||
 | 
					      await ct.updateChannelProfile(out);
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      Navigator.pop(context, true);
 | 
					      Navigator.pop(context, true);
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
@@ -457,7 +480,8 @@ class _ChannelMemberListWidget extends StatefulWidget {
 | 
				
			|||||||
  const _ChannelMemberListWidget({required this.channel});
 | 
					  const _ChannelMemberListWidget({required this.channel});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  State<_ChannelMemberListWidget> createState() => _ChannelMemberListWidgetState();
 | 
					  State<_ChannelMemberListWidget> createState() =>
 | 
				
			||||||
 | 
					      _ChannelMemberListWidgetState();
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
					class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
				
			||||||
@@ -472,10 +496,12 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
				
			|||||||
    try {
 | 
					    try {
 | 
				
			||||||
      final ud = context.read<UserDirectoryProvider>();
 | 
					      final ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
      final sn = context.read<SnNetworkProvider>();
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
      final resp = await sn.client.get('/cgi/im/channels/${widget.channel.keyPath}/members', queryParameters: {
 | 
					      final resp = await sn.client.get(
 | 
				
			||||||
        'take': 10,
 | 
					          '/cgi/im/channels/${widget.channel.keyPath}/members',
 | 
				
			||||||
        'offset': 0,
 | 
					          queryParameters: {
 | 
				
			||||||
      });
 | 
					            'take': 10,
 | 
				
			||||||
 | 
					            'offset': _members.length,
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
      final out = List<SnChannelMember>.from(
 | 
					      final out = List<SnChannelMember>.from(
 | 
				
			||||||
        resp.data['data']?.map((e) => SnChannelMember.fromJson(e)) ?? [],
 | 
					        resp.data['data']?.map((e) => SnChannelMember.fromJson(e)) ?? [],
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
@@ -533,7 +559,9 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
				
			|||||||
          children: [
 | 
					          children: [
 | 
				
			||||||
            const Icon(Symbols.group, size: 24),
 | 
					            const Icon(Symbols.group, size: 24),
 | 
				
			||||||
            const Gap(16),
 | 
					            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),
 | 
					        ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
				
			||||||
        Expanded(
 | 
					        Expanded(
 | 
				
			||||||
@@ -544,7 +572,8 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
				
			|||||||
            },
 | 
					            },
 | 
				
			||||||
            child: InfiniteList(
 | 
					            child: InfiniteList(
 | 
				
			||||||
              itemCount: _members.length,
 | 
					              itemCount: _members.length,
 | 
				
			||||||
              hasReachedMax: _totalCount != null && _members.length >= _totalCount!,
 | 
					              hasReachedMax:
 | 
				
			||||||
 | 
					                  _totalCount != null && _members.length >= _totalCount!,
 | 
				
			||||||
              isLoading: _isBusy,
 | 
					              isLoading: _isBusy,
 | 
				
			||||||
              onFetchData: _fetchMembers,
 | 
					              onFetchData: _fetchMembers,
 | 
				
			||||||
              itemBuilder: (context, index) {
 | 
					              itemBuilder: (context, index) {
 | 
				
			||||||
@@ -552,10 +581,10 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
				
			|||||||
                return ListTile(
 | 
					                return ListTile(
 | 
				
			||||||
                  contentPadding: const EdgeInsets.only(right: 24, left: 16),
 | 
					                  contentPadding: const EdgeInsets.only(right: 24, left: 16),
 | 
				
			||||||
                  leading: AccountImage(
 | 
					                  leading: AccountImage(
 | 
				
			||||||
                    content: ud.getAccountFromCache(member.accountId)?.avatar,
 | 
					                    content: ud.getFromCache(member.accountId)?.avatar,
 | 
				
			||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
                  title: Text(
 | 
					                  title: Text(
 | 
				
			||||||
                    ud.getAccountFromCache(member.accountId)?.name ?? 'unknown'.tr(),
 | 
					                    ud.getFromCache(member.accountId)?.name ?? 'unknown'.tr(),
 | 
				
			||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
                  subtitle: Text(member.nick ?? 'unknown'.tr()),
 | 
					                  subtitle: Text(member.nick ?? 'unknown'.tr()),
 | 
				
			||||||
                  trailing: SizedBox(
 | 
					                  trailing: SizedBox(
 | 
				
			||||||
@@ -565,7 +594,8 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
				
			|||||||
                      mainAxisAlignment: MainAxisAlignment.end,
 | 
					                      mainAxisAlignment: MainAxisAlignment.end,
 | 
				
			||||||
                      children: [
 | 
					                      children: [
 | 
				
			||||||
                        IconButton(
 | 
					                        IconButton(
 | 
				
			||||||
                          onPressed: _isUpdating ? null : () => _deleteMember(member),
 | 
					                          onPressed:
 | 
				
			||||||
 | 
					                              _isUpdating ? null : () => _deleteMember(member),
 | 
				
			||||||
                          icon: const Icon(Symbols.person_remove),
 | 
					                          icon: const Icon(Symbols.person_remove),
 | 
				
			||||||
                        ),
 | 
					                        ),
 | 
				
			||||||
                      ],
 | 
					                      ],
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -37,6 +37,9 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  SnChannel? _editingChannel;
 | 
					  SnChannel? _editingChannel;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _isPublic = false;
 | 
				
			||||||
 | 
					  bool _isCommunity = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> _fetchRealms() async {
 | 
					  Future<void> _fetchRealms() async {
 | 
				
			||||||
    setState(() => _isBusy = true);
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
@@ -67,6 +70,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
				
			|||||||
      _aliasController.text = _editingChannel!.alias;
 | 
					      _aliasController.text = _editingChannel!.alias;
 | 
				
			||||||
      _nameController.text = _editingChannel!.name;
 | 
					      _nameController.text = _editingChannel!.name;
 | 
				
			||||||
      _descriptionController.text = _editingChannel!.description;
 | 
					      _descriptionController.text = _editingChannel!.description;
 | 
				
			||||||
 | 
					      _isPublic = _editingChannel!.isPublic;
 | 
				
			||||||
 | 
					      _isCommunity = _editingChannel!.isCommunity;
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      context.showErrorDialog(err);
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
@@ -88,6 +93,12 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
				
			|||||||
          : uuid.v4().replaceAll('-', '').substring(0, 12),
 | 
					          : uuid.v4().replaceAll('-', '').substring(0, 12),
 | 
				
			||||||
      'name': _nameController.text,
 | 
					      'name': _nameController.text,
 | 
				
			||||||
      'description': _descriptionController.text,
 | 
					      'description': _descriptionController.text,
 | 
				
			||||||
 | 
					      'is_public': _isPublic,
 | 
				
			||||||
 | 
					      'is_community': _isCommunity,
 | 
				
			||||||
 | 
					      if (_editingChannel != null && _belongToRealm == null)
 | 
				
			||||||
 | 
					        'new_belongs_realm': 'global'
 | 
				
			||||||
 | 
					      else if (_editingChannel != null && _belongToRealm?.id != _editingChannel?.realm?.id)
 | 
				
			||||||
 | 
					        'new_belongs_realm': _belongToRealm!.alias,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
@@ -164,7 +175,6 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
				
			|||||||
                items: [
 | 
					                items: [
 | 
				
			||||||
                  ...(_realms?.map(
 | 
					                  ...(_realms?.map(
 | 
				
			||||||
                        (SnRealm item) => DropdownMenuItem<SnRealm>(
 | 
					                        (SnRealm item) => DropdownMenuItem<SnRealm>(
 | 
				
			||||||
                          enabled: _editingChannel == null || _editingChannel?.realmId == item.id,
 | 
					 | 
				
			||||||
                          value: item,
 | 
					                          value: item,
 | 
				
			||||||
                          child: Row(
 | 
					                          child: Row(
 | 
				
			||||||
                            children: [
 | 
					                            children: [
 | 
				
			||||||
@@ -197,7 +207,6 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
				
			|||||||
                      ) ??
 | 
					                      ) ??
 | 
				
			||||||
                      []),
 | 
					                      []),
 | 
				
			||||||
                  DropdownMenuItem<SnRealm>(
 | 
					                  DropdownMenuItem<SnRealm>(
 | 
				
			||||||
                    enabled: _editingChannel == null,
 | 
					 | 
				
			||||||
                    value: null,
 | 
					                    value: null,
 | 
				
			||||||
                    child: Row(
 | 
					                    child: Row(
 | 
				
			||||||
                      children: [
 | 
					                      children: [
 | 
				
			||||||
@@ -271,6 +280,23 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
				
			|||||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
					                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
                const Gap(12),
 | 
					                const Gap(12),
 | 
				
			||||||
 | 
					                CheckboxListTile(
 | 
				
			||||||
 | 
					                  value: _isPublic,
 | 
				
			||||||
 | 
					                  title: Text('channelIsPublic'.tr()),
 | 
				
			||||||
 | 
					                  subtitle: Text('channelIsPublicDescription'.tr()),
 | 
				
			||||||
 | 
					                  onChanged: (value) {
 | 
				
			||||||
 | 
					                    setState(() => _isPublic = value ?? false);
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                CheckboxListTile(
 | 
				
			||||||
 | 
					                  value: _isCommunity,
 | 
				
			||||||
 | 
					                  title: Text('channelIsCommunity'.tr()),
 | 
				
			||||||
 | 
					                  subtitle: Text('channelIsCommunityDescription'.tr()),
 | 
				
			||||||
 | 
					                  onChanged: (value) {
 | 
				
			||||||
 | 
					                    setState(() => _isCommunity = value ?? false);
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                const Gap(12),
 | 
				
			||||||
                Row(
 | 
					                Row(
 | 
				
			||||||
                  mainAxisAlignment: MainAxisAlignment.end,
 | 
					                  mainAxisAlignment: MainAxisAlignment.end,
 | 
				
			||||||
                  children: [
 | 
					                  children: [
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
import 'dart:async';
 | 
					import 'dart:async';
 | 
				
			||||||
 | 
					import 'dart:convert';
 | 
				
			||||||
import 'dart:developer';
 | 
					import 'dart:developer';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import 'package:dio/dio.dart';
 | 
					import 'package:dio/dio.dart';
 | 
				
			||||||
@@ -13,11 +14,13 @@ import 'package:surface/controllers/chat_message_controller.dart';
 | 
				
			|||||||
import 'package:surface/controllers/post_write_controller.dart';
 | 
					import 'package:surface/controllers/post_write_controller.dart';
 | 
				
			||||||
import 'package:surface/providers/channel.dart';
 | 
					import 'package:surface/providers/channel.dart';
 | 
				
			||||||
import 'package:surface/providers/chat_call.dart';
 | 
					import 'package:surface/providers/chat_call.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/notification.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/providers/user_directory.dart';
 | 
					import 'package:surface/providers/user_directory.dart';
 | 
				
			||||||
import 'package:surface/providers/userinfo.dart';
 | 
					import 'package:surface/providers/userinfo.dart';
 | 
				
			||||||
import 'package:surface/providers/websocket.dart';
 | 
					import 'package:surface/providers/websocket.dart';
 | 
				
			||||||
import 'package:surface/types/chat.dart';
 | 
					import 'package:surface/types/chat.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/websocket.dart';
 | 
				
			||||||
import 'package:surface/widgets/chat/call/call_prejoin.dart';
 | 
					import 'package:surface/widgets/chat/call/call_prejoin.dart';
 | 
				
			||||||
import 'package:surface/widgets/chat/chat_message.dart';
 | 
					import 'package:surface/widgets/chat/chat_message.dart';
 | 
				
			||||||
import 'package:surface/widgets/chat/chat_message_input.dart';
 | 
					import 'package:surface/widgets/chat/chat_message_input.dart';
 | 
				
			||||||
@@ -39,7 +42,8 @@ class ChatRoomScreen extends StatefulWidget {
 | 
				
			|||||||
  final String alias;
 | 
					  final String alias;
 | 
				
			||||||
  final ChatRoomScreenExtra? extra;
 | 
					  final ChatRoomScreenExtra? extra;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const ChatRoomScreen({super.key, required this.scope, required this.alias, this.extra});
 | 
					  const ChatRoomScreen(
 | 
				
			||||||
 | 
					      {super.key, required this.scope, required this.alias, this.extra});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  State<ChatRoomScreen> createState() => _ChatRoomScreenState();
 | 
					  State<ChatRoomScreen> createState() => _ChatRoomScreenState();
 | 
				
			||||||
@@ -56,8 +60,14 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
				
			|||||||
  final GlobalKey<ChatMessageInputState> _inputGlobalKey = GlobalKey();
 | 
					  final GlobalKey<ChatMessageInputState> _inputGlobalKey = GlobalKey();
 | 
				
			||||||
  late final ChatMessageController _messageController;
 | 
					  late final ChatMessageController _messageController;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  late final NotificationProvider _nty = context.read<NotificationProvider>();
 | 
				
			||||||
 | 
					  late final WebSocketProvider _ws = context.read<WebSocketProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _isEncrypted = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  StreamSubscription? _wsSubscription;
 | 
					  StreamSubscription? _wsSubscription;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // TODO fetch user identity and ask them to join the channel or not
 | 
				
			||||||
  Future<void> _fetchChannel() async {
 | 
					  Future<void> _fetchChannel() async {
 | 
				
			||||||
    setState(() => _isBusy = true);
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -82,6 +92,20 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
				
			|||||||
              orElse: () => null,
 | 
					              orElse: () => null,
 | 
				
			||||||
            );
 | 
					            );
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      _nty.skippableNotifyChannel = _channel!.id;
 | 
				
			||||||
 | 
					      final ws = context.read<WebSocketProvider>();
 | 
				
			||||||
 | 
					      if (_channel != null) {
 | 
				
			||||||
 | 
					        ws.conn?.sink.add(
 | 
				
			||||||
 | 
					          jsonEncode(WebSocketPackage(
 | 
				
			||||||
 | 
					              method: 'events.subscribe',
 | 
				
			||||||
 | 
					              endpoint: 'im',
 | 
				
			||||||
 | 
					              payload: {
 | 
				
			||||||
 | 
					                'channel_id': _channel!.id,
 | 
				
			||||||
 | 
					              })),
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      context.showErrorDialog(err);
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
@@ -191,10 +215,12 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
				
			|||||||
        WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
					        WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
				
			||||||
          log('[ChatInput] Setting initial text and attachments...');
 | 
					          log('[ChatInput] Setting initial text and attachments...');
 | 
				
			||||||
          if (widget.extra!.initialText != null) {
 | 
					          if (widget.extra!.initialText != null) {
 | 
				
			||||||
            _inputGlobalKey.currentState?.setInitialText(widget.extra!.initialText!);
 | 
					            _inputGlobalKey.currentState
 | 
				
			||||||
 | 
					                ?.setInitialText(widget.extra!.initialText!);
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
          if (widget.extra!.initialAttachments != null) {
 | 
					          if (widget.extra!.initialAttachments != null) {
 | 
				
			||||||
            _inputGlobalKey.currentState?.setInitialAttachments(widget.extra!.initialAttachments!);
 | 
					            _inputGlobalKey.currentState
 | 
				
			||||||
 | 
					                ?.setInitialAttachments(widget.extra!.initialAttachments!);
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
@@ -205,8 +231,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
				
			|||||||
      ]);
 | 
					      ]);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final ws = context.read<WebSocketProvider>();
 | 
					    _wsSubscription = _ws.pk.stream.listen((event) {
 | 
				
			||||||
    _wsSubscription = ws.pk.stream.listen((event) {
 | 
					 | 
				
			||||||
      switch (event.method) {
 | 
					      switch (event.method) {
 | 
				
			||||||
        case 'calls.new':
 | 
					        case 'calls.new':
 | 
				
			||||||
          final payload = SnChatCall.fromJson(event.payload!);
 | 
					          final payload = SnChatCall.fromJson(event.payload!);
 | 
				
			||||||
@@ -228,6 +253,18 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
				
			|||||||
  void dispose() {
 | 
					  void dispose() {
 | 
				
			||||||
    _wsSubscription?.cancel();
 | 
					    _wsSubscription?.cancel();
 | 
				
			||||||
    _messageController.dispose();
 | 
					    _messageController.dispose();
 | 
				
			||||||
 | 
					    _nty.skippableNotifyChannel = null;
 | 
				
			||||||
 | 
					    if (_channel != null) {
 | 
				
			||||||
 | 
					      _ws.conn?.sink.add(
 | 
				
			||||||
 | 
					        jsonEncode(WebSocketPackage(
 | 
				
			||||||
 | 
					          method: 'events.unsubscribe',
 | 
				
			||||||
 | 
					          endpoint: 'im',
 | 
				
			||||||
 | 
					          payload: {
 | 
				
			||||||
 | 
					            'channel_id': _channel!.id,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        )),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
    super.dispose();
 | 
					    super.dispose();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -240,12 +277,23 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
				
			|||||||
      appBar: AppBar(
 | 
					      appBar: AppBar(
 | 
				
			||||||
        title: Text(
 | 
					        title: Text(
 | 
				
			||||||
          _channel?.type == 1
 | 
					          _channel?.type == 1
 | 
				
			||||||
              ? ud.getAccountFromCache(_otherMember?.accountId)?.nick ?? _channel!.name
 | 
					              ? ud.getFromCache(_otherMember?.accountId)?.nick ?? _channel!.name
 | 
				
			||||||
              : _channel?.name ?? 'loading'.tr(),
 | 
					              : _channel?.name ?? 'loading'.tr(),
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        actions: [
 | 
					        actions: [
 | 
				
			||||||
          IconButton(
 | 
					          IconButton(
 | 
				
			||||||
            icon: _ongoingCall == null ? const Icon(Symbols.call) : const Icon(Symbols.call_end),
 | 
					            onPressed: () {
 | 
				
			||||||
 | 
					              setState(() => _isEncrypted = !_isEncrypted);
 | 
				
			||||||
 | 
					              _inputGlobalKey.currentState?.setEncrypted(_isEncrypted);
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            icon: _isEncrypted
 | 
				
			||||||
 | 
					                ? const Icon(Symbols.lock)
 | 
				
			||||||
 | 
					                : const Icon(Symbols.no_encryption),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          IconButton(
 | 
				
			||||||
 | 
					            icon: _ongoingCall == null
 | 
				
			||||||
 | 
					                ? const Icon(Symbols.call)
 | 
				
			||||||
 | 
					                : const Icon(Symbols.call_end),
 | 
				
			||||||
            onPressed: _isCalling
 | 
					            onPressed: _isCalling
 | 
				
			||||||
                ? null
 | 
					                ? null
 | 
				
			||||||
                : _ongoingCall == null
 | 
					                : _ongoingCall == null
 | 
				
			||||||
@@ -275,7 +323,9 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
				
			|||||||
        builder: (context, _) {
 | 
					        builder: (context, _) {
 | 
				
			||||||
          return Column(
 | 
					          return Column(
 | 
				
			||||||
            children: [
 | 
					            children: [
 | 
				
			||||||
              LoadingIndicator(isActive: _isBusy),
 | 
					              LoadingIndicator(
 | 
				
			||||||
 | 
					                isActive: _isBusy || _messageController.isAggressiveLoading,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
              SingleChildScrollView(
 | 
					              SingleChildScrollView(
 | 
				
			||||||
                physics: const NeverScrollableScrollPhysics(),
 | 
					                physics: const NeverScrollableScrollPhysics(),
 | 
				
			||||||
                child: MaterialBanner(
 | 
					                child: MaterialBanner(
 | 
				
			||||||
@@ -295,14 +345,14 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
				
			|||||||
                      )
 | 
					                      )
 | 
				
			||||||
                  ],
 | 
					                  ],
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
              )
 | 
					              ).height(_ongoingCall != null ? 54 : 0, animate: true).animate(
 | 
				
			||||||
                  .height(_ongoingCall != null ? 54 : 0, animate: true)
 | 
					                  const Duration(milliseconds: 300),
 | 
				
			||||||
                  .animate(const Duration(milliseconds: 300), Curves.fastLinearToSlowEaseIn),
 | 
					                  Curves.fastLinearToSlowEaseIn),
 | 
				
			||||||
              if (_messageController.isPending)
 | 
					              if (_messageController.isPending)
 | 
				
			||||||
                Expanded(
 | 
					                Expanded(
 | 
				
			||||||
                  child: const CircularProgressIndicator().center(),
 | 
					                  child: const CircularProgressIndicator().center(),
 | 
				
			||||||
                ),
 | 
					                )
 | 
				
			||||||
              if (!_messageController.isPending)
 | 
					              else
 | 
				
			||||||
                Expanded(
 | 
					                Expanded(
 | 
				
			||||||
                  child: InfiniteList(
 | 
					                  child: InfiniteList(
 | 
				
			||||||
                    reverse: true,
 | 
					                    reverse: true,
 | 
				
			||||||
@@ -315,6 +365,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
				
			|||||||
                    },
 | 
					                    },
 | 
				
			||||||
                    itemBuilder: (context, idx) {
 | 
					                    itemBuilder: (context, idx) {
 | 
				
			||||||
                      final message = _messageController.messages[idx];
 | 
					                      final message = _messageController.messages[idx];
 | 
				
			||||||
 | 
					                      _messageController.readEvent(message.id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                      bool canMerge = false, canMergePrevious = false;
 | 
					                      bool canMerge = false, canMergePrevious = false;
 | 
				
			||||||
                      if (idx > 0) {
 | 
					                      if (idx > 0) {
 | 
				
			||||||
@@ -336,7 +387,8 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
				
			|||||||
                          data: message,
 | 
					                          data: message,
 | 
				
			||||||
                          isMerged: canMerge,
 | 
					                          isMerged: canMerge,
 | 
				
			||||||
                          hasMerged: canMergePrevious,
 | 
					                          hasMerged: canMergePrevious,
 | 
				
			||||||
                          isPending: _messageController.unconfirmedMessages.contains(message.uuid),
 | 
					                          isPending: _messageController.unconfirmedMessages
 | 
				
			||||||
 | 
					                              .contains(message.uuid),
 | 
				
			||||||
                          onReply: (value) {
 | 
					                          onReply: (value) {
 | 
				
			||||||
                            _inputGlobalKey.currentState?.setReply(value);
 | 
					                            _inputGlobalKey.currentState?.setReply(value);
 | 
				
			||||||
                          },
 | 
					                          },
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					import 'package:dropdown_button2/dropdown_button2.dart';
 | 
				
			||||||
import 'package:easy_localization/easy_localization.dart';
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
 | 
					import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
 | 
				
			||||||
@@ -8,7 +9,10 @@ import 'package:provider/provider.dart';
 | 
				
			|||||||
import 'package:styled_widget/styled_widget.dart';
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
import 'package:surface/providers/post.dart';
 | 
					import 'package:surface/providers/post.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_realm.dart';
 | 
				
			||||||
import 'package:surface/types/post.dart';
 | 
					import 'package:surface/types/post.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/realm.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/account/account_image.dart';
 | 
				
			||||||
import 'package:surface/widgets/app_bar_leading.dart';
 | 
					import 'package:surface/widgets/app_bar_leading.dart';
 | 
				
			||||||
import 'package:surface/widgets/dialog.dart';
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
					import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
				
			||||||
@@ -35,61 +39,54 @@ class ExploreScreen extends StatefulWidget {
 | 
				
			|||||||
  State<ExploreScreen> createState() => _ExploreScreenState();
 | 
					  State<ExploreScreen> createState() => _ExploreScreenState();
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class _ExploreScreenState extends State<ExploreScreen> {
 | 
					// You know what? I'm not going to make this a global variable.
 | 
				
			||||||
 | 
					// Cuz the global key make the selected category not update to child widget when the category is changed.
 | 
				
			||||||
 | 
					SnPostCategory? _selectedCategory;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _ExploreScreenState extends State<ExploreScreen>
 | 
				
			||||||
 | 
					    with SingleTickerProviderStateMixin {
 | 
				
			||||||
 | 
					  late final TabController _tabController =
 | 
				
			||||||
 | 
					      TabController(length: 4, vsync: this);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  final _fabKey = GlobalKey<ExpandableFabState>();
 | 
					  final _fabKey = GlobalKey<ExpandableFabState>();
 | 
				
			||||||
 | 
					  final _listKeys = List.generate(4, (_) => GlobalKey<_PostListWidgetState>());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  bool _isBusy = true;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  final List<SnPost> _posts = List.empty(growable: true);
 | 
					 | 
				
			||||||
  final List<SnPostCategory> _categories = List.empty(growable: true);
 | 
					  final List<SnPostCategory> _categories = List.empty(growable: true);
 | 
				
			||||||
  int? _postCount;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  String? _selectedCategory;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> _fetchCategories() async {
 | 
					  Future<void> _fetchCategories() async {
 | 
				
			||||||
    _categories.clear();
 | 
					    _categories.clear();
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      final sn = context.read<SnNetworkProvider>();
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
      final resp = await sn.client.get('/cgi/co/categories?take=100');
 | 
					      final resp = await sn.client.get('/cgi/co/categories?take=100');
 | 
				
			||||||
      _categories.addAll(resp.data.map((e) => SnPostCategory.fromJson(e)).cast<SnPostCategory>() ?? []);
 | 
					      setState(() {
 | 
				
			||||||
 | 
					        _categories.addAll(resp.data
 | 
				
			||||||
 | 
					                .map((e) => SnPostCategory.fromJson(e))
 | 
				
			||||||
 | 
					                .cast<SnPostCategory>() ??
 | 
				
			||||||
 | 
					            []);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (mounted) context.showErrorDialog(err);
 | 
				
			||||||
      context.showErrorDialog(err);
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> _fetchPosts() async {
 | 
					  void _clearFilter() {
 | 
				
			||||||
    if (_postCount != null && _posts.length >= _postCount!) return;
 | 
					    _selectedCategory = null;
 | 
				
			||||||
 | 
					 | 
				
			||||||
    setState(() => _isBusy = true);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    final pt = context.read<SnPostContentProvider>();
 | 
					 | 
				
			||||||
    final result = await pt.listPosts(
 | 
					 | 
				
			||||||
      take: 10,
 | 
					 | 
				
			||||||
      offset: _posts.length,
 | 
					 | 
				
			||||||
      categories: _selectedCategory != null ? [_selectedCategory!] : null,
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    final out = result.$1;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (!mounted) return;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    _postCount = result.$2;
 | 
					 | 
				
			||||||
    _posts.addAll(out);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (mounted) setState(() => _isBusy = false);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  Future<void> _refreshPosts() {
 | 
					 | 
				
			||||||
    _postCount = null;
 | 
					 | 
				
			||||||
    _posts.clear();
 | 
					 | 
				
			||||||
    return _fetchPosts();
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  void initState() {
 | 
					  void initState() {
 | 
				
			||||||
    super.initState();
 | 
					 | 
				
			||||||
    _fetchPosts();
 | 
					 | 
				
			||||||
    _fetchCategories();
 | 
					    _fetchCategories();
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    _tabController.dispose();
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> refreshPosts() async {
 | 
				
			||||||
 | 
					    await _listKeys[_tabController.index].currentState?.refreshPosts();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
@@ -102,20 +99,27 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
				
			|||||||
        type: ExpandableFabType.up,
 | 
					        type: ExpandableFabType.up,
 | 
				
			||||||
        childrenAnimation: ExpandableFabAnimation.none,
 | 
					        childrenAnimation: ExpandableFabAnimation.none,
 | 
				
			||||||
        overlayStyle: ExpandableFabOverlayStyle(
 | 
					        overlayStyle: ExpandableFabOverlayStyle(
 | 
				
			||||||
          color: Theme.of(context).colorScheme.surface.withAlpha((255 * 0.5).round()),
 | 
					          color: Theme.of(context)
 | 
				
			||||||
 | 
					              .colorScheme
 | 
				
			||||||
 | 
					              .surface
 | 
				
			||||||
 | 
					              .withAlpha((255 * 0.5).round()),
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        openButtonBuilder: RotateFloatingActionButtonBuilder(
 | 
					        openButtonBuilder: RotateFloatingActionButtonBuilder(
 | 
				
			||||||
          child: const Icon(Symbols.add, size: 28),
 | 
					          child: const Icon(Symbols.add, size: 28),
 | 
				
			||||||
          fabSize: ExpandableFabSize.regular,
 | 
					          fabSize: ExpandableFabSize.regular,
 | 
				
			||||||
          foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
 | 
					          foregroundColor:
 | 
				
			||||||
          backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
 | 
					              Theme.of(context).floatingActionButtonTheme.foregroundColor,
 | 
				
			||||||
 | 
					          backgroundColor:
 | 
				
			||||||
 | 
					              Theme.of(context).floatingActionButtonTheme.backgroundColor,
 | 
				
			||||||
          shape: const CircleBorder(),
 | 
					          shape: const CircleBorder(),
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        closeButtonBuilder: DefaultFloatingActionButtonBuilder(
 | 
					        closeButtonBuilder: DefaultFloatingActionButtonBuilder(
 | 
				
			||||||
          child: const Icon(Symbols.close, size: 28),
 | 
					          child: const Icon(Symbols.close, size: 28),
 | 
				
			||||||
          fabSize: ExpandableFabSize.regular,
 | 
					          fabSize: ExpandableFabSize.regular,
 | 
				
			||||||
          foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
 | 
					          foregroundColor:
 | 
				
			||||||
          backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
 | 
					              Theme.of(context).floatingActionButtonTheme.foregroundColor,
 | 
				
			||||||
 | 
					          backgroundColor:
 | 
				
			||||||
 | 
					              Theme.of(context).floatingActionButtonTheme.backgroundColor,
 | 
				
			||||||
          shape: const CircleBorder(),
 | 
					          shape: const CircleBorder(),
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        children: [
 | 
					        children: [
 | 
				
			||||||
@@ -131,7 +135,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
				
			|||||||
                    'mode': 'stories',
 | 
					                    'mode': 'stories',
 | 
				
			||||||
                  }).then((value) {
 | 
					                  }).then((value) {
 | 
				
			||||||
                    if (value == true) {
 | 
					                    if (value == true) {
 | 
				
			||||||
                      _refreshPosts();
 | 
					                      refreshPosts();
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                  });
 | 
					                  });
 | 
				
			||||||
                  _fabKey.currentState!.toggle();
 | 
					                  _fabKey.currentState!.toggle();
 | 
				
			||||||
@@ -152,7 +156,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
				
			|||||||
                    'mode': 'articles',
 | 
					                    'mode': 'articles',
 | 
				
			||||||
                  }).then((value) {
 | 
					                  }).then((value) {
 | 
				
			||||||
                    if (value == true) {
 | 
					                    if (value == true) {
 | 
				
			||||||
                      _refreshPosts();
 | 
					                      refreshPosts();
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                  });
 | 
					                  });
 | 
				
			||||||
                  _fabKey.currentState!.toggle();
 | 
					                  _fabKey.currentState!.toggle();
 | 
				
			||||||
@@ -173,7 +177,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
				
			|||||||
                    'mode': 'questions',
 | 
					                    'mode': 'questions',
 | 
				
			||||||
                  }).then((value) {
 | 
					                  }).then((value) {
 | 
				
			||||||
                    if (value == true) {
 | 
					                    if (value == true) {
 | 
				
			||||||
                      _refreshPosts();
 | 
					                      refreshPosts();
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                  });
 | 
					                  });
 | 
				
			||||||
                  _fabKey.currentState!.toggle();
 | 
					                  _fabKey.currentState!.toggle();
 | 
				
			||||||
@@ -194,7 +198,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
				
			|||||||
                    'mode': 'videos',
 | 
					                    'mode': 'videos',
 | 
				
			||||||
                  }).then((value) {
 | 
					                  }).then((value) {
 | 
				
			||||||
                    if (value == true) {
 | 
					                    if (value == true) {
 | 
				
			||||||
                      _refreshPosts();
 | 
					                      refreshPosts();
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                  });
 | 
					                  });
 | 
				
			||||||
                  _fabKey.currentState!.toggle();
 | 
					                  _fabKey.currentState!.toggle();
 | 
				
			||||||
@@ -205,74 +209,157 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
				
			|||||||
          ),
 | 
					          ),
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
      body: RefreshIndicator(
 | 
					      body: NestedScrollView(
 | 
				
			||||||
        displacement: 40 + MediaQuery.of(context).padding.top,
 | 
					        headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
 | 
				
			||||||
        onRefresh: () => _refreshPosts(),
 | 
					          return [
 | 
				
			||||||
        child: CustomScrollView(
 | 
					            SliverOverlapAbsorber(
 | 
				
			||||||
          slivers: [
 | 
					              handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
 | 
				
			||||||
            SliverAppBar(
 | 
					              sliver: SliverAppBar(
 | 
				
			||||||
              leading: AutoAppBarLeading(),
 | 
					                leading: AutoAppBarLeading(),
 | 
				
			||||||
              title: Text('screenExplore').tr(),
 | 
					                title: Text('screenExplore').tr(),
 | 
				
			||||||
              floating: true,
 | 
					                floating: true,
 | 
				
			||||||
              snap: true,
 | 
					                snap: true,
 | 
				
			||||||
              actions: [
 | 
					                actions: [
 | 
				
			||||||
                IconButton(
 | 
					                  IconButton(
 | 
				
			||||||
                  icon: const Icon(Symbols.search),
 | 
					                    icon: const Icon(Symbols.category),
 | 
				
			||||||
                  onPressed: () {
 | 
					                    onPressed: () {
 | 
				
			||||||
                    GoRouter.of(context).pushNamed('postSearch');
 | 
					                      showModalBottomSheet(
 | 
				
			||||||
                  },
 | 
					                        context: context,
 | 
				
			||||||
                ),
 | 
					                        builder: (context) => _PostCategoryPickerPopup(
 | 
				
			||||||
                const Gap(8),
 | 
					                          categories: _categories,
 | 
				
			||||||
              ],
 | 
					                          selected: _selectedCategory,
 | 
				
			||||||
              bottom: PreferredSize(
 | 
					                        ),
 | 
				
			||||||
                preferredSize: const Size.fromHeight(50),
 | 
					                      ).then((value) {
 | 
				
			||||||
                child: SizedBox(
 | 
					                        if (value != null && context.mounted) {
 | 
				
			||||||
                  height: 50,
 | 
					                          _selectedCategory = value == false ? null : value;
 | 
				
			||||||
                  child: SingleChildScrollView(
 | 
					                          refreshPosts();
 | 
				
			||||||
                    scrollDirection: Axis.horizontal,
 | 
					                        }
 | 
				
			||||||
                    padding: const EdgeInsets.only(left: 8, right: 8, bottom: 12),
 | 
					                      });
 | 
				
			||||||
                    child: Row(
 | 
					                    },
 | 
				
			||||||
                      mainAxisAlignment: MainAxisAlignment.center,
 | 
					 | 
				
			||||||
                      children: _categories.map((ele) {
 | 
					 | 
				
			||||||
                        return StyledWidget(ChoiceChip(
 | 
					 | 
				
			||||||
                          avatar: Icon(kCategoryIcons[ele.alias] ?? Symbols.question_mark),
 | 
					 | 
				
			||||||
                          label: Text(
 | 
					 | 
				
			||||||
                            'postCategory${ele.alias.capitalize()}'.trExists()
 | 
					 | 
				
			||||||
                                ? 'postCategory${ele.alias.capitalize()}'.tr()
 | 
					 | 
				
			||||||
                                : ele.name,
 | 
					 | 
				
			||||||
                          ),
 | 
					 | 
				
			||||||
                          selected: _selectedCategory == ele.alias,
 | 
					 | 
				
			||||||
                          onSelected: (value) {
 | 
					 | 
				
			||||||
                            _selectedCategory = value ? ele.alias : null;
 | 
					 | 
				
			||||||
                            _refreshPosts();
 | 
					 | 
				
			||||||
                          },
 | 
					 | 
				
			||||||
                        )).padding(horizontal: 4);
 | 
					 | 
				
			||||||
                      }).toList(),
 | 
					 | 
				
			||||||
                    ),
 | 
					 | 
				
			||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
 | 
					                  IconButton(
 | 
				
			||||||
 | 
					                    icon: const Icon(Symbols.search),
 | 
				
			||||||
 | 
					                    onPressed: () {
 | 
				
			||||||
 | 
					                      GoRouter.of(context).pushNamed('postSearch');
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                  const Gap(8),
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					                bottom: TabBar(
 | 
				
			||||||
 | 
					                  controller: _tabController,
 | 
				
			||||||
 | 
					                  tabs: [
 | 
				
			||||||
 | 
					                    Tab(
 | 
				
			||||||
 | 
					                      child: Row(
 | 
				
			||||||
 | 
					                        mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					                        crossAxisAlignment: CrossAxisAlignment.center,
 | 
				
			||||||
 | 
					                        children: [
 | 
				
			||||||
 | 
					                          Icon(Symbols.globe,
 | 
				
			||||||
 | 
					                              size: 20,
 | 
				
			||||||
 | 
					                              color: Theme.of(context)
 | 
				
			||||||
 | 
					                                  .appBarTheme
 | 
				
			||||||
 | 
					                                  .foregroundColor),
 | 
				
			||||||
 | 
					                          const Gap(8),
 | 
				
			||||||
 | 
					                          Flexible(
 | 
				
			||||||
 | 
					                            child: Text(
 | 
				
			||||||
 | 
					                              'postChannelGlobal',
 | 
				
			||||||
 | 
					                              maxLines: 1,
 | 
				
			||||||
 | 
					                            ).tr().textColor(
 | 
				
			||||||
 | 
					                                Theme.of(context).appBarTheme.foregroundColor),
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        ],
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    Tab(
 | 
				
			||||||
 | 
					                      child: Row(
 | 
				
			||||||
 | 
					                        mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					                        crossAxisAlignment: CrossAxisAlignment.center,
 | 
				
			||||||
 | 
					                        children: [
 | 
				
			||||||
 | 
					                          Icon(Symbols.group,
 | 
				
			||||||
 | 
					                              size: 20,
 | 
				
			||||||
 | 
					                              color: Theme.of(context)
 | 
				
			||||||
 | 
					                                  .appBarTheme
 | 
				
			||||||
 | 
					                                  .foregroundColor),
 | 
				
			||||||
 | 
					                          const Gap(8),
 | 
				
			||||||
 | 
					                          Flexible(
 | 
				
			||||||
 | 
					                            child: Text(
 | 
				
			||||||
 | 
					                              'postChannelFriends',
 | 
				
			||||||
 | 
					                              maxLines: 1,
 | 
				
			||||||
 | 
					                              textAlign: TextAlign.center,
 | 
				
			||||||
 | 
					                            ).tr().textColor(
 | 
				
			||||||
 | 
					                                Theme.of(context).appBarTheme.foregroundColor),
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        ],
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    Tab(
 | 
				
			||||||
 | 
					                      child: Row(
 | 
				
			||||||
 | 
					                        mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					                        crossAxisAlignment: CrossAxisAlignment.center,
 | 
				
			||||||
 | 
					                        children: [
 | 
				
			||||||
 | 
					                          Icon(Symbols.subscriptions,
 | 
				
			||||||
 | 
					                              size: 20,
 | 
				
			||||||
 | 
					                              color: Theme.of(context)
 | 
				
			||||||
 | 
					                                  .appBarTheme
 | 
				
			||||||
 | 
					                                  .foregroundColor),
 | 
				
			||||||
 | 
					                          const Gap(8),
 | 
				
			||||||
 | 
					                          Flexible(
 | 
				
			||||||
 | 
					                            child: Text(
 | 
				
			||||||
 | 
					                              'postChannelFollowing',
 | 
				
			||||||
 | 
					                              maxLines: 1,
 | 
				
			||||||
 | 
					                            ).tr().textColor(
 | 
				
			||||||
 | 
					                                Theme.of(context).appBarTheme.foregroundColor),
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        ],
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    Tab(
 | 
				
			||||||
 | 
					                      child: Row(
 | 
				
			||||||
 | 
					                        mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					                        crossAxisAlignment: CrossAxisAlignment.center,
 | 
				
			||||||
 | 
					                        children: [
 | 
				
			||||||
 | 
					                          Icon(Symbols.workspaces,
 | 
				
			||||||
 | 
					                              size: 20,
 | 
				
			||||||
 | 
					                              color: Theme.of(context)
 | 
				
			||||||
 | 
					                                  .appBarTheme
 | 
				
			||||||
 | 
					                                  .foregroundColor),
 | 
				
			||||||
 | 
					                          const Gap(8),
 | 
				
			||||||
 | 
					                          Flexible(
 | 
				
			||||||
 | 
					                            child: Text(
 | 
				
			||||||
 | 
					                              'postChannelRealm',
 | 
				
			||||||
 | 
					                              maxLines: 1,
 | 
				
			||||||
 | 
					                            ).tr().textColor(
 | 
				
			||||||
 | 
					                                Theme.of(context).appBarTheme.foregroundColor),
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        ],
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ],
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
            const SliverGap(12),
 | 
					          ];
 | 
				
			||||||
            SliverInfiniteList(
 | 
					        },
 | 
				
			||||||
              itemCount: _posts.length,
 | 
					        body: TabBarView(
 | 
				
			||||||
              isLoading: _isBusy,
 | 
					          controller: _tabController,
 | 
				
			||||||
              centerLoading: true,
 | 
					          children: [
 | 
				
			||||||
              hasReachedMax: _postCount != null && _posts.length >= _postCount!,
 | 
					            _PostListWidget(
 | 
				
			||||||
              onFetchData: _fetchPosts,
 | 
					              key: _listKeys[0],
 | 
				
			||||||
              itemBuilder: (context, idx) {
 | 
					              onClearFilter: _clearFilter,
 | 
				
			||||||
                return OpenablePostItem(
 | 
					            ),
 | 
				
			||||||
                  data: _posts[idx],
 | 
					            _PostListWidget(
 | 
				
			||||||
                  maxWidth: 640,
 | 
					              key: _listKeys[1],
 | 
				
			||||||
                  onChanged: (data) {
 | 
					              channel: 'friends',
 | 
				
			||||||
                    setState(() => _posts[idx] = data);
 | 
					              onClearFilter: _clearFilter,
 | 
				
			||||||
                  },
 | 
					            ),
 | 
				
			||||||
                  onDeleted: () {
 | 
					            _PostListWidget(
 | 
				
			||||||
                    _refreshPosts();
 | 
					              key: _listKeys[2],
 | 
				
			||||||
                  },
 | 
					              channel: 'following',
 | 
				
			||||||
                );
 | 
					              onClearFilter: _clearFilter,
 | 
				
			||||||
              },
 | 
					            ),
 | 
				
			||||||
              separatorBuilder: (_, __) => const Gap(8),
 | 
					            _PostListWidget(
 | 
				
			||||||
 | 
					              key: _listKeys[3],
 | 
				
			||||||
 | 
					              withRealm: true,
 | 
				
			||||||
 | 
					              onClearFilter: _clearFilter,
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
          ],
 | 
					          ],
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
@@ -280,3 +367,261 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
				
			|||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _PostListWidget extends StatefulWidget {
 | 
				
			||||||
 | 
					  final String? channel;
 | 
				
			||||||
 | 
					  final bool withRealm;
 | 
				
			||||||
 | 
					  final Function onClearFilter;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const _PostListWidget(
 | 
				
			||||||
 | 
					      {super.key,
 | 
				
			||||||
 | 
					      this.channel,
 | 
				
			||||||
 | 
					      this.withRealm = false,
 | 
				
			||||||
 | 
					      required this.onClearFilter});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<_PostListWidget> createState() => _PostListWidgetState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _PostListWidgetState extends State<_PostListWidget> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final List<SnPost> _posts = List.empty(growable: true);
 | 
				
			||||||
 | 
					  final List<SnRealm> _realms = List.empty(growable: true);
 | 
				
			||||||
 | 
					  SnRealm? _selectedRealm;
 | 
				
			||||||
 | 
					  int? _postCount;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchRealms() async {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final rels = context.read<SnRealmProvider>();
 | 
				
			||||||
 | 
					      final out = await rels.listAvailableRealms();
 | 
				
			||||||
 | 
					      setState(() {
 | 
				
			||||||
 | 
					        _realms.addAll(out);
 | 
				
			||||||
 | 
					        _selectedRealm = out.firstOrNull;
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					      rethrow;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchPosts() async {
 | 
				
			||||||
 | 
					    if (_postCount != null && _posts.length >= _postCount!) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final pt = context.read<SnPostContentProvider>();
 | 
				
			||||||
 | 
					    final result = await pt.listPosts(
 | 
				
			||||||
 | 
					      take: 10,
 | 
				
			||||||
 | 
					      offset: _posts.length,
 | 
				
			||||||
 | 
					      categories: _selectedCategory != null ? [_selectedCategory!.alias] : null,
 | 
				
			||||||
 | 
					      channel: widget.channel,
 | 
				
			||||||
 | 
					      realm: _selectedRealm?.alias,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    final out = result.$1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!mounted) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _postCount = result.$2;
 | 
				
			||||||
 | 
					    _posts.addAll(out);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (mounted) setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> refreshPosts() {
 | 
				
			||||||
 | 
					    _postCount = null;
 | 
				
			||||||
 | 
					    _posts.clear();
 | 
				
			||||||
 | 
					    return _fetchPosts();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    if (widget.withRealm) {
 | 
				
			||||||
 | 
					      _fetchRealms().then((_) {
 | 
				
			||||||
 | 
					        _fetchPosts();
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      _fetchPosts();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return Column(
 | 
				
			||||||
 | 
					      children: [
 | 
				
			||||||
 | 
					        if (_selectedCategory != null)
 | 
				
			||||||
 | 
					          MaterialBanner(
 | 
				
			||||||
 | 
					            content: Text(
 | 
				
			||||||
 | 
					              'postFilterWithCategory'.tr(args: [
 | 
				
			||||||
 | 
					                'postCategory${_selectedCategory!.alias.capitalize()}'.trExists()
 | 
				
			||||||
 | 
					                    ? 'postCategory${_selectedCategory!.alias.capitalize()}'
 | 
				
			||||||
 | 
					                        .tr()
 | 
				
			||||||
 | 
					                    : _selectedCategory!.name,
 | 
				
			||||||
 | 
					              ]),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            leading: Icon(kCategoryIcons[_selectedCategory!.alias] ??
 | 
				
			||||||
 | 
					                Symbols.question_mark),
 | 
				
			||||||
 | 
					            actions: [
 | 
				
			||||||
 | 
					              IconButton(
 | 
				
			||||||
 | 
					                icon: const Icon(Symbols.clear),
 | 
				
			||||||
 | 
					                onPressed: () {
 | 
				
			||||||
 | 
					                  widget.onClearFilter.call();
 | 
				
			||||||
 | 
					                  refreshPosts();
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            padding: const EdgeInsets.only(left: 20, right: 4),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        if (widget.withRealm)
 | 
				
			||||||
 | 
					          DropdownButtonHideUnderline(
 | 
				
			||||||
 | 
					            child: DropdownButton2<SnRealm>(
 | 
				
			||||||
 | 
					              isExpanded: true,
 | 
				
			||||||
 | 
					              items: _realms
 | 
				
			||||||
 | 
					                  .map(
 | 
				
			||||||
 | 
					                    (ele) => DropdownMenuItem<SnRealm>(
 | 
				
			||||||
 | 
					                      value: ele,
 | 
				
			||||||
 | 
					                      child: Row(
 | 
				
			||||||
 | 
					                        children: [
 | 
				
			||||||
 | 
					                          AccountImage(
 | 
				
			||||||
 | 
					                            content: ele.avatar,
 | 
				
			||||||
 | 
					                            fallbackWidget: const Icon(Symbols.group, size: 16),
 | 
				
			||||||
 | 
					                            radius: 14,
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                          const Gap(8),
 | 
				
			||||||
 | 
					                          Text(
 | 
				
			||||||
 | 
					                            ele.name,
 | 
				
			||||||
 | 
					                            style: Theme.of(context).textTheme.bodyMedium,
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        ],
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  )
 | 
				
			||||||
 | 
					                  .toList(),
 | 
				
			||||||
 | 
					              value: _selectedRealm,
 | 
				
			||||||
 | 
					              onChanged: (SnRealm? value) {
 | 
				
			||||||
 | 
					                setState(() => _selectedRealm = value);
 | 
				
			||||||
 | 
					                refreshPosts();
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					              buttonStyleData: const ButtonStyleData(
 | 
				
			||||||
 | 
					                padding: EdgeInsets.only(left: 4, right: 12),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              menuItemStyleData: const MenuItemStyleData(
 | 
				
			||||||
 | 
					                height: 48,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        if (widget.withRealm) const Divider(height: 1),
 | 
				
			||||||
 | 
					        Expanded(
 | 
				
			||||||
 | 
					          child: MediaQuery.removePadding(
 | 
				
			||||||
 | 
					            context: context,
 | 
				
			||||||
 | 
					            removeTop: true,
 | 
				
			||||||
 | 
					            child: RefreshIndicator(
 | 
				
			||||||
 | 
					              displacement: 40 + MediaQuery.of(context).padding.top,
 | 
				
			||||||
 | 
					              onRefresh: () => refreshPosts(),
 | 
				
			||||||
 | 
					              child: InfiniteList(
 | 
				
			||||||
 | 
					                itemCount: _posts.length,
 | 
				
			||||||
 | 
					                isLoading: _isBusy,
 | 
				
			||||||
 | 
					                centerLoading: true,
 | 
				
			||||||
 | 
					                hasReachedMax:
 | 
				
			||||||
 | 
					                    _postCount != null && _posts.length >= _postCount!,
 | 
				
			||||||
 | 
					                onFetchData: _fetchPosts,
 | 
				
			||||||
 | 
					                itemBuilder: (context, idx) {
 | 
				
			||||||
 | 
					                  return OpenablePostItem(
 | 
				
			||||||
 | 
					                    data: _posts[idx],
 | 
				
			||||||
 | 
					                    maxWidth: 640,
 | 
				
			||||||
 | 
					                    onChanged: (data) {
 | 
				
			||||||
 | 
					                      setState(() => _posts[idx] = data);
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                    onDeleted: () {
 | 
				
			||||||
 | 
					                      refreshPosts();
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                  );
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                separatorBuilder: (_, __) => const Gap(8),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ).padding(top: 8),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _PostCategoryPickerPopup extends StatelessWidget {
 | 
				
			||||||
 | 
					  final List<SnPostCategory> categories;
 | 
				
			||||||
 | 
					  final SnPostCategory? selected;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const _PostCategoryPickerPopup({required this.categories, this.selected});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return Column(
 | 
				
			||||||
 | 
					      crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					      children: [
 | 
				
			||||||
 | 
					        Row(
 | 
				
			||||||
 | 
					          crossAxisAlignment: CrossAxisAlignment.center,
 | 
				
			||||||
 | 
					          children: [
 | 
				
			||||||
 | 
					            const Icon(Symbols.category, size: 24),
 | 
				
			||||||
 | 
					            const Gap(16),
 | 
				
			||||||
 | 
					            Text('postCategory')
 | 
				
			||||||
 | 
					                .tr()
 | 
				
			||||||
 | 
					                .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
				
			||||||
 | 
					        ListTile(
 | 
				
			||||||
 | 
					          leading: const Icon(Symbols.clear),
 | 
				
			||||||
 | 
					          title: Text('postFilterReset').tr(),
 | 
				
			||||||
 | 
					          subtitle: Text('postFilterResetDescription').tr(),
 | 
				
			||||||
 | 
					          contentPadding: const EdgeInsets.symmetric(horizontal: 20),
 | 
				
			||||||
 | 
					          onTap: () {
 | 
				
			||||||
 | 
					            Navigator.pop(context, false);
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        const Divider(height: 1),
 | 
				
			||||||
 | 
					        Expanded(
 | 
				
			||||||
 | 
					          child: GridView.count(
 | 
				
			||||||
 | 
					            crossAxisCount: 4,
 | 
				
			||||||
 | 
					            shrinkWrap: true,
 | 
				
			||||||
 | 
					            physics: const NeverScrollableScrollPhysics(),
 | 
				
			||||||
 | 
					            childAspectRatio: 1,
 | 
				
			||||||
 | 
					            children: categories
 | 
				
			||||||
 | 
					                .map(
 | 
				
			||||||
 | 
					                  (ele) => InkWell(
 | 
				
			||||||
 | 
					                    onTap: () {
 | 
				
			||||||
 | 
					                      _selectedCategory = ele;
 | 
				
			||||||
 | 
					                      Navigator.pop(context, ele);
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                    child: Column(
 | 
				
			||||||
 | 
					                      crossAxisAlignment: CrossAxisAlignment.center,
 | 
				
			||||||
 | 
					                      mainAxisAlignment: MainAxisAlignment.center,
 | 
				
			||||||
 | 
					                      mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					                      children: [
 | 
				
			||||||
 | 
					                        Icon(
 | 
				
			||||||
 | 
					                          kCategoryIcons[ele.alias] ?? Symbols.question_mark,
 | 
				
			||||||
 | 
					                          color: selected == ele
 | 
				
			||||||
 | 
					                              ? Theme.of(context).colorScheme.primary
 | 
				
			||||||
 | 
					                              : null,
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                        const Gap(4),
 | 
				
			||||||
 | 
					                        Text(
 | 
				
			||||||
 | 
					                          'postCategory${ele.alias.capitalize()}'.trExists()
 | 
				
			||||||
 | 
					                              ? 'postCategory${ele.alias.capitalize()}'.tr()
 | 
				
			||||||
 | 
					                              : ele.name,
 | 
				
			||||||
 | 
					                        )
 | 
				
			||||||
 | 
					                            .textStyle(Theme.of(context).textTheme.titleMedium!)
 | 
				
			||||||
 | 
					                            .textColor(selected == ele
 | 
				
			||||||
 | 
					                                ? Theme.of(context).colorScheme.primary
 | 
				
			||||||
 | 
					                                : null),
 | 
				
			||||||
 | 
					                      ],
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                .toList(),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +1,7 @@
 | 
				
			|||||||
import 'dart:io';
 | 
					 | 
				
			||||||
import 'dart:math' as math;
 | 
					import 'dart:math' as math;
 | 
				
			||||||
import 'dart:ui';
 | 
					import 'dart:ui';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import 'package:easy_localization/easy_localization.dart';
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
import 'package:flutter/foundation.dart';
 | 
					 | 
				
			||||||
import 'package:flutter_app_update/flutter_app_update.dart';
 | 
					 | 
				
			||||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
 | 
					import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
 | 
				
			||||||
import 'package:gap/gap.dart';
 | 
					import 'package:gap/gap.dart';
 | 
				
			||||||
import 'package:go_router/go_router.dart';
 | 
					import 'package:go_router/go_router.dart';
 | 
				
			||||||
@@ -29,6 +26,7 @@ import 'package:surface/widgets/app_bar_leading.dart';
 | 
				
			|||||||
import 'package:surface/widgets/dialog.dart';
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
					import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
				
			||||||
import 'package:surface/widgets/post/post_item.dart';
 | 
					import 'package:surface/widgets/post/post_item.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/updater.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class HomeScreenDashEntry {
 | 
					class HomeScreenDashEntry {
 | 
				
			||||||
  final String name;
 | 
					  final String name;
 | 
				
			||||||
@@ -83,14 +81,24 @@ class _HomeScreenState extends State<HomeScreen> {
 | 
				
			|||||||
      body: LayoutBuilder(
 | 
					      body: LayoutBuilder(
 | 
				
			||||||
        builder: (context, constraints) {
 | 
					        builder: (context, constraints) {
 | 
				
			||||||
          return Align(
 | 
					          return Align(
 | 
				
			||||||
            alignment: constraints.maxWidth > 640 ? Alignment.center : Alignment.topCenter,
 | 
					            alignment: constraints.maxWidth > 640
 | 
				
			||||||
 | 
					                ? Alignment.center
 | 
				
			||||||
 | 
					                : Alignment.topCenter,
 | 
				
			||||||
            child: Container(
 | 
					            child: Container(
 | 
				
			||||||
              constraints: const BoxConstraints(maxWidth: 640),
 | 
					              constraints: const BoxConstraints(maxWidth: 640),
 | 
				
			||||||
              child: SingleChildScrollView(
 | 
					              child: SingleChildScrollView(
 | 
				
			||||||
                child: Column(
 | 
					                child: Column(
 | 
				
			||||||
                  mainAxisAlignment: constraints.maxWidth > 640 ? MainAxisAlignment.center : MainAxisAlignment.start,
 | 
					                  mainAxisAlignment: constraints.maxWidth > 640
 | 
				
			||||||
 | 
					                      ? MainAxisAlignment.center
 | 
				
			||||||
 | 
					                      : MainAxisAlignment.start,
 | 
				
			||||||
                  children: [
 | 
					                  children: [
 | 
				
			||||||
                    _HomeDashUpdateWidget(padding: const EdgeInsets.only(bottom: 8, left: 8, right: 8)),
 | 
					                    _HomeDashUpdateWidget(
 | 
				
			||||||
 | 
					                      padding: const EdgeInsets.only(
 | 
				
			||||||
 | 
					                        bottom: 8,
 | 
				
			||||||
 | 
					                        left: 8,
 | 
				
			||||||
 | 
					                        right: 8,
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
                    _HomeDashSpecialDayWidget().padding(horizontal: 8),
 | 
					                    _HomeDashSpecialDayWidget().padding(horizontal: 8),
 | 
				
			||||||
                    StaggeredGrid.extent(
 | 
					                    StaggeredGrid.extent(
 | 
				
			||||||
                      maxCrossAxisExtent: 280,
 | 
					                      maxCrossAxisExtent: 280,
 | 
				
			||||||
@@ -131,25 +139,20 @@ class _HomeDashUpdateWidget extends StatelessWidget {
 | 
				
			|||||||
          return Container(
 | 
					          return Container(
 | 
				
			||||||
            padding: padding,
 | 
					            padding: padding,
 | 
				
			||||||
            child: Card(
 | 
					            child: Card(
 | 
				
			||||||
 | 
					              margin: EdgeInsets.zero,
 | 
				
			||||||
              child: ListTile(
 | 
					              child: ListTile(
 | 
				
			||||||
                leading: Icon(Symbols.update),
 | 
					                leading: Icon(Symbols.update),
 | 
				
			||||||
                title: Text('updateAvailable').tr(),
 | 
					                title: Text('updateAvailable').tr(),
 | 
				
			||||||
                subtitle: Text(config.updatableVersion!),
 | 
					                subtitle: Text(config.updatableVersion!),
 | 
				
			||||||
                trailing: (kIsWeb || Platform.isWindows || Platform.isLinux)
 | 
					                trailing: IconButton(
 | 
				
			||||||
                    ? null
 | 
					                  icon: const Icon(Symbols.arrow_right_alt),
 | 
				
			||||||
                    : IconButton(
 | 
					                  onPressed: () {
 | 
				
			||||||
                        icon: const Icon(Symbols.arrow_right_alt),
 | 
					                    showModalBottomSheet(
 | 
				
			||||||
                        onPressed: () {
 | 
					                      context: context,
 | 
				
			||||||
                          final model = UpdateModel(
 | 
					                      builder: (context) => VersionUpdatePopup(),
 | 
				
			||||||
                            'https://files.solsynth.dev/d/production01/solian/app-arm64-v8a-release.apk',
 | 
					                    );
 | 
				
			||||||
                            'solian-app-release-${config.updatableVersion!}.apk',
 | 
					                  },
 | 
				
			||||||
                            'ic_launcher',
 | 
					                ),
 | 
				
			||||||
                            'https://apps.apple.com/us/app/solian/id6499032345',
 | 
					 | 
				
			||||||
                          );
 | 
					 | 
				
			||||||
                          AzhonAppUpdate.update(model);
 | 
					 | 
				
			||||||
                          context.showSnackbar('updateOngoing'.tr());
 | 
					 | 
				
			||||||
                        },
 | 
					 | 
				
			||||||
                      ),
 | 
					 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
          );
 | 
					          );
 | 
				
			||||||
@@ -165,7 +168,8 @@ class _HomeDashSpecialDayWidget extends StatefulWidget {
 | 
				
			|||||||
  const _HomeDashSpecialDayWidget();
 | 
					  const _HomeDashSpecialDayWidget();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  State<_HomeDashSpecialDayWidget> createState() => _HomeDashSpecialDayWidgetState();
 | 
					  State<_HomeDashSpecialDayWidget> createState() =>
 | 
				
			||||||
 | 
					      _HomeDashSpecialDayWidgetState();
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
 | 
					class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
 | 
				
			||||||
@@ -180,6 +184,7 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
 | 
				
			|||||||
      return Column(
 | 
					      return Column(
 | 
				
			||||||
          children: days.map((ele) {
 | 
					          children: days.map((ele) {
 | 
				
			||||||
        return Card(
 | 
					        return Card(
 | 
				
			||||||
 | 
					          margin: EdgeInsets.zero,
 | 
				
			||||||
          child: ListTile(
 | 
					          child: ListTile(
 | 
				
			||||||
            leading: Text(kSpecialDaysSymbol[ele] ?? '🎉').fontSize(24),
 | 
					            leading: Text(kSpecialDaysSymbol[ele] ?? '🎉').fontSize(24),
 | 
				
			||||||
            title: Text('celebrate$ele').tr(args: [ua.user?.nick ?? 'user']),
 | 
					            title: Text('celebrate$ele').tr(args: [ua.user?.nick ?? 'user']),
 | 
				
			||||||
@@ -203,9 +208,12 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
 | 
				
			|||||||
      final progress = dayz.getSpecialDayProgress(lastOne.$2, date);
 | 
					      final progress = dayz.getSpecialDayProgress(lastOne.$2, date);
 | 
				
			||||||
      final diff = nextOne.$2.difference(DateTime.now());
 | 
					      final diff = nextOne.$2.difference(DateTime.now());
 | 
				
			||||||
      return Card(
 | 
					      return Card(
 | 
				
			||||||
 | 
					        margin: EdgeInsets.zero,
 | 
				
			||||||
        child: ListTile(
 | 
					        child: ListTile(
 | 
				
			||||||
          leading: Text(kSpecialDaysSymbol[name] ?? '🎉').fontSize(24),
 | 
					          leading: Text(kSpecialDaysSymbol[name] ?? '🎉').fontSize(24),
 | 
				
			||||||
          title: Text('pending$name').tr(args: [RelativeTime(context).format(date).replaceFirst('in', '').trim()]),
 | 
					          title: Text('pending$name').tr(args: [
 | 
				
			||||||
 | 
					            RelativeTime(context).format(date).replaceFirst('in', '').trim()
 | 
				
			||||||
 | 
					          ]),
 | 
				
			||||||
          subtitle: Row(
 | 
					          subtitle: Row(
 | 
				
			||||||
            crossAxisAlignment: CrossAxisAlignment.center,
 | 
					            crossAxisAlignment: CrossAxisAlignment.center,
 | 
				
			||||||
            children: [
 | 
					            children: [
 | 
				
			||||||
@@ -270,6 +278,7 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
 | 
				
			|||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    return Card(
 | 
					    return Card(
 | 
				
			||||||
 | 
					      margin: EdgeInsets.zero,
 | 
				
			||||||
      child: Column(
 | 
					      child: Column(
 | 
				
			||||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
					        crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
        children: [
 | 
					        children: [
 | 
				
			||||||
@@ -293,12 +302,19 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
 | 
				
			|||||||
                  children: [
 | 
					                  children: [
 | 
				
			||||||
                    Text(
 | 
					                    Text(
 | 
				
			||||||
                      _article!.title,
 | 
					                      _article!.title,
 | 
				
			||||||
                      style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 18),
 | 
					                      style: Theme.of(context)
 | 
				
			||||||
                      maxLines: MediaQuery.of(context).size.width >= 640 ? 2 : 1,
 | 
					                          .textTheme
 | 
				
			||||||
 | 
					                          .titleMedium!
 | 
				
			||||||
 | 
					                          .copyWith(fontSize: 18),
 | 
				
			||||||
 | 
					                      maxLines:
 | 
				
			||||||
 | 
					                          MediaQuery.of(context).size.width >= 640 ? 2 : 1,
 | 
				
			||||||
                      overflow: TextOverflow.ellipsis,
 | 
					                      overflow: TextOverflow.ellipsis,
 | 
				
			||||||
                    ),
 | 
					                    ),
 | 
				
			||||||
                    Text(
 | 
					                    Text(
 | 
				
			||||||
                      parse(_article!.description).children.map((e) => e.text.trim()).join(),
 | 
					                      parse(_article!.description)
 | 
				
			||||||
 | 
					                          .children
 | 
				
			||||||
 | 
					                          .map((e) => e.text.trim())
 | 
				
			||||||
 | 
					                          .join(),
 | 
				
			||||||
                      maxLines: 3,
 | 
					                      maxLines: 3,
 | 
				
			||||||
                      overflow: TextOverflow.ellipsis,
 | 
					                      overflow: TextOverflow.ellipsis,
 | 
				
			||||||
                      style: Theme.of(context).textTheme.bodyMedium,
 | 
					                      style: Theme.of(context).textTheme.bodyMedium,
 | 
				
			||||||
@@ -309,9 +325,13 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
 | 
				
			|||||||
                        crossAxisAlignment: CrossAxisAlignment.center,
 | 
					                        crossAxisAlignment: CrossAxisAlignment.center,
 | 
				
			||||||
                        spacing: 2,
 | 
					                        spacing: 2,
 | 
				
			||||||
                        children: [
 | 
					                        children: [
 | 
				
			||||||
                          Text(DateFormat().format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
					                          Text(DateFormat().format(date)).textStyle(
 | 
				
			||||||
                          Text(' · ').textStyle(Theme.of(context).textTheme.bodySmall!).bold(),
 | 
					                              Theme.of(context).textTheme.bodySmall!),
 | 
				
			||||||
                          Text(RelativeTime(context).format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
					                          Text(' · ')
 | 
				
			||||||
 | 
					                              .textStyle(Theme.of(context).textTheme.bodySmall!)
 | 
				
			||||||
 | 
					                              .bold(),
 | 
				
			||||||
 | 
					                          Text(RelativeTime(context).format(date)).textStyle(
 | 
				
			||||||
 | 
					                              Theme.of(context).textTheme.bodySmall!),
 | 
				
			||||||
                        ],
 | 
					                        ],
 | 
				
			||||||
                      ).opacity(0.75);
 | 
					                      ).opacity(0.75);
 | 
				
			||||||
                    }),
 | 
					                    }),
 | 
				
			||||||
@@ -382,15 +402,20 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Widget _buildDetailChunk(int index, bool positive) {
 | 
					  Widget _buildDetailChunk(int index, bool positive) {
 | 
				
			||||||
    final prefix = positive ? 'dailyCheckPositiveHint' : 'dailyCheckNegativeHint';
 | 
					    final prefix =
 | 
				
			||||||
    final mod = positive ? kSuggestionPositiveHintCount : kSuggestionNegativeHintCount;
 | 
					        positive ? 'dailyCheckPositiveHint' : 'dailyCheckNegativeHint';
 | 
				
			||||||
 | 
					    final mod =
 | 
				
			||||||
 | 
					        positive ? kSuggestionPositiveHintCount : kSuggestionNegativeHintCount;
 | 
				
			||||||
    final pos = math.max(1, _todayRecord!.resultModifiers[index] % mod);
 | 
					    final pos = math.max(1, _todayRecord!.resultModifiers[index] % mod);
 | 
				
			||||||
    return Column(
 | 
					    return Column(
 | 
				
			||||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
					      crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
      children: [
 | 
					      children: [
 | 
				
			||||||
        Text(
 | 
					        Text(
 | 
				
			||||||
          prefix.tr(args: ['$prefix$pos'.tr()]),
 | 
					          prefix.tr(args: ['$prefix$pos'.tr()]),
 | 
				
			||||||
          style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold),
 | 
					          style: Theme.of(context)
 | 
				
			||||||
 | 
					              .textTheme
 | 
				
			||||||
 | 
					              .titleMedium!
 | 
				
			||||||
 | 
					              .copyWith(fontWeight: FontWeight.bold),
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        Text(
 | 
					        Text(
 | 
				
			||||||
          '$prefix${pos}Description',
 | 
					          '$prefix${pos}Description',
 | 
				
			||||||
@@ -425,7 +450,10 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
 | 
				
			|||||||
              else
 | 
					              else
 | 
				
			||||||
                Text(
 | 
					                Text(
 | 
				
			||||||
                  'dailyCheckEverythingIsNegative',
 | 
					                  'dailyCheckEverythingIsNegative',
 | 
				
			||||||
                  style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold),
 | 
					                  style: Theme.of(context)
 | 
				
			||||||
 | 
					                      .textTheme
 | 
				
			||||||
 | 
					                      .titleMedium!
 | 
				
			||||||
 | 
					                      .copyWith(fontWeight: FontWeight.bold),
 | 
				
			||||||
                ).tr(),
 | 
					                ).tr(),
 | 
				
			||||||
              const Gap(8),
 | 
					              const Gap(8),
 | 
				
			||||||
              if (_todayRecord?.resultTier != 4)
 | 
					              if (_todayRecord?.resultTier != 4)
 | 
				
			||||||
@@ -441,7 +469,10 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
 | 
				
			|||||||
              else
 | 
					              else
 | 
				
			||||||
                Text(
 | 
					                Text(
 | 
				
			||||||
                  'dailyCheckEverythingIsPositive',
 | 
					                  'dailyCheckEverythingIsPositive',
 | 
				
			||||||
                  style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold),
 | 
					                  style: Theme.of(context)
 | 
				
			||||||
 | 
					                      .textTheme
 | 
				
			||||||
 | 
					                      .titleMedium!
 | 
				
			||||||
 | 
					                      .copyWith(fontWeight: FontWeight.bold),
 | 
				
			||||||
                ).tr(),
 | 
					                ).tr(),
 | 
				
			||||||
            ],
 | 
					            ],
 | 
				
			||||||
          ),
 | 
					          ),
 | 
				
			||||||
@@ -469,6 +500,7 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
 | 
				
			|||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    return Card(
 | 
					    return Card(
 | 
				
			||||||
 | 
					      margin: EdgeInsets.zero,
 | 
				
			||||||
      child: Column(
 | 
					      child: Column(
 | 
				
			||||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
					        crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
        children: [
 | 
					        children: [
 | 
				
			||||||
@@ -566,10 +598,12 @@ class _HomeDashNotificationWidget extends StatefulWidget {
 | 
				
			|||||||
  const _HomeDashNotificationWidget();
 | 
					  const _HomeDashNotificationWidget();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  State<_HomeDashNotificationWidget> createState() => _HomeDashNotificationWidgetState();
 | 
					  State<_HomeDashNotificationWidget> createState() =>
 | 
				
			||||||
 | 
					      _HomeDashNotificationWidgetState();
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget> {
 | 
					class _HomeDashNotificationWidgetState
 | 
				
			||||||
 | 
					    extends State<_HomeDashNotificationWidget> {
 | 
				
			||||||
  int? _count;
 | 
					  int? _count;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> _fetchNotificationCount() async {
 | 
					  Future<void> _fetchNotificationCount() async {
 | 
				
			||||||
@@ -594,6 +628,7 @@ class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget
 | 
				
			|||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    return Card(
 | 
					    return Card(
 | 
				
			||||||
 | 
					      margin: EdgeInsets.zero,
 | 
				
			||||||
      child: Column(
 | 
					      child: Column(
 | 
				
			||||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
					        crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
        children: [
 | 
					        children: [
 | 
				
			||||||
@@ -606,7 +641,9 @@ class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget
 | 
				
			|||||||
                  style: Theme.of(context).textTheme.titleLarge,
 | 
					                  style: Theme.of(context).textTheme.titleLarge,
 | 
				
			||||||
                ).tr(),
 | 
					                ).tr(),
 | 
				
			||||||
                Text(
 | 
					                Text(
 | 
				
			||||||
                  _count == null ? 'loading'.tr() : 'notificationUnreadCount'.plural(_count ?? 0),
 | 
					                  _count == null
 | 
				
			||||||
 | 
					                      ? 'loading'.tr()
 | 
				
			||||||
 | 
					                      : 'notificationUnreadCount'.plural(_count ?? 0),
 | 
				
			||||||
                  style: Theme.of(context).textTheme.bodyLarge,
 | 
					                  style: Theme.of(context).textTheme.bodyLarge,
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
              ],
 | 
					              ],
 | 
				
			||||||
@@ -637,10 +674,12 @@ class _HomeDashRecommendationPostWidget extends StatefulWidget {
 | 
				
			|||||||
  const _HomeDashRecommendationPostWidget();
 | 
					  const _HomeDashRecommendationPostWidget();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  State<_HomeDashRecommendationPostWidget> createState() => _HomeDashRecommendationPostWidgetState();
 | 
					  State<_HomeDashRecommendationPostWidget> createState() =>
 | 
				
			||||||
 | 
					      _HomeDashRecommendationPostWidgetState();
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendationPostWidget> {
 | 
					class _HomeDashRecommendationPostWidgetState
 | 
				
			||||||
 | 
					    extends State<_HomeDashRecommendationPostWidget> {
 | 
				
			||||||
  bool _isBusy = false;
 | 
					  bool _isBusy = false;
 | 
				
			||||||
  List<SnPost>? _posts;
 | 
					  List<SnPost>? _posts;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -657,37 +696,62 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  int _currentPage = 0;
 | 
				
			||||||
 | 
					  final PageController _pageController = PageController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  void initState() {
 | 
					  void initState() {
 | 
				
			||||||
    super.initState();
 | 
					    super.initState();
 | 
				
			||||||
    _fetchRecommendationPosts();
 | 
					    _fetchRecommendationPosts();
 | 
				
			||||||
 | 
					    _pageController.addListener(() {
 | 
				
			||||||
 | 
					      setState(() {
 | 
				
			||||||
 | 
					        _currentPage = _pageController.page?.round() ?? 0;
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    _pageController.dispose();
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    if (_isBusy) {
 | 
					    if (_isBusy) {
 | 
				
			||||||
      return Card(
 | 
					      return Card(
 | 
				
			||||||
 | 
					        margin: EdgeInsets.zero,
 | 
				
			||||||
        child: CircularProgressIndicator().center(),
 | 
					        child: CircularProgressIndicator().center(),
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return Card(
 | 
					    return Card(
 | 
				
			||||||
 | 
					      margin: EdgeInsets.zero,
 | 
				
			||||||
      child: Column(
 | 
					      child: Column(
 | 
				
			||||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
					        crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
        children: [
 | 
					        children: [
 | 
				
			||||||
          Row(
 | 
					          Row(
 | 
				
			||||||
 | 
					            mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | 
				
			||||||
            children: [
 | 
					            children: [
 | 
				
			||||||
              const Icon(Symbols.star),
 | 
					              Row(
 | 
				
			||||||
              const Gap(8),
 | 
					                children: [
 | 
				
			||||||
              Text(
 | 
					                  const Icon(Symbols.star),
 | 
				
			||||||
                'postRecommendation',
 | 
					                  const Gap(8),
 | 
				
			||||||
                style: Theme.of(context).textTheme.titleLarge,
 | 
					                  Text(
 | 
				
			||||||
              ).tr()
 | 
					                    'postRecommendation',
 | 
				
			||||||
 | 
					                    style: Theme.of(context).textTheme.titleLarge,
 | 
				
			||||||
 | 
					                  ).tr(),
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              Text('${_currentPage + 1}/${_posts?.length ?? 0}',
 | 
				
			||||||
 | 
					                  style: GoogleFonts.robotoMono())
 | 
				
			||||||
            ],
 | 
					            ],
 | 
				
			||||||
          ).padding(horizontal: 18, top: 12, bottom: 8),
 | 
					          ).padding(horizontal: 18, top: 12, bottom: 8),
 | 
				
			||||||
          Expanded(
 | 
					          Expanded(
 | 
				
			||||||
            child: PageView.builder(
 | 
					            child: PageView.builder(
 | 
				
			||||||
              scrollBehavior: ScrollConfiguration.of(context).copyWith(dragDevices: {
 | 
					              controller: _pageController,
 | 
				
			||||||
 | 
					              scrollBehavior:
 | 
				
			||||||
 | 
					                  ScrollConfiguration.of(context).copyWith(dragDevices: {
 | 
				
			||||||
                PointerDeviceKind.mouse,
 | 
					                PointerDeviceKind.mouse,
 | 
				
			||||||
                PointerDeviceKind.touch,
 | 
					                PointerDeviceKind.touch,
 | 
				
			||||||
              }),
 | 
					              }),
 | 
				
			||||||
@@ -700,7 +764,8 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati
 | 
				
			|||||||
                      showMenu: false,
 | 
					                      showMenu: false,
 | 
				
			||||||
                    ).padding(bottom: 8),
 | 
					                    ).padding(bottom: 8),
 | 
				
			||||||
                    onTap: () {
 | 
					                    onTap: () {
 | 
				
			||||||
                      GoRouter.of(context).pushNamed('postDetail', pathParameters: {
 | 
					                      GoRouter.of(context)
 | 
				
			||||||
 | 
					                          .pushNamed('postDetail', pathParameters: {
 | 
				
			||||||
                        'slug': _posts![index].id.toString(),
 | 
					                        'slug': _posts![index].id.toString(),
 | 
				
			||||||
                      });
 | 
					                      });
 | 
				
			||||||
                    },
 | 
					                    },
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										167
									
								
								lib/screens/logging.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								lib/screens/logging.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,167 @@
 | 
				
			|||||||
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/services.dart';
 | 
				
			||||||
 | 
					import 'package:google_fonts/google_fonts.dart';
 | 
				
			||||||
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
 | 
					import 'package:surface/logger.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
				
			||||||
 | 
					import 'package:talker_dio_logger/dio_logs.dart';
 | 
				
			||||||
 | 
					import 'package:talker_flutter/talker_flutter.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					final Map<LogLevel, IconData> kLogLevelIcons = {
 | 
				
			||||||
 | 
					  LogLevel.error: Symbols.error,
 | 
				
			||||||
 | 
					  LogLevel.critical: Symbols.error,
 | 
				
			||||||
 | 
					  LogLevel.warning: Symbols.warning,
 | 
				
			||||||
 | 
					  LogLevel.info: Symbols.info,
 | 
				
			||||||
 | 
					  LogLevel.debug: Symbols.info_i,
 | 
				
			||||||
 | 
					  LogLevel.verbose: Symbols.info_i,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					final Map<LogLevel, bool> kLogLevelFilled = {
 | 
				
			||||||
 | 
					  LogLevel.error: false,
 | 
				
			||||||
 | 
					  LogLevel.critical: true,
 | 
				
			||||||
 | 
					  LogLevel.warning: true,
 | 
				
			||||||
 | 
					  LogLevel.info: true,
 | 
				
			||||||
 | 
					  LogLevel.debug: false,
 | 
				
			||||||
 | 
					  LogLevel.verbose: false,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DebugLoggingScreen extends StatelessWidget {
 | 
				
			||||||
 | 
					  const DebugLoggingScreen({super.key});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    final talkerTheme = TalkerScreenTheme.fromTheme(Theme.of(context));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return AppScaffold(
 | 
				
			||||||
 | 
					      appBar: AppBar(
 | 
				
			||||||
 | 
					        leading: const PageBackButton(),
 | 
				
			||||||
 | 
					        title: Text('debugLogging').tr(),
 | 
				
			||||||
 | 
					        actions: [
 | 
				
			||||||
 | 
					          IconButton(
 | 
				
			||||||
 | 
					            onPressed: () {
 | 
				
			||||||
 | 
					              logging.cleanHistory();
 | 
				
			||||||
 | 
					              Navigator.pop(context);
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            icon: const Icon(Symbols.delete),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      body: ListView.builder(
 | 
				
			||||||
 | 
					        reverse: true,
 | 
				
			||||||
 | 
					        padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom),
 | 
				
			||||||
 | 
					        itemCount: logging.history.length,
 | 
				
			||||||
 | 
					        itemBuilder: (context, index) {
 | 
				
			||||||
 | 
					          final log = logging.history[index];
 | 
				
			||||||
 | 
					          final color = log.getFlutterColor(talkerTheme);
 | 
				
			||||||
 | 
					          return ListTile(
 | 
				
			||||||
 | 
					            minTileHeight: 0,
 | 
				
			||||||
 | 
					            tileColor: color.withOpacity(0.2),
 | 
				
			||||||
 | 
					            leading: Icon(
 | 
				
			||||||
 | 
					              kLogLevelIcons[log.logLevel ?? LogLevel.debug] ?? Symbols.help,
 | 
				
			||||||
 | 
					              color: color,
 | 
				
			||||||
 | 
					              fill: (kLogLevelFilled[log.logLevel ?? LogLevel.debug] ?? false)
 | 
				
			||||||
 | 
					                  ? 1
 | 
				
			||||||
 | 
					                  : 0,
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            title: Column(
 | 
				
			||||||
 | 
					              crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					              children: [
 | 
				
			||||||
 | 
					                if (log is DioRequestLog)
 | 
				
			||||||
 | 
					                  Column(
 | 
				
			||||||
 | 
					                    crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                    children: [
 | 
				
			||||||
 | 
					                      Text(
 | 
				
			||||||
 | 
					                        '${log.requestOptions.method} ${log.displayMessage}',
 | 
				
			||||||
 | 
					                        style: GoogleFonts.robotoMono(fontSize: 13),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                      if (log.requestOptions.data != null)
 | 
				
			||||||
 | 
					                        Theme(
 | 
				
			||||||
 | 
					                          data: Theme.of(context).copyWith(
 | 
				
			||||||
 | 
					                            dividerColor: Colors.transparent,
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                          child: ExpansionTile(
 | 
				
			||||||
 | 
					                            title: Text('Payload').fontSize(13),
 | 
				
			||||||
 | 
					                            minTileHeight: 0,
 | 
				
			||||||
 | 
					                            tilePadding: EdgeInsets.zero,
 | 
				
			||||||
 | 
					                            expandedCrossAxisAlignment:
 | 
				
			||||||
 | 
					                                CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                            children: [
 | 
				
			||||||
 | 
					                              Text(
 | 
				
			||||||
 | 
					                                log.requestOptions.data.toString(),
 | 
				
			||||||
 | 
					                                style: GoogleFonts.robotoMono(fontSize: 13),
 | 
				
			||||||
 | 
					                              ),
 | 
				
			||||||
 | 
					                            ],
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                  )
 | 
				
			||||||
 | 
					                else if (log is DioResponseLog)
 | 
				
			||||||
 | 
					                  Column(
 | 
				
			||||||
 | 
					                    crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                    children: [
 | 
				
			||||||
 | 
					                      Text(
 | 
				
			||||||
 | 
					                        '${log.response.statusCode} ${log.displayMessage}',
 | 
				
			||||||
 | 
					                        style: GoogleFonts.robotoMono(fontSize: 13),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                      if (log.response.data != null)
 | 
				
			||||||
 | 
					                        Theme(
 | 
				
			||||||
 | 
					                          data: Theme.of(context).copyWith(
 | 
				
			||||||
 | 
					                            dividerColor: Colors.transparent,
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                          child: ExpansionTile(
 | 
				
			||||||
 | 
					                            title: Text('Payload').fontSize(13),
 | 
				
			||||||
 | 
					                            minTileHeight: 0,
 | 
				
			||||||
 | 
					                            tilePadding: EdgeInsets.zero,
 | 
				
			||||||
 | 
					                            expandedCrossAxisAlignment:
 | 
				
			||||||
 | 
					                                CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                            children: [
 | 
				
			||||||
 | 
					                              Text(
 | 
				
			||||||
 | 
					                                log.response.data.toString(),
 | 
				
			||||||
 | 
					                                style: GoogleFonts.robotoMono(fontSize: 13),
 | 
				
			||||||
 | 
					                              ),
 | 
				
			||||||
 | 
					                            ],
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                  )
 | 
				
			||||||
 | 
					                else
 | 
				
			||||||
 | 
					                  Text(
 | 
				
			||||||
 | 
					                    log.displayMessage,
 | 
				
			||||||
 | 
					                    style: GoogleFonts.robotoMono(fontSize: 13),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                if (log.exception != null)
 | 
				
			||||||
 | 
					                  Text(
 | 
				
			||||||
 | 
					                    log.displayException,
 | 
				
			||||||
 | 
					                    style: GoogleFonts.robotoMono(fontSize: 13),
 | 
				
			||||||
 | 
					                  ).bold(),
 | 
				
			||||||
 | 
					                if (log.error != null)
 | 
				
			||||||
 | 
					                  Text(
 | 
				
			||||||
 | 
					                    log.displayException,
 | 
				
			||||||
 | 
					                    style: GoogleFonts.robotoMono(fontSize: 13),
 | 
				
			||||||
 | 
					                  ).bold(),
 | 
				
			||||||
 | 
					                if (log.stackTrace != null)
 | 
				
			||||||
 | 
					                  Text(
 | 
				
			||||||
 | 
					                    log.displayStackTrace,
 | 
				
			||||||
 | 
					                    style: GoogleFonts.robotoMono(fontSize: 12),
 | 
				
			||||||
 | 
					                  ).padding(top: 4),
 | 
				
			||||||
 | 
					              ],
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            subtitle: Text(
 | 
				
			||||||
 | 
					              '${(log.title?.replaceAll('-', ' ') ?? 'default').capitalizeEachWord()} · ${log.displayTime()}',
 | 
				
			||||||
 | 
					            ).fontSize(11),
 | 
				
			||||||
 | 
					            onTap: () {
 | 
				
			||||||
 | 
					              Clipboard.setData(
 | 
				
			||||||
 | 
					                ClipboardData(
 | 
				
			||||||
 | 
					                  text: log.generateTextMessage(),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              );
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -3,6 +3,7 @@ import 'dart:math' as math;
 | 
				
			|||||||
import 'package:easy_localization/easy_localization.dart';
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:gap/gap.dart';
 | 
					import 'package:gap/gap.dart';
 | 
				
			||||||
 | 
					import 'package:go_router/go_router.dart';
 | 
				
			||||||
import 'package:material_symbols_icons/symbols.dart';
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
import 'package:relative_time/relative_time.dart';
 | 
					import 'package:relative_time/relative_time.dart';
 | 
				
			||||||
@@ -28,6 +29,7 @@ const Map<String, IconData> kNotificationTopicIcons = {
 | 
				
			|||||||
  'passport.security.otp': Symbols.password,
 | 
					  'passport.security.otp': Symbols.password,
 | 
				
			||||||
  'interactive.subscription': Symbols.subscriptions,
 | 
					  'interactive.subscription': Symbols.subscriptions,
 | 
				
			||||||
  'interactive.feedback': Symbols.add_reaction,
 | 
					  'interactive.feedback': Symbols.add_reaction,
 | 
				
			||||||
 | 
					  'interactive.reply': Symbols.reply,
 | 
				
			||||||
  'messaging.callStart': Symbols.call_received,
 | 
					  'messaging.callStart': Symbols.call_received,
 | 
				
			||||||
  'wallet.transaction.new': Symbols.receipt,
 | 
					  'wallet.transaction.new': Symbols.receipt,
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
@@ -56,14 +58,12 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
				
			|||||||
    try {
 | 
					    try {
 | 
				
			||||||
      final sn = context.read<SnNetworkProvider>();
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
      final nty = context.read<NotificationProvider>();
 | 
					      final nty = context.read<NotificationProvider>();
 | 
				
			||||||
      final resp = await sn.client.get('/cgi/id/notifications?take=10');
 | 
					      final resp = await sn.client.get(
 | 
				
			||||||
      _totalCount = resp.data['count'];
 | 
					        '/cgi/id/notifications',
 | 
				
			||||||
      _notifications.addAll(
 | 
					        queryParameters: {'take': 10, 'offset': _notifications.length},
 | 
				
			||||||
        resp.data['data']
 | 
					 | 
				
			||||||
                ?.map((e) => SnNotification.fromJson(e))
 | 
					 | 
				
			||||||
                .cast<SnNotification>() ??
 | 
					 | 
				
			||||||
            [],
 | 
					 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
 | 
					      _totalCount = resp.data['count'];
 | 
				
			||||||
 | 
					      _notifications.addAll(resp.data['data']?.map((e) => SnNotification.fromJson(e)).cast<SnNotification>() ?? []);
 | 
				
			||||||
      nty.updateTray();
 | 
					      nty.updateTray();
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
@@ -98,9 +98,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
				
			|||||||
      nty.clear();
 | 
					      nty.clear();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      context.showSnackbar(
 | 
					      context.showSnackbar('notificationMarkAllReadPrompt'.plural(resp.data['count']));
 | 
				
			||||||
        'notificationMarkAllReadPrompt'.plural(resp.data['count']),
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      context.showErrorDialog(err);
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
@@ -124,9 +122,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
				
			|||||||
      _fetchNotifications();
 | 
					      _fetchNotifications();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      context.showSnackbar(
 | 
					      context.showSnackbar('notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']));
 | 
				
			||||||
        'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']),
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      context.showErrorDialog(err);
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
@@ -147,13 +143,8 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    if (!ua.isAuthorized) {
 | 
					    if (!ua.isAuthorized) {
 | 
				
			||||||
      return AppScaffold(
 | 
					      return AppScaffold(
 | 
				
			||||||
        appBar: AppBar(
 | 
					        appBar: AppBar(leading: AutoAppBarLeading(), title: Text('screenNotification').tr()),
 | 
				
			||||||
          leading: AutoAppBarLeading(),
 | 
					        body: Center(child: UnauthorizedHint()),
 | 
				
			||||||
          title: Text('screenNotification').tr(),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        body: Center(
 | 
					 | 
				
			||||||
          child: UnauthorizedHint(),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -162,10 +153,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
				
			|||||||
        leading: AutoAppBarLeading(),
 | 
					        leading: AutoAppBarLeading(),
 | 
				
			||||||
        title: Text('screenNotification').tr(),
 | 
					        title: Text('screenNotification').tr(),
 | 
				
			||||||
        actions: [
 | 
					        actions: [
 | 
				
			||||||
          IconButton(
 | 
					          IconButton(icon: const Icon(Symbols.checklist), onPressed: _isSubmitting ? null : _markAllAsRead),
 | 
				
			||||||
            icon: const Icon(Symbols.checklist),
 | 
					 | 
				
			||||||
            onPressed: _isSubmitting ? null : _markAllAsRead,
 | 
					 | 
				
			||||||
          ),
 | 
					 | 
				
			||||||
          const Gap(8),
 | 
					          const Gap(8),
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
@@ -179,17 +167,13 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
				
			|||||||
                return _fetchNotifications();
 | 
					                return _fetchNotifications();
 | 
				
			||||||
              },
 | 
					              },
 | 
				
			||||||
              child: InfiniteList(
 | 
					              child: InfiniteList(
 | 
				
			||||||
                padding: EdgeInsets.only(
 | 
					                padding: EdgeInsets.only(top: 16, bottom: math.max(MediaQuery.of(context).padding.bottom, 16)),
 | 
				
			||||||
                  top: 16,
 | 
					 | 
				
			||||||
                  bottom: math.max(MediaQuery.of(context).padding.bottom, 16),
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
                itemCount: _notifications.length,
 | 
					                itemCount: _notifications.length,
 | 
				
			||||||
                onFetchData: () {
 | 
					                onFetchData: () {
 | 
				
			||||||
                  _fetchNotifications();
 | 
					                  _fetchNotifications();
 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
                isLoading: _isBusy,
 | 
					                isLoading: _isBusy,
 | 
				
			||||||
                hasReachedMax: _totalCount != null &&
 | 
					                hasReachedMax: _totalCount != null && _notifications.length >= _totalCount!,
 | 
				
			||||||
                    _notifications.length >= _totalCount!,
 | 
					 | 
				
			||||||
                itemBuilder: (context, idx) {
 | 
					                itemBuilder: (context, idx) {
 | 
				
			||||||
                  final nty = _notifications[idx];
 | 
					                  final nty = _notifications[idx];
 | 
				
			||||||
                  return Row(
 | 
					                  return Row(
 | 
				
			||||||
@@ -202,63 +186,46 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
				
			|||||||
                          crossAxisAlignment: CrossAxisAlignment.start,
 | 
					                          crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
                          children: [
 | 
					                          children: [
 | 
				
			||||||
                            if (nty.readAt == null)
 | 
					                            if (nty.readAt == null)
 | 
				
			||||||
                              StyledWidget(Badge(
 | 
					                              StyledWidget(Badge(label: Text('notificationUnread').tr())).padding(bottom: 4),
 | 
				
			||||||
                                label: Text('notificationUnread').tr(),
 | 
					                            Text(nty.title, style: Theme.of(context).textTheme.titleMedium),
 | 
				
			||||||
                              )).padding(bottom: 4),
 | 
					 | 
				
			||||||
                            Text(
 | 
					 | 
				
			||||||
                              nty.title,
 | 
					 | 
				
			||||||
                              style: Theme.of(context).textTheme.titleMedium,
 | 
					 | 
				
			||||||
                            ),
 | 
					 | 
				
			||||||
                            if (nty.subtitle != null)
 | 
					                            if (nty.subtitle != null)
 | 
				
			||||||
                              Text(
 | 
					                              Text(nty.subtitle!, style: Theme.of(context).textTheme.titleSmall),
 | 
				
			||||||
                                nty.subtitle!,
 | 
					 | 
				
			||||||
                                style: Theme.of(context).textTheme.titleSmall,
 | 
					 | 
				
			||||||
                              ),
 | 
					 | 
				
			||||||
                            if (nty.subtitle != null) const Gap(4),
 | 
					                            if (nty.subtitle != null) const Gap(4),
 | 
				
			||||||
                            SelectionArea(
 | 
					                            SelectionArea(child: MarkdownTextContent(content: nty.body, isAutoWarp: true)),
 | 
				
			||||||
                              child: MarkdownTextContent(
 | 
					 | 
				
			||||||
                                content: nty.body,
 | 
					 | 
				
			||||||
                                isAutoWarp: true,
 | 
					 | 
				
			||||||
                              ),
 | 
					 | 
				
			||||||
                            ),
 | 
					 | 
				
			||||||
                            if ([
 | 
					                            if ([
 | 
				
			||||||
 | 
					                                  'interactive.reply',
 | 
				
			||||||
                                  'interactive.feedback',
 | 
					                                  'interactive.feedback',
 | 
				
			||||||
                                  'interactive.subscription'
 | 
					                                  'interactive.subscription',
 | 
				
			||||||
                                ].contains(nty.topic) &&
 | 
					                                ].contains(nty.topic) &&
 | 
				
			||||||
                                nty.metadata['related_post'] != null)
 | 
					                                nty.metadata['related_post'] != null)
 | 
				
			||||||
                              StyledWidget(Container(
 | 
					                              GestureDetector(
 | 
				
			||||||
                                decoration: BoxDecoration(
 | 
					                                child: Container(
 | 
				
			||||||
                                  borderRadius: const BorderRadius.all(
 | 
					                                  decoration: BoxDecoration(
 | 
				
			||||||
                                      Radius.circular(8)),
 | 
					                                    borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
				
			||||||
                                  border: Border.all(
 | 
					                                    border: Border.all(color: Theme.of(context).dividerColor, width: 1),
 | 
				
			||||||
                                    color: Theme.of(context).dividerColor,
 | 
					                                  ),
 | 
				
			||||||
                                    width: 1,
 | 
					                                  child: PostItem(
 | 
				
			||||||
 | 
					                                    data: SnPost.fromJson(nty.metadata['related_post']!),
 | 
				
			||||||
 | 
					                                    showComments: false,
 | 
				
			||||||
 | 
					                                    showReactions: false,
 | 
				
			||||||
 | 
					                                    showMenu: false,
 | 
				
			||||||
                                  ),
 | 
					                                  ),
 | 
				
			||||||
                                ),
 | 
					                                ),
 | 
				
			||||||
                                child: PostItem(
 | 
					                                onTap: () {
 | 
				
			||||||
                                  data: SnPost.fromJson(
 | 
					                                  GoRouter.of(context).pushNamed(
 | 
				
			||||||
                                    nty.metadata['related_post']!,
 | 
					                                    'postDetail',
 | 
				
			||||||
                                  ),
 | 
					                                    pathParameters: {'slug': nty.metadata['related_post']!['id'].toString()},
 | 
				
			||||||
                                  showComments: false,
 | 
					                                  );
 | 
				
			||||||
                                  showReactions: false,
 | 
					                                },
 | 
				
			||||||
                                  showMenu: false,
 | 
					                              ).padding(top: 8),
 | 
				
			||||||
                                ),
 | 
					 | 
				
			||||||
                              )).padding(top: 8),
 | 
					 | 
				
			||||||
                            const Gap(8),
 | 
					                            const Gap(8),
 | 
				
			||||||
                            Row(
 | 
					                            Row(
 | 
				
			||||||
                              children: [
 | 
					                              children: [
 | 
				
			||||||
                                Text(
 | 
					                                Text(DateFormat('yy/MM/dd').format(nty.createdAt)).fontSize(12),
 | 
				
			||||||
                                  DateFormat('yy/MM/dd').format(nty.createdAt),
 | 
					 | 
				
			||||||
                                ).fontSize(12),
 | 
					 | 
				
			||||||
                                const Gap(4),
 | 
					                                const Gap(4),
 | 
				
			||||||
                                Text(
 | 
					                                Text('·', style: TextStyle(fontSize: 12)),
 | 
				
			||||||
                                  '·',
 | 
					 | 
				
			||||||
                                  style: TextStyle(fontSize: 12),
 | 
					 | 
				
			||||||
                                ),
 | 
					 | 
				
			||||||
                                const Gap(4),
 | 
					                                const Gap(4),
 | 
				
			||||||
                                Text(
 | 
					                                Text(RelativeTime(context).format(nty.createdAt)).fontSize(12),
 | 
				
			||||||
                                  RelativeTime(context).format(nty.createdAt),
 | 
					 | 
				
			||||||
                                ).fontSize(12),
 | 
					 | 
				
			||||||
                              ],
 | 
					                              ],
 | 
				
			||||||
                            ).opacity(0.75),
 | 
					                            ).opacity(0.75),
 | 
				
			||||||
                          ],
 | 
					                          ],
 | 
				
			||||||
@@ -268,10 +235,8 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
				
			|||||||
                      IconButton(
 | 
					                      IconButton(
 | 
				
			||||||
                        icon: const Icon(Symbols.check),
 | 
					                        icon: const Icon(Symbols.check),
 | 
				
			||||||
                        padding: EdgeInsets.all(0),
 | 
					                        padding: EdgeInsets.all(0),
 | 
				
			||||||
                        visualDensity:
 | 
					                        visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
 | 
				
			||||||
                            const VisualDensity(horizontal: -4, vertical: -4),
 | 
					                        onPressed: _isSubmitting ? null : () => _markOneAsRead(nty),
 | 
				
			||||||
                        onPressed:
 | 
					 | 
				
			||||||
                            _isSubmitting ? null : () => _markOneAsRead(nty),
 | 
					 | 
				
			||||||
                      ),
 | 
					                      ),
 | 
				
			||||||
                    ],
 | 
					                    ],
 | 
				
			||||||
                  ).padding(horizontal: 16);
 | 
					                  ).padding(horizontal: 16);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,7 +6,6 @@ import 'package:gap/gap.dart';
 | 
				
			|||||||
import 'package:go_router/go_router.dart';
 | 
					import 'package:go_router/go_router.dart';
 | 
				
			||||||
import 'package:material_symbols_icons/symbols.dart';
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
import 'package:responsive_framework/responsive_framework.dart';
 | 
					 | 
				
			||||||
import 'package:styled_widget/styled_widget.dart';
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
import 'package:surface/providers/post.dart';
 | 
					import 'package:surface/providers/post.dart';
 | 
				
			||||||
import 'package:surface/providers/userinfo.dart';
 | 
					import 'package:surface/providers/userinfo.dart';
 | 
				
			||||||
@@ -17,7 +16,6 @@ import 'package:surface/widgets/navigation/app_background.dart';
 | 
				
			|||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
					import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
				
			||||||
import 'package:surface/widgets/post/post_comment_list.dart';
 | 
					import 'package:surface/widgets/post/post_comment_list.dart';
 | 
				
			||||||
import 'package:surface/widgets/post/post_item.dart';
 | 
					import 'package:surface/widgets/post/post_item.dart';
 | 
				
			||||||
import 'package:surface/widgets/post/post_mini_editor.dart';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PostDetailScreen extends StatefulWidget {
 | 
					class PostDetailScreen extends StatefulWidget {
 | 
				
			||||||
  final String slug;
 | 
					  final String slug;
 | 
				
			||||||
@@ -64,7 +62,8 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
 | 
				
			|||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    final ua = context.watch<UserProvider>();
 | 
					    final ua = context.watch<UserProvider>();
 | 
				
			||||||
    final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
 | 
					
 | 
				
			||||||
 | 
					    final double maxWidth = _data?.type == 'video' ? double.infinity : 640;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return AppBackground(
 | 
					    return AppBackground(
 | 
				
			||||||
      isRoot: widget.onBack != null,
 | 
					      isRoot: widget.onBack != null,
 | 
				
			||||||
@@ -114,7 +113,7 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
 | 
				
			|||||||
              SliverToBoxAdapter(
 | 
					              SliverToBoxAdapter(
 | 
				
			||||||
                child: PostItem(
 | 
					                child: PostItem(
 | 
				
			||||||
                  data: _data!,
 | 
					                  data: _data!,
 | 
				
			||||||
                  maxWidth: 640,
 | 
					                  maxWidth: maxWidth,
 | 
				
			||||||
                  showComments: false,
 | 
					                  showComments: false,
 | 
				
			||||||
                  showFullPost: true,
 | 
					                  showFullPost: true,
 | 
				
			||||||
                  onChanged: (data) {
 | 
					                  onChanged: (data) {
 | 
				
			||||||
@@ -125,11 +124,11 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
 | 
				
			|||||||
                  },
 | 
					                  },
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
            const SliverToBoxAdapter(child: Divider(height: 1)),
 | 
					            if (_data != null && _data!.type != 'video') const SliverToBoxAdapter(child: Divider(height: 1)),
 | 
				
			||||||
            if (_data != null)
 | 
					            if (_data != null && _data!.type != 'video')
 | 
				
			||||||
              SliverToBoxAdapter(
 | 
					              SliverToBoxAdapter(
 | 
				
			||||||
                child: Container(
 | 
					                child: Container(
 | 
				
			||||||
                  constraints: const BoxConstraints(maxWidth: 640),
 | 
					                  constraints: BoxConstraints(maxWidth: maxWidth),
 | 
				
			||||||
                  child: Row(
 | 
					                  child: Row(
 | 
				
			||||||
                    crossAxisAlignment: CrossAxisAlignment.center,
 | 
					                    crossAxisAlignment: CrossAxisAlignment.center,
 | 
				
			||||||
                    children: [
 | 
					                    children: [
 | 
				
			||||||
@@ -142,51 +141,30 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
 | 
				
			|||||||
                  ).padding(horizontal: 20, vertical: 12).center(),
 | 
					                  ).padding(horizontal: 20, vertical: 12).center(),
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
            if (_data != null && ua.isAuthorized)
 | 
					            if (_data != null && ua.isAuthorized && _data!.type != 'video')
 | 
				
			||||||
              SliverToBoxAdapter(
 | 
					              SliverToBoxAdapter(
 | 
				
			||||||
                child: Container(
 | 
					                child: PostCommentQuickAction(
 | 
				
			||||||
                  height: 240,
 | 
					                  parentPost: _data!,
 | 
				
			||||||
                  constraints: const BoxConstraints(maxWidth: 640),
 | 
					                  maxWidth: maxWidth,
 | 
				
			||||||
                  margin:
 | 
					                  onPosted: () {
 | 
				
			||||||
                      ResponsiveBreakpoints.of(context).largerThan(MOBILE) ? const EdgeInsets.all(8) : EdgeInsets.zero,
 | 
					                    setState(() {
 | 
				
			||||||
                  decoration: BoxDecoration(
 | 
					                      _data = _data!.copyWith(
 | 
				
			||||||
                    borderRadius: ResponsiveBreakpoints.of(context).largerThan(MOBILE)
 | 
					                        metric: _data!.metric.copyWith(
 | 
				
			||||||
                        ? const BorderRadius.all(Radius.circular(8))
 | 
					                          replyCount: _data!.metric.replyCount + 1,
 | 
				
			||||||
                        : BorderRadius.zero,
 | 
					                        ),
 | 
				
			||||||
                    border: ResponsiveBreakpoints.of(context).largerThan(MOBILE)
 | 
					                      );
 | 
				
			||||||
                        ? Border.all(
 | 
					                    });
 | 
				
			||||||
                            color: Theme.of(context).dividerColor,
 | 
					                    _childListKey.currentState!.refresh();
 | 
				
			||||||
                            width: 1 / devicePixelRatio,
 | 
					                  },
 | 
				
			||||||
                          )
 | 
					                ),
 | 
				
			||||||
                        : Border.symmetric(
 | 
					 | 
				
			||||||
                            horizontal: BorderSide(
 | 
					 | 
				
			||||||
                              color: Theme.of(context).dividerColor,
 | 
					 | 
				
			||||||
                              width: 1 / devicePixelRatio,
 | 
					 | 
				
			||||||
                            ),
 | 
					 | 
				
			||||||
                          ),
 | 
					 | 
				
			||||||
                  ),
 | 
					 | 
				
			||||||
                  child: PostMiniEditor(
 | 
					 | 
				
			||||||
                    postReplyId: _data!.id,
 | 
					 | 
				
			||||||
                    onPost: () {
 | 
					 | 
				
			||||||
                      setState(() {
 | 
					 | 
				
			||||||
                        _data = _data!.copyWith(
 | 
					 | 
				
			||||||
                          metric: _data!.metric.copyWith(
 | 
					 | 
				
			||||||
                            replyCount: _data!.metric.replyCount + 1,
 | 
					 | 
				
			||||||
                          ),
 | 
					 | 
				
			||||||
                        );
 | 
					 | 
				
			||||||
                      });
 | 
					 | 
				
			||||||
                      _childListKey.currentState!.refresh();
 | 
					 | 
				
			||||||
                    },
 | 
					 | 
				
			||||||
                  ),
 | 
					 | 
				
			||||||
                ).center(),
 | 
					 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
            if (_data != null)
 | 
					            if (_data != null && _data!.type != 'video')
 | 
				
			||||||
              PostCommentSliverList(
 | 
					              PostCommentSliverList(
 | 
				
			||||||
                key: _childListKey,
 | 
					                key: _childListKey,
 | 
				
			||||||
                parentPost: _data!,
 | 
					                parentPost: _data!,
 | 
				
			||||||
                maxWidth: 640,
 | 
					                maxWidth: maxWidth,
 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
            SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)),
 | 
					            if (_data != null && _data!.type == 'video') SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)),
 | 
				
			||||||
          ],
 | 
					          ],
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
 
 | 
				
			|||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -4,17 +4,16 @@ import 'package:gap/gap.dart';
 | 
				
			|||||||
import 'package:go_router/go_router.dart';
 | 
					import 'package:go_router/go_router.dart';
 | 
				
			||||||
import 'package:material_symbols_icons/symbols.dart';
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
import 'package:styled_widget/styled_widget.dart';
 | 
					import 'package:surface/providers/config.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/providers/userinfo.dart';
 | 
					import 'package:surface/providers/userinfo.dart';
 | 
				
			||||||
import 'package:surface/types/realm.dart';
 | 
					import 'package:surface/types/realm.dart';
 | 
				
			||||||
import 'package:surface/widgets/account/account_image.dart';
 | 
					 | 
				
			||||||
import 'package:surface/widgets/app_bar_leading.dart';
 | 
					import 'package:surface/widgets/app_bar_leading.dart';
 | 
				
			||||||
import 'package:surface/widgets/dialog.dart';
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
					import 'package:surface/widgets/loading_indicator.dart';
 | 
				
			||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
					import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/realm/realm_item.dart';
 | 
				
			||||||
import 'package:surface/widgets/unauthorized_hint.dart';
 | 
					import 'package:surface/widgets/unauthorized_hint.dart';
 | 
				
			||||||
import 'package:surface/widgets/universal_image.dart';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
class RealmScreen extends StatefulWidget {
 | 
					class RealmScreen extends StatefulWidget {
 | 
				
			||||||
  const RealmScreen({super.key});
 | 
					  const RealmScreen({super.key});
 | 
				
			||||||
@@ -75,12 +74,12 @@ class _RealmScreenState extends State<RealmScreen> {
 | 
				
			|||||||
  @override
 | 
					  @override
 | 
				
			||||||
  void initState() {
 | 
					  void initState() {
 | 
				
			||||||
    super.initState();
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _isCompactView = context.read<ConfigProvider>().realmCompactView;
 | 
				
			||||||
    _fetchRealms();
 | 
					    _fetchRealms();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    final sn = context.read<SnNetworkProvider>();
 | 
					 | 
				
			||||||
    final ua = context.read<UserProvider>();
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!ua.isAuthorized) {
 | 
					    if (!ua.isAuthorized) {
 | 
				
			||||||
@@ -110,6 +109,7 @@ class _RealmScreenState extends State<RealmScreen> {
 | 
				
			|||||||
            icon: !_isCompactView ? const Icon(Symbols.view_list) : const Icon(Symbols.view_module),
 | 
					            icon: !_isCompactView ? const Icon(Symbols.view_list) : const Icon(Symbols.view_module),
 | 
				
			||||||
            onPressed: () {
 | 
					            onPressed: () {
 | 
				
			||||||
              setState(() => _isCompactView = !_isCompactView);
 | 
					              setState(() => _isCompactView = !_isCompactView);
 | 
				
			||||||
 | 
					              context.read<ConfigProvider>().realmCompactView = _isCompactView;
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
          ),
 | 
					          ),
 | 
				
			||||||
          const Gap(8),
 | 
					          const Gap(8),
 | 
				
			||||||
@@ -134,121 +134,46 @@ class _RealmScreenState extends State<RealmScreen> {
 | 
				
			|||||||
                  itemCount: _realms?.length ?? 0,
 | 
					                  itemCount: _realms?.length ?? 0,
 | 
				
			||||||
                  itemBuilder: (context, idx) {
 | 
					                  itemBuilder: (context, idx) {
 | 
				
			||||||
                    final realm = _realms![idx];
 | 
					                    final realm = _realms![idx];
 | 
				
			||||||
                    if (_isCompactView) {
 | 
					 | 
				
			||||||
                      return ListTile(
 | 
					 | 
				
			||||||
                        contentPadding: const EdgeInsets.symmetric(horizontal: 16),
 | 
					 | 
				
			||||||
                        leading: AccountImage(
 | 
					 | 
				
			||||||
                          content: realm.avatar,
 | 
					 | 
				
			||||||
                          fallbackWidget: const Icon(Symbols.group, size: 20),
 | 
					 | 
				
			||||||
                        ),
 | 
					 | 
				
			||||||
                        title: Text(realm.name),
 | 
					 | 
				
			||||||
                        subtitle: Text(
 | 
					 | 
				
			||||||
                          realm.description,
 | 
					 | 
				
			||||||
                          maxLines: 1,
 | 
					 | 
				
			||||||
                          overflow: TextOverflow.ellipsis,
 | 
					 | 
				
			||||||
                        ),
 | 
					 | 
				
			||||||
                        trailing: PopupMenuButton(
 | 
					 | 
				
			||||||
                          itemBuilder: (BuildContext context) => [
 | 
					 | 
				
			||||||
                            PopupMenuItem(
 | 
					 | 
				
			||||||
                              child: Row(
 | 
					 | 
				
			||||||
                                children: [
 | 
					 | 
				
			||||||
                                  const Icon(Symbols.edit),
 | 
					 | 
				
			||||||
                                  const Gap(16),
 | 
					 | 
				
			||||||
                                  Text('edit').tr(),
 | 
					 | 
				
			||||||
                                ],
 | 
					 | 
				
			||||||
                              ),
 | 
					 | 
				
			||||||
                              onTap: () {
 | 
					 | 
				
			||||||
                                GoRouter.of(context).pushNamed(
 | 
					 | 
				
			||||||
                                  'realmManage',
 | 
					 | 
				
			||||||
                                  queryParameters: {'editing': realm.alias},
 | 
					 | 
				
			||||||
                                ).then((value) {
 | 
					 | 
				
			||||||
                                  if (value != null) {
 | 
					 | 
				
			||||||
                                    _fetchRealms();
 | 
					 | 
				
			||||||
                                  }
 | 
					 | 
				
			||||||
                                });
 | 
					 | 
				
			||||||
                              },
 | 
					 | 
				
			||||||
                            ),
 | 
					 | 
				
			||||||
                            PopupMenuItem(
 | 
					 | 
				
			||||||
                              child: Row(
 | 
					 | 
				
			||||||
                                children: [
 | 
					 | 
				
			||||||
                                  const Icon(Symbols.delete),
 | 
					 | 
				
			||||||
                                  const Gap(16),
 | 
					 | 
				
			||||||
                                  Text('delete').tr(),
 | 
					 | 
				
			||||||
                                ],
 | 
					 | 
				
			||||||
                              ),
 | 
					 | 
				
			||||||
                              onTap: () {
 | 
					 | 
				
			||||||
                                _deleteRealm(realm);
 | 
					 | 
				
			||||||
                              },
 | 
					 | 
				
			||||||
                            ),
 | 
					 | 
				
			||||||
                          ],
 | 
					 | 
				
			||||||
                        ),
 | 
					 | 
				
			||||||
                        onTap: () {
 | 
					 | 
				
			||||||
                          GoRouter.of(context).pushNamed(
 | 
					 | 
				
			||||||
                            'realmDetail',
 | 
					 | 
				
			||||||
                            pathParameters: {'alias': realm.alias},
 | 
					 | 
				
			||||||
                          );
 | 
					 | 
				
			||||||
                        },
 | 
					 | 
				
			||||||
                      );
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    return Container(
 | 
					                    return RealmItemWidget(
 | 
				
			||||||
                      constraints: BoxConstraints(maxWidth: 640),
 | 
					                      showPopularity: false,
 | 
				
			||||||
                      child: Card(
 | 
					                      item: realm,
 | 
				
			||||||
                        margin: const EdgeInsets.all(12),
 | 
					                      isListView: _isCompactView,
 | 
				
			||||||
                        child: InkWell(
 | 
					                      actionListView: [
 | 
				
			||||||
                          borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
					                        PopupMenuItem(
 | 
				
			||||||
                          child: Column(
 | 
					                          child: Row(
 | 
				
			||||||
                            crossAxisAlignment: CrossAxisAlignment.start,
 | 
					 | 
				
			||||||
                            children: [
 | 
					                            children: [
 | 
				
			||||||
                              AspectRatio(
 | 
					                              const Icon(Symbols.edit),
 | 
				
			||||||
                                aspectRatio: 16 / 7,
 | 
					                              const Gap(16),
 | 
				
			||||||
                                child: Stack(
 | 
					                              Text('edit').tr(),
 | 
				
			||||||
                                  clipBehavior: Clip.none,
 | 
					 | 
				
			||||||
                                  fit: StackFit.expand,
 | 
					 | 
				
			||||||
                                  children: [
 | 
					 | 
				
			||||||
                                    ClipRRect(
 | 
					 | 
				
			||||||
                                      borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
					 | 
				
			||||||
                                      child: Container(
 | 
					 | 
				
			||||||
                                        color: Theme.of(context).colorScheme.surfaceContainer,
 | 
					 | 
				
			||||||
                                        child: (realm.banner?.isEmpty ?? true)
 | 
					 | 
				
			||||||
                                            ? const SizedBox.shrink()
 | 
					 | 
				
			||||||
                                            : AutoResizeUniversalImage(
 | 
					 | 
				
			||||||
                                                sn.getAttachmentUrl(realm.banner!),
 | 
					 | 
				
			||||||
                                                fit: BoxFit.cover,
 | 
					 | 
				
			||||||
                                              ),
 | 
					 | 
				
			||||||
                                      ),
 | 
					 | 
				
			||||||
                                    ),
 | 
					 | 
				
			||||||
                                    Positioned(
 | 
					 | 
				
			||||||
                                      bottom: -30,
 | 
					 | 
				
			||||||
                                      left: 18,
 | 
					 | 
				
			||||||
                                      child: AccountImage(
 | 
					 | 
				
			||||||
                                        content: realm.avatar,
 | 
					 | 
				
			||||||
                                        radius: 24,
 | 
					 | 
				
			||||||
                                        fallbackWidget: const Icon(Symbols.group, size: 24),
 | 
					 | 
				
			||||||
                                      ),
 | 
					 | 
				
			||||||
                                    ),
 | 
					 | 
				
			||||||
                                  ],
 | 
					 | 
				
			||||||
                                ),
 | 
					 | 
				
			||||||
                              ),
 | 
					 | 
				
			||||||
                              const Gap(20 + 12),
 | 
					 | 
				
			||||||
                              Column(
 | 
					 | 
				
			||||||
                                crossAxisAlignment: CrossAxisAlignment.start,
 | 
					 | 
				
			||||||
                                children: [
 | 
					 | 
				
			||||||
                                  Text(realm.name).textStyle(Theme.of(context).textTheme.titleMedium!),
 | 
					 | 
				
			||||||
                                  Text(realm.description).textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
					 | 
				
			||||||
                                ],
 | 
					 | 
				
			||||||
                              ).padding(horizontal: 24, bottom: 14),
 | 
					 | 
				
			||||||
                            ],
 | 
					                            ],
 | 
				
			||||||
                          ),
 | 
					                          ),
 | 
				
			||||||
                          onTap: () {
 | 
					                          onTap: () {
 | 
				
			||||||
                            GoRouter.of(context).pushNamed(
 | 
					                            GoRouter.of(context).pushNamed(
 | 
				
			||||||
                              'realmDetail',
 | 
					                              'realmManage',
 | 
				
			||||||
                              pathParameters: {'alias': realm.alias},
 | 
					                              queryParameters: {'editing': realm.alias},
 | 
				
			||||||
                            );
 | 
					                            ).then((value) {
 | 
				
			||||||
 | 
					                              if (value != null) {
 | 
				
			||||||
 | 
					                                _fetchRealms();
 | 
				
			||||||
 | 
					                              }
 | 
				
			||||||
 | 
					                            });
 | 
				
			||||||
                          },
 | 
					                          },
 | 
				
			||||||
                        ),
 | 
					                        ),
 | 
				
			||||||
                      ),
 | 
					                        PopupMenuItem(
 | 
				
			||||||
                    ).center();
 | 
					                          child: Row(
 | 
				
			||||||
 | 
					                            children: [
 | 
				
			||||||
 | 
					                              const Icon(Symbols.delete),
 | 
				
			||||||
 | 
					                              const Gap(16),
 | 
				
			||||||
 | 
					                              Text('delete').tr(),
 | 
				
			||||||
 | 
					                            ],
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                          onTap: () {
 | 
				
			||||||
 | 
					                            _deleteRealm(realm);
 | 
				
			||||||
 | 
					                          },
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      ],
 | 
				
			||||||
 | 
					                      onUpdate: _fetchRealms,
 | 
				
			||||||
 | 
					                    );
 | 
				
			||||||
                  },
 | 
					                  },
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -50,6 +50,8 @@ class _RealmManageScreenState extends State<RealmManageScreen> {
 | 
				
			|||||||
      _aliasController.text = out.alias;
 | 
					      _aliasController.text = out.alias;
 | 
				
			||||||
      _nameController.text = out.name;
 | 
					      _nameController.text = out.name;
 | 
				
			||||||
      _descriptionController.text = out.description;
 | 
					      _descriptionController.text = out.description;
 | 
				
			||||||
 | 
					      _isPublic = out.isPublic;
 | 
				
			||||||
 | 
					      _isCommunity = out.isCommunity;
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      // ignore: use_build_context_synchronously
 | 
					      // ignore: use_build_context_synchronously
 | 
				
			||||||
      if (context.mounted) context.showErrorDialog(err);
 | 
					      if (context.mounted) context.showErrorDialog(err);
 | 
				
			||||||
@@ -67,6 +69,9 @@ class _RealmManageScreenState extends State<RealmManageScreen> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  final _imagePicker = ImagePicker();
 | 
					  final _imagePicker = ImagePicker();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _isPublic = false;
 | 
				
			||||||
 | 
					  bool _isCommunity = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> _updateImage(String place) async {
 | 
					  Future<void> _updateImage(String place) async {
 | 
				
			||||||
    final image = await _imagePicker.pickImage(source: ImageSource.gallery);
 | 
					    final image = await _imagePicker.pickImage(source: ImageSource.gallery);
 | 
				
			||||||
    if (image == null) return;
 | 
					    if (image == null) return;
 | 
				
			||||||
@@ -138,6 +143,8 @@ class _RealmManageScreenState extends State<RealmManageScreen> {
 | 
				
			|||||||
      'description': _descriptionController.text,
 | 
					      'description': _descriptionController.text,
 | 
				
			||||||
      'avatar': _avatar,
 | 
					      'avatar': _avatar,
 | 
				
			||||||
      'banner': _banner,
 | 
					      'banner': _banner,
 | 
				
			||||||
 | 
					      'is_public': _isPublic,
 | 
				
			||||||
 | 
					      'is_community': _isCommunity,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
@@ -293,6 +300,23 @@ class _RealmManageScreenState extends State<RealmManageScreen> {
 | 
				
			|||||||
                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
					                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
                const Gap(12),
 | 
					                const Gap(12),
 | 
				
			||||||
 | 
					                CheckboxListTile(
 | 
				
			||||||
 | 
					                  value: _isPublic,
 | 
				
			||||||
 | 
					                  title: Text('realmIsPublic'.tr()),
 | 
				
			||||||
 | 
					                  subtitle: Text('realmIsPublicDescription'.tr()),
 | 
				
			||||||
 | 
					                  onChanged: (value) {
 | 
				
			||||||
 | 
					                    setState(() => _isPublic = value ?? false);
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                CheckboxListTile(
 | 
				
			||||||
 | 
					                  value: _isCommunity,
 | 
				
			||||||
 | 
					                  title: Text('realmIsCommunity'.tr()),
 | 
				
			||||||
 | 
					                  subtitle: Text('realmIsCommunityDescription'.tr()),
 | 
				
			||||||
 | 
					                  onChanged: (value) {
 | 
				
			||||||
 | 
					                    setState(() => _isCommunity = value ?? false);
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                const Gap(12),
 | 
				
			||||||
                Row(
 | 
					                Row(
 | 
				
			||||||
                  mainAxisAlignment: MainAxisAlignment.end,
 | 
					                  mainAxisAlignment: MainAxisAlignment.end,
 | 
				
			||||||
                  children: [
 | 
					                  children: [
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,16 +5,19 @@ import 'package:go_router/go_router.dart';
 | 
				
			|||||||
import 'package:material_symbols_icons/symbols.dart';
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
import 'package:styled_widget/styled_widget.dart';
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/post.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/providers/user_directory.dart';
 | 
					import 'package:surface/providers/user_directory.dart';
 | 
				
			||||||
import 'package:surface/providers/userinfo.dart';
 | 
					import 'package:surface/providers/userinfo.dart';
 | 
				
			||||||
import 'package:surface/types/account.dart';
 | 
					import 'package:surface/types/account.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/chat.dart';
 | 
				
			||||||
import 'package:surface/types/post.dart';
 | 
					import 'package:surface/types/post.dart';
 | 
				
			||||||
import 'package:surface/types/realm.dart';
 | 
					import 'package:surface/types/realm.dart';
 | 
				
			||||||
import 'package:surface/widgets/account/account_image.dart';
 | 
					import 'package:surface/widgets/account/account_image.dart';
 | 
				
			||||||
import 'package:surface/widgets/account/account_select.dart';
 | 
					import 'package:surface/widgets/account/account_select.dart';
 | 
				
			||||||
import 'package:surface/widgets/dialog.dart';
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
					import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/post/post_item.dart';
 | 
				
			||||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
					import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class RealmDetailScreen extends StatefulWidget {
 | 
					class RealmDetailScreen extends StatefulWidget {
 | 
				
			||||||
@@ -48,7 +51,8 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
 | 
				
			|||||||
  Future<void> _fetchPublishers() async {
 | 
					  Future<void> _fetchPublishers() async {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      final sn = context.read<SnNetworkProvider>();
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
      final resp = await sn.client.get('/cgi/co/publishers?realm=${widget.alias}');
 | 
					      final resp =
 | 
				
			||||||
 | 
					          await sn.client.get('/cgi/co/publishers?realm=${widget.alias}');
 | 
				
			||||||
      _publishers = List<SnPublisher>.from(
 | 
					      _publishers = List<SnPublisher>.from(
 | 
				
			||||||
        resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
 | 
					        resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
@@ -60,31 +64,68 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  List<SnChannel>? _channels;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchChannels() async {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp =
 | 
				
			||||||
 | 
					          await sn.client.get('/cgi/im/channels/${widget.alias}/public');
 | 
				
			||||||
 | 
					      _channels = List<SnChannel>.from(
 | 
				
			||||||
 | 
					        resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (mounted) context.showErrorDialog(err);
 | 
				
			||||||
 | 
					      rethrow;
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() {});
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  void initState() {
 | 
					  void initState() {
 | 
				
			||||||
    super.initState();
 | 
					    super.initState();
 | 
				
			||||||
    _fetchRealm().then((_) {
 | 
					    _fetchRealm().then((_) {
 | 
				
			||||||
      _fetchPublishers();
 | 
					      _fetchPublishers();
 | 
				
			||||||
 | 
					      _fetchChannels();
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    return DefaultTabController(
 | 
					    return DefaultTabController(
 | 
				
			||||||
      length: 3,
 | 
					      length: 4,
 | 
				
			||||||
      child: AppScaffold(
 | 
					      child: AppScaffold(
 | 
				
			||||||
        body: NestedScrollView(
 | 
					        body: NestedScrollView(
 | 
				
			||||||
          headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
 | 
					          headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
 | 
				
			||||||
            return <Widget>[
 | 
					            return <Widget>[
 | 
				
			||||||
              SliverOverlapAbsorber(
 | 
					              SliverOverlapAbsorber(
 | 
				
			||||||
                handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
 | 
					                handle:
 | 
				
			||||||
 | 
					                    NestedScrollView.sliverOverlapAbsorberHandleFor(context),
 | 
				
			||||||
                sliver: SliverAppBar(
 | 
					                sliver: SliverAppBar(
 | 
				
			||||||
                  title: Text(_realm?.name ?? 'loading'.tr()),
 | 
					                  title: Text(_realm?.name ?? 'loading'.tr()),
 | 
				
			||||||
                  bottom: TabBar(
 | 
					                  bottom: TabBar(
 | 
				
			||||||
                    tabs: [
 | 
					                    tabs: [
 | 
				
			||||||
                      Tab(icon: Icon(Symbols.home, color: Theme.of(context).appBarTheme.foregroundColor)),
 | 
					                      Tab(
 | 
				
			||||||
                      Tab(icon: Icon(Symbols.group, color: Theme.of(context).appBarTheme.foregroundColor)),
 | 
					                          icon: Icon(Symbols.home,
 | 
				
			||||||
                      Tab(icon: Icon(Symbols.settings, color: Theme.of(context).appBarTheme.foregroundColor)),
 | 
					                              color: Theme.of(context)
 | 
				
			||||||
 | 
					                                  .appBarTheme
 | 
				
			||||||
 | 
					                                  .foregroundColor)),
 | 
				
			||||||
 | 
					                      Tab(
 | 
				
			||||||
 | 
					                          icon: Icon(Symbols.explore,
 | 
				
			||||||
 | 
					                              color: Theme.of(context)
 | 
				
			||||||
 | 
					                                  .appBarTheme
 | 
				
			||||||
 | 
					                                  .foregroundColor)),
 | 
				
			||||||
 | 
					                      Tab(
 | 
				
			||||||
 | 
					                          icon: Icon(Symbols.group,
 | 
				
			||||||
 | 
					                              color: Theme.of(context)
 | 
				
			||||||
 | 
					                                  .appBarTheme
 | 
				
			||||||
 | 
					                                  .foregroundColor)),
 | 
				
			||||||
 | 
					                      Tab(
 | 
				
			||||||
 | 
					                          icon: Icon(Symbols.settings,
 | 
				
			||||||
 | 
					                              color: Theme.of(context)
 | 
				
			||||||
 | 
					                                  .appBarTheme
 | 
				
			||||||
 | 
					                                  .foregroundColor)),
 | 
				
			||||||
                    ],
 | 
					                    ],
 | 
				
			||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
@@ -93,7 +134,9 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
 | 
				
			|||||||
          },
 | 
					          },
 | 
				
			||||||
          body: TabBarView(
 | 
					          body: TabBarView(
 | 
				
			||||||
            children: [
 | 
					            children: [
 | 
				
			||||||
              _RealmDetailHomeWidget(realm: _realm, publishers: _publishers),
 | 
					              _RealmDetailHomeWidget(
 | 
				
			||||||
 | 
					                  realm: _realm, publishers: _publishers, channels: _channels),
 | 
				
			||||||
 | 
					              _RealmPostListWidget(realm: _realm),
 | 
				
			||||||
              _RealmMemberListWidget(realm: _realm),
 | 
					              _RealmMemberListWidget(realm: _realm),
 | 
				
			||||||
              _RealmSettingsWidget(
 | 
					              _RealmSettingsWidget(
 | 
				
			||||||
                realm: _realm,
 | 
					                realm: _realm,
 | 
				
			||||||
@@ -112,8 +155,10 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
 | 
				
			|||||||
class _RealmDetailHomeWidget extends StatelessWidget {
 | 
					class _RealmDetailHomeWidget extends StatelessWidget {
 | 
				
			||||||
  final SnRealm? realm;
 | 
					  final SnRealm? realm;
 | 
				
			||||||
  final List<SnPublisher>? publishers;
 | 
					  final List<SnPublisher>? publishers;
 | 
				
			||||||
 | 
					  final List<SnChannel>? channels;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const _RealmDetailHomeWidget({required this.realm, this.publishers});
 | 
					  const _RealmDetailHomeWidget(
 | 
				
			||||||
 | 
					      {required this.realm, this.publishers, this.channels});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
@@ -135,30 +180,78 @@ class _RealmDetailHomeWidget extends StatelessWidget {
 | 
				
			|||||||
            ],
 | 
					            ],
 | 
				
			||||||
          ).padding(horizontal: 24),
 | 
					          ).padding(horizontal: 24),
 | 
				
			||||||
        const Gap(16),
 | 
					        const Gap(16),
 | 
				
			||||||
        const Divider(),
 | 
					        const Divider(height: 1),
 | 
				
			||||||
        Expanded(
 | 
					        Expanded(
 | 
				
			||||||
          child: ListView.builder(
 | 
					          child: CustomScrollView(
 | 
				
			||||||
            padding: EdgeInsets.zero,
 | 
					            slivers: [
 | 
				
			||||||
            itemCount: publishers?.length ?? 0,
 | 
					              if (publishers?.isNotEmpty ?? false)
 | 
				
			||||||
            itemBuilder: (context, idx) {
 | 
					                SliverToBoxAdapter(
 | 
				
			||||||
              final ele = publishers![idx];
 | 
					                  child: Container(
 | 
				
			||||||
              return ListTile(
 | 
					                    width: double.infinity,
 | 
				
			||||||
                contentPadding: const EdgeInsets.symmetric(horizontal: 20),
 | 
					                    color: Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
				
			||||||
                leading: AccountImage(
 | 
					                    child: Text('realmCommunityPublishersHint'.tr(),
 | 
				
			||||||
                  content: ele.avatar,
 | 
					                            style: Theme.of(context).textTheme.bodyMedium)
 | 
				
			||||||
                  fallbackWidget: const Icon(Symbols.group, size: 24),
 | 
					                        .padding(horizontal: 24, vertical: 8),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
                title: Text(ele.nick),
 | 
					              SliverList.builder(
 | 
				
			||||||
                subtitle: Text('@${ele.name}'),
 | 
					                itemCount: publishers?.length ?? 0,
 | 
				
			||||||
                trailing: const Icon(Symbols.chevron_right),
 | 
					                itemBuilder: (context, idx) {
 | 
				
			||||||
                onTap: () {
 | 
					                  final ele = publishers![idx];
 | 
				
			||||||
                  GoRouter.of(context).pushNamed(
 | 
					                  return ListTile(
 | 
				
			||||||
                    'postPublisher',
 | 
					                    contentPadding: const EdgeInsets.symmetric(horizontal: 20),
 | 
				
			||||||
                    pathParameters: {'name': ele.name},
 | 
					                    leading: AccountImage(
 | 
				
			||||||
 | 
					                      content: ele.avatar,
 | 
				
			||||||
 | 
					                      fallbackWidget: const Icon(Symbols.group, size: 24),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    title: Text(ele.nick),
 | 
				
			||||||
 | 
					                    subtitle: Text('@${ele.name}'),
 | 
				
			||||||
 | 
					                    trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					                    onTap: () {
 | 
				
			||||||
 | 
					                      GoRouter.of(context).pushNamed(
 | 
				
			||||||
 | 
					                        'postPublisher',
 | 
				
			||||||
 | 
					                        pathParameters: {'name': ele.name},
 | 
				
			||||||
 | 
					                      );
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
                  );
 | 
					                  );
 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
              );
 | 
					              ),
 | 
				
			||||||
            },
 | 
					              if (channels?.isNotEmpty ?? false)
 | 
				
			||||||
 | 
					                SliverToBoxAdapter(
 | 
				
			||||||
 | 
					                  child: Container(
 | 
				
			||||||
 | 
					                    width: double.infinity,
 | 
				
			||||||
 | 
					                    color: Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
				
			||||||
 | 
					                    child: Text('realmCommunityPublicChannelsHint'.tr(),
 | 
				
			||||||
 | 
					                            style: Theme.of(context).textTheme.bodyMedium)
 | 
				
			||||||
 | 
					                        .padding(horizontal: 24, vertical: 8),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              SliverList.builder(
 | 
				
			||||||
 | 
					                itemCount: channels?.length ?? 0,
 | 
				
			||||||
 | 
					                itemBuilder: (context, idx) {
 | 
				
			||||||
 | 
					                  final ele = channels![idx];
 | 
				
			||||||
 | 
					                  return ListTile(
 | 
				
			||||||
 | 
					                    contentPadding: const EdgeInsets.symmetric(horizontal: 20),
 | 
				
			||||||
 | 
					                    leading: AccountImage(
 | 
				
			||||||
 | 
					                      content: null,
 | 
				
			||||||
 | 
					                      fallbackWidget: const Icon(Symbols.chat, size: 20),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    title: Text(ele.name),
 | 
				
			||||||
 | 
					                    subtitle: Text('#${ele.alias}'),
 | 
				
			||||||
 | 
					                    trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					                    onTap: () {
 | 
				
			||||||
 | 
					                      GoRouter.of(context).pushNamed(
 | 
				
			||||||
 | 
					                        'chatRoom',
 | 
				
			||||||
 | 
					                        pathParameters: {
 | 
				
			||||||
 | 
					                          'scope': realm?.alias ?? 'global',
 | 
				
			||||||
 | 
					                          'alias': ele.alias,
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                      );
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                  );
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
          ),
 | 
					          ),
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
      ],
 | 
					      ],
 | 
				
			||||||
@@ -166,6 +259,72 @@ class _RealmDetailHomeWidget extends StatelessWidget {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _RealmPostListWidget extends StatefulWidget {
 | 
				
			||||||
 | 
					  final SnRealm? realm;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const _RealmPostListWidget({this.realm});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<_RealmPostListWidget> createState() => _RealmPostListWidgetState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _RealmPostListWidgetState extends State<_RealmPostListWidget> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					  int? _totalCount;
 | 
				
			||||||
 | 
					  final List<SnPost> _posts = List.empty(growable: true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchPosts() async {
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final pt = context.read<SnPostContentProvider>();
 | 
				
			||||||
 | 
					      final out = await pt.listPosts(
 | 
				
			||||||
 | 
					        take: 10,
 | 
				
			||||||
 | 
					        offset: _posts.length,
 | 
				
			||||||
 | 
					        realm: widget.realm?.id.toString(),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      _totalCount = out.$2;
 | 
				
			||||||
 | 
					      _posts.addAll(out.$1);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return MediaQuery.removePadding(
 | 
				
			||||||
 | 
					      context: context,
 | 
				
			||||||
 | 
					      removeTop: true,
 | 
				
			||||||
 | 
					      child: RefreshIndicator(
 | 
				
			||||||
 | 
					        onRefresh: _fetchPosts,
 | 
				
			||||||
 | 
					        child: InfiniteList(
 | 
				
			||||||
 | 
					          itemCount: _posts.length,
 | 
				
			||||||
 | 
					          isLoading: _isBusy,
 | 
				
			||||||
 | 
					          hasReachedMax: _totalCount != null && _posts.length >= _totalCount!,
 | 
				
			||||||
 | 
					          onFetchData: _fetchPosts,
 | 
				
			||||||
 | 
					          itemBuilder: (context, idx) {
 | 
				
			||||||
 | 
					            final post = _posts[idx];
 | 
				
			||||||
 | 
					            return OpenablePostItem(
 | 
				
			||||||
 | 
					              data: post,
 | 
				
			||||||
 | 
					              maxWidth: 640,
 | 
				
			||||||
 | 
					              onChanged: (data) {
 | 
				
			||||||
 | 
					                setState(() => _posts[idx] = data);
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					              onDeleted: () {
 | 
				
			||||||
 | 
					                setState(() => _posts.removeAt(idx));
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          separatorBuilder: (_, __) => const Gap(8),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    ).padding(top: 8);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class _RealmMemberListWidget extends StatefulWidget {
 | 
					class _RealmMemberListWidget extends StatefulWidget {
 | 
				
			||||||
  final SnRealm? realm;
 | 
					  final SnRealm? realm;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -187,10 +346,12 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
 | 
				
			|||||||
    try {
 | 
					    try {
 | 
				
			||||||
      final ud = context.read<UserDirectoryProvider>();
 | 
					      final ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
      final sn = context.read<SnNetworkProvider>();
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
      final resp = await sn.client.get('/cgi/id/realms/${widget.realm!.alias}/members', queryParameters: {
 | 
					      final resp = await sn.client.get(
 | 
				
			||||||
        'take': 10,
 | 
					          '/cgi/id/realms/${widget.realm!.alias}/members',
 | 
				
			||||||
        'offset': 0,
 | 
					          queryParameters: {
 | 
				
			||||||
      });
 | 
					            'take': 10,
 | 
				
			||||||
 | 
					            'offset': _members.length,
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      final out = List<SnRealmMember>.from(
 | 
					      final out = List<SnRealmMember>.from(
 | 
				
			||||||
        resp.data['data']?.map((e) => SnRealmMember.fromJson(e)) ?? [],
 | 
					        resp.data['data']?.map((e) => SnRealmMember.fromJson(e)) ?? [],
 | 
				
			||||||
@@ -296,14 +457,14 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
 | 
				
			|||||||
            return ListTile(
 | 
					            return ListTile(
 | 
				
			||||||
              contentPadding: const EdgeInsets.only(right: 24, left: 16),
 | 
					              contentPadding: const EdgeInsets.only(right: 24, left: 16),
 | 
				
			||||||
              leading: AccountImage(
 | 
					              leading: AccountImage(
 | 
				
			||||||
                content: ud.getAccountFromCache(member.accountId)?.avatar,
 | 
					                content: ud.getFromCache(member.accountId)?.avatar,
 | 
				
			||||||
                fallbackWidget: const Icon(Symbols.group, size: 24),
 | 
					                fallbackWidget: const Icon(Symbols.group, size: 24),
 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
              title: Text(
 | 
					              title: Text(
 | 
				
			||||||
                ud.getAccountFromCache(member.accountId)?.nick ?? 'unknown'.tr(),
 | 
					                ud.getFromCache(member.accountId)?.nick ?? 'unknown'.tr(),
 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
              subtitle: Text(
 | 
					              subtitle: Text(
 | 
				
			||||||
                ud.getAccountFromCache(member.accountId)?.name ?? 'unknown'.tr(),
 | 
					                ud.getFromCache(member.accountId)?.name ?? 'unknown'.tr(),
 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
              trailing: IconButton(
 | 
					              trailing: IconButton(
 | 
				
			||||||
                icon: const Icon(Symbols.person_remove),
 | 
					                icon: const Icon(Symbols.person_remove),
 | 
				
			||||||
@@ -343,12 +504,31 @@ class _RealmSettingsWidgetState extends State<_RealmSettingsWidget> {
 | 
				
			|||||||
    setState(() => _isBusy = true);
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      await sn.client.delete('/cgi/id/realms/${widget.realm!.alias}');
 | 
					      await sn.client.delete('/cgi/id/realms/${widget.realm!.id}');
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      Navigator.pop(context, true);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _leaveRealm() async {
 | 
				
			||||||
 | 
					    final confirm = await context.showConfirmDialog(
 | 
				
			||||||
 | 
					      'realmLeave'.tr(),
 | 
				
			||||||
 | 
					      'realmLeaveDescription'.tr(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    if (!confirm) return;
 | 
				
			||||||
 | 
					    if (!mounted) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await sn.client.delete('/cgi/id/realms/${widget.realm!.alias}/me');
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      Navigator.pop(context, true);
 | 
					      Navigator.pop(context, true);
 | 
				
			||||||
      context.showSnackbar('realmDeleted'.tr(args: [
 | 
					 | 
				
			||||||
        '#${widget.realm!.alias}',
 | 
					 | 
				
			||||||
      ]));
 | 
					 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      context.showErrorDialog(err);
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
@@ -367,22 +547,31 @@ class _RealmSettingsWidgetState extends State<_RealmSettingsWidget> {
 | 
				
			|||||||
      children: [
 | 
					      children: [
 | 
				
			||||||
        const Gap(8),
 | 
					        const Gap(8),
 | 
				
			||||||
        ListTile(
 | 
					        ListTile(
 | 
				
			||||||
          leading: const Icon(Symbols.edit),
 | 
					          leading: const Icon(Symbols.logout),
 | 
				
			||||||
          trailing: const Icon(Symbols.chevron_right),
 | 
					          trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
          title: Text('realmEdit').tr(),
 | 
					          title: Text('realmLeave').tr(),
 | 
				
			||||||
          subtitle: Text('realmEditDescription').tr(),
 | 
					          subtitle: Text('realmLeaveDescription').tr(),
 | 
				
			||||||
          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
					          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
          onTap: () {
 | 
					          onTap: _isBusy ? null : () => _leaveRealm(),
 | 
				
			||||||
            GoRouter.of(context).pushNamed(
 | 
					 | 
				
			||||||
              'realmManage',
 | 
					 | 
				
			||||||
              queryParameters: {'editing': widget.realm!.alias},
 | 
					 | 
				
			||||||
            ).then((value) {
 | 
					 | 
				
			||||||
              if (value != null) {
 | 
					 | 
				
			||||||
                widget.onUpdate();
 | 
					 | 
				
			||||||
              }
 | 
					 | 
				
			||||||
            });
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
 | 
					        if (isOwned)
 | 
				
			||||||
 | 
					          ListTile(
 | 
				
			||||||
 | 
					            leading: const Icon(Symbols.edit),
 | 
				
			||||||
 | 
					            trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					            title: Text('realmEdit').tr(),
 | 
				
			||||||
 | 
					            subtitle: Text('realmEditDescription').tr(),
 | 
				
			||||||
 | 
					            contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					            onTap: () {
 | 
				
			||||||
 | 
					              GoRouter.of(context).pushNamed(
 | 
				
			||||||
 | 
					                'realmManage',
 | 
				
			||||||
 | 
					                queryParameters: {'editing': widget.realm!.alias},
 | 
				
			||||||
 | 
					              ).then((value) {
 | 
				
			||||||
 | 
					                if (value != null) {
 | 
				
			||||||
 | 
					                  widget.onUpdate();
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					              });
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
        if (isOwned)
 | 
					        if (isOwned)
 | 
				
			||||||
          ListTile(
 | 
					          ListTile(
 | 
				
			||||||
            leading: const Icon(Symbols.delete),
 | 
					            leading: const Icon(Symbols.delete),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +1,10 @@
 | 
				
			|||||||
import 'package:easy_localization/easy_localization.dart';
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:gap/gap.dart';
 | 
					import 'package:gap/gap.dart';
 | 
				
			||||||
import 'package:go_router/go_router.dart';
 | 
					 | 
				
			||||||
import 'package:material_symbols_icons/symbols.dart';
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
import 'package:styled_widget/styled_widget.dart';
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/config.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/providers/userinfo.dart';
 | 
					import 'package:surface/providers/userinfo.dart';
 | 
				
			||||||
import 'package:surface/types/chat.dart';
 | 
					import 'package:surface/types/chat.dart';
 | 
				
			||||||
@@ -13,7 +13,7 @@ import 'package:surface/widgets/account/account_image.dart';
 | 
				
			|||||||
import 'package:surface/widgets/dialog.dart';
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
					import 'package:surface/widgets/loading_indicator.dart';
 | 
				
			||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
					import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
				
			||||||
import 'package:surface/widgets/universal_image.dart';
 | 
					import 'package:surface/widgets/realm/realm_item.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class RealmDiscoveryScreen extends StatefulWidget {
 | 
					class RealmDiscoveryScreen extends StatefulWidget {
 | 
				
			||||||
  const RealmDiscoveryScreen({super.key});
 | 
					  const RealmDiscoveryScreen({super.key});
 | 
				
			||||||
@@ -25,6 +25,7 @@ class RealmDiscoveryScreen extends StatefulWidget {
 | 
				
			|||||||
class _RealmDiscoveryScreenState extends State<RealmDiscoveryScreen> {
 | 
					class _RealmDiscoveryScreenState extends State<RealmDiscoveryScreen> {
 | 
				
			||||||
  List<SnRealm>? _realms;
 | 
					  List<SnRealm>? _realms;
 | 
				
			||||||
  bool _isBusy = false;
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					  bool _isCompactView = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> _fetchRealms() async {
 | 
					  Future<void> _fetchRealms() async {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
@@ -45,16 +46,25 @@ class _RealmDiscoveryScreenState extends State<RealmDiscoveryScreen> {
 | 
				
			|||||||
  @override
 | 
					  @override
 | 
				
			||||||
  void initState() {
 | 
					  void initState() {
 | 
				
			||||||
    super.initState();
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _isCompactView = context.read<ConfigProvider>().realmCompactView;
 | 
				
			||||||
    _fetchRealms();
 | 
					    _fetchRealms();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    final sn = context.read<SnNetworkProvider>();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return AppScaffold(
 | 
					    return AppScaffold(
 | 
				
			||||||
      appBar: AppBar(
 | 
					      appBar: AppBar(
 | 
				
			||||||
        title: Text('screenRealmDiscovery').tr(),
 | 
					        title: Text('screenRealmDiscovery').tr(),
 | 
				
			||||||
 | 
					        actions: [
 | 
				
			||||||
 | 
					          IconButton(
 | 
				
			||||||
 | 
					            icon: _isCompactView ? const Icon(Symbols.view_list) : const Icon(Symbols.view_module),
 | 
				
			||||||
 | 
					            onPressed: () {
 | 
				
			||||||
 | 
					              setState(() => _isCompactView = !_isCompactView);
 | 
				
			||||||
 | 
					              context.read<ConfigProvider>().realmCompactView = _isCompactView;
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          const Gap(8),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
      body: Column(
 | 
					      body: Column(
 | 
				
			||||||
        children: [
 | 
					        children: [
 | 
				
			||||||
@@ -67,64 +77,16 @@ class _RealmDiscoveryScreenState extends State<RealmDiscoveryScreen> {
 | 
				
			|||||||
                itemCount: _realms?.length ?? 0,
 | 
					                itemCount: _realms?.length ?? 0,
 | 
				
			||||||
                itemBuilder: (context, idx) {
 | 
					                itemBuilder: (context, idx) {
 | 
				
			||||||
                  final realm = _realms![idx];
 | 
					                  final realm = _realms![idx];
 | 
				
			||||||
                  return Container(
 | 
					                  return RealmItemWidget(
 | 
				
			||||||
                    constraints: BoxConstraints(maxWidth: 640),
 | 
					                    item: realm,
 | 
				
			||||||
                    child: Card(
 | 
					                    isListView: _isCompactView,
 | 
				
			||||||
                      margin: const EdgeInsets.all(12),
 | 
					                    onTap: () {
 | 
				
			||||||
                      child: InkWell(
 | 
					                      showModalBottomSheet(
 | 
				
			||||||
                        borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
					                        context: context,
 | 
				
			||||||
                        child: Column(
 | 
					                        builder: (context) => _RealmJoinPopup(realm: realm),
 | 
				
			||||||
                          crossAxisAlignment: CrossAxisAlignment.start,
 | 
					                      );
 | 
				
			||||||
                          children: [
 | 
					                    },
 | 
				
			||||||
                            AspectRatio(
 | 
					                  );
 | 
				
			||||||
                              aspectRatio: 16 / 7,
 | 
					 | 
				
			||||||
                              child: Stack(
 | 
					 | 
				
			||||||
                                clipBehavior: Clip.none,
 | 
					 | 
				
			||||||
                                fit: StackFit.expand,
 | 
					 | 
				
			||||||
                                children: [
 | 
					 | 
				
			||||||
                                  ClipRRect(
 | 
					 | 
				
			||||||
                                    borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
					 | 
				
			||||||
                                    child: Container(
 | 
					 | 
				
			||||||
                                      color: Theme.of(context).colorScheme.surfaceContainer,
 | 
					 | 
				
			||||||
                                      child: (realm.banner?.isEmpty ?? true)
 | 
					 | 
				
			||||||
                                          ? const SizedBox.shrink()
 | 
					 | 
				
			||||||
                                          : AutoResizeUniversalImage(
 | 
					 | 
				
			||||||
                                              sn.getAttachmentUrl(realm.banner!),
 | 
					 | 
				
			||||||
                                              fit: BoxFit.cover,
 | 
					 | 
				
			||||||
                                            ),
 | 
					 | 
				
			||||||
                                    ),
 | 
					 | 
				
			||||||
                                  ),
 | 
					 | 
				
			||||||
                                  Positioned(
 | 
					 | 
				
			||||||
                                    bottom: -30,
 | 
					 | 
				
			||||||
                                    left: 18,
 | 
					 | 
				
			||||||
                                    child: AccountImage(
 | 
					 | 
				
			||||||
                                      content: realm.avatar,
 | 
					 | 
				
			||||||
                                      radius: 24,
 | 
					 | 
				
			||||||
                                      fallbackWidget: const Icon(Symbols.group, size: 24),
 | 
					 | 
				
			||||||
                                    ),
 | 
					 | 
				
			||||||
                                  ),
 | 
					 | 
				
			||||||
                                ],
 | 
					 | 
				
			||||||
                              ),
 | 
					 | 
				
			||||||
                            ),
 | 
					 | 
				
			||||||
                            const Gap(20 + 12),
 | 
					 | 
				
			||||||
                            Column(
 | 
					 | 
				
			||||||
                              crossAxisAlignment: CrossAxisAlignment.start,
 | 
					 | 
				
			||||||
                              children: [
 | 
					 | 
				
			||||||
                                Text(realm.name).textStyle(Theme.of(context).textTheme.titleMedium!),
 | 
					 | 
				
			||||||
                                Text(realm.description).textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
					 | 
				
			||||||
                              ],
 | 
					 | 
				
			||||||
                            ).padding(horizontal: 24, bottom: 14),
 | 
					 | 
				
			||||||
                          ],
 | 
					 | 
				
			||||||
                        ),
 | 
					 | 
				
			||||||
                        onTap: () {
 | 
					 | 
				
			||||||
                          showModalBottomSheet(
 | 
					 | 
				
			||||||
                            context: context,
 | 
					 | 
				
			||||||
                            builder: (context) => _RealmJoinPopup(realm: realm),
 | 
					 | 
				
			||||||
                          );
 | 
					 | 
				
			||||||
                        },
 | 
					 | 
				
			||||||
                      ),
 | 
					 | 
				
			||||||
                    ),
 | 
					 | 
				
			||||||
                  ).center();
 | 
					 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
@@ -155,7 +117,7 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
 | 
				
			|||||||
    try {
 | 
					    try {
 | 
				
			||||||
      setState(() => _isBusy = true);
 | 
					      setState(() => _isBusy = true);
 | 
				
			||||||
      final sn = context.read<SnNetworkProvider>();
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
      final resp = await sn.client.get('/cgi/im/channels/${widget.realm.alias}');
 | 
					      final resp = await sn.client.get('/cgi/im/channels/${widget.realm.alias}/public');
 | 
				
			||||||
      final out = List<SnChannel>.from(
 | 
					      final out = List<SnChannel>.from(
 | 
				
			||||||
        resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(),
 | 
					        resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(),
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
@@ -236,6 +198,8 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
 | 
				
			|||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
                  Text(
 | 
					                  Text(
 | 
				
			||||||
                    widget.realm.description,
 | 
					                    widget.realm.description,
 | 
				
			||||||
 | 
					                    maxLines: 3,
 | 
				
			||||||
 | 
					                    overflow: TextOverflow.ellipsis,
 | 
				
			||||||
                    style: Theme.of(context).textTheme.bodyMedium,
 | 
					                    style: Theme.of(context).textTheme.bodyMedium,
 | 
				
			||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
                ],
 | 
					                ],
 | 
				
			||||||
@@ -261,7 +225,7 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
 | 
				
			|||||||
            itemBuilder: (context, index) {
 | 
					            itemBuilder: (context, index) {
 | 
				
			||||||
              final channel = _channels![index];
 | 
					              final channel = _channels![index];
 | 
				
			||||||
              return CheckboxListTile(
 | 
					              return CheckboxListTile(
 | 
				
			||||||
                value: _planJoinChannels.contains(channel.alias) ?? false,
 | 
					                value: _planJoinChannels.contains(channel.alias),
 | 
				
			||||||
                title: Text(channel.name),
 | 
					                title: Text(channel.name),
 | 
				
			||||||
                subtitle: Text(
 | 
					                subtitle: Text(
 | 
				
			||||||
                  channel.description,
 | 
					                  channel.description,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,8 +5,11 @@ import 'package:dropdown_button2/dropdown_button2.dart';
 | 
				
			|||||||
import 'package:easy_localization/easy_localization.dart';
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
import 'package:flutter/foundation.dart';
 | 
					import 'package:flutter/foundation.dart';
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/services.dart';
 | 
				
			||||||
 | 
					import 'package:flutter_cache_manager/flutter_cache_manager.dart';
 | 
				
			||||||
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
 | 
					import 'package:flutter_colorpicker/flutter_colorpicker.dart';
 | 
				
			||||||
import 'package:go_router/go_router.dart';
 | 
					import 'package:go_router/go_router.dart';
 | 
				
			||||||
 | 
					import 'package:google_fonts/google_fonts.dart';
 | 
				
			||||||
import 'package:image_picker/image_picker.dart';
 | 
					import 'package:image_picker/image_picker.dart';
 | 
				
			||||||
import 'package:material_symbols_icons/symbols.dart';
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
import 'package:path_provider/path_provider.dart';
 | 
					import 'package:path_provider/path_provider.dart';
 | 
				
			||||||
@@ -14,11 +17,15 @@ import 'package:provider/provider.dart';
 | 
				
			|||||||
import 'package:shared_preferences/shared_preferences.dart';
 | 
					import 'package:shared_preferences/shared_preferences.dart';
 | 
				
			||||||
import 'package:styled_widget/styled_widget.dart';
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
import 'package:surface/providers/config.dart';
 | 
					import 'package:surface/providers/config.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/database.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/notification.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_sticker.dart';
 | 
				
			||||||
import 'package:surface/providers/theme.dart';
 | 
					import 'package:surface/providers/theme.dart';
 | 
				
			||||||
import 'package:surface/theme.dart';
 | 
					import 'package:surface/theme.dart';
 | 
				
			||||||
import 'package:surface/widgets/dialog.dart';
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
					import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/updater.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const Map<String, Color> kColorSchemes = {
 | 
					const Map<String, Color> kColorSchemes = {
 | 
				
			||||||
  'colorSchemeIndigo': Colors.indigo,
 | 
					  'colorSchemeIndigo': Colors.indigo,
 | 
				
			||||||
@@ -42,6 +49,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
  late final SharedPreferences _prefs;
 | 
					  late final SharedPreferences _prefs;
 | 
				
			||||||
  String _docBasepath = '/';
 | 
					  String _docBasepath = '/';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final TextEditingController _customFontController = TextEditingController();
 | 
				
			||||||
  final TextEditingController _serverUrlController = TextEditingController();
 | 
					  final TextEditingController _serverUrlController = TextEditingController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
@@ -56,17 +64,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
    final config = context.read<ConfigProvider>();
 | 
					    final config = context.read<ConfigProvider>();
 | 
				
			||||||
    _prefs = config.prefs;
 | 
					    _prefs = config.prefs;
 | 
				
			||||||
    _serverUrlController.text = config.serverUrl;
 | 
					    _serverUrlController.text = config.serverUrl;
 | 
				
			||||||
 | 
					    if (_prefs.getString(kAppCustomFonts) != null) {
 | 
				
			||||||
 | 
					      _customFontController.text = _prefs.getString(kAppCustomFonts) ?? '';
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  void dispose() {
 | 
					  void dispose() {
 | 
				
			||||||
    _serverUrlController.dispose();
 | 
					    _serverUrlController.dispose();
 | 
				
			||||||
 | 
					    _customFontController.dispose();
 | 
				
			||||||
    super.dispose();
 | 
					    super.dispose();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    final sn = context.read<SnNetworkProvider>();
 | 
					    final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					    final dt = context.read<DatabaseProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return AppScaffold(
 | 
					    return AppScaffold(
 | 
				
			||||||
      appBar: AppBar(
 | 
					      appBar: AppBar(
 | 
				
			||||||
@@ -81,7 +94,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
            Column(
 | 
					            Column(
 | 
				
			||||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
					              crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
              children: [
 | 
					              children: [
 | 
				
			||||||
                Text('settingsAppearance').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
					                Text('settingsAppearance')
 | 
				
			||||||
 | 
					                    .bold()
 | 
				
			||||||
 | 
					                    .fontSize(17)
 | 
				
			||||||
 | 
					                    .tr()
 | 
				
			||||||
 | 
					                    .padding(horizontal: 20, bottom: 4),
 | 
				
			||||||
                ListTile(
 | 
					                ListTile(
 | 
				
			||||||
                  title: Text('settingsDisplayLanguage').tr(),
 | 
					                  title: Text('settingsDisplayLanguage').tr(),
 | 
				
			||||||
                  subtitle: Text('settingsDisplayLanguageDescription').tr(),
 | 
					                  subtitle: Text('settingsDisplayLanguageDescription').tr(),
 | 
				
			||||||
@@ -91,15 +108,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
                    child: DropdownButton2<Locale?>(
 | 
					                    child: DropdownButton2<Locale?>(
 | 
				
			||||||
                      isExpanded: true,
 | 
					                      isExpanded: true,
 | 
				
			||||||
                      items: [
 | 
					                      items: [
 | 
				
			||||||
                        ...EasyLocalization.of(context)!.supportedLocales.mapIndexed((idx, ele) {
 | 
					                        ...EasyLocalization.of(context)!
 | 
				
			||||||
 | 
					                            .supportedLocales
 | 
				
			||||||
 | 
					                            .mapIndexed((idx, ele) {
 | 
				
			||||||
                          return DropdownMenuItem<Locale?>(
 | 
					                          return DropdownMenuItem<Locale?>(
 | 
				
			||||||
                            value: ele,
 | 
					                            value: ele,
 | 
				
			||||||
                            child: Text('${ele.languageCode}-${ele.countryCode}').fontSize(14),
 | 
					                            child:
 | 
				
			||||||
 | 
					                                Text('${ele.languageCode}-${ele.countryCode}')
 | 
				
			||||||
 | 
					                                    .fontSize(14),
 | 
				
			||||||
                          );
 | 
					                          );
 | 
				
			||||||
                        }),
 | 
					                        }),
 | 
				
			||||||
                        DropdownMenuItem<Locale?>(
 | 
					                        DropdownMenuItem<Locale?>(
 | 
				
			||||||
                          value: null,
 | 
					                          value: null,
 | 
				
			||||||
                          child: Text('settingsDisplayLanguageSystem').tr().fontSize(14),
 | 
					                          child: Text('settingsDisplayLanguageSystem')
 | 
				
			||||||
 | 
					                              .tr()
 | 
				
			||||||
 | 
					                              .fontSize(14),
 | 
				
			||||||
                        ),
 | 
					                        ),
 | 
				
			||||||
                      ],
 | 
					                      ],
 | 
				
			||||||
                      value: EasyLocalization.of(context)!.currentLocale,
 | 
					                      value: EasyLocalization.of(context)!.currentLocale,
 | 
				
			||||||
@@ -132,10 +155,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
                    leading: const Icon(Symbols.image),
 | 
					                    leading: const Icon(Symbols.image),
 | 
				
			||||||
                    trailing: const Icon(Symbols.chevron_right),
 | 
					                    trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
                    onTap: () async {
 | 
					                    onTap: () async {
 | 
				
			||||||
                      final image = await ImagePicker().pickImage(source: ImageSource.gallery);
 | 
					                      final image = await ImagePicker()
 | 
				
			||||||
 | 
					                          .pickImage(source: ImageSource.gallery);
 | 
				
			||||||
                      if (image == null) return;
 | 
					                      if (image == null) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                      await File(image.path).copy('$_docBasepath/app_background_image');
 | 
					                      await File(image.path)
 | 
				
			||||||
 | 
					                          .copy('$_docBasepath/app_background_image');
 | 
				
			||||||
                      _prefs.setBool(kAppBackgroundStoreKey, true);
 | 
					                      _prefs.setBool(kAppBackgroundStoreKey, true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                      setState(() {});
 | 
					                      setState(() {});
 | 
				
			||||||
@@ -143,7 +168,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
                if (!kIsWeb)
 | 
					                if (!kIsWeb)
 | 
				
			||||||
                  FutureBuilder<bool>(
 | 
					                  FutureBuilder<bool>(
 | 
				
			||||||
                      future: File('$_docBasepath/app_background_image').exists(),
 | 
					                      future:
 | 
				
			||||||
 | 
					                          File('$_docBasepath/app_background_image').exists(),
 | 
				
			||||||
                      builder: (context, snapshot) {
 | 
					                      builder: (context, snapshot) {
 | 
				
			||||||
                        if (!snapshot.hasData || !snapshot.data!) {
 | 
					                        if (!snapshot.hasData || !snapshot.data!) {
 | 
				
			||||||
                          return const SizedBox.shrink();
 | 
					                          return const SizedBox.shrink();
 | 
				
			||||||
@@ -151,12 +177,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
                        return ListTile(
 | 
					                        return ListTile(
 | 
				
			||||||
                          title: Text('settingsBackgroundImageClear').tr(),
 | 
					                          title: Text('settingsBackgroundImageClear').tr(),
 | 
				
			||||||
                          subtitle: Text('settingsBackgroundImageClearDescription').tr(),
 | 
					                          subtitle:
 | 
				
			||||||
                          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
					                              Text('settingsBackgroundImageClearDescription')
 | 
				
			||||||
 | 
					                                  .tr(),
 | 
				
			||||||
 | 
					                          contentPadding:
 | 
				
			||||||
 | 
					                              const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
                          leading: const Icon(Symbols.texture),
 | 
					                          leading: const Icon(Symbols.texture),
 | 
				
			||||||
                          trailing: const Icon(Symbols.chevron_right),
 | 
					                          trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
                          onTap: () {
 | 
					                          onTap: () {
 | 
				
			||||||
                            File('$_docBasepath/app_background_image').deleteSync();
 | 
					                            File('$_docBasepath/app_background_image')
 | 
				
			||||||
 | 
					                                .deleteSync();
 | 
				
			||||||
                            _prefs.remove(kAppBackgroundStoreKey);
 | 
					                            _prefs.remove(kAppBackgroundStoreKey);
 | 
				
			||||||
                            setState(() {});
 | 
					                            setState(() {});
 | 
				
			||||||
                          },
 | 
					                          },
 | 
				
			||||||
@@ -186,34 +216,35 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
                  contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
					                  contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
                  trailing: const Icon(Symbols.chevron_right),
 | 
					                  trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
                  onTap: () async {
 | 
					                  onTap: () async {
 | 
				
			||||||
                    Color pickerColor = Color(_prefs.getInt(kAppColorSchemeStoreKey) ?? Colors.indigo.value);
 | 
					                    Color pickerColor = Color(
 | 
				
			||||||
 | 
					                        _prefs.getInt(kAppColorSchemeStoreKey) ??
 | 
				
			||||||
 | 
					                            Colors.indigo.value);
 | 
				
			||||||
                    final color = await showDialog<Color?>(
 | 
					                    final color = await showDialog<Color?>(
 | 
				
			||||||
                      context: context,
 | 
					                      context: context,
 | 
				
			||||||
                      builder: (context) =>
 | 
					                      builder: (context) => AlertDialog(
 | 
				
			||||||
                          AlertDialog(
 | 
					                        content: SingleChildScrollView(
 | 
				
			||||||
                            content: SingleChildScrollView(
 | 
					                          child: ColorPicker(
 | 
				
			||||||
                              child: ColorPicker(
 | 
					                            pickerColor: pickerColor,
 | 
				
			||||||
                                pickerColor: pickerColor,
 | 
					                            onColorChanged: (color) => pickerColor = color,
 | 
				
			||||||
                                onColorChanged: (color) => pickerColor = color,
 | 
					                            enableAlpha: false,
 | 
				
			||||||
                                enableAlpha: false,
 | 
					                            hexInputBar: true,
 | 
				
			||||||
                                hexInputBar: true,
 | 
					 | 
				
			||||||
                              ),
 | 
					 | 
				
			||||||
                            ),
 | 
					 | 
				
			||||||
                            actions: <Widget>[
 | 
					 | 
				
			||||||
                              TextButton(
 | 
					 | 
				
			||||||
                                child: const Text('dialogDismiss').tr(),
 | 
					 | 
				
			||||||
                                onPressed: () {
 | 
					 | 
				
			||||||
                                  Navigator.of(context).pop();
 | 
					 | 
				
			||||||
                                },
 | 
					 | 
				
			||||||
                              ),
 | 
					 | 
				
			||||||
                              TextButton(
 | 
					 | 
				
			||||||
                                child: const Text('dialogConfirm').tr(),
 | 
					 | 
				
			||||||
                                onPressed: () {
 | 
					 | 
				
			||||||
                                  Navigator.of(context).pop(pickerColor);
 | 
					 | 
				
			||||||
                                },
 | 
					 | 
				
			||||||
                              ),
 | 
					 | 
				
			||||||
                            ],
 | 
					 | 
				
			||||||
                          ),
 | 
					                          ),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                        actions: <Widget>[
 | 
				
			||||||
 | 
					                          TextButton(
 | 
				
			||||||
 | 
					                            child: const Text('dialogDismiss').tr(),
 | 
				
			||||||
 | 
					                            onPressed: () {
 | 
				
			||||||
 | 
					                              Navigator.of(context).pop();
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                          TextButton(
 | 
				
			||||||
 | 
					                            child: const Text('dialogConfirm').tr(),
 | 
				
			||||||
 | 
					                            onPressed: () {
 | 
				
			||||||
 | 
					                              Navigator.of(context).pop(pickerColor);
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        ],
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
                    );
 | 
					                    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    if (color == null || !context.mounted) return;
 | 
					                    if (color == null || !context.mounted) return;
 | 
				
			||||||
@@ -248,16 +279,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
                      ],
 | 
					                      ],
 | 
				
			||||||
                      value: _prefs.getInt(kAppColorSchemeStoreKey) == null
 | 
					                      value: _prefs.getInt(kAppColorSchemeStoreKey) == null
 | 
				
			||||||
                          ? 1
 | 
					                          ? 1
 | 
				
			||||||
                          : kColorSchemes.values
 | 
					                          : kColorSchemes.values.toList().indexWhere((ele) =>
 | 
				
			||||||
                          .toList()
 | 
					                              ele.value ==
 | 
				
			||||||
                          .indexWhere((ele) => ele.value == _prefs.getInt(kAppColorSchemeStoreKey)),
 | 
					                              _prefs.getInt(kAppColorSchemeStoreKey)),
 | 
				
			||||||
                      onChanged: (int? value) {
 | 
					                      onChanged: (int? value) {
 | 
				
			||||||
                        if (value != null && value != -1) {
 | 
					                        if (value != null && value != -1) {
 | 
				
			||||||
                          _prefs.setInt(kAppColorSchemeStoreKey, kColorSchemes.values
 | 
					                          _prefs.setInt(kAppColorSchemeStoreKey,
 | 
				
			||||||
                              .elementAt(value)
 | 
					                              kColorSchemes.values.elementAt(value).value);
 | 
				
			||||||
                              .value);
 | 
					 | 
				
			||||||
                          final th = context.read<ThemeProvider>();
 | 
					                          final th = context.read<ThemeProvider>();
 | 
				
			||||||
                          th.reloadTheme(seedColorOverride: kColorSchemes.values.elementAt(value));
 | 
					                          th.reloadTheme(
 | 
				
			||||||
 | 
					                              seedColorOverride:
 | 
				
			||||||
 | 
					                                  kColorSchemes.values.elementAt(value));
 | 
				
			||||||
                          setState(() {});
 | 
					                          setState(() {});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                          context.showSnackbar('colorSchemeApplied'.tr());
 | 
					                          context.showSnackbar('colorSchemeApplied'.tr());
 | 
				
			||||||
@@ -293,7 +325,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
                CheckboxListTile(
 | 
					                CheckboxListTile(
 | 
				
			||||||
                  secondary: const Icon(Symbols.left_panel_close),
 | 
					                  secondary: const Icon(Symbols.left_panel_close),
 | 
				
			||||||
                  title: Text('settingsDrawerPreferCollapse').tr(),
 | 
					                  title: Text('settingsDrawerPreferCollapse').tr(),
 | 
				
			||||||
                  subtitle: Text('settingsDrawerPreferCollapseDescription').tr(),
 | 
					                  subtitle:
 | 
				
			||||||
 | 
					                      Text('settingsDrawerPreferCollapseDescription').tr(),
 | 
				
			||||||
                  contentPadding: const EdgeInsets.only(left: 24, right: 17),
 | 
					                  contentPadding: const EdgeInsets.only(left: 24, right: 17),
 | 
				
			||||||
                  value: _prefs.getBool(kAppDrawerPreferCollapse) ?? false,
 | 
					                  value: _prefs.getBool(kAppDrawerPreferCollapse) ?? false,
 | 
				
			||||||
                  onChanged: (value) {
 | 
					                  onChanged: (value) {
 | 
				
			||||||
@@ -303,12 +336,57 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
                    setState(() {});
 | 
					                    setState(() {});
 | 
				
			||||||
                  },
 | 
					                  },
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
 | 
					                ListTile(
 | 
				
			||||||
 | 
					                  leading: const Icon(Symbols.font_download),
 | 
				
			||||||
 | 
					                  title: Text('settingsCustomFonts').tr(),
 | 
				
			||||||
 | 
					                  subtitle: Text('settingsCustomFontsDescription').tr(),
 | 
				
			||||||
 | 
					                  contentPadding: const EdgeInsets.only(left: 24, right: 14),
 | 
				
			||||||
 | 
					                  trailing: IconButton(
 | 
				
			||||||
 | 
					                    padding: EdgeInsets.zero,
 | 
				
			||||||
 | 
					                    constraints: const BoxConstraints(),
 | 
				
			||||||
 | 
					                    icon: const Icon(Icons.clear),
 | 
				
			||||||
 | 
					                    onPressed: () {
 | 
				
			||||||
 | 
					                      _prefs.remove(kAppCustomFonts);
 | 
				
			||||||
 | 
					                      context.showSnackbar('settingsCustomFontApplied'.tr());
 | 
				
			||||||
 | 
					                      final theme = context.read<ThemeProvider>();
 | 
				
			||||||
 | 
					                      _customFontController.clear();
 | 
				
			||||||
 | 
					                      theme.reloadTheme();
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                TextField(
 | 
				
			||||||
 | 
					                  controller: _customFontController,
 | 
				
			||||||
 | 
					                  decoration: InputDecoration(
 | 
				
			||||||
 | 
					                    border: const OutlineInputBorder(),
 | 
				
			||||||
 | 
					                    labelText: 'settingsCustomFontFamily'.tr(),
 | 
				
			||||||
 | 
					                    helperText: 'settingsCustomFontFamilyHint'.tr(),
 | 
				
			||||||
 | 
					                    prefixIcon: const Icon(Symbols.format_paint),
 | 
				
			||||||
 | 
					                    suffixIcon: IconButton(
 | 
				
			||||||
 | 
					                      icon: const Icon(Symbols.save),
 | 
				
			||||||
 | 
					                      onPressed: () {
 | 
				
			||||||
 | 
					                        _prefs.setString(
 | 
				
			||||||
 | 
					                          kAppCustomFonts,
 | 
				
			||||||
 | 
					                          _customFontController.text,
 | 
				
			||||||
 | 
					                        );
 | 
				
			||||||
 | 
					                        context.showSnackbar('settingsCustomFontApplied'.tr());
 | 
				
			||||||
 | 
					                        final theme = context.read<ThemeProvider>();
 | 
				
			||||||
 | 
					                        theme.reloadTheme();
 | 
				
			||||||
 | 
					                      },
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                  onTapOutside: (_) =>
 | 
				
			||||||
 | 
					                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					                ).padding(horizontal: 16, top: 8, bottom: 4),
 | 
				
			||||||
              ],
 | 
					              ],
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
            Column(
 | 
					            Column(
 | 
				
			||||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
					              crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
              children: [
 | 
					              children: [
 | 
				
			||||||
                Text('settingsFeatures').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
					                Text('settingsFeatures')
 | 
				
			||||||
 | 
					                    .bold()
 | 
				
			||||||
 | 
					                    .fontSize(17)
 | 
				
			||||||
 | 
					                    .tr()
 | 
				
			||||||
 | 
					                    .padding(horizontal: 20, bottom: 4),
 | 
				
			||||||
                CheckboxListTile(
 | 
					                CheckboxListTile(
 | 
				
			||||||
                  secondary: const Icon(Symbols.vibration),
 | 
					                  secondary: const Icon(Symbols.vibration),
 | 
				
			||||||
                  contentPadding: const EdgeInsets.only(left: 24, right: 17),
 | 
					                  contentPadding: const EdgeInsets.only(left: 24, right: 17),
 | 
				
			||||||
@@ -350,7 +428,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
            Column(
 | 
					            Column(
 | 
				
			||||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
					              crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
              children: [
 | 
					              children: [
 | 
				
			||||||
                Text('settingsNetwork').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
					                Text('settingsNetwork')
 | 
				
			||||||
 | 
					                    .bold()
 | 
				
			||||||
 | 
					                    .fontSize(17)
 | 
				
			||||||
 | 
					                    .tr()
 | 
				
			||||||
 | 
					                    .padding(horizontal: 20, bottom: 4),
 | 
				
			||||||
                TextField(
 | 
					                TextField(
 | 
				
			||||||
                  controller: _serverUrlController,
 | 
					                  controller: _serverUrlController,
 | 
				
			||||||
                  decoration: InputDecoration(
 | 
					                  decoration: InputDecoration(
 | 
				
			||||||
@@ -371,7 +453,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
                      },
 | 
					                      },
 | 
				
			||||||
                    ),
 | 
					                    ),
 | 
				
			||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
					                  onTapOutside: (_) =>
 | 
				
			||||||
 | 
					                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
                ).padding(horizontal: 16, top: 8, bottom: 4),
 | 
					                ).padding(horizontal: 16, top: 8, bottom: 4),
 | 
				
			||||||
                ListTile(
 | 
					                ListTile(
 | 
				
			||||||
                  title: Text('settingsNetworkServerPreset').tr(),
 | 
					                  title: Text('settingsNetworkServerPreset').tr(),
 | 
				
			||||||
@@ -383,12 +466,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
                      isExpanded: true,
 | 
					                      isExpanded: true,
 | 
				
			||||||
                      items: [
 | 
					                      items: [
 | 
				
			||||||
                        ...kNetworkServerDirectory,
 | 
					                        ...kNetworkServerDirectory,
 | 
				
			||||||
                        if (!kNetworkServerDirectory.map((ele) => ele.$2).contains(_serverUrlController.text))
 | 
					                        if (!kNetworkServerDirectory
 | 
				
			||||||
 | 
					                            .map((ele) => ele.$2)
 | 
				
			||||||
 | 
					                            .contains(_serverUrlController.text))
 | 
				
			||||||
                          ('Custom', _serverUrlController.text),
 | 
					                          ('Custom', _serverUrlController.text),
 | 
				
			||||||
                      ]
 | 
					                      ]
 | 
				
			||||||
                          .map(
 | 
					                          .map(
 | 
				
			||||||
                            (item) =>
 | 
					                            (item) => DropdownMenuItem<String>(
 | 
				
			||||||
                            DropdownMenuItem<String>(
 | 
					 | 
				
			||||||
                              value: item.$2,
 | 
					                              value: item.$2,
 | 
				
			||||||
                              child: Column(
 | 
					                              child: Column(
 | 
				
			||||||
                                mainAxisSize: MainAxisSize.max,
 | 
					                                mainAxisSize: MainAxisSize.max,
 | 
				
			||||||
@@ -396,11 +480,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
                                crossAxisAlignment: CrossAxisAlignment.start,
 | 
					                                crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
                                children: [
 | 
					                                children: [
 | 
				
			||||||
                                  Text(item.$1).fontSize(14),
 | 
					                                  Text(item.$1).fontSize(14),
 | 
				
			||||||
                                  Text(item.$2, overflow: TextOverflow.ellipsis).fontSize(11)
 | 
					                                  Text(item.$2, overflow: TextOverflow.ellipsis)
 | 
				
			||||||
 | 
					                                      .fontSize(11)
 | 
				
			||||||
                                ],
 | 
					                                ],
 | 
				
			||||||
                              ),
 | 
					                              ),
 | 
				
			||||||
                            ),
 | 
					                            ),
 | 
				
			||||||
                      )
 | 
					                          )
 | 
				
			||||||
                          .toList(),
 | 
					                          .toList(),
 | 
				
			||||||
                      value: _serverUrlController.text,
 | 
					                      value: _serverUrlController.text,
 | 
				
			||||||
                      onChanged: (String? value) {
 | 
					                      onChanged: (String? value) {
 | 
				
			||||||
@@ -442,7 +527,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
            Column(
 | 
					            Column(
 | 
				
			||||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
					              crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
              children: [
 | 
					              children: [
 | 
				
			||||||
                Text('settingsPerformance').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
					                Text('settingsPerformance')
 | 
				
			||||||
 | 
					                    .bold()
 | 
				
			||||||
 | 
					                    .fontSize(17)
 | 
				
			||||||
 | 
					                    .tr()
 | 
				
			||||||
 | 
					                    .padding(horizontal: 20, bottom: 4),
 | 
				
			||||||
                ListTile(
 | 
					                ListTile(
 | 
				
			||||||
                  title: Text('settingsImageQuality').tr(),
 | 
					                  title: Text('settingsImageQuality').tr(),
 | 
				
			||||||
                  subtitle: Text('settingsImageQualityDescription').tr(),
 | 
					                  subtitle: Text('settingsImageQualityDescription').tr(),
 | 
				
			||||||
@@ -450,21 +539,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
                  leading: const Icon(Symbols.image),
 | 
					                  leading: const Icon(Symbols.image),
 | 
				
			||||||
                  trailing: DropdownButtonHideUnderline(
 | 
					                  trailing: DropdownButtonHideUnderline(
 | 
				
			||||||
                    child: DropdownButton2<FilterQuality>(
 | 
					                    child: DropdownButton2<FilterQuality>(
 | 
				
			||||||
                      value: kImageQualityLevel.values.elementAtOrNull(_prefs.getInt('app_image_quality') ?? 3) ??
 | 
					                      value: kImageQualityLevel.values.elementAtOrNull(
 | 
				
			||||||
 | 
					                              _prefs.getInt('app_image_quality') ?? 3) ??
 | 
				
			||||||
                          FilterQuality.high,
 | 
					                          FilterQuality.high,
 | 
				
			||||||
                      isExpanded: true,
 | 
					                      isExpanded: true,
 | 
				
			||||||
                      items: kImageQualityLevel.entries
 | 
					                      items: kImageQualityLevel.entries
 | 
				
			||||||
                          .map(
 | 
					                          .map(
 | 
				
			||||||
                            (item) =>
 | 
					                            (item) => DropdownMenuItem<FilterQuality>(
 | 
				
			||||||
                            DropdownMenuItem<FilterQuality>(
 | 
					 | 
				
			||||||
                              value: item.value,
 | 
					                              value: item.value,
 | 
				
			||||||
                              child: Text(item.key).tr().fontSize(14),
 | 
					                              child: Text(item.key).tr().fontSize(14),
 | 
				
			||||||
                            ),
 | 
					                            ),
 | 
				
			||||||
                      )
 | 
					                          )
 | 
				
			||||||
                          .toList(),
 | 
					                          .toList(),
 | 
				
			||||||
                      onChanged: (FilterQuality? value) {
 | 
					                      onChanged: (FilterQuality? value) {
 | 
				
			||||||
                        if (value == null) return;
 | 
					                        if (value == null) return;
 | 
				
			||||||
                        _prefs.setInt('app_image_quality', kImageQualityLevel.values.toList().indexOf(value));
 | 
					                        _prefs.setInt('app_image_quality',
 | 
				
			||||||
 | 
					                            kImageQualityLevel.values.toList().indexOf(value));
 | 
				
			||||||
                        setState(() {});
 | 
					                        setState(() {});
 | 
				
			||||||
                      },
 | 
					                      },
 | 
				
			||||||
                      buttonStyleData: const ButtonStyleData(
 | 
					                      buttonStyleData: const ButtonStyleData(
 | 
				
			||||||
@@ -486,7 +576,136 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
            Column(
 | 
					            Column(
 | 
				
			||||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
					              crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
              children: [
 | 
					              children: [
 | 
				
			||||||
                Text('settingsMisc').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
					                Text('settingsMisc')
 | 
				
			||||||
 | 
					                    .bold()
 | 
				
			||||||
 | 
					                    .fontSize(17)
 | 
				
			||||||
 | 
					                    .tr()
 | 
				
			||||||
 | 
					                    .padding(horizontal: 20, bottom: 4),
 | 
				
			||||||
 | 
					                ListTile(
 | 
				
			||||||
 | 
					                  leading: const Icon(Symbols.home_storage),
 | 
				
			||||||
 | 
					                  contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					                  title: Text('cacheSize').tr(),
 | 
				
			||||||
 | 
					                  subtitle: FutureBuilder(
 | 
				
			||||||
 | 
					                    future: DefaultCacheManager().store.getCacheSize(),
 | 
				
			||||||
 | 
					                    builder: (context, snapshot) {
 | 
				
			||||||
 | 
					                      if (!snapshot.hasData || kIsWeb) {
 | 
				
			||||||
 | 
					                        return Text('unknown').tr();
 | 
				
			||||||
 | 
					                      }
 | 
				
			||||||
 | 
					                      return Text(
 | 
				
			||||||
 | 
					                        snapshot.data!.formatBytes(),
 | 
				
			||||||
 | 
					                        style: GoogleFonts.robotoMono(),
 | 
				
			||||||
 | 
					                      );
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                ListTile(
 | 
				
			||||||
 | 
					                  leading: const Icon(Symbols.cleaning_services),
 | 
				
			||||||
 | 
					                  contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					                  title: Text('cacheDelete').tr(),
 | 
				
			||||||
 | 
					                  subtitle: Text('cacheDeleteDescription').tr(),
 | 
				
			||||||
 | 
					                  trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					                  onTap: () async {
 | 
				
			||||||
 | 
					                    await DefaultCacheManager().emptyCache();
 | 
				
			||||||
 | 
					                    if (!context.mounted) return;
 | 
				
			||||||
 | 
					                    HapticFeedback.heavyImpact();
 | 
				
			||||||
 | 
					                    context.showSnackbar('cacheDeleted'.tr());
 | 
				
			||||||
 | 
					                    setState(() {});
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                ListTile(
 | 
				
			||||||
 | 
					                  leading: const Icon(Symbols.database),
 | 
				
			||||||
 | 
					                  contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					                  title: Text('databaseSize').tr(),
 | 
				
			||||||
 | 
					                  subtitle: FutureBuilder(
 | 
				
			||||||
 | 
					                    future: dt.getDatabaseSize(),
 | 
				
			||||||
 | 
					                    builder: (context, snapshot) {
 | 
				
			||||||
 | 
					                      if (!snapshot.hasData || kIsWeb) {
 | 
				
			||||||
 | 
					                        return Text('unknown').tr();
 | 
				
			||||||
 | 
					                      }
 | 
				
			||||||
 | 
					                      return Text(
 | 
				
			||||||
 | 
					                        snapshot.data!.formatBytes(),
 | 
				
			||||||
 | 
					                        style: GoogleFonts.robotoMono(),
 | 
				
			||||||
 | 
					                      );
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                ListTile(
 | 
				
			||||||
 | 
					                  leading: const Icon(Symbols.database_off),
 | 
				
			||||||
 | 
					                  contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					                  title: Text('databaseDelete').tr(),
 | 
				
			||||||
 | 
					                  subtitle: Text('databaseDeleteDescription').tr(),
 | 
				
			||||||
 | 
					                  trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					                  onTap: () async {
 | 
				
			||||||
 | 
					                    await dt.removeDatabase();
 | 
				
			||||||
 | 
					                    if (!context.mounted) return;
 | 
				
			||||||
 | 
					                    HapticFeedback.heavyImpact();
 | 
				
			||||||
 | 
					                    context.showSnackbar('databaseDeleted'.tr());
 | 
				
			||||||
 | 
					                    setState(() {});
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                ListTile(
 | 
				
			||||||
 | 
					                  leading: const Icon(Symbols.notifications),
 | 
				
			||||||
 | 
					                  title: Text('settingsEnablePushNotifications').tr(),
 | 
				
			||||||
 | 
					                  subtitle:
 | 
				
			||||||
 | 
					                      Text('settingsEnablePushNotificationsDescription').tr(),
 | 
				
			||||||
 | 
					                  contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					                  trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					                  onTap: () async {
 | 
				
			||||||
 | 
					                    final nty = context.read<NotificationProvider>();
 | 
				
			||||||
 | 
					                    try {
 | 
				
			||||||
 | 
					                      await nty.registerPushNotifications();
 | 
				
			||||||
 | 
					                      if (!context.mounted) return;
 | 
				
			||||||
 | 
					                      HapticFeedback.heavyImpact();
 | 
				
			||||||
 | 
					                      context.showSnackbar(
 | 
				
			||||||
 | 
					                          'settingsEnabledPushNotifications'.tr());
 | 
				
			||||||
 | 
					                    } catch (err) {
 | 
				
			||||||
 | 
					                      if (!mounted) return;
 | 
				
			||||||
 | 
					                      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                ListTile(
 | 
				
			||||||
 | 
					                  leading: const Icon(Symbols.refresh),
 | 
				
			||||||
 | 
					                  title: Text('stickersReload').tr(),
 | 
				
			||||||
 | 
					                  subtitle: Text('stickersReloadDescription').tr(),
 | 
				
			||||||
 | 
					                  contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					                  trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					                  onTap: () async {
 | 
				
			||||||
 | 
					                    final stickers = context.read<SnStickerProvider>();
 | 
				
			||||||
 | 
					                    try {
 | 
				
			||||||
 | 
					                      await stickers.listSticker();
 | 
				
			||||||
 | 
					                      if (!context.mounted) return;
 | 
				
			||||||
 | 
					                      HapticFeedback.heavyImpact();
 | 
				
			||||||
 | 
					                      context.showSnackbar('stickersReloaded'.tr());
 | 
				
			||||||
 | 
					                    } catch (err) {
 | 
				
			||||||
 | 
					                      if (!context.mounted) return;
 | 
				
			||||||
 | 
					                      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                ListTile(
 | 
				
			||||||
 | 
					                  title: Text('forceUpdate').tr(),
 | 
				
			||||||
 | 
					                  subtitle: Text('forceUpdateDescription').tr(),
 | 
				
			||||||
 | 
					                  contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					                  leading: const Icon(Symbols.update),
 | 
				
			||||||
 | 
					                  trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					                  onTap: () async {
 | 
				
			||||||
 | 
					                    showModalBottomSheet(
 | 
				
			||||||
 | 
					                      context: context,
 | 
				
			||||||
 | 
					                      builder: (context) => VersionUpdatePopup(),
 | 
				
			||||||
 | 
					                    );
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                ListTile(
 | 
				
			||||||
 | 
					                  title: Text('runtimeLogsOpen').tr(),
 | 
				
			||||||
 | 
					                  subtitle: Text('runtimeLogsDescription').tr(),
 | 
				
			||||||
 | 
					                  contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					                  leading: const Icon(Symbols.receipt_long),
 | 
				
			||||||
 | 
					                  trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					                  onTap: () async {
 | 
				
			||||||
 | 
					                    GoRouter.of(context).pushNamed('debugLogging');
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
                ListTile(
 | 
					                ListTile(
 | 
				
			||||||
                  title: Text('settingsMiscAbout').tr(),
 | 
					                  title: Text('settingsMiscAbout').tr(),
 | 
				
			||||||
                  subtitle: Text('settingsMiscAboutDescription').tr(),
 | 
					                  subtitle: Text('settingsMiscAboutDescription').tr(),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -51,8 +51,10 @@ class _AppSharingListenerState extends State<AppSharingListener> {
 | 
				
			|||||||
                child: Column(
 | 
					                child: Column(
 | 
				
			||||||
                  children: [
 | 
					                  children: [
 | 
				
			||||||
                    ListTile(
 | 
					                    ListTile(
 | 
				
			||||||
                      contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
					                      contentPadding:
 | 
				
			||||||
                      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
 | 
					                          const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					                      shape: RoundedRectangleBorder(
 | 
				
			||||||
 | 
					                          borderRadius: BorderRadius.circular(8)),
 | 
				
			||||||
                      leading: Icon(Icons.post_add),
 | 
					                      leading: Icon(Icons.post_add),
 | 
				
			||||||
                      trailing: const Icon(Icons.chevron_right),
 | 
					                      trailing: const Icon(Icons.chevron_right),
 | 
				
			||||||
                      title: Text('shareIntentPostStory').tr(),
 | 
					                      title: Text('shareIntentPostStory').tr(),
 | 
				
			||||||
@@ -64,13 +66,20 @@ class _AppSharingListenerState extends State<AppSharingListener> {
 | 
				
			|||||||
                          },
 | 
					                          },
 | 
				
			||||||
                          extra: PostEditorExtra(
 | 
					                          extra: PostEditorExtra(
 | 
				
			||||||
                            text: value
 | 
					                            text: value
 | 
				
			||||||
                                .where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type))
 | 
					                                .where((e) => [
 | 
				
			||||||
 | 
					                                      SharedMediaType.text,
 | 
				
			||||||
 | 
					                                      SharedMediaType.url
 | 
				
			||||||
 | 
					                                    ].contains(e.type))
 | 
				
			||||||
                                .map((e) => e.path)
 | 
					                                .map((e) => e.path)
 | 
				
			||||||
                                .join('\n'),
 | 
					                                .join('\n'),
 | 
				
			||||||
                            attachments: value
 | 
					                            attachments: value
 | 
				
			||||||
                                .where((e) => [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image]
 | 
					                                .where((e) => [
 | 
				
			||||||
                                    .contains(e.type))
 | 
					                                      SharedMediaType.video,
 | 
				
			||||||
                                .map((e) => PostWriteMedia.fromFile(XFile(e.path)))
 | 
					                                      SharedMediaType.file,
 | 
				
			||||||
 | 
					                                      SharedMediaType.image
 | 
				
			||||||
 | 
					                                    ].contains(e.type))
 | 
				
			||||||
 | 
					                                .map((e) =>
 | 
				
			||||||
 | 
					                                    PostWriteMedia.fromFile(XFile(e.path)))
 | 
				
			||||||
                                .toList(),
 | 
					                                .toList(),
 | 
				
			||||||
                          ),
 | 
					                          ),
 | 
				
			||||||
                        );
 | 
					                        );
 | 
				
			||||||
@@ -78,15 +87,18 @@ class _AppSharingListenerState extends State<AppSharingListener> {
 | 
				
			|||||||
                      },
 | 
					                      },
 | 
				
			||||||
                    ),
 | 
					                    ),
 | 
				
			||||||
                    ListTile(
 | 
					                    ListTile(
 | 
				
			||||||
                      contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
					                      contentPadding:
 | 
				
			||||||
                      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
 | 
					                          const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					                      shape: RoundedRectangleBorder(
 | 
				
			||||||
 | 
					                          borderRadius: BorderRadius.circular(8)),
 | 
				
			||||||
                      leading: Icon(Icons.chat_outlined),
 | 
					                      leading: Icon(Icons.chat_outlined),
 | 
				
			||||||
                      trailing: const Icon(Icons.chevron_right),
 | 
					                      trailing: const Icon(Icons.chevron_right),
 | 
				
			||||||
                      title: Text('shareIntentSendChannel').tr(),
 | 
					                      title: Text('shareIntentSendChannel').tr(),
 | 
				
			||||||
                      onTap: () {
 | 
					                      onTap: () {
 | 
				
			||||||
                        showModalBottomSheet(
 | 
					                        showModalBottomSheet(
 | 
				
			||||||
                          context: context,
 | 
					                          context: context,
 | 
				
			||||||
                          builder: (context) => _ShareIntentChannelSelect(value: value),
 | 
					                          builder: (context) =>
 | 
				
			||||||
 | 
					                              _ShareIntentChannelSelect(value: value),
 | 
				
			||||||
                        ).then((val) {
 | 
					                        ).then((val) {
 | 
				
			||||||
                          if (!context.mounted) return;
 | 
					                          if (!context.mounted) return;
 | 
				
			||||||
                          if (val == true) Navigator.pop(context);
 | 
					                          if (val == true) Navigator.pop(context);
 | 
				
			||||||
@@ -110,7 +122,8 @@ class _AppSharingListenerState extends State<AppSharingListener> {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void _initialize() async {
 | 
					  void _initialize() async {
 | 
				
			||||||
    _shareIntentSubscription = ReceiveSharingIntent.instance.getMediaStream().listen((value) {
 | 
					    _shareIntentSubscription =
 | 
				
			||||||
 | 
					        ReceiveSharingIntent.instance.getMediaStream().listen((value) {
 | 
				
			||||||
      if (value.isEmpty) return;
 | 
					      if (value.isEmpty) return;
 | 
				
			||||||
      if (mounted) {
 | 
					      if (mounted) {
 | 
				
			||||||
        _gotoPost(value);
 | 
					        _gotoPost(value);
 | 
				
			||||||
@@ -157,7 +170,8 @@ class _ShareIntentChannelSelect extends StatefulWidget {
 | 
				
			|||||||
  const _ShareIntentChannelSelect({required this.value});
 | 
					  const _ShareIntentChannelSelect({required this.value});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  State<_ShareIntentChannelSelect> createState() => _ShareIntentChannelSelectState();
 | 
					  State<_ShareIntentChannelSelect> createState() =>
 | 
				
			||||||
 | 
					      _ShareIntentChannelSelectState();
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
 | 
					class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
 | 
				
			||||||
@@ -178,8 +192,11 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
 | 
				
			|||||||
      final lastMessages = await chan.getLastMessages(channels);
 | 
					      final lastMessages = await chan.getLastMessages(channels);
 | 
				
			||||||
      _lastMessages = {for (final val in lastMessages) val.channelId: val};
 | 
					      _lastMessages = {for (final val in lastMessages) val.channelId: val};
 | 
				
			||||||
      channels.sort((a, b) {
 | 
					      channels.sort((a, b) {
 | 
				
			||||||
        if (_lastMessages!.containsKey(a.id) && _lastMessages!.containsKey(b.id)) {
 | 
					        if (_lastMessages!.containsKey(a.id) &&
 | 
				
			||||||
          return _lastMessages![b.id]!.createdAt.compareTo(_lastMessages![a.id]!.createdAt);
 | 
					            _lastMessages!.containsKey(b.id)) {
 | 
				
			||||||
 | 
					          return _lastMessages![b.id]!
 | 
				
			||||||
 | 
					              .createdAt
 | 
				
			||||||
 | 
					              .compareTo(_lastMessages![a.id]!.createdAt);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        if (_lastMessages!.containsKey(a.id)) return -1;
 | 
					        if (_lastMessages!.containsKey(a.id)) return -1;
 | 
				
			||||||
        if (_lastMessages!.containsKey(b.id)) return 1;
 | 
					        if (_lastMessages!.containsKey(b.id)) return 1;
 | 
				
			||||||
@@ -232,7 +249,9 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
 | 
				
			|||||||
          children: [
 | 
					          children: [
 | 
				
			||||||
            const Icon(Symbols.chat, size: 24),
 | 
					            const Icon(Symbols.chat, size: 24),
 | 
				
			||||||
            const Gap(16),
 | 
					            const Gap(16),
 | 
				
			||||||
            Text('shareIntentSendChannel', style: Theme.of(context).textTheme.titleLarge).tr(),
 | 
					            Text('shareIntentSendChannel',
 | 
				
			||||||
 | 
					                    style: Theme.of(context).textTheme.titleLarge)
 | 
				
			||||||
 | 
					                .tr(),
 | 
				
			||||||
          ],
 | 
					          ],
 | 
				
			||||||
        ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
					        ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
				
			||||||
        LoadingIndicator(isActive: _isBusy),
 | 
					        LoadingIndicator(isActive: _isBusy),
 | 
				
			||||||
@@ -249,29 +268,34 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
 | 
				
			|||||||
                  final lastMessage = _lastMessages?[channel.id];
 | 
					                  final lastMessage = _lastMessages?[channel.id];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                  if (channel.type == 1) {
 | 
					                  if (channel.type == 1) {
 | 
				
			||||||
                    final otherMember = channel.members?.cast<SnChannelMember?>().firstWhere(
 | 
					                    final otherMember =
 | 
				
			||||||
                          (ele) => ele?.accountId != ua.user?.id,
 | 
					                        channel.members?.cast<SnChannelMember?>().firstWhere(
 | 
				
			||||||
                          orElse: () => null,
 | 
					                              (ele) => ele?.accountId != ua.user?.id,
 | 
				
			||||||
                        );
 | 
					                              orElse: () => null,
 | 
				
			||||||
 | 
					                            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    return ListTile(
 | 
					                    return ListTile(
 | 
				
			||||||
                      title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name),
 | 
					                      title: Text(
 | 
				
			||||||
 | 
					                          ud.getFromCache(otherMember?.accountId)?.nick ??
 | 
				
			||||||
 | 
					                              channel.name),
 | 
				
			||||||
                      subtitle: lastMessage != null
 | 
					                      subtitle: lastMessage != null
 | 
				
			||||||
                          ? Text(
 | 
					                          ? Text(
 | 
				
			||||||
                              '${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
 | 
					                              '${ud.getFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
 | 
				
			||||||
                              maxLines: 1,
 | 
					                              maxLines: 1,
 | 
				
			||||||
                              overflow: TextOverflow.ellipsis,
 | 
					                              overflow: TextOverflow.ellipsis,
 | 
				
			||||||
                            )
 | 
					                            )
 | 
				
			||||||
                          : Text(
 | 
					                          : Text(
 | 
				
			||||||
                              'channelDirectMessageDescription'.tr(args: [
 | 
					                              'channelDirectMessageDescription'.tr(args: [
 | 
				
			||||||
                                '@${ud.getAccountFromCache(otherMember?.accountId)?.name}',
 | 
					                                '@${ud.getFromCache(otherMember?.accountId)?.name}',
 | 
				
			||||||
                              ]),
 | 
					                              ]),
 | 
				
			||||||
                              maxLines: 1,
 | 
					                              maxLines: 1,
 | 
				
			||||||
                              overflow: TextOverflow.ellipsis,
 | 
					                              overflow: TextOverflow.ellipsis,
 | 
				
			||||||
                            ),
 | 
					                            ),
 | 
				
			||||||
                      contentPadding: const EdgeInsets.symmetric(horizontal: 16),
 | 
					                      contentPadding:
 | 
				
			||||||
 | 
					                          const EdgeInsets.symmetric(horizontal: 16),
 | 
				
			||||||
                      leading: AccountImage(
 | 
					                      leading: AccountImage(
 | 
				
			||||||
                        content: ud.getAccountFromCache(otherMember?.accountId)?.avatar,
 | 
					                        content:
 | 
				
			||||||
 | 
					                            ud.getFromCache(otherMember?.accountId)?.avatar,
 | 
				
			||||||
                      ),
 | 
					                      ),
 | 
				
			||||||
                      onTap: () {
 | 
					                      onTap: () {
 | 
				
			||||||
                        GoRouter.of(context).pushNamed(
 | 
					                        GoRouter.of(context).pushNamed(
 | 
				
			||||||
@@ -291,7 +315,7 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
 | 
				
			|||||||
                    title: Text(channel.name),
 | 
					                    title: Text(channel.name),
 | 
				
			||||||
                    subtitle: lastMessage != null
 | 
					                    subtitle: lastMessage != null
 | 
				
			||||||
                        ? Text(
 | 
					                        ? Text(
 | 
				
			||||||
                            '${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
 | 
					                            '${ud.getFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
 | 
				
			||||||
                            maxLines: 1,
 | 
					                            maxLines: 1,
 | 
				
			||||||
                            overflow: TextOverflow.ellipsis,
 | 
					                            overflow: TextOverflow.ellipsis,
 | 
				
			||||||
                          )
 | 
					                          )
 | 
				
			||||||
@@ -316,13 +340,20 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
 | 
				
			|||||||
                        },
 | 
					                        },
 | 
				
			||||||
                        extra: ChatRoomScreenExtra(
 | 
					                        extra: ChatRoomScreenExtra(
 | 
				
			||||||
                          initialText: widget.value
 | 
					                          initialText: widget.value
 | 
				
			||||||
                              .where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type))
 | 
					                              .where((e) => [
 | 
				
			||||||
 | 
					                                    SharedMediaType.text,
 | 
				
			||||||
 | 
					                                    SharedMediaType.url
 | 
				
			||||||
 | 
					                                  ].contains(e.type))
 | 
				
			||||||
                              .map((e) => e.path)
 | 
					                              .map((e) => e.path)
 | 
				
			||||||
                              .join('\n'),
 | 
					                              .join('\n'),
 | 
				
			||||||
                          initialAttachments: widget.value
 | 
					                          initialAttachments: widget.value
 | 
				
			||||||
                              .where((e) =>
 | 
					                              .where((e) => [
 | 
				
			||||||
                                  [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image].contains(e.type))
 | 
					                                    SharedMediaType.video,
 | 
				
			||||||
                              .map((e) => PostWriteMedia.fromFile(XFile(e.path)))
 | 
					                                    SharedMediaType.file,
 | 
				
			||||||
 | 
					                                    SharedMediaType.image
 | 
				
			||||||
 | 
					                                  ].contains(e.type))
 | 
				
			||||||
 | 
					                              .map(
 | 
				
			||||||
 | 
					                                  (e) => PostWriteMedia.fromFile(XFile(e.path)))
 | 
				
			||||||
                              .toList(),
 | 
					                              .toList(),
 | 
				
			||||||
                        ),
 | 
					                        ),
 | 
				
			||||||
                      )
 | 
					                      )
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										469
									
								
								lib/screens/stickers.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										469
									
								
								lib/screens/stickers.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,469 @@
 | 
				
			|||||||
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:gap/gap.dart';
 | 
				
			||||||
 | 
					import 'package:go_router/go_router.dart';
 | 
				
			||||||
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_sticker.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/userinfo.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/attachment.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/app_bar_leading.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/attachment/attachment_item.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/loading_indicator.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
				
			||||||
 | 
					import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class StickerScreen extends StatefulWidget {
 | 
				
			||||||
 | 
					  const StickerScreen({super.key});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<StickerScreen> createState() => _StickerScreenState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _StickerScreenState extends State<StickerScreen>
 | 
				
			||||||
 | 
					    with SingleTickerProviderStateMixin {
 | 
				
			||||||
 | 
					  late final TabController _tabController =
 | 
				
			||||||
 | 
					      TabController(length: 3, vsync: this);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					  int? _totalCount;
 | 
				
			||||||
 | 
					  final List<SnStickerPack> _packs = List.empty(growable: true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchPacks() async {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get(
 | 
				
			||||||
 | 
					        _tabController.index == 1
 | 
				
			||||||
 | 
					            ? '/cgi/uc/stickers/packs/own'
 | 
				
			||||||
 | 
					            : '/cgi/uc/stickers/packs',
 | 
				
			||||||
 | 
					        queryParameters: {
 | 
				
			||||||
 | 
					          'take': 10,
 | 
				
			||||||
 | 
					          'offset': _packs.length,
 | 
				
			||||||
 | 
					          if (_tabController.index == 2) 'author': ua.user?.id,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      if (resp.data is Map<String, dynamic>) {
 | 
				
			||||||
 | 
					        _totalCount = resp.data['count'] as int?;
 | 
				
			||||||
 | 
					        final out = List<SnStickerPack>.from(
 | 
				
			||||||
 | 
					          resp.data['data'].map((ele) => SnStickerPack.fromJson(ele)),
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        _packs.addAll(out);
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        _totalCount = 0;
 | 
				
			||||||
 | 
					        final out = List<SnStickerPack>.from(
 | 
				
			||||||
 | 
					          resp.data.map((ele) => SnStickerPack.fromJson(ele)),
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        _packs.addAll(out);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _removePack(SnStickerPack pack) async {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.delete('/cgi/uc/stickers/packs/${pack.id}/own');
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showSnackbar('stickersRemoved'.tr());
 | 
				
			||||||
 | 
					      _refreshPacks();
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _deletePack(SnStickerPack pack) async {
 | 
				
			||||||
 | 
					    final confirm = await context.showConfirmDialog(
 | 
				
			||||||
 | 
					      'stickersPackDelete'.tr(args: [pack.name]),
 | 
				
			||||||
 | 
					      'stickersPackDeleteDescription'.tr(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    if (!confirm) return;
 | 
				
			||||||
 | 
					    if (!mounted) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.delete('/cgi/uc/stickers/packs/${pack.id}');
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showSnackbar('stickersDeleted'.tr());
 | 
				
			||||||
 | 
					      _refreshPacks();
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _refreshPacks() async {
 | 
				
			||||||
 | 
					    _packs.clear();
 | 
				
			||||||
 | 
					    _totalCount = null;
 | 
				
			||||||
 | 
					    await _fetchPacks();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _fetchPacks();
 | 
				
			||||||
 | 
					    _tabController.addListener(() {
 | 
				
			||||||
 | 
					      if (_tabController.indexIsChanging) {
 | 
				
			||||||
 | 
					        _refreshPacks();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    _tabController.dispose();
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return AppScaffold(
 | 
				
			||||||
 | 
					      appBar: AppBar(
 | 
				
			||||||
 | 
					        leading: AutoAppBarLeading(),
 | 
				
			||||||
 | 
					        title: Text('screenStickers').tr(),
 | 
				
			||||||
 | 
					        actions: [
 | 
				
			||||||
 | 
					          IconButton(
 | 
				
			||||||
 | 
					            icon: const Icon(Symbols.add_circle),
 | 
				
			||||||
 | 
					            onPressed: () {
 | 
				
			||||||
 | 
					              showDialog(
 | 
				
			||||||
 | 
					                context: context,
 | 
				
			||||||
 | 
					                builder: (context) => _StickerPackCreateDialog(),
 | 
				
			||||||
 | 
					              ).then((value) {
 | 
				
			||||||
 | 
					                if (value == true) _refreshPacks();
 | 
				
			||||||
 | 
					              });
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          const Gap(8),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        bottom: TabBar(
 | 
				
			||||||
 | 
					          controller: _tabController,
 | 
				
			||||||
 | 
					          tabs: [
 | 
				
			||||||
 | 
					            Tab(
 | 
				
			||||||
 | 
					              child: Text('stickersDiscovery'.tr()).textColor(
 | 
				
			||||||
 | 
					                Theme.of(context).appBarTheme.foregroundColor,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            Tab(
 | 
				
			||||||
 | 
					              child: Text('stickersOwned'.tr()).textColor(
 | 
				
			||||||
 | 
					                Theme.of(context).appBarTheme.foregroundColor,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            Tab(
 | 
				
			||||||
 | 
					              child: Text('stickersCreated'.tr()).textColor(
 | 
				
			||||||
 | 
					                Theme.of(context).appBarTheme.foregroundColor,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      body: MediaQuery.removePadding(
 | 
				
			||||||
 | 
					        context: context,
 | 
				
			||||||
 | 
					        removeTop: true,
 | 
				
			||||||
 | 
					        child: RefreshIndicator(
 | 
				
			||||||
 | 
					          onRefresh: _refreshPacks,
 | 
				
			||||||
 | 
					          child: InfiniteList(
 | 
				
			||||||
 | 
					            itemCount: _packs.length,
 | 
				
			||||||
 | 
					            onFetchData: _fetchPacks,
 | 
				
			||||||
 | 
					            hasReachedMax:
 | 
				
			||||||
 | 
					                (_totalCount != null && _packs.length >= _totalCount!) ||
 | 
				
			||||||
 | 
					                    _tabController.index == 2,
 | 
				
			||||||
 | 
					            isLoading: _isBusy,
 | 
				
			||||||
 | 
					            itemBuilder: (context, idx) {
 | 
				
			||||||
 | 
					              final pack = _packs[idx];
 | 
				
			||||||
 | 
					              return ListTile(
 | 
				
			||||||
 | 
					                title: Text(pack.name),
 | 
				
			||||||
 | 
					                subtitle: Text(
 | 
				
			||||||
 | 
					                  pack.description,
 | 
				
			||||||
 | 
					                  maxLines: 1,
 | 
				
			||||||
 | 
					                  overflow: TextOverflow.ellipsis,
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                contentPadding: const EdgeInsets.symmetric(horizontal: 16),
 | 
				
			||||||
 | 
					                trailing: _tabController.index == 1
 | 
				
			||||||
 | 
					                    ? IconButton(
 | 
				
			||||||
 | 
					                        onPressed: () {
 | 
				
			||||||
 | 
					                          _removePack(pack);
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                        icon: const Icon(Symbols.remove),
 | 
				
			||||||
 | 
					                      )
 | 
				
			||||||
 | 
					                    : _tabController.index == 2
 | 
				
			||||||
 | 
					                        ? IconButton(
 | 
				
			||||||
 | 
					                            onPressed: () {
 | 
				
			||||||
 | 
					                              _deletePack(pack);
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                            icon: const Icon(Symbols.delete),
 | 
				
			||||||
 | 
					                          )
 | 
				
			||||||
 | 
					                        : null,
 | 
				
			||||||
 | 
					                onTap: () {
 | 
				
			||||||
 | 
					                  if (_tabController.index == 0) {
 | 
				
			||||||
 | 
					                    showModalBottomSheet(
 | 
				
			||||||
 | 
					                      context: context,
 | 
				
			||||||
 | 
					                      builder: (context) => _StickerPackAddPopup(pack: pack),
 | 
				
			||||||
 | 
					                    ).then((value) {
 | 
				
			||||||
 | 
					                      if (value == true && _tabController.index == 1) {
 | 
				
			||||||
 | 
					                        _refreshPacks();
 | 
				
			||||||
 | 
					                      }
 | 
				
			||||||
 | 
					                    });
 | 
				
			||||||
 | 
					                  } else {
 | 
				
			||||||
 | 
					                    GoRouter.of(context).pushNamed(
 | 
				
			||||||
 | 
					                      'stickerPack',
 | 
				
			||||||
 | 
					                      pathParameters: {
 | 
				
			||||||
 | 
					                        'id': pack.id.toString(),
 | 
				
			||||||
 | 
					                      },
 | 
				
			||||||
 | 
					                    );
 | 
				
			||||||
 | 
					                  }
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              );
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _StickerPackAddPopup extends StatefulWidget {
 | 
				
			||||||
 | 
					  final SnStickerPack pack;
 | 
				
			||||||
 | 
					  const _StickerPackAddPopup({required this.pack});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<_StickerPackAddPopup> createState() => _StickerPackAddPopupState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _StickerPackAddPopupState extends State<_StickerPackAddPopup> {
 | 
				
			||||||
 | 
					  SnStickerPack? _pack;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchPack() async {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp =
 | 
				
			||||||
 | 
					          await sn.client.get('/cgi/uc/stickers/packs/${widget.pack.id}');
 | 
				
			||||||
 | 
					      _pack = SnStickerPack.fromJson(resp.data);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _fetchPack();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _isAdding = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _addPack() async {
 | 
				
			||||||
 | 
					    if (_pack == null) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      setState(() => _isAdding = true);
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final stickers = context.read<SnStickerProvider>();
 | 
				
			||||||
 | 
					      await sn.client.post(
 | 
				
			||||||
 | 
					        '/cgi/uc/stickers/packs/${widget.pack.id}/own',
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showSnackbar('stickersAdded'.tr());
 | 
				
			||||||
 | 
					      if (_pack?.stickers != null) {
 | 
				
			||||||
 | 
					        stickers.putSticker(
 | 
				
			||||||
 | 
					            _pack!.stickers!.map((ele) => ele.copyWith(pack: _pack!)));
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      Navigator.pop(context, true);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isAdding = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return Column(
 | 
				
			||||||
 | 
					      crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					      children: [
 | 
				
			||||||
 | 
					        Row(
 | 
				
			||||||
 | 
					          crossAxisAlignment: CrossAxisAlignment.center,
 | 
				
			||||||
 | 
					          children: [
 | 
				
			||||||
 | 
					            const Icon(Symbols.add, size: 24),
 | 
				
			||||||
 | 
					            const Gap(16),
 | 
				
			||||||
 | 
					            Text('stickersAdd', style: Theme.of(context).textTheme.titleLarge)
 | 
				
			||||||
 | 
					                .tr(),
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
				
			||||||
 | 
					        Row(
 | 
				
			||||||
 | 
					          crossAxisAlignment: CrossAxisAlignment.end,
 | 
				
			||||||
 | 
					          children: [
 | 
				
			||||||
 | 
					            Expanded(
 | 
				
			||||||
 | 
					              child: Column(
 | 
				
			||||||
 | 
					                crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                children: [
 | 
				
			||||||
 | 
					                  Text(widget.pack.name).bold(),
 | 
				
			||||||
 | 
					                  Text(
 | 
				
			||||||
 | 
					                    widget.pack.description,
 | 
				
			||||||
 | 
					                    maxLines: 2,
 | 
				
			||||||
 | 
					                    overflow: TextOverflow.ellipsis,
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            ElevatedButton(
 | 
				
			||||||
 | 
					              onPressed: _isAdding ? null : _addPack,
 | 
				
			||||||
 | 
					              child: Text('add').tr(),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        ).padding(horizontal: 24),
 | 
				
			||||||
 | 
					        LoadingIndicator(isActive: _isBusy),
 | 
				
			||||||
 | 
					        if (_pack?.stickers != null)
 | 
				
			||||||
 | 
					          Expanded(
 | 
				
			||||||
 | 
					            child: GridView.extent(
 | 
				
			||||||
 | 
					              padding: EdgeInsets.only(left: 20, right: 20, top: 8),
 | 
				
			||||||
 | 
					              maxCrossAxisExtent: 48,
 | 
				
			||||||
 | 
					              mainAxisSpacing: 8,
 | 
				
			||||||
 | 
					              crossAxisSpacing: 8,
 | 
				
			||||||
 | 
					              children: _pack!.stickers!
 | 
				
			||||||
 | 
					                  .map(
 | 
				
			||||||
 | 
					                    (ele) => ClipRRect(
 | 
				
			||||||
 | 
					                      borderRadius: BorderRadius.circular(8),
 | 
				
			||||||
 | 
					                      child: Container(
 | 
				
			||||||
 | 
					                        color:
 | 
				
			||||||
 | 
					                            Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
				
			||||||
 | 
					                        child: AttachmentItem(
 | 
				
			||||||
 | 
					                          data: ele.attachment,
 | 
				
			||||||
 | 
					                          heroTag: 'sticker-pack-${ele.attachment.rid}',
 | 
				
			||||||
 | 
					                          fit: BoxFit.contain,
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  )
 | 
				
			||||||
 | 
					                  .toList(),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _StickerPackCreateDialog extends StatefulWidget {
 | 
				
			||||||
 | 
					  const _StickerPackCreateDialog();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<_StickerPackCreateDialog> createState() =>
 | 
				
			||||||
 | 
					      _StickerPackCreateDialogState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _StickerPackCreateDialogState extends State<_StickerPackCreateDialog> {
 | 
				
			||||||
 | 
					  final TextEditingController _nameController = TextEditingController();
 | 
				
			||||||
 | 
					  final TextEditingController _prefixController = TextEditingController();
 | 
				
			||||||
 | 
					  final TextEditingController _descriptionController = TextEditingController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _createPack() async {
 | 
				
			||||||
 | 
					    if (_nameController.text.isEmpty ||
 | 
				
			||||||
 | 
					        _prefixController.text.isEmpty ||
 | 
				
			||||||
 | 
					        _descriptionController.text.isEmpty) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.post(
 | 
				
			||||||
 | 
					        '/cgi/uc/stickers/packs',
 | 
				
			||||||
 | 
					        data: {
 | 
				
			||||||
 | 
					          'name': _nameController.text,
 | 
				
			||||||
 | 
					          'prefix': _prefixController.text,
 | 
				
			||||||
 | 
					          'description': _descriptionController.text,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      Navigator.pop(context, true);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    _nameController.dispose();
 | 
				
			||||||
 | 
					    _prefixController.dispose();
 | 
				
			||||||
 | 
					    _descriptionController.dispose();
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return AlertDialog(
 | 
				
			||||||
 | 
					      title: Text('stickersPackNew').tr(),
 | 
				
			||||||
 | 
					      content: Column(
 | 
				
			||||||
 | 
					        mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					        crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					        children: [
 | 
				
			||||||
 | 
					          TextField(
 | 
				
			||||||
 | 
					            controller: _nameController,
 | 
				
			||||||
 | 
					            decoration: InputDecoration(
 | 
				
			||||||
 | 
					              border: const UnderlineInputBorder(),
 | 
				
			||||||
 | 
					              labelText: 'fieldStickerPackName'.tr(),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          const Gap(4),
 | 
				
			||||||
 | 
					          TextField(
 | 
				
			||||||
 | 
					            controller: _prefixController,
 | 
				
			||||||
 | 
					            decoration: InputDecoration(
 | 
				
			||||||
 | 
					              border: const UnderlineInputBorder(),
 | 
				
			||||||
 | 
					              labelText: 'fieldStickerPackPrefix'.tr(),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          const Gap(4),
 | 
				
			||||||
 | 
					          TextField(
 | 
				
			||||||
 | 
					            controller: _descriptionController,
 | 
				
			||||||
 | 
					            decoration: InputDecoration(
 | 
				
			||||||
 | 
					              border: const UnderlineInputBorder(),
 | 
				
			||||||
 | 
					              labelText: 'fieldStickerPackDescription'.tr(),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      actions: [
 | 
				
			||||||
 | 
					        TextButton(
 | 
				
			||||||
 | 
					          onPressed: _isBusy
 | 
				
			||||||
 | 
					              ? null
 | 
				
			||||||
 | 
					              : () {
 | 
				
			||||||
 | 
					                  Navigator.pop(context);
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					          child: Text('dialogDismiss').tr(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        TextButton(
 | 
				
			||||||
 | 
					          onPressed: _isBusy ? null : () => _createPack(),
 | 
				
			||||||
 | 
					          child: Text('dialogConfirm').tr(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										266
									
								
								lib/screens/stickers/pack_detail.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										266
									
								
								lib/screens/stickers/pack_detail.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,266 @@
 | 
				
			|||||||
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:gap/gap.dart';
 | 
				
			||||||
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/attachment.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/attachment/attachment_input.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/attachment/attachment_item.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/loading_indicator.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class StickerPackScreen extends StatefulWidget {
 | 
				
			||||||
 | 
					  final int id;
 | 
				
			||||||
 | 
					  const StickerPackScreen({super.key, required this.id});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<StickerPackScreen> createState() => _StickerPackScreenState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _StickerPackScreenState extends State<StickerPackScreen> {
 | 
				
			||||||
 | 
					  SnStickerPack? _pack;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchPack() async {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get('/cgi/uc/stickers/packs/${widget.id}');
 | 
				
			||||||
 | 
					      _pack = SnStickerPack.fromJson(resp.data);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _deleteSticker(SnSticker sticker) async {
 | 
				
			||||||
 | 
					    final confirm = await context.showConfirmDialog(
 | 
				
			||||||
 | 
					      'stickersDelete'.tr(args: [sticker.name]),
 | 
				
			||||||
 | 
					      'stickersDeleteDescription'.tr(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    if (!confirm) return;
 | 
				
			||||||
 | 
					    if (!mounted) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.delete('/cgi/uc/stickers/${sticker.id}');
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showSnackbar('stickersDeleted'.tr());
 | 
				
			||||||
 | 
					      _fetchPack();
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _fetchPack();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return AppScaffold(
 | 
				
			||||||
 | 
					      appBar: AppBar(
 | 
				
			||||||
 | 
					        title: Text(_pack?.name ?? 'loading'.tr()),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      body: Column(
 | 
				
			||||||
 | 
					        crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					        children: [
 | 
				
			||||||
 | 
					          LoadingIndicator(isActive: _isBusy),
 | 
				
			||||||
 | 
					          if (_pack != null)
 | 
				
			||||||
 | 
					            Column(
 | 
				
			||||||
 | 
					              crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					              children: [
 | 
				
			||||||
 | 
					                Text(_pack!.name).bold(),
 | 
				
			||||||
 | 
					                Text(
 | 
				
			||||||
 | 
					                  _pack!.description,
 | 
				
			||||||
 | 
					                  maxLines: 2,
 | 
				
			||||||
 | 
					                  overflow: TextOverflow.ellipsis,
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ],
 | 
				
			||||||
 | 
					            ).padding(horizontal: 24, vertical: 16),
 | 
				
			||||||
 | 
					          const Divider(height: 1),
 | 
				
			||||||
 | 
					          ListTile(
 | 
				
			||||||
 | 
					            leading: const Icon(Symbols.add),
 | 
				
			||||||
 | 
					            title: Text('stickersNew').tr(),
 | 
				
			||||||
 | 
					            subtitle: Text('stickersNewDescription').tr(),
 | 
				
			||||||
 | 
					            contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					            onTap: () {
 | 
				
			||||||
 | 
					              showDialog(
 | 
				
			||||||
 | 
					                context: context,
 | 
				
			||||||
 | 
					                builder: (context) => _StickerCreateDialog(pack: _pack!),
 | 
				
			||||||
 | 
					              ).then((value) {
 | 
				
			||||||
 | 
					                if (value) _fetchPack();
 | 
				
			||||||
 | 
					              });
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          const Divider(height: 1),
 | 
				
			||||||
 | 
					          if (_pack?.stickers != null)
 | 
				
			||||||
 | 
					            Expanded(
 | 
				
			||||||
 | 
					              child: GridView.extent(
 | 
				
			||||||
 | 
					                padding: EdgeInsets.only(left: 20, right: 20, top: 16),
 | 
				
			||||||
 | 
					                maxCrossAxisExtent: 48,
 | 
				
			||||||
 | 
					                mainAxisSpacing: 8,
 | 
				
			||||||
 | 
					                crossAxisSpacing: 8,
 | 
				
			||||||
 | 
					                children: _pack!.stickers!
 | 
				
			||||||
 | 
					                    .map(
 | 
				
			||||||
 | 
					                      (ele) => GestureDetector(
 | 
				
			||||||
 | 
					                        child: ClipRRect(
 | 
				
			||||||
 | 
					                          borderRadius: BorderRadius.circular(8),
 | 
				
			||||||
 | 
					                          child: Container(
 | 
				
			||||||
 | 
					                            color: Theme.of(context)
 | 
				
			||||||
 | 
					                                .colorScheme
 | 
				
			||||||
 | 
					                                .surfaceContainerHigh,
 | 
				
			||||||
 | 
					                            child: AttachmentItem(
 | 
				
			||||||
 | 
					                              data: ele.attachment,
 | 
				
			||||||
 | 
					                              heroTag: 'sticker-pack-${ele.attachment.rid}',
 | 
				
			||||||
 | 
					                              fit: BoxFit.contain,
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                        onTap: () {
 | 
				
			||||||
 | 
					                          _deleteSticker(ele);
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    )
 | 
				
			||||||
 | 
					                    .toList(),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _StickerCreateDialog extends StatefulWidget {
 | 
				
			||||||
 | 
					  final SnStickerPack pack;
 | 
				
			||||||
 | 
					  const _StickerCreateDialog({required this.pack});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<_StickerCreateDialog> createState() => _StickerCreateDialogState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _StickerCreateDialogState extends State<_StickerCreateDialog> {
 | 
				
			||||||
 | 
					  final TextEditingController _nameController = TextEditingController();
 | 
				
			||||||
 | 
					  final TextEditingController _aliasController = TextEditingController();
 | 
				
			||||||
 | 
					  final TextEditingController _attachmentController = TextEditingController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    _nameController.dispose();
 | 
				
			||||||
 | 
					    _aliasController.dispose();
 | 
				
			||||||
 | 
					    _attachmentController.dispose();
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _createSticker() async {
 | 
				
			||||||
 | 
					    if (_nameController.text.isEmpty ||
 | 
				
			||||||
 | 
					        _aliasController.text.isEmpty ||
 | 
				
			||||||
 | 
					        _attachmentController.text.isEmpty) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.post(
 | 
				
			||||||
 | 
					        '/cgi/uc/stickers',
 | 
				
			||||||
 | 
					        data: {
 | 
				
			||||||
 | 
					          'name': _nameController.text,
 | 
				
			||||||
 | 
					          'alias': _aliasController.text,
 | 
				
			||||||
 | 
					          'attachment_id': _attachmentController.text,
 | 
				
			||||||
 | 
					          'pack_id': widget.pack.id,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      Navigator.pop(context, true);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return AlertDialog(
 | 
				
			||||||
 | 
					      title: Text('stickersNew'.tr()),
 | 
				
			||||||
 | 
					      content: Column(
 | 
				
			||||||
 | 
					        mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					        crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					        children: [
 | 
				
			||||||
 | 
					          TextField(
 | 
				
			||||||
 | 
					            controller: _nameController,
 | 
				
			||||||
 | 
					            decoration: InputDecoration(
 | 
				
			||||||
 | 
					              border: const UnderlineInputBorder(),
 | 
				
			||||||
 | 
					              labelText: 'fieldStickerName'.tr(),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          const Gap(4),
 | 
				
			||||||
 | 
					          TextField(
 | 
				
			||||||
 | 
					            controller: _aliasController,
 | 
				
			||||||
 | 
					            decoration: InputDecoration(
 | 
				
			||||||
 | 
					              border: const UnderlineInputBorder(),
 | 
				
			||||||
 | 
					              labelText: 'fieldStickerAlias'.tr(),
 | 
				
			||||||
 | 
					              helperText: 'fieldStickerAliasHint'.tr(),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          const Gap(4),
 | 
				
			||||||
 | 
					          TextField(
 | 
				
			||||||
 | 
					            controller: _attachmentController,
 | 
				
			||||||
 | 
					            decoration: InputDecoration(
 | 
				
			||||||
 | 
					              border: const UnderlineInputBorder(),
 | 
				
			||||||
 | 
					              labelText: 'fieldStickerAttachment'.tr(),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            readOnly: true,
 | 
				
			||||||
 | 
					            onTap: () async {
 | 
				
			||||||
 | 
					              final attachment = await showDialog<SnAttachment?>(
 | 
				
			||||||
 | 
					                context: context,
 | 
				
			||||||
 | 
					                builder: (context) => AttachmentInputDialog(
 | 
				
			||||||
 | 
					                  title: 'fieldStickerAttachment'.tr(),
 | 
				
			||||||
 | 
					                  pool: 'sticker',
 | 
				
			||||||
 | 
					                  mediaType: SnMediaType.image,
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              );
 | 
				
			||||||
 | 
					              if (attachment != null) {
 | 
				
			||||||
 | 
					                setState(() {
 | 
				
			||||||
 | 
					                  _attachmentController.text = attachment.rid;
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      actions: [
 | 
				
			||||||
 | 
					        TextButton(
 | 
				
			||||||
 | 
					          onPressed: _isBusy
 | 
				
			||||||
 | 
					              ? null
 | 
				
			||||||
 | 
					              : () {
 | 
				
			||||||
 | 
					                  Navigator.pop(context);
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					          child: Text('dialogDismiss').tr(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        TextButton(
 | 
				
			||||||
 | 
					          onPressed: _isBusy ? null : () => _createSticker(),
 | 
				
			||||||
 | 
					          child: Text('dialogConfirm').tr(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -11,10 +11,19 @@ class ThemeSet {
 | 
				
			|||||||
  ThemeSet({required this.light, required this.dark});
 | 
					  ThemeSet({required this.light, required this.dark});
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Future<ThemeSet> createAppThemeSet({Color? seedColorOverride, bool? useMaterial3}) async {
 | 
					Future<ThemeSet> createAppThemeSet(
 | 
				
			||||||
 | 
					    {Color? seedColorOverride, bool? useMaterial3, String? customFonts}) async {
 | 
				
			||||||
  return ThemeSet(
 | 
					  return ThemeSet(
 | 
				
			||||||
    light: await createAppTheme(Brightness.light, useMaterial3: useMaterial3),
 | 
					    light: await createAppTheme(
 | 
				
			||||||
    dark: await createAppTheme(Brightness.dark, useMaterial3: useMaterial3),
 | 
					      Brightness.light,
 | 
				
			||||||
 | 
					      useMaterial3: useMaterial3,
 | 
				
			||||||
 | 
					      customFonts: customFonts,
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					    dark: await createAppTheme(
 | 
				
			||||||
 | 
					      Brightness.dark,
 | 
				
			||||||
 | 
					      useMaterial3: useMaterial3,
 | 
				
			||||||
 | 
					      customFonts: customFonts,
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -22,24 +31,36 @@ Future<ThemeData> createAppTheme(
 | 
				
			|||||||
  Brightness brightness, {
 | 
					  Brightness brightness, {
 | 
				
			||||||
  Color? seedColorOverride,
 | 
					  Color? seedColorOverride,
 | 
				
			||||||
  bool? useMaterial3,
 | 
					  bool? useMaterial3,
 | 
				
			||||||
 | 
					  String? customFonts,
 | 
				
			||||||
}) async {
 | 
					}) async {
 | 
				
			||||||
  final prefs = await SharedPreferences.getInstance();
 | 
					  final prefs = await SharedPreferences.getInstance();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  final seedColorString = prefs.getInt(kAppColorSchemeStoreKey);
 | 
					  final seedColorString = prefs.getInt(kAppColorSchemeStoreKey);
 | 
				
			||||||
  final seedColor = seedColorString != null ? Color(seedColorString) : Colors.indigo;
 | 
					  final seedColor =
 | 
				
			||||||
 | 
					      seedColorString != null ? Color(seedColorString) : Colors.indigo;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  final colorScheme = ColorScheme.fromSeed(
 | 
					  final colorScheme = ColorScheme.fromSeed(
 | 
				
			||||||
    seedColor: seedColorOverride ?? seedColor,
 | 
					    seedColor: seedColorOverride ?? seedColor,
 | 
				
			||||||
    brightness: brightness,
 | 
					    brightness: brightness,
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  final hasAppBarTransparent = prefs.getBool(kAppbarTransparentStoreKey) ?? false;
 | 
					  final hasAppBarTransparent =
 | 
				
			||||||
  final useM3 = useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? true);
 | 
					      prefs.getBool(kAppbarTransparentStoreKey) ?? false;
 | 
				
			||||||
 | 
					  final useM3 =
 | 
				
			||||||
 | 
					      useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final inUseFonts = (customFonts ?? prefs.getString(kAppCustomFonts))
 | 
				
			||||||
 | 
					          ?.split(',')
 | 
				
			||||||
 | 
					          .map((ele) => ele.trim())
 | 
				
			||||||
 | 
					          .toList() ??
 | 
				
			||||||
 | 
					      ['Nunito'];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return ThemeData(
 | 
					  return ThemeData(
 | 
				
			||||||
    useMaterial3: useM3,
 | 
					    useMaterial3: useM3,
 | 
				
			||||||
    colorScheme: colorScheme,
 | 
					    colorScheme: colorScheme,
 | 
				
			||||||
    brightness: brightness,
 | 
					    brightness: brightness,
 | 
				
			||||||
 | 
					    fontFamily: inUseFonts.firstOrNull,
 | 
				
			||||||
 | 
					    fontFamilyFallback: inUseFonts.sublist(1),
 | 
				
			||||||
    iconTheme: IconThemeData(
 | 
					    iconTheme: IconThemeData(
 | 
				
			||||||
      fill: 0,
 | 
					      fill: 0,
 | 
				
			||||||
      weight: 400,
 | 
					      weight: 400,
 | 
				
			||||||
@@ -52,12 +73,14 @@ Future<ThemeData> createAppTheme(
 | 
				
			|||||||
    appBarTheme: AppBarTheme(
 | 
					    appBarTheme: AppBarTheme(
 | 
				
			||||||
      centerTitle: true,
 | 
					      centerTitle: true,
 | 
				
			||||||
      elevation: hasAppBarTransparent ? 0 : null,
 | 
					      elevation: hasAppBarTransparent ? 0 : null,
 | 
				
			||||||
      backgroundColor: hasAppBarTransparent ? Colors.transparent : colorScheme.primary,
 | 
					      backgroundColor:
 | 
				
			||||||
      foregroundColor: hasAppBarTransparent ? colorScheme.onSurface : colorScheme.onPrimary,
 | 
					          hasAppBarTransparent ? Colors.transparent : colorScheme.primary,
 | 
				
			||||||
 | 
					      foregroundColor:
 | 
				
			||||||
 | 
					          hasAppBarTransparent ? colorScheme.onSurface : colorScheme.onPrimary,
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
    pageTransitionsTheme: PageTransitionsTheme(
 | 
					    pageTransitionsTheme: PageTransitionsTheme(
 | 
				
			||||||
      builders: {
 | 
					      builders: {
 | 
				
			||||||
        TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),
 | 
					        TargetPlatform.android: ZoomPageTransitionsBuilder(),
 | 
				
			||||||
        TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
 | 
					        TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
 | 
				
			||||||
        TargetPlatform.macOS: ZoomPageTransitionsBuilder(),
 | 
					        TargetPlatform.macOS: ZoomPageTransitionsBuilder(),
 | 
				
			||||||
        TargetPlatform.fuchsia: ZoomPageTransitionsBuilder(),
 | 
					        TargetPlatform.fuchsia: ZoomPageTransitionsBuilder(),
 | 
				
			||||||
@@ -67,3 +90,20 @@ Future<ThemeData> createAppTheme(
 | 
				
			|||||||
    ),
 | 
					    ),
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					extension HexColor on Color {
 | 
				
			||||||
 | 
					  /// String is in the format "aabbcc" or "ffaabbcc" with an optional leading "#".
 | 
				
			||||||
 | 
					  static Color fromHex(String hexString) {
 | 
				
			||||||
 | 
					    final buffer = StringBuffer();
 | 
				
			||||||
 | 
					    if (hexString.length == 6 || hexString.length == 7) buffer.write('ff');
 | 
				
			||||||
 | 
					    buffer.write(hexString.replaceFirst('#', ''));
 | 
				
			||||||
 | 
					    return Color(int.parse(buffer.toString(), radix: 16));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Prefixes a hash sign if [leadingHashSign] is set to `true` (default is `true`).
 | 
				
			||||||
 | 
					  String toHex({bool leadingHashSign = true}) => '${leadingHashSign ? '#' : ''}'
 | 
				
			||||||
 | 
					      '${alpha.toRadixString(16).padLeft(2, '0')}'
 | 
				
			||||||
 | 
					      '${red.toRadixString(16).padLeft(2, '0')}'
 | 
				
			||||||
 | 
					      '${green.toRadixString(16).padLeft(2, '0')}'
 | 
				
			||||||
 | 
					      '${blue.toRadixString(16).padLeft(2, '0')}';
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,15 +1,14 @@
 | 
				
			|||||||
import 'package:freezed_annotation/freezed_annotation.dart';
 | 
					import 'package:freezed_annotation/freezed_annotation.dart';
 | 
				
			||||||
import 'package:hive_flutter/hive_flutter.dart';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
part 'account.freezed.dart';
 | 
					part 'account.freezed.dart';
 | 
				
			||||||
part 'account.g.dart';
 | 
					part 'account.g.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@freezed
 | 
					@freezed
 | 
				
			||||||
class SnAccount with _$SnAccount {
 | 
					abstract class SnAccount with _$SnAccount {
 | 
				
			||||||
  const SnAccount._();
 | 
					  const SnAccount._();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const factory SnAccount({
 | 
					  const factory SnAccount({
 | 
				
			||||||
    @HiveField(0) required int id,
 | 
					    required int id,
 | 
				
			||||||
    required DateTime createdAt,
 | 
					    required DateTime createdAt,
 | 
				
			||||||
    required DateTime updatedAt,
 | 
					    required DateTime updatedAt,
 | 
				
			||||||
    required DateTime? deletedAt,
 | 
					    required DateTime? deletedAt,
 | 
				
			||||||
@@ -17,10 +16,9 @@ class SnAccount with _$SnAccount {
 | 
				
			|||||||
    required List<SnAccountContact>? contacts,
 | 
					    required List<SnAccountContact>? contacts,
 | 
				
			||||||
    @Default("") String avatar,
 | 
					    @Default("") String avatar,
 | 
				
			||||||
    @Default("") String banner,
 | 
					    @Default("") String banner,
 | 
				
			||||||
    required String description,
 | 
					 | 
				
			||||||
    required String name,
 | 
					    required String name,
 | 
				
			||||||
    required String nick,
 | 
					    required String nick,
 | 
				
			||||||
    required Map<String, dynamic> permNodes,
 | 
					    @Default({}) Map<String, dynamic> permNodes,
 | 
				
			||||||
    required String language,
 | 
					    required String language,
 | 
				
			||||||
    required SnAccountProfile? profile,
 | 
					    required SnAccountProfile? profile,
 | 
				
			||||||
    @Default([]) List<SnAccountBadge> badges,
 | 
					    @Default([]) List<SnAccountBadge> badges,
 | 
				
			||||||
@@ -36,7 +34,7 @@ class SnAccount with _$SnAccount {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@freezed
 | 
					@freezed
 | 
				
			||||||
class SnAccountContact with _$SnAccountContact {
 | 
					abstract class SnAccountContact with _$SnAccountContact {
 | 
				
			||||||
  const factory SnAccountContact({
 | 
					  const factory SnAccountContact({
 | 
				
			||||||
    required int accountId,
 | 
					    required int accountId,
 | 
				
			||||||
    required String content,
 | 
					    required String content,
 | 
				
			||||||
@@ -55,18 +53,24 @@ class SnAccountContact with _$SnAccountContact {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@freezed
 | 
					@freezed
 | 
				
			||||||
class SnAccountProfile with _$SnAccountProfile {
 | 
					abstract class SnAccountProfile with _$SnAccountProfile {
 | 
				
			||||||
  const factory SnAccountProfile({
 | 
					  const factory SnAccountProfile({
 | 
				
			||||||
    required int id,
 | 
					    required int id,
 | 
				
			||||||
    required int accountId,
 | 
					 | 
				
			||||||
    required DateTime? birthday,
 | 
					 | 
				
			||||||
    required DateTime createdAt,
 | 
					    required DateTime createdAt,
 | 
				
			||||||
 | 
					    required DateTime updatedAt,
 | 
				
			||||||
    required DateTime? deletedAt,
 | 
					    required DateTime? deletedAt,
 | 
				
			||||||
    required int experience,
 | 
					 | 
				
			||||||
    required String firstName,
 | 
					    required String firstName,
 | 
				
			||||||
    required String lastName,
 | 
					    required String lastName,
 | 
				
			||||||
 | 
					    required String description,
 | 
				
			||||||
 | 
					    required String timeZone,
 | 
				
			||||||
 | 
					    required String location,
 | 
				
			||||||
 | 
					    required String pronouns,
 | 
				
			||||||
 | 
					    required String gender,
 | 
				
			||||||
 | 
					    @Default({}) Map<String, String> links,
 | 
				
			||||||
 | 
					    required int experience,
 | 
				
			||||||
    required DateTime? lastSeenAt,
 | 
					    required DateTime? lastSeenAt,
 | 
				
			||||||
    required DateTime updatedAt,
 | 
					    required DateTime? birthday,
 | 
				
			||||||
 | 
					    required int accountId,
 | 
				
			||||||
  }) = _SnAccountProfile;
 | 
					  }) = _SnAccountProfile;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  factory SnAccountProfile.fromJson(Map<String, Object?> json) =>
 | 
					  factory SnAccountProfile.fromJson(Map<String, Object?> json) =>
 | 
				
			||||||
@@ -74,7 +78,7 @@ class SnAccountProfile with _$SnAccountProfile {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@freezed
 | 
					@freezed
 | 
				
			||||||
class SnRelationship with _$SnRelationship {
 | 
					abstract class SnRelationship with _$SnRelationship {
 | 
				
			||||||
  const factory SnRelationship({
 | 
					  const factory SnRelationship({
 | 
				
			||||||
    required int id,
 | 
					    required int id,
 | 
				
			||||||
    required DateTime createdAt,
 | 
					    required DateTime createdAt,
 | 
				
			||||||
@@ -93,7 +97,7 @@ class SnRelationship with _$SnRelationship {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@freezed
 | 
					@freezed
 | 
				
			||||||
class SnAccountBadge with _$SnAccountBadge {
 | 
					abstract class SnAccountBadge with _$SnAccountBadge {
 | 
				
			||||||
  const factory SnAccountBadge({
 | 
					  const factory SnAccountBadge({
 | 
				
			||||||
    required int id,
 | 
					    required int id,
 | 
				
			||||||
    required DateTime createdAt,
 | 
					    required DateTime createdAt,
 | 
				
			||||||
@@ -101,6 +105,7 @@ class SnAccountBadge with _$SnAccountBadge {
 | 
				
			|||||||
    required dynamic deletedAt,
 | 
					    required dynamic deletedAt,
 | 
				
			||||||
    required String type,
 | 
					    required String type,
 | 
				
			||||||
    required int accountId,
 | 
					    required int accountId,
 | 
				
			||||||
 | 
					    @Default(false) bool isActive,
 | 
				
			||||||
    @Default({}) Map<String, dynamic> metadata,
 | 
					    @Default({}) Map<String, dynamic> metadata,
 | 
				
			||||||
  }) = _SnAccountBadge;
 | 
					  }) = _SnAccountBadge;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -109,7 +114,7 @@ class SnAccountBadge with _$SnAccountBadge {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@freezed
 | 
					@freezed
 | 
				
			||||||
class SnAccountStatusInfo with _$SnAccountStatusInfo {
 | 
					abstract class SnAccountStatusInfo with _$SnAccountStatusInfo {
 | 
				
			||||||
  const factory SnAccountStatusInfo({
 | 
					  const factory SnAccountStatusInfo({
 | 
				
			||||||
    required bool isDisturbable,
 | 
					    required bool isDisturbable,
 | 
				
			||||||
    required bool isOnline,
 | 
					    required bool isOnline,
 | 
				
			||||||
@@ -122,7 +127,7 @@ class SnAccountStatusInfo with _$SnAccountStatusInfo {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@freezed
 | 
					@freezed
 | 
				
			||||||
class SnAbuseReport with _$SnAbuseReport {
 | 
					abstract class SnAbuseReport with _$SnAbuseReport {
 | 
				
			||||||
  const factory SnAbuseReport({
 | 
					  const factory SnAbuseReport({
 | 
				
			||||||
    required int id,
 | 
					    required int id,
 | 
				
			||||||
    required DateTime createdAt,
 | 
					    required DateTime createdAt,
 | 
				
			||||||
 
 | 
				
			|||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -6,8 +6,7 @@ part of 'account.dart';
 | 
				
			|||||||
// JsonSerializableGenerator
 | 
					// JsonSerializableGenerator
 | 
				
			||||||
// **************************************************************************
 | 
					// **************************************************************************
 | 
				
			||||||
 | 
					
 | 
				
			||||||
_$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
 | 
					_SnAccount _$SnAccountFromJson(Map<String, dynamic> json) => _SnAccount(
 | 
				
			||||||
    _$SnAccountImpl(
 | 
					 | 
				
			||||||
      id: (json['id'] as num).toInt(),
 | 
					      id: (json['id'] as num).toInt(),
 | 
				
			||||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
					      createdAt: DateTime.parse(json['created_at'] as String),
 | 
				
			||||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
					      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
				
			||||||
@@ -22,10 +21,9 @@ _$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
 | 
				
			|||||||
          .toList(),
 | 
					          .toList(),
 | 
				
			||||||
      avatar: json['avatar'] as String? ?? "",
 | 
					      avatar: json['avatar'] as String? ?? "",
 | 
				
			||||||
      banner: json['banner'] as String? ?? "",
 | 
					      banner: json['banner'] as String? ?? "",
 | 
				
			||||||
      description: json['description'] as String,
 | 
					 | 
				
			||||||
      name: json['name'] as String,
 | 
					      name: json['name'] as String,
 | 
				
			||||||
      nick: json['nick'] as String,
 | 
					      nick: json['nick'] as String,
 | 
				
			||||||
      permNodes: json['perm_nodes'] as Map<String, dynamic>,
 | 
					      permNodes: json['perm_nodes'] as Map<String, dynamic>? ?? const {},
 | 
				
			||||||
      language: json['language'] as String,
 | 
					      language: json['language'] as String,
 | 
				
			||||||
      profile: json['profile'] == null
 | 
					      profile: json['profile'] == null
 | 
				
			||||||
          ? null
 | 
					          ? null
 | 
				
			||||||
@@ -43,7 +41,7 @@ _$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
 | 
				
			|||||||
      automatedId: (json['automated_id'] as num?)?.toInt(),
 | 
					      automatedId: (json['automated_id'] as num?)?.toInt(),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) =>
 | 
					Map<String, dynamic> _$SnAccountToJson(_SnAccount instance) =>
 | 
				
			||||||
    <String, dynamic>{
 | 
					    <String, dynamic>{
 | 
				
			||||||
      'id': instance.id,
 | 
					      'id': instance.id,
 | 
				
			||||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
					      'created_at': instance.createdAt.toIso8601String(),
 | 
				
			||||||
@@ -53,7 +51,6 @@ Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) =>
 | 
				
			|||||||
      'contacts': instance.contacts?.map((e) => e.toJson()).toList(),
 | 
					      'contacts': instance.contacts?.map((e) => e.toJson()).toList(),
 | 
				
			||||||
      'avatar': instance.avatar,
 | 
					      'avatar': instance.avatar,
 | 
				
			||||||
      'banner': instance.banner,
 | 
					      'banner': instance.banner,
 | 
				
			||||||
      'description': instance.description,
 | 
					 | 
				
			||||||
      'name': instance.name,
 | 
					      'name': instance.name,
 | 
				
			||||||
      'nick': instance.nick,
 | 
					      'nick': instance.nick,
 | 
				
			||||||
      'perm_nodes': instance.permNodes,
 | 
					      'perm_nodes': instance.permNodes,
 | 
				
			||||||
@@ -67,9 +64,8 @@ Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) =>
 | 
				
			|||||||
      'automated_id': instance.automatedId,
 | 
					      'automated_id': instance.automatedId,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
_$SnAccountContactImpl _$$SnAccountContactImplFromJson(
 | 
					_SnAccountContact _$SnAccountContactFromJson(Map<String, dynamic> json) =>
 | 
				
			||||||
        Map<String, dynamic> json) =>
 | 
					    _SnAccountContact(
 | 
				
			||||||
    _$SnAccountContactImpl(
 | 
					 | 
				
			||||||
      accountId: (json['account_id'] as num).toInt(),
 | 
					      accountId: (json['account_id'] as num).toInt(),
 | 
				
			||||||
      content: json['content'] as String,
 | 
					      content: json['content'] as String,
 | 
				
			||||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
					      createdAt: DateTime.parse(json['created_at'] as String),
 | 
				
			||||||
@@ -86,8 +82,7 @@ _$SnAccountContactImpl _$$SnAccountContactImplFromJson(
 | 
				
			|||||||
          : DateTime.parse(json['verified_at'] as String),
 | 
					          : DateTime.parse(json['verified_at'] as String),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Map<String, dynamic> _$$SnAccountContactImplToJson(
 | 
					Map<String, dynamic> _$SnAccountContactToJson(_SnAccountContact instance) =>
 | 
				
			||||||
        _$SnAccountContactImpl instance) =>
 | 
					 | 
				
			||||||
    <String, dynamic>{
 | 
					    <String, dynamic>{
 | 
				
			||||||
      'account_id': instance.accountId,
 | 
					      'account_id': instance.accountId,
 | 
				
			||||||
      'content': instance.content,
 | 
					      'content': instance.content,
 | 
				
			||||||
@@ -101,44 +96,57 @@ Map<String, dynamic> _$$SnAccountContactImplToJson(
 | 
				
			|||||||
      'verified_at': instance.verifiedAt?.toIso8601String(),
 | 
					      'verified_at': instance.verifiedAt?.toIso8601String(),
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
_$SnAccountProfileImpl _$$SnAccountProfileImplFromJson(
 | 
					_SnAccountProfile _$SnAccountProfileFromJson(Map<String, dynamic> json) =>
 | 
				
			||||||
        Map<String, dynamic> json) =>
 | 
					    _SnAccountProfile(
 | 
				
			||||||
    _$SnAccountProfileImpl(
 | 
					 | 
				
			||||||
      id: (json['id'] as num).toInt(),
 | 
					      id: (json['id'] as num).toInt(),
 | 
				
			||||||
      accountId: (json['account_id'] as num).toInt(),
 | 
					 | 
				
			||||||
      birthday: json['birthday'] == null
 | 
					 | 
				
			||||||
          ? null
 | 
					 | 
				
			||||||
          : DateTime.parse(json['birthday'] as String),
 | 
					 | 
				
			||||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
					      createdAt: DateTime.parse(json['created_at'] as String),
 | 
				
			||||||
 | 
					      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
				
			||||||
      deletedAt: json['deleted_at'] == null
 | 
					      deletedAt: json['deleted_at'] == null
 | 
				
			||||||
          ? null
 | 
					          ? null
 | 
				
			||||||
          : DateTime.parse(json['deleted_at'] as String),
 | 
					          : DateTime.parse(json['deleted_at'] as String),
 | 
				
			||||||
      experience: (json['experience'] as num).toInt(),
 | 
					 | 
				
			||||||
      firstName: json['first_name'] as String,
 | 
					      firstName: json['first_name'] as String,
 | 
				
			||||||
      lastName: json['last_name'] as String,
 | 
					      lastName: json['last_name'] as String,
 | 
				
			||||||
 | 
					      description: json['description'] as String,
 | 
				
			||||||
 | 
					      timeZone: json['time_zone'] as String,
 | 
				
			||||||
 | 
					      location: json['location'] as String,
 | 
				
			||||||
 | 
					      pronouns: json['pronouns'] as String,
 | 
				
			||||||
 | 
					      gender: json['gender'] as String,
 | 
				
			||||||
 | 
					      links: (json['links'] as Map<String, dynamic>?)?.map(
 | 
				
			||||||
 | 
					            (k, e) => MapEntry(k, e as String),
 | 
				
			||||||
 | 
					          ) ??
 | 
				
			||||||
 | 
					          const {},
 | 
				
			||||||
 | 
					      experience: (json['experience'] as num).toInt(),
 | 
				
			||||||
      lastSeenAt: json['last_seen_at'] == null
 | 
					      lastSeenAt: json['last_seen_at'] == null
 | 
				
			||||||
          ? null
 | 
					          ? null
 | 
				
			||||||
          : DateTime.parse(json['last_seen_at'] as String),
 | 
					          : DateTime.parse(json['last_seen_at'] as String),
 | 
				
			||||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
					      birthday: json['birthday'] == null
 | 
				
			||||||
 | 
					          ? null
 | 
				
			||||||
 | 
					          : DateTime.parse(json['birthday'] as String),
 | 
				
			||||||
 | 
					      accountId: (json['account_id'] as num).toInt(),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Map<String, dynamic> _$$SnAccountProfileImplToJson(
 | 
					Map<String, dynamic> _$SnAccountProfileToJson(_SnAccountProfile instance) =>
 | 
				
			||||||
        _$SnAccountProfileImpl instance) =>
 | 
					 | 
				
			||||||
    <String, dynamic>{
 | 
					    <String, dynamic>{
 | 
				
			||||||
      'id': instance.id,
 | 
					      'id': instance.id,
 | 
				
			||||||
      'account_id': instance.accountId,
 | 
					 | 
				
			||||||
      'birthday': instance.birthday?.toIso8601String(),
 | 
					 | 
				
			||||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
					      'created_at': instance.createdAt.toIso8601String(),
 | 
				
			||||||
 | 
					      'updated_at': instance.updatedAt.toIso8601String(),
 | 
				
			||||||
      'deleted_at': instance.deletedAt?.toIso8601String(),
 | 
					      'deleted_at': instance.deletedAt?.toIso8601String(),
 | 
				
			||||||
      'experience': instance.experience,
 | 
					 | 
				
			||||||
      'first_name': instance.firstName,
 | 
					      'first_name': instance.firstName,
 | 
				
			||||||
      'last_name': instance.lastName,
 | 
					      'last_name': instance.lastName,
 | 
				
			||||||
 | 
					      'description': instance.description,
 | 
				
			||||||
 | 
					      'time_zone': instance.timeZone,
 | 
				
			||||||
 | 
					      'location': instance.location,
 | 
				
			||||||
 | 
					      'pronouns': instance.pronouns,
 | 
				
			||||||
 | 
					      'gender': instance.gender,
 | 
				
			||||||
 | 
					      'links': instance.links,
 | 
				
			||||||
 | 
					      'experience': instance.experience,
 | 
				
			||||||
      'last_seen_at': instance.lastSeenAt?.toIso8601String(),
 | 
					      'last_seen_at': instance.lastSeenAt?.toIso8601String(),
 | 
				
			||||||
      'updated_at': instance.updatedAt.toIso8601String(),
 | 
					      'birthday': instance.birthday?.toIso8601String(),
 | 
				
			||||||
 | 
					      'account_id': instance.accountId,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
_$SnRelationshipImpl _$$SnRelationshipImplFromJson(Map<String, dynamic> json) =>
 | 
					_SnRelationship _$SnRelationshipFromJson(Map<String, dynamic> json) =>
 | 
				
			||||||
    _$SnRelationshipImpl(
 | 
					    _SnRelationship(
 | 
				
			||||||
      id: (json['id'] as num).toInt(),
 | 
					      id: (json['id'] as num).toInt(),
 | 
				
			||||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
					      createdAt: DateTime.parse(json['created_at'] as String),
 | 
				
			||||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
					      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
				
			||||||
@@ -157,8 +165,7 @@ _$SnRelationshipImpl _$$SnRelationshipImplFromJson(Map<String, dynamic> json) =>
 | 
				
			|||||||
      permNodes: json['perm_nodes'] as Map<String, dynamic>? ?? const {},
 | 
					      permNodes: json['perm_nodes'] as Map<String, dynamic>? ?? const {},
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Map<String, dynamic> _$$SnRelationshipImplToJson(
 | 
					Map<String, dynamic> _$SnRelationshipToJson(_SnRelationship instance) =>
 | 
				
			||||||
        _$SnRelationshipImpl instance) =>
 | 
					 | 
				
			||||||
    <String, dynamic>{
 | 
					    <String, dynamic>{
 | 
				
			||||||
      'id': instance.id,
 | 
					      'id': instance.id,
 | 
				
			||||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
					      'created_at': instance.createdAt.toIso8601String(),
 | 
				
			||||||
@@ -172,19 +179,19 @@ Map<String, dynamic> _$$SnRelationshipImplToJson(
 | 
				
			|||||||
      'perm_nodes': instance.permNodes,
 | 
					      'perm_nodes': instance.permNodes,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
_$SnAccountBadgeImpl _$$SnAccountBadgeImplFromJson(Map<String, dynamic> json) =>
 | 
					_SnAccountBadge _$SnAccountBadgeFromJson(Map<String, dynamic> json) =>
 | 
				
			||||||
    _$SnAccountBadgeImpl(
 | 
					    _SnAccountBadge(
 | 
				
			||||||
      id: (json['id'] as num).toInt(),
 | 
					      id: (json['id'] as num).toInt(),
 | 
				
			||||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
					      createdAt: DateTime.parse(json['created_at'] as String),
 | 
				
			||||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
					      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
				
			||||||
      deletedAt: json['deleted_at'],
 | 
					      deletedAt: json['deleted_at'],
 | 
				
			||||||
      type: json['type'] as String,
 | 
					      type: json['type'] as String,
 | 
				
			||||||
      accountId: (json['account_id'] as num).toInt(),
 | 
					      accountId: (json['account_id'] as num).toInt(),
 | 
				
			||||||
 | 
					      isActive: json['is_active'] as bool? ?? false,
 | 
				
			||||||
      metadata: json['metadata'] as Map<String, dynamic>? ?? const {},
 | 
					      metadata: json['metadata'] as Map<String, dynamic>? ?? const {},
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Map<String, dynamic> _$$SnAccountBadgeImplToJson(
 | 
					Map<String, dynamic> _$SnAccountBadgeToJson(_SnAccountBadge instance) =>
 | 
				
			||||||
        _$SnAccountBadgeImpl instance) =>
 | 
					 | 
				
			||||||
    <String, dynamic>{
 | 
					    <String, dynamic>{
 | 
				
			||||||
      'id': instance.id,
 | 
					      'id': instance.id,
 | 
				
			||||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
					      'created_at': instance.createdAt.toIso8601String(),
 | 
				
			||||||
@@ -192,12 +199,12 @@ Map<String, dynamic> _$$SnAccountBadgeImplToJson(
 | 
				
			|||||||
      'deleted_at': instance.deletedAt,
 | 
					      'deleted_at': instance.deletedAt,
 | 
				
			||||||
      'type': instance.type,
 | 
					      'type': instance.type,
 | 
				
			||||||
      'account_id': instance.accountId,
 | 
					      'account_id': instance.accountId,
 | 
				
			||||||
 | 
					      'is_active': instance.isActive,
 | 
				
			||||||
      'metadata': instance.metadata,
 | 
					      'metadata': instance.metadata,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
_$SnAccountStatusInfoImpl _$$SnAccountStatusInfoImplFromJson(
 | 
					_SnAccountStatusInfo _$SnAccountStatusInfoFromJson(Map<String, dynamic> json) =>
 | 
				
			||||||
        Map<String, dynamic> json) =>
 | 
					    _SnAccountStatusInfo(
 | 
				
			||||||
    _$SnAccountStatusInfoImpl(
 | 
					 | 
				
			||||||
      isDisturbable: json['is_disturbable'] as bool,
 | 
					      isDisturbable: json['is_disturbable'] as bool,
 | 
				
			||||||
      isOnline: json['is_online'] as bool,
 | 
					      isOnline: json['is_online'] as bool,
 | 
				
			||||||
      lastSeenAt: json['last_seen_at'] == null
 | 
					      lastSeenAt: json['last_seen_at'] == null
 | 
				
			||||||
@@ -206,8 +213,8 @@ _$SnAccountStatusInfoImpl _$$SnAccountStatusInfoImplFromJson(
 | 
				
			|||||||
      status: json['status'],
 | 
					      status: json['status'],
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Map<String, dynamic> _$$SnAccountStatusInfoImplToJson(
 | 
					Map<String, dynamic> _$SnAccountStatusInfoToJson(
 | 
				
			||||||
        _$SnAccountStatusInfoImpl instance) =>
 | 
					        _SnAccountStatusInfo instance) =>
 | 
				
			||||||
    <String, dynamic>{
 | 
					    <String, dynamic>{
 | 
				
			||||||
      'is_disturbable': instance.isDisturbable,
 | 
					      'is_disturbable': instance.isDisturbable,
 | 
				
			||||||
      'is_online': instance.isOnline,
 | 
					      'is_online': instance.isOnline,
 | 
				
			||||||
@@ -215,8 +222,8 @@ Map<String, dynamic> _$$SnAccountStatusInfoImplToJson(
 | 
				
			|||||||
      'status': instance.status,
 | 
					      'status': instance.status,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
_$SnAbuseReportImpl _$$SnAbuseReportImplFromJson(Map<String, dynamic> json) =>
 | 
					_SnAbuseReport _$SnAbuseReportFromJson(Map<String, dynamic> json) =>
 | 
				
			||||||
    _$SnAbuseReportImpl(
 | 
					    _SnAbuseReport(
 | 
				
			||||||
      id: (json['id'] as num).toInt(),
 | 
					      id: (json['id'] as num).toInt(),
 | 
				
			||||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
					      createdAt: DateTime.parse(json['created_at'] as String),
 | 
				
			||||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
					      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
				
			||||||
@@ -229,7 +236,7 @@ _$SnAbuseReportImpl _$$SnAbuseReportImplFromJson(Map<String, dynamic> json) =>
 | 
				
			|||||||
      accountId: (json['account_id'] as num).toInt(),
 | 
					      accountId: (json['account_id'] as num).toInt(),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Map<String, dynamic> _$$SnAbuseReportImplToJson(_$SnAbuseReportImpl instance) =>
 | 
					Map<String, dynamic> _$SnAbuseReportToJson(_SnAbuseReport instance) =>
 | 
				
			||||||
    <String, dynamic>{
 | 
					    <String, dynamic>{
 | 
				
			||||||
      'id': instance.id,
 | 
					      'id': instance.id,
 | 
				
			||||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
					      'created_at': instance.createdAt.toIso8601String(),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -12,7 +12,7 @@ enum SnMediaType {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@freezed
 | 
					@freezed
 | 
				
			||||||
class SnAttachment with _$SnAttachment {
 | 
					abstract class SnAttachment with _$SnAttachment {
 | 
				
			||||||
  const SnAttachment._();
 | 
					  const SnAttachment._();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const factory SnAttachment({
 | 
					  const factory SnAttachment({
 | 
				
			||||||
@@ -65,7 +65,7 @@ class SnAttachment with _$SnAttachment {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@freezed
 | 
					@freezed
 | 
				
			||||||
class SnAttachmentFragment with _$SnAttachmentFragment {
 | 
					abstract class SnAttachmentFragment with _$SnAttachmentFragment {
 | 
				
			||||||
  const SnAttachmentFragment._();
 | 
					  const SnAttachmentFragment._();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const factory SnAttachmentFragment({
 | 
					  const factory SnAttachmentFragment({
 | 
				
			||||||
@@ -96,7 +96,7 @@ class SnAttachmentFragment with _$SnAttachmentFragment {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@freezed
 | 
					@freezed
 | 
				
			||||||
class SnAttachmentPool with _$SnAttachmentPool {
 | 
					abstract class SnAttachmentPool with _$SnAttachmentPool {
 | 
				
			||||||
  const factory SnAttachmentPool({
 | 
					  const factory SnAttachmentPool({
 | 
				
			||||||
    required int id,
 | 
					    required int id,
 | 
				
			||||||
    required DateTime createdAt,
 | 
					    required DateTime createdAt,
 | 
				
			||||||
@@ -113,7 +113,7 @@ class SnAttachmentPool with _$SnAttachmentPool {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@freezed
 | 
					@freezed
 | 
				
			||||||
class SnAttachmentDestination with _$SnAttachmentDestination {
 | 
					abstract class SnAttachmentDestination with _$SnAttachmentDestination {
 | 
				
			||||||
  const factory SnAttachmentDestination({
 | 
					  const factory SnAttachmentDestination({
 | 
				
			||||||
    @Default(0) int id,
 | 
					    @Default(0) int id,
 | 
				
			||||||
    required String type,
 | 
					    required String type,
 | 
				
			||||||
@@ -126,7 +126,7 @@ class SnAttachmentDestination with _$SnAttachmentDestination {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@freezed
 | 
					@freezed
 | 
				
			||||||
class SnAttachmentBoost with _$SnAttachmentBoost {
 | 
					abstract class SnAttachmentBoost with _$SnAttachmentBoost {
 | 
				
			||||||
  const factory SnAttachmentBoost({
 | 
					  const factory SnAttachmentBoost({
 | 
				
			||||||
    required int id,
 | 
					    required int id,
 | 
				
			||||||
    required DateTime createdAt,
 | 
					    required DateTime createdAt,
 | 
				
			||||||
@@ -143,7 +143,7 @@ class SnAttachmentBoost with _$SnAttachmentBoost {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@freezed
 | 
					@freezed
 | 
				
			||||||
class SnSticker with _$SnSticker {
 | 
					abstract class SnSticker with _$SnSticker {
 | 
				
			||||||
  const factory SnSticker({
 | 
					  const factory SnSticker({
 | 
				
			||||||
    required int id,
 | 
					    required int id,
 | 
				
			||||||
    required DateTime createdAt,
 | 
					    required DateTime createdAt,
 | 
				
			||||||
@@ -162,7 +162,7 @@ class SnSticker with _$SnSticker {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@freezed
 | 
					@freezed
 | 
				
			||||||
class SnStickerPack with _$SnStickerPack {
 | 
					abstract class SnStickerPack with _$SnStickerPack {
 | 
				
			||||||
  const factory SnStickerPack({
 | 
					  const factory SnStickerPack({
 | 
				
			||||||
    required int id,
 | 
					    required int id,
 | 
				
			||||||
    required DateTime createdAt,
 | 
					    required DateTime createdAt,
 | 
				
			||||||
@@ -177,3 +177,14 @@ class SnStickerPack with _$SnStickerPack {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  factory SnStickerPack.fromJson(Map<String, Object?> json) => _$SnStickerPackFromJson(json);
 | 
					  factory SnStickerPack.fromJson(Map<String, Object?> json) => _$SnStickerPackFromJson(json);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@freezed
 | 
				
			||||||
 | 
					abstract class SnAttachmentBilling with _$SnAttachmentBilling {
 | 
				
			||||||
 | 
					  const factory SnAttachmentBilling({
 | 
				
			||||||
 | 
					    required int currentBytes,
 | 
				
			||||||
 | 
					    required int discountFileSize,
 | 
				
			||||||
 | 
					    required double includedRatio,
 | 
				
			||||||
 | 
					  }) = _SnAttachmentBilling;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  factory SnAttachmentBilling.fromJson(Map<String, Object?> json) => _$SnAttachmentBillingFromJson(json);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -6,8 +6,8 @@ part of 'attachment.dart';
 | 
				
			|||||||
// JsonSerializableGenerator
 | 
					// JsonSerializableGenerator
 | 
				
			||||||
// **************************************************************************
 | 
					// **************************************************************************
 | 
				
			||||||
 | 
					
 | 
				
			||||||
_$SnAttachmentImpl _$$SnAttachmentImplFromJson(Map<String, dynamic> json) =>
 | 
					_SnAttachment _$SnAttachmentFromJson(Map<String, dynamic> json) =>
 | 
				
			||||||
    _$SnAttachmentImpl(
 | 
					    _SnAttachment(
 | 
				
			||||||
      id: (json['id'] as num).toInt(),
 | 
					      id: (json['id'] as num).toInt(),
 | 
				
			||||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
					      createdAt: DateTime.parse(json['created_at'] as String),
 | 
				
			||||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
					      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
				
			||||||
@@ -57,7 +57,7 @@ _$SnAttachmentImpl _$$SnAttachmentImplFromJson(Map<String, dynamic> json) =>
 | 
				
			|||||||
      metadata: json['metadata'] as Map<String, dynamic>? ?? const {},
 | 
					      metadata: json['metadata'] as Map<String, dynamic>? ?? const {},
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Map<String, dynamic> _$$SnAttachmentImplToJson(_$SnAttachmentImpl instance) =>
 | 
					Map<String, dynamic> _$SnAttachmentToJson(_SnAttachment instance) =>
 | 
				
			||||||
    <String, dynamic>{
 | 
					    <String, dynamic>{
 | 
				
			||||||
      'id': instance.id,
 | 
					      'id': instance.id,
 | 
				
			||||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
					      'created_at': instance.createdAt.toIso8601String(),
 | 
				
			||||||
@@ -92,9 +92,9 @@ Map<String, dynamic> _$$SnAttachmentImplToJson(_$SnAttachmentImpl instance) =>
 | 
				
			|||||||
      'metadata': instance.metadata,
 | 
					      'metadata': instance.metadata,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
_$SnAttachmentFragmentImpl _$$SnAttachmentFragmentImplFromJson(
 | 
					_SnAttachmentFragment _$SnAttachmentFragmentFromJson(
 | 
				
			||||||
        Map<String, dynamic> json) =>
 | 
					        Map<String, dynamic> json) =>
 | 
				
			||||||
    _$SnAttachmentFragmentImpl(
 | 
					    _SnAttachmentFragment(
 | 
				
			||||||
      id: (json['id'] as num).toInt(),
 | 
					      id: (json['id'] as num).toInt(),
 | 
				
			||||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
					      createdAt: DateTime.parse(json['created_at'] as String),
 | 
				
			||||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
					      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
				
			||||||
@@ -119,8 +119,8 @@ _$SnAttachmentFragmentImpl _$$SnAttachmentFragmentImplFromJson(
 | 
				
			|||||||
          const [],
 | 
					          const [],
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Map<String, dynamic> _$$SnAttachmentFragmentImplToJson(
 | 
					Map<String, dynamic> _$SnAttachmentFragmentToJson(
 | 
				
			||||||
        _$SnAttachmentFragmentImpl instance) =>
 | 
					        _SnAttachmentFragment instance) =>
 | 
				
			||||||
    <String, dynamic>{
 | 
					    <String, dynamic>{
 | 
				
			||||||
      'id': instance.id,
 | 
					      'id': instance.id,
 | 
				
			||||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
					      'created_at': instance.createdAt.toIso8601String(),
 | 
				
			||||||
@@ -138,9 +138,8 @@ Map<String, dynamic> _$$SnAttachmentFragmentImplToJson(
 | 
				
			|||||||
      'file_chunks_missing': instance.fileChunksMissing,
 | 
					      'file_chunks_missing': instance.fileChunksMissing,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
_$SnAttachmentPoolImpl _$$SnAttachmentPoolImplFromJson(
 | 
					_SnAttachmentPool _$SnAttachmentPoolFromJson(Map<String, dynamic> json) =>
 | 
				
			||||||
        Map<String, dynamic> json) =>
 | 
					    _SnAttachmentPool(
 | 
				
			||||||
    _$SnAttachmentPoolImpl(
 | 
					 | 
				
			||||||
      id: (json['id'] as num).toInt(),
 | 
					      id: (json['id'] as num).toInt(),
 | 
				
			||||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
					      createdAt: DateTime.parse(json['created_at'] as String),
 | 
				
			||||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
					      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
				
			||||||
@@ -154,8 +153,7 @@ _$SnAttachmentPoolImpl _$$SnAttachmentPoolImplFromJson(
 | 
				
			|||||||
      accountId: (json['account_id'] as num?)?.toInt(),
 | 
					      accountId: (json['account_id'] as num?)?.toInt(),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Map<String, dynamic> _$$SnAttachmentPoolImplToJson(
 | 
					Map<String, dynamic> _$SnAttachmentPoolToJson(_SnAttachmentPool instance) =>
 | 
				
			||||||
        _$SnAttachmentPoolImpl instance) =>
 | 
					 | 
				
			||||||
    <String, dynamic>{
 | 
					    <String, dynamic>{
 | 
				
			||||||
      'id': instance.id,
 | 
					      'id': instance.id,
 | 
				
			||||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
					      'created_at': instance.createdAt.toIso8601String(),
 | 
				
			||||||
@@ -168,9 +166,9 @@ Map<String, dynamic> _$$SnAttachmentPoolImplToJson(
 | 
				
			|||||||
      'account_id': instance.accountId,
 | 
					      'account_id': instance.accountId,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
_$SnAttachmentDestinationImpl _$$SnAttachmentDestinationImplFromJson(
 | 
					_SnAttachmentDestination _$SnAttachmentDestinationFromJson(
 | 
				
			||||||
        Map<String, dynamic> json) =>
 | 
					        Map<String, dynamic> json) =>
 | 
				
			||||||
    _$SnAttachmentDestinationImpl(
 | 
					    _SnAttachmentDestination(
 | 
				
			||||||
      id: (json['id'] as num?)?.toInt() ?? 0,
 | 
					      id: (json['id'] as num?)?.toInt() ?? 0,
 | 
				
			||||||
      type: json['type'] as String,
 | 
					      type: json['type'] as String,
 | 
				
			||||||
      label: json['label'] as String,
 | 
					      label: json['label'] as String,
 | 
				
			||||||
@@ -178,8 +176,8 @@ _$SnAttachmentDestinationImpl _$$SnAttachmentDestinationImplFromJson(
 | 
				
			|||||||
      isBoost: json['is_boost'] as bool,
 | 
					      isBoost: json['is_boost'] as bool,
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Map<String, dynamic> _$$SnAttachmentDestinationImplToJson(
 | 
					Map<String, dynamic> _$SnAttachmentDestinationToJson(
 | 
				
			||||||
        _$SnAttachmentDestinationImpl instance) =>
 | 
					        _SnAttachmentDestination instance) =>
 | 
				
			||||||
    <String, dynamic>{
 | 
					    <String, dynamic>{
 | 
				
			||||||
      'id': instance.id,
 | 
					      'id': instance.id,
 | 
				
			||||||
      'type': instance.type,
 | 
					      'type': instance.type,
 | 
				
			||||||
@@ -188,9 +186,8 @@ Map<String, dynamic> _$$SnAttachmentDestinationImplToJson(
 | 
				
			|||||||
      'is_boost': instance.isBoost,
 | 
					      'is_boost': instance.isBoost,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
_$SnAttachmentBoostImpl _$$SnAttachmentBoostImplFromJson(
 | 
					_SnAttachmentBoost _$SnAttachmentBoostFromJson(Map<String, dynamic> json) =>
 | 
				
			||||||
        Map<String, dynamic> json) =>
 | 
					    _SnAttachmentBoost(
 | 
				
			||||||
    _$SnAttachmentBoostImpl(
 | 
					 | 
				
			||||||
      id: (json['id'] as num).toInt(),
 | 
					      id: (json['id'] as num).toInt(),
 | 
				
			||||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
					      createdAt: DateTime.parse(json['created_at'] as String),
 | 
				
			||||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
					      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
				
			||||||
@@ -205,8 +202,7 @@ _$SnAttachmentBoostImpl _$$SnAttachmentBoostImplFromJson(
 | 
				
			|||||||
      account: (json['account'] as num).toInt(),
 | 
					      account: (json['account'] as num).toInt(),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Map<String, dynamic> _$$SnAttachmentBoostImplToJson(
 | 
					Map<String, dynamic> _$SnAttachmentBoostToJson(_SnAttachmentBoost instance) =>
 | 
				
			||||||
        _$SnAttachmentBoostImpl instance) =>
 | 
					 | 
				
			||||||
    <String, dynamic>{
 | 
					    <String, dynamic>{
 | 
				
			||||||
      'id': instance.id,
 | 
					      'id': instance.id,
 | 
				
			||||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
					      'created_at': instance.createdAt.toIso8601String(),
 | 
				
			||||||
@@ -219,8 +215,7 @@ Map<String, dynamic> _$$SnAttachmentBoostImplToJson(
 | 
				
			|||||||
      'account': instance.account,
 | 
					      'account': instance.account,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
_$SnStickerImpl _$$SnStickerImplFromJson(Map<String, dynamic> json) =>
 | 
					_SnSticker _$SnStickerFromJson(Map<String, dynamic> json) => _SnSticker(
 | 
				
			||||||
    _$SnStickerImpl(
 | 
					 | 
				
			||||||
      id: (json['id'] as num).toInt(),
 | 
					      id: (json['id'] as num).toInt(),
 | 
				
			||||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
					      createdAt: DateTime.parse(json['created_at'] as String),
 | 
				
			||||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
					      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
				
			||||||
@@ -237,7 +232,7 @@ _$SnStickerImpl _$$SnStickerImplFromJson(Map<String, dynamic> json) =>
 | 
				
			|||||||
      accountId: (json['account_id'] as num).toInt(),
 | 
					      accountId: (json['account_id'] as num).toInt(),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Map<String, dynamic> _$$SnStickerImplToJson(_$SnStickerImpl instance) =>
 | 
					Map<String, dynamic> _$SnStickerToJson(_SnSticker instance) =>
 | 
				
			||||||
    <String, dynamic>{
 | 
					    <String, dynamic>{
 | 
				
			||||||
      'id': instance.id,
 | 
					      'id': instance.id,
 | 
				
			||||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
					      'created_at': instance.createdAt.toIso8601String(),
 | 
				
			||||||
@@ -252,8 +247,8 @@ Map<String, dynamic> _$$SnStickerImplToJson(_$SnStickerImpl instance) =>
 | 
				
			|||||||
      'account_id': instance.accountId,
 | 
					      'account_id': instance.accountId,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
_$SnStickerPackImpl _$$SnStickerPackImplFromJson(Map<String, dynamic> json) =>
 | 
					_SnStickerPack _$SnStickerPackFromJson(Map<String, dynamic> json) =>
 | 
				
			||||||
    _$SnStickerPackImpl(
 | 
					    _SnStickerPack(
 | 
				
			||||||
      id: (json['id'] as num).toInt(),
 | 
					      id: (json['id'] as num).toInt(),
 | 
				
			||||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
					      createdAt: DateTime.parse(json['created_at'] as String),
 | 
				
			||||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
					      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
				
			||||||
@@ -269,7 +264,7 @@ _$SnStickerPackImpl _$$SnStickerPackImplFromJson(Map<String, dynamic> json) =>
 | 
				
			|||||||
      accountId: (json['account_id'] as num).toInt(),
 | 
					      accountId: (json['account_id'] as num).toInt(),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Map<String, dynamic> _$$SnStickerPackImplToJson(_$SnStickerPackImpl instance) =>
 | 
					Map<String, dynamic> _$SnStickerPackToJson(_SnStickerPack instance) =>
 | 
				
			||||||
    <String, dynamic>{
 | 
					    <String, dynamic>{
 | 
				
			||||||
      'id': instance.id,
 | 
					      'id': instance.id,
 | 
				
			||||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
					      'created_at': instance.createdAt.toIso8601String(),
 | 
				
			||||||
@@ -281,3 +276,18 @@ Map<String, dynamic> _$$SnStickerPackImplToJson(_$SnStickerPackImpl instance) =>
 | 
				
			|||||||
      'stickers': instance.stickers?.map((e) => e.toJson()).toList(),
 | 
					      'stickers': instance.stickers?.map((e) => e.toJson()).toList(),
 | 
				
			||||||
      'account_id': instance.accountId,
 | 
					      'account_id': instance.accountId,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					_SnAttachmentBilling _$SnAttachmentBillingFromJson(Map<String, dynamic> json) =>
 | 
				
			||||||
 | 
					    _SnAttachmentBilling(
 | 
				
			||||||
 | 
					      currentBytes: (json['current_bytes'] as num).toInt(),
 | 
				
			||||||
 | 
					      discountFileSize: (json['discount_file_size'] as num).toInt(),
 | 
				
			||||||
 | 
					      includedRatio: (json['included_ratio'] as num).toDouble(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Map<String, dynamic> _$SnAttachmentBillingToJson(
 | 
				
			||||||
 | 
					        _SnAttachmentBilling instance) =>
 | 
				
			||||||
 | 
					    <String, dynamic>{
 | 
				
			||||||
 | 
					      'current_bytes': instance.currentBytes,
 | 
				
			||||||
 | 
					      'discount_file_size': instance.discountFileSize,
 | 
				
			||||||
 | 
					      'included_ratio': instance.includedRatio,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,7 +4,7 @@ part 'auth.freezed.dart';
 | 
				
			|||||||
part 'auth.g.dart';
 | 
					part 'auth.g.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@freezed
 | 
					@freezed
 | 
				
			||||||
class SnAuthResult with _$SnAuthResult {
 | 
					abstract class SnAuthResult with _$SnAuthResult {
 | 
				
			||||||
  const factory SnAuthResult({
 | 
					  const factory SnAuthResult({
 | 
				
			||||||
    required bool isFinished,
 | 
					    required bool isFinished,
 | 
				
			||||||
    required SnAuthTicket? ticket,
 | 
					    required SnAuthTicket? ticket,
 | 
				
			||||||
@@ -15,7 +15,7 @@ class SnAuthResult with _$SnAuthResult {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@freezed
 | 
					@freezed
 | 
				
			||||||
class SnAuthTicket with _$SnAuthTicket {
 | 
					abstract class SnAuthTicket with _$SnAuthTicket {
 | 
				
			||||||
  const factory SnAuthTicket({
 | 
					  const factory SnAuthTicket({
 | 
				
			||||||
    required int id,
 | 
					    required int id,
 | 
				
			||||||
    required DateTime createdAt,
 | 
					    required DateTime createdAt,
 | 
				
			||||||
@@ -41,7 +41,7 @@ class SnAuthTicket with _$SnAuthTicket {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@freezed
 | 
					@freezed
 | 
				
			||||||
class SnAuthFactor with _$SnAuthFactor {
 | 
					abstract class SnAuthFactor with _$SnAuthFactor {
 | 
				
			||||||
  const factory SnAuthFactor({
 | 
					  const factory SnAuthFactor({
 | 
				
			||||||
    required int id,
 | 
					    required int id,
 | 
				
			||||||
    required DateTime createdAt,
 | 
					    required DateTime createdAt,
 | 
				
			||||||
 
 | 
				
			|||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user