Compare commits
	
		
			201 Commits
		
	
	
		
			2.3.2+70
			...
			908f0cb59e
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 908f0cb59e | |||
| 7c2b8de931 | |||
| 6bb9c21759 | |||
| 8f2fc55608 | |||
| a1c4e5eca0 | |||
| 595050f89f | |||
| 0722c99f21 | |||
| 12d03836f9 | |||
| 
						 | 
					f78d3f4fd5 | ||
| 
						 | 
					e798a8ba76 | ||
| c28a664373 | |||
| 4589722c3b | |||
| 38e1c51b45 | |||
| 610ddec05c | |||
| d0276f9ac6 | |||
| c1e89a2ee6 | |||
| ecc79368a1 | |||
| e6d732c86a | |||
| dd055fb077 | |||
| 280840c6d8 | |||
| bde62a7b2c | |||
| 5445c570a2 | |||
| b2302f5b3c | |||
| d7359cfd0d | |||
| 9cc577adbe | |||
| dd196b7754 | |||
| 16c07c2133 | |||
| 6bcb658d44 | |||
| 9311bfc3b5 | |||
| 8dd6435a30 | |||
| 21a1d4a2ad | |||
| 603875b1af | |||
| 4209a13c84 | |||
| 55b79bfd8f | |||
| 6e6c3f42f6 | |||
| dc38b46b2c | |||
| b4990308e9 | |||
| 237abe564d | |||
| 71b41d470a | |||
| 7052b5b635 | |||
| f356e08f79 | |||
| 152872db65 | |||
| dfe117d04f | |||
| caf63f0cbe | |||
| b8f5cc82f9 | |||
| 360bc50f21 | |||
| 2de93a0486 | |||
| 02227852f8 | |||
| ad16de595b | |||
| 9f8c8923d9 | |||
| 060bfa4887 | |||
| e68ada2d04 | |||
| d6013078bd | |||
| 5976d61997 | |||
| b492db90ca | |||
| c9f69fed2c | |||
| d2f4e7a969 | |||
| aecd04e0b9 | |||
| e5212419ae | |||
| ec7650a920 | |||
| 7b96013406 | |||
| fc5a79b29b | |||
| 4146820be5 | |||
| 9ec0f1ff19 | |||
| ac2aec48aa | |||
| 58421e5d5e | |||
| 172d0d24fb | |||
| 71899dd4f2 | |||
| 02ffe9866d | |||
| 1b7e668b3f | |||
| f03d80ba88 | |||
| 14ee6845ed | |||
| 8fe6c2be46 | |||
| 78e765f69d | |||
| ddd6ff7eee | |||
| b8f379796f | |||
| 3a10e9280c | |||
| 65fe06de22 | |||
| e44320e0fe | |||
| f2d913ffec | |||
| e88dea8858 | |||
| 813679b161 | |||
| 9d4ce6ca8c | |||
| 88396647f3 | |||
| 335318ae3f | |||
| da25fb9c29 | |||
| c1aef89b84 | |||
| 0241c5f804 | |||
| f6939d7c23 | |||
| d654c162e3 | |||
| 25550ba197 | |||
| 3defd3a593 | |||
| d62ed4c375 | |||
| 857f3cc832 | |||
| e16bc80eea | |||
| a4f6e8af56 | |||
| 060a97f5ec | |||
| 92f7e92018 | |||
| 5c483bd3b8 | |||
| 1c510d63fe | |||
| 115cb4adc1 | |||
| 54c098c274 | |||
| 29731728cd | |||
| 9e8882c580 | |||
| 6042e57e7a | |||
| 6235e736b9 | |||
| e075804782 | |||
| d40a6ca1c4 | |||
| 5ac657e526 | |||
| 97ddc18b8e | |||
| b835c8edea | |||
| 288c0399f9 | |||
| 1478933cf1 | |||
| 93c6fa6e53 | |||
| ce6e9c185a | |||
| cdaa8cfe58 | |||
| 76d8cd943d | |||
| d6f3ffc655 | |||
| 5a6b841253 | |||
| cb2de52bee | |||
| 64e2644745 | |||
| 56711889ab | |||
| 4f47cd2c0c | |||
| 2b61c372f5 | |||
| 73777fe74e | |||
| 33a4bd7e71 | |||
| 17e6b81f76 | |||
| 22fde6b400 | |||
| 6e03a00280 | |||
| 72e6a6a1f6 | |||
| 66aef44281 | |||
| 7bb73c80b0 | |||
| d043ef2410 | |||
| 1d0e2f7591 | |||
| e9ef28d764 | |||
| 289aa17a7a | |||
| 93f41bb523 | |||
| 09ec9d4a0c | |||
| 1153fbdeee | |||
| e933058338 | |||
| ae9743c84f | |||
| 32bf834108 | |||
| 1b41c847a6 | |||
| b1af6c2c97 | |||
| 8e76ff3f84 | |||
| bd26602299 | |||
| 52ab1d0d10 | |||
| f746e06f65 | |||
| d11069a2be | |||
| d6dc487d9e | |||
| a07c7cdede | |||
| 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 | 
							
								
								
									
										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
 | 
			
		||||
							
								
								
									
										11
									
								
								.github/workflows/nightly.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								.github/workflows/nightly.yml
									
									
									
									
										vendored
									
									
								
							@@ -52,13 +52,16 @@ jobs:
 | 
			
		||||
      - 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 -y libmpv-dev mpv
 | 
			
		||||
          sudo apt-get install -y libayatana-appindicator3-dev
 | 
			
		||||
          sudo apt-get install -y keybinder-3.0
 | 
			
		||||
          sudo apt-get install -y libnotify-dev
 | 
			
		||||
          sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
 | 
			
		||||
          sudo apt-get install -y gstreamer-1.0
 | 
			
		||||
      - run: flutter pub get
 | 
			
		||||
      - run: flutter build linux
 | 
			
		||||
      - name: Archive production artifacts
 | 
			
		||||
        uses: actions/upload-artifact@v4
 | 
			
		||||
        with:
 | 
			
		||||
          name: build-output-linux
 | 
			
		||||
          path: build/linux/x64/release/bundle
 | 
			
		||||
          path: build/linux/x64/release/bundle
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										11
									
								
								api/Interactive/Trigger Fediverse Scan.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								api/Interactive/Trigger Fediverse Scan.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
meta {
 | 
			
		||||
  name: Trigger Fediverse Scan
 | 
			
		||||
  type: http
 | 
			
		||||
  seq: 1
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
post {
 | 
			
		||||
  url: {{endpoint}}/cgi/co/admin/fediverse
 | 
			
		||||
  body: none
 | 
			
		||||
  auth: inherit
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										11
									
								
								api/Nexus/Check Status.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								api/Nexus/Check Status.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
meta {
 | 
			
		||||
  name: Check Status
 | 
			
		||||
  type: http
 | 
			
		||||
  seq: 1
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
get {
 | 
			
		||||
  url: {{endpoint}}/directory/status
 | 
			
		||||
  body: none
 | 
			
		||||
  auth: none
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										11
									
								
								api/Nexus/List Services.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								api/Nexus/List Services.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
meta {
 | 
			
		||||
  name: List Services
 | 
			
		||||
  type: http
 | 
			
		||||
  seq: 2
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
get {
 | 
			
		||||
  url: {{endpoint}}/directory/services
 | 
			
		||||
  body: none
 | 
			
		||||
  auth: none
 | 
			
		||||
}
 | 
			
		||||
@@ -12,9 +12,9 @@ post {
 | 
			
		||||
 | 
			
		||||
body:json {
 | 
			
		||||
  {
 | 
			
		||||
    "alias": "BaLoading",
 | 
			
		||||
    "name": "BaLoading",
 | 
			
		||||
    "attachment_id": "2JCI2uh21mKkfk9P",
 | 
			
		||||
    "pack_id": 3
 | 
			
		||||
    "alias": "Deadge",
 | 
			
		||||
    "name": "Dead",
 | 
			
		||||
    "attachment_id": "pcbFd0u4zgdM39HM",
 | 
			
		||||
    "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/6/status
 | 
			
		||||
  body: json
 | 
			
		||||
  auth: inherit
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body:json {
 | 
			
		||||
  {
 | 
			
		||||
    "status": "rejected",
 | 
			
		||||
    "message": "Not a good reason"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -15,12 +15,10 @@ body:json {
 | 
			
		||||
    "client_id": "{{third_client_id}}",
 | 
			
		||||
    "client_secret":"{{third_client_tk}}",
 | 
			
		||||
    "type": "general",
 | 
			
		||||
    "subject": "新年快乐!",
 | 
			
		||||
    "subtitle": "一条来自 Solar Network 团队的信息",
 | 
			
		||||
    "content": "今天是农历正月初一,小羊祝您新年快乐 🎉",
 | 
			
		||||
    "metadata": {
 | 
			
		||||
      "image": "D2EDbcrsTugs3xk5"
 | 
			
		||||
    },
 | 
			
		||||
    "subject": "关于迁移服务器完成的提示",
 | 
			
		||||
    "subtitle": "一条来自 Solar Network 团队的运营信息",
 | 
			
		||||
    "content": "我们已经将所有用户数据迁移到新版服务器,刚刚发布新的 DNS,因为部分 DNS 缓存的影响。可能更改不会生效,可以使用 nslookup / ping 检查解析地址是否未 8. 开头,您可以主动刷新 DNS。谢谢!",
 | 
			
		||||
    "metadata": {},
 | 
			
		||||
    "priority": 10
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,7 @@ meta {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
post {
 | 
			
		||||
  url: {{endpoint}}/cgi/id/dev/notify/122
 | 
			
		||||
  url: {{endpoint}}/cgi/id/dev/notify/328
 | 
			
		||||
  body: json
 | 
			
		||||
  auth: inherit
 | 
			
		||||
}
 | 
			
		||||
@@ -15,9 +15,9 @@ body:json {
 | 
			
		||||
    "client_id": "{{third_client_id}}",
 | 
			
		||||
    "client_secret":"{{third_client_tk}}",
 | 
			
		||||
    "type": "general",
 | 
			
		||||
    "subject": "处理该帐号 @solian 的决定",
 | 
			
		||||
    "subtitle": "违反用户协议",
 | 
			
		||||
    "content": "您的帐号违反了我们用户协议中关于冒充我们官方的行为,至此做出停权的决定。还请见谅。该决定是最终决定,不接受上诉。",
 | 
			
		||||
    "subject": "处理该发布者 @vedal987 的决定",
 | 
			
		||||
    "subtitle": "一条来自 Solar Network 客户支持的信息",
 | 
			
		||||
    "content": "您的发布者违反了我们用户协议中的「禁止冒充他人」的相关条例,经管理决定,将相关内容隐藏。冒充他人的判定无论作者是否有主观意志,只要造成了误解我们就有责任处理。希望您能理解,本次决定未作出任何帐号相关的连带处罚。",
 | 
			
		||||
    "priority": 10
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -7,5 +7,5 @@ meta {
 | 
			
		||||
get {
 | 
			
		||||
  url: {{endpoint}}/cgi/re/well-known/sources
 | 
			
		||||
  body: none
 | 
			
		||||
  auth: none
 | 
			
		||||
  auth: inherit
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -12,7 +12,7 @@ post {
 | 
			
		||||
 | 
			
		||||
body:json {
 | 
			
		||||
  {
 | 
			
		||||
    "sources": ["taiwan-ltn"],
 | 
			
		||||
    "sources": ["taiwan-pts"],
 | 
			
		||||
    "eager": true
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								assets/audio/notify/metal-pipe.mp3
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/audio/notify/metal-pipe.mp3
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								assets/audio/sfx/launch-done.mp3
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/audio/sfx/launch-done.mp3
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								assets/audio/sfx/launch-intro.mp3
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/audio/sfx/launch-intro.mp3
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Bold.ttf
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Bold.ttf
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Italic.ttf
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Italic.ttf
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Regular.ttf
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Regular.ttf
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								assets/icon/kanban-1st.jpg
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/icon/kanban-1st.jpg
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 509 KiB  | 
@@ -130,7 +130,7 @@
 | 
			
		||||
  "accountPublishersSubtitle": "Manage your publish identities.",
 | 
			
		||||
  "accountSettings": "Account Settings",
 | 
			
		||||
  "accountSettingsSubtitle": "Manage your account and make it yours.",
 | 
			
		||||
  "accountProfileEdit": "Edit your profile",
 | 
			
		||||
  "accountProfileEdit": "Edit Profile",
 | 
			
		||||
  "accountProfileEditSubtitle": "Make your Solarpass account more looks like you.",
 | 
			
		||||
  "accountWallet": "Wallet",
 | 
			
		||||
  "accountWalletSubtitle": "View your balance and transactions.",
 | 
			
		||||
@@ -153,6 +153,11 @@
 | 
			
		||||
  "publisherRunBy": "Run by {}",
 | 
			
		||||
  "fieldPublisherBelongToRealm": "Belongs to",
 | 
			
		||||
  "fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm",
 | 
			
		||||
  "writePost": "Compose",
 | 
			
		||||
  "postTypeStory": "Story",
 | 
			
		||||
  "postTypeArticle": "Article",
 | 
			
		||||
  "postTypeQuestion": "Question",
 | 
			
		||||
  "postTypeVideo": "Video",
 | 
			
		||||
  "writePostTypeStory": "Post a story",
 | 
			
		||||
  "writePostTypeArticle": "Write an article",
 | 
			
		||||
  "writePostTypeQuestion": "Ask a question",
 | 
			
		||||
@@ -202,7 +207,13 @@
 | 
			
		||||
    "one": "{} comment",
 | 
			
		||||
    "other": "{} comments"
 | 
			
		||||
  },
 | 
			
		||||
  "postCommentExpand": "Show comments",
 | 
			
		||||
  "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",
 | 
			
		||||
  "settingsDisplayLanguageDescription": "Set the application language.",
 | 
			
		||||
  "settingsDisplayLanguageSystem": "Follow System",
 | 
			
		||||
@@ -327,6 +338,7 @@
 | 
			
		||||
  "fieldAttachmentRandomId": "Random ID",
 | 
			
		||||
  "fieldAttachmentAlt": "Alternative text",
 | 
			
		||||
  "addAttachmentFromAlbum": "Add from album",
 | 
			
		||||
  "addAttachmentFromFiles": "Add from files",
 | 
			
		||||
  "addAttachmentFromClipboard": "Paste file",
 | 
			
		||||
  "addAttachmentFromCameraPhoto": "Take photo",
 | 
			
		||||
  "addAttachmentFromCameraVideo": "Take video",
 | 
			
		||||
@@ -512,8 +524,13 @@
 | 
			
		||||
  "accountBirthday": "Born on {}",
 | 
			
		||||
  "accountBadge": "Badge",
 | 
			
		||||
  "accountCheckInNoRecords": "No check-in records",
 | 
			
		||||
  "badgeCompanyStaff": "Solsynth Staff",
 | 
			
		||||
  "badgeCompanyStaff": "Staff",
 | 
			
		||||
  "badgeSiteMigration": "Solar Network Native",
 | 
			
		||||
  "badgeCommunitySurvey": "Survey Participant",
 | 
			
		||||
  "badgeCommunityVerified": "Verified User",
 | 
			
		||||
  "badgeCommunityContributor": "Great Contributor",
 | 
			
		||||
  "badgeSiteAnniversary": "Anniversary",
 | 
			
		||||
  "badgeUserBirthday": "Birthday",
 | 
			
		||||
  "accountStatus": "Status",
 | 
			
		||||
  "accountStatusOnline": "Online",
 | 
			
		||||
  "accountStatusOffline": "Offline",
 | 
			
		||||
@@ -548,6 +565,7 @@
 | 
			
		||||
  "termAcceptNextWithAgree": "By clicking the \"Next\", it means you agree to our terms and its updates.",
 | 
			
		||||
  "unauthorized": "Unauthorized",
 | 
			
		||||
  "unauthorizedDescription": "Login to explore the entire Solar Network.",
 | 
			
		||||
  "projectDetail": "Project Details",
 | 
			
		||||
  "serviceStatus": "Service Status",
 | 
			
		||||
  "termRelated": "Related Terms",
 | 
			
		||||
  "appDetails": "App Details",
 | 
			
		||||
@@ -583,6 +601,7 @@
 | 
			
		||||
  "colorSchemeBlack": "Black",
 | 
			
		||||
  "colorSchemeApplied": "Color scheme has been applied, may need restart the app to take effect.",
 | 
			
		||||
  "postFeaturedComment": "Featured Comment",
 | 
			
		||||
  "postCategory": "Category",
 | 
			
		||||
  "postCategoryTechnology": "Technology",
 | 
			
		||||
  "postCategoryGaming": "Gaming",
 | 
			
		||||
  "postCategoryLife": "Life",
 | 
			
		||||
@@ -620,11 +639,13 @@
 | 
			
		||||
  "postQuestionUnansweredWithReward": "Unanswered Question, reward source points {}",
 | 
			
		||||
  "postQuestionAnswered": "Answered Question",
 | 
			
		||||
  "postQuestionAnswerSelect": "Select as Answer",
 | 
			
		||||
  "postQuestionAnswerTitle": "Selected Question",
 | 
			
		||||
  "postQuestionAnswerSelected": "Answer has been selected, reward has been applied.",
 | 
			
		||||
  "postVideoUpload": "Upload Video",
 | 
			
		||||
  "realmJoin": "Join Realm",
 | 
			
		||||
  "realmCommunityHint": "This realm is a community realm, you can freely join.",
 | 
			
		||||
  "realmCommunityPublicChannelsHint": "The public channels in this realm",
 | 
			
		||||
  "realmCommunityPublishersHint": "The publishers in this realm",
 | 
			
		||||
  "realmJoined": "Joined realm {}.",
 | 
			
		||||
  "join": "Join",
 | 
			
		||||
  "pollEditorNew": "New Poll",
 | 
			
		||||
@@ -665,5 +686,260 @@
 | 
			
		||||
    "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",
 | 
			
		||||
  "postViewInGlobalDescription": "Do not view the post in the specific realm.",
 | 
			
		||||
  "postDraftSaved": "The draft has been saved.",
 | 
			
		||||
  "postDraftBox": "Draft Box",
 | 
			
		||||
  "postShuffle": "Read Randomly",
 | 
			
		||||
  "checkInStreak": {
 | 
			
		||||
    "zero": "No streak",
 | 
			
		||||
    "one": "{} day streak",
 | 
			
		||||
    "other": "{} days streak"
 | 
			
		||||
  },
 | 
			
		||||
  "accountChangeStatus": "Change Status",
 | 
			
		||||
  "accountStatusSilent": "Do not Disturb",
 | 
			
		||||
  "accountStatusSilentDesc": "The notification will stop popping up",
 | 
			
		||||
  "accountStatusInvisible": "Invisible",
 | 
			
		||||
  "accountStatusInvisibleDesc": "Will show as offline, but all features still remain normal",
 | 
			
		||||
  "accountCustomStatus": "Custom Status",
 | 
			
		||||
  "accountCustomStatusDescription": "Customize your status.",
 | 
			
		||||
  "accountClearStatus": "Clear Status",
 | 
			
		||||
  "accountClearStatusDescription": "Clear your status, and let server decide which status you are for you.",
 | 
			
		||||
  "fieldAccountStatusLabel": "Status Text",
 | 
			
		||||
  "fieldAccountStatusClearAt": "Clear At",
 | 
			
		||||
  "accountStatusNegative": "Negative",
 | 
			
		||||
  "accountStatusNeutral": "Neutral",
 | 
			
		||||
  "accountStatusPositive": "Positive",
 | 
			
		||||
  "mixedFeed": "Mixed Feed",
 | 
			
		||||
  "mixedFeedDescription": "The Explore screen may not only display the user's posts, but may also contain other content. However, this mode does not apply to classification and filtering.",
 | 
			
		||||
  "filterFeed": "Exploring Adjust",
 | 
			
		||||
  "feedUnknownItem": "Unable to display this content, the current version of the client does not support the type of content, please try to update the application afterwards.",
 | 
			
		||||
  "serviceStatusOperational": "All services operational",
 | 
			
		||||
  "serviceStatusDowngraded": "Some services downgraded",
 | 
			
		||||
  "serviceStatusFailed": "All services unavailable",
 | 
			
		||||
  "serviceStatusFailedDescription": "The server is down or the maintenance is just finished.",
 | 
			
		||||
  "serviceNameInsights": "Summarize and Insights",
 | 
			
		||||
  "serviceNameInteractive": "Posts, Reactions and Explore",
 | 
			
		||||
  "serviceNameReader": "News and Link Previews",
 | 
			
		||||
  "serviceNameMessaging": "Chat",
 | 
			
		||||
  "serviceNameMatrix": "Matrix Software and Game Marketplace",
 | 
			
		||||
  "serviceNamePaperclip": "Attachments, Images and Files",
 | 
			
		||||
  "serviceNameWallet": "Source Points Wallet",
 | 
			
		||||
  "serviceNamePassport": "Authorization and Authentication",
 | 
			
		||||
  "accountActionEvent": "Action Events",
 | 
			
		||||
  "accountActionEventDescription": "View your action event logs.",
 | 
			
		||||
  "eventMetadata": "Metadata",
 | 
			
		||||
  "accountAuthTickets": "Auth Sessions",
 | 
			
		||||
  "accountAuthTicketsDescription": "View and manage your auth sessions.",
 | 
			
		||||
  "authTicketCreatedAt": "Issued at {}",
 | 
			
		||||
  "authTicketExpiredAt": "Expired at {}",
 | 
			
		||||
  "authTicketLastGrantAt": "Last granted at {}",
 | 
			
		||||
  "authTicketCurrent": "Current",
 | 
			
		||||
  "accountUnconfirmedTitle": "Unconfirmed Account",
 | 
			
		||||
  "accountUnconfirmedSubtitle": "Your account is unconfirmed, which will make most features unavailable and your account will be destroyed in 24 hours. You should receive an email in your inbox with a confirmation link.",
 | 
			
		||||
  "accountUnconfirmedUnreceived": "Didn't receive the email?",
 | 
			
		||||
  "accountUnconfirmedResend": "Resend one",
 | 
			
		||||
  "accountUnconfirmedResendSuccessful": "Email has been resent, you can resend it again in 60 minutes.",
 | 
			
		||||
  "stickerPickerEmpty": "Sticker list is empty",
 | 
			
		||||
  "stickerPickerEmptyHint": "To start using stickers, you need to add a sticker pack first.",
 | 
			
		||||
  "goto": "Go to {}",
 | 
			
		||||
  "accountContactMethods": "Contact Methods",
 | 
			
		||||
  "accountContactMethodsDescription": "Manage your contact methods.",
 | 
			
		||||
  "accountContactMethodsNameEmail": "Email address",
 | 
			
		||||
  "accountContactMethodsNamePhone": "Phone number",
 | 
			
		||||
  "accountContactMethodsNameAddress": "Address",
 | 
			
		||||
  "accountContactMethodsPrimary": "Primary",
 | 
			
		||||
  "accountContactMethodsVerified": "Verified",
 | 
			
		||||
  "accountContactMethodsPublic": "Public",
 | 
			
		||||
  "accountContactMethodsAdd": "Add Contact Method",
 | 
			
		||||
  "accountContactMethodsEdit": "Edit Contact Method",
 | 
			
		||||
  "accountContactMethodsAddDescription": "Add a new contact method.",
 | 
			
		||||
  "fieldContactContent": "Contact method",
 | 
			
		||||
  "accountContactMethodsPublicHint": "This contact method will be displayed publicly on your profile.",
 | 
			
		||||
  "accountContactMethodsDelete": "Delete Contact Method",
 | 
			
		||||
  "accountContactMethodsDeleteDescription": "Are you sure you want to delete contact method {}? This operation is irreversible.",
 | 
			
		||||
  "postCommentAdd": "Write a comment",
 | 
			
		||||
  "translate": "Translate",
 | 
			
		||||
  "translating": "Translating…",
 | 
			
		||||
  "translated": "Translated",
 | 
			
		||||
  "settingsAutoTranslate": "Auto Translate",
 | 
			
		||||
  "settingsAutoTranslateDescription": "Automatically translate text when viewing posts and messages.",
 | 
			
		||||
  "trayMenuHide": "Hide",
 | 
			
		||||
  "accountSettingsNotify": "Notify Settings",
 | 
			
		||||
  "accountSettingsNotifyDescription": "Adjust the types of notifications you receive.",
 | 
			
		||||
  "accountSettingsSecurity": "Security Settings",
 | 
			
		||||
  "accountSettingsSecurityDescription": "Adjust your account security settings.",
 | 
			
		||||
  "save": "Save",
 | 
			
		||||
  "notificationTopicPostFeedback": "Post Feedback",
 | 
			
		||||
  "notificationTopicPostReply": "Post Replies",
 | 
			
		||||
  "notificationTopicPostSubscription": "Post Subscriptions",
 | 
			
		||||
  "notificationTopicMessaging": "New Messages",
 | 
			
		||||
  "notificationTopicMessagingCall": "Incoming Calls",
 | 
			
		||||
  "notificationTopicGeneral": "General",
 | 
			
		||||
  "authMaximumAuthSteps": "Maximum Authenticate Steps",
 | 
			
		||||
  "authMaximumAuthStepsDescription": {
 | 
			
		||||
    "one": "Maximum ask for {} step authenticate",
 | 
			
		||||
    "other": "Maximum ask for {} steps authenticate"
 | 
			
		||||
  },
 | 
			
		||||
  "authAlwaysRisky": "Always Risky",
 | 
			
		||||
  "authAlwaysRiskyDescription": "Always ask for the highest steps count of authentication when logging in.",
 | 
			
		||||
  "chatUnjoined": "Unjoined Channel",
 | 
			
		||||
  "chatUnjoinedDescription": "You haven't joined this channel, so you can't send messages either view messages in it.",
 | 
			
		||||
  "chatUnjoinedPublicDescription": "Fortunately, this is a public channel, so you can join it as you want.",
 | 
			
		||||
  "chatJoin": "Join the Channel",
 | 
			
		||||
  "appInitStarting": "Starting",
 | 
			
		||||
  "appInitNetwork": "Initializing Network",
 | 
			
		||||
  "appInitUserdata": "Initializing User Data",
 | 
			
		||||
  "appInitWebsocket": "Establishing Solar Link",
 | 
			
		||||
  "appInitNotification": "Initializing Push Notifications", 
 | 
			
		||||
  "appInitKeyPair": "Initializing Key Pairs",
 | 
			
		||||
  "appInitStickers": "Initializing Stickers",
 | 
			
		||||
  "appInitUserDirectory": "Initializing User Directory",
 | 
			
		||||
  "appInitRealm": "Initializing Realms",
 | 
			
		||||
  "appInitChat": "Initializing Chat",
 | 
			
		||||
  "appInitDone": "Completed",
 | 
			
		||||
  "community": "Community",
 | 
			
		||||
  "realmCommunity": "{}'s Community",
 | 
			
		||||
  "postTotalCount": {
 | 
			
		||||
    "one": "Total {} post",
 | 
			
		||||
    "other": "Total {} posts"
 | 
			
		||||
  },
 | 
			
		||||
  "settingsHideBottomNav": "Hide Bottom Navigation",
 | 
			
		||||
  "settingsHideBottomNavDescription": "Hide the bottom navigation bar, and show the navigation buttons in the drawer.",
 | 
			
		||||
  "reCaptcha": "reCaptcha",
 | 
			
		||||
  "friends": "Friends",
 | 
			
		||||
  "friendsDescription": "Manage your friendships.",
 | 
			
		||||
  "album": "Album",
 | 
			
		||||
  "albumDescription": "View albums and manage attachments.",
 | 
			
		||||
  "stickers": "Stickers",
 | 
			
		||||
  "stickersDescription": "View sticker packs and manage stickers.",
 | 
			
		||||
  "navBottomUnauthorizedCaption": "Or create an account",
 | 
			
		||||
  "walletCurrencyGoldenShort": "GDP",
 | 
			
		||||
  "walletCurrencyGolden": {
 | 
			
		||||
    "one": "{} Golden Point",
 | 
			
		||||
    "other": "{} Golden Points"
 | 
			
		||||
  },
 | 
			
		||||
  "walletTransactionTypeNormal": "Source Point",
 | 
			
		||||
  "walletTransactionTypeGolden": "Golden Point",
 | 
			
		||||
  "accountProgram": "Programs",
 | 
			
		||||
  "accountProgramDescription": "Explore the available member programs.",
 | 
			
		||||
  "accountProgramJoin": "Join Program",
 | 
			
		||||
  "accountProgramJoinRequirements": "Requirements",
 | 
			
		||||
  "accountProgramJoinPricing": "Pricing",
 | 
			
		||||
  "accountProgramJoinPricingHint": "Billed every (30 days) month.",
 | 
			
		||||
  "accountProgramLeaveHint": "After leaving the program, the source points will not be refunded.",
 | 
			
		||||
  "accountProgramJoined": "Joined Program.",
 | 
			
		||||
  "accountProgramAlreadyJoined": "Joined",
 | 
			
		||||
  "accountProgramLeft": "Left Program.",
 | 
			
		||||
  "leave": "Leave",
 | 
			
		||||
  "attachmentFailedToLoadMedia": "Unable to load media file, please try again later. If this error occurs repeatedly, the source file may not exist or the network connection may be abnormal.",
 | 
			
		||||
  "accountPunishments": "Punishments",
 | 
			
		||||
  "accountPunishmentsDescription": "View your account's reputation status.",
 | 
			
		||||
  "punishmentType0": "Strike",
 | 
			
		||||
  "punishmentType1": "Limited",
 | 
			
		||||
  "punishmentType2": "Banned",
 | 
			
		||||
  "punishmentOverall": "Overall Status",
 | 
			
		||||
  "punishmentStatusNormal": "All abilities normal",
 | 
			
		||||
  "punishmentStatusWarned": "All abilities normal, but at least one strike is in effect",
 | 
			
		||||
  "punishmentStatusLimited": "Some abilities limited, at least one limited punishment is in effect",
 | 
			
		||||
  "punishmentStatusLimitedFully": "All abilities limited, at least one completely limited punishment is in effect",
 | 
			
		||||
  "punishmentStatusBanned": "All services are terminated, banned",
 | 
			
		||||
  "punishmentCreatedAt": "Applied since {}",
 | 
			
		||||
  "punishmentExpiredAt": "Expired at {}",
 | 
			
		||||
  "punishmentExpiredNever": "Never expired",
 | 
			
		||||
  "punishmentModerator": "Moderator who made this punishment",
 | 
			
		||||
  "punishmentMadeBySystem": "Made by auto-mod system",
 | 
			
		||||
  "settingsAprilFoolFeatures": "April Fool Features",
 | 
			
		||||
  "settingsAprilFoolFeaturesDescription": "Enable April Fool features during April Fool, this option will only be visible during April Fool.",
 | 
			
		||||
  "settingsSoundEffects": "Sound Effects",
 | 
			
		||||
  "settingsSoundEffectsDescription": "Enable the sound effects around the app.",
 | 
			
		||||
  "settingsResetMemorizedWindowSize": "Reset Window Size",
 | 
			
		||||
  "settingsResetMemorizedWindowSizeDescription": "Reset the memorized window size, and set it to the default size.",
 | 
			
		||||
  "chatDirect": "Direct Messages",
 | 
			
		||||
  "back": "返回"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -137,6 +137,11 @@
 | 
			
		||||
  "publisherRunBy": "由 {} 管理",
 | 
			
		||||
  "fieldPublisherBelongToRealm": "所属领域",
 | 
			
		||||
  "fieldPublisherBelongToRealmUnset": "未设置发布者所属领域",
 | 
			
		||||
  "writePost": "撰写",
 | 
			
		||||
  "postTypeStory": "动态",
 | 
			
		||||
  "postTypeArticle": "文章",
 | 
			
		||||
  "postTypeQuestion": "问题",
 | 
			
		||||
  "postTypeVideo": "视频",
 | 
			
		||||
  "writePostTypeStory": "发动态",
 | 
			
		||||
  "writePostTypeArticle": "写文章",
 | 
			
		||||
  "writePostTypeQuestion": "提问题",
 | 
			
		||||
@@ -200,7 +205,13 @@
 | 
			
		||||
    "one": "{} 条评论",
 | 
			
		||||
    "other": "{} 条评论"
 | 
			
		||||
  },
 | 
			
		||||
  "postCommentExpand": "展开评论",
 | 
			
		||||
  "settingsAppearance": "外观",
 | 
			
		||||
  "settingsCustomFonts": "自定义字体",
 | 
			
		||||
  "settingsCustomFontsDescription": "设置应用程序使用的字体。",
 | 
			
		||||
  "settingsCustomFontFamily": "应用字体",
 | 
			
		||||
  "settingsCustomFontFamilyHint": "使用英文逗号分割每一种字体,越前优先级越高",
 | 
			
		||||
  "settingsCustomFontApplied": "自定义字体已经应用。",
 | 
			
		||||
  "settingsDisplayLanguage": "显示语言",
 | 
			
		||||
  "settingsDisplayLanguageDescription": "设置应用程序使用的语言",
 | 
			
		||||
  "settingsDisplayLanguageSystem": "跟随系统",
 | 
			
		||||
@@ -325,6 +336,7 @@
 | 
			
		||||
  "fieldAttachmentRandomId": "访问 ID",
 | 
			
		||||
  "fieldAttachmentAlt": "概述文字",
 | 
			
		||||
  "addAttachmentFromAlbum": "从相册中添加附件",
 | 
			
		||||
  "addAttachmentFromFiles": "从文件中添加附件",
 | 
			
		||||
  "addAttachmentFromClipboard": "粘贴附件",
 | 
			
		||||
  "addAttachmentFromCameraPhoto": "拍摄照片",
 | 
			
		||||
  "addAttachmentFromCameraVideo": "拍摄视频",
 | 
			
		||||
@@ -510,8 +522,13 @@
 | 
			
		||||
  "accountBirthday": "出生于 {}",
 | 
			
		||||
  "accountBadge": "徽章",
 | 
			
		||||
  "accountCheckInNoRecords": "暂无运势记录",
 | 
			
		||||
  "badgeCompanyStaff": "索尔辛茨士大夫 · 员工",
 | 
			
		||||
  "badgeCompanyStaff": "工作人员",
 | 
			
		||||
  "badgeSiteMigration": "Solar Network 原住民",
 | 
			
		||||
  "badgeCommunitySurvey": "调研参与者",
 | 
			
		||||
  "badgeCommunityVerified": "认证用户",
 | 
			
		||||
  "badgeCommunityContributor": "优秀社区贡献者",
 | 
			
		||||
  "badgeSiteAnniversary": "周年纪念",
 | 
			
		||||
  "badgeUserBirthday": "生日纪念",
 | 
			
		||||
  "accountStatus": "状态",
 | 
			
		||||
  "accountStatusOnline": "在线",
 | 
			
		||||
  "accountStatusOffline": "离线",
 | 
			
		||||
@@ -546,6 +563,7 @@
 | 
			
		||||
  "termAcceptNextWithAgree": "点击 “下一步”,即表示你同意我们的各项条款,包括其之后的更新。",
 | 
			
		||||
  "unauthorized": "未登陆",
 | 
			
		||||
  "unauthorizedDescription": "登陆以探索整个 Solar Network。",
 | 
			
		||||
  "projectDetail": "项目详情",
 | 
			
		||||
  "serviceStatus": "服务状态",
 | 
			
		||||
  "termRelated": "相关条款",
 | 
			
		||||
  "appDetails": "应用程序详情",
 | 
			
		||||
@@ -581,6 +599,7 @@
 | 
			
		||||
  "colorSchemeBlack": "黑色",
 | 
			
		||||
  "colorSchemeApplied": "主题色已应用,可能需要重启来生效。",
 | 
			
		||||
  "postFeaturedComment": "精选评论",
 | 
			
		||||
  "postCategory": "分类",
 | 
			
		||||
  "postCategoryTechnology": "技术",
 | 
			
		||||
  "postCategoryGaming": "游戏",
 | 
			
		||||
  "postCategoryLife": "生活",
 | 
			
		||||
@@ -624,6 +643,7 @@
 | 
			
		||||
  "realmJoin": "加入领域",
 | 
			
		||||
  "realmCommunityHint": "该领域是一个社区领域,你可以自由加入。",
 | 
			
		||||
  "realmCommunityPublicChannelsHint": "该领域包含的公共频道",
 | 
			
		||||
  "realmCommunityPublishersHint": "该领域的发布者",
 | 
			
		||||
  "realmJoined": "已加入领域 {}。",
 | 
			
		||||
  "join": "加入",
 | 
			
		||||
  "pollEditorNew": "新投票",
 | 
			
		||||
@@ -664,5 +684,259 @@
 | 
			
		||||
    "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": "无法预览加密消息",
 | 
			
		||||
  "postViewInGlobalDescription": "不查看特定领域的帖子。",
 | 
			
		||||
  "postDraftSaved": "已保存为草稿。",
 | 
			
		||||
  "postDraftBox": "草稿箱",
 | 
			
		||||
  "postShuffle": "随便看看",
 | 
			
		||||
  "checkInStreak": {
 | 
			
		||||
    "zero": "无连击",
 | 
			
		||||
    "one": "连续签到 {} 天",
 | 
			
		||||
    "other": "连续签到 {} 天"
 | 
			
		||||
  },
 | 
			
		||||
  "accountChangeStatus": "修改状态",
 | 
			
		||||
  "accountStatusSilent": "请勿打扰",
 | 
			
		||||
  "accountStatusSilentDesc": "将会暂停所有通知推送",
 | 
			
		||||
  "accountStatusInvisible": "隐身",
 | 
			
		||||
  "accountStatusInvisibleDesc": "将会在他人界面显示离线,但不影响功能使用",
 | 
			
		||||
  "accountCustomStatus": "自定义状态",
 | 
			
		||||
  "accountCustomStatusDescription": "客制化你的状态。",
 | 
			
		||||
  "accountClearStatus": "清除状态",
 | 
			
		||||
  "accountClearStatusDescription": "清除你的状态,并让服务器决定你的状态。",
 | 
			
		||||
  "fieldAccountStatusLabel": "状态文字",
 | 
			
		||||
  "fieldAccountStatusClearAt": "清除时间",
 | 
			
		||||
  "accountStatusNegative": "负面",
 | 
			
		||||
  "accountStatusNeutral": "中性",
 | 
			
		||||
  "accountStatusPositive": "正面",
 | 
			
		||||
  "mixedFeed": "混合推荐流",
 | 
			
		||||
  "mixedFeedDescription": "探索页面可能不只会展示用户的帖子,更可能包含其他的内容。但该模式不适用分类和过滤。",
 | 
			
		||||
  "filterFeed": "探索队列调整",
 | 
			
		||||
  "feedUnknownItem": "无法显示该内容,当前版本客户端不支持该类型的内容,请尝试更新应用程序后再试。",
 | 
			
		||||
  "serviceStatusOperational": "所有服务正常",
 | 
			
		||||
  "serviceStatusDowngraded": "部分服务异常",
 | 
			
		||||
  "serviceStatusFailed": "服务状态异常",
 | 
			
		||||
  "serviceStatusFailedDescription": "服务器炸了或者刚刚执行完维护程序。",
 | 
			
		||||
  "serviceNameInsights": "总结、见解与洞察",
 | 
			
		||||
  "serviceNameInteractive": "帖子与互动",
 | 
			
		||||
  "serviceNameReader": "新闻与链接展开",
 | 
			
		||||
  "serviceNameMessaging": "即使聊天",
 | 
			
		||||
  "serviceNameMatrix": "矩阵市场",
 | 
			
		||||
  "serviceNamePaperclip": "附件",
 | 
			
		||||
  "serviceNameWallet": "源点钱包",
 | 
			
		||||
  "serviceNamePassport": "身份验证与授权",
 | 
			
		||||
  "accountActionEvent": "操作日志",
 | 
			
		||||
  "accountActionEventDescription": "查看你的操作日志。",
 | 
			
		||||
  "eventMetadata": "元数据",
 | 
			
		||||
  "accountAuthTickets": "授权会话",
 | 
			
		||||
  "accountAuthTicketsDescription": "查看和管理你的授权会话。",
 | 
			
		||||
  "authTicketCreatedAt": "签发于 {}",
 | 
			
		||||
  "authTicketExpiredAt": "到期于 {}",
 | 
			
		||||
  "authTicketLastGrantAt": "上次刷新于 {}",
 | 
			
		||||
  "authTicketCurrent": "当前会话",
 | 
			
		||||
  "accountUnconfirmedTitle": "尚未未确认账户",
 | 
			
		||||
  "accountUnconfirmedSubtitle": "您的账户尚未确认,这会导致大部分功能不可用,并且您的帐户将在 24 小时后自毁。您绑定的邮箱的收件箱应该会有一封邮件指引您确认您的帐户。",
 | 
			
		||||
  "accountUnconfirmedUnreceived": "未收到邮件?",
 | 
			
		||||
  "accountUnconfirmedResend": "重新发送一封",
 | 
			
		||||
  "accountUnconfirmedResendSuccessful": "邮件已重新发送,你可以在 60 分钟后再重发一封。",
 | 
			
		||||
  "stickerPickerEmpty": "贴图列表为空",
 | 
			
		||||
  "stickerPickerEmptyHint": "想要开始使用贴图,请先添加贴图包。",
 | 
			
		||||
  "goto": "跳转到 {}",
 | 
			
		||||
  "accountContactMethods": "联系方式",
 | 
			
		||||
  "accountContactMethodsDescription": "管理你的联系方式。",
 | 
			
		||||
  "accountContactMethodsNameEmail": "电子邮箱",
 | 
			
		||||
  "accountContactMethodsNamePhone": "电话",
 | 
			
		||||
  "accountContactMethodsNameAddress": "地址",
 | 
			
		||||
  "accountContactMethodsPrimary": "主要的",
 | 
			
		||||
  "accountContactMethodsVerified": "已验证",
 | 
			
		||||
  "accountContactMethodsPublic": "公开的",
 | 
			
		||||
  "accountContactMethodsAdd": "添加联系方式",
 | 
			
		||||
  "accountContactMethodsEdit": "编辑联系方式",
 | 
			
		||||
  "accountContactMethodsAddDescription": "添加新的联系方式。",
 | 
			
		||||
  "fieldContactContent": "联系方式",
 | 
			
		||||
  "accountContactMethodsPublicHint": "这个联系方式公开地显示在个人资料中。",
 | 
			
		||||
  "accountContactMethodsDelete": "删除联系方式",
 | 
			
		||||
  "accountContactMethodsDeleteDescription": "你确定要删除联系方式 {} 吗?这个操作不可撤销。",
 | 
			
		||||
  "postCommentAdd": "撰写一条评论",
 | 
			
		||||
  "translate": "翻译",
 | 
			
		||||
  "translating": "正在翻译……",
 | 
			
		||||
  "translated": "已翻译",
 | 
			
		||||
  "settingsAutoTranslate": "自动翻译",
 | 
			
		||||
  "settingsAutoTranslateDescription": "在查看帖子、消息时自动翻译文本。",
 | 
			
		||||
  "trayMenuHide": "隐藏",
 | 
			
		||||
  "accountSettingsNotify": "通知设置",
 | 
			
		||||
  "accountSettingsNotifyDescription": "调整你所收到的通知种类。",
 | 
			
		||||
  "accountSettingsSecurity": "安全设置",
 | 
			
		||||
  "accountSettingsSecurityDescription": "调整你的帐户安全设置。",
 | 
			
		||||
  "save": "保存",
 | 
			
		||||
  "notificationTopicPostFeedback": "帖子数据反馈",
 | 
			
		||||
  "notificationTopicPostReply": "帖子回复",
 | 
			
		||||
  "notificationTopicPostSubscription": "帖子订阅",
 | 
			
		||||
  "notificationTopicMessaging": "消息",
 | 
			
		||||
  "notificationTopicMessagingCall": "通话",
 | 
			
		||||
  "notificationTopicGeneral": "杂项",
 | 
			
		||||
  "authMaximumAuthSteps": "最大验证步骤",
 | 
			
		||||
  "authMaximumAuthStepsDescription": {
 | 
			
		||||
    "one": "登入时最多要求 {} 步验证",
 | 
			
		||||
    "other": "登入时最多要求 {} 步验证"
 | 
			
		||||
  },
 | 
			
		||||
  "authAlwaysRisky": "总是风险",
 | 
			
		||||
  "authAlwaysRiskyDescription": "在登入时始终按最高标准要求验证。",
 | 
			
		||||
  "chatUnjoined": "未加入频道",
 | 
			
		||||
  "chatUnjoinedDescription": "你没有加入这个频道,所以你也无法发送消息或者查看这个频道中的消息。",
 | 
			
		||||
  "chatUnjoinedPublicDescription": "但幸运的是,这是一个公开频道,所以你可以主动加入。",
 | 
			
		||||
  "chatJoin": "加入频道",
 | 
			
		||||
  "appInitStarting": "启动中",
 | 
			
		||||
  "appInitNetwork": "正在初始化网络",
 | 
			
		||||
  "appInitUserdata": "正在初始化用户数据",
 | 
			
		||||
  "appInitWebsocket": "正在建立 Solar Link",
 | 
			
		||||
  "appInitNotification": "正在初始化推送通知", 
 | 
			
		||||
  "appInitKeyPair": "正在初始化密钥对",
 | 
			
		||||
  "appInitStickers": "正在初始化贴图包",
 | 
			
		||||
  "appInitUserDirectory": "正在初始化用户目录",
 | 
			
		||||
  "appInitRealm": "正在初始化领域信息",
 | 
			
		||||
  "appInitChat": "正在初始化聊天",
 | 
			
		||||
  "appInitDone": "完成",
 | 
			
		||||
  "community": "社区",
 | 
			
		||||
  "realmCommunity": "{}的社区",
 | 
			
		||||
  "postTotalCount": {
 | 
			
		||||
    "zero": "没有帖子",
 | 
			
		||||
    "one": "共 {} 条帖子"
 | 
			
		||||
  },
 | 
			
		||||
  "settingsHideBottomNav": "隐藏底部导航栏",
 | 
			
		||||
  "settingsHideBottomNavDescription": "隐藏底部导航栏,在侧边栏抽屉显示导航按钮。",
 | 
			
		||||
  "reCaptcha": "人机验证",
 | 
			
		||||
  "friends": "好友",
 | 
			
		||||
  "friendsDescription": "管理好友关系。",
 | 
			
		||||
  "album": "相册",
 | 
			
		||||
  "albumDescription": "查看相册与管理上传附件。",
 | 
			
		||||
  "stickers": "贴图",
 | 
			
		||||
  "stickersDescription": "查看贴图包与管理贴图。",
 | 
			
		||||
  "navBottomUnauthorizedCaption": "或者注册一个账号",
 | 
			
		||||
  "walletCurrencyGoldenShort": "金点",
 | 
			
		||||
  "walletCurrencyGolden": {
 | 
			
		||||
    "one": "{} 金点",
 | 
			
		||||
    "other": "{} 金点"
 | 
			
		||||
  },
 | 
			
		||||
  "walletTransactionTypeNormal": "源点",
 | 
			
		||||
  "walletTransactionTypeGolden": "金点",
 | 
			
		||||
  "accountProgram": "计划",
 | 
			
		||||
  "accountProgramDescription": "了解可用的成员计划。",
 | 
			
		||||
  "accountProgramJoin": "加入计划",
 | 
			
		||||
  "accountProgramJoinRequirements": "要求",
 | 
			
		||||
  "accountProgramJoinPricing": "价格",
 | 
			
		||||
  "accountProgramJoinPricingHint": "按月(30 天)收费",
 | 
			
		||||
  "accountProgramLeaveHint": "离开计划后,之前花费的源点不会退款。",
 | 
			
		||||
  "accountProgramJoined": "已加入计划。",
 | 
			
		||||
  "accountProgramLeft": "已离开计划。",
 | 
			
		||||
  "accountProgramAlreadyJoined": "已加入",
 | 
			
		||||
  "leave": "离开",
 | 
			
		||||
  "attachmentFailedToLoadMedia": "无法加载媒体文件,请稍后重试。若此错误重复出现,可能源文件不存在或者网络连接异常。",
 | 
			
		||||
  "accountPunishments": "处分",
 | 
			
		||||
  "accountPunishmentsDescription": "查看你帐号的信誉状态。",
 | 
			
		||||
  "punishmentType0": "警告",
 | 
			
		||||
  "punishmentType1": "停权",
 | 
			
		||||
  "punishmentType2": "封禁",
 | 
			
		||||
  "punishmentOverall": "总体状态",
 | 
			
		||||
  "punishmentStatusNormal": "所有功能正常",
 | 
			
		||||
  "punishmentStatusWarned": "所有功能正常,但有警告生效",
 | 
			
		||||
  "punishmentStatusLimited": "部份功能暂时受限,有至少一个停权生效",
 | 
			
		||||
  "punishmentStatusLimitedFully": "所有功能暂时受限,有至少一个完全停权生效",
 | 
			
		||||
  "punishmentStatusBanned": "所有服务终止,已被封禁",
 | 
			
		||||
  "punishmentCreatedAt": "宣布于 {}",
 | 
			
		||||
  "punishmentExpiredAt": "到期于 {}",
 | 
			
		||||
  "punishmentExpiredNever": "永久生效",
 | 
			
		||||
  "punishmentModerator": "责任管理员",
 | 
			
		||||
  "punishmentMadeBySystem": "由系统自动裁决",
 | 
			
		||||
  "settingsAprilFoolFeatures": "愚人节特性",
 | 
			
		||||
  "settingsAprilFoolFeaturesDescription": "在愚人节期间启用愚人节特性,该选项只会在愚人节期间显示。",
 | 
			
		||||
  "settingsSoundEffects": "声音效果",
 | 
			
		||||
  "settingsSoundEffectsDescription": "在一些场合下启用声音特效。",
 | 
			
		||||
  "settingsResetMemorizedWindowSize": "重置窗口大小",
 | 
			
		||||
  "settingsResetMemorizedWindowSizeDescription": "重置记忆的窗口大小,以重新设置为默认大小。",
 | 
			
		||||
  "chatDirect": "私信",
 | 
			
		||||
  "back": "返回"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -137,6 +137,11 @@
 | 
			
		||||
  "publisherRunBy": "由 {} 管理",
 | 
			
		||||
  "fieldPublisherBelongToRealm": "所屬領域",
 | 
			
		||||
  "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
 | 
			
		||||
  "writePost": "撰寫",
 | 
			
		||||
  "postTypeStory": "動態",
 | 
			
		||||
  "postTypeArticle": "文章",
 | 
			
		||||
  "postTypeQuestion": "問題",
 | 
			
		||||
  "postTypeVideo": "視頻",
 | 
			
		||||
  "writePostTypeStory": "發動態",
 | 
			
		||||
  "writePostTypeArticle": "寫文章",
 | 
			
		||||
  "writePostTypeQuestion": "提問題",
 | 
			
		||||
@@ -200,7 +205,13 @@
 | 
			
		||||
    "one": "{} 條評論",
 | 
			
		||||
    "other": "{} 條評論"
 | 
			
		||||
  },
 | 
			
		||||
  "postCommentExpand": "展開評論",
 | 
			
		||||
  "settingsAppearance": "外觀",
 | 
			
		||||
  "settingsCustomFonts": "自定義字體",
 | 
			
		||||
  "settingsCustomFontsDescription": "設置應用程序使用的字體。",
 | 
			
		||||
  "settingsCustomFontFamily": "應用字體",
 | 
			
		||||
  "settingsCustomFontFamilyHint": "使用英文逗號分割每一種字體,越前優先級越高",
 | 
			
		||||
  "settingsCustomFontApplied": "自定義字體已經應用。",
 | 
			
		||||
  "settingsDisplayLanguage": "顯示語言",
 | 
			
		||||
  "settingsDisplayLanguageDescription": "設置應用程序使用的語言",
 | 
			
		||||
  "settingsDisplayLanguageSystem": "跟隨系統",
 | 
			
		||||
@@ -325,6 +336,7 @@
 | 
			
		||||
  "fieldAttachmentRandomId": "訪問 ID",
 | 
			
		||||
  "fieldAttachmentAlt": "概述文字",
 | 
			
		||||
  "addAttachmentFromAlbum": "從相冊中添加附件",
 | 
			
		||||
  "addAttachmentFromFiles": "從文件中添加附件",
 | 
			
		||||
  "addAttachmentFromClipboard": "粘貼附件",
 | 
			
		||||
  "addAttachmentFromCameraPhoto": "拍攝照片",
 | 
			
		||||
  "addAttachmentFromCameraVideo": "拍攝視頻",
 | 
			
		||||
@@ -510,8 +522,13 @@
 | 
			
		||||
  "accountBirthday": "出生於 {}",
 | 
			
		||||
  "accountBadge": "徽章",
 | 
			
		||||
  "accountCheckInNoRecords": "暫無運勢記錄",
 | 
			
		||||
  "badgeCompanyStaff": "索爾辛茨士大夫 · 員工",
 | 
			
		||||
  "badgeCompanyStaff": "工作人員",
 | 
			
		||||
  "badgeSiteMigration": "Solar Network 原住民",
 | 
			
		||||
  "badgeCommunitySurvey": "調研參與者",
 | 
			
		||||
  "badgeCommunityVerified": "認證用户",
 | 
			
		||||
  "badgeCommunityContributor": "優秀社區貢獻者",
 | 
			
		||||
  "badgeSiteAnniversary": "週年紀念",
 | 
			
		||||
  "badgeUserBirthday": "生日紀念",
 | 
			
		||||
  "accountStatus": "狀態",
 | 
			
		||||
  "accountStatusOnline": "在線",
 | 
			
		||||
  "accountStatusOffline": "離線",
 | 
			
		||||
@@ -546,6 +563,7 @@
 | 
			
		||||
  "termAcceptNextWithAgree": "點擊 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。",
 | 
			
		||||
  "unauthorized": "未登陸",
 | 
			
		||||
  "unauthorizedDescription": "登陸以探索整個 Solar Network。",
 | 
			
		||||
  "projectDetail": "項目詳情",
 | 
			
		||||
  "serviceStatus": "服務狀態",
 | 
			
		||||
  "termRelated": "相關條款",
 | 
			
		||||
  "appDetails": "應用程序詳情",
 | 
			
		||||
@@ -581,6 +599,7 @@
 | 
			
		||||
  "colorSchemeBlack": "黑色",
 | 
			
		||||
  "colorSchemeApplied": "主題色已應用,可能需要重啓來生效。",
 | 
			
		||||
  "postFeaturedComment": "精選評論",
 | 
			
		||||
  "postCategory": "分類",
 | 
			
		||||
  "postCategoryTechnology": "技術",
 | 
			
		||||
  "postCategoryGaming": "遊戲",
 | 
			
		||||
  "postCategoryLife": "生活",
 | 
			
		||||
@@ -624,6 +643,7 @@
 | 
			
		||||
  "realmJoin": "加入領域",
 | 
			
		||||
  "realmCommunityHint": "該領域是一個社區領域,你可以自由加入。",
 | 
			
		||||
  "realmCommunityPublicChannelsHint": "該領域包含的公共頻道",
 | 
			
		||||
  "realmCommunityPublishersHint": "該領域的發佈者",
 | 
			
		||||
  "realmJoined": "已加入領域 {}。",
 | 
			
		||||
  "join": "加入",
 | 
			
		||||
  "pollEditorNew": "新投票",
 | 
			
		||||
@@ -664,5 +684,216 @@
 | 
			
		||||
    "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": "無法預覽加密消息",
 | 
			
		||||
  "postViewInGlobalDescription": "不查看特定領域的帖子。",
 | 
			
		||||
  "postDraftSaved": "已保存為草稿。",
 | 
			
		||||
  "postDraftBox": "草稿箱",
 | 
			
		||||
  "postShuffle": "隨便看看",
 | 
			
		||||
  "checkInStreak": {
 | 
			
		||||
    "zero": "無連擊",
 | 
			
		||||
    "one": "連續簽到 {} 天",
 | 
			
		||||
    "other": "連續簽到 {} 天"
 | 
			
		||||
  },
 | 
			
		||||
  "accountChangeStatus": "修改狀態",
 | 
			
		||||
  "accountStatusSilent": "請勿打擾",
 | 
			
		||||
  "accountStatusSilentDesc": "將會暫停所有通知推送",
 | 
			
		||||
  "accountStatusInvisible": "隱身",
 | 
			
		||||
  "accountStatusInvisibleDesc": "將會在他人界面顯示離線,但不影響功能使用",
 | 
			
		||||
  "accountCustomStatus": "自定義狀態",
 | 
			
		||||
  "accountCustomStatusDescription": "客製化你的狀態。",
 | 
			
		||||
  "accountClearStatus": "清除狀態",
 | 
			
		||||
  "accountClearStatusDescription": "清除你的狀態,並讓服務器決定你的狀態。",
 | 
			
		||||
  "fieldAccountStatusLabel": "狀態文字",
 | 
			
		||||
  "fieldAccountStatusClearAt": "清除時間",
 | 
			
		||||
  "accountStatusNegative": "負面",
 | 
			
		||||
  "accountStatusNeutral": "中性",
 | 
			
		||||
  "accountStatusPositive": "正面",
 | 
			
		||||
  "mixedFeed": "混合推薦流",
 | 
			
		||||
  "mixedFeedDescription": "探索頁面可能不只會展示用户的帖子,更可能包含其他的內容。但該模式不適用分類和過濾。",
 | 
			
		||||
  "filterFeed": "探索隊列調整",
 | 
			
		||||
  "feedUnknownItem": "無法顯示該內容,當前版本客户端不支持該類型的內容,請嘗試更新應用程序後再試。",
 | 
			
		||||
  "serviceStatusOperational": "所有服務正常",
 | 
			
		||||
  "serviceStatusDowngraded": "部分服務異常",
 | 
			
		||||
  "serviceStatusFailed": "服務狀態異常",
 | 
			
		||||
  "serviceStatusFailedDescription": "服務器炸了或者剛剛執行完維護程序。",
 | 
			
		||||
  "serviceNameInsights": "總結、見解與洞察",
 | 
			
		||||
  "serviceNameInteractive": "帖子與互動",
 | 
			
		||||
  "serviceNameReader": "新聞與鏈接展開",
 | 
			
		||||
  "serviceNameMessaging": "即使聊天",
 | 
			
		||||
  "serviceNameMatrix": "矩陣市場",
 | 
			
		||||
  "serviceNamePaperclip": "附件",
 | 
			
		||||
  "serviceNameWallet": "源點錢包",
 | 
			
		||||
  "serviceNamePassport": "身份驗證與授權",
 | 
			
		||||
  "accountActionEvent": "操作日誌",
 | 
			
		||||
  "accountActionEventDescription": "查看你的操作日誌。",
 | 
			
		||||
  "eventMetadata": "元數據",
 | 
			
		||||
  "accountAuthTickets": "授權會話",
 | 
			
		||||
  "accountAuthTicketsDescription": "查看和管理你的授權會話。",
 | 
			
		||||
  "authTicketCreatedAt": "簽發於 {}",
 | 
			
		||||
  "authTicketExpiredAt": "到期於 {}",
 | 
			
		||||
  "authTicketLastGrantAt": "上次刷新於 {}",
 | 
			
		||||
  "authTicketCurrent": "當前會話",
 | 
			
		||||
  "accountUnconfirmedTitle": "尚未未確認賬户",
 | 
			
		||||
  "accountUnconfirmedSubtitle": "您的賬户尚未確認,這會導致大部分功能不可用,並且您的帳户將在 24 小時後自毀。您綁定的郵箱的收件箱應該會有一封郵件指引您確認您的帳户。",
 | 
			
		||||
  "accountUnconfirmedUnreceived": "未收到郵件?",
 | 
			
		||||
  "accountUnconfirmedResend": "重新發送一封",
 | 
			
		||||
  "accountUnconfirmedResendSuccessful": "郵件已重新發送,你可以在 60 分鐘後再重發一封。",
 | 
			
		||||
  "stickerPickerEmpty": "貼圖列表為空",
 | 
			
		||||
  "stickerPickerEmptyHint": "想要開始使用貼圖,請先添加貼圖包。",
 | 
			
		||||
  "goto": "跳轉到 {}",
 | 
			
		||||
  "accountContactMethods": "聯繫方式",
 | 
			
		||||
  "accountContactMethodsDescription": "管理你的聯繫方式。",
 | 
			
		||||
  "accountContactMethodsNameEmail": "電子郵箱",
 | 
			
		||||
  "accountContactMethodsNamePhone": "電話",
 | 
			
		||||
  "accountContactMethodsNameAddress": "地址",
 | 
			
		||||
  "accountContactMethodsPrimary": "主要的",
 | 
			
		||||
  "accountContactMethodsVerified": "已驗證",
 | 
			
		||||
  "accountContactMethodsPublic": "公開的",
 | 
			
		||||
  "accountContactMethodsAdd": "添加聯繫方式",
 | 
			
		||||
  "accountContactMethodsEdit": "編輯聯繫方式",
 | 
			
		||||
  "accountContactMethodsAddDescription": "添加新的聯繫方式。",
 | 
			
		||||
  "fieldContactContent": "聯繫方式",
 | 
			
		||||
  "accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。",
 | 
			
		||||
  "accountContactMethodsDelete": "刪除聯繫方式",
 | 
			
		||||
  "accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。",
 | 
			
		||||
  "postCommentAdd": "撰寫一條評論",
 | 
			
		||||
  "translate": "翻譯",
 | 
			
		||||
  "translating": "正在翻譯……",
 | 
			
		||||
  "translated": "已翻譯",
 | 
			
		||||
  "settingsAutoTranslate": "自動翻譯",
 | 
			
		||||
  "settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。",
 | 
			
		||||
  "trayMenuHide": "隱藏",
 | 
			
		||||
  "accountSettingsNotify": "通知設置",
 | 
			
		||||
  "accountSettingsNotifyDescription": "調整你所收到的通知種類。",
 | 
			
		||||
  "accountSettingsSecurity": "安全設置",
 | 
			
		||||
  "accountSettingsSecurityDescription": "調整你的帳户安全設置。",
 | 
			
		||||
  "save": "保存",
 | 
			
		||||
  "notificationTopicPostFeedback": "帖子數據反饋",
 | 
			
		||||
  "notificationTopicPostReply": "帖子回覆",
 | 
			
		||||
  "notificationTopicPostSubscription": "帖子訂閲",
 | 
			
		||||
  "notificationTopicMessaging": "消息",
 | 
			
		||||
  "notificationTopicMessagingCall": "通話",
 | 
			
		||||
  "notificationTopicGeneral": "雜項",
 | 
			
		||||
  "authMaximumAuthSteps": "最大驗證步驟",
 | 
			
		||||
  "authMaximumAuthStepsDescription": {
 | 
			
		||||
    "one": "登入時最多要求 {} 步驗證",
 | 
			
		||||
    "other": "登入時最多要求 {} 步驗證"
 | 
			
		||||
  },
 | 
			
		||||
  "authAlwaysRisky": "總是風險",
 | 
			
		||||
  "authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。",
 | 
			
		||||
  "chatUnjoined": "未加入頻道",
 | 
			
		||||
  "chatUnjoinedDescription": "你沒有加入這個頻道,所以你也無法發送消息或者查看這個頻道中的消息。",
 | 
			
		||||
  "chatUnjoinedPublicDescription": "但幸運的是,這是一個公開頻道,所以你可以主動加入。",
 | 
			
		||||
  "chatJoin": "加入頻道",
 | 
			
		||||
  "appInitStarting": "啓動中",
 | 
			
		||||
  "appInitNetwork": "正在初始化網絡",
 | 
			
		||||
  "appInitUserdata": "正在初始化用户數據",
 | 
			
		||||
  "appInitWebsocket": "正在建立 Solar Link",
 | 
			
		||||
  "appInitNotification": "正在初始化推送通知", 
 | 
			
		||||
  "appInitKeyPair": "正在初始化密鑰對",
 | 
			
		||||
  "appInitStickers": "正在初始化貼圖包",
 | 
			
		||||
  "appInitUserDirectory": "正在初始化用户目錄",
 | 
			
		||||
  "appInitRealm": "正在初始化領域信息",
 | 
			
		||||
  "appInitChat": "正在初始化聊天",
 | 
			
		||||
  "appInitDone": "完成",
 | 
			
		||||
  "community": "社區",
 | 
			
		||||
  "realmCommunity": "{}的社區",
 | 
			
		||||
  "postTotalCount": {
 | 
			
		||||
    "zero": "沒有帖子",
 | 
			
		||||
    "one": "共 {} 條帖子"
 | 
			
		||||
  },
 | 
			
		||||
  "settingsHideBottomNav": "隱藏底部導航欄",
 | 
			
		||||
  "settingsHideBottomNavDescription": "隱藏底部導航欄,在側邊欄抽屜顯示導航按鈕。",
 | 
			
		||||
  "reCaptcha": "人機驗證",
 | 
			
		||||
  "friends": "好友",
 | 
			
		||||
  "friendsDescription": "管理好友關係。",
 | 
			
		||||
  "album": "相冊",
 | 
			
		||||
  "albumDescription": "查看相冊與管理上傳附件。",
 | 
			
		||||
  "stickers": "貼圖",
 | 
			
		||||
  "stickersDescription": "查看貼圖包與管理貼圖。",
 | 
			
		||||
  "navBottomUnauthorizedCaption": "或者註冊一個賬號"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -137,6 +137,11 @@
 | 
			
		||||
  "publisherRunBy": "由 {} 管理",
 | 
			
		||||
  "fieldPublisherBelongToRealm": "所屬領域",
 | 
			
		||||
  "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
 | 
			
		||||
  "writePost": "撰寫",
 | 
			
		||||
  "postTypeStory": "動態",
 | 
			
		||||
  "postTypeArticle": "文章",
 | 
			
		||||
  "postTypeQuestion": "問題",
 | 
			
		||||
  "postTypeVideo": "視頻",
 | 
			
		||||
  "writePostTypeStory": "發動態",
 | 
			
		||||
  "writePostTypeArticle": "寫文章",
 | 
			
		||||
  "writePostTypeQuestion": "提問題",
 | 
			
		||||
@@ -200,7 +205,13 @@
 | 
			
		||||
    "one": "{} 條評論",
 | 
			
		||||
    "other": "{} 條評論"
 | 
			
		||||
  },
 | 
			
		||||
  "postCommentExpand": "展開評論",
 | 
			
		||||
  "settingsAppearance": "外觀",
 | 
			
		||||
  "settingsCustomFonts": "自定義字體",
 | 
			
		||||
  "settingsCustomFontsDescription": "設置應用程序使用的字體。",
 | 
			
		||||
  "settingsCustomFontFamily": "應用字體",
 | 
			
		||||
  "settingsCustomFontFamilyHint": "使用英文逗號分割每一種字體,越前優先級越高",
 | 
			
		||||
  "settingsCustomFontApplied": "自定義字體已經應用。",
 | 
			
		||||
  "settingsDisplayLanguage": "顯示語言",
 | 
			
		||||
  "settingsDisplayLanguageDescription": "設置應用程序使用的語言",
 | 
			
		||||
  "settingsDisplayLanguageSystem": "跟隨系統",
 | 
			
		||||
@@ -325,6 +336,7 @@
 | 
			
		||||
  "fieldAttachmentRandomId": "訪問 ID",
 | 
			
		||||
  "fieldAttachmentAlt": "概述文字",
 | 
			
		||||
  "addAttachmentFromAlbum": "從相冊中添加附件",
 | 
			
		||||
  "addAttachmentFromFiles": "從文件中添加附件",
 | 
			
		||||
  "addAttachmentFromClipboard": "粘貼附件",
 | 
			
		||||
  "addAttachmentFromCameraPhoto": "拍攝照片",
 | 
			
		||||
  "addAttachmentFromCameraVideo": "拍攝視頻",
 | 
			
		||||
@@ -510,8 +522,13 @@
 | 
			
		||||
  "accountBirthday": "出生於 {}",
 | 
			
		||||
  "accountBadge": "徽章",
 | 
			
		||||
  "accountCheckInNoRecords": "暫無運勢記錄",
 | 
			
		||||
  "badgeCompanyStaff": "索爾辛茨士大夫 · 員工",
 | 
			
		||||
  "badgeCompanyStaff": "工作人員",
 | 
			
		||||
  "badgeSiteMigration": "Solar Network 原住民",
 | 
			
		||||
  "badgeCommunitySurvey": "調研參與者",
 | 
			
		||||
  "badgeCommunityVerified": "認證用戶",
 | 
			
		||||
  "badgeCommunityContributor": "優秀社區貢獻者",
 | 
			
		||||
  "badgeSiteAnniversary": "週年紀念",
 | 
			
		||||
  "badgeUserBirthday": "生日紀念",
 | 
			
		||||
  "accountStatus": "狀態",
 | 
			
		||||
  "accountStatusOnline": "在線",
 | 
			
		||||
  "accountStatusOffline": "離線",
 | 
			
		||||
@@ -546,6 +563,7 @@
 | 
			
		||||
  "termAcceptNextWithAgree": "點擊 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。",
 | 
			
		||||
  "unauthorized": "未登陸",
 | 
			
		||||
  "unauthorizedDescription": "登陸以探索整個 Solar Network。",
 | 
			
		||||
  "projectDetail": "項目詳情",
 | 
			
		||||
  "serviceStatus": "服務狀態",
 | 
			
		||||
  "termRelated": "相關條款",
 | 
			
		||||
  "appDetails": "應用程序詳情",
 | 
			
		||||
@@ -581,6 +599,7 @@
 | 
			
		||||
  "colorSchemeBlack": "黑色",
 | 
			
		||||
  "colorSchemeApplied": "主題色已應用,可能需要重啟來生效。",
 | 
			
		||||
  "postFeaturedComment": "精選評論",
 | 
			
		||||
  "postCategory": "分類",
 | 
			
		||||
  "postCategoryTechnology": "技術",
 | 
			
		||||
  "postCategoryGaming": "遊戲",
 | 
			
		||||
  "postCategoryLife": "生活",
 | 
			
		||||
@@ -624,6 +643,7 @@
 | 
			
		||||
  "realmJoin": "加入領域",
 | 
			
		||||
  "realmCommunityHint": "該領域是一個社區領域,你可以自由加入。",
 | 
			
		||||
  "realmCommunityPublicChannelsHint": "該領域包含的公共頻道",
 | 
			
		||||
  "realmCommunityPublishersHint": "該領域的發佈者",
 | 
			
		||||
  "realmJoined": "已加入領域 {}。",
 | 
			
		||||
  "join": "加入",
 | 
			
		||||
  "pollEditorNew": "新投票",
 | 
			
		||||
@@ -664,5 +684,216 @@
 | 
			
		||||
    "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": "無法預覽加密消息",
 | 
			
		||||
  "postViewInGlobalDescription": "不查看特定領域的帖子。",
 | 
			
		||||
  "postDraftSaved": "已保存為草稿。",
 | 
			
		||||
  "postDraftBox": "草稿箱",
 | 
			
		||||
  "postShuffle": "隨便看看",
 | 
			
		||||
  "checkInStreak": {
 | 
			
		||||
    "zero": "無連擊",
 | 
			
		||||
    "one": "連續簽到 {} 天",
 | 
			
		||||
    "other": "連續簽到 {} 天"
 | 
			
		||||
  },
 | 
			
		||||
  "accountChangeStatus": "修改狀態",
 | 
			
		||||
  "accountStatusSilent": "請勿打擾",
 | 
			
		||||
  "accountStatusSilentDesc": "將會暫停所有通知推送",
 | 
			
		||||
  "accountStatusInvisible": "隱身",
 | 
			
		||||
  "accountStatusInvisibleDesc": "將會在他人界面顯示離線,但不影響功能使用",
 | 
			
		||||
  "accountCustomStatus": "自定義狀態",
 | 
			
		||||
  "accountCustomStatusDescription": "客製化你的狀態。",
 | 
			
		||||
  "accountClearStatus": "清除狀態",
 | 
			
		||||
  "accountClearStatusDescription": "清除你的狀態,並讓服務器決定你的狀態。",
 | 
			
		||||
  "fieldAccountStatusLabel": "狀態文字",
 | 
			
		||||
  "fieldAccountStatusClearAt": "清除時間",
 | 
			
		||||
  "accountStatusNegative": "負面",
 | 
			
		||||
  "accountStatusNeutral": "中性",
 | 
			
		||||
  "accountStatusPositive": "正面",
 | 
			
		||||
  "mixedFeed": "混合推薦流",
 | 
			
		||||
  "mixedFeedDescription": "探索頁面可能不只會展示用戶的帖子,更可能包含其他的內容。但該模式不適用分類和過濾。",
 | 
			
		||||
  "filterFeed": "探索隊列調整",
 | 
			
		||||
  "feedUnknownItem": "無法顯示該內容,當前版本客戶端不支持該類型的內容,請嘗試更新應用程序後再試。",
 | 
			
		||||
  "serviceStatusOperational": "所有服務正常",
 | 
			
		||||
  "serviceStatusDowngraded": "部分服務異常",
 | 
			
		||||
  "serviceStatusFailed": "服務狀態異常",
 | 
			
		||||
  "serviceStatusFailedDescription": "服務器炸了或者剛剛執行完維護程序。",
 | 
			
		||||
  "serviceNameInsights": "總結、見解與洞察",
 | 
			
		||||
  "serviceNameInteractive": "帖子與互動",
 | 
			
		||||
  "serviceNameReader": "新聞與鏈接展開",
 | 
			
		||||
  "serviceNameMessaging": "即使聊天",
 | 
			
		||||
  "serviceNameMatrix": "矩陣市場",
 | 
			
		||||
  "serviceNamePaperclip": "附件",
 | 
			
		||||
  "serviceNameWallet": "源點錢包",
 | 
			
		||||
  "serviceNamePassport": "身份驗證與授權",
 | 
			
		||||
  "accountActionEvent": "操作日誌",
 | 
			
		||||
  "accountActionEventDescription": "查看你的操作日誌。",
 | 
			
		||||
  "eventMetadata": "元數據",
 | 
			
		||||
  "accountAuthTickets": "授權會話",
 | 
			
		||||
  "accountAuthTicketsDescription": "查看和管理你的授權會話。",
 | 
			
		||||
  "authTicketCreatedAt": "簽發於 {}",
 | 
			
		||||
  "authTicketExpiredAt": "到期於 {}",
 | 
			
		||||
  "authTicketLastGrantAt": "上次刷新於 {}",
 | 
			
		||||
  "authTicketCurrent": "當前會話",
 | 
			
		||||
  "accountUnconfirmedTitle": "尚未未確認賬戶",
 | 
			
		||||
  "accountUnconfirmedSubtitle": "您的賬戶尚未確認,這會導致大部分功能不可用,並且您的帳戶將在 24 小時後自毀。您綁定的郵箱的收件箱應該會有一封郵件指引您確認您的帳戶。",
 | 
			
		||||
  "accountUnconfirmedUnreceived": "未收到郵件?",
 | 
			
		||||
  "accountUnconfirmedResend": "重新發送一封",
 | 
			
		||||
  "accountUnconfirmedResendSuccessful": "郵件已重新發送,你可以在 60 分鐘後再重發一封。",
 | 
			
		||||
  "stickerPickerEmpty": "貼圖列表為空",
 | 
			
		||||
  "stickerPickerEmptyHint": "想要開始使用貼圖,請先添加貼圖包。",
 | 
			
		||||
  "goto": "跳轉到 {}",
 | 
			
		||||
  "accountContactMethods": "聯繫方式",
 | 
			
		||||
  "accountContactMethodsDescription": "管理你的聯繫方式。",
 | 
			
		||||
  "accountContactMethodsNameEmail": "電子郵箱",
 | 
			
		||||
  "accountContactMethodsNamePhone": "電話",
 | 
			
		||||
  "accountContactMethodsNameAddress": "地址",
 | 
			
		||||
  "accountContactMethodsPrimary": "主要的",
 | 
			
		||||
  "accountContactMethodsVerified": "已驗證",
 | 
			
		||||
  "accountContactMethodsPublic": "公開的",
 | 
			
		||||
  "accountContactMethodsAdd": "添加聯繫方式",
 | 
			
		||||
  "accountContactMethodsEdit": "編輯聯繫方式",
 | 
			
		||||
  "accountContactMethodsAddDescription": "添加新的聯繫方式。",
 | 
			
		||||
  "fieldContactContent": "聯繫方式",
 | 
			
		||||
  "accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。",
 | 
			
		||||
  "accountContactMethodsDelete": "刪除聯繫方式",
 | 
			
		||||
  "accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。",
 | 
			
		||||
  "postCommentAdd": "撰寫一條評論",
 | 
			
		||||
  "translate": "翻譯",
 | 
			
		||||
  "translating": "正在翻譯……",
 | 
			
		||||
  "translated": "已翻譯",
 | 
			
		||||
  "settingsAutoTranslate": "自動翻譯",
 | 
			
		||||
  "settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。",
 | 
			
		||||
  "trayMenuHide": "隱藏",
 | 
			
		||||
  "accountSettingsNotify": "通知設置",
 | 
			
		||||
  "accountSettingsNotifyDescription": "調整你所收到的通知種類。",
 | 
			
		||||
  "accountSettingsSecurity": "安全設置",
 | 
			
		||||
  "accountSettingsSecurityDescription": "調整你的帳戶安全設置。",
 | 
			
		||||
  "save": "保存",
 | 
			
		||||
  "notificationTopicPostFeedback": "帖子數據反饋",
 | 
			
		||||
  "notificationTopicPostReply": "帖子回覆",
 | 
			
		||||
  "notificationTopicPostSubscription": "帖子訂閱",
 | 
			
		||||
  "notificationTopicMessaging": "消息",
 | 
			
		||||
  "notificationTopicMessagingCall": "通話",
 | 
			
		||||
  "notificationTopicGeneral": "雜項",
 | 
			
		||||
  "authMaximumAuthSteps": "最大驗證步驟",
 | 
			
		||||
  "authMaximumAuthStepsDescription": {
 | 
			
		||||
    "one": "登入時最多要求 {} 步驗證",
 | 
			
		||||
    "other": "登入時最多要求 {} 步驗證"
 | 
			
		||||
  },
 | 
			
		||||
  "authAlwaysRisky": "總是風險",
 | 
			
		||||
  "authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。",
 | 
			
		||||
  "chatUnjoined": "未加入頻道",
 | 
			
		||||
  "chatUnjoinedDescription": "你沒有加入這個頻道,所以你也無法發送消息或者查看這個頻道中的消息。",
 | 
			
		||||
  "chatUnjoinedPublicDescription": "但幸運的是,這是一個公開頻道,所以你可以主動加入。",
 | 
			
		||||
  "chatJoin": "加入頻道",
 | 
			
		||||
  "appInitStarting": "啟動中",
 | 
			
		||||
  "appInitNetwork": "正在初始化網絡",
 | 
			
		||||
  "appInitUserdata": "正在初始化用戶數據",
 | 
			
		||||
  "appInitWebsocket": "正在建立 Solar Link",
 | 
			
		||||
  "appInitNotification": "正在初始化推送通知", 
 | 
			
		||||
  "appInitKeyPair": "正在初始化密鑰對",
 | 
			
		||||
  "appInitStickers": "正在初始化貼圖包",
 | 
			
		||||
  "appInitUserDirectory": "正在初始化用戶目錄",
 | 
			
		||||
  "appInitRealm": "正在初始化領域信息",
 | 
			
		||||
  "appInitChat": "正在初始化聊天",
 | 
			
		||||
  "appInitDone": "完成",
 | 
			
		||||
  "community": "社區",
 | 
			
		||||
  "realmCommunity": "{}的社區",
 | 
			
		||||
  "postTotalCount": {
 | 
			
		||||
    "zero": "沒有帖子",
 | 
			
		||||
    "one": "共 {} 條帖子"
 | 
			
		||||
  },
 | 
			
		||||
  "settingsHideBottomNav": "隱藏底部導航欄",
 | 
			
		||||
  "settingsHideBottomNavDescription": "隱藏底部導航欄,在側邊欄抽屜顯示導航按鈕。",
 | 
			
		||||
  "reCaptcha": "人機驗證",
 | 
			
		||||
  "friends": "好友",
 | 
			
		||||
  "friendsDescription": "管理好友關係。",
 | 
			
		||||
  "album": "相冊",
 | 
			
		||||
  "albumDescription": "查看相冊與管理上傳附件。",
 | 
			
		||||
  "stickers": "貼圖",
 | 
			
		||||
  "stickersDescription": "查看貼圖包與管理貼圖。",
 | 
			
		||||
  "navBottomUnauthorizedCaption": "或者註冊一個賬號"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,4 +4,8 @@ targets:
 | 
			
		||||
      json_serializable:
 | 
			
		||||
        options:
 | 
			
		||||
          explicit_to_json: true
 | 
			
		||||
          field_rename: snake
 | 
			
		||||
          field_rename: snake
 | 
			
		||||
      drift_dev:
 | 
			
		||||
        options:
 | 
			
		||||
          databases:
 | 
			
		||||
            my_database: lib/database/database.dart
 | 
			
		||||
							
								
								
									
										1
									
								
								drift_schemas/my_database/drift_schema_v1.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								drift_schemas/my_database/drift_schema_v1.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"sn_local_chat_channel","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"alias","getter_name":"alias","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnChannelConverter()","dart_type_name":"SnChannel"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"sn_local_chat_message","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"channel_id","getter_name":"channelId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnMessageConverter()","dart_type_name":"SnChatMessage"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}}]}
 | 
			
		||||
							
								
								
									
										1
									
								
								drift_schemas/my_database/drift_schema_v2.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								drift_schemas/my_database/drift_schema_v2.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"sn_local_chat_channel","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"alias","getter_name":"alias","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnChannelConverter()","dart_type_name":"SnChannel"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"sn_local_chat_message","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"channel_id","getter_name":"channelId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnMessageConverter()","dart_type_name":"SnChatMessage"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":2,"references":[],"type":"table","data":{"name":"sn_local_key_pair","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"account_id","getter_name":"accountId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"public_key","getter_name":"publicKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"private_key","getter_name":"privateKey","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_active","getter_name":"isActive","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_active\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_active\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["id"]}}]}
 | 
			
		||||
							
								
								
									
										1
									
								
								drift_schemas/my_database/drift_schema_v3.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								drift_schemas/my_database/drift_schema_v3.json
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										1
									
								
								drift_schemas/my_database/drift_schema_v4.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								drift_schemas/my_database/drift_schema_v4.json
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										210
									
								
								ios/Podfile.lock
									
									
									
									
									
								
							
							
						
						
									
										210
									
								
								ios/Podfile.lock
									
									
									
									
									
								
							@@ -1,5 +1,7 @@
 | 
			
		||||
PODS:
 | 
			
		||||
  - Alamofire (5.10.2)
 | 
			
		||||
  - audioplayers_darwin (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - connectivity_plus (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - croppy (0.0.1):
 | 
			
		||||
@@ -37,63 +39,65 @@ PODS:
 | 
			
		||||
  - DKPhotoGallery/Resource (0.0.19):
 | 
			
		||||
    - SDWebImage
 | 
			
		||||
    - SwiftyGif
 | 
			
		||||
  - fast_rsa (0.6.0):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - file_picker (0.0.1):
 | 
			
		||||
    - DKImagePickerController/PhotoGallery
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - file_saver (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - Firebase/Analytics (11.7.0):
 | 
			
		||||
  - Firebase/Analytics (11.8.0):
 | 
			
		||||
    - Firebase/Core
 | 
			
		||||
  - Firebase/Core (11.7.0):
 | 
			
		||||
  - Firebase/Core (11.8.0):
 | 
			
		||||
    - Firebase/CoreOnly
 | 
			
		||||
    - FirebaseAnalytics (~> 11.7.0)
 | 
			
		||||
  - Firebase/CoreOnly (11.7.0):
 | 
			
		||||
    - FirebaseCore (~> 11.7.0)
 | 
			
		||||
  - Firebase/Messaging (11.7.0):
 | 
			
		||||
    - FirebaseAnalytics (~> 11.8.0)
 | 
			
		||||
  - Firebase/CoreOnly (11.8.0):
 | 
			
		||||
    - FirebaseCore (~> 11.8.0)
 | 
			
		||||
  - Firebase/Messaging (11.8.0):
 | 
			
		||||
    - Firebase/CoreOnly
 | 
			
		||||
    - FirebaseMessaging (~> 11.7.0)
 | 
			
		||||
  - firebase_analytics (11.4.2):
 | 
			
		||||
    - Firebase/Analytics (= 11.7.0)
 | 
			
		||||
    - FirebaseMessaging (~> 11.8.0)
 | 
			
		||||
  - firebase_analytics (11.4.4):
 | 
			
		||||
    - Firebase/Analytics (= 11.8.0)
 | 
			
		||||
    - firebase_core
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - firebase_core (3.11.0):
 | 
			
		||||
    - Firebase/CoreOnly (= 11.7.0)
 | 
			
		||||
  - firebase_core (3.12.1):
 | 
			
		||||
    - Firebase/CoreOnly (= 11.8.0)
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - firebase_messaging (15.2.2):
 | 
			
		||||
    - Firebase/Messaging (= 11.7.0)
 | 
			
		||||
  - firebase_messaging (15.2.4):
 | 
			
		||||
    - Firebase/Messaging (= 11.8.0)
 | 
			
		||||
    - firebase_core
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - FirebaseAnalytics (11.7.0):
 | 
			
		||||
    - FirebaseAnalytics/AdIdSupport (= 11.7.0)
 | 
			
		||||
    - FirebaseCore (~> 11.7.0)
 | 
			
		||||
  - FirebaseAnalytics (11.8.0):
 | 
			
		||||
    - FirebaseAnalytics/AdIdSupport (= 11.8.0)
 | 
			
		||||
    - FirebaseCore (~> 11.8.0)
 | 
			
		||||
    - FirebaseInstallations (~> 11.0)
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/Network (~> 8.0)
 | 
			
		||||
    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
			
		||||
    - nanopb (~> 3.30910.0)
 | 
			
		||||
  - FirebaseAnalytics/AdIdSupport (11.7.0):
 | 
			
		||||
    - FirebaseCore (~> 11.7.0)
 | 
			
		||||
  - FirebaseAnalytics/AdIdSupport (11.8.0):
 | 
			
		||||
    - FirebaseCore (~> 11.8.0)
 | 
			
		||||
    - FirebaseInstallations (~> 11.0)
 | 
			
		||||
    - GoogleAppMeasurement (= 11.7.0)
 | 
			
		||||
    - GoogleAppMeasurement (= 11.8.0)
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/Network (~> 8.0)
 | 
			
		||||
    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
			
		||||
    - nanopb (~> 3.30910.0)
 | 
			
		||||
  - FirebaseCore (11.7.0):
 | 
			
		||||
    - FirebaseCoreInternal (~> 11.7.0)
 | 
			
		||||
  - FirebaseCore (11.8.1):
 | 
			
		||||
    - FirebaseCoreInternal (~> 11.8.0)
 | 
			
		||||
    - GoogleUtilities/Environment (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/Logger (~> 8.0)
 | 
			
		||||
  - FirebaseCoreInternal (11.7.0):
 | 
			
		||||
  - FirebaseCoreInternal (11.8.0):
 | 
			
		||||
    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
			
		||||
  - FirebaseInstallations (11.7.0):
 | 
			
		||||
    - FirebaseCore (~> 11.7.0)
 | 
			
		||||
  - FirebaseInstallations (11.8.0):
 | 
			
		||||
    - FirebaseCore (~> 11.8.0)
 | 
			
		||||
    - GoogleUtilities/Environment (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/UserDefaults (~> 8.0)
 | 
			
		||||
    - PromisesObjC (~> 2.4)
 | 
			
		||||
  - FirebaseMessaging (11.7.0):
 | 
			
		||||
    - FirebaseCore (~> 11.7.0)
 | 
			
		||||
  - FirebaseMessaging (11.8.0):
 | 
			
		||||
    - FirebaseCore (~> 11.8.0)
 | 
			
		||||
    - FirebaseInstallations (~> 11.0)
 | 
			
		||||
    - GoogleDataTransport (~> 10.0)
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
			
		||||
@@ -113,6 +117,8 @@ PODS:
 | 
			
		||||
    - OrderedSet (~> 6.0.3)
 | 
			
		||||
  - flutter_native_splash (2.4.3):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - flutter_timezone (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - flutter_udid (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - SAMKeychain
 | 
			
		||||
@@ -122,21 +128,21 @@ PODS:
 | 
			
		||||
  - gal (1.0.0):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - GoogleAppMeasurement (11.7.0):
 | 
			
		||||
    - GoogleAppMeasurement/AdIdSupport (= 11.7.0)
 | 
			
		||||
  - GoogleAppMeasurement (11.8.0):
 | 
			
		||||
    - GoogleAppMeasurement/AdIdSupport (= 11.8.0)
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/Network (~> 8.0)
 | 
			
		||||
    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
			
		||||
    - nanopb (~> 3.30910.0)
 | 
			
		||||
  - GoogleAppMeasurement/AdIdSupport (11.7.0):
 | 
			
		||||
    - GoogleAppMeasurement/WithoutAdIdSupport (= 11.7.0)
 | 
			
		||||
  - GoogleAppMeasurement/AdIdSupport (11.8.0):
 | 
			
		||||
    - GoogleAppMeasurement/WithoutAdIdSupport (= 11.8.0)
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/Network (~> 8.0)
 | 
			
		||||
    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
			
		||||
    - nanopb (~> 3.30910.0)
 | 
			
		||||
  - GoogleAppMeasurement/WithoutAdIdSupport (11.7.0):
 | 
			
		||||
  - GoogleAppMeasurement/WithoutAdIdSupport (11.8.0):
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/Network (~> 8.0)
 | 
			
		||||
@@ -179,14 +185,12 @@ PODS:
 | 
			
		||||
  - in_app_review (2.0.0):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - Kingfisher (8.2.0)
 | 
			
		||||
  - livekit_client (2.3.6):
 | 
			
		||||
  - livekit_client (2.4.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - flutter_webrtc
 | 
			
		||||
    - WebRTC-SDK (= 125.6422.06)
 | 
			
		||||
  - media_kit_libs_ios_video (1.0.4):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - media_kit_native_event_loop (1.0.0):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - media_kit_video (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - nanopb (3.30910.0):
 | 
			
		||||
@@ -208,11 +212,9 @@ PODS:
 | 
			
		||||
  - receive_sharing_intent (1.8.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - SAMKeychain (1.5.3)
 | 
			
		||||
  - screen_brightness_ios (0.1.0):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - SDWebImage (5.20.0):
 | 
			
		||||
    - SDWebImage/Core (= 5.20.0)
 | 
			
		||||
  - SDWebImage/Core (5.20.0)
 | 
			
		||||
  - SDWebImage (5.20.1):
 | 
			
		||||
    - SDWebImage/Core (= 5.20.1)
 | 
			
		||||
  - SDWebImage/Core (5.20.1)
 | 
			
		||||
  - share_plus (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - shared_preferences_foundation (0.0.1):
 | 
			
		||||
@@ -221,6 +223,28 @@ PODS:
 | 
			
		||||
  - sqflite_darwin (0.0.4):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - 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/math (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/math
 | 
			
		||||
    - sqlite3/perf-threadsafe
 | 
			
		||||
    - sqlite3/rtree
 | 
			
		||||
  - SwiftyGif (5.4.5)
 | 
			
		||||
  - url_launcher_ios (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
@@ -236,9 +260,11 @@ PODS:
 | 
			
		||||
 | 
			
		||||
DEPENDENCIES:
 | 
			
		||||
  - Alamofire
 | 
			
		||||
  - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/ios`)
 | 
			
		||||
  - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
 | 
			
		||||
  - croppy (from `.symlinks/plugins/croppy/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_saver (from `.symlinks/plugins/file_saver/ios`)
 | 
			
		||||
  - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
 | 
			
		||||
@@ -248,6 +274,7 @@ DEPENDENCIES:
 | 
			
		||||
  - flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`)
 | 
			
		||||
  - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/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_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
 | 
			
		||||
  - gal (from `.symlinks/plugins/gal/darwin`)
 | 
			
		||||
@@ -257,17 +284,16 @@ DEPENDENCIES:
 | 
			
		||||
  - Kingfisher (~> 8.0)
 | 
			
		||||
  - livekit_client (from `.symlinks/plugins/livekit_client/ios`)
 | 
			
		||||
  - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
 | 
			
		||||
  - media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
 | 
			
		||||
  - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
 | 
			
		||||
  - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
 | 
			
		||||
  - pasteboard (from `.symlinks/plugins/pasteboard/ios`)
 | 
			
		||||
  - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
 | 
			
		||||
  - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
 | 
			
		||||
  - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
 | 
			
		||||
  - screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
 | 
			
		||||
  - share_plus (from `.symlinks/plugins/share_plus/ios`)
 | 
			
		||||
  - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/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`)
 | 
			
		||||
  - video_compress (from `.symlinks/plugins/video_compress/ios`)
 | 
			
		||||
  - volume_controller (from `.symlinks/plugins/volume_controller/ios`)
 | 
			
		||||
@@ -294,16 +320,21 @@ SPEC REPOS:
 | 
			
		||||
    - PromisesObjC
 | 
			
		||||
    - SAMKeychain
 | 
			
		||||
    - SDWebImage
 | 
			
		||||
    - sqlite3
 | 
			
		||||
    - SwiftyGif
 | 
			
		||||
    - WebRTC-SDK
 | 
			
		||||
 | 
			
		||||
EXTERNAL SOURCES:
 | 
			
		||||
  audioplayers_darwin:
 | 
			
		||||
    :path: ".symlinks/plugins/audioplayers_darwin/ios"
 | 
			
		||||
  connectivity_plus:
 | 
			
		||||
    :path: ".symlinks/plugins/connectivity_plus/ios"
 | 
			
		||||
  croppy:
 | 
			
		||||
    :path: ".symlinks/plugins/croppy/ios"
 | 
			
		||||
  device_info_plus:
 | 
			
		||||
    :path: ".symlinks/plugins/device_info_plus/ios"
 | 
			
		||||
  fast_rsa:
 | 
			
		||||
    :path: ".symlinks/plugins/fast_rsa/ios"
 | 
			
		||||
  file_picker:
 | 
			
		||||
    :path: ".symlinks/plugins/file_picker/ios"
 | 
			
		||||
  file_saver:
 | 
			
		||||
@@ -322,6 +353,8 @@ EXTERNAL SOURCES:
 | 
			
		||||
    :path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
 | 
			
		||||
  flutter_native_splash:
 | 
			
		||||
    :path: ".symlinks/plugins/flutter_native_splash/ios"
 | 
			
		||||
  flutter_timezone:
 | 
			
		||||
    :path: ".symlinks/plugins/flutter_timezone/ios"
 | 
			
		||||
  flutter_udid:
 | 
			
		||||
    :path: ".symlinks/plugins/flutter_udid/ios"
 | 
			
		||||
  flutter_webrtc:
 | 
			
		||||
@@ -338,8 +371,6 @@ EXTERNAL SOURCES:
 | 
			
		||||
    :path: ".symlinks/plugins/livekit_client/ios"
 | 
			
		||||
  media_kit_libs_ios_video:
 | 
			
		||||
    :path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
 | 
			
		||||
  media_kit_native_event_loop:
 | 
			
		||||
    :path: ".symlinks/plugins/media_kit_native_event_loop/ios"
 | 
			
		||||
  media_kit_video:
 | 
			
		||||
    :path: ".symlinks/plugins/media_kit_video/ios"
 | 
			
		||||
  package_info_plus:
 | 
			
		||||
@@ -352,14 +383,14 @@ EXTERNAL SOURCES:
 | 
			
		||||
    :path: ".symlinks/plugins/permission_handler_apple/ios"
 | 
			
		||||
  receive_sharing_intent:
 | 
			
		||||
    :path: ".symlinks/plugins/receive_sharing_intent/ios"
 | 
			
		||||
  screen_brightness_ios:
 | 
			
		||||
    :path: ".symlinks/plugins/screen_brightness_ios/ios"
 | 
			
		||||
  share_plus:
 | 
			
		||||
    :path: ".symlinks/plugins/share_plus/ios"
 | 
			
		||||
  shared_preferences_foundation:
 | 
			
		||||
    :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
 | 
			
		||||
  sqflite_darwin:
 | 
			
		||||
    :path: ".symlinks/plugins/sqflite_darwin/darwin"
 | 
			
		||||
  sqlite3_flutter_libs:
 | 
			
		||||
    :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
 | 
			
		||||
  url_launcher_ios:
 | 
			
		||||
    :path: ".symlinks/plugins/url_launcher_ios/ios"
 | 
			
		||||
  video_compress:
 | 
			
		||||
@@ -373,61 +404,64 @@ EXTERNAL SOURCES:
 | 
			
		||||
 | 
			
		||||
SPEC CHECKSUMS:
 | 
			
		||||
  Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
 | 
			
		||||
  connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
 | 
			
		||||
  croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321
 | 
			
		||||
  device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
 | 
			
		||||
  audioplayers_darwin: ccf9c770ee768abb07e26d90af093f7bab1c12ab
 | 
			
		||||
  connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
 | 
			
		||||
  croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30
 | 
			
		||||
  device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
 | 
			
		||||
  DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
 | 
			
		||||
  DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
 | 
			
		||||
  file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
 | 
			
		||||
  file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
 | 
			
		||||
  Firebase: a64bf6a8546e6eab54f1c715cd6151f39d2329f4
 | 
			
		||||
  firebase_analytics: 7236e6115c1b4e62c2270faa29c052a317e31107
 | 
			
		||||
  firebase_core: aa979ae726f00b3ef4ccf59dfb96170af84efbd4
 | 
			
		||||
  firebase_messaging: 3af84b6a90aeac4d7a67fbf4c43a91e7083bea1f
 | 
			
		||||
  FirebaseAnalytics: bc9e565af9044ba8d6c6e4157e4edca8e5fdf7ec
 | 
			
		||||
  FirebaseCore: 3227e35f4197a924206fbcdc0349325baf4f5de4
 | 
			
		||||
  FirebaseCoreInternal: d6c17dafc8dc33614733a8b52df78fcb4394c881
 | 
			
		||||
  FirebaseInstallations: 9347e719c3d52d8d7b9074b2c32407dd027305e9
 | 
			
		||||
  FirebaseMessaging: 00ece041b71ddb52a2862ffdee73fb6e9824bd0c
 | 
			
		||||
  fast_rsa: d99f8e1809a4a312fa9216d830186869b2e9eb65
 | 
			
		||||
  file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
 | 
			
		||||
  file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
 | 
			
		||||
  Firebase: d80354ed7f6df5f9aca55e9eb47cc4b634735eaf
 | 
			
		||||
  firebase_analytics: 4e93dbe66872104d28ae9750fec1800e1fd66858
 | 
			
		||||
  firebase_core: 8d552814f6c01ccde5d88939fced4ec26f2f5510
 | 
			
		||||
  firebase_messaging: 8b96a4f09841c15a16b96973ef5c3dcfc1a064e4
 | 
			
		||||
  FirebaseAnalytics: 4fd42def128146e24e480e89f310e3d8534ea42b
 | 
			
		||||
  FirebaseCore: 99fe0c4b44a39f37d99e6404e02009d2db5d718d
 | 
			
		||||
  FirebaseCoreInternal: df24ce5af28864660ecbd13596fc8dd3a8c34629
 | 
			
		||||
  FirebaseInstallations: 6c963bd2a86aca0481eef4f48f5a4df783ae5917
 | 
			
		||||
  FirebaseMessaging: 487b634ccdf6f7b7ff180fdcb2a9935490f764e8
 | 
			
		||||
  Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
 | 
			
		||||
  flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc
 | 
			
		||||
  flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4
 | 
			
		||||
  flutter_native_splash: f71420956eb811e6d310720fee915f1d42852e7a
 | 
			
		||||
  flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab
 | 
			
		||||
  flutter_webrtc: 90260f83024b1b96d239a575ea4e3708e79344d1
 | 
			
		||||
  gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
 | 
			
		||||
  GoogleAppMeasurement: 0471a5b5bff51f3a91b1e76df22c952d04c63967
 | 
			
		||||
  flutter_app_update: 816fdb2e30e4832a7c45e3f108d391c42ef040a9
 | 
			
		||||
  flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99
 | 
			
		||||
  flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
 | 
			
		||||
  flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544
 | 
			
		||||
  flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9
 | 
			
		||||
  flutter_webrtc: 57f32415b8744e806f9c2a96ccdb60c6a627ba33
 | 
			
		||||
  gal: baecd024ebfd13c441269ca7404792a7152fde89
 | 
			
		||||
  GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896
 | 
			
		||||
  GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
 | 
			
		||||
  GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
 | 
			
		||||
  home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
 | 
			
		||||
  image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
 | 
			
		||||
  in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
 | 
			
		||||
  home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
 | 
			
		||||
  image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
 | 
			
		||||
  in_app_review: 5596fe56fab799e8edb3561c03d053363ab13457
 | 
			
		||||
  Kingfisher: 323e5c4ec7983aaace12af655a7b51a7f88a599d
 | 
			
		||||
  livekit_client: 148b2cf67a09aaf475ba8e5bf1667fe10dc35f81
 | 
			
		||||
  media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
 | 
			
		||||
  media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
 | 
			
		||||
  media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
 | 
			
		||||
  livekit_client: 08755cabfa4da4ed455642f460cfbb39bc518070
 | 
			
		||||
  media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
 | 
			
		||||
  media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
 | 
			
		||||
  nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
 | 
			
		||||
  OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
 | 
			
		||||
  package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
 | 
			
		||||
  pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0
 | 
			
		||||
  path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
 | 
			
		||||
  permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
 | 
			
		||||
  package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
 | 
			
		||||
  pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c
 | 
			
		||||
  path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
 | 
			
		||||
  permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
 | 
			
		||||
  PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
 | 
			
		||||
  receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1
 | 
			
		||||
  receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
 | 
			
		||||
  SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
 | 
			
		||||
  screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
 | 
			
		||||
  SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8
 | 
			
		||||
  share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
 | 
			
		||||
  shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
 | 
			
		||||
  sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
 | 
			
		||||
  SDWebImage: 33d0f23bddeb5d209ae959153883247be6703713
 | 
			
		||||
  share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
 | 
			
		||||
  shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
 | 
			
		||||
  sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
 | 
			
		||||
  sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
 | 
			
		||||
  sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2
 | 
			
		||||
  SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
 | 
			
		||||
  url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
 | 
			
		||||
  video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe
 | 
			
		||||
  volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
 | 
			
		||||
  wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56
 | 
			
		||||
  url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
 | 
			
		||||
  video_compress: f2133a07762889d67f0711ac831faa26f956980e
 | 
			
		||||
  volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12
 | 
			
		||||
  wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49
 | 
			
		||||
  WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db
 | 
			
		||||
  workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
 | 
			
		||||
  workmanager: 01be2de7f184bd15de93a1812936a2b7f42ef07e
 | 
			
		||||
 | 
			
		||||
PODFILE CHECKSUM: 9b244e02f87527430136c8d21cbdcf1cd586b6bc
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -59,6 +59,7 @@
 | 
			
		||||
      ignoresPersistentStateOnLaunch = "NO"
 | 
			
		||||
      debugDocumentVersioning = "YES"
 | 
			
		||||
      debugServiceExtension = "internal"
 | 
			
		||||
      enableGPUValidationMode = "1"
 | 
			
		||||
      allowLocationSimulation = "YES">
 | 
			
		||||
      <BuildableProductRunnable
 | 
			
		||||
         runnableDebuggingMode = "0">
 | 
			
		||||
 
 | 
			
		||||
@@ -79,6 +79,8 @@
 | 
			
		||||
		<string>UIInterfaceOrientationLandscapeLeft</string>
 | 
			
		||||
		<string>UIInterfaceOrientationLandscapeRight</string>
 | 
			
		||||
	</array>
 | 
			
		||||
	<key>LSSupportsOpeningDocumentsInPlace</key>
 | 
			
		||||
	<true/>
 | 
			
		||||
	<key>UISupportedInterfaceOrientations~ipad</key>
 | 
			
		||||
	<array>
 | 
			
		||||
		<string>UIInterfaceOrientationPortrait</string>
 | 
			
		||||
 
 | 
			
		||||
@@ -2,11 +2,15 @@ import 'dart:async';
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
import 'dart:math' as math;
 | 
			
		||||
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:dio/dio.dart';
 | 
			
		||||
import 'package:drift/drift.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hive/hive.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_network.dart';
 | 
			
		||||
import 'package:surface/providers/user_directory.dart';
 | 
			
		||||
@@ -16,13 +20,15 @@ import 'package:surface/types/websocket.dart';
 | 
			
		||||
import 'package:uuid/uuid.dart';
 | 
			
		||||
 | 
			
		||||
class ChatMessageController extends ChangeNotifier {
 | 
			
		||||
  static const kChatMessageBoxPrefix = 'nex_chat_messages_';
 | 
			
		||||
  static const kSingleBatchLoadLimit = 100;
 | 
			
		||||
 | 
			
		||||
  late final SnNetworkProvider _sn;
 | 
			
		||||
  late final UserDirectoryProvider _ud;
 | 
			
		||||
  late final WebSocketProvider _ws;
 | 
			
		||||
  late final SnAttachmentProvider _attach;
 | 
			
		||||
  late final DatabaseProvider _dt;
 | 
			
		||||
  late final ChatChannelProvider _ct;
 | 
			
		||||
  late final KeyPairProvider _kp;
 | 
			
		||||
 | 
			
		||||
  StreamSubscription? _wsSubscription;
 | 
			
		||||
 | 
			
		||||
@@ -31,16 +37,20 @@ class ChatMessageController extends ChangeNotifier {
 | 
			
		||||
    _ud = context.read<UserDirectoryProvider>();
 | 
			
		||||
    _ws = context.read<WebSocketProvider>();
 | 
			
		||||
    _attach = context.read<SnAttachmentProvider>();
 | 
			
		||||
    _ct = context.read<ChatChannelProvider>();
 | 
			
		||||
    _dt = context.read<DatabaseProvider>();
 | 
			
		||||
    _kp = context.read<KeyPairProvider>();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bool isPending = true;
 | 
			
		||||
  bool isLoading = false;
 | 
			
		||||
  bool isAggressiveLoading = false;
 | 
			
		||||
 | 
			
		||||
  int? messageTotal;
 | 
			
		||||
 | 
			
		||||
  bool get isAllLoaded => messageTotal != null && messages.length >= messageTotal!;
 | 
			
		||||
  bool get isAllLoaded =>
 | 
			
		||||
      messageTotal != null && messages.length >= messageTotal!;
 | 
			
		||||
 | 
			
		||||
  String? _boxKey;
 | 
			
		||||
  SnChannel? channel;
 | 
			
		||||
  SnChannelMember? profile;
 | 
			
		||||
 | 
			
		||||
@@ -51,25 +61,14 @@ class ChatMessageController extends ChangeNotifier {
 | 
			
		||||
  /// Stored as a list of nonce to provide the loading state
 | 
			
		||||
  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 Map<int, Timer> typingInactiveTimer = {};
 | 
			
		||||
 | 
			
		||||
  Future<void> initialize(SnChannel chan) async {
 | 
			
		||||
    channel = chan;
 | 
			
		||||
 | 
			
		||||
    // Initialize local data
 | 
			
		||||
    _boxKey = '$kChatMessageBoxPrefix${chan.id}';
 | 
			
		||||
    await Hive.openBox<SnChatMessage>(_boxKey!);
 | 
			
		||||
 | 
			
		||||
    // Fetch channel profile
 | 
			
		||||
    final resp = await _sn.client.get(
 | 
			
		||||
      '/cgi/im/channels/${chan.keyPath}/me',
 | 
			
		||||
    );
 | 
			
		||||
    profile = SnChannelMember.fromJson(
 | 
			
		||||
      resp.data as Map<String, dynamic>,
 | 
			
		||||
    );
 | 
			
		||||
    profile = await _ct.getChannelProfile(channel!);
 | 
			
		||||
 | 
			
		||||
    _wsSubscription = _ws.pk.stream.listen((event) {
 | 
			
		||||
      switch (event.method) {
 | 
			
		||||
@@ -87,7 +86,8 @@ class ChatMessageController extends ChangeNotifier {
 | 
			
		||||
            notifyListeners();
 | 
			
		||||
          }
 | 
			
		||||
          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);
 | 
			
		||||
            typingInactiveTimer.remove(member.id);
 | 
			
		||||
            notifyListeners();
 | 
			
		||||
@@ -129,10 +129,16 @@ class ChatMessageController extends ChangeNotifier {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _saveMessageToLocal(Iterable<SnChatMessage> messages) async {
 | 
			
		||||
    if (_box == null) return;
 | 
			
		||||
    await _box!.putAll({
 | 
			
		||||
      for (final message in messages) message.id: message,
 | 
			
		||||
    });
 | 
			
		||||
    await _dt.db.snLocalChatMessage.insertAll(
 | 
			
		||||
        messages.map(
 | 
			
		||||
          (ele) => SnLocalChatMessageCompanion.insert(
 | 
			
		||||
            id: Value(ele.id),
 | 
			
		||||
            content: ele,
 | 
			
		||||
            channelId: channel!.id,
 | 
			
		||||
            createdAt: Value(ele.createdAt),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        onConflict: DoNothing());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _addUnconfirmedMessage(SnChatMessage message) async {
 | 
			
		||||
@@ -181,11 +187,27 @@ class ChatMessageController extends ChangeNotifier {
 | 
			
		||||
    } else {
 | 
			
		||||
      messages.insert(0, message);
 | 
			
		||||
    }
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
    await _applyMessage(message);
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
 | 
			
		||||
    if (_box == null) return;
 | 
			
		||||
    await _box!.put(message.id, message);
 | 
			
		||||
    if (isCheckedUpdate) {
 | 
			
		||||
      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 {
 | 
			
		||||
@@ -194,29 +216,56 @@ class ChatMessageController extends ChangeNotifier {
 | 
			
		||||
    switch (message.type) {
 | 
			
		||||
      case 'messages.edit':
 | 
			
		||||
        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) {
 | 
			
		||||
            final newBody = message.body;
 | 
			
		||||
            final newBody = Map<String, dynamic>.from(message.body);
 | 
			
		||||
            newBody.remove('related_event');
 | 
			
		||||
            messages[idx] = messages[idx].copyWith(
 | 
			
		||||
              body: newBody,
 | 
			
		||||
              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':
 | 
			
		||||
        if (message.relatedEventId != null) {
 | 
			
		||||
          messages.removeWhere((x) => x.id == message.relatedEventId);
 | 
			
		||||
          if (_box!.containsKey(message.relatedEventId)) {
 | 
			
		||||
            await _box!.delete(message.relatedEventId);
 | 
			
		||||
          if (message.relatedEventId != null) {
 | 
			
		||||
            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(
 | 
			
		||||
    String type,
 | 
			
		||||
    String content, {
 | 
			
		||||
@@ -224,36 +273,40 @@ class ChatMessageController extends ChangeNotifier {
 | 
			
		||||
    int? relatedId,
 | 
			
		||||
    List<String>? attachments,
 | 
			
		||||
    SnChatMessage? editingMessage,
 | 
			
		||||
    bool isEncrypted = false,
 | 
			
		||||
  }) async {
 | 
			
		||||
    if (channel == null) return;
 | 
			
		||||
    const uuid = Uuid();
 | 
			
		||||
    final nonce = uuid.v4();
 | 
			
		||||
    final body = {
 | 
			
		||||
      'text': content,
 | 
			
		||||
      'algorithm': 'plain',
 | 
			
		||||
      ...(await _encodeMessageBody(content, isEncrypted)),
 | 
			
		||||
      if (quoteId != null) 'quote_event': quoteId,
 | 
			
		||||
      if (relatedId != null) 'related_event': relatedId,
 | 
			
		||||
      if (attachments != null && attachments.isNotEmpty) 'attachments': attachments,
 | 
			
		||||
      if (attachments != null && attachments.isNotEmpty)
 | 
			
		||||
        'attachments': attachments,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Mock the message locally
 | 
			
		||||
    final createdAt = DateTime.now();
 | 
			
		||||
    final message = SnChatMessage(
 | 
			
		||||
      id: 0,
 | 
			
		||||
      createdAt: createdAt,
 | 
			
		||||
      updatedAt: createdAt,
 | 
			
		||||
      deletedAt: null,
 | 
			
		||||
      uuid: nonce,
 | 
			
		||||
      body: body,
 | 
			
		||||
      type: type,
 | 
			
		||||
      channel: channel!,
 | 
			
		||||
      channelId: channel!.id,
 | 
			
		||||
      sender: profile!,
 | 
			
		||||
      senderId: profile!.id,
 | 
			
		||||
      quoteEventId: quoteId,
 | 
			
		||||
      relatedEventId: relatedId,
 | 
			
		||||
    );
 | 
			
		||||
    _addUnconfirmedMessage(message);
 | 
			
		||||
    // Do not mock the editing message
 | 
			
		||||
    if (editingMessage == null) {
 | 
			
		||||
      final createdAt = DateTime.now();
 | 
			
		||||
      final message = SnChatMessage(
 | 
			
		||||
        id: 0,
 | 
			
		||||
        createdAt: createdAt,
 | 
			
		||||
        updatedAt: createdAt,
 | 
			
		||||
        deletedAt: null,
 | 
			
		||||
        uuid: nonce,
 | 
			
		||||
        body: body,
 | 
			
		||||
        type: type,
 | 
			
		||||
        channel: channel!,
 | 
			
		||||
        channelId: channel!.id,
 | 
			
		||||
        sender: profile!,
 | 
			
		||||
        senderId: profile!.id,
 | 
			
		||||
        quoteEventId: quoteId,
 | 
			
		||||
        relatedEventId: relatedId,
 | 
			
		||||
      );
 | 
			
		||||
      _addUnconfirmedMessage(message);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Send to server
 | 
			
		||||
    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.
 | 
			
		||||
  /// If the local storage is not up to date, it will be updated.
 | 
			
		||||
  Future<void> checkUpdate() async {
 | 
			
		||||
    if (_box == null) return;
 | 
			
		||||
    if (_box!.isEmpty) return;
 | 
			
		||||
 | 
			
		||||
    isLoading = true;
 | 
			
		||||
    isAggressiveLoading = true;
 | 
			
		||||
    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 {
 | 
			
		||||
      final resp = await _sn.client.get(
 | 
			
		||||
        '/cgi/im/channels/${channel!.keyPath}/events/update',
 | 
			
		||||
        queryParameters: {
 | 
			
		||||
          'pivot': _box!.values.last.id,
 | 
			
		||||
          'pivot': mostRecentMessage.content.id,
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
      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);
 | 
			
		||||
 | 
			
		||||
      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) {
 | 
			
		||||
      rethrow;
 | 
			
		||||
    } finally {
 | 
			
		||||
      await loadMessages();
 | 
			
		||||
      isLoading = false;
 | 
			
		||||
      isAggressiveLoading = false;
 | 
			
		||||
 | 
			
		||||
      isCheckedUpdate = true;
 | 
			
		||||
      _saveMessageToLocal(incomeStrandedQueue).then((_) {
 | 
			
		||||
        incomeStrandedQueue.clear();
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      notifyListeners();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@@ -324,13 +405,18 @@ class ChatMessageController extends ChangeNotifier {
 | 
			
		||||
  /// If it was not found in local storage we will look it up in remote
 | 
			
		||||
  Future<SnChatMessage?> getMessage(int id) async {
 | 
			
		||||
    SnChatMessage? out;
 | 
			
		||||
    if (_box != null && _box!.containsKey(id)) {
 | 
			
		||||
      out = _box!.get(id);
 | 
			
		||||
    final local = await (_dt.db.snLocalChatMessage.select()
 | 
			
		||||
          ..limit(1)
 | 
			
		||||
          ..where((e) => e.id.equals(id)))
 | 
			
		||||
        .getSingleOrNull();
 | 
			
		||||
    if (local != null) {
 | 
			
		||||
      out = local.content;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (out == null) {
 | 
			
		||||
      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);
 | 
			
		||||
        _saveMessageToLocal([out]);
 | 
			
		||||
      } catch (_) {
 | 
			
		||||
@@ -364,16 +450,21 @@ class ChatMessageController extends ChangeNotifier {
 | 
			
		||||
    bool forceLocal = false,
 | 
			
		||||
    bool forceRemote = false,
 | 
			
		||||
  }) async {
 | 
			
		||||
    final localTotal = await _dt.db.snLocalChatMessage
 | 
			
		||||
        .count(where: (e) => e.channelId.equals(channel!.id))
 | 
			
		||||
        .getSingle();
 | 
			
		||||
 | 
			
		||||
    late List<SnChatMessage> out;
 | 
			
		||||
    if (_box != null && (_box!.length >= take + offset || forceLocal) && !forceRemote) {
 | 
			
		||||
      out = _box!.keys
 | 
			
		||||
          .toList()
 | 
			
		||||
          .cast<int>()
 | 
			
		||||
          .sorted((a, b) => b.compareTo(a))
 | 
			
		||||
          .skip(offset)
 | 
			
		||||
          .take(take)
 | 
			
		||||
          .map((key) => _box!.get(key)!)
 | 
			
		||||
          .toList();
 | 
			
		||||
    if ((localTotal >= take + offset || forceLocal) && !forceRemote) {
 | 
			
		||||
      final result = await (_dt.db.snLocalChatMessage.select()
 | 
			
		||||
            ..where((e) => e.channelId.equals(channel!.id))
 | 
			
		||||
            ..orderBy([
 | 
			
		||||
              (e) =>
 | 
			
		||||
                  OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc)
 | 
			
		||||
            ])
 | 
			
		||||
            ..limit(take, offset: offset))
 | 
			
		||||
          .get();
 | 
			
		||||
      out = result.map((e) => e.content).toList();
 | 
			
		||||
    } else {
 | 
			
		||||
      final resp = await _sn.client.get(
 | 
			
		||||
        '/cgi/im/channels/${channel!.keyPath}/events',
 | 
			
		||||
@@ -408,7 +499,8 @@ class ChatMessageController extends ChangeNotifier {
 | 
			
		||||
          quoteEvent: quoteEvent,
 | 
			
		||||
          attachments: attachments
 | 
			
		||||
              .where(
 | 
			
		||||
                (ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false,
 | 
			
		||||
                (ele) =>
 | 
			
		||||
                    out[i].body['attachments']?.contains(ele?.rid) ?? false,
 | 
			
		||||
              )
 | 
			
		||||
              .toList(),
 | 
			
		||||
        ),
 | 
			
		||||
@@ -416,7 +508,10 @@ class ChatMessageController extends ChangeNotifier {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 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);
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    _box?.close();
 | 
			
		||||
    _wsSubscription?.cancel();
 | 
			
		||||
    if (_readEventDebounce?.isActive ?? false) {
 | 
			
		||||
      _sendReadEvent();
 | 
			
		||||
    }
 | 
			
		||||
    _readEventDebounce?.cancel();
 | 
			
		||||
    super.dispose();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -18,6 +18,7 @@ import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/types/attachment.dart';
 | 
			
		||||
import 'package:surface/types/poll.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/types/realm.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
import 'package:video_compress/video_compress.dart';
 | 
			
		||||
@@ -70,7 +71,8 @@ class PostWriteMedia {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  PostWriteMedia.fromBytes(this.raw, this.name, this.type, {this.attachment, this.file});
 | 
			
		||||
  PostWriteMedia.fromBytes(this.raw, this.name, this.type,
 | 
			
		||||
      {this.attachment, this.file});
 | 
			
		||||
 | 
			
		||||
  bool get isEmpty => attachment == null && file == null && raw == null;
 | 
			
		||||
 | 
			
		||||
@@ -104,7 +106,8 @@ class PostWriteMedia {
 | 
			
		||||
  }) {
 | 
			
		||||
    if (attachment != null) {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final ImageProvider provider = UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
 | 
			
		||||
      final ImageProvider provider =
 | 
			
		||||
          UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
 | 
			
		||||
      if (width != null && height != null && !kIsWeb) {
 | 
			
		||||
        return ResizeImage(
 | 
			
		||||
          provider,
 | 
			
		||||
@@ -115,7 +118,8 @@ class PostWriteMedia {
 | 
			
		||||
      }
 | 
			
		||||
      return provider;
 | 
			
		||||
    } else if (file != null) {
 | 
			
		||||
      final ImageProvider provider = kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path));
 | 
			
		||||
      final ImageProvider provider =
 | 
			
		||||
          kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path));
 | 
			
		||||
      if (width != null && height != null) {
 | 
			
		||||
        return ResizeImage(
 | 
			
		||||
          provider,
 | 
			
		||||
@@ -158,13 +162,17 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
  final TextEditingController aliasController = TextEditingController();
 | 
			
		||||
  final TextEditingController rewardController = TextEditingController();
 | 
			
		||||
 | 
			
		||||
  ContentInsertionConfiguration get contentInsertionConfiguration => ContentInsertionConfiguration(
 | 
			
		||||
    onContentInserted: (KeyboardInsertedContent content) {
 | 
			
		||||
      if (content.hasData) {
 | 
			
		||||
        addAttachments([PostWriteMedia.fromBytes(content.data!, 'attachmentInsertedImage'.tr(), SnMediaType.image)]);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
  );
 | 
			
		||||
  ContentInsertionConfiguration get contentInsertionConfiguration =>
 | 
			
		||||
      ContentInsertionConfiguration(
 | 
			
		||||
        onContentInserted: (KeyboardInsertedContent content) {
 | 
			
		||||
          if (content.hasData) {
 | 
			
		||||
            addAttachments([
 | 
			
		||||
              PostWriteMedia.fromBytes(content.data!,
 | 
			
		||||
                  'attachmentInsertedImage'.tr(), SnMediaType.image)
 | 
			
		||||
            ]);
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
  bool _temporarySaveActive = false;
 | 
			
		||||
 | 
			
		||||
@@ -191,13 +199,16 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
 | 
			
		||||
  String get description => descriptionController.text;
 | 
			
		||||
 | 
			
		||||
  bool get isRelatedNull => ![editingPost, repostingPost, replyingPost].any((ele) => ele != null);
 | 
			
		||||
  bool get isRelatedNull =>
 | 
			
		||||
      ![editingPost, repostingPost, replyingPost].any((ele) => ele != null);
 | 
			
		||||
 | 
			
		||||
  bool isLoading = false, isBusy = false;
 | 
			
		||||
  double? progress;
 | 
			
		||||
 | 
			
		||||
  SnRealm? realm;
 | 
			
		||||
  SnPublisher? publisher;
 | 
			
		||||
  SnPost? editingPost, repostingPost, replyingPost;
 | 
			
		||||
  bool editingDraft = false;
 | 
			
		||||
 | 
			
		||||
  int visibility = 0;
 | 
			
		||||
  List<int> visibleUsers = List.empty();
 | 
			
		||||
@@ -234,16 +245,25 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
        publishedAt = post.publishedAt;
 | 
			
		||||
        publishedUntil = post.publishedUntil;
 | 
			
		||||
        visibleUsers = List.from(post.visibleUsersList ?? [], growable: true);
 | 
			
		||||
        invisibleUsers = List.from(post.invisibleUsersList ?? [], growable: true);
 | 
			
		||||
        invisibleUsers =
 | 
			
		||||
            List.from(post.invisibleUsersList ?? [], growable: true);
 | 
			
		||||
        visibility = post.visibility;
 | 
			
		||||
        tags = List.from(post.tags.map((ele) => ele.alias), growable: true);
 | 
			
		||||
        categories = List.from(post.categories.map((ele) => ele.alias), growable: true);
 | 
			
		||||
        attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
 | 
			
		||||
        categories =
 | 
			
		||||
            List.from(post.categories.map((ele) => ele.alias), growable: true);
 | 
			
		||||
        attachments.addAll(
 | 
			
		||||
            post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
 | 
			
		||||
        poll = post.preload?.poll;
 | 
			
		||||
 | 
			
		||||
        if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
 | 
			
		||||
        editingDraft = post.isDraft;
 | 
			
		||||
 | 
			
		||||
        if (post.preload?.thumbnail != null &&
 | 
			
		||||
            (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
 | 
			
		||||
          thumbnail = PostWriteMedia(post.preload!.thumbnail);
 | 
			
		||||
        }
 | 
			
		||||
        if (post.preload?.realm != null) {
 | 
			
		||||
          realm = post.preload!.realm!;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        editingPost = post;
 | 
			
		||||
      }
 | 
			
		||||
@@ -266,7 +286,8 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<SnAttachment> _uploadAttachment(BuildContext context, PostWriteMedia media,
 | 
			
		||||
  Future<SnAttachment> _uploadAttachment(
 | 
			
		||||
      BuildContext context, PostWriteMedia media,
 | 
			
		||||
      {bool isCompressed = false}) async {
 | 
			
		||||
    final attach = context.read<SnAttachmentProvider>();
 | 
			
		||||
 | 
			
		||||
@@ -275,7 +296,9 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
      media.name,
 | 
			
		||||
      'interactive',
 | 
			
		||||
      null,
 | 
			
		||||
      mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null,
 | 
			
		||||
      mimetype: media.raw != null && media.type == SnMediaType.image
 | 
			
		||||
          ? 'image/png'
 | 
			
		||||
          : null,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    var item = await attach.chunkedUploadParts(
 | 
			
		||||
@@ -291,9 +314,11 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
 | 
			
		||||
    if (media.type == SnMediaType.video && !isCompressed && context.mounted) {
 | 
			
		||||
      try {
 | 
			
		||||
        final compressedAttachment = await _tryCompressVideoCopy(context, media);
 | 
			
		||||
        final compressedAttachment =
 | 
			
		||||
            await _tryCompressVideoCopy(context, media);
 | 
			
		||||
        if (compressedAttachment != null) {
 | 
			
		||||
          item = await attach.updateOne(item, compressedId: compressedAttachment.id);
 | 
			
		||||
          item = await attach.updateOne(item,
 | 
			
		||||
              compressedId: compressedAttachment.id);
 | 
			
		||||
        }
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        if (context.mounted) context.showErrorDialog(err);
 | 
			
		||||
@@ -303,8 +328,10 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
    return item;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<SnAttachment?> _tryCompressVideoCopy(BuildContext context, PostWriteMedia media) async {
 | 
			
		||||
    if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) return null;
 | 
			
		||||
  Future<SnAttachment?> _tryCompressVideoCopy(
 | 
			
		||||
      BuildContext context, PostWriteMedia media) async {
 | 
			
		||||
    if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS))
 | 
			
		||||
      return null;
 | 
			
		||||
    if (media.type != SnMediaType.video) return null;
 | 
			
		||||
    if (media.file == null) return null;
 | 
			
		||||
    if (VideoCompress.isCompressing) return null;
 | 
			
		||||
@@ -328,7 +355,8 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
    if (!context.mounted) return null;
 | 
			
		||||
 | 
			
		||||
    final compressedMedia = PostWriteMedia.fromFile(XFile(mediaInfo.path!));
 | 
			
		||||
    final compressedAttachment = await _uploadAttachment(context, compressedMedia, isCompressed: true);
 | 
			
		||||
    final compressedAttachment =
 | 
			
		||||
        await _uploadAttachment(context, compressedMedia, isCompressed: true);
 | 
			
		||||
 | 
			
		||||
    return compressedAttachment;
 | 
			
		||||
  }
 | 
			
		||||
@@ -364,26 +392,40 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
          'content': contentController.text,
 | 
			
		||||
          if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
 | 
			
		||||
          if (titleController.text.isNotEmpty) 'title': titleController.text,
 | 
			
		||||
          if (descriptionController.text.isNotEmpty) 'description': descriptionController.text,
 | 
			
		||||
          if (descriptionController.text.isNotEmpty)
 | 
			
		||||
            'description': descriptionController.text,
 | 
			
		||||
          if (rewardController.text.isNotEmpty) 'reward': rewardController.text,
 | 
			
		||||
          if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.toJson(),
 | 
			
		||||
          'attachments':
 | 
			
		||||
              attachments.where((e) => e.attachment != null).map((e) => e.attachment!.toJson()).toList(growable: true),
 | 
			
		||||
          if (thumbnail != null && thumbnail!.attachment != null)
 | 
			
		||||
            'thumbnail': thumbnail!.attachment!.toJson(),
 | 
			
		||||
          'attachments': attachments
 | 
			
		||||
              .where((e) => e.attachment != null)
 | 
			
		||||
              .map((e) => e.attachment!.toJson())
 | 
			
		||||
              .toList(growable: true),
 | 
			
		||||
          'tags': tags.map((ele) => {'alias': ele}).toList(growable: true),
 | 
			
		||||
          'categories': categories.map((ele) => {'alias': ele}).toList(growable: true),
 | 
			
		||||
          'categories':
 | 
			
		||||
              categories.map((ele) => {'alias': ele}).toList(growable: true),
 | 
			
		||||
          'visibility': visibility,
 | 
			
		||||
          'visible_users_list': visibleUsers,
 | 
			
		||||
          'invisible_users_list': invisibleUsers,
 | 
			
		||||
          if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(),
 | 
			
		||||
          if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
 | 
			
		||||
          if (publishedAt != null)
 | 
			
		||||
            'published_at': publishedAt!.toUtc().toIso8601String(),
 | 
			
		||||
          if (publishedUntil != null)
 | 
			
		||||
            'published_until': publishedAt!.toUtc().toIso8601String(),
 | 
			
		||||
          if (replyingPost != null) 'reply_to': replyingPost!.toJson(),
 | 
			
		||||
          if (repostingPost != null) 'repost_to': repostingPost!.toJson(),
 | 
			
		||||
          if (poll != null) 'poll': poll!.toJson(),
 | 
			
		||||
          if (realm != null) 'realm': realm!.toJson(),
 | 
			
		||||
        }),
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bool get isNotEmpty =>
 | 
			
		||||
      title.isNotEmpty ||
 | 
			
		||||
      description.isNotEmpty ||
 | 
			
		||||
      contentController.text.isNotEmpty ||
 | 
			
		||||
      attachments.isNotEmpty;
 | 
			
		||||
 | 
			
		||||
  bool temporaryRestored = false;
 | 
			
		||||
 | 
			
		||||
  void _temporaryLoad() {
 | 
			
		||||
@@ -396,19 +438,26 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
      titleController.text = data['title'] ?? '';
 | 
			
		||||
      descriptionController.text = data['description'] ?? '';
 | 
			
		||||
      rewardController.text = data['reward']?.toString() ?? '';
 | 
			
		||||
      if (data['thumbnail'] != null) thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail']));
 | 
			
		||||
      attachments
 | 
			
		||||
          .addAll(data['attachments'].map((ele) => PostWriteMedia(SnAttachment.fromJson(ele))).cast<PostWriteMedia>());
 | 
			
		||||
      if (data['thumbnail'] != null)
 | 
			
		||||
        thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail']));
 | 
			
		||||
      attachments.addAll(data['attachments']
 | 
			
		||||
          .map((ele) => PostWriteMedia(SnAttachment.fromJson(ele)))
 | 
			
		||||
          .cast<PostWriteMedia>());
 | 
			
		||||
      tags = List.from(data['tags'].map((ele) => ele['alias']));
 | 
			
		||||
      categories = List.from(data['categories'].map((ele) => ele['alias']));
 | 
			
		||||
      visibility = data['visibility'];
 | 
			
		||||
      visibleUsers = List.from(data['visible_users_list'] ?? []);
 | 
			
		||||
      invisibleUsers = List.from(data['invisible_users_list'] ?? []);
 | 
			
		||||
      if (data['published_at'] != null) publishedAt = DateTime.tryParse(data['published_at'])?.toLocal();
 | 
			
		||||
      if (data['published_until'] != null) publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal();
 | 
			
		||||
      replyingPost = data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null;
 | 
			
		||||
      repostingPost = data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null;
 | 
			
		||||
      if (data['published_at'] != null)
 | 
			
		||||
        publishedAt = DateTime.tryParse(data['published_at'])?.toLocal();
 | 
			
		||||
      if (data['published_until'] != null)
 | 
			
		||||
        publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal();
 | 
			
		||||
      replyingPost =
 | 
			
		||||
          data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null;
 | 
			
		||||
      repostingPost =
 | 
			
		||||
          data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null;
 | 
			
		||||
      poll = data['poll'] != null ? SnPoll.fromJson(data['poll']) : null;
 | 
			
		||||
      realm = data['realm'] != null ? SnRealm.fromJson(data['realm']) : null;
 | 
			
		||||
      temporaryRestored = true;
 | 
			
		||||
      notifyListeners();
 | 
			
		||||
    });
 | 
			
		||||
@@ -428,7 +477,10 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> sendPost(BuildContext context) async {
 | 
			
		||||
  Future<void> sendPost(
 | 
			
		||||
    BuildContext context, {
 | 
			
		||||
    bool saveAsDraft = false,
 | 
			
		||||
  }) async {
 | 
			
		||||
    if (isBusy || publisher == null) return;
 | 
			
		||||
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
@@ -455,7 +507,9 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
          media.name,
 | 
			
		||||
          'interactive',
 | 
			
		||||
          null,
 | 
			
		||||
          mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null,
 | 
			
		||||
          mimetype: media.raw != null && media.type == SnMediaType.image
 | 
			
		||||
              ? 'image/png'
 | 
			
		||||
              : null,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        var item = await attach.chunkedUploadParts(
 | 
			
		||||
@@ -464,16 +518,20 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
          place.$2,
 | 
			
		||||
          onProgress: (value) {
 | 
			
		||||
            // Calculate overall progress for attachments
 | 
			
		||||
            progress = math.max(((i + value) / attachments.length) * kAttachmentProgressWeight, value);
 | 
			
		||||
            progress = math.max(
 | 
			
		||||
                ((i + value) / attachments.length) * kAttachmentProgressWeight,
 | 
			
		||||
                value);
 | 
			
		||||
            notifyListeners();
 | 
			
		||||
          },
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
          if (context.mounted) {
 | 
			
		||||
            final compressedAttachment = await _tryCompressVideoCopy(context, media);
 | 
			
		||||
            final compressedAttachment =
 | 
			
		||||
                await _tryCompressVideoCopy(context, media);
 | 
			
		||||
            if (compressedAttachment != null) {
 | 
			
		||||
              item = await attach.updateOne(item, compressedId: compressedAttachment.id);
 | 
			
		||||
              item = await attach.updateOne(item,
 | 
			
		||||
                  compressedId: compressedAttachment.id);
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        } catch (err) {
 | 
			
		||||
@@ -500,7 +558,7 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
    // Posting the content
 | 
			
		||||
    try {
 | 
			
		||||
      final baseProgressVal = progress!;
 | 
			
		||||
      await sn.client.request(
 | 
			
		||||
      final resp = await sn.client.request(
 | 
			
		||||
        [
 | 
			
		||||
          '/cgi/co/$mode',
 | 
			
		||||
          if (editingPost != null) '${editingPost!.id}',
 | 
			
		||||
@@ -510,35 +568,56 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
          'content': contentController.text,
 | 
			
		||||
          if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
 | 
			
		||||
          if (titleController.text.isNotEmpty) 'title': titleController.text,
 | 
			
		||||
          if (descriptionController.text.isNotEmpty) 'description': descriptionController.text,
 | 
			
		||||
          if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid,
 | 
			
		||||
          'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(),
 | 
			
		||||
          if (descriptionController.text.isNotEmpty)
 | 
			
		||||
            'description': descriptionController.text,
 | 
			
		||||
          if (thumbnail != null && thumbnail!.attachment != null)
 | 
			
		||||
            'thumbnail': thumbnail!.attachment!.rid,
 | 
			
		||||
          'attachments': attachments
 | 
			
		||||
              .where((e) => e.attachment != null)
 | 
			
		||||
              .map((e) => e.attachment!.rid)
 | 
			
		||||
              .toList(),
 | 
			
		||||
          'tags': tags.map((ele) => {'alias': ele}).toList(),
 | 
			
		||||
          'categories': categories.map((ele) => {'alias': ele}).toList(),
 | 
			
		||||
          'visibility': visibility,
 | 
			
		||||
          'visible_users_list': visibleUsers,
 | 
			
		||||
          'invisible_users_list': invisibleUsers,
 | 
			
		||||
          if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(),
 | 
			
		||||
          if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
 | 
			
		||||
          if (publishedAt != null)
 | 
			
		||||
            'published_at': publishedAt!.toUtc().toIso8601String(),
 | 
			
		||||
          if (publishedUntil != null)
 | 
			
		||||
            'published_until': publishedAt!.toUtc().toIso8601String(),
 | 
			
		||||
          if (replyingPost != null) 'reply_to': replyingPost!.id,
 | 
			
		||||
          if (repostingPost != null) 'repost_to': repostingPost!.id,
 | 
			
		||||
          if (reward != null) 'reward': reward,
 | 
			
		||||
          if (videoAttachment != null) 'video': videoAttachment!.rid,
 | 
			
		||||
          if (poll != null) 'poll': poll!.id,
 | 
			
		||||
          if (realm != null) 'realm': realm!.id,
 | 
			
		||||
          'is_draft': saveAsDraft,
 | 
			
		||||
        },
 | 
			
		||||
        onSendProgress: (count, total) {
 | 
			
		||||
          progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
 | 
			
		||||
          progress =
 | 
			
		||||
              baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
 | 
			
		||||
          notifyListeners();
 | 
			
		||||
        },
 | 
			
		||||
        onReceiveProgress: (count, total) {
 | 
			
		||||
          progress = baseProgressVal + (kPostingProgressWeight / 2) + (count / total) * (kPostingProgressWeight / 2);
 | 
			
		||||
          progress = baseProgressVal +
 | 
			
		||||
              (kPostingProgressWeight / 2) +
 | 
			
		||||
              (count / total) * (kPostingProgressWeight / 2);
 | 
			
		||||
          notifyListeners();
 | 
			
		||||
        },
 | 
			
		||||
        options: Options(
 | 
			
		||||
          method: editingPost != null ? 'PUT' : 'POST',
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
      reset();
 | 
			
		||||
      if (saveAsDraft) {
 | 
			
		||||
        if (!context.mounted) return;
 | 
			
		||||
        editingDraft = true;
 | 
			
		||||
        final out = SnPost.fromJson(resp.data);
 | 
			
		||||
        final pt = context.read<SnPostContentProvider>();
 | 
			
		||||
        editingPost = await pt.completePostData(out);
 | 
			
		||||
        notifyListeners();
 | 
			
		||||
      } else {
 | 
			
		||||
        reset();
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!context.mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
@@ -571,17 +650,8 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setThumbnail(int? idx) {
 | 
			
		||||
    if (idx == null) {
 | 
			
		||||
      attachments.add(thumbnail!);
 | 
			
		||||
      thumbnail = null;
 | 
			
		||||
    } else {
 | 
			
		||||
      if (thumbnail != null) {
 | 
			
		||||
        attachments.add(thumbnail!);
 | 
			
		||||
      }
 | 
			
		||||
      thumbnail = attachments[idx];
 | 
			
		||||
      attachments.removeAt(idx);
 | 
			
		||||
    }
 | 
			
		||||
  void setThumbnail(SnAttachment? value) {
 | 
			
		||||
    thumbnail = value == null ? null : PostWriteMedia(value);
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -633,6 +703,11 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setRealm(SnRealm? value) {
 | 
			
		||||
    realm = value;
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setProgress(double? value) {
 | 
			
		||||
    progress = value;
 | 
			
		||||
    _temporaryPlanSave();
 | 
			
		||||
@@ -678,7 +753,8 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
    repostingPost = null;
 | 
			
		||||
    mode = kTitleMap.keys.first;
 | 
			
		||||
    temporaryRestored = false;
 | 
			
		||||
    SharedPreferences.getInstance().then((prefs) => prefs.remove(kTemporaryStorageKey));
 | 
			
		||||
    SharedPreferences.getInstance()
 | 
			
		||||
        .then((prefs) => prefs.remove(kTemporaryStorageKey));
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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()();
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										62
									
								
								lib/database/database.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								lib/database/database.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,62 @@
 | 
			
		||||
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/realm.dart';
 | 
			
		||||
import 'package:surface/database/sticker.dart';
 | 
			
		||||
import 'package:surface/types/chat.dart';
 | 
			
		||||
import 'package:surface/types/attachment.dart';
 | 
			
		||||
import 'package:surface/types/account.dart';
 | 
			
		||||
import 'package:surface/types/realm.dart';
 | 
			
		||||
 | 
			
		||||
part 'database.g.dart';
 | 
			
		||||
 | 
			
		||||
@DriftDatabase(tables: [
 | 
			
		||||
  SnLocalChatChannel,
 | 
			
		||||
  SnLocalChatMessage,
 | 
			
		||||
  SnLocalChannelMember,
 | 
			
		||||
  SnLocalKeyPair,
 | 
			
		||||
  SnLocalAccount,
 | 
			
		||||
  SnLocalAttachment,
 | 
			
		||||
  SnLocalSticker,
 | 
			
		||||
  SnLocalStickerPack,
 | 
			
		||||
  SnLocalRealm,
 | 
			
		||||
])
 | 
			
		||||
class AppDatabase extends _$AppDatabase {
 | 
			
		||||
  AppDatabase([QueryExecutor? e]) : super(e ?? _openConnection());
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get schemaVersion => 4;
 | 
			
		||||
 | 
			
		||||
  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
 | 
			
		||||
      }, from3To4: (m, schema) async {
 | 
			
		||||
        m.createTable(schema.snLocalRealm);
 | 
			
		||||
        m.createIndex(schema.idxRealmAccount);
 | 
			
		||||
        m.createIndex(schema.idxRealmAlias);
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										4452
									
								
								lib/database/database.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4452
									
								
								lib/database/database.g.dart
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										657
									
								
								lib/database/database.steps.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										657
									
								
								lib/database/database.steps.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,657 @@
 | 
			
		||||
// dart format width=80
 | 
			
		||||
import 'package:drift/internal/versioned_schema.dart' as i0;
 | 
			
		||||
import 'package:drift/drift.dart' as i1;
 | 
			
		||||
import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import
 | 
			
		||||
 | 
			
		||||
// GENERATED BY drift_dev, DO NOT MODIFY.
 | 
			
		||||
final class Schema2 extends i0.VersionedSchema {
 | 
			
		||||
  Schema2({required super.database}) : super(version: 2);
 | 
			
		||||
  @override
 | 
			
		||||
  late final List<i1.DatabaseSchemaEntity> entities = [
 | 
			
		||||
    snLocalChatChannel,
 | 
			
		||||
    snLocalChatMessage,
 | 
			
		||||
    snLocalKeyPair,
 | 
			
		||||
  ];
 | 
			
		||||
  late final Shape0 snLocalChatChannel = Shape0(
 | 
			
		||||
      source: i0.VersionedTable(
 | 
			
		||||
        entityName: 'sn_local_chat_channel',
 | 
			
		||||
        withoutRowId: false,
 | 
			
		||||
        isStrict: false,
 | 
			
		||||
        tableConstraints: [],
 | 
			
		||||
        columns: [
 | 
			
		||||
          _column_0,
 | 
			
		||||
          _column_1,
 | 
			
		||||
          _column_2,
 | 
			
		||||
          _column_3,
 | 
			
		||||
        ],
 | 
			
		||||
        attachedDatabase: database,
 | 
			
		||||
      ),
 | 
			
		||||
      alias: null);
 | 
			
		||||
  late final Shape1 snLocalChatMessage = Shape1(
 | 
			
		||||
      source: i0.VersionedTable(
 | 
			
		||||
        entityName: 'sn_local_chat_message',
 | 
			
		||||
        withoutRowId: false,
 | 
			
		||||
        isStrict: false,
 | 
			
		||||
        tableConstraints: [],
 | 
			
		||||
        columns: [
 | 
			
		||||
          _column_0,
 | 
			
		||||
          _column_4,
 | 
			
		||||
          _column_2,
 | 
			
		||||
          _column_3,
 | 
			
		||||
        ],
 | 
			
		||||
        attachedDatabase: database,
 | 
			
		||||
      ),
 | 
			
		||||
      alias: null);
 | 
			
		||||
  late final Shape2 snLocalKeyPair = Shape2(
 | 
			
		||||
      source: i0.VersionedTable(
 | 
			
		||||
        entityName: 'sn_local_key_pair',
 | 
			
		||||
        withoutRowId: false,
 | 
			
		||||
        isStrict: false,
 | 
			
		||||
        tableConstraints: [
 | 
			
		||||
          'PRIMARY KEY(id)',
 | 
			
		||||
        ],
 | 
			
		||||
        columns: [
 | 
			
		||||
          _column_5,
 | 
			
		||||
          _column_6,
 | 
			
		||||
          _column_7,
 | 
			
		||||
          _column_8,
 | 
			
		||||
          _column_9,
 | 
			
		||||
        ],
 | 
			
		||||
        attachedDatabase: database,
 | 
			
		||||
      ),
 | 
			
		||||
      alias: null);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class Shape0 extends i0.VersionedTable {
 | 
			
		||||
  Shape0({required super.source, required super.alias}) : super.aliased();
 | 
			
		||||
  i1.GeneratedColumn<int> get id =>
 | 
			
		||||
      columnsByName['id']! as i1.GeneratedColumn<int>;
 | 
			
		||||
  i1.GeneratedColumn<String> get alias =>
 | 
			
		||||
      columnsByName['alias']! as i1.GeneratedColumn<String>;
 | 
			
		||||
  i1.GeneratedColumn<String> get content =>
 | 
			
		||||
      columnsByName['content']! as i1.GeneratedColumn<String>;
 | 
			
		||||
  i1.GeneratedColumn<DateTime> get createdAt =>
 | 
			
		||||
      columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
i1.GeneratedColumn<int> _column_0(String aliasedName) =>
 | 
			
		||||
    i1.GeneratedColumn<int>('id', aliasedName, false,
 | 
			
		||||
        hasAutoIncrement: true,
 | 
			
		||||
        type: i1.DriftSqlType.int,
 | 
			
		||||
        defaultConstraints:
 | 
			
		||||
            i1.GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
 | 
			
		||||
i1.GeneratedColumn<String> _column_1(String aliasedName) =>
 | 
			
		||||
    i1.GeneratedColumn<String>('alias', aliasedName, false,
 | 
			
		||||
        type: i1.DriftSqlType.string);
 | 
			
		||||
i1.GeneratedColumn<String> _column_2(String aliasedName) =>
 | 
			
		||||
    i1.GeneratedColumn<String>('content', aliasedName, false,
 | 
			
		||||
        type: i1.DriftSqlType.string);
 | 
			
		||||
i1.GeneratedColumn<DateTime> _column_3(String aliasedName) =>
 | 
			
		||||
    i1.GeneratedColumn<DateTime>('created_at', aliasedName, false,
 | 
			
		||||
        type: i1.DriftSqlType.dateTime,
 | 
			
		||||
        defaultValue: const CustomExpression(
 | 
			
		||||
            'CAST(strftime(\'%s\', CURRENT_TIMESTAMP) AS INTEGER)'));
 | 
			
		||||
 | 
			
		||||
class Shape1 extends i0.VersionedTable {
 | 
			
		||||
  Shape1({required super.source, required super.alias}) : super.aliased();
 | 
			
		||||
  i1.GeneratedColumn<int> get id =>
 | 
			
		||||
      columnsByName['id']! as i1.GeneratedColumn<int>;
 | 
			
		||||
  i1.GeneratedColumn<int> get channelId =>
 | 
			
		||||
      columnsByName['channel_id']! as i1.GeneratedColumn<int>;
 | 
			
		||||
  i1.GeneratedColumn<String> get content =>
 | 
			
		||||
      columnsByName['content']! as i1.GeneratedColumn<String>;
 | 
			
		||||
  i1.GeneratedColumn<DateTime> get createdAt =>
 | 
			
		||||
      columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
i1.GeneratedColumn<int> _column_4(String aliasedName) =>
 | 
			
		||||
    i1.GeneratedColumn<int>('channel_id', aliasedName, false,
 | 
			
		||||
        type: i1.DriftSqlType.int);
 | 
			
		||||
 | 
			
		||||
class Shape2 extends i0.VersionedTable {
 | 
			
		||||
  Shape2({required super.source, required super.alias}) : super.aliased();
 | 
			
		||||
  i1.GeneratedColumn<String> get id =>
 | 
			
		||||
      columnsByName['id']! as i1.GeneratedColumn<String>;
 | 
			
		||||
  i1.GeneratedColumn<int> get accountId =>
 | 
			
		||||
      columnsByName['account_id']! as i1.GeneratedColumn<int>;
 | 
			
		||||
  i1.GeneratedColumn<String> get publicKey =>
 | 
			
		||||
      columnsByName['public_key']! as i1.GeneratedColumn<String>;
 | 
			
		||||
  i1.GeneratedColumn<String> get privateKey =>
 | 
			
		||||
      columnsByName['private_key']! as i1.GeneratedColumn<String>;
 | 
			
		||||
  i1.GeneratedColumn<bool> get isActive =>
 | 
			
		||||
      columnsByName['is_active']! as i1.GeneratedColumn<bool>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
i1.GeneratedColumn<String> _column_5(String aliasedName) =>
 | 
			
		||||
    i1.GeneratedColumn<String>('id', aliasedName, false,
 | 
			
		||||
        type: i1.DriftSqlType.string);
 | 
			
		||||
i1.GeneratedColumn<int> _column_6(String aliasedName) =>
 | 
			
		||||
    i1.GeneratedColumn<int>('account_id', aliasedName, false,
 | 
			
		||||
        type: i1.DriftSqlType.int);
 | 
			
		||||
i1.GeneratedColumn<String> _column_7(String aliasedName) =>
 | 
			
		||||
    i1.GeneratedColumn<String>('public_key', aliasedName, false,
 | 
			
		||||
        type: i1.DriftSqlType.string);
 | 
			
		||||
i1.GeneratedColumn<String> _column_8(String aliasedName) =>
 | 
			
		||||
    i1.GeneratedColumn<String>('private_key', aliasedName, true,
 | 
			
		||||
        type: i1.DriftSqlType.string);
 | 
			
		||||
i1.GeneratedColumn<bool> _column_9(String aliasedName) =>
 | 
			
		||||
    i1.GeneratedColumn<bool>('is_active', aliasedName, false,
 | 
			
		||||
        type: i1.DriftSqlType.bool,
 | 
			
		||||
        defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
 | 
			
		||||
            'CHECK ("is_active" IN (0, 1))'),
 | 
			
		||||
        defaultValue: const CustomExpression('0'));
 | 
			
		||||
 | 
			
		||||
final class Schema3 extends i0.VersionedSchema {
 | 
			
		||||
  Schema3({required super.database}) : super(version: 3);
 | 
			
		||||
  @override
 | 
			
		||||
  late final List<i1.DatabaseSchemaEntity> entities = [
 | 
			
		||||
    snLocalChatChannel,
 | 
			
		||||
    snLocalChatMessage,
 | 
			
		||||
    snLocalChannelMember,
 | 
			
		||||
    snLocalKeyPair,
 | 
			
		||||
    snLocalAccount,
 | 
			
		||||
    snLocalAttachment,
 | 
			
		||||
    snLocalSticker,
 | 
			
		||||
    snLocalStickerPack,
 | 
			
		||||
    idxChannelAlias,
 | 
			
		||||
    idxChatChannel,
 | 
			
		||||
    idxAccountName,
 | 
			
		||||
    idxAttachmentRid,
 | 
			
		||||
    idxAttachmentAccount,
 | 
			
		||||
  ];
 | 
			
		||||
  late final Shape0 snLocalChatChannel = Shape0(
 | 
			
		||||
      source: i0.VersionedTable(
 | 
			
		||||
        entityName: 'sn_local_chat_channel',
 | 
			
		||||
        withoutRowId: false,
 | 
			
		||||
        isStrict: false,
 | 
			
		||||
        tableConstraints: [],
 | 
			
		||||
        columns: [
 | 
			
		||||
          _column_0,
 | 
			
		||||
          _column_1,
 | 
			
		||||
          _column_2,
 | 
			
		||||
          _column_3,
 | 
			
		||||
        ],
 | 
			
		||||
        attachedDatabase: database,
 | 
			
		||||
      ),
 | 
			
		||||
      alias: null);
 | 
			
		||||
  late final Shape3 snLocalChatMessage = Shape3(
 | 
			
		||||
      source: i0.VersionedTable(
 | 
			
		||||
        entityName: 'sn_local_chat_message',
 | 
			
		||||
        withoutRowId: false,
 | 
			
		||||
        isStrict: false,
 | 
			
		||||
        tableConstraints: [],
 | 
			
		||||
        columns: [
 | 
			
		||||
          _column_0,
 | 
			
		||||
          _column_4,
 | 
			
		||||
          _column_10,
 | 
			
		||||
          _column_2,
 | 
			
		||||
          _column_3,
 | 
			
		||||
        ],
 | 
			
		||||
        attachedDatabase: database,
 | 
			
		||||
      ),
 | 
			
		||||
      alias: null);
 | 
			
		||||
  late final Shape4 snLocalChannelMember = Shape4(
 | 
			
		||||
      source: i0.VersionedTable(
 | 
			
		||||
        entityName: 'sn_local_channel_member',
 | 
			
		||||
        withoutRowId: false,
 | 
			
		||||
        isStrict: false,
 | 
			
		||||
        tableConstraints: [],
 | 
			
		||||
        columns: [
 | 
			
		||||
          _column_0,
 | 
			
		||||
          _column_4,
 | 
			
		||||
          _column_6,
 | 
			
		||||
          _column_2,
 | 
			
		||||
          _column_3,
 | 
			
		||||
          _column_11,
 | 
			
		||||
        ],
 | 
			
		||||
        attachedDatabase: database,
 | 
			
		||||
      ),
 | 
			
		||||
      alias: null);
 | 
			
		||||
  late final Shape2 snLocalKeyPair = Shape2(
 | 
			
		||||
      source: i0.VersionedTable(
 | 
			
		||||
        entityName: 'sn_local_key_pair',
 | 
			
		||||
        withoutRowId: false,
 | 
			
		||||
        isStrict: false,
 | 
			
		||||
        tableConstraints: [
 | 
			
		||||
          'PRIMARY KEY(id)',
 | 
			
		||||
        ],
 | 
			
		||||
        columns: [
 | 
			
		||||
          _column_5,
 | 
			
		||||
          _column_6,
 | 
			
		||||
          _column_7,
 | 
			
		||||
          _column_8,
 | 
			
		||||
          _column_9,
 | 
			
		||||
        ],
 | 
			
		||||
        attachedDatabase: database,
 | 
			
		||||
      ),
 | 
			
		||||
      alias: null);
 | 
			
		||||
  late final Shape5 snLocalAccount = Shape5(
 | 
			
		||||
      source: i0.VersionedTable(
 | 
			
		||||
        entityName: 'sn_local_account',
 | 
			
		||||
        withoutRowId: false,
 | 
			
		||||
        isStrict: false,
 | 
			
		||||
        tableConstraints: [],
 | 
			
		||||
        columns: [
 | 
			
		||||
          _column_0,
 | 
			
		||||
          _column_12,
 | 
			
		||||
          _column_2,
 | 
			
		||||
          _column_3,
 | 
			
		||||
          _column_11,
 | 
			
		||||
        ],
 | 
			
		||||
        attachedDatabase: database,
 | 
			
		||||
      ),
 | 
			
		||||
      alias: null);
 | 
			
		||||
  late final Shape6 snLocalAttachment = Shape6(
 | 
			
		||||
      source: i0.VersionedTable(
 | 
			
		||||
        entityName: 'sn_local_attachment',
 | 
			
		||||
        withoutRowId: false,
 | 
			
		||||
        isStrict: false,
 | 
			
		||||
        tableConstraints: [],
 | 
			
		||||
        columns: [
 | 
			
		||||
          _column_0,
 | 
			
		||||
          _column_13,
 | 
			
		||||
          _column_14,
 | 
			
		||||
          _column_2,
 | 
			
		||||
          _column_6,
 | 
			
		||||
          _column_3,
 | 
			
		||||
          _column_11,
 | 
			
		||||
        ],
 | 
			
		||||
        attachedDatabase: database,
 | 
			
		||||
      ),
 | 
			
		||||
      alias: null);
 | 
			
		||||
  late final Shape7 snLocalSticker = Shape7(
 | 
			
		||||
      source: i0.VersionedTable(
 | 
			
		||||
        entityName: 'sn_local_sticker',
 | 
			
		||||
        withoutRowId: false,
 | 
			
		||||
        isStrict: false,
 | 
			
		||||
        tableConstraints: [],
 | 
			
		||||
        columns: [
 | 
			
		||||
          _column_0,
 | 
			
		||||
          _column_1,
 | 
			
		||||
          _column_15,
 | 
			
		||||
          _column_2,
 | 
			
		||||
          _column_3,
 | 
			
		||||
        ],
 | 
			
		||||
        attachedDatabase: database,
 | 
			
		||||
      ),
 | 
			
		||||
      alias: null);
 | 
			
		||||
  late final Shape8 snLocalStickerPack = Shape8(
 | 
			
		||||
      source: i0.VersionedTable(
 | 
			
		||||
        entityName: 'sn_local_sticker_pack',
 | 
			
		||||
        withoutRowId: false,
 | 
			
		||||
        isStrict: false,
 | 
			
		||||
        tableConstraints: [],
 | 
			
		||||
        columns: [
 | 
			
		||||
          _column_0,
 | 
			
		||||
          _column_2,
 | 
			
		||||
          _column_3,
 | 
			
		||||
        ],
 | 
			
		||||
        attachedDatabase: database,
 | 
			
		||||
      ),
 | 
			
		||||
      alias: null);
 | 
			
		||||
  final i1.Index idxChannelAlias = i1.Index('idx_channel_alias',
 | 
			
		||||
      'CREATE INDEX idx_channel_alias ON sn_local_chat_channel (alias)');
 | 
			
		||||
  final i1.Index idxChatChannel = i1.Index('idx_chat_channel',
 | 
			
		||||
      'CREATE INDEX idx_chat_channel ON sn_local_chat_message (channel_id)');
 | 
			
		||||
  final i1.Index idxAccountName = i1.Index('idx_account_name',
 | 
			
		||||
      'CREATE INDEX idx_account_name ON sn_local_account (name)');
 | 
			
		||||
  final i1.Index idxAttachmentRid = i1.Index('idx_attachment_rid',
 | 
			
		||||
      'CREATE INDEX idx_attachment_rid ON sn_local_attachment (rid)');
 | 
			
		||||
  final i1.Index idxAttachmentAccount = i1.Index('idx_attachment_account',
 | 
			
		||||
      'CREATE INDEX idx_attachment_account ON sn_local_attachment (account_id)');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class Shape3 extends i0.VersionedTable {
 | 
			
		||||
  Shape3({required super.source, required super.alias}) : super.aliased();
 | 
			
		||||
  i1.GeneratedColumn<int> get id =>
 | 
			
		||||
      columnsByName['id']! as i1.GeneratedColumn<int>;
 | 
			
		||||
  i1.GeneratedColumn<int> get channelId =>
 | 
			
		||||
      columnsByName['channel_id']! as i1.GeneratedColumn<int>;
 | 
			
		||||
  i1.GeneratedColumn<int> get senderId =>
 | 
			
		||||
      columnsByName['sender_id']! as i1.GeneratedColumn<int>;
 | 
			
		||||
  i1.GeneratedColumn<String> get content =>
 | 
			
		||||
      columnsByName['content']! as i1.GeneratedColumn<String>;
 | 
			
		||||
  i1.GeneratedColumn<DateTime> get createdAt =>
 | 
			
		||||
      columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
i1.GeneratedColumn<int> _column_10(String aliasedName) =>
 | 
			
		||||
    i1.GeneratedColumn<int>('sender_id', aliasedName, true,
 | 
			
		||||
        type: i1.DriftSqlType.int);
 | 
			
		||||
 | 
			
		||||
class Shape4 extends i0.VersionedTable {
 | 
			
		||||
  Shape4({required super.source, required super.alias}) : super.aliased();
 | 
			
		||||
  i1.GeneratedColumn<int> get id =>
 | 
			
		||||
      columnsByName['id']! as i1.GeneratedColumn<int>;
 | 
			
		||||
  i1.GeneratedColumn<int> get channelId =>
 | 
			
		||||
      columnsByName['channel_id']! as i1.GeneratedColumn<int>;
 | 
			
		||||
  i1.GeneratedColumn<int> get accountId =>
 | 
			
		||||
      columnsByName['account_id']! as i1.GeneratedColumn<int>;
 | 
			
		||||
  i1.GeneratedColumn<String> get content =>
 | 
			
		||||
      columnsByName['content']! as i1.GeneratedColumn<String>;
 | 
			
		||||
  i1.GeneratedColumn<DateTime> get createdAt =>
 | 
			
		||||
      columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
 | 
			
		||||
  i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
 | 
			
		||||
      columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
i1.GeneratedColumn<DateTime> _column_11(String aliasedName) =>
 | 
			
		||||
    i1.GeneratedColumn<DateTime>('cache_expired_at', aliasedName, false,
 | 
			
		||||
        type: i1.DriftSqlType.dateTime);
 | 
			
		||||
 | 
			
		||||
class Shape5 extends i0.VersionedTable {
 | 
			
		||||
  Shape5({required super.source, required super.alias}) : super.aliased();
 | 
			
		||||
  i1.GeneratedColumn<int> get id =>
 | 
			
		||||
      columnsByName['id']! as i1.GeneratedColumn<int>;
 | 
			
		||||
  i1.GeneratedColumn<String> get name =>
 | 
			
		||||
      columnsByName['name']! as i1.GeneratedColumn<String>;
 | 
			
		||||
  i1.GeneratedColumn<String> get content =>
 | 
			
		||||
      columnsByName['content']! as i1.GeneratedColumn<String>;
 | 
			
		||||
  i1.GeneratedColumn<DateTime> get createdAt =>
 | 
			
		||||
      columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
 | 
			
		||||
  i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
 | 
			
		||||
      columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
i1.GeneratedColumn<String> _column_12(String aliasedName) =>
 | 
			
		||||
    i1.GeneratedColumn<String>('name', aliasedName, false,
 | 
			
		||||
        type: i1.DriftSqlType.string);
 | 
			
		||||
 | 
			
		||||
class Shape6 extends i0.VersionedTable {
 | 
			
		||||
  Shape6({required super.source, required super.alias}) : super.aliased();
 | 
			
		||||
  i1.GeneratedColumn<int> get id =>
 | 
			
		||||
      columnsByName['id']! as i1.GeneratedColumn<int>;
 | 
			
		||||
  i1.GeneratedColumn<String> get rid =>
 | 
			
		||||
      columnsByName['rid']! as i1.GeneratedColumn<String>;
 | 
			
		||||
  i1.GeneratedColumn<String> get uuid =>
 | 
			
		||||
      columnsByName['uuid']! as i1.GeneratedColumn<String>;
 | 
			
		||||
  i1.GeneratedColumn<String> get content =>
 | 
			
		||||
      columnsByName['content']! as i1.GeneratedColumn<String>;
 | 
			
		||||
  i1.GeneratedColumn<int> get accountId =>
 | 
			
		||||
      columnsByName['account_id']! as i1.GeneratedColumn<int>;
 | 
			
		||||
  i1.GeneratedColumn<DateTime> get createdAt =>
 | 
			
		||||
      columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
 | 
			
		||||
  i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
 | 
			
		||||
      columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
i1.GeneratedColumn<String> _column_13(String aliasedName) =>
 | 
			
		||||
    i1.GeneratedColumn<String>('rid', aliasedName, false,
 | 
			
		||||
        type: i1.DriftSqlType.string,
 | 
			
		||||
        defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'));
 | 
			
		||||
i1.GeneratedColumn<String> _column_14(String aliasedName) =>
 | 
			
		||||
    i1.GeneratedColumn<String>('uuid', aliasedName, false,
 | 
			
		||||
        type: i1.DriftSqlType.string,
 | 
			
		||||
        defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'));
 | 
			
		||||
 | 
			
		||||
class Shape7 extends i0.VersionedTable {
 | 
			
		||||
  Shape7({required super.source, required super.alias}) : super.aliased();
 | 
			
		||||
  i1.GeneratedColumn<int> get id =>
 | 
			
		||||
      columnsByName['id']! as i1.GeneratedColumn<int>;
 | 
			
		||||
  i1.GeneratedColumn<String> get alias =>
 | 
			
		||||
      columnsByName['alias']! as i1.GeneratedColumn<String>;
 | 
			
		||||
  i1.GeneratedColumn<String> get fullAlias =>
 | 
			
		||||
      columnsByName['full_alias']! as i1.GeneratedColumn<String>;
 | 
			
		||||
  i1.GeneratedColumn<String> get content =>
 | 
			
		||||
      columnsByName['content']! as i1.GeneratedColumn<String>;
 | 
			
		||||
  i1.GeneratedColumn<DateTime> get createdAt =>
 | 
			
		||||
      columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
i1.GeneratedColumn<String> _column_15(String aliasedName) =>
 | 
			
		||||
    i1.GeneratedColumn<String>('full_alias', aliasedName, false,
 | 
			
		||||
        type: i1.DriftSqlType.string);
 | 
			
		||||
 | 
			
		||||
class Shape8 extends i0.VersionedTable {
 | 
			
		||||
  Shape8({required super.source, required super.alias}) : super.aliased();
 | 
			
		||||
  i1.GeneratedColumn<int> get id =>
 | 
			
		||||
      columnsByName['id']! as i1.GeneratedColumn<int>;
 | 
			
		||||
  i1.GeneratedColumn<String> get content =>
 | 
			
		||||
      columnsByName['content']! as i1.GeneratedColumn<String>;
 | 
			
		||||
  i1.GeneratedColumn<DateTime> get createdAt =>
 | 
			
		||||
      columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
final class Schema4 extends i0.VersionedSchema {
 | 
			
		||||
  Schema4({required super.database}) : super(version: 4);
 | 
			
		||||
  @override
 | 
			
		||||
  late final List<i1.DatabaseSchemaEntity> entities = [
 | 
			
		||||
    snLocalChatChannel,
 | 
			
		||||
    snLocalChatMessage,
 | 
			
		||||
    snLocalChannelMember,
 | 
			
		||||
    snLocalKeyPair,
 | 
			
		||||
    snLocalAccount,
 | 
			
		||||
    snLocalAttachment,
 | 
			
		||||
    snLocalSticker,
 | 
			
		||||
    snLocalStickerPack,
 | 
			
		||||
    snLocalRealm,
 | 
			
		||||
    idxChannelAlias,
 | 
			
		||||
    idxChatChannel,
 | 
			
		||||
    idxAccountName,
 | 
			
		||||
    idxAttachmentRid,
 | 
			
		||||
    idxAttachmentAccount,
 | 
			
		||||
    idxRealmAlias,
 | 
			
		||||
    idxRealmAccount,
 | 
			
		||||
  ];
 | 
			
		||||
  late final Shape0 snLocalChatChannel = Shape0(
 | 
			
		||||
      source: i0.VersionedTable(
 | 
			
		||||
        entityName: 'sn_local_chat_channel',
 | 
			
		||||
        withoutRowId: false,
 | 
			
		||||
        isStrict: false,
 | 
			
		||||
        tableConstraints: [],
 | 
			
		||||
        columns: [
 | 
			
		||||
          _column_0,
 | 
			
		||||
          _column_1,
 | 
			
		||||
          _column_2,
 | 
			
		||||
          _column_3,
 | 
			
		||||
        ],
 | 
			
		||||
        attachedDatabase: database,
 | 
			
		||||
      ),
 | 
			
		||||
      alias: null);
 | 
			
		||||
  late final Shape3 snLocalChatMessage = Shape3(
 | 
			
		||||
      source: i0.VersionedTable(
 | 
			
		||||
        entityName: 'sn_local_chat_message',
 | 
			
		||||
        withoutRowId: false,
 | 
			
		||||
        isStrict: false,
 | 
			
		||||
        tableConstraints: [],
 | 
			
		||||
        columns: [
 | 
			
		||||
          _column_0,
 | 
			
		||||
          _column_4,
 | 
			
		||||
          _column_10,
 | 
			
		||||
          _column_2,
 | 
			
		||||
          _column_3,
 | 
			
		||||
        ],
 | 
			
		||||
        attachedDatabase: database,
 | 
			
		||||
      ),
 | 
			
		||||
      alias: null);
 | 
			
		||||
  late final Shape4 snLocalChannelMember = Shape4(
 | 
			
		||||
      source: i0.VersionedTable(
 | 
			
		||||
        entityName: 'sn_local_channel_member',
 | 
			
		||||
        withoutRowId: false,
 | 
			
		||||
        isStrict: false,
 | 
			
		||||
        tableConstraints: [],
 | 
			
		||||
        columns: [
 | 
			
		||||
          _column_0,
 | 
			
		||||
          _column_4,
 | 
			
		||||
          _column_6,
 | 
			
		||||
          _column_2,
 | 
			
		||||
          _column_3,
 | 
			
		||||
          _column_11,
 | 
			
		||||
        ],
 | 
			
		||||
        attachedDatabase: database,
 | 
			
		||||
      ),
 | 
			
		||||
      alias: null);
 | 
			
		||||
  late final Shape2 snLocalKeyPair = Shape2(
 | 
			
		||||
      source: i0.VersionedTable(
 | 
			
		||||
        entityName: 'sn_local_key_pair',
 | 
			
		||||
        withoutRowId: false,
 | 
			
		||||
        isStrict: false,
 | 
			
		||||
        tableConstraints: [
 | 
			
		||||
          'PRIMARY KEY(id)',
 | 
			
		||||
        ],
 | 
			
		||||
        columns: [
 | 
			
		||||
          _column_5,
 | 
			
		||||
          _column_6,
 | 
			
		||||
          _column_7,
 | 
			
		||||
          _column_8,
 | 
			
		||||
          _column_9,
 | 
			
		||||
        ],
 | 
			
		||||
        attachedDatabase: database,
 | 
			
		||||
      ),
 | 
			
		||||
      alias: null);
 | 
			
		||||
  late final Shape5 snLocalAccount = Shape5(
 | 
			
		||||
      source: i0.VersionedTable(
 | 
			
		||||
        entityName: 'sn_local_account',
 | 
			
		||||
        withoutRowId: false,
 | 
			
		||||
        isStrict: false,
 | 
			
		||||
        tableConstraints: [],
 | 
			
		||||
        columns: [
 | 
			
		||||
          _column_0,
 | 
			
		||||
          _column_12,
 | 
			
		||||
          _column_2,
 | 
			
		||||
          _column_3,
 | 
			
		||||
          _column_11,
 | 
			
		||||
        ],
 | 
			
		||||
        attachedDatabase: database,
 | 
			
		||||
      ),
 | 
			
		||||
      alias: null);
 | 
			
		||||
  late final Shape6 snLocalAttachment = Shape6(
 | 
			
		||||
      source: i0.VersionedTable(
 | 
			
		||||
        entityName: 'sn_local_attachment',
 | 
			
		||||
        withoutRowId: false,
 | 
			
		||||
        isStrict: false,
 | 
			
		||||
        tableConstraints: [],
 | 
			
		||||
        columns: [
 | 
			
		||||
          _column_0,
 | 
			
		||||
          _column_13,
 | 
			
		||||
          _column_14,
 | 
			
		||||
          _column_2,
 | 
			
		||||
          _column_6,
 | 
			
		||||
          _column_3,
 | 
			
		||||
          _column_11,
 | 
			
		||||
        ],
 | 
			
		||||
        attachedDatabase: database,
 | 
			
		||||
      ),
 | 
			
		||||
      alias: null);
 | 
			
		||||
  late final Shape7 snLocalSticker = Shape7(
 | 
			
		||||
      source: i0.VersionedTable(
 | 
			
		||||
        entityName: 'sn_local_sticker',
 | 
			
		||||
        withoutRowId: false,
 | 
			
		||||
        isStrict: false,
 | 
			
		||||
        tableConstraints: [],
 | 
			
		||||
        columns: [
 | 
			
		||||
          _column_0,
 | 
			
		||||
          _column_1,
 | 
			
		||||
          _column_15,
 | 
			
		||||
          _column_2,
 | 
			
		||||
          _column_3,
 | 
			
		||||
        ],
 | 
			
		||||
        attachedDatabase: database,
 | 
			
		||||
      ),
 | 
			
		||||
      alias: null);
 | 
			
		||||
  late final Shape8 snLocalStickerPack = Shape8(
 | 
			
		||||
      source: i0.VersionedTable(
 | 
			
		||||
        entityName: 'sn_local_sticker_pack',
 | 
			
		||||
        withoutRowId: false,
 | 
			
		||||
        isStrict: false,
 | 
			
		||||
        tableConstraints: [],
 | 
			
		||||
        columns: [
 | 
			
		||||
          _column_0,
 | 
			
		||||
          _column_2,
 | 
			
		||||
          _column_3,
 | 
			
		||||
        ],
 | 
			
		||||
        attachedDatabase: database,
 | 
			
		||||
      ),
 | 
			
		||||
      alias: null);
 | 
			
		||||
  late final Shape9 snLocalRealm = Shape9(
 | 
			
		||||
      source: i0.VersionedTable(
 | 
			
		||||
        entityName: 'sn_local_realm',
 | 
			
		||||
        withoutRowId: false,
 | 
			
		||||
        isStrict: false,
 | 
			
		||||
        tableConstraints: [],
 | 
			
		||||
        columns: [
 | 
			
		||||
          _column_0,
 | 
			
		||||
          _column_16,
 | 
			
		||||
          _column_2,
 | 
			
		||||
          _column_6,
 | 
			
		||||
          _column_3,
 | 
			
		||||
          _column_11,
 | 
			
		||||
        ],
 | 
			
		||||
        attachedDatabase: database,
 | 
			
		||||
      ),
 | 
			
		||||
      alias: null);
 | 
			
		||||
  final i1.Index idxChannelAlias = i1.Index('idx_channel_alias',
 | 
			
		||||
      'CREATE INDEX idx_channel_alias ON sn_local_chat_channel (alias)');
 | 
			
		||||
  final i1.Index idxChatChannel = i1.Index('idx_chat_channel',
 | 
			
		||||
      'CREATE INDEX idx_chat_channel ON sn_local_chat_message (channel_id)');
 | 
			
		||||
  final i1.Index idxAccountName = i1.Index('idx_account_name',
 | 
			
		||||
      'CREATE INDEX idx_account_name ON sn_local_account (name)');
 | 
			
		||||
  final i1.Index idxAttachmentRid = i1.Index('idx_attachment_rid',
 | 
			
		||||
      'CREATE INDEX idx_attachment_rid ON sn_local_attachment (rid)');
 | 
			
		||||
  final i1.Index idxAttachmentAccount = i1.Index('idx_attachment_account',
 | 
			
		||||
      'CREATE INDEX idx_attachment_account ON sn_local_attachment (account_id)');
 | 
			
		||||
  final i1.Index idxRealmAlias = i1.Index('idx_realm_alias',
 | 
			
		||||
      'CREATE INDEX idx_realm_alias ON sn_local_realm (alias)');
 | 
			
		||||
  final i1.Index idxRealmAccount = i1.Index('idx_realm_account',
 | 
			
		||||
      'CREATE INDEX idx_realm_account ON sn_local_realm (account_id)');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class Shape9 extends i0.VersionedTable {
 | 
			
		||||
  Shape9({required super.source, required super.alias}) : super.aliased();
 | 
			
		||||
  i1.GeneratedColumn<int> get id =>
 | 
			
		||||
      columnsByName['id']! as i1.GeneratedColumn<int>;
 | 
			
		||||
  i1.GeneratedColumn<String> get alias =>
 | 
			
		||||
      columnsByName['alias']! as i1.GeneratedColumn<String>;
 | 
			
		||||
  i1.GeneratedColumn<String> get content =>
 | 
			
		||||
      columnsByName['content']! as i1.GeneratedColumn<String>;
 | 
			
		||||
  i1.GeneratedColumn<int> get accountId =>
 | 
			
		||||
      columnsByName['account_id']! as i1.GeneratedColumn<int>;
 | 
			
		||||
  i1.GeneratedColumn<DateTime> get createdAt =>
 | 
			
		||||
      columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
 | 
			
		||||
  i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
 | 
			
		||||
      columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
i1.GeneratedColumn<String> _column_16(String aliasedName) =>
 | 
			
		||||
    i1.GeneratedColumn<String>('alias', aliasedName, false,
 | 
			
		||||
        type: i1.DriftSqlType.string,
 | 
			
		||||
        defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'));
 | 
			
		||||
i0.MigrationStepWithVersion migrationSteps({
 | 
			
		||||
  required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
 | 
			
		||||
  required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
 | 
			
		||||
  required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
 | 
			
		||||
}) {
 | 
			
		||||
  return (currentVersion, database) async {
 | 
			
		||||
    switch (currentVersion) {
 | 
			
		||||
      case 1:
 | 
			
		||||
        final schema = Schema2(database: database);
 | 
			
		||||
        final migrator = i1.Migrator(database, schema);
 | 
			
		||||
        await from1To2(migrator, schema);
 | 
			
		||||
        return 2;
 | 
			
		||||
      case 2:
 | 
			
		||||
        final schema = Schema3(database: database);
 | 
			
		||||
        final migrator = i1.Migrator(database, schema);
 | 
			
		||||
        await from2To3(migrator, schema);
 | 
			
		||||
        return 3;
 | 
			
		||||
      case 3:
 | 
			
		||||
        final schema = Schema4(database: database);
 | 
			
		||||
        final migrator = i1.Migrator(database, schema);
 | 
			
		||||
        await from3To4(migrator, schema);
 | 
			
		||||
        return 4;
 | 
			
		||||
      default:
 | 
			
		||||
        throw ArgumentError.value('Unknown migration from $currentVersion');
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
i1.OnUpgrade stepByStep({
 | 
			
		||||
  required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
 | 
			
		||||
  required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
 | 
			
		||||
  required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
 | 
			
		||||
}) =>
 | 
			
		||||
    i0.VersionedSchema.stepByStepHelper(
 | 
			
		||||
        step: migrationSteps(
 | 
			
		||||
      from1To2: from1To2,
 | 
			
		||||
      from2To3: from2To3,
 | 
			
		||||
      from3To4: from3To4,
 | 
			
		||||
    ));
 | 
			
		||||
							
								
								
									
										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};
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										45
									
								
								lib/database/realm.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								lib/database/realm.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,45 @@
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
 | 
			
		||||
import 'package:drift/drift.dart';
 | 
			
		||||
import 'package:surface/types/realm.dart';
 | 
			
		||||
 | 
			
		||||
class SnRealmConverter extends TypeConverter<SnRealm, String>
 | 
			
		||||
    with JsonTypeConverter2<SnRealm, String, Map<String, Object?>> {
 | 
			
		||||
  const SnRealmConverter();
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  SnRealm fromSql(String fromDb) {
 | 
			
		||||
    return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toSql(SnRealm value) {
 | 
			
		||||
    return jsonEncode(toJson(value));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  SnRealm fromJson(Map<String, Object?> json) {
 | 
			
		||||
    return SnRealm.fromJson(json);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, Object?> toJson(SnRealm value) {
 | 
			
		||||
    return value.toJson();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@TableIndex(name: 'idx_realm_alias', columns: {#alias})
 | 
			
		||||
@TableIndex(name: 'idx_realm_account', columns: {#accountId})
 | 
			
		||||
class SnLocalRealm extends Table {
 | 
			
		||||
  IntColumn get id => integer().autoIncrement()();
 | 
			
		||||
 | 
			
		||||
  TextColumn get alias => text().unique()();
 | 
			
		||||
 | 
			
		||||
  TextColumn get content => text().map(const SnRealmConverter())();
 | 
			
		||||
 | 
			
		||||
  IntColumn get accountId => integer()();
 | 
			
		||||
 | 
			
		||||
  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
 | 
			
		||||
 | 
			
		||||
  DateTimeColumn get cacheExpiredAt => dateTime()();
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										74
									
								
								lib/database/sticker.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								lib/database/sticker.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,74 @@
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
 | 
			
		||||
import 'package:drift/drift.dart';
 | 
			
		||||
import 'package:surface/types/attachment.dart';
 | 
			
		||||
 | 
			
		||||
class SnStickerConverter extends TypeConverter<SnSticker, String>
 | 
			
		||||
    with JsonTypeConverter2<SnSticker, String, Map<String, Object?>> {
 | 
			
		||||
  const SnStickerConverter();
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  SnSticker fromSql(String fromDb) {
 | 
			
		||||
    return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toSql(SnSticker value) {
 | 
			
		||||
    return jsonEncode(toJson(value));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  SnSticker fromJson(Map<String, Object?> json) {
 | 
			
		||||
    return SnSticker.fromJson(json);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, Object?> toJson(SnSticker value) {
 | 
			
		||||
    return value.toJson();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class SnLocalSticker extends Table {
 | 
			
		||||
  IntColumn get id => integer().autoIncrement()();
 | 
			
		||||
 | 
			
		||||
  TextColumn get alias => text()();
 | 
			
		||||
 | 
			
		||||
  TextColumn get fullAlias => text()();
 | 
			
		||||
 | 
			
		||||
  TextColumn get content => text().map(const SnStickerConverter())();
 | 
			
		||||
 | 
			
		||||
  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class SnStickerPackConverter extends TypeConverter<SnStickerPack, String>
 | 
			
		||||
    with JsonTypeConverter2<SnStickerPack, String, Map<String, Object?>> {
 | 
			
		||||
  const SnStickerPackConverter();
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  SnStickerPack fromSql(String fromDb) {
 | 
			
		||||
    return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toSql(SnStickerPack value) {
 | 
			
		||||
    return jsonEncode(toJson(value));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  SnStickerPack fromJson(Map<String, Object?> json) {
 | 
			
		||||
    return SnStickerPack.fromJson(json);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, Object?> toJson(SnStickerPack value) {
 | 
			
		||||
    return value.toJson();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class SnLocalStickerPack extends Table {
 | 
			
		||||
  IntColumn get id => integer().autoIncrement()();
 | 
			
		||||
 | 
			
		||||
  TextColumn get content => text().map(const SnStickerPackConverter())();
 | 
			
		||||
 | 
			
		||||
  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										10
									
								
								lib/logger.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								lib/logger.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
import 'package:talker/talker.dart';
 | 
			
		||||
 | 
			
		||||
final logging = Talker(
 | 
			
		||||
  settings: TalkerSettings(
 | 
			
		||||
    enabled: true,
 | 
			
		||||
    useHistory: true,
 | 
			
		||||
    maxHistoryItems: 1000,
 | 
			
		||||
    useConsoleLogs: true,
 | 
			
		||||
  ),
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										333
									
								
								lib/main.dart
									
									
									
									
									
								
							
							
						
						
									
										333
									
								
								lib/main.dart
									
									
									
									
									
								
							@@ -3,6 +3,7 @@ import 'dart:developer';
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
import 'dart:ui';
 | 
			
		||||
 | 
			
		||||
import 'package:audioplayers/audioplayers.dart';
 | 
			
		||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
 | 
			
		||||
import 'package:croppy/croppy.dart';
 | 
			
		||||
import 'package:dio/dio.dart';
 | 
			
		||||
@@ -12,18 +13,22 @@ import 'package:firebase_core/firebase_core.dart';
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:hive_flutter/hive_flutter.dart';
 | 
			
		||||
import 'package:hotkey_manager/hotkey_manager.dart';
 | 
			
		||||
import 'package:package_info_plus/package_info_plus.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:relative_time/relative_time.dart';
 | 
			
		||||
import 'package:responsive_framework/responsive_framework.dart';
 | 
			
		||||
import 'package:shared_preferences/shared_preferences.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/firebase_options.dart';
 | 
			
		||||
import 'package:surface/logger.dart';
 | 
			
		||||
import 'package:surface/providers/channel.dart';
 | 
			
		||||
import 'package:surface/providers/chat_call.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/navigation.dart';
 | 
			
		||||
import 'package:surface/providers/notification.dart';
 | 
			
		||||
@@ -31,24 +36,27 @@ import 'package:surface/providers/post.dart';
 | 
			
		||||
import 'package:surface/providers/relationship.dart';
 | 
			
		||||
import 'package:surface/providers/sn_attachment.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/sn_realm.dart';
 | 
			
		||||
import 'package:surface/providers/sn_sticker.dart';
 | 
			
		||||
import 'package:surface/providers/special_day.dart';
 | 
			
		||||
import 'package:surface/providers/theme.dart';
 | 
			
		||||
import 'package:surface/providers/translation.dart';
 | 
			
		||||
import 'package:surface/providers/user_directory.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/providers/websocket.dart';
 | 
			
		||||
import 'package:surface/providers/widget.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:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/menu_bar.dart';
 | 
			
		||||
import 'package:surface/widgets/version_label.dart';
 | 
			
		||||
import 'package:tray_manager/tray_manager.dart';
 | 
			
		||||
import 'package:version/version.dart';
 | 
			
		||||
import 'package:workmanager/workmanager.dart';
 | 
			
		||||
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')
 | 
			
		||||
void appBackgroundDispatcher() {
 | 
			
		||||
@@ -67,13 +75,40 @@ void appBackgroundDispatcher() {
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Desktop size tools
 | 
			
		||||
 | 
			
		||||
Future<Size> _getSavedWindowSize() async {
 | 
			
		||||
  final prefs = await SharedPreferences.getInstance();
 | 
			
		||||
  String? sizeString = prefs.getString(kAppWindowSize);
 | 
			
		||||
 | 
			
		||||
  if (sizeString != null) {
 | 
			
		||||
    List<String> parts = sizeString.split('x');
 | 
			
		||||
    if (parts.length == 2) {
 | 
			
		||||
      double? width = double.tryParse(parts[0]);
 | 
			
		||||
      double? height = double.tryParse(parts[1]);
 | 
			
		||||
      if (width != null && height != null) {
 | 
			
		||||
        return Size(width, height);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return const Size(1280, 720); // Default size
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
Future<void> _saveWindowSize() async {
 | 
			
		||||
  final prefs = await SharedPreferences.getInstance();
 | 
			
		||||
  final size = appWindow.size;
 | 
			
		||||
  await prefs.setString(kAppWindowSize, '${size.width}x${size.height}');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
void main() async {
 | 
			
		||||
  WidgetsFlutterBinding.ensureInitialized();
 | 
			
		||||
 | 
			
		||||
  if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
 | 
			
		||||
    final Size savedSize = await _getSavedWindowSize();
 | 
			
		||||
    doWhenWindowReady(() {
 | 
			
		||||
      appWindow.minSize = Size(480, 640);
 | 
			
		||||
      appWindow.size = Size(1280, 720);
 | 
			
		||||
      appWindow.size = savedSize;
 | 
			
		||||
      appWindow.alignment = Alignment.center;
 | 
			
		||||
      appWindow.show();
 | 
			
		||||
    });
 | 
			
		||||
@@ -81,26 +116,17 @@ void main() async {
 | 
			
		||||
 | 
			
		||||
  await EasyLocalization.ensureInitialized();
 | 
			
		||||
 | 
			
		||||
  await Hive.initFlutter();
 | 
			
		||||
  Hive.registerAdapter(SnChannelImplAdapter());
 | 
			
		||||
  Hive.registerAdapter(SnRealmImplAdapter());
 | 
			
		||||
  Hive.registerAdapter(SnChannelMemberImplAdapter());
 | 
			
		||||
  Hive.registerAdapter(SnChatMessageImplAdapter());
 | 
			
		||||
 | 
			
		||||
  if (kIsWeb && !Platform.isLinux) {
 | 
			
		||||
  if (!kIsWeb && !Platform.isLinux) {
 | 
			
		||||
    await Firebase.initializeApp(
 | 
			
		||||
      options: DefaultFirebaseOptions.currentPlatform,
 | 
			
		||||
    );
 | 
			
		||||
        options: DefaultFirebaseOptions.currentPlatform);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  GoRouter.optionURLReflectsImperativeAPIs = true;
 | 
			
		||||
  usePathUrlStrategy();
 | 
			
		||||
 | 
			
		||||
  if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
 | 
			
		||||
    Workmanager().initialize(
 | 
			
		||||
      appBackgroundDispatcher,
 | 
			
		||||
      isInDebugMode: kDebugMode,
 | 
			
		||||
    );
 | 
			
		||||
    Workmanager()
 | 
			
		||||
        .initialize(appBackgroundDispatcher, isInDebugMode: kDebugMode);
 | 
			
		||||
    if (Platform.isAndroid) {
 | 
			
		||||
      Workmanager().registerPeriodicTask(
 | 
			
		||||
        "widget-update-random-post",
 | 
			
		||||
@@ -113,7 +139,8 @@ void main() async {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!kIsWeb && Platform.isAndroid) {
 | 
			
		||||
    final ImagePickerPlatform imagePickerImplementation = ImagePickerPlatform.instance;
 | 
			
		||||
    final ImagePickerPlatform imagePickerImplementation =
 | 
			
		||||
        ImagePickerPlatform.instance;
 | 
			
		||||
    if (imagePickerImplementation is ImagePickerAndroid) {
 | 
			
		||||
      imagePickerImplementation.useAndroidPhotoPicker = true;
 | 
			
		||||
    }
 | 
			
		||||
@@ -134,13 +161,16 @@ class SolianApp extends StatelessWidget {
 | 
			
		||||
          Locale('en', 'US'),
 | 
			
		||||
          Locale('zh', 'CN'),
 | 
			
		||||
          Locale('zh', 'TW'),
 | 
			
		||||
          Locale('zh', 'HK'),
 | 
			
		||||
          Locale('zh', 'HK')
 | 
			
		||||
        ],
 | 
			
		||||
        fallbackLocale: Locale('en', 'US'),
 | 
			
		||||
        useFallbackTranslations: true,
 | 
			
		||||
        assetLoader: JsonAssetLoader(),
 | 
			
		||||
        child: MultiProvider(
 | 
			
		||||
          providers: [
 | 
			
		||||
            // Infrastructure layer
 | 
			
		||||
            Provider(create: (ctx) => DatabaseProvider(ctx)),
 | 
			
		||||
 | 
			
		||||
            // System extensions layer
 | 
			
		||||
            Provider(create: (ctx) => HomeWidgetProvider(ctx)),
 | 
			
		||||
 | 
			
		||||
@@ -155,15 +185,18 @@ class SolianApp extends StatelessWidget {
 | 
			
		||||
            Provider(create: (ctx) => SnNetworkProvider(ctx)),
 | 
			
		||||
            Provider(create: (ctx) => UserDirectoryProvider(ctx)),
 | 
			
		||||
            Provider(create: (ctx) => SnAttachmentProvider(ctx)),
 | 
			
		||||
            ChangeNotifierProvider(create: (ctx) => SnRealmProvider(ctx)),
 | 
			
		||||
            Provider(create: (ctx) => SnPostContentProvider(ctx)),
 | 
			
		||||
            Provider(create: (ctx) => SnRelationshipProvider(ctx)),
 | 
			
		||||
            Provider(create: (ctx) => SnLinkPreviewProvider(ctx)),
 | 
			
		||||
            Provider(create: (ctx) => SnStickerProvider(ctx)),
 | 
			
		||||
            ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)),
 | 
			
		||||
            ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)),
 | 
			
		||||
            Provider(create: (ctx) => KeyPairProvider(ctx)),
 | 
			
		||||
            ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
 | 
			
		||||
            ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
 | 
			
		||||
            ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
 | 
			
		||||
            Provider(create: (ctx) => SnTranslator()),
 | 
			
		||||
 | 
			
		||||
            // Additional helper layer
 | 
			
		||||
            Provider(create: (ctx) => SpecialDayProvider(ctx)),
 | 
			
		||||
@@ -223,19 +256,23 @@ class _AppSplashScreen extends StatefulWidget {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
  String _phaseText = 'appInitStarting';
 | 
			
		||||
 | 
			
		||||
  void _tryRequestRating() async {
 | 
			
		||||
    final prefs = await SharedPreferences.getInstance();
 | 
			
		||||
    if (prefs.containsKey('first_boot_time')) {
 | 
			
		||||
      final rawTime = prefs.getString('first_boot_time');
 | 
			
		||||
      final time = DateTime.tryParse(rawTime ?? '');
 | 
			
		||||
      if (time != null && time.isBefore(DateTime.now().subtract(const Duration(days: 3)))) {
 | 
			
		||||
      if (time != null &&
 | 
			
		||||
          time.isBefore(DateTime.now().subtract(const Duration(days: 3)))) {
 | 
			
		||||
        final inAppReview = InAppReview.instance;
 | 
			
		||||
        if (prefs.getBool('rating_requested') == true) return;
 | 
			
		||||
        if (await inAppReview.isAvailable()) {
 | 
			
		||||
          await inAppReview.requestReview();
 | 
			
		||||
          prefs.setBool('rating_requested', true);
 | 
			
		||||
        } else {
 | 
			
		||||
          log('Unable request app review, unavailable');
 | 
			
		||||
          logging.error('Unable request app review, unavailable');
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
@@ -250,28 +287,38 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
			
		||||
      final localVersionString = '${info.version}+${info.buildNumber}';
 | 
			
		||||
      final resp = await Dio(
 | 
			
		||||
        BaseOptions(
 | 
			
		||||
          sendTimeout: const Duration(seconds: 60),
 | 
			
		||||
          receiveTimeout: const Duration(seconds: 60),
 | 
			
		||||
        ),
 | 
			
		||||
            sendTimeout: const Duration(seconds: 60),
 | 
			
		||||
            receiveTimeout: const Duration(seconds: 60)),
 | 
			
		||||
      ).get(
 | 
			
		||||
        'https://git.solsynth.dev/api/v1/repos/HyperNet/Surface/tags?page=1&limit=1',
 | 
			
		||||
      );
 | 
			
		||||
      final remoteVersionString = (resp.data as List).firstOrNull?['name'] ?? '0.0.0+0';
 | 
			
		||||
          'https://api.github.com/repos/Solsynth/HyperNet.Surface/releases/latest');
 | 
			
		||||
      final remoteVersionString = resp.data?['tag_name'] ?? '0.0.0+0';
 | 
			
		||||
      final remoteVersion = Version.parse(remoteVersionString.split('+').first);
 | 
			
		||||
      final localVersion = Version.parse(localVersionString.split('+').first);
 | 
			
		||||
      final remoteBuildNumber = int.tryParse(remoteVersionString.split('+').last) ?? 0;
 | 
			
		||||
      final localBuildNumber = int.tryParse(localVersionString.split('+').last) ?? 0;
 | 
			
		||||
      log("[Update] Local: $localVersionString, Remote: $remoteVersionString");
 | 
			
		||||
      if ((remoteVersion > localVersion || remoteBuildNumber > localBuildNumber) && mounted) {
 | 
			
		||||
      final remoteBuildNumber =
 | 
			
		||||
          int.tryParse(remoteVersionString.split('+').last) ?? 0;
 | 
			
		||||
      final localBuildNumber =
 | 
			
		||||
          int.tryParse(localVersionString.split('+').last) ?? 0;
 | 
			
		||||
      logging.info(
 | 
			
		||||
          "[Update] Local: $localVersionString, Remote: $remoteVersionString");
 | 
			
		||||
      if ((remoteVersion > localVersion ||
 | 
			
		||||
              remoteBuildNumber > localBuildNumber) &&
 | 
			
		||||
          mounted) {
 | 
			
		||||
        final config = context.read<ConfigProvider>();
 | 
			
		||||
        config.setUpdate(remoteVersionString);
 | 
			
		||||
        log("[Update] Update available: $remoteVersionString");
 | 
			
		||||
        config.setUpdate(
 | 
			
		||||
            remoteVersionString, resp.data?['body'] ?? 'No changelog');
 | 
			
		||||
        logging.info("[Update] Update available: $remoteVersionString");
 | 
			
		||||
      }
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      logging.error('[Error] Unable to check update...', e);
 | 
			
		||||
      if (mounted) context.showErrorDialog('Unable to check update: $e');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _setPhaseText(String text) {
 | 
			
		||||
    _phaseText = 'appInit${text.capitalize()}'.tr();
 | 
			
		||||
    if (mounted) setState(() {});
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _initialize() async {
 | 
			
		||||
    try {
 | 
			
		||||
      final cfg = context.read<ConfigProvider>();
 | 
			
		||||
@@ -284,22 +331,52 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
			
		||||
      // The Network initialization must be done after the HomeWidget initialization
 | 
			
		||||
      // The Network initialization will save the server url to the HomeWidget
 | 
			
		||||
      // The Network initialization will also save initialize the Config, so it not need to be initialized again
 | 
			
		||||
      _setPhaseText('network');
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.initializeUserAgent();
 | 
			
		||||
      await sn.setConfigWithNative();
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      _setPhaseText('userdata');
 | 
			
		||||
      final ua = context.read<UserProvider>();
 | 
			
		||||
      await ua.initialize();
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      _setPhaseText('websocket');
 | 
			
		||||
      final ws = context.read<WebSocketProvider>();
 | 
			
		||||
      await ws.tryConnect();
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      final notify = context.read<NotificationProvider>();
 | 
			
		||||
      notify.listen();
 | 
			
		||||
      await notify.registerPushNotifications();
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      final sticker = context.read<SnStickerProvider>();
 | 
			
		||||
      await sticker.listStickerEagerly();
 | 
			
		||||
      try {
 | 
			
		||||
        if (!mounted) return;
 | 
			
		||||
        _setPhaseText('keyPair');
 | 
			
		||||
        final kp = context.read<KeyPairProvider>();
 | 
			
		||||
        await kp.reloadActive();
 | 
			
		||||
        kp.listen();
 | 
			
		||||
      } catch (_) {}
 | 
			
		||||
      if (ua.isAuthorized) {
 | 
			
		||||
        if (!mounted) return;
 | 
			
		||||
        _setPhaseText('notification');
 | 
			
		||||
        final notify = context.read<NotificationProvider>();
 | 
			
		||||
        notify.listen();
 | 
			
		||||
        try {
 | 
			
		||||
          notify.registerPushNotifications();
 | 
			
		||||
        } catch (_) {}
 | 
			
		||||
        if (!mounted) return;
 | 
			
		||||
        _setPhaseText('stickers');
 | 
			
		||||
        final sticker = context.read<SnStickerProvider>();
 | 
			
		||||
        await sticker.listSticker();
 | 
			
		||||
        if (!mounted) return;
 | 
			
		||||
        _setPhaseText('userDirectory');
 | 
			
		||||
        final ud = context.read<UserDirectoryProvider>();
 | 
			
		||||
        await ud.loadAccountCache();
 | 
			
		||||
        if (!mounted) return;
 | 
			
		||||
        _setPhaseText('realm');
 | 
			
		||||
        final rm = context.read<SnRealmProvider>();
 | 
			
		||||
        await rm.refreshAvailableRealms();
 | 
			
		||||
        if (!mounted) return;
 | 
			
		||||
        _setPhaseText('chat');
 | 
			
		||||
        final ct = context.read<ChatChannelProvider>();
 | 
			
		||||
        await ct.refreshAvailableChannels();
 | 
			
		||||
        _setPhaseText('done');
 | 
			
		||||
        _playIntro();
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      await context.showErrorDialog(err);
 | 
			
		||||
@@ -312,44 +389,59 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
			
		||||
 | 
			
		||||
  Future<void> _hotkeyInitialization() async {
 | 
			
		||||
    if (kIsWeb) return;
 | 
			
		||||
 | 
			
		||||
    if (Platform.isMacOS) {
 | 
			
		||||
      HotKey quitHotKey = HotKey(
 | 
			
		||||
        key: PhysicalKeyboardKey.keyQ,
 | 
			
		||||
        modifiers: [HotKeyModifier.meta],
 | 
			
		||||
        scope: HotKeyScope.inapp,
 | 
			
		||||
      );
 | 
			
		||||
      await hotKeyManager.register(quitHotKey, keyUpHandler: (_) {
 | 
			
		||||
        _appLifecycleListener?.dispose();
 | 
			
		||||
        SystemChannels.platform.invokeMethod('SystemNavigator.pop');
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    // The quit key has been removed, and the logic of the quit key is moved to system menu bar activator.
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _playIntro() async {
 | 
			
		||||
    final cfg = context.read<ConfigProvider>();
 | 
			
		||||
    if (!cfg.soundEffects) return;
 | 
			
		||||
 | 
			
		||||
    final player = AudioPlayer(playerId: 'launch-done-player');
 | 
			
		||||
    await player.play(AssetSource('audio/sfx/launch-done.mp3'), volume: 0.8);
 | 
			
		||||
    player.onPlayerComplete.listen((_) {
 | 
			
		||||
      player.dispose();
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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 {
 | 
			
		||||
    if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
 | 
			
		||||
 | 
			
		||||
    final icon = Platform.isWindows ? 'assets/icon/tray-icon.ico' : 'assets/icon/tray-icon.png';
 | 
			
		||||
    final icon = Platform.isWindows
 | 
			
		||||
        ? 'assets/icon/tray-icon.ico'
 | 
			
		||||
        : 'assets/icon/tray-icon.png';
 | 
			
		||||
    final appVersion = await PackageInfo.fromPlatform();
 | 
			
		||||
 | 
			
		||||
    trayManager.addListener(this);
 | 
			
		||||
    await trayManager.setIcon(icon);
 | 
			
		||||
 | 
			
		||||
    Menu menu = Menu(
 | 
			
		||||
      items: [
 | 
			
		||||
        MenuItem(
 | 
			
		||||
          key: 'version_label',
 | 
			
		||||
          label: 'Solian ${appVersion.version}+${appVersion.buildNumber}',
 | 
			
		||||
          disabled: true,
 | 
			
		||||
        ),
 | 
			
		||||
        MenuItem.separator(),
 | 
			
		||||
        MenuItem(
 | 
			
		||||
          key: 'exit',
 | 
			
		||||
          label: 'trayMenuExit'.tr(),
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    _appTrayMenu.items![0] = MenuItem(
 | 
			
		||||
      key: 'version_label',
 | 
			
		||||
      label: 'Solian ${appVersion.version}+${appVersion.buildNumber}',
 | 
			
		||||
      disabled: true,
 | 
			
		||||
    );
 | 
			
		||||
    await trayManager.setContextMenu(menu);
 | 
			
		||||
 | 
			
		||||
    await trayManager.setContextMenu(_appTrayMenu);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _notifyInitialization() async {
 | 
			
		||||
    if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
 | 
			
		||||
 | 
			
		||||
    await localNotifier.setup(
 | 
			
		||||
        appName: 'Solian', shortcutPolicy: ShortcutPolicy.requireCreate);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  AppLifecycleListener? _appLifecycleListener;
 | 
			
		||||
@@ -358,18 +450,20 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
 | 
			
		||||
    _isBusy = true;
 | 
			
		||||
    if (!kIsWeb && !(Platform.isIOS || Platform.isAndroid)) {
 | 
			
		||||
      _appLifecycleListener = AppLifecycleListener(
 | 
			
		||||
        onExitRequested: _onExitRequested,
 | 
			
		||||
      );
 | 
			
		||||
      _appLifecycleListener =
 | 
			
		||||
          AppLifecycleListener(onExitRequested: _onExitRequested);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _trayInitialization();
 | 
			
		||||
    _hotkeyInitialization();
 | 
			
		||||
    _notifyInitialization();
 | 
			
		||||
    _initialize().then((_) {
 | 
			
		||||
      _postInitialization();
 | 
			
		||||
      _tryRequestRating();
 | 
			
		||||
      _checkForUpdate();
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -378,6 +472,16 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
			
		||||
    return AppExitResponse.cancel;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _quitApp() {
 | 
			
		||||
    _saveWindowSize();
 | 
			
		||||
    _appLifecycleListener?.dispose();
 | 
			
		||||
    if (Platform.isWindows) {
 | 
			
		||||
      appWindow.close();
 | 
			
		||||
    } else {
 | 
			
		||||
      SystemChannels.platform.invokeMethod('SystemNavigator.pop');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void onTrayIconMouseDown() {
 | 
			
		||||
    if (Platform.isWindows) {
 | 
			
		||||
@@ -401,9 +505,18 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
			
		||||
  @override
 | 
			
		||||
  void onTrayMenuItemClick(MenuItem menuItem) {
 | 
			
		||||
    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':
 | 
			
		||||
        _appLifecycleListener?.dispose();
 | 
			
		||||
        SystemChannels.platform.invokeMethod('SystemNavigator.pop');
 | 
			
		||||
        _quitApp();
 | 
			
		||||
        break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@@ -420,15 +533,73 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final cfg = context.read<ConfigProvider>();
 | 
			
		||||
    return NotificationListener<SizeChangedLayoutNotification>(
 | 
			
		||||
      onNotification: (notification) {
 | 
			
		||||
        WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
			
		||||
          cfg.calcDrawerSize(context);
 | 
			
		||||
        });
 | 
			
		||||
        return false;
 | 
			
		||||
      },
 | 
			
		||||
      child: SizeChangedLayoutNotifier(
 | 
			
		||||
        child: widget.child,
 | 
			
		||||
    return AppSystemMenuBar(
 | 
			
		||||
      onQuit: _quitApp,
 | 
			
		||||
      child: NotificationListener<SizeChangedLayoutNotification>(
 | 
			
		||||
        onNotification: (notification) {
 | 
			
		||||
          WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
			
		||||
            cfg.calcDrawerSize(context);
 | 
			
		||||
          });
 | 
			
		||||
          return false;
 | 
			
		||||
        },
 | 
			
		||||
        child: OrientationBuilder(
 | 
			
		||||
          builder: (context, orientation) {
 | 
			
		||||
            final cfg = context.read<ConfigProvider>();
 | 
			
		||||
            WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
			
		||||
              cfg.calcDrawerSize(context);
 | 
			
		||||
            });
 | 
			
		||||
            Future.delayed(const Duration(milliseconds: 300), () {
 | 
			
		||||
              if (context.mounted) {
 | 
			
		||||
                cfg.calcDrawerSize(context);
 | 
			
		||||
              }
 | 
			
		||||
            });
 | 
			
		||||
            return SizeChangedLayoutNotifier(
 | 
			
		||||
              child: _isBusy
 | 
			
		||||
                  ? Material(
 | 
			
		||||
                      key: Key('app-splash-screen-$_isBusy'),
 | 
			
		||||
                      child: Stack(
 | 
			
		||||
                        children: [
 | 
			
		||||
                          Container(
 | 
			
		||||
                            decoration: BoxDecoration(
 | 
			
		||||
                              image: DecorationImage(
 | 
			
		||||
                                image: AssetImage('assets/icon/kanban-1st.jpg'),
 | 
			
		||||
                                fit: BoxFit.cover,
 | 
			
		||||
                                opacity: 0.1,
 | 
			
		||||
                              ),
 | 
			
		||||
                              color: Theme.of(context).colorScheme.surface,
 | 
			
		||||
                              backgroundBlendMode: BlendMode.darken,
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                          Center(
 | 
			
		||||
                            child: Container(
 | 
			
		||||
                              constraints: const BoxConstraints(maxWidth: 240),
 | 
			
		||||
                              child: Column(
 | 
			
		||||
                                mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                                children: [
 | 
			
		||||
                                  Image.asset(
 | 
			
		||||
                                    'assets/icon/icon.png',
 | 
			
		||||
                                    width: 64,
 | 
			
		||||
                                    height: 64,
 | 
			
		||||
                                    color:
 | 
			
		||||
                                        Theme.of(context).colorScheme.onSurface,
 | 
			
		||||
                                  ),
 | 
			
		||||
                                  Text('Solar Network').bold(),
 | 
			
		||||
                                  AppVersionLabel(),
 | 
			
		||||
                                  Gap(8),
 | 
			
		||||
                                  Text(_phaseText, textAlign: TextAlign.center),
 | 
			
		||||
                                  Gap(16),
 | 
			
		||||
                                  const LinearProgressIndicator(),
 | 
			
		||||
                                ],
 | 
			
		||||
                              ),
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ],
 | 
			
		||||
                      ),
 | 
			
		||||
                    )
 | 
			
		||||
                  : widget.child,
 | 
			
		||||
            );
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,48 +1,75 @@
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
 | 
			
		||||
import 'package:drift/drift.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hive_flutter/hive_flutter.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_realm.dart';
 | 
			
		||||
import 'package:surface/providers/user_directory.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/types/chat.dart';
 | 
			
		||||
import 'package:surface/types/realm.dart';
 | 
			
		||||
 | 
			
		||||
class ChatChannelProvider extends ChangeNotifier {
 | 
			
		||||
  static const kChatChannelBoxName = 'nex_chat_channels';
 | 
			
		||||
 | 
			
		||||
  late final SnNetworkProvider _sn;
 | 
			
		||||
  late final UserDirectoryProvider _ud;
 | 
			
		||||
 | 
			
		||||
  Box<SnChannel>? get _channelBox => Hive.box<SnChannel>(kChatChannelBoxName);
 | 
			
		||||
  late final UserProvider _ua;
 | 
			
		||||
  late final DatabaseProvider _dt;
 | 
			
		||||
  late final SnRealmProvider _rels;
 | 
			
		||||
 | 
			
		||||
  ChatChannelProvider(BuildContext context) {
 | 
			
		||||
    _sn = context.read<SnNetworkProvider>();
 | 
			
		||||
    _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);
 | 
			
		||||
  }
 | 
			
		||||
  final List<SnChannel> _availableChannels = List.empty(growable: true);
 | 
			
		||||
 | 
			
		||||
  Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async {
 | 
			
		||||
    if (_channelBox == null) return;
 | 
			
		||||
    await _channelBox!.putAll({
 | 
			
		||||
      for (final channel in channels) channel.key: channel,
 | 
			
		||||
  List<SnChannel> get availableChannels => _availableChannels;
 | 
			
		||||
 | 
			
		||||
  Future<void> refreshAvailableChannels() async {
 | 
			
		||||
    final stream = fetchChannels();
 | 
			
		||||
    stream.listen((ele) {
 | 
			
		||||
      _availableChannels.clear();
 | 
			
		||||
      _availableChannels.addAll(ele);
 | 
			
		||||
      notifyListeners();
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void addAvailableChannel(SnChannel channel) {
 | 
			
		||||
    _availableChannels.add(channel);
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async {
 | 
			
		||||
    await Future.wait(
 | 
			
		||||
      channels.map(
 | 
			
		||||
        (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({
 | 
			
		||||
    String scope = 'global',
 | 
			
		||||
    bool direct = false,
 | 
			
		||||
    bool doNotSave = false,
 | 
			
		||||
  }) async {
 | 
			
		||||
    final resp = await _sn.client.get(
 | 
			
		||||
      '/cgi/im/channels/$scope/me/available',
 | 
			
		||||
      queryParameters: {
 | 
			
		||||
        'direct': direct,
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
    final resp = await _sn.client.get('/cgi/im/channels/me/available');
 | 
			
		||||
    final out = List<SnChannel>.from(
 | 
			
		||||
      resp.data?.map((e) => SnChannel.fromJson(e)) ?? [],
 | 
			
		||||
    );
 | 
			
		||||
@@ -54,18 +81,25 @@ class ChatChannelProvider extends ChangeNotifier {
 | 
			
		||||
  /// It will use the local storage as much as possible.
 | 
			
		||||
  /// The alias should include the scope, formatted as `scope:alias`.
 | 
			
		||||
  Future<SnChannel> getChannel(String key) async {
 | 
			
		||||
    if (_channelBox != null) {
 | 
			
		||||
      final local = _channelBox!.get(key);
 | 
			
		||||
      if (local != null) return local;
 | 
			
		||||
    final local = await (_dt.db.snLocalChatChannel.select()
 | 
			
		||||
          ..where((e) => e.alias.equals(key)))
 | 
			
		||||
        .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);
 | 
			
		||||
 | 
			
		||||
    // Preload realm of the channel
 | 
			
		||||
    if (out.realmId != null) {
 | 
			
		||||
      resp = await _sn.client.get('/cgi/id/realms/${out.realmId}');
 | 
			
		||||
      out = out.copyWith(realm: SnRealm.fromJson(resp.data));
 | 
			
		||||
      out = out.copyWith(realm: await _rels.getRealm(out.realmId!));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _saveChannelToLocal([out]);
 | 
			
		||||
@@ -77,66 +111,119 @@ class ChatChannelProvider extends ChangeNotifier {
 | 
			
		||||
  /// 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.
 | 
			
		||||
  /// Like the local storage is broken or the server is down.
 | 
			
		||||
  Stream<List<SnChannel>> fetchChannels() async* {
 | 
			
		||||
    if (_channelBox != null) yield _channelBox!.values.toList();
 | 
			
		||||
 | 
			
		||||
    var resp = await _sn.client.get('/cgi/id/realms/me/available');
 | 
			
		||||
    final realms = List<SnRealm>.from(
 | 
			
		||||
      resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
 | 
			
		||||
    );
 | 
			
		||||
    final realmMap = {
 | 
			
		||||
      for (final realm in realms) realm.alias: realm,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    final scopeToFetch = {'global', ...realms.map((e) => e.alias)};
 | 
			
		||||
 | 
			
		||||
    final List<SnChannel> result = List.empty(growable: true);
 | 
			
		||||
    final directMessages = await _fetchChannelsFromServer(
 | 
			
		||||
      scope: scopeToFetch.first,
 | 
			
		||||
      direct: true,
 | 
			
		||||
    );
 | 
			
		||||
    result.addAll(directMessages);
 | 
			
		||||
 | 
			
		||||
    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);
 | 
			
		||||
  Stream<List<SnChannel>> fetchChannels(
 | 
			
		||||
      {bool noRemote = false, bool noLocal = false}) async* {
 | 
			
		||||
    if (!noLocal) {
 | 
			
		||||
      final local = await (_dt.db.snLocalChatChannel.select()
 | 
			
		||||
            ..orderBy([
 | 
			
		||||
              (e) =>
 | 
			
		||||
                  OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc)
 | 
			
		||||
            ]))
 | 
			
		||||
          .get();
 | 
			
		||||
      final out = local.map((e) => e.content).toList();
 | 
			
		||||
      for (var idx = 0; idx < out.length; idx++) {
 | 
			
		||||
        final channel = out[idx];
 | 
			
		||||
        if (channel.realmId != null) {
 | 
			
		||||
          out[idx] = out[idx].copyWith(
 | 
			
		||||
            realm: await _rels.getRealm(channel.realmId!),
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      yield 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;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<List<SnChatMessage>> getLastMessages(
 | 
			
		||||
    Iterable<SnChannel> channels,
 | 
			
		||||
  ) async {
 | 
			
		||||
    final result = List<SnChatMessage>.empty(growable: true);
 | 
			
		||||
    final result = List<Future<SnLocalChatMessageData?>>.empty(growable: true);
 | 
			
		||||
    for (final channel in channels) {
 | 
			
		||||
      final channelBox = await Hive.openBox<SnChatMessage>(
 | 
			
		||||
        '${ChatMessageController.kChatMessageBoxPrefix}${channel.id}',
 | 
			
		||||
      );
 | 
			
		||||
      final lastMessage =
 | 
			
		||||
          channelBox.isNotEmpty ? channelBox.values.reduce((a, b) => a.createdAt.isAfter(b.createdAt) ? a : b) : null;
 | 
			
		||||
      if (lastMessage != null) result.add(lastMessage);
 | 
			
		||||
      channelBox.close();
 | 
			
		||||
      final out = (_dt.db.snLocalChatMessage.select()
 | 
			
		||||
            ..where((e) => e.channelId.equals(channel.id))
 | 
			
		||||
            ..orderBy([
 | 
			
		||||
              (e) =>
 | 
			
		||||
                  OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc)
 | 
			
		||||
            ])
 | 
			
		||||
            ..limit(1))
 | 
			
		||||
          .getSingleOrNull();
 | 
			
		||||
      result.add(out);
 | 
			
		||||
    }
 | 
			
		||||
    await _ud.listAccount(result.map((ele) => ele.sender.accountId).toSet());
 | 
			
		||||
    return result;
 | 
			
		||||
    final out = (await Future.wait(result))
 | 
			
		||||
        .where((e) => e != null)
 | 
			
		||||
        .map((e) => e!.content)
 | 
			
		||||
        .toList();
 | 
			
		||||
    await _ud.listAccount(out.map((ele) => ele.sender.accountId).toSet());
 | 
			
		||||
    return out;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    _channelBox?.close();
 | 
			
		||||
    super.dispose();
 | 
			
		||||
  Future<void> _saveMemberToLocal(Iterable<SnChannelMember> members) async {
 | 
			
		||||
    final queries = members.map((ele) {
 | 
			
		||||
      return _dt.db.snLocalChannelMember.insertOne(
 | 
			
		||||
        SnLocalChannelMemberCompanion.insert(
 | 
			
		||||
          id: Value(ele.id),
 | 
			
		||||
          channelId: ele.channelId,
 | 
			
		||||
          accountId: ele.accountId,
 | 
			
		||||
          content: ele,
 | 
			
		||||
          cacheExpiredAt: DateTime.now().add(const Duration(days: 7)),
 | 
			
		||||
        ),
 | 
			
		||||
        onConflict: DoUpdate(
 | 
			
		||||
          (_) => SnLocalChannelMemberCompanion.custom(
 | 
			
		||||
            content: Constant(jsonEncode(ele.toJson())),
 | 
			
		||||
            cacheExpiredAt:
 | 
			
		||||
                Constant(DateTime.now().add(const Duration(days: 7))),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
    await Future.wait(queries);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> removeLocalChannel(SnChannel channel) async {
 | 
			
		||||
    await _dt.db.transaction(() async {
 | 
			
		||||
      await (_dt.db.snLocalChannelMember.delete()
 | 
			
		||||
            ..where((e) => e.channelId.equals(channel.id)))
 | 
			
		||||
          .go();
 | 
			
		||||
      await (_dt.db.snLocalChatChannel.delete()
 | 
			
		||||
            ..where((e) => e.id.equals(channel.id)))
 | 
			
		||||
          .go();
 | 
			
		||||
      await (_dt.db.snLocalChatMessage.delete()
 | 
			
		||||
            ..where((e) => e.channelId.equals(channel.id)))
 | 
			
		||||
          .go();
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> updateChannelProfile(SnChannelMember member) {
 | 
			
		||||
    return _saveMemberToLocal([member]);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<SnChannelMember> getChannelProfile(SnChannel channel) async {
 | 
			
		||||
    if (_ua.user == null) throw Exception('User not logged in');
 | 
			
		||||
    final local = await (_dt.db.snLocalChannelMember.select()
 | 
			
		||||
          ..where((e) => e.channelId.equals(channel.id))
 | 
			
		||||
          ..where((e) => e.accountId.equals(_ua.user!.id)))
 | 
			
		||||
        .getSingleOrNull();
 | 
			
		||||
    if (local != null) {
 | 
			
		||||
      return local.content;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final resp = await _sn.client.get('/cgi/im/channels/${channel.keyPath}/me');
 | 
			
		||||
    final out = SnChannelMember.fromJson(resp.data);
 | 
			
		||||
    _saveMemberToLocal([out]);
 | 
			
		||||
    return out;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -13,10 +13,17 @@ const kNetworkServerStoreKey = 'app_server_url';
 | 
			
		||||
const kAppbarTransparentStoreKey = 'app_bar_transparent';
 | 
			
		||||
const kAppBackgroundStoreKey = 'app_has_background';
 | 
			
		||||
const kAppColorSchemeStoreKey = 'app_color_scheme';
 | 
			
		||||
const kAppDrawerPreferCollapse = 'app_drawer_prefer_collapse';
 | 
			
		||||
const kAppNotifyWithHaptic = 'app_notify_with_haptic';
 | 
			
		||||
const kAppExpandPostLink = 'app_expand_post_link';
 | 
			
		||||
const kAppExpandChatLink = 'app_expand_chat_link';
 | 
			
		||||
const kAppRealmCompactView = 'app_realm_compact_view';
 | 
			
		||||
const kAppCustomFonts = 'app_custom_fonts';
 | 
			
		||||
const kAppMixedFeed = 'app_mixed_feed';
 | 
			
		||||
const kAppAutoTranslate = 'app_auto_translate';
 | 
			
		||||
const kAppHideBottomNav = 'app_hide_bottom_nav';
 | 
			
		||||
const kAppSoundEffects = 'app_sound_effects';
 | 
			
		||||
const kAppAprilFoolFeatures = 'app_april_fool_features';
 | 
			
		||||
const kAppWindowSize = 'app_window_size';
 | 
			
		||||
 | 
			
		||||
const Map<String, FilterQuality> kImageQualityLevel = {
 | 
			
		||||
  'settingsImageQualityLowest': FilterQuality.none,
 | 
			
		||||
@@ -39,48 +46,97 @@ class ConfigProvider extends ChangeNotifier {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bool drawerIsCollapsed = false;
 | 
			
		||||
  bool drawerIsExpanded = false;
 | 
			
		||||
 | 
			
		||||
  void calcDrawerSize(BuildContext context, {bool withMediaQuery = false}) {
 | 
			
		||||
    bool newDrawerIsCollapsed = false;
 | 
			
		||||
    bool newDrawerIsExpanded = false;
 | 
			
		||||
    if (withMediaQuery) {
 | 
			
		||||
      newDrawerIsCollapsed = MediaQuery.of(context).size.width < 600;
 | 
			
		||||
      newDrawerIsExpanded = MediaQuery.of(context).size.width >= 601;
 | 
			
		||||
    } else {
 | 
			
		||||
      final rpb = ResponsiveBreakpoints.of(context);
 | 
			
		||||
      newDrawerIsCollapsed = rpb.smallerOrEqualTo(MOBILE);
 | 
			
		||||
      newDrawerIsExpanded = rpb.largerThan(TABLET)
 | 
			
		||||
          ? (prefs.getBool(kAppDrawerPreferCollapse) ?? false)
 | 
			
		||||
              ? false
 | 
			
		||||
              : true
 | 
			
		||||
          : false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (newDrawerIsExpanded != drawerIsExpanded || newDrawerIsCollapsed != drawerIsCollapsed) {
 | 
			
		||||
      drawerIsExpanded = newDrawerIsExpanded;
 | 
			
		||||
    if (newDrawerIsCollapsed != drawerIsCollapsed) {
 | 
			
		||||
      drawerIsCollapsed = newDrawerIsCollapsed;
 | 
			
		||||
      notifyListeners();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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 {
 | 
			
		||||
    return prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bool get realmCompactView {
 | 
			
		||||
    return prefs.getBool(kAppRealmCompactView) ?? false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bool get mixedFeed {
 | 
			
		||||
    return prefs.getBool(kAppMixedFeed) ?? true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bool get autoTranslate {
 | 
			
		||||
    return prefs.getBool(kAppAutoTranslate) ?? false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bool get hideBottomNav {
 | 
			
		||||
    return prefs.getBool(kAppHideBottomNav) ?? false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bool get aprilFoolFeatures {
 | 
			
		||||
    return prefs.getBool(kAppAprilFoolFeatures) ?? true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bool get soundEffects {
 | 
			
		||||
    return prefs.getBool(kAppSoundEffects) ?? true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  set soundEffects(bool value) {
 | 
			
		||||
    prefs.setBool(kAppSoundEffects, value);
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  set aprilFoolFeatures(bool value) {
 | 
			
		||||
    prefs.setBool(kAppAprilFoolFeatures, value);
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  set hideBottomNav(bool value) {
 | 
			
		||||
    prefs.setBool(kAppHideBottomNav, value);
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  set autoTranslate(bool value) {
 | 
			
		||||
    prefs.setBool(kAppAutoTranslate, value);
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  set mixedFeed(bool value) {
 | 
			
		||||
    prefs.setBool(kAppMixedFeed, value);
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  set realmCompactView(bool value) {
 | 
			
		||||
    prefs.setBool(kAppRealmCompactView, value);
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  set serverUrl(String url) {
 | 
			
		||||
    prefs.setString(kNetworkServerStoreKey, url);
 | 
			
		||||
    _home.saveWidgetData("nex_server_url", url);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  String? updatableVersion;
 | 
			
		||||
  String? updatableChangelog;
 | 
			
		||||
 | 
			
		||||
  void setUpdate(String newVersion) {
 | 
			
		||||
  void setUpdate(String newVersion, String newChangelog) {
 | 
			
		||||
    updatableVersion = newVersion;
 | 
			
		||||
    updatableChangelog = newChangelog;
 | 
			
		||||
    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:developer';
 | 
			
		||||
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:surface/logger.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/types/link.dart';
 | 
			
		||||
 | 
			
		||||
@@ -20,7 +20,7 @@ class SnLinkPreviewProvider {
 | 
			
		||||
    final target = b64.encode(url);
 | 
			
		||||
    if (_cache.containsKey(target)) return _cache[target];
 | 
			
		||||
 | 
			
		||||
    log('[LinkPreview] Fetching $url ($target)');
 | 
			
		||||
    logging.debug('[LinkPreview] Fetching $url ($target)');
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final resp = await _sn.client.get('/cgi/re/link/$target');
 | 
			
		||||
@@ -28,7 +28,7 @@ class SnLinkPreviewProvider {
 | 
			
		||||
      _cache[url] = meta;
 | 
			
		||||
      return meta;
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      log('[LinkPreview] Failed to fetch $url ($target)...');
 | 
			
		||||
      logging.warning('[LinkPreview] Failed to fetch $url ($target)...', err);
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,20 @@ import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:shared_preferences/shared_preferences.dart';
 | 
			
		||||
 | 
			
		||||
class AppNavListItem {
 | 
			
		||||
  final String title;
 | 
			
		||||
  final String subtitle;
 | 
			
		||||
  final String screen;
 | 
			
		||||
  final IconData icon;
 | 
			
		||||
 | 
			
		||||
  const AppNavListItem({
 | 
			
		||||
    required this.title,
 | 
			
		||||
    required this.subtitle,
 | 
			
		||||
    required this.screen,
 | 
			
		||||
    required this.icon,
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class AppNavDestination {
 | 
			
		||||
  final String label;
 | 
			
		||||
  final String screen;
 | 
			
		||||
@@ -24,13 +38,10 @@ class NavigationProvider extends ChangeNotifier {
 | 
			
		||||
 | 
			
		||||
  int? get currentIndex => _currentIndex;
 | 
			
		||||
 | 
			
		||||
  static const List<String> kShowBottomNavScreen = [
 | 
			
		||||
    'home',
 | 
			
		||||
    'explore',
 | 
			
		||||
    'account',
 | 
			
		||||
    'album',
 | 
			
		||||
    'chat',
 | 
			
		||||
  ];
 | 
			
		||||
  List<String> get showBottomNavScreen => destinations
 | 
			
		||||
      .where((ele) => ele.isPinned)
 | 
			
		||||
      .map((ele) => ele.screen)
 | 
			
		||||
      .toList();
 | 
			
		||||
 | 
			
		||||
  static const List<AppNavDestination> kAllDestination = [
 | 
			
		||||
    AppNavDestination(
 | 
			
		||||
@@ -48,11 +59,6 @@ class NavigationProvider extends ChangeNotifier {
 | 
			
		||||
      screen: 'chat',
 | 
			
		||||
      label: 'screenChat',
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavDestination(
 | 
			
		||||
      icon: Icon(Symbols.account_circle, weight: 400, opticalSize: 20),
 | 
			
		||||
      screen: 'account',
 | 
			
		||||
      label: 'screenAccount',
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavDestination(
 | 
			
		||||
      icon: Icon(Symbols.group, weight: 400, opticalSize: 20),
 | 
			
		||||
      screen: 'realm',
 | 
			
		||||
@@ -64,31 +70,22 @@ class NavigationProvider extends ChangeNotifier {
 | 
			
		||||
      label: 'screenNews',
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavDestination(
 | 
			
		||||
      icon: Icon(Symbols.photo_library, weight: 400, opticalSize: 20),
 | 
			
		||||
      screen: 'album',
 | 
			
		||||
      label: 'screenAlbum',
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavDestination(
 | 
			
		||||
      icon: Icon(Symbols.diversity_4, weight: 400, opticalSize: 20),
 | 
			
		||||
      screen: 'friend',
 | 
			
		||||
      label: 'screenFriend',
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavDestination(
 | 
			
		||||
      icon: Icon(Symbols.notifications, weight: 400, opticalSize: 20),
 | 
			
		||||
      screen: 'notification',
 | 
			
		||||
      label: 'screenNotification',
 | 
			
		||||
      icon: Icon(Symbols.settings, weight: 400, opticalSize: 20),
 | 
			
		||||
      screen: 'settings',
 | 
			
		||||
      label: 'screenSettings',
 | 
			
		||||
    ),
 | 
			
		||||
  ];
 | 
			
		||||
  static const List<String> kDefaultPinnedDestination = [
 | 
			
		||||
    'home',
 | 
			
		||||
    'explore',
 | 
			
		||||
    'chat',
 | 
			
		||||
    'account',
 | 
			
		||||
    'realm',
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  List<AppNavDestination> destinations = [];
 | 
			
		||||
 | 
			
		||||
  int get pinnedDestinationCount => destinations.where((ele) => ele.isPinned).length;
 | 
			
		||||
  int get pinnedDestinationCount =>
 | 
			
		||||
      destinations.where((ele) => ele.isPinned).length;
 | 
			
		||||
 | 
			
		||||
  NavigationProvider() {
 | 
			
		||||
    buildDestinations(kDefaultPinnedDestination);
 | 
			
		||||
@@ -117,13 +114,17 @@ class NavigationProvider extends ChangeNotifier {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bool isIndexInRange(int min, int max) {
 | 
			
		||||
    return _currentIndex != null && _currentIndex! >= min && _currentIndex! < max;
 | 
			
		||||
    return _currentIndex != null &&
 | 
			
		||||
        _currentIndex! >= min &&
 | 
			
		||||
        _currentIndex! < max;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void autoDetectIndex(GoRouter? state) {
 | 
			
		||||
    if (state == null) return;
 | 
			
		||||
    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;
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,15 @@
 | 
			
		||||
import 'dart:developer';
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
 | 
			
		||||
import 'package:audioplayers/audioplayers.dart';
 | 
			
		||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
 | 
			
		||||
import 'package:firebase_messaging/firebase_messaging.dart';
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:flutter_udid/flutter_udid.dart';
 | 
			
		||||
import 'package:local_notifier/local_notifier.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:surface/logger.dart';
 | 
			
		||||
import 'package:surface/providers/config.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
@@ -20,6 +23,8 @@ class NotificationProvider extends ChangeNotifier {
 | 
			
		||||
  late final WebSocketProvider _ws;
 | 
			
		||||
  late final ConfigProvider _cfg;
 | 
			
		||||
 | 
			
		||||
  final AudioPlayer _notifySoundPlayer = AudioPlayer(playerId: 'notify-sound');
 | 
			
		||||
 | 
			
		||||
  NotificationProvider(BuildContext context) {
 | 
			
		||||
    _sn = context.read<SnNetworkProvider>();
 | 
			
		||||
    _ua = context.read<UserProvider>();
 | 
			
		||||
@@ -46,11 +51,13 @@ class NotificationProvider extends ChangeNotifier {
 | 
			
		||||
    var deviceUuid = await FlutterUdid.consistentUdid;
 | 
			
		||||
 | 
			
		||||
    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;
 | 
			
		||||
    } else {
 | 
			
		||||
      log('Device UUID is $deviceUuid');
 | 
			
		||||
      log('Registering device push notifications...');
 | 
			
		||||
      logging.info('[Push Notification] Device UUID is $deviceUuid');
 | 
			
		||||
      logging
 | 
			
		||||
          .info('[Push Notification] Registering device push notifications...');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (Platform.isIOS || Platform.isMacOS) {
 | 
			
		||||
@@ -60,38 +67,81 @@ class NotificationProvider extends ChangeNotifier {
 | 
			
		||||
      provider = 'fcm';
 | 
			
		||||
      token = await FirebaseMessaging.instance.getToken();
 | 
			
		||||
    }
 | 
			
		||||
    log('Device Push Token is $token');
 | 
			
		||||
    logging.info('[Push Notification] Device Push Token is $token');
 | 
			
		||||
 | 
			
		||||
    await _sn.client.post(
 | 
			
		||||
      '/cgi/id/notifications/subscription',
 | 
			
		||||
      data: {
 | 
			
		||||
        'provider': provider,
 | 
			
		||||
        'device_token': token,
 | 
			
		||||
        'device_id': deviceUuid,
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
    try {
 | 
			
		||||
      await _sn.client.post(
 | 
			
		||||
        '/cgi/id/notifications/subscription',
 | 
			
		||||
        data: {
 | 
			
		||||
          'provider': provider,
 | 
			
		||||
          'device_token': token,
 | 
			
		||||
          'device_id': deviceUuid
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      logging.error(
 | 
			
		||||
          '[Push Notification] Unable to register push notifications: $err');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  int showingCount = 0;
 | 
			
		||||
  int showingTrayCount = 0;
 | 
			
		||||
  List<SnNotification> notifications = List.empty(growable: true);
 | 
			
		||||
 | 
			
		||||
  int? skippableNotifyChannel;
 | 
			
		||||
  bool isMuted = false;
 | 
			
		||||
 | 
			
		||||
  void listen() {
 | 
			
		||||
    _ws.pk.stream.listen((event) {
 | 
			
		||||
      if (event.method == 'notifications.new') {
 | 
			
		||||
        final notification = SnNotification.fromJson(event.payload!);
 | 
			
		||||
 | 
			
		||||
        final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true;
 | 
			
		||||
        if (doHaptic) HapticFeedback.mediumImpact();
 | 
			
		||||
 | 
			
		||||
        // April fool notification sfx
 | 
			
		||||
        if (_cfg.prefs.getBool(kAppAprilFoolFeatures) ?? true) {
 | 
			
		||||
          final now = DateTime.now();
 | 
			
		||||
          if (now.day == 1 && now.month == 4) {
 | 
			
		||||
            _notifySoundPlayer.play(
 | 
			
		||||
              AssetSource('audio/notify/metal-pipe.mp3'),
 | 
			
		||||
              volume: 0.6,
 | 
			
		||||
            );
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (notification.topic == 'messaging.message' &&
 | 
			
		||||
            skippableNotifyChannel != null) {
 | 
			
		||||
          if (notification.metadata['channel_id'] != null &&
 | 
			
		||||
              notification.metadata['channel_id'] == skippableNotifyChannel) {
 | 
			
		||||
            return;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (showingCount < 0) showingCount = 0;
 | 
			
		||||
        showingCount++;
 | 
			
		||||
        showingTrayCount++;
 | 
			
		||||
        notifications.add(notification);
 | 
			
		||||
        Future.delayed(const Duration(seconds: 3), () {
 | 
			
		||||
        Future.delayed(const Duration(seconds: 5), () {
 | 
			
		||||
          if (showingCount >= 0) showingCount--;
 | 
			
		||||
          notifyListeners();
 | 
			
		||||
        });
 | 
			
		||||
        notifyListeners();
 | 
			
		||||
        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,19 +2,23 @@ import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:surface/providers/sn_attachment.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/sn_realm.dart';
 | 
			
		||||
import 'package:surface/providers/user_directory.dart';
 | 
			
		||||
import 'package:surface/types/poll.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/types/realm.dart';
 | 
			
		||||
 | 
			
		||||
class SnPostContentProvider {
 | 
			
		||||
  late final SnNetworkProvider _sn;
 | 
			
		||||
  late final UserDirectoryProvider _ud;
 | 
			
		||||
  late final SnAttachmentProvider _attach;
 | 
			
		||||
  late final SnRealmProvider _realm;
 | 
			
		||||
 | 
			
		||||
  SnPostContentProvider(BuildContext context) {
 | 
			
		||||
    _sn = context.read<SnNetworkProvider>();
 | 
			
		||||
    _ud = context.read<UserDirectoryProvider>();
 | 
			
		||||
    _attach = context.read<SnAttachmentProvider>();
 | 
			
		||||
    _realm = context.read<SnRealmProvider>();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<SnPoll> _fetchPoll(int id) async {
 | 
			
		||||
@@ -24,6 +28,7 @@ class SnPostContentProvider {
 | 
			
		||||
 | 
			
		||||
  Future<List<SnPost>> _preloadRelatedDataInBatch(List<SnPost> out) async {
 | 
			
		||||
    Set<String> rids = {};
 | 
			
		||||
    Set<int> uids = {};
 | 
			
		||||
    for (var i = 0; i < out.length; i++) {
 | 
			
		||||
      rids.addAll(out[i].body['attachments']?.cast<String>() ?? []);
 | 
			
		||||
      if (out[i].body['thumbnail'] != null) {
 | 
			
		||||
@@ -37,34 +42,50 @@ class SnPostContentProvider {
 | 
			
		||||
          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());
 | 
			
		||||
    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(
 | 
			
		||||
        preload: SnPostPreload(
 | 
			
		||||
          thumbnail: attachments.where((ele) => ele?.rid == out[i].body['thumbnail']).firstOrNull,
 | 
			
		||||
          attachments: attachments.where((ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false).toList(),
 | 
			
		||||
          video: attachments.where((ele) => ele?.rid == out[i].body['video']).firstOrNull,
 | 
			
		||||
          thumbnail: attachments
 | 
			
		||||
              .where((ele) => ele?.rid == out[i].body['thumbnail'])
 | 
			
		||||
              .firstOrNull,
 | 
			
		||||
          attachments: attachments
 | 
			
		||||
              .where((ele) =>
 | 
			
		||||
                  out[i].body['attachments']?.contains(ele?.rid) ?? false)
 | 
			
		||||
              .toList(),
 | 
			
		||||
          video: attachments
 | 
			
		||||
              .where((ele) => ele?.rid == out[i].body['video'])
 | 
			
		||||
              .firstOrNull,
 | 
			
		||||
          poll: poll,
 | 
			
		||||
          realm: realm,
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await _ud.listAccount(
 | 
			
		||||
      attachments.where((ele) => ele != null).map((ele) => ele!.accountId).toSet(),
 | 
			
		||||
    );
 | 
			
		||||
    uids.addAll(
 | 
			
		||||
        attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
 | 
			
		||||
    await _ud.listAccount(uids);
 | 
			
		||||
 | 
			
		||||
    return out;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<SnPost> _preloadRelatedDataSingle(SnPost out) async {
 | 
			
		||||
    Set<String> rids = {};
 | 
			
		||||
    Set<int> uids = {};
 | 
			
		||||
    rids.addAll(out.body['attachments']?.cast<String>() ?? []);
 | 
			
		||||
    if (out.body['thumbnail'] != null) {
 | 
			
		||||
      rids.add(out.body['thumbnail']);
 | 
			
		||||
@@ -77,23 +98,42 @@ class SnPostContentProvider {
 | 
			
		||||
        repostTo: await _preloadRelatedDataSingle(out.repostTo!),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    if (out.publisher.type == 0) {
 | 
			
		||||
      uids.add(out.publisher.accountId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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(
 | 
			
		||||
      preload: SnPostPreload(
 | 
			
		||||
        thumbnail: attachments.where((ele) => ele?.rid == out.body['thumbnail']).firstOrNull,
 | 
			
		||||
        attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(),
 | 
			
		||||
        video: attachments.where((ele) => ele?.rid == out.body['video']).firstOrNull,
 | 
			
		||||
        thumbnail: attachments
 | 
			
		||||
            .where((ele) => ele?.rid == out.body['thumbnail'])
 | 
			
		||||
            .firstOrNull,
 | 
			
		||||
        attachments: attachments
 | 
			
		||||
            .where(
 | 
			
		||||
                (ele) => out.body['attachments']?.contains(ele?.rid) ?? false)
 | 
			
		||||
            .toList(),
 | 
			
		||||
        video: attachments
 | 
			
		||||
            .where((ele) => ele?.rid == out.body['video'])
 | 
			
		||||
            .firstOrNull,
 | 
			
		||||
        poll: poll,
 | 
			
		||||
        realm: realm,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    uids.addAll(
 | 
			
		||||
        attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
 | 
			
		||||
    await _ud.listAccount(uids);
 | 
			
		||||
 | 
			
		||||
    return out;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -105,6 +145,36 @@ class SnPostContentProvider {
 | 
			
		||||
    return out;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<List<SnFeedEntry>> getFeed({int take = 20, DateTime? cursor}) async {
 | 
			
		||||
    final resp =
 | 
			
		||||
        await _sn.client.get('/cgi/co/recommendations/feed', queryParameters: {
 | 
			
		||||
      'take': take,
 | 
			
		||||
      if (cursor != null) 'cursor': cursor.toUtc().millisecondsSinceEpoch,
 | 
			
		||||
    });
 | 
			
		||||
    final List<SnFeedEntry> out =
 | 
			
		||||
        List.from(resp.data.map((ele) => SnFeedEntry.fromJson(ele)));
 | 
			
		||||
 | 
			
		||||
    List<SnPost> posts = List.empty(growable: true);
 | 
			
		||||
    for (var idx = 0; idx < out.length; idx++) {
 | 
			
		||||
      final ele = out[idx];
 | 
			
		||||
      if (ele.type == 'interactive.post') {
 | 
			
		||||
        posts.add(SnPost.fromJson(ele.data));
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    posts = await _preloadRelatedDataInBatch(posts);
 | 
			
		||||
 | 
			
		||||
    var postsIdx = 0;
 | 
			
		||||
    for (var idx = 0; idx < out.length; idx++) {
 | 
			
		||||
      final ele = out[idx];
 | 
			
		||||
      if (ele.type == 'interactive.post') {
 | 
			
		||||
        out[idx] = ele.copyWith(data: posts[postsIdx].toJson());
 | 
			
		||||
        postsIdx++;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return out;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<(List<SnPost>, int)> listPosts({
 | 
			
		||||
    int take = 10,
 | 
			
		||||
    int offset = 0,
 | 
			
		||||
@@ -112,15 +182,27 @@ class SnPostContentProvider {
 | 
			
		||||
    String? author,
 | 
			
		||||
    Iterable<String>? categories,
 | 
			
		||||
    Iterable<String>? tags,
 | 
			
		||||
    String? realm,
 | 
			
		||||
    String? channel,
 | 
			
		||||
    bool isDraft = false,
 | 
			
		||||
    bool isShuffle = false,
 | 
			
		||||
  }) async {
 | 
			
		||||
    final resp = await _sn.client.get('/cgi/co/posts', queryParameters: {
 | 
			
		||||
      'take': take,
 | 
			
		||||
      'offset': offset,
 | 
			
		||||
      if (type != null) 'type': type,
 | 
			
		||||
      if (author != null) 'author': author,
 | 
			
		||||
      if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
 | 
			
		||||
      if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','),
 | 
			
		||||
    });
 | 
			
		||||
    final resp = await _sn.client.get(
 | 
			
		||||
      isShuffle
 | 
			
		||||
          ? '/cgi/co/recommendations/shuffle'
 | 
			
		||||
          : '/cgi/co/posts${isDraft ? '/drafts' : ''}',
 | 
			
		||||
      queryParameters: {
 | 
			
		||||
        'take': take,
 | 
			
		||||
        'offset': offset,
 | 
			
		||||
        if (type != null) 'type': type,
 | 
			
		||||
        if (author != null) 'author': author,
 | 
			
		||||
        if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
 | 
			
		||||
        if (categories?.isNotEmpty ?? false)
 | 
			
		||||
          'categories': categories!.join(','),
 | 
			
		||||
        if (realm != null) 'realm': realm,
 | 
			
		||||
        if (channel != null) 'channel': channel,
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
    final List<SnPost> out = await _preloadRelatedDataInBatch(
 | 
			
		||||
      List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
 | 
			
		||||
    );
 | 
			
		||||
@@ -133,7 +215,8 @@ class SnPostContentProvider {
 | 
			
		||||
    int take = 10,
 | 
			
		||||
    int offset = 0,
 | 
			
		||||
  }) async {
 | 
			
		||||
    final resp = await _sn.client.get('/cgi/co/posts/$parentId/replies', queryParameters: {
 | 
			
		||||
    final resp = await _sn.client
 | 
			
		||||
        .get('/cgi/co/posts/$parentId/replies', queryParameters: {
 | 
			
		||||
      'take': take,
 | 
			
		||||
      'offset': offset,
 | 
			
		||||
    });
 | 
			
		||||
@@ -172,4 +255,9 @@ class SnPostContentProvider {
 | 
			
		||||
    );
 | 
			
		||||
    return out;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<SnPost> completePostData(SnPost post) async {
 | 
			
		||||
    final out = await _preloadRelatedDataSingle(post);
 | 
			
		||||
    return out;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,14 @@
 | 
			
		||||
import 'dart:collection';
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
import 'dart:math' as math;
 | 
			
		||||
import 'dart:typed_data';
 | 
			
		||||
 | 
			
		||||
import 'package:dio/dio.dart';
 | 
			
		||||
import 'package:drift/drift.dart';
 | 
			
		||||
import 'package:flutter/widgets.dart';
 | 
			
		||||
import 'package:cross_file/cross_file.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:surface/database/database.dart';
 | 
			
		||||
import 'package:surface/providers/database.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/types/attachment.dart';
 | 
			
		||||
 | 
			
		||||
@@ -13,10 +16,12 @@ const kConcurrentUploadChunks = 5;
 | 
			
		||||
 | 
			
		||||
class SnAttachmentProvider {
 | 
			
		||||
  late final SnNetworkProvider _sn;
 | 
			
		||||
  late final DatabaseProvider _dt;
 | 
			
		||||
  final Map<String, SnAttachment> _cache = {};
 | 
			
		||||
 | 
			
		||||
  SnAttachmentProvider(BuildContext context) {
 | 
			
		||||
    _sn = context.read<SnNetworkProvider>();
 | 
			
		||||
    _dt = context.read<DatabaseProvider>();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) {
 | 
			
		||||
@@ -28,20 +33,33 @@ class SnAttachmentProvider {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<SnAttachment> getOne(String rid, {noCache = false}) async {
 | 
			
		||||
    // In-memory cache
 | 
			
		||||
    if (!noCache && _cache.containsKey(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 out = SnAttachment.fromJson(resp.data);
 | 
			
		||||
    if (out.isAnalyzed) {
 | 
			
		||||
      _cache[rid] = out;
 | 
			
		||||
    }
 | 
			
		||||
    _saveToLocal([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 Map<String, int> randomMapping = {};
 | 
			
		||||
    for (int i = 0; i < rids.length; i++) {
 | 
			
		||||
@@ -52,32 +70,55 @@ class SnAttachmentProvider {
 | 
			
		||||
        result[i] = _cache[rid]!;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    final pendingFetch = randomMapping.keys;
 | 
			
		||||
 | 
			
		||||
    if (pendingFetch.isNotEmpty) {
 | 
			
		||||
      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;
 | 
			
		||||
    var pendingFetch = randomMapping.keys;
 | 
			
		||||
    // On-disk cache
 | 
			
		||||
    if (pendingFetch.isEmpty) return result;
 | 
			
		||||
    if (!noCache) {
 | 
			
		||||
      final dbResp = await (_dt.db.snLocalAttachment.select()
 | 
			
		||||
            ..where((e) => e.rid.isIn(pendingFetch))
 | 
			
		||||
            ..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())))
 | 
			
		||||
          .get();
 | 
			
		||||
      for (final item in dbResp) {
 | 
			
		||||
        if (item.content.isAnalyzed) {
 | 
			
		||||
          _cache[item.rid] = item.content;
 | 
			
		||||
        }
 | 
			
		||||
        result[randomMapping[item.rid]!] = item;
 | 
			
		||||
        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;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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(
 | 
			
		||||
    Uint8List data,
 | 
			
		||||
@@ -89,8 +130,11 @@ class SnAttachmentProvider {
 | 
			
		||||
    bool analyzeNow = false,
 | 
			
		||||
  }) async {
 | 
			
		||||
    final filePayload = MultipartFile.fromBytes(data, filename: filename);
 | 
			
		||||
    final fileAlt = filename.contains('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename;
 | 
			
		||||
    final fileExt = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
 | 
			
		||||
    final fileAlt = filename.contains('.')
 | 
			
		||||
        ? filename.substring(0, filename.lastIndexOf('.'))
 | 
			
		||||
        : filename;
 | 
			
		||||
    final fileExt =
 | 
			
		||||
        filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
 | 
			
		||||
 | 
			
		||||
    String? mimetypeOverride;
 | 
			
		||||
    if (mimetype != null) {
 | 
			
		||||
@@ -127,8 +171,11 @@ class SnAttachmentProvider {
 | 
			
		||||
    Map<String, dynamic>? metadata, {
 | 
			
		||||
    String? mimetype,
 | 
			
		||||
  }) async {
 | 
			
		||||
    final fileAlt = filename.contains('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename;
 | 
			
		||||
    final fileExt = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
 | 
			
		||||
    final fileAlt = filename.contains('.')
 | 
			
		||||
        ? filename.substring(0, filename.lastIndexOf('.'))
 | 
			
		||||
        : filename;
 | 
			
		||||
    final fileExt =
 | 
			
		||||
        filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
 | 
			
		||||
 | 
			
		||||
    String? mimetypeOverride;
 | 
			
		||||
    if (mimetype == null && mimetypeOverrides.keys.contains(fileExt)) {
 | 
			
		||||
@@ -146,7 +193,10 @@ class SnAttachmentProvider {
 | 
			
		||||
      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(
 | 
			
		||||
@@ -197,7 +247,10 @@ class SnAttachmentProvider {
 | 
			
		||||
          (entry.value + 1) * chunkSize,
 | 
			
		||||
          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(
 | 
			
		||||
          data,
 | 
			
		||||
@@ -253,6 +306,31 @@ class SnAttachmentProvider {
 | 
			
		||||
      'metadata': metadata ?? item.usermeta,
 | 
			
		||||
      'is_indexable': isIndexable ?? item.isIndexable,
 | 
			
		||||
    });
 | 
			
		||||
    return SnAttachment.fromJson(resp.data);
 | 
			
		||||
    final out = SnAttachment.fromJson(resp.data);
 | 
			
		||||
    _saveToLocal([out]);
 | 
			
		||||
    return out;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _saveToLocal(Iterable<SnAttachment> out) async {
 | 
			
		||||
    for (final ele in out) {
 | 
			
		||||
      if (!ele.isAnalyzed || ele.destination == 0) continue;
 | 
			
		||||
      await _dt.db.snLocalAttachment.insertOne(
 | 
			
		||||
        SnLocalAttachmentCompanion.insert(
 | 
			
		||||
          id: Value(ele.id),
 | 
			
		||||
          rid: ele.rid,
 | 
			
		||||
          uuid: ele.uuid,
 | 
			
		||||
          content: ele,
 | 
			
		||||
          accountId: ele.accountId,
 | 
			
		||||
          cacheExpiredAt: DateTime.now().add(const Duration(hours: 1)),
 | 
			
		||||
        ),
 | 
			
		||||
        onConflict: DoUpdate(
 | 
			
		||||
          (_) => SnLocalAttachmentCompanion.custom(
 | 
			
		||||
            content: Constant(jsonEncode(ele.toJson())),
 | 
			
		||||
            cacheExpiredAt:
 | 
			
		||||
                Constant(DateTime.now().add(const Duration(hours: 1))),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,5 @@
 | 
			
		||||
import 'dart:async';
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
import 'dart:developer';
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
 | 
			
		||||
import 'package:dio/dio.dart';
 | 
			
		||||
@@ -11,9 +10,26 @@ import 'package:package_info_plus/package_info_plus.dart';
 | 
			
		||||
import 'package:device_info_plus/device_info_plus.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:shared_preferences/shared_preferences.dart';
 | 
			
		||||
import 'package:surface/logger.dart';
 | 
			
		||||
import 'package:surface/providers/config.dart';
 | 
			
		||||
import 'package:surface/providers/widget.dart';
 | 
			
		||||
import 'package:synchronized/synchronized.dart';
 | 
			
		||||
import 'package:talker_dio_logger/talker_dio_logger_interceptor.dart';
 | 
			
		||||
import 'package:talker_dio_logger/talker_dio_logger_settings.dart';
 | 
			
		||||
 | 
			
		||||
enum ServiceStatus { operational, downgraded, failed }
 | 
			
		||||
 | 
			
		||||
const Map<String, String> kServicesName = {
 | 
			
		||||
  'ai': 'Insights',
 | 
			
		||||
  'co': 'Interactive',
 | 
			
		||||
  're': 'Reader',
 | 
			
		||||
  'im': 'Messaging',
 | 
			
		||||
  'ma': 'Matrix',
 | 
			
		||||
  'uc': 'Paperclip',
 | 
			
		||||
  'wa': 'Wallet',
 | 
			
		||||
  'id': 'Passport',
 | 
			
		||||
  'pusher': 'Pusher',
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const kNetworkServerDirectory = [
 | 
			
		||||
  ('Solar Network', 'https://api.sn.solsynth.dev'),
 | 
			
		||||
@@ -36,6 +52,19 @@ class SnNetworkProvider {
 | 
			
		||||
 | 
			
		||||
    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(
 | 
			
		||||
      dio: client,
 | 
			
		||||
      retries: 3,
 | 
			
		||||
@@ -69,7 +98,6 @@ class SnNetworkProvider {
 | 
			
		||||
      _prefs = _config.prefs;
 | 
			
		||||
      client.options.baseUrl = _config.serverUrl;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static Future<Dio> createOffContextClient() async {
 | 
			
		||||
@@ -91,7 +119,8 @@ class SnNetworkProvider {
 | 
			
		||||
          RequestOptions options,
 | 
			
		||||
          RequestInterceptorHandler handler,
 | 
			
		||||
        ) 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(kRtkStoreKey, rtk);
 | 
			
		||||
          });
 | 
			
		||||
@@ -103,7 +132,8 @@ class SnNetworkProvider {
 | 
			
		||||
        },
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    client.options.baseUrl = prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
 | 
			
		||||
    client.options.baseUrl =
 | 
			
		||||
        prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
 | 
			
		||||
 | 
			
		||||
    return client;
 | 
			
		||||
  }
 | 
			
		||||
@@ -119,7 +149,8 @@ class SnNetworkProvider {
 | 
			
		||||
      platformInfo = 'Web; ${deviceInfo.vendor}';
 | 
			
		||||
    } else if (Platform.isAndroid) {
 | 
			
		||||
      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) {
 | 
			
		||||
      final deviceInfo = await DeviceInfoPlugin().iosInfo;
 | 
			
		||||
      platformInfo = 'iOS; ${deviceInfo.model}; ${deviceInfo.name}';
 | 
			
		||||
@@ -128,7 +159,8 @@ class SnNetworkProvider {
 | 
			
		||||
      platformInfo = 'MacOS; ${deviceInfo.model}; ${deviceInfo.hostName}';
 | 
			
		||||
    } else if (Platform.isWindows) {
 | 
			
		||||
      final deviceInfo = await DeviceInfoPlugin().windowsInfo;
 | 
			
		||||
      platformInfo = 'Windows NT; ${deviceInfo.productName}; ${deviceInfo.computerName}';
 | 
			
		||||
      platformInfo =
 | 
			
		||||
          'Windows NT; ${deviceInfo.productName}; ${deviceInfo.computerName}';
 | 
			
		||||
    } else if (Platform.isLinux) {
 | 
			
		||||
      final deviceInfo = await DeviceInfoPlugin().linuxInfo;
 | 
			
		||||
      platformInfo = 'Linux; ${deviceInfo.prettyName}';
 | 
			
		||||
@@ -148,12 +180,15 @@ class SnNetworkProvider {
 | 
			
		||||
  final tkLock = Lock();
 | 
			
		||||
 | 
			
		||||
  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);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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) {
 | 
			
		||||
      return await _refreshCompleter!.future;
 | 
			
		||||
    } else {
 | 
			
		||||
@@ -185,7 +220,8 @@ class SnNetworkProvider {
 | 
			
		||||
        final payload = b64.decode(rawPayload);
 | 
			
		||||
        final exp = jsonDecode(payload)['exp'];
 | 
			
		||||
        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);
 | 
			
		||||
          if (result == null) {
 | 
			
		||||
            atk = null;
 | 
			
		||||
@@ -199,12 +235,12 @@ class SnNetworkProvider {
 | 
			
		||||
          _refreshCompleter!.complete(atk);
 | 
			
		||||
          return atk;
 | 
			
		||||
        } else {
 | 
			
		||||
          log('Access token refresh failed...');
 | 
			
		||||
          logging.error('[Auth] Access token refresh failed...');
 | 
			
		||||
          _refreshCompleter!.complete(null);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      log('Failed to authenticate user: $err');
 | 
			
		||||
      logging.error('[Auth] Failed to authenticate user...', err);
 | 
			
		||||
      _refreshCompleter!.completeError(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      _refreshCompleter = null;
 | 
			
		||||
@@ -237,7 +273,8 @@ class SnNetworkProvider {
 | 
			
		||||
    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;
 | 
			
		||||
 | 
			
		||||
    final dio = Dio();
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										90
									
								
								lib/providers/sn_realm.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								lib/providers/sn_realm.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,90 @@
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
 | 
			
		||||
import 'package:drift/drift.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:surface/database/database.dart';
 | 
			
		||||
import 'package:surface/providers/database.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/types/realm.dart';
 | 
			
		||||
 | 
			
		||||
class SnRealmProvider extends ChangeNotifier {
 | 
			
		||||
  late final SnNetworkProvider _sn;
 | 
			
		||||
  late final DatabaseProvider _dt;
 | 
			
		||||
 | 
			
		||||
  SnRealmProvider(BuildContext context) {
 | 
			
		||||
    _sn = context.read<SnNetworkProvider>();
 | 
			
		||||
    _dt = context.read<DatabaseProvider>();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  final Map<String, SnRealm> _cache = {};
 | 
			
		||||
  List<SnRealm> _availableRealms = List.empty(growable: true);
 | 
			
		||||
 | 
			
		||||
  Future<void> refreshAvailableRealms() async {
 | 
			
		||||
    _availableRealms = await listAvailableRealms();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  List<SnRealm> get availableRealms => _availableRealms;
 | 
			
		||||
 | 
			
		||||
  Future<List<SnRealm>> listAvailableRealms() async {
 | 
			
		||||
    final resp = await _sn.client.get('/cgi/id/realms/me/available');
 | 
			
		||||
    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;
 | 
			
		||||
    }
 | 
			
		||||
    _saveToLocal(out);
 | 
			
		||||
    return out;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void addAvailableRealm(SnRealm realm) {
 | 
			
		||||
    _availableRealms.add(realm);
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<SnRealm> getRealm(dynamic aliasOrId) async {
 | 
			
		||||
    if (_cache.containsKey(aliasOrId.toString())) {
 | 
			
		||||
      return _cache[aliasOrId.toString()]!;
 | 
			
		||||
    }
 | 
			
		||||
    final localResp = await (_dt.db.snLocalRealm.select()
 | 
			
		||||
          ..where((e) =>
 | 
			
		||||
              e.id.equals(aliasOrId is int ? aliasOrId : 0) |
 | 
			
		||||
              e.alias.equals(aliasOrId.toString()))
 | 
			
		||||
          ..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())))
 | 
			
		||||
        .getSingleOrNull();
 | 
			
		||||
    if (localResp != null) {
 | 
			
		||||
      _cache[localResp.content.id.toString()] = localResp.content;
 | 
			
		||||
      _cache[localResp.content.alias] = localResp.content;
 | 
			
		||||
      return localResp.content;
 | 
			
		||||
    }
 | 
			
		||||
    final resp = await _sn.client.get('/cgi/id/realms/$aliasOrId');
 | 
			
		||||
    final out = SnRealm.fromJson(resp.data);
 | 
			
		||||
    _cache[out.alias] = out;
 | 
			
		||||
    _cache[out.id.toString()] = out;
 | 
			
		||||
    _saveToLocal([out]);
 | 
			
		||||
    return out;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _saveToLocal(Iterable<SnRealm> out) async {
 | 
			
		||||
    for (final ele in out) {
 | 
			
		||||
      await _dt.db.snLocalRealm.insertOne(
 | 
			
		||||
        SnLocalRealmCompanion.insert(
 | 
			
		||||
          id: Value(ele.id),
 | 
			
		||||
          alias: ele.alias,
 | 
			
		||||
          content: ele,
 | 
			
		||||
          accountId: ele.accountId,
 | 
			
		||||
          cacheExpiredAt: DateTime.now().add(const Duration(hours: 1)),
 | 
			
		||||
        ),
 | 
			
		||||
        onConflict: DoUpdate(
 | 
			
		||||
          (_) => SnLocalRealmCompanion.custom(
 | 
			
		||||
            content: Constant(jsonEncode(ele.toJson())),
 | 
			
		||||
            cacheExpiredAt:
 | 
			
		||||
                Constant(DateTime.now().add(const Duration(hours: 1))),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,20 +1,27 @@
 | 
			
		||||
import 'dart:developer';
 | 
			
		||||
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/sn_network.dart';
 | 
			
		||||
import 'package:surface/types/attachment.dart';
 | 
			
		||||
 | 
			
		||||
class SnStickerProvider {
 | 
			
		||||
  late final SnNetworkProvider _sn;
 | 
			
		||||
  late final DatabaseProvider _dt;
 | 
			
		||||
  final Map<String, SnSticker?> _cache = {};
 | 
			
		||||
 | 
			
		||||
  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) {
 | 
			
		||||
    _sn = context.read<SnNetworkProvider>();
 | 
			
		||||
    _dt = context.read<DatabaseProvider>();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bool hasNotSticker(String alias) {
 | 
			
		||||
@@ -23,52 +30,103 @@ class SnStickerProvider {
 | 
			
		||||
 | 
			
		||||
  void _cacheSticker(SnSticker sticker) {
 | 
			
		||||
    _cache['${sticker.pack.prefix}:${sticker.alias}'] = sticker;
 | 
			
		||||
    if (stickersByPack[sticker.pack.id] == null) stickersByPack[sticker.pack.id] = List.empty(growable: true);
 | 
			
		||||
    if (!stickersByPack[sticker.pack.id]!.contains(sticker)) stickersByPack[sticker.pack.id]!.add(sticker);
 | 
			
		||||
    if (stickersByPack[sticker.pack.id] == null) {
 | 
			
		||||
      stickersByPack[sticker.pack.id] = List.empty(growable: true);
 | 
			
		||||
    }
 | 
			
		||||
    if (!stickersByPack[sticker.pack.id]!.contains(sticker)) {
 | 
			
		||||
      stickersByPack[sticker.pack.id]!.add(sticker);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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 {
 | 
			
		||||
    // In-memory cache
 | 
			
		||||
    if (_cache.containsKey(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 {
 | 
			
		||||
      final resp = await _sn.client.get('/cgi/uc/stickers/lookup/$alias');
 | 
			
		||||
      final sticker = SnSticker.fromJson(resp.data);
 | 
			
		||||
      _cacheSticker(sticker);
 | 
			
		||||
 | 
			
		||||
      putSticker([sticker]);
 | 
			
		||||
      return sticker;
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      _cache[alias] = null;
 | 
			
		||||
      log('[Sticker] Failed to lookup sticker $alias: $err');
 | 
			
		||||
      logging.warning('[Sticker] Failed to lookup sticker $alias', err);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> listStickerEagerly() async {
 | 
			
		||||
    var count = await listSticker();
 | 
			
		||||
    for (var page = 1; count > 0; count -= 10) {
 | 
			
		||||
      await listSticker(page: page);
 | 
			
		||||
      page++;
 | 
			
		||||
  Future<void> listSticker() async {
 | 
			
		||||
    final localPacks = await _dt.db.snLocalStickerPack.select().get();
 | 
			
		||||
    final localStickers = await _dt.db.snLocalSticker.select().get();
 | 
			
		||||
    final local = localStickers.map((ele) {
 | 
			
		||||
      return ele.content.copyWith(
 | 
			
		||||
        pack: localPacks
 | 
			
		||||
            .firstWhere((pk) => pk.content.id == ele.content.packId)
 | 
			
		||||
            .content,
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
    for (final sticker in local) {
 | 
			
		||||
      _cacheSticker(sticker);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<int> listSticker({int page = 0}) async {
 | 
			
		||||
    try {
 | 
			
		||||
      final resp = await _sn.client.get('/cgi/uc/stickers', queryParameters: {
 | 
			
		||||
        'take': 10,
 | 
			
		||||
        'offset': page * 10,
 | 
			
		||||
      });
 | 
			
		||||
      final resp = await _sn.client.get('/cgi/uc/stickers');
 | 
			
		||||
      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) {
 | 
			
		||||
        _cacheSticker(sticker);
 | 
			
		||||
      }
 | 
			
		||||
      return data['count'] as int;
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      log('[Sticker] Failed to list stickers: $err');
 | 
			
		||||
      logging.error('[Sticker] Failed to list stickers...', err);
 | 
			
		||||
      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}) {
 | 
			
		||||
    createAppThemeSet(seedColorOverride: seedColorOverride, useMaterial3: useMaterial3).then((value) {
 | 
			
		||||
  void reloadTheme({
 | 
			
		||||
    Color? seedColorOverride,
 | 
			
		||||
    bool? useMaterial3,
 | 
			
		||||
    String? customFonts,
 | 
			
		||||
  }) {
 | 
			
		||||
    createAppThemeSet(
 | 
			
		||||
      seedColorOverride: seedColorOverride,
 | 
			
		||||
      useMaterial3: useMaterial3,
 | 
			
		||||
      customFonts: customFonts,
 | 
			
		||||
    ).then((value) {
 | 
			
		||||
      theme = value;
 | 
			
		||||
      notifyListeners();
 | 
			
		||||
    });
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										55
									
								
								lib/providers/translation.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								lib/providers/translation.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,55 @@
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
 | 
			
		||||
import 'package:crypto/crypto.dart';
 | 
			
		||||
import 'package:dio/dio.dart';
 | 
			
		||||
import 'package:surface/logger.dart';
 | 
			
		||||
 | 
			
		||||
const kTranslateApiBaseUrl = 'https://translate.solsynth.dev';
 | 
			
		||||
 | 
			
		||||
class SnTranslator {
 | 
			
		||||
  final Dio client = Dio(
 | 
			
		||||
    BaseOptions(
 | 
			
		||||
      baseUrl: kTranslateApiBaseUrl,
 | 
			
		||||
      connectTimeout: Duration(seconds: 3),
 | 
			
		||||
      sendTimeout: Duration(seconds: 3),
 | 
			
		||||
      receiveTimeout: Duration(seconds: 3),
 | 
			
		||||
    ),
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  final Map<String, String> _cache = {};
 | 
			
		||||
 | 
			
		||||
  Future<String> translate(
 | 
			
		||||
    String text, {
 | 
			
		||||
    required String to,
 | 
			
		||||
    String from = 'auto',
 | 
			
		||||
    bool skipCache = false,
 | 
			
		||||
  }) async {
 | 
			
		||||
    if (text.isEmpty) return text;
 | 
			
		||||
 | 
			
		||||
    final cacheKey = md5.convert(utf8.encode('$text$from$to')).toString();
 | 
			
		||||
    if (!skipCache && _cache.containsKey(cacheKey)) {
 | 
			
		||||
      return _cache[cacheKey]!;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    logging.info('[Translator] Translate $text from $from to $to');
 | 
			
		||||
 | 
			
		||||
    final resp = await client.post(
 | 
			
		||||
      '/translate',
 | 
			
		||||
      data: {
 | 
			
		||||
        'q': text,
 | 
			
		||||
        'source': from,
 | 
			
		||||
        'target': to,
 | 
			
		||||
        'format': 'text',
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
    if (resp.statusCode == 200) {
 | 
			
		||||
      final out = resp.data['translatedText'];
 | 
			
		||||
      if (out.isNotEmpty) {
 | 
			
		||||
        logging.info('[Translator] Translated $text from $from to $to');
 | 
			
		||||
        _cache[cacheKey] = out;
 | 
			
		||||
        return out;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    throw Exception('translate failed: $resp');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,19 +1,44 @@
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
 | 
			
		||||
import 'package:drift/drift.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:surface/database/database.dart';
 | 
			
		||||
import 'package:surface/providers/database.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/types/account.dart';
 | 
			
		||||
 | 
			
		||||
class UserDirectoryProvider {
 | 
			
		||||
  late final SnNetworkProvider _sn;
 | 
			
		||||
  late final DatabaseProvider _dt;
 | 
			
		||||
 | 
			
		||||
  UserDirectoryProvider(BuildContext context) {
 | 
			
		||||
    _sn = context.read<SnNetworkProvider>();
 | 
			
		||||
    _dt = context.read<DatabaseProvider>();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  final Map<String, int> _idCache = {};
 | 
			
		||||
  final Map<int, SnAccount> _cache = {};
 | 
			
		||||
  DateTime? _cacheExpiredAt;
 | 
			
		||||
 | 
			
		||||
  Future<int> loadAccountCache({int max = 100}) async {
 | 
			
		||||
    final out = await (_dt.db.snLocalAccount.select()..limit(max)).get();
 | 
			
		||||
    for (final ele in out) {
 | 
			
		||||
      _cache[ele.id] = ele.content;
 | 
			
		||||
      _idCache[ele.name] = ele.id;
 | 
			
		||||
    }
 | 
			
		||||
    _cacheExpiredAt = DateTime.now().add(const Duration(hours: 1));
 | 
			
		||||
    return out.length;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async {
 | 
			
		||||
    // In-memory cache
 | 
			
		||||
    if (_cacheExpiredAt != null && _cacheExpiredAt!.isBefore(DateTime.now())) {
 | 
			
		||||
      _cache.clear();
 | 
			
		||||
      _cacheExpiredAt = DateTime.now().add(const Duration(hours: 1));
 | 
			
		||||
    } else {
 | 
			
		||||
      _cacheExpiredAt ??= DateTime.now().add(const Duration(hours: 1));
 | 
			
		||||
    }
 | 
			
		||||
    final out = List<SnAccount?>.generate(id.length, (e) => null);
 | 
			
		||||
    final plannedQuery = <int>{};
 | 
			
		||||
    for (var idx = 0; idx < out.length; idx++) {
 | 
			
		||||
@@ -27,8 +52,30 @@ class UserDirectoryProvider {
 | 
			
		||||
        plannedQuery.add(item);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    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();
 | 
			
		||||
    // 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
 | 
			
		||||
    _saveToLocal(out.where((ele) => ele != null).cast());
 | 
			
		||||
    if (plannedQuery.isEmpty) return out;
 | 
			
		||||
    final resp = await _sn.client
 | 
			
		||||
        .get('/cgi/id/users', queryParameters: {'id': plannedQuery.join(',')});
 | 
			
		||||
    final respDecoded =
 | 
			
		||||
        resp.data.map((e) => SnAccount.fromJson(e)).cast<SnAccount>().toList();
 | 
			
		||||
    var sideIdx = 0;
 | 
			
		||||
    for (var idx = 0; idx < out.length; idx++) {
 | 
			
		||||
      if (out[idx] != null) continue;
 | 
			
		||||
@@ -40,17 +87,29 @@ class UserDirectoryProvider {
 | 
			
		||||
      _idCache[respDecoded[sideIdx].name] = respDecoded[sideIdx].id;
 | 
			
		||||
      sideIdx++;
 | 
			
		||||
    }
 | 
			
		||||
    if (respDecoded.isNotEmpty) _saveToLocal(respDecoded);
 | 
			
		||||
    return out;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<SnAccount?> getAccount(dynamic id) async {
 | 
			
		||||
    // In-memory cache
 | 
			
		||||
    if (id is String && _idCache.containsKey(id)) {
 | 
			
		||||
      id = _idCache[id];
 | 
			
		||||
    }
 | 
			
		||||
    if (_cache.containsKey(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 {
 | 
			
		||||
      final resp = await _sn.client.get('/cgi/id/users/$id');
 | 
			
		||||
      final account = SnAccount.fromJson(
 | 
			
		||||
@@ -58,16 +117,42 @@ class UserDirectoryProvider {
 | 
			
		||||
      );
 | 
			
		||||
      _cache[account.id] = account;
 | 
			
		||||
      if (id is String) _idCache[id] = account.id;
 | 
			
		||||
      _saveToLocal([account]);
 | 
			
		||||
      return account;
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  SnAccount? getAccountFromCache(dynamic id) {
 | 
			
		||||
  SnAccount? getFromCache(dynamic id) {
 | 
			
		||||
    if (id is String && _idCache.containsKey(id)) {
 | 
			
		||||
      id = _idCache[id];
 | 
			
		||||
    }
 | 
			
		||||
    return _cache[id];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _saveToLocal(Iterable<SnAccount> out) async {
 | 
			
		||||
    // For better on conflict resolution
 | 
			
		||||
    // And consider the method usually called with usually small amount of data
 | 
			
		||||
    // Use for to insert each record instead of bulk insert
 | 
			
		||||
    List<Future<int>> queries = out.map((ele) {
 | 
			
		||||
      return _dt.db.snLocalAccount.insertOne(
 | 
			
		||||
        SnLocalAccountCompanion.insert(
 | 
			
		||||
          id: Value(ele.id),
 | 
			
		||||
          name: ele.name,
 | 
			
		||||
          content: ele,
 | 
			
		||||
          cacheExpiredAt: DateTime.now().add(const Duration(hours: 1)),
 | 
			
		||||
        ),
 | 
			
		||||
        onConflict: DoUpdate(
 | 
			
		||||
          (_) => SnLocalAccountCompanion.custom(
 | 
			
		||||
            name: Constant(ele.name),
 | 
			
		||||
            content: Constant(jsonEncode(ele.toJson())),
 | 
			
		||||
            cacheExpiredAt:
 | 
			
		||||
                Constant(DateTime.now().add(const Duration(hours: 1))),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }).toList();
 | 
			
		||||
    await Future.wait(queries);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,9 @@
 | 
			
		||||
import 'dart:developer';
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:shared_preferences/shared_preferences.dart';
 | 
			
		||||
import 'package:surface/logger.dart';
 | 
			
		||||
import 'package:surface/providers/config.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/types/account.dart';
 | 
			
		||||
@@ -30,13 +31,40 @@ class UserProvider extends ChangeNotifier {
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
    refreshUser().then((value) async {
 | 
			
		||||
      if (value != null) {
 | 
			
		||||
        log('Logged in as @${value.name}');
 | 
			
		||||
        log('Atk: ${await atk}');
 | 
			
		||||
        logging.info('[Auth] Logged in as @${value.name}');
 | 
			
		||||
        logging.debug('[Auth] Access token: ${await atk}');
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<Map<String, dynamic>?> get atkClaims async {
 | 
			
		||||
    final tk = (await atk);
 | 
			
		||||
    if (tk == null) return null;
 | 
			
		||||
    final atkParts = tk.split('.');
 | 
			
		||||
    if (atkParts.length != 3) {
 | 
			
		||||
      throw Exception('invalid format of access token');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var rawPayload = atkParts[1].replaceAll('-', '+').replaceAll('_', '/');
 | 
			
		||||
    switch (rawPayload.length % 4) {
 | 
			
		||||
      case 0:
 | 
			
		||||
        break;
 | 
			
		||||
      case 2:
 | 
			
		||||
        rawPayload += '==';
 | 
			
		||||
        break;
 | 
			
		||||
      case 3:
 | 
			
		||||
        rawPayload += '=';
 | 
			
		||||
        break;
 | 
			
		||||
      default:
 | 
			
		||||
        throw Exception('illegal format of access token payload');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final b64 = utf8.fuse(base64Url);
 | 
			
		||||
    return jsonDecode(b64.decode(rawPayload));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<SnAccount?> refreshUser() async {
 | 
			
		||||
    if (!isAuthorized) return null;
 | 
			
		||||
    final resp = await _sn.client.get('/cgi/id/users/me');
 | 
			
		||||
    final out = SnAccount.fromJson(resp.data);
 | 
			
		||||
 | 
			
		||||
@@ -48,7 +76,13 @@ class UserProvider extends ChangeNotifier {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void logoutUser() async {
 | 
			
		||||
    _sn.clearTokenPair();
 | 
			
		||||
    atkClaims.then((value) async {
 | 
			
		||||
      if (value != null) {
 | 
			
		||||
        await _sn.client.delete('/cgi/id/users/me/tickets/${value['sed']}');
 | 
			
		||||
        logging.info('[Auth] Current session has been destroyed.');
 | 
			
		||||
      }
 | 
			
		||||
      _sn.clearTokenPair();
 | 
			
		||||
    });
 | 
			
		||||
    isAuthorized = false;
 | 
			
		||||
    user = null;
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,15 @@
 | 
			
		||||
import 'dart:async';
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
import 'dart:developer';
 | 
			
		||||
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_udid/flutter_udid.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:surface/logger.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/types/websocket.dart';
 | 
			
		||||
import 'package:web_socket_channel/io.dart';
 | 
			
		||||
import 'package:web_socket_channel/web_socket_channel.dart';
 | 
			
		||||
 | 
			
		||||
class WebSocketProvider extends ChangeNotifier {
 | 
			
		||||
@@ -30,7 +33,7 @@ class WebSocketProvider extends ChangeNotifier {
 | 
			
		||||
    if (isConnected) return;
 | 
			
		||||
    if (!_ua.isAuthorized) return;
 | 
			
		||||
 | 
			
		||||
    log('[WebSocket] Connecting to the server...');
 | 
			
		||||
    logging.debug('[WebSocket] Connecting to the server...');
 | 
			
		||||
    await connect();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -39,7 +42,7 @@ class WebSocketProvider extends ChangeNotifier {
 | 
			
		||||
  Future<void> connect({noRetry = false}) async {
 | 
			
		||||
    if (_connectCompleter != null) {
 | 
			
		||||
      await _connectCompleter!.future;
 | 
			
		||||
      _connectCompleter = null;
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!_ua.isAuthorized) return;
 | 
			
		||||
@@ -52,27 +55,39 @@ class WebSocketProvider extends ChangeNotifier {
 | 
			
		||||
 | 
			
		||||
      final atk = await _sn.getFreshAtk();
 | 
			
		||||
      final uri = Uri.parse(
 | 
			
		||||
        '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk',
 | 
			
		||||
        kIsWeb
 | 
			
		||||
            ? '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk'
 | 
			
		||||
            : '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?clientId=${await FlutterUdid.consistentUdid}tk=$atk',
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      isBusy = true;
 | 
			
		||||
      notifyListeners();
 | 
			
		||||
 | 
			
		||||
      conn = WebSocketChannel.connect(uri);
 | 
			
		||||
      conn = kIsWeb
 | 
			
		||||
          ? WebSocketChannel.connect(uri)
 | 
			
		||||
          : IOWebSocketChannel.connect(
 | 
			
		||||
              uri,
 | 
			
		||||
              headers: {'Authorization': 'Bearer $atk'},
 | 
			
		||||
            );
 | 
			
		||||
      await conn!.ready;
 | 
			
		||||
      _wsStream = conn!.stream.asBroadcastStream();
 | 
			
		||||
      listen();
 | 
			
		||||
      log('[WebSocket] Connected to server!');
 | 
			
		||||
      logging.info('[WebSocket] Connected to server!');
 | 
			
		||||
      isConnected = true;
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      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 {
 | 
			
		||||
        log('Failed to connect to websocket: $err');
 | 
			
		||||
        logging.error('[WebSocket] Failed to connect to websocket...', err);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (!noRetry) {
 | 
			
		||||
        log('Retry connecting to websocket in 3 seconds...');
 | 
			
		||||
        logging.warning(
 | 
			
		||||
          '[WebSocket] Retry connecting to websocket in 3 seconds...',
 | 
			
		||||
        );
 | 
			
		||||
        return Future.delayed(
 | 
			
		||||
          const Duration(seconds: 3),
 | 
			
		||||
          () => connect(noRetry: true),
 | 
			
		||||
@@ -100,7 +115,9 @@ class WebSocketProvider extends ChangeNotifier {
 | 
			
		||||
    _wsStream!.listen(
 | 
			
		||||
      (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);
 | 
			
		||||
      },
 | 
			
		||||
      onDone: () {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										353
									
								
								lib/router.dart
									
									
									
									
									
								
							
							
						
						
									
										353
									
								
								lib/router.dart
									
									
									
									
									
								
							@@ -3,13 +3,22 @@ import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:surface/screens/abuse_report.dart';
 | 
			
		||||
import 'package:surface/screens/account.dart';
 | 
			
		||||
import 'package:surface/screens/account/account_settings.dart';
 | 
			
		||||
import 'package:surface/screens/account/punishments.dart';
 | 
			
		||||
import 'package:surface/screens/account/settings.dart';
 | 
			
		||||
import 'package:surface/screens/account/action_events.dart';
 | 
			
		||||
import 'package:surface/screens/account/badges.dart';
 | 
			
		||||
import 'package:surface/screens/account/contact_methods.dart';
 | 
			
		||||
import 'package:surface/screens/account/factor_settings.dart';
 | 
			
		||||
import 'package:surface/screens/account/keypairs.dart';
 | 
			
		||||
import 'package:surface/screens/account/prefs/notify.dart';
 | 
			
		||||
import 'package:surface/screens/account/prefs/security.dart';
 | 
			
		||||
import 'package:surface/screens/account/profile_page.dart';
 | 
			
		||||
import 'package:surface/screens/account/profile_edit.dart';
 | 
			
		||||
import 'package:surface/screens/account/programs.dart';
 | 
			
		||||
import 'package:surface/screens/account/publishers/publisher_edit.dart';
 | 
			
		||||
import 'package:surface/screens/account/publishers/publisher_new.dart';
 | 
			
		||||
import 'package:surface/screens/account/publishers/publishers.dart';
 | 
			
		||||
import 'package:surface/screens/account/auth_tickets.dart';
 | 
			
		||||
import 'package:surface/screens/album.dart';
 | 
			
		||||
import 'package:surface/screens/auth/login.dart';
 | 
			
		||||
import 'package:surface/screens/auth/register.dart';
 | 
			
		||||
@@ -21,26 +30,32 @@ import 'package:surface/screens/chat/room.dart';
 | 
			
		||||
import 'package:surface/screens/explore.dart';
 | 
			
		||||
import 'package:surface/screens/friend.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_list.dart';
 | 
			
		||||
import 'package:surface/screens/notification.dart';
 | 
			
		||||
import 'package:surface/screens/post/post_detail.dart';
 | 
			
		||||
import 'package:surface/screens/post/post_draft.dart';
 | 
			
		||||
import 'package:surface/screens/post/post_editor.dart';
 | 
			
		||||
import 'package:surface/screens/post/post_shuffle.dart';
 | 
			
		||||
import 'package:surface/screens/post/publisher_page.dart';
 | 
			
		||||
import 'package:surface/screens/post/post_search.dart';
 | 
			
		||||
import 'package:surface/screens/realm.dart';
 | 
			
		||||
import 'package:surface/screens/realm/community.dart';
 | 
			
		||||
import 'package:surface/screens/realm/manage.dart';
 | 
			
		||||
import 'package:surface/screens/realm/realm_detail.dart';
 | 
			
		||||
import 'package:surface/screens/realm/realm_discovery.dart';
 | 
			
		||||
import 'package:surface/screens/settings.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/types/post.dart';
 | 
			
		||||
import 'package:surface/widgets/about.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
 | 
			
		||||
Widget _fadeThroughTransition(
 | 
			
		||||
    BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
 | 
			
		||||
Widget _fadeThroughTransition(BuildContext context, Animation<double> animation,
 | 
			
		||||
    Animation<double> secondaryAnimation, Widget child) {
 | 
			
		||||
  return FadeThroughTransition(
 | 
			
		||||
    animation: animation,
 | 
			
		||||
    secondaryAnimation: secondaryAnimation,
 | 
			
		||||
@@ -57,14 +72,19 @@ final _appRoutes = [
 | 
			
		||||
  ),
 | 
			
		||||
  GoRoute(
 | 
			
		||||
    path: '/posts',
 | 
			
		||||
    name: 'explore',
 | 
			
		||||
    builder: (context, state) => const ExploreScreen(),
 | 
			
		||||
    name: 'posts',
 | 
			
		||||
    builder: (_, __) => const SizedBox.shrink(),
 | 
			
		||||
    routes: [
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/write/:mode',
 | 
			
		||||
        path: '/draft',
 | 
			
		||||
        name: 'postDraftBox',
 | 
			
		||||
        builder: (context, state) => const PostDraftBox(),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/write',
 | 
			
		||||
        name: 'postEditor',
 | 
			
		||||
        builder: (context, state) => PostEditorScreen(
 | 
			
		||||
          mode: state.pathParameters['mode']!,
 | 
			
		||||
          mode: state.uri.queryParameters['mode'],
 | 
			
		||||
          postEditId: int.tryParse(
 | 
			
		||||
            state.uri.queryParameters['editing'] ?? '',
 | 
			
		||||
          ),
 | 
			
		||||
@@ -77,111 +97,208 @@ final _appRoutes = [
 | 
			
		||||
          extraProps: state.extra as PostEditorExtra?,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/shuffle',
 | 
			
		||||
        name: 'postShuffle',
 | 
			
		||||
        builder: (context, state) => const PostShuffleScreen(),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/search',
 | 
			
		||||
        name: 'postSearch',
 | 
			
		||||
        builder: (context, state) => PostSearchScreen(
 | 
			
		||||
          initialTags: state.uri.queryParameters['tags']?.split(','),
 | 
			
		||||
          initialCategories: state.uri.queryParameters['categories']?.split(','),
 | 
			
		||||
          initialCategories:
 | 
			
		||||
              state.uri.queryParameters['categories']?.split(','),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    ],
 | 
			
		||||
  ),
 | 
			
		||||
  ShellRoute(
 | 
			
		||||
    builder: (context, state, child) => ResponsiveScaffold(
 | 
			
		||||
      asideFlex: 2,
 | 
			
		||||
      contentFlex: 3,
 | 
			
		||||
      aside: const ExploreScreen(),
 | 
			
		||||
      child: child,
 | 
			
		||||
    ),
 | 
			
		||||
    routes: [
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/explore',
 | 
			
		||||
        name: 'explore',
 | 
			
		||||
        builder: (context, state) => const ResponsiveScaffoldLanding(
 | 
			
		||||
          child: ExploreScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/posts/:slug',
 | 
			
		||||
        name: 'postDetail',
 | 
			
		||||
        builder: (context, state) => PostDetailScreen(
 | 
			
		||||
          key: ValueKey(state.pathParameters['slug']!),
 | 
			
		||||
          slug: state.pathParameters['slug']!,
 | 
			
		||||
          preload: state.extra as SnPost?,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/publishers/:name',
 | 
			
		||||
        name: 'postPublisher',
 | 
			
		||||
        builder: (context, state) => PostPublisherScreen(name: state.pathParameters['name']!),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/:slug',
 | 
			
		||||
        name: 'postDetail',
 | 
			
		||||
        builder: (context, state) => PostDetailScreen(
 | 
			
		||||
          slug: state.pathParameters['slug']!,
 | 
			
		||||
          preload: state.extra as SnPost?,
 | 
			
		||||
        ),
 | 
			
		||||
        builder: (context, state) =>
 | 
			
		||||
            PostPublisherScreen(name: state.pathParameters['name']!),
 | 
			
		||||
      ),
 | 
			
		||||
    ],
 | 
			
		||||
  ),
 | 
			
		||||
  GoRoute(path: '/account', name: 'account', builder: (context, state) => const AccountScreen(), routes: [
 | 
			
		||||
    GoRoute(
 | 
			
		||||
      path: '/wallet',
 | 
			
		||||
      name: 'accountWallet',
 | 
			
		||||
      builder: (context, state) => const WalletScreen(),
 | 
			
		||||
  ShellRoute(
 | 
			
		||||
    builder: (context, state, child) => ResponsiveScaffold(
 | 
			
		||||
      aside: const AccountScreen(),
 | 
			
		||||
      child: child,
 | 
			
		||||
    ),
 | 
			
		||||
    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(
 | 
			
		||||
    path: '/chat',
 | 
			
		||||
    name: 'chat',
 | 
			
		||||
    builder: (context, state) => const ChatScreen(),
 | 
			
		||||
    routes: [
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/:scope/:alias',
 | 
			
		||||
        name: 'chatRoom',
 | 
			
		||||
        builder: (context, state) => ChatRoomScreen(
 | 
			
		||||
          scope: state.pathParameters['scope']!,
 | 
			
		||||
          alias: state.pathParameters['alias']!,
 | 
			
		||||
          extra: state.extra as ChatRoomScreenExtra?,
 | 
			
		||||
        ),
 | 
			
		||||
        path: '/account',
 | 
			
		||||
        name: 'account',
 | 
			
		||||
        builder: (context, state) =>
 | 
			
		||||
            const ResponsiveScaffoldLanding(child: AccountScreen()),
 | 
			
		||||
        routes: [
 | 
			
		||||
          GoRoute(
 | 
			
		||||
            path: '/punishments',
 | 
			
		||||
            name: 'accountPunishments',
 | 
			
		||||
            builder: (context, state) => const PunishmentsScreen(),
 | 
			
		||||
          ),
 | 
			
		||||
          GoRoute(
 | 
			
		||||
            path: '/programs',
 | 
			
		||||
            name: 'accountProgram',
 | 
			
		||||
            builder: (context, state) => const AccountProgramScreen(),
 | 
			
		||||
          ),
 | 
			
		||||
          GoRoute(
 | 
			
		||||
            path: '/contacts',
 | 
			
		||||
            name: 'accountContactMethods',
 | 
			
		||||
            builder: (context, state) => const AccountContactMethod(),
 | 
			
		||||
          ),
 | 
			
		||||
          GoRoute(
 | 
			
		||||
            path: '/events',
 | 
			
		||||
            name: 'accountActionEvents',
 | 
			
		||||
            builder: (context, state) => const ActionEventScreen(),
 | 
			
		||||
          ),
 | 
			
		||||
          GoRoute(
 | 
			
		||||
            path: '/tickets',
 | 
			
		||||
            name: 'accountAuthTickets',
 | 
			
		||||
            builder: (context, state) => const AccountAuthTicket(),
 | 
			
		||||
          ),
 | 
			
		||||
          GoRoute(
 | 
			
		||||
            path: '/badges',
 | 
			
		||||
            name: 'accountBadges',
 | 
			
		||||
            builder: (context, state) => const AccountBadgesScreen(),
 | 
			
		||||
          ),
 | 
			
		||||
          GoRoute(
 | 
			
		||||
            path: '/wallet',
 | 
			
		||||
            name: 'accountWallet',
 | 
			
		||||
            builder: (context, state) => const WalletScreen(),
 | 
			
		||||
          ),
 | 
			
		||||
          GoRoute(
 | 
			
		||||
            path: '/keypairs',
 | 
			
		||||
            name: 'accountKeyPairs',
 | 
			
		||||
            builder: (context, state) => const KeyPairScreen(),
 | 
			
		||||
          ),
 | 
			
		||||
          GoRoute(
 | 
			
		||||
            path: '/settings',
 | 
			
		||||
            name: 'accountSettings',
 | 
			
		||||
            builder: (context, state) => AccountSettingsScreen(),
 | 
			
		||||
            routes: [
 | 
			
		||||
              GoRoute(
 | 
			
		||||
                path: '/notify',
 | 
			
		||||
                name: 'accountSettingsNotify',
 | 
			
		||||
                builder: (context, state) => const AccountNotifyPrefsScreen(),
 | 
			
		||||
              ),
 | 
			
		||||
              GoRoute(
 | 
			
		||||
                path: '/auth',
 | 
			
		||||
                name: 'accountSettingsSecurity',
 | 
			
		||||
                builder: (context, state) => const AccountSecurityPrefsScreen(),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
          GoRoute(
 | 
			
		||||
            path: '/settings/factors',
 | 
			
		||||
            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: '/accounts/:name',
 | 
			
		||||
    name: 'accountProfilePage',
 | 
			
		||||
    pageBuilder: (context, state) => NoTransitionPage(
 | 
			
		||||
      child: UserScreen(name: state.pathParameters['name']!),
 | 
			
		||||
    ),
 | 
			
		||||
  ),
 | 
			
		||||
  ShellRoute(
 | 
			
		||||
    builder: (context, state, child) =>
 | 
			
		||||
        ResponsiveScaffold(aside: const ChatScreen(), child: child),
 | 
			
		||||
    routes: [
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/:scope/:alias/call',
 | 
			
		||||
        name: 'chatCallRoom',
 | 
			
		||||
        builder: (context, state) => CallRoomScreen(
 | 
			
		||||
          scope: state.pathParameters['scope']!,
 | 
			
		||||
          alias: state.pathParameters['alias']!,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/:scope/:alias/detail',
 | 
			
		||||
        name: 'channelDetail',
 | 
			
		||||
        builder: (context, state) => ChannelDetailScreen(
 | 
			
		||||
          scope: state.pathParameters['scope']!,
 | 
			
		||||
          alias: state.pathParameters['alias']!,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/manage',
 | 
			
		||||
        name: 'chatManage',
 | 
			
		||||
        builder: (context, state) => ChatManageScreen(
 | 
			
		||||
          editingChannelAlias: state.uri.queryParameters['editing'],
 | 
			
		||||
        path: '/chat',
 | 
			
		||||
        name: 'chat',
 | 
			
		||||
        builder: (context, state) => const ResponsiveScaffoldLanding(
 | 
			
		||||
          child: ChatScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
        routes: [
 | 
			
		||||
          GoRoute(
 | 
			
		||||
            path: '/:scope/:alias',
 | 
			
		||||
            name: 'chatRoom',
 | 
			
		||||
            builder: (context, state) => ChatRoomScreen(
 | 
			
		||||
              key: ValueKey(
 | 
			
		||||
                '${state.pathParameters['scope']!}:${state.pathParameters['alias']!}',
 | 
			
		||||
              ),
 | 
			
		||||
              scope: state.pathParameters['scope']!,
 | 
			
		||||
              alias: state.pathParameters['alias']!,
 | 
			
		||||
              extra: state.extra as ChatRoomScreenExtra?,
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          GoRoute(
 | 
			
		||||
            path: '/:scope/:alias/call',
 | 
			
		||||
            name: 'chatCallRoom',
 | 
			
		||||
            builder: (context, state) => CallRoomScreen(
 | 
			
		||||
              scope: state.pathParameters['scope']!,
 | 
			
		||||
              alias: state.pathParameters['alias']!,
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          GoRoute(
 | 
			
		||||
            path: '/:scope/:alias/detail',
 | 
			
		||||
            name: 'channelDetail',
 | 
			
		||||
            builder: (context, state) => ChannelDetailScreen(
 | 
			
		||||
              scope: state.pathParameters['scope']!,
 | 
			
		||||
              alias: state.pathParameters['alias']!,
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          GoRoute(
 | 
			
		||||
            path: '/manage',
 | 
			
		||||
            name: 'chatManage',
 | 
			
		||||
            builder: (context, state) => ChatManageScreen(
 | 
			
		||||
              editingChannelAlias: state.uri.queryParameters['editing'],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    ],
 | 
			
		||||
  ),
 | 
			
		||||
@@ -193,6 +310,13 @@ final _appRoutes = [
 | 
			
		||||
      child: const RealmScreen(),
 | 
			
		||||
    ),
 | 
			
		||||
    routes: [
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/:alias/community',
 | 
			
		||||
        name: 'realmCommunity',
 | 
			
		||||
        builder: (context, state) => RealmCommunityScreen(
 | 
			
		||||
          alias: state.pathParameters['alias']!,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/manage',
 | 
			
		||||
        name: 'realmManage',
 | 
			
		||||
@@ -208,19 +332,44 @@ final _appRoutes = [
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/:alias',
 | 
			
		||||
        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(
 | 
			
		||||
      path: '/:hash',
 | 
			
		||||
      name: 'newsDetail',
 | 
			
		||||
      builder: (context, state) => NewsDetailScreen(
 | 
			
		||||
        hash: state.pathParameters['hash']!,
 | 
			
		||||
  GoRoute(
 | 
			
		||||
    path: '/news',
 | 
			
		||||
    name: 'news',
 | 
			
		||||
    builder: (context, state) => const NewsScreen(),
 | 
			
		||||
    routes: [
 | 
			
		||||
      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(
 | 
			
		||||
    path: '/album',
 | 
			
		||||
    name: 'album',
 | 
			
		||||
 
 | 
			
		||||
@@ -4,14 +4,17 @@ 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:hive_flutter/hive_flutter.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/database.dart';
 | 
			
		||||
import 'package:surface/providers/navigation.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/providers/websocket.dart';
 | 
			
		||||
import 'package:surface/types/account.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_status.dart';
 | 
			
		||||
import 'package:surface/widgets/app_bar_leading.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
@@ -20,32 +23,103 @@ import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
class AccountScreen extends StatelessWidget {
 | 
			
		||||
  const AccountScreen({super.key});
 | 
			
		||||
 | 
			
		||||
  static const List<AppNavListItem> kNavList = [
 | 
			
		||||
    AppNavListItem(
 | 
			
		||||
      title: "accountPublishers",
 | 
			
		||||
      subtitle: "accountPublishersSubtitle",
 | 
			
		||||
      screen: "accountPublishers",
 | 
			
		||||
      icon: Symbols.face,
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavListItem(
 | 
			
		||||
      title: "accountProgram",
 | 
			
		||||
      subtitle: "accountProgramDescription",
 | 
			
		||||
      screen: "accountProgram",
 | 
			
		||||
      icon: Symbols.communities,
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavListItem(
 | 
			
		||||
      title: "friends",
 | 
			
		||||
      subtitle: "friendsDescription",
 | 
			
		||||
      screen: "friend",
 | 
			
		||||
      icon: Symbols.person,
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavListItem(
 | 
			
		||||
      title: "album",
 | 
			
		||||
      subtitle: "albumDescription",
 | 
			
		||||
      screen: "album",
 | 
			
		||||
      icon: Symbols.photo_library,
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavListItem(
 | 
			
		||||
      title: "stickers",
 | 
			
		||||
      subtitle: "stickersDescription",
 | 
			
		||||
      screen: "stickers",
 | 
			
		||||
      icon: Symbols.emoji_emotions,
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavListItem(
 | 
			
		||||
      title: "accountWallet",
 | 
			
		||||
      subtitle: "accountWalletSubtitle",
 | 
			
		||||
      screen: "accountWallet",
 | 
			
		||||
      icon: Symbols.wallet,
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavListItem(
 | 
			
		||||
      title: "accountBadges",
 | 
			
		||||
      subtitle: "accountBadgesDescription",
 | 
			
		||||
      screen: "accountBadges",
 | 
			
		||||
      icon: Symbols.award_star,
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavListItem(
 | 
			
		||||
      title: "accountKeyPairs",
 | 
			
		||||
      subtitle: "accountKeyPairsDescription",
 | 
			
		||||
      screen: "accountKeyPairs",
 | 
			
		||||
      icon: Symbols.key,
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavListItem(
 | 
			
		||||
      title: "accountPunishments",
 | 
			
		||||
      subtitle: "accountPunishmentsDescription",
 | 
			
		||||
      screen: "accountPunishments",
 | 
			
		||||
      icon: Symbols.credit_score,
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavListItem(
 | 
			
		||||
      title: "accountActionEvent",
 | 
			
		||||
      subtitle: "accountActionEventDescription",
 | 
			
		||||
      screen: "accountActionEvents",
 | 
			
		||||
      icon: Symbols.history,
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavListItem(
 | 
			
		||||
      title: "accountAuthTickets",
 | 
			
		||||
      subtitle: "accountAuthTicketsDescription",
 | 
			
		||||
      screen: "accountAuthTickets",
 | 
			
		||||
      icon: Symbols.confirmation_number,
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavListItem(
 | 
			
		||||
      title: "accountSettings",
 | 
			
		||||
      subtitle: "accountSettingsSubtitle",
 | 
			
		||||
      screen: "accountSettings",
 | 
			
		||||
      icon: Symbols.manage_accounts,
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavListItem(
 | 
			
		||||
      title: "abuseReport",
 | 
			
		||||
      subtitle: "abuseReportActionDescription",
 | 
			
		||||
      screen: "abuseReport",
 | 
			
		||||
      icon: Symbols.flag,
 | 
			
		||||
    ),
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final ua = context.watch<UserProvider>();
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: AutoAppBarLeading(),
 | 
			
		||||
        title: Text(
 | 
			
		||||
          "screenAccount",
 | 
			
		||||
          style: TextStyle(
 | 
			
		||||
            color: Colors.white,
 | 
			
		||||
            shadows: [
 | 
			
		||||
              Shadow(
 | 
			
		||||
                offset: Offset(1, 1),
 | 
			
		||||
                blurRadius: 5.0,
 | 
			
		||||
                color: Color.fromARGB(255, 0, 0, 0),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ).tr(),
 | 
			
		||||
        title: Text("screenAccount").tr(),
 | 
			
		||||
        flexibleSpace: ua.user != null && ua.user!.banner.isNotEmpty
 | 
			
		||||
            ? Stack(
 | 
			
		||||
                fit: StackFit.expand,
 | 
			
		||||
                children: [
 | 
			
		||||
                  AutoResizeUniversalImage(sn.getAttachmentUrl(ua.user!.banner), fit: BoxFit.cover),
 | 
			
		||||
                  AutoResizeUniversalImage(sn.getAttachmentUrl(ua.user!.banner),
 | 
			
		||||
                      fit: BoxFit.cover),
 | 
			
		||||
                  Positioned(
 | 
			
		||||
                    top: 0,
 | 
			
		||||
                    left: 0,
 | 
			
		||||
@@ -68,18 +142,11 @@ class AccountScreen extends StatelessWidget {
 | 
			
		||||
                ],
 | 
			
		||||
              )
 | 
			
		||||
            : null,
 | 
			
		||||
        actions: [
 | 
			
		||||
          IconButton(
 | 
			
		||||
            icon: const Icon(Symbols.settings, fill: 1),
 | 
			
		||||
            onPressed: () {
 | 
			
		||||
              GoRouter.of(context).pushNamed('settings');
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
          const Gap(8),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
      body: SingleChildScrollView(
 | 
			
		||||
        child: ua.isAuthorized ? _AuthorizedAccountScreen() : _UnauthorizedAccountScreen(),
 | 
			
		||||
        child: ua.isAuthorized
 | 
			
		||||
            ? _AuthorizedAccountScreen()
 | 
			
		||||
            : _UnauthorizedAccountScreen(),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
@@ -109,93 +176,86 @@ class _AuthorizedAccountScreen extends StatelessWidget {
 | 
			
		||||
              child: Column(
 | 
			
		||||
                crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                children: [
 | 
			
		||||
                  AccountImage(content: ua.user!.avatar, radius: 28),
 | 
			
		||||
                  Row(
 | 
			
		||||
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | 
			
		||||
                    crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      GestureDetector(
 | 
			
		||||
                        child: AccountImage(
 | 
			
		||||
                          content: ua.user!.avatar,
 | 
			
		||||
                          radius: 28,
 | 
			
		||||
                        ),
 | 
			
		||||
                        onTap: () {
 | 
			
		||||
                          GoRouter.of(context)
 | 
			
		||||
                              .pushNamed('accountProfilePage', pathParameters: {
 | 
			
		||||
                            'name': ua.user!.name,
 | 
			
		||||
                          });
 | 
			
		||||
                        },
 | 
			
		||||
                      ),
 | 
			
		||||
                      _AccountStatusWidget(account: ua.user!),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                  const Gap(8),
 | 
			
		||||
                  Row(
 | 
			
		||||
                    crossAxisAlignment: CrossAxisAlignment.baseline,
 | 
			
		||||
                    textBaseline: TextBaseline.alphabetic,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      Text(ua.user!.nick).textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
			
		||||
                      Text(ua.user!.nick)
 | 
			
		||||
                          .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
			
		||||
                      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!),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            );
 | 
			
		||||
          }).padding(all: 20),
 | 
			
		||||
        ).padding(horizontal: 8, top: 16, bottom: 4),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          title: Text('accountPublishers').tr(),
 | 
			
		||||
          subtitle: Text('accountPublishersSubtitle').tr(),
 | 
			
		||||
          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
          leading: const Icon(Symbols.face),
 | 
			
		||||
          trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            GoRouter.of(context).pushNamed('accountPublishers');
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          title: Text('abuseReport').tr(),
 | 
			
		||||
          subtitle: Text('abuseReportActionDescription').tr(),
 | 
			
		||||
          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
          leading: const Icon(Symbols.flag),
 | 
			
		||||
          trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            GoRouter.of(context).pushNamed('abuseReport');
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          title: Text('factorSettings').tr(),
 | 
			
		||||
          subtitle: Text('factorSettingsSubtitle').tr(),
 | 
			
		||||
          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
          leading: const Icon(Symbols.lock),
 | 
			
		||||
          trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            GoRouter.of(context).pushNamed('factorSettings');
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          title: Text('accountWallet').tr(),
 | 
			
		||||
          subtitle: Text('accountWalletSubtitle').tr(),
 | 
			
		||||
          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
          leading: const Icon(Symbols.wallet),
 | 
			
		||||
          trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            GoRouter.of(context).pushNamed('accountWallet');
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          title: Text('accountSettings').tr(),
 | 
			
		||||
          subtitle: Text('accountSettingsSubtitle').tr(),
 | 
			
		||||
          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
          leading: const Icon(Symbols.manage_accounts),
 | 
			
		||||
          trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            GoRouter.of(context).pushNamed('accountSettings');
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          title: Text('accountLogout').tr(),
 | 
			
		||||
          subtitle: Text('accountLogoutSubtitle').tr(),
 | 
			
		||||
          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
          leading: const Icon(Symbols.logout),
 | 
			
		||||
          trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
          onTap: () async {
 | 
			
		||||
            final confirm = await context.showConfirmDialog(
 | 
			
		||||
              'accountLogoutConfirmTitle'.tr(),
 | 
			
		||||
              'accountLogoutConfirm'.tr(),
 | 
			
		||||
            );
 | 
			
		||||
        for (final item in AccountScreen.kNavList)
 | 
			
		||||
          Tooltip(
 | 
			
		||||
            message: item.subtitle.tr(),
 | 
			
		||||
            child: ListTile(
 | 
			
		||||
              minTileHeight: 48,
 | 
			
		||||
              title: Text(item.title).tr(),
 | 
			
		||||
              contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
              leading: Icon(item.icon),
 | 
			
		||||
              trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
              onTap: () {
 | 
			
		||||
                GoRouter.of(context).pushNamed(item.screen);
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        Tooltip(
 | 
			
		||||
          message: 'accountLogoutSubtitle'.tr(),
 | 
			
		||||
          child: ListTile(
 | 
			
		||||
            title: Text('accountLogout').tr(),
 | 
			
		||||
            minTileHeight: 48,
 | 
			
		||||
            contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
            leading: const Icon(Symbols.logout),
 | 
			
		||||
            trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
            onTap: () async {
 | 
			
		||||
              final confirm = await context.showConfirmDialog(
 | 
			
		||||
                'accountLogoutConfirmTitle'.tr(),
 | 
			
		||||
                'accountLogoutConfirm'.tr(),
 | 
			
		||||
              );
 | 
			
		||||
 | 
			
		||||
            if (!confirm) return;
 | 
			
		||||
            if (!context.mounted) return;
 | 
			
		||||
            ua.logoutUser();
 | 
			
		||||
            final ws = context.read<WebSocketProvider>();
 | 
			
		||||
            ws.disconnect();
 | 
			
		||||
            await Hive.deleteFromDisk();
 | 
			
		||||
            await Hive.initFlutter();
 | 
			
		||||
          },
 | 
			
		||||
              if (!confirm) return;
 | 
			
		||||
              if (!context.mounted) return;
 | 
			
		||||
              ua.logoutUser();
 | 
			
		||||
              final ws = context.read<WebSocketProvider>();
 | 
			
		||||
              ws.disconnect();
 | 
			
		||||
              context.read<DatabaseProvider>().removeDatabase();
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
@@ -220,7 +280,9 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
 | 
			
		||||
                  child: Icon(Symbols.waving_hand, size: 28),
 | 
			
		||||
                ),
 | 
			
		||||
                const Gap(8),
 | 
			
		||||
                Text('accountIntroTitle').tr().textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
			
		||||
                Text('accountIntroTitle')
 | 
			
		||||
                    .tr()
 | 
			
		||||
                    .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
			
		||||
                Text('accountIntroSubtitle').tr(),
 | 
			
		||||
              ],
 | 
			
		||||
            ).padding(all: 20),
 | 
			
		||||
@@ -236,9 +298,7 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
 | 
			
		||||
            GoRouter.of(context).pushNamed('authLogin').then((value) {
 | 
			
		||||
              if (value == true && context.mounted) {
 | 
			
		||||
                final ua = context.read<UserProvider>();
 | 
			
		||||
                context.showSnackbar('loginSuccess'.tr(args: [
 | 
			
		||||
                  '@${ua.user?.name} (${ua.user?.nick})',
 | 
			
		||||
                ]));
 | 
			
		||||
                ua.refreshUser();
 | 
			
		||||
              }
 | 
			
		||||
            });
 | 
			
		||||
          },
 | 
			
		||||
@@ -257,3 +317,81 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AccountStatusWidget extends StatefulWidget {
 | 
			
		||||
  final SnAccount account;
 | 
			
		||||
  const _AccountStatusWidget({required this.account});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_AccountStatusWidget> createState() => _AccountStatusWidgetState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AccountStatusWidgetState extends State<_AccountStatusWidget> {
 | 
			
		||||
  SnAccountStatusInfo? _status;
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchStatus() async {
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp =
 | 
			
		||||
          await sn.client.get('/cgi/id/users/${widget.account.name}/status');
 | 
			
		||||
      setState(() {
 | 
			
		||||
        _status = SnAccountStatusInfo.fromJson(resp.data);
 | 
			
		||||
      });
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() {});
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _fetchStatus();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return InkWell(
 | 
			
		||||
      child: Row(
 | 
			
		||||
        children: [
 | 
			
		||||
          Text(
 | 
			
		||||
            _status != null
 | 
			
		||||
                ? (_status!.status?.label.isNotEmpty ?? false)
 | 
			
		||||
                    ? _status!.status!.label
 | 
			
		||||
                    : _status!.isOnline
 | 
			
		||||
                        ? 'accountStatusOnline'.tr()
 | 
			
		||||
                        : 'accountStatusOffline'.tr()
 | 
			
		||||
                : 'loading'.tr(),
 | 
			
		||||
          ),
 | 
			
		||||
          const Gap(4),
 | 
			
		||||
          Icon(
 | 
			
		||||
            (_status?.isDisturbable ?? true)
 | 
			
		||||
                ? Symbols.circle
 | 
			
		||||
                : Symbols.do_not_disturb_on,
 | 
			
		||||
            fill: (_status?.isOnline ?? false) ? 1 : 0,
 | 
			
		||||
            size: 16,
 | 
			
		||||
            color: (_status?.isOnline ?? false)
 | 
			
		||||
                ? (_status?.isDisturbable ?? true)
 | 
			
		||||
                    ? Colors.green
 | 
			
		||||
                    : Colors.red
 | 
			
		||||
                : Colors.grey,
 | 
			
		||||
          ).padding(all: 4),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
      onTap: () {
 | 
			
		||||
        showModalBottomSheet(
 | 
			
		||||
          context: context,
 | 
			
		||||
          builder: (context) => AccountStatusActionPopup(
 | 
			
		||||
            currentStatus: _status,
 | 
			
		||||
          ),
 | 
			
		||||
        ).then((value) {
 | 
			
		||||
          if (value == true && mounted) {
 | 
			
		||||
            _fetchStatus();
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										161
									
								
								lib/screens/account/action_events.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										161
									
								
								lib/screens/account/action_events.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,161 @@
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:google_fonts/google_fonts.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:relative_time/relative_time.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/types/account.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:timelines_plus/timelines_plus.dart';
 | 
			
		||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
			
		||||
 | 
			
		||||
class ActionEventScreen extends StatefulWidget {
 | 
			
		||||
  const ActionEventScreen({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<ActionEventScreen> createState() => _ActionEventScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ActionEventScreenState extends State<ActionEventScreen> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
  int? _totalCount;
 | 
			
		||||
  final List<SnActionEvent> _actionEvents = List.empty(growable: true);
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchActionEvents() async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get(
 | 
			
		||||
        '/cgi/id/users/me/events',
 | 
			
		||||
        queryParameters: {
 | 
			
		||||
          'take': 10,
 | 
			
		||||
          'offset': _actionEvents.length,
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
      _totalCount = resp.data['count'];
 | 
			
		||||
      _actionEvents.addAll(
 | 
			
		||||
        (resp.data['data'] as List<dynamic>)
 | 
			
		||||
            .map((e) => SnActionEvent.fromJson(e)),
 | 
			
		||||
      );
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _fetchActionEvents();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: const PageBackButton(),
 | 
			
		||||
        title: Text('accountActionEvent').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: Column(
 | 
			
		||||
        children: [
 | 
			
		||||
          LoadingIndicator(isActive: _isBusy),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: RefreshIndicator(
 | 
			
		||||
              onRefresh: () {
 | 
			
		||||
                _totalCount = null;
 | 
			
		||||
                return _fetchActionEvents();
 | 
			
		||||
              },
 | 
			
		||||
              child: InfiniteList(
 | 
			
		||||
                padding: EdgeInsets.only(left: 20, right: 8),
 | 
			
		||||
                itemCount: _actionEvents.length,
 | 
			
		||||
                isLoading: _isBusy,
 | 
			
		||||
                hasReachedMax:
 | 
			
		||||
                    _totalCount != null && _actionEvents.length >= _totalCount!,
 | 
			
		||||
                onFetchData: _fetchActionEvents,
 | 
			
		||||
                itemBuilder: (context, idx) {
 | 
			
		||||
                  final event = _actionEvents[idx];
 | 
			
		||||
                  return TimelineTile(
 | 
			
		||||
                    nodeAlign: TimelineNodeAlign.start,
 | 
			
		||||
                    contents: Card(
 | 
			
		||||
                      margin: EdgeInsets.symmetric(horizontal: 8, vertical: 12),
 | 
			
		||||
                      child: Column(
 | 
			
		||||
                        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                        children: [
 | 
			
		||||
                          Container(
 | 
			
		||||
                            padding: EdgeInsets.symmetric(
 | 
			
		||||
                              horizontal: 16,
 | 
			
		||||
                              vertical: 12,
 | 
			
		||||
                            ),
 | 
			
		||||
                            child: Column(
 | 
			
		||||
                              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                              children: [
 | 
			
		||||
                                Text(
 | 
			
		||||
                                  event.type,
 | 
			
		||||
                                  maxLines: 1,
 | 
			
		||||
                                  style: GoogleFonts.robotoMono(),
 | 
			
		||||
                                ),
 | 
			
		||||
                                if (event.ipAddress.isNotEmpty)
 | 
			
		||||
                                  Text(
 | 
			
		||||
                                    event.ipAddress,
 | 
			
		||||
                                    style: TextStyle(fontSize: 13),
 | 
			
		||||
                                  ),
 | 
			
		||||
                                if (event.location?.isNotEmpty ?? false)
 | 
			
		||||
                                  Text(event.location!),
 | 
			
		||||
                                Row(
 | 
			
		||||
                                  children: [
 | 
			
		||||
                                    Text(DateFormat()
 | 
			
		||||
                                            .format(event.createdAt.toLocal()))
 | 
			
		||||
                                        .fontSize(12),
 | 
			
		||||
                                    Text(' · ')
 | 
			
		||||
                                        .fontSize(12)
 | 
			
		||||
                                        .padding(horizontal: 4),
 | 
			
		||||
                                    Text(RelativeTime(context)
 | 
			
		||||
                                            .format(event.createdAt.toLocal()))
 | 
			
		||||
                                        .fontSize(12),
 | 
			
		||||
                                  ],
 | 
			
		||||
                                ).opacity(0.75).padding(top: 4),
 | 
			
		||||
                              ],
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                          if (event.metadata != null)
 | 
			
		||||
                            ExpansionTile(
 | 
			
		||||
                              minTileHeight: 40,
 | 
			
		||||
                              tilePadding: EdgeInsets.symmetric(horizontal: 16),
 | 
			
		||||
                              title: Text('eventMetadata').tr(),
 | 
			
		||||
                              expandedAlignment: Alignment.topLeft,
 | 
			
		||||
                              expandedCrossAxisAlignment:
 | 
			
		||||
                                  CrossAxisAlignment.start,
 | 
			
		||||
                              children: [
 | 
			
		||||
                                Text(
 | 
			
		||||
                                  JsonEncoder.withIndent('\t')
 | 
			
		||||
                                      .convert(event.metadata),
 | 
			
		||||
                                  style: GoogleFonts.robotoMono(),
 | 
			
		||||
                                ).padding(vertical: 8, horizontal: 16),
 | 
			
		||||
                              ],
 | 
			
		||||
                            ).padding(bottom: 6),
 | 
			
		||||
                        ],
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                    node: TimelineNode(
 | 
			
		||||
                      indicator: DotIndicator(),
 | 
			
		||||
                      startConnector: SolidLineConnector(),
 | 
			
		||||
                      endConnector: SolidLineConnector(),
 | 
			
		||||
                    ),
 | 
			
		||||
                  );
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										187
									
								
								lib/screens/account/auth_tickets.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										187
									
								
								lib/screens/account/auth_tickets.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,187 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/types/auth.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
			
		||||
 | 
			
		||||
const Map<String, IconData> kAuthTicketIcon = {
 | 
			
		||||
  'ios': Symbols.ios,
 | 
			
		||||
  'android': Symbols.android,
 | 
			
		||||
  'macos': Symbols.computer,
 | 
			
		||||
  'windows nt': Symbols.laptop_windows,
 | 
			
		||||
  'linux': Symbols.laptop,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
class AccountAuthTicket extends StatefulWidget {
 | 
			
		||||
  const AccountAuthTicket({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<AccountAuthTicket> createState() => _AccountAuthTicketState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AccountAuthTicketState extends State<AccountAuthTicket> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
  int? _totalCount;
 | 
			
		||||
  final List<SnAuthTicket> _authTickets = List.empty(growable: true);
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchAuthTickets() async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get(
 | 
			
		||||
        '/cgi/id/users/me/tickets',
 | 
			
		||||
        queryParameters: {
 | 
			
		||||
          'take': 10,
 | 
			
		||||
          'offset': _authTickets.length,
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
      _totalCount = resp.data['count'];
 | 
			
		||||
      _authTickets.addAll(
 | 
			
		||||
        (resp.data['data'] as List<dynamic>)
 | 
			
		||||
            .map((e) => SnAuthTicket.fromJson(e)),
 | 
			
		||||
      );
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _deleteAuthTicket(SnAuthTicket ticket) async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.delete(
 | 
			
		||||
        '/cgi/id/users/me/tickets/${ticket.id}',
 | 
			
		||||
      );
 | 
			
		||||
      setState(() {
 | 
			
		||||
        _authTickets.remove(ticket);
 | 
			
		||||
      });
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  int? _currentTicketId;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _fetchAuthTickets();
 | 
			
		||||
 | 
			
		||||
    final ua = context.read<UserProvider>();
 | 
			
		||||
    ua.atkClaims.then((value) {
 | 
			
		||||
      if (value == null) return;
 | 
			
		||||
      _currentTicketId = int.parse(value['sed']);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: const PageBackButton(),
 | 
			
		||||
        title: Text('accountAuthTickets').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: Column(
 | 
			
		||||
        children: [
 | 
			
		||||
          LoadingIndicator(isActive: _isBusy),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: RefreshIndicator(
 | 
			
		||||
              onRefresh: () {
 | 
			
		||||
                _totalCount = null;
 | 
			
		||||
                return _fetchAuthTickets();
 | 
			
		||||
              },
 | 
			
		||||
              child: InfiniteList(
 | 
			
		||||
                padding: EdgeInsets.zero,
 | 
			
		||||
                onFetchData: _fetchAuthTickets,
 | 
			
		||||
                isLoading: _isBusy,
 | 
			
		||||
                hasReachedMax:
 | 
			
		||||
                    _totalCount != null && _authTickets.length >= _totalCount!,
 | 
			
		||||
                itemCount: _authTickets.length,
 | 
			
		||||
                itemBuilder: (context, idx) {
 | 
			
		||||
                  final ticket = _authTickets[idx];
 | 
			
		||||
                  final platform = RegExp(r'\(([^;]+);')
 | 
			
		||||
                      .firstMatch(ticket.userAgent)
 | 
			
		||||
                      ?.group(1);
 | 
			
		||||
                  return Row(
 | 
			
		||||
                    crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      Icon(
 | 
			
		||||
                        kAuthTicketIcon[platform!.toLowerCase()] ?? Symbols.web,
 | 
			
		||||
                      ),
 | 
			
		||||
                      const Gap(12),
 | 
			
		||||
                      Expanded(
 | 
			
		||||
                        child: Column(
 | 
			
		||||
                          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            Text(
 | 
			
		||||
                              ticket.ipAddress,
 | 
			
		||||
                              style: TextStyle(fontSize: 15),
 | 
			
		||||
                            ),
 | 
			
		||||
                            Text(ticket.userAgent).opacity(0.8),
 | 
			
		||||
                            if (ticket.location?.isNotEmpty ?? false)
 | 
			
		||||
                              const Gap(4),
 | 
			
		||||
                            if (ticket.location?.isNotEmpty ?? false)
 | 
			
		||||
                              Text(ticket.location!).opacity(0.8),
 | 
			
		||||
                            const Gap(4),
 | 
			
		||||
                            Text('authTicketCreatedAt'.tr(args: [
 | 
			
		||||
                              (DateFormat().format(ticket.createdAt.toLocal()))
 | 
			
		||||
                            ])).fontSize(12).opacity(0.75),
 | 
			
		||||
                            if (ticket.expiredAt != null)
 | 
			
		||||
                              Text('authTicketExpiredAt'.tr(args: [
 | 
			
		||||
                                (DateFormat()
 | 
			
		||||
                                    .format(ticket.expiredAt!.toLocal()))
 | 
			
		||||
                              ])).fontSize(12).opacity(0.75),
 | 
			
		||||
                            if (ticket.lastGrantAt != null)
 | 
			
		||||
                              Text('authTicketLastGrantAt'.tr(args: [
 | 
			
		||||
                                (DateFormat()
 | 
			
		||||
                                    .format(ticket.lastGrantAt!.toLocal()))
 | 
			
		||||
                              ])).fontSize(12).opacity(0.75),
 | 
			
		||||
                            const Gap(4),
 | 
			
		||||
                            if (_currentTicketId == ticket.id)
 | 
			
		||||
                              Text('authTicketCurrent'.tr())
 | 
			
		||||
                                  .fontSize(11)
 | 
			
		||||
                                  .bold()
 | 
			
		||||
                                  .opacity(0.75),
 | 
			
		||||
                            Text('#${ticket.id}').fontSize(11).opacity(0.75),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                      IconButton(
 | 
			
		||||
                        iconSize: 20,
 | 
			
		||||
                        visualDensity:
 | 
			
		||||
                            VisualDensity(horizontal: -4, vertical: -4),
 | 
			
		||||
                        constraints: const BoxConstraints(),
 | 
			
		||||
                        padding: EdgeInsets.zero,
 | 
			
		||||
                        icon: const Icon(Symbols.logout),
 | 
			
		||||
                        onPressed: _currentTicketId == ticket.id
 | 
			
		||||
                            ? null
 | 
			
		||||
                            : () {
 | 
			
		||||
                                _deleteAuthTicket(ticket);
 | 
			
		||||
                              },
 | 
			
		||||
                      ),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ).padding(horizontal: 16, vertical: 12);
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										141
									
								
								lib/screens/account/badges.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								lib/screens/account/badges.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,141 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:google_fonts/google_fonts.dart';
 | 
			
		||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/screens/account/profile_page.dart' show kBadgesMeta;
 | 
			
		||||
import 'package:surface/theme.dart';
 | 
			
		||||
import 'package:surface/types/account.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
 | 
			
		||||
class AccountBadgesScreen extends StatefulWidget {
 | 
			
		||||
  const AccountBadgesScreen({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<AccountBadgesScreen> createState() => _AccountBadgesScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AccountBadgesScreenState extends State<AccountBadgesScreen> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
  List<SnAccountBadge>? _badges;
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchBadges() async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/id/badges/me');
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      setState(
 | 
			
		||||
        () => _badges = List<SnAccountBadge>.from(
 | 
			
		||||
          resp.data?.map((e) => SnAccountBadge.fromJson(e)) ?? [],
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bool _isActivating = false;
 | 
			
		||||
 | 
			
		||||
  Future<void> _activateBadge(SnAccountBadge badge) async {
 | 
			
		||||
    try {
 | 
			
		||||
      setState(() => _isActivating = true);
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.post('/cgi/id/badges/${badge.id}/active');
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar('badgeActivated'
 | 
			
		||||
          .tr(args: [(kBadgesMeta[badge.type]?.$1 ?? 'unknown').tr()]));
 | 
			
		||||
      await _fetchBadges();
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isActivating = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _fetchBadges();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        title: Text('screenAccountBadges').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: Column(
 | 
			
		||||
        children: [
 | 
			
		||||
          LoadingIndicator(isActive: _isBusy),
 | 
			
		||||
          if (_badges != null)
 | 
			
		||||
            Expanded(
 | 
			
		||||
              child: MediaQuery.removePadding(
 | 
			
		||||
                context: context,
 | 
			
		||||
                removeTop: true,
 | 
			
		||||
                child: RefreshIndicator(
 | 
			
		||||
                  onRefresh: _fetchBadges,
 | 
			
		||||
                  child: ListView.builder(
 | 
			
		||||
                    itemCount: _badges!.length,
 | 
			
		||||
                    itemBuilder: (context, idx) {
 | 
			
		||||
                      final badge = _badges![idx];
 | 
			
		||||
                      return ListTile(
 | 
			
		||||
                        title: Text(
 | 
			
		||||
                          kBadgesMeta[badge.type]?.$1 ?? 'unknown',
 | 
			
		||||
                        ).tr(),
 | 
			
		||||
                        contentPadding: const EdgeInsets.only(
 | 
			
		||||
                          left: 24,
 | 
			
		||||
                          right: 16,
 | 
			
		||||
                          top: 4,
 | 
			
		||||
                          bottom: 4,
 | 
			
		||||
                        ),
 | 
			
		||||
                        subtitle: Column(
 | 
			
		||||
                          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            if (badge.metadata['title'] != null)
 | 
			
		||||
                              Text(badge.metadata['title']).fontSize(14).bold()
 | 
			
		||||
                            else
 | 
			
		||||
                              Text(
 | 
			
		||||
                                '#${badge.id.toString().padLeft(8, '0')}',
 | 
			
		||||
                                style: GoogleFonts.robotoMono(),
 | 
			
		||||
                              ).fontSize(14).bold(),
 | 
			
		||||
                            Text(
 | 
			
		||||
                              DateFormat('y/M/d').format(badge.createdAt),
 | 
			
		||||
                            )
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                        trailing: IconButton(
 | 
			
		||||
                          icon: const Icon(Symbols.check),
 | 
			
		||||
                          onPressed: (badge.isActive || _isActivating)
 | 
			
		||||
                              ? null
 | 
			
		||||
                              : () {
 | 
			
		||||
                                  _activateBadge(badge);
 | 
			
		||||
                                },
 | 
			
		||||
                        ),
 | 
			
		||||
                        leading: Icon(
 | 
			
		||||
                          kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark,
 | 
			
		||||
                          color: badge.metadata['color'] != null
 | 
			
		||||
                              ? HexColor.fromHex(badge.metadata['color']!)
 | 
			
		||||
                              : kBadgesMeta[badge.type]?.$3,
 | 
			
		||||
                          fill: 1,
 | 
			
		||||
                        ),
 | 
			
		||||
                      );
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										323
									
								
								lib/screens/account/contact_methods.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										323
									
								
								lib/screens/account/contact_methods.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,323 @@
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:dio/dio.dart';
 | 
			
		||||
import 'package:dropdown_button2/dropdown_button2.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/types/account.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
 | 
			
		||||
const kContactMethodsIcons = [Symbols.email, Symbols.phone, Symbols.map];
 | 
			
		||||
const kContactMethodsName = ['Email', 'Phone', 'Address'];
 | 
			
		||||
 | 
			
		||||
class AccountContactMethod extends StatefulWidget {
 | 
			
		||||
  const AccountContactMethod({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<AccountContactMethod> createState() => _AccountContactMethodState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AccountContactMethodState extends State<AccountContactMethod> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
  List<SnAccountContact> _contactMethods = List.empty(growable: true);
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchContactMethods() async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/id/users/me/contacts');
 | 
			
		||||
      _contactMethods = List.from((resp.data as List<dynamic>)
 | 
			
		||||
          .map((e) => SnAccountContact.fromJson(e)));
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _deleteContactMethod(SnAccountContact contact) async {
 | 
			
		||||
    final confirm = await context.showConfirmDialog(
 | 
			
		||||
      'accountContactMethodsDelete'.tr(),
 | 
			
		||||
      'accountContactMethodsDeleteDescription'.tr(args: [contact.content]),
 | 
			
		||||
    );
 | 
			
		||||
    if (!confirm || !mounted) return;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.delete('/cgi/id/users/me/contacts/${contact.id}');
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      await _fetchContactMethods();
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _fetchContactMethods();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: const PageBackButton(),
 | 
			
		||||
        title: Text('accountContactMethods').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: Column(
 | 
			
		||||
        children: [
 | 
			
		||||
          LoadingIndicator(isActive: _isBusy),
 | 
			
		||||
          ListTile(
 | 
			
		||||
            title: Text('accountContactMethodsAdd').tr(),
 | 
			
		||||
            subtitle: Text('accountContactMethodsAddDescription').tr(),
 | 
			
		||||
            contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
            leading: const Icon(Symbols.add),
 | 
			
		||||
            trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
            onTap: () {
 | 
			
		||||
              showDialog(
 | 
			
		||||
                context: context,
 | 
			
		||||
                builder: (context) => _ContactMethodEditor(),
 | 
			
		||||
              ).then((value) {
 | 
			
		||||
                if (value) {
 | 
			
		||||
                  _fetchContactMethods();
 | 
			
		||||
                }
 | 
			
		||||
              });
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
          Divider(height: 1),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: RefreshIndicator(
 | 
			
		||||
              onRefresh: _fetchContactMethods,
 | 
			
		||||
              child: ListView.builder(
 | 
			
		||||
                padding: EdgeInsets.zero,
 | 
			
		||||
                itemCount: _contactMethods.length,
 | 
			
		||||
                itemBuilder: (context, index) {
 | 
			
		||||
                  final method = _contactMethods[index];
 | 
			
		||||
                  return ListTile(
 | 
			
		||||
                    title: Text(method.content),
 | 
			
		||||
                    subtitle: Column(
 | 
			
		||||
                      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                      children: [
 | 
			
		||||
                        Text(
 | 
			
		||||
                          'accountContactMethodsName${kContactMethodsName[method.type]}',
 | 
			
		||||
                        ).tr().bold(),
 | 
			
		||||
                        if (method.isPrimary ||
 | 
			
		||||
                            method.isPublic ||
 | 
			
		||||
                            method.verifiedAt != null)
 | 
			
		||||
                          Row(
 | 
			
		||||
                            spacing: 4,
 | 
			
		||||
                            children: [
 | 
			
		||||
                              if (method.isPrimary)
 | 
			
		||||
                                Text('accountContactMethodsPrimary').tr(),
 | 
			
		||||
                              if (method.isPublic)
 | 
			
		||||
                                Text('accountContactMethodsPublic').tr(),
 | 
			
		||||
                              if (method.verifiedAt != null)
 | 
			
		||||
                                Text('accountContactMethodsVerified').tr(),
 | 
			
		||||
                            ],
 | 
			
		||||
                          ),
 | 
			
		||||
                      ],
 | 
			
		||||
                    ),
 | 
			
		||||
                    contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
                    leading: Icon(
 | 
			
		||||
                      kContactMethodsIcons[method.type],
 | 
			
		||||
                    ),
 | 
			
		||||
                    trailing: PopupMenuButton(
 | 
			
		||||
                      itemBuilder: (_) => [
 | 
			
		||||
                        PopupMenuItem(
 | 
			
		||||
                          child: Row(
 | 
			
		||||
                            children: [
 | 
			
		||||
                              const Icon(Symbols.edit),
 | 
			
		||||
                              const Gap(16),
 | 
			
		||||
                              Text('edit').tr(),
 | 
			
		||||
                            ],
 | 
			
		||||
                          ),
 | 
			
		||||
                          onTap: () {
 | 
			
		||||
                            showDialog(
 | 
			
		||||
                              context: context,
 | 
			
		||||
                              builder: (context) => _ContactMethodEditor(
 | 
			
		||||
                                contact: method,
 | 
			
		||||
                              ),
 | 
			
		||||
                            ).then((value) {
 | 
			
		||||
                              if (value) {
 | 
			
		||||
                                _fetchContactMethods();
 | 
			
		||||
                              }
 | 
			
		||||
                            });
 | 
			
		||||
                          },
 | 
			
		||||
                        ),
 | 
			
		||||
                        PopupMenuItem(
 | 
			
		||||
                          child: Row(
 | 
			
		||||
                            children: [
 | 
			
		||||
                              const Icon(Symbols.delete),
 | 
			
		||||
                              const Gap(16),
 | 
			
		||||
                              Text('delete'.tr()),
 | 
			
		||||
                            ],
 | 
			
		||||
                          ),
 | 
			
		||||
                          onTap: () {
 | 
			
		||||
                            _deleteContactMethod(method);
 | 
			
		||||
                          },
 | 
			
		||||
                        ),
 | 
			
		||||
                      ],
 | 
			
		||||
                    ),
 | 
			
		||||
                  );
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ContactMethodEditor extends StatefulWidget {
 | 
			
		||||
  final SnAccountContact? contact;
 | 
			
		||||
  const _ContactMethodEditor({this.contact});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_ContactMethodEditor> createState() => _ContactMethodEditorState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ContactMethodEditorState extends State<_ContactMethodEditor> {
 | 
			
		||||
  int _type = 0;
 | 
			
		||||
  bool _isPublic = false;
 | 
			
		||||
  final TextEditingController _contentController = TextEditingController();
 | 
			
		||||
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
 | 
			
		||||
  Future<void> _saveContactMethod() async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.request(
 | 
			
		||||
        widget.contact == null
 | 
			
		||||
            ? '/cgi/id/users/me/contacts'
 | 
			
		||||
            : '/cgi/id/users/me/contacts/${widget.contact!.id}',
 | 
			
		||||
        data: {
 | 
			
		||||
          'content': _contentController.text,
 | 
			
		||||
          'type': _type,
 | 
			
		||||
          'is_public': _isPublic,
 | 
			
		||||
        },
 | 
			
		||||
        options: Options(
 | 
			
		||||
          method: widget.contact == null ? 'POST' : 'PUT',
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      Navigator.pop(context, true);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    if (widget.contact != null) {
 | 
			
		||||
      _type = widget.contact!.type;
 | 
			
		||||
      _isPublic = widget.contact!.isPublic;
 | 
			
		||||
      _contentController.text = widget.contact!.content;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AlertDialog(
 | 
			
		||||
      title: widget.contact == null
 | 
			
		||||
          ? Text('accountContactMethodsAdd').tr()
 | 
			
		||||
          : Text('accountContactMethodsEdit').tr(),
 | 
			
		||||
      content: Column(
 | 
			
		||||
        mainAxisSize: MainAxisSize.min,
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
        children: [
 | 
			
		||||
          DropdownButtonHideUnderline(
 | 
			
		||||
            child: DropdownButton2<int>(
 | 
			
		||||
              value: _type,
 | 
			
		||||
              items: kContactMethodsName
 | 
			
		||||
                  .mapIndexed((idx, ele) => DropdownMenuItem<int>(
 | 
			
		||||
                        value: idx,
 | 
			
		||||
                        child: Text('accountContactMethodsName$ele').tr(),
 | 
			
		||||
                      ))
 | 
			
		||||
                  .toList(),
 | 
			
		||||
              buttonStyleData: ButtonStyleData(
 | 
			
		||||
                height: 48,
 | 
			
		||||
                width: double.infinity,
 | 
			
		||||
                padding: const EdgeInsets.only(left: 14, right: 14),
 | 
			
		||||
                decoration: BoxDecoration(
 | 
			
		||||
                  borderRadius: BorderRadius.circular(4),
 | 
			
		||||
                  border: Border.all(
 | 
			
		||||
                    color: Theme.of(context).dividerColor,
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              menuItemStyleData: const MenuItemStyleData(
 | 
			
		||||
                height: 48,
 | 
			
		||||
                padding: EdgeInsets.only(left: 14, right: 14),
 | 
			
		||||
              ),
 | 
			
		||||
              onChanged: (value) {
 | 
			
		||||
                setState(() => _type = value ?? 0);
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          const Gap(8),
 | 
			
		||||
          TextField(
 | 
			
		||||
            controller: _contentController,
 | 
			
		||||
            decoration: InputDecoration(
 | 
			
		||||
              isDense: true,
 | 
			
		||||
              border: const OutlineInputBorder(),
 | 
			
		||||
              labelText: 'fieldContactContent'.tr(),
 | 
			
		||||
            ),
 | 
			
		||||
            onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
          ),
 | 
			
		||||
          const Gap(8),
 | 
			
		||||
          Card(
 | 
			
		||||
            margin: EdgeInsets.zero,
 | 
			
		||||
            child: CheckboxListTile(
 | 
			
		||||
              shape: RoundedRectangleBorder(
 | 
			
		||||
                borderRadius: BorderRadius.all(
 | 
			
		||||
                  Radius.circular(8),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              title: Text('accountContactMethodsPublic').tr(),
 | 
			
		||||
              subtitle: Text('accountContactMethodsPublicHint').tr(),
 | 
			
		||||
              secondary: const Icon(Symbols.globe),
 | 
			
		||||
              value: _isPublic,
 | 
			
		||||
              onChanged: (value) {
 | 
			
		||||
                setState(() => _isPublic = value ?? false);
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
          )
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
      actions: [
 | 
			
		||||
        TextButton(
 | 
			
		||||
          onPressed: _isBusy
 | 
			
		||||
              ? null
 | 
			
		||||
              : () {
 | 
			
		||||
                  Navigator.of(context).pop();
 | 
			
		||||
                },
 | 
			
		||||
          child: Text('dialogDismiss').tr(),
 | 
			
		||||
        ),
 | 
			
		||||
        TextButton(
 | 
			
		||||
          onPressed: _isBusy
 | 
			
		||||
              ? null
 | 
			
		||||
              : () {
 | 
			
		||||
                  _saveContactMethod();
 | 
			
		||||
                },
 | 
			
		||||
          child: Text('dialogConfirm').tr(),
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -16,7 +16,11 @@ final Map<int, (String, String, IconData)> kFactorTypes = {
 | 
			
		||||
  0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
 | 
			
		||||
  1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email),
 | 
			
		||||
  2: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer),
 | 
			
		||||
  3: ('authFactorInAppNotify', 'authFactorInAppNotifyDescription', Symbols.notifications_active),
 | 
			
		||||
  3: (
 | 
			
		||||
    'authFactorInAppNotify',
 | 
			
		||||
    'authFactorInAppNotifyDescription',
 | 
			
		||||
    Symbols.notifications_active
 | 
			
		||||
  ),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
class FactorSettingsScreen extends StatefulWidget {
 | 
			
		||||
@@ -36,7 +40,10 @@ class _FactorSettingsScreenState extends State<FactorSettingsScreen> {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/id/users/me/factors');
 | 
			
		||||
      _factors = List<SnAuthFactor>.from(
 | 
			
		||||
        resp.data?.map((e) => SnAuthFactor.fromJson(e as Map<String, dynamic>)).toList() ?? [],
 | 
			
		||||
        resp.data
 | 
			
		||||
                ?.map((e) => SnAuthFactor.fromJson(e as Map<String, dynamic>))
 | 
			
		||||
                .toList() ??
 | 
			
		||||
            [],
 | 
			
		||||
      );
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
@@ -55,6 +62,7 @@ class _FactorSettingsScreenState extends State<FactorSettingsScreen> {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: PageBackButton(),
 | 
			
		||||
        title: Text('screenFactorSettings').tr(),
 | 
			
		||||
@@ -96,7 +104,8 @@ class _FactorSettingsScreenState extends State<FactorSettingsScreen> {
 | 
			
		||||
                    return ListTile(
 | 
			
		||||
                      title: Text(kFactorTypes[ele.type]!.$1).tr(),
 | 
			
		||||
                      subtitle: Text(kFactorTypes[ele.type]!.$2).tr(),
 | 
			
		||||
                      contentPadding: const EdgeInsets.only(left: 24, right: 12),
 | 
			
		||||
                      contentPadding:
 | 
			
		||||
                          const EdgeInsets.only(left: 24, right: 12),
 | 
			
		||||
                      leading: Icon(kFactorTypes[ele.type]!.$3),
 | 
			
		||||
                      trailing: IconButton(
 | 
			
		||||
                        icon: const Icon(Symbols.close),
 | 
			
		||||
@@ -105,14 +114,17 @@ class _FactorSettingsScreenState extends State<FactorSettingsScreen> {
 | 
			
		||||
                                context
 | 
			
		||||
                                    .showConfirmDialog(
 | 
			
		||||
                                  'authFactorDelete'.tr(),
 | 
			
		||||
                                  'authFactorDeleteDescription'.tr(args: [kFactorTypes[ele.type]!.$1.tr()]),
 | 
			
		||||
                                  'authFactorDeleteDescription'.tr(
 | 
			
		||||
                                      args: [kFactorTypes[ele.type]!.$1.tr()]),
 | 
			
		||||
                                )
 | 
			
		||||
                                    .then((val) async {
 | 
			
		||||
                                  if (!val) return;
 | 
			
		||||
                                  try {
 | 
			
		||||
                                    if (!context.mounted) return;
 | 
			
		||||
                                    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
                                    await sn.client.delete('/cgi/id/users/me/factors/${ele.id}');
 | 
			
		||||
                                    final sn =
 | 
			
		||||
                                        context.read<SnNetworkProvider>();
 | 
			
		||||
                                    await sn.client.delete(
 | 
			
		||||
                                        '/cgi/id/users/me/factors/${ele.id}');
 | 
			
		||||
                                    _fetchFactors();
 | 
			
		||||
                                  } catch (err) {
 | 
			
		||||
                                    if (!context.mounted) return;
 | 
			
		||||
@@ -191,7 +203,9 @@ class _FactorNewDialogState extends State<_FactorNewDialog> {
 | 
			
		||||
              value: _factorType,
 | 
			
		||||
              items: kFactorTypes.entries.map(
 | 
			
		||||
                (ele) {
 | 
			
		||||
                  final contains = widget.currentlyHave.map((ele) => ele.type).contains(ele.key);
 | 
			
		||||
                  final contains = widget.currentlyHave
 | 
			
		||||
                      .map((ele) => ele.type)
 | 
			
		||||
                      .contains(ele.key);
 | 
			
		||||
                  return DropdownMenuItem<int>(
 | 
			
		||||
                    enabled: !contains,
 | 
			
		||||
                    value: ele.key,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										107
									
								
								lib/screens/account/keypairs.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								lib/screens/account/keypairs.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,107 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:google_fonts/google_fonts.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:surface/providers/keypair.dart';
 | 
			
		||||
import 'package:surface/types/keypair.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
 | 
			
		||||
class KeyPairScreen extends StatefulWidget {
 | 
			
		||||
  const KeyPairScreen({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<KeyPairScreen> createState() => _KeyPairScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _KeyPairScreenState extends State<KeyPairScreen> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
  List<SnKeyPair>? _keyPairs;
 | 
			
		||||
 | 
			
		||||
  Future<void> _loadKeyPairs() async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
    final kps = await context.read<KeyPairProvider>().listKeyPair();
 | 
			
		||||
    setState(() {
 | 
			
		||||
      _keyPairs = kps;
 | 
			
		||||
      _isBusy = false;
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _loadKeyPairs();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        title: Text('screenKeyPairs').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: Column(
 | 
			
		||||
        children: [
 | 
			
		||||
          LoadingIndicator(isActive: _isBusy),
 | 
			
		||||
          ListTile(
 | 
			
		||||
            leading: const Icon(Symbols.add),
 | 
			
		||||
            title: Text('enrollNewKeyPair').tr(),
 | 
			
		||||
            subtitle: Text('enrollNewKeyPairDescription').tr(),
 | 
			
		||||
            onTap: () async {
 | 
			
		||||
              await context.read<KeyPairProvider>().enrollNew();
 | 
			
		||||
              _loadKeyPairs();
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
          const Divider(height: 1),
 | 
			
		||||
          if (_keyPairs != null)
 | 
			
		||||
            Expanded(
 | 
			
		||||
              child: MediaQuery.removePadding(
 | 
			
		||||
                context: context,
 | 
			
		||||
                removeTop: true,
 | 
			
		||||
                child: RefreshIndicator(
 | 
			
		||||
                  onRefresh: _loadKeyPairs,
 | 
			
		||||
                  child: ListView.builder(
 | 
			
		||||
                    itemCount: _keyPairs!.length,
 | 
			
		||||
                    itemBuilder: (context, index) {
 | 
			
		||||
                      final kp = _keyPairs![index];
 | 
			
		||||
                      return ListTile(
 | 
			
		||||
                        title: Text(kp.id.toUpperCase()),
 | 
			
		||||
                        subtitle: Row(
 | 
			
		||||
                          spacing: 8,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            if (kp.privateKey != null)
 | 
			
		||||
                              Text(
 | 
			
		||||
                                'keyPairHasPrivateKey'.tr(),
 | 
			
		||||
                              ),
 | 
			
		||||
                            if (kp.privateKey != null) Text('·'),
 | 
			
		||||
                            Flexible(
 | 
			
		||||
                              flex: 1,
 | 
			
		||||
                              child: Text(
 | 
			
		||||
                                'UID #${kp.accountId.toString().padLeft(8, '0')}',
 | 
			
		||||
                                style: GoogleFonts.robotoMono(),
 | 
			
		||||
                              ),
 | 
			
		||||
                            ),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                        trailing: IconButton(
 | 
			
		||||
                          icon: const Icon(Symbols.check),
 | 
			
		||||
                          onPressed: kp.isActive == true
 | 
			
		||||
                              ? null
 | 
			
		||||
                              : () async {
 | 
			
		||||
                                  final k = context.read<KeyPairProvider>();
 | 
			
		||||
                                  await k.activeKeyPair(kp.id);
 | 
			
		||||
                                  _loadKeyPairs();
 | 
			
		||||
                                },
 | 
			
		||||
                        ),
 | 
			
		||||
                      );
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										123
									
								
								lib/screens/account/prefs/notify.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								lib/screens/account/prefs/notify.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,123 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:google_fonts/google_fonts.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
 | 
			
		||||
final Map<String, String> kNotifyTopicMap = {
 | 
			
		||||
  'interactive.reply': 'notificationTopicPostReply'.tr(),
 | 
			
		||||
  'interactive.feedback': 'notificationTopicPostFeedback'.tr(),
 | 
			
		||||
  'interactive.subscription': 'notificationTopicPostSubscription'.tr(),
 | 
			
		||||
  'messaging.message': 'notificationTopicMessaging'.tr(),
 | 
			
		||||
  'messaging.call': 'notificationTopicMessagingCall'.tr(),
 | 
			
		||||
  'general': 'notificationTopicGeneral'.tr(),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
class AccountNotifyPrefsScreen extends StatefulWidget {
 | 
			
		||||
  const AccountNotifyPrefsScreen({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<AccountNotifyPrefsScreen> createState() =>
 | 
			
		||||
      _AccountNotifyPrefsScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AccountNotifyPrefsScreenState extends State<AccountNotifyPrefsScreen> {
 | 
			
		||||
  bool _isBusy = true;
 | 
			
		||||
 | 
			
		||||
  Map<String, bool> _config = {};
 | 
			
		||||
 | 
			
		||||
  Future<void> _getPreferences() async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final resp = await sn.client.get('/cgi/id/preferences/notifications');
 | 
			
		||||
      _config = resp.data['config']
 | 
			
		||||
          .map((k, v) => MapEntry(k, v as bool))
 | 
			
		||||
          .cast<String, bool>();
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _savePreferences() async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      await sn.client.put(
 | 
			
		||||
        '/cgi/id/preferences/notifications',
 | 
			
		||||
        data: {
 | 
			
		||||
          'config': _config,
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar('accountSettingsApplied'.tr());
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _getPreferences();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: const PageBackButton(),
 | 
			
		||||
        title: Text('accountSettingsNotify').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: Column(
 | 
			
		||||
        children: [
 | 
			
		||||
          LoadingIndicator(isActive: _isBusy),
 | 
			
		||||
          ListTile(
 | 
			
		||||
            tileColor: Theme.of(context).colorScheme.surfaceContainer,
 | 
			
		||||
            contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
            leading: const Icon(Icons.save),
 | 
			
		||||
            title: Text('save').tr(),
 | 
			
		||||
            enabled: !_isBusy,
 | 
			
		||||
            onTap: () {
 | 
			
		||||
              _savePreferences();
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: ListView.builder(
 | 
			
		||||
              padding: EdgeInsets.zero,
 | 
			
		||||
              itemCount: kNotifyTopicMap.length,
 | 
			
		||||
              itemBuilder: (context, index) {
 | 
			
		||||
                final element = kNotifyTopicMap.entries.elementAt(index);
 | 
			
		||||
                return CheckboxListTile(
 | 
			
		||||
                  title: Text(element.value),
 | 
			
		||||
                  subtitle: Text(
 | 
			
		||||
                    element.key,
 | 
			
		||||
                    style: GoogleFonts.robotoMono(fontSize: 12),
 | 
			
		||||
                  ),
 | 
			
		||||
                  value: _config[element.key] ?? true,
 | 
			
		||||
                  contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
                  onChanged: (value) {
 | 
			
		||||
                    setState(() {
 | 
			
		||||
                      _config[element.key] = value ?? false;
 | 
			
		||||
                    });
 | 
			
		||||
                  },
 | 
			
		||||
                );
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										148
									
								
								lib/screens/account/prefs/security.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										148
									
								
								lib/screens/account/prefs/security.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,148 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
 | 
			
		||||
class AccountSecurityPrefsScreen extends StatefulWidget {
 | 
			
		||||
  const AccountSecurityPrefsScreen({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<AccountSecurityPrefsScreen> createState() =>
 | 
			
		||||
      _AccountSecurityPrefsScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AccountSecurityPrefsScreenState
 | 
			
		||||
    extends State<AccountSecurityPrefsScreen> {
 | 
			
		||||
  bool _isBusy = true;
 | 
			
		||||
 | 
			
		||||
  Map<String, dynamic> _config = {
 | 
			
		||||
    'maximum_auth_steps': 2,
 | 
			
		||||
    'always_risky': false,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  Future<void> _getPreferences() async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final resp = await sn.client.get('/cgi/id/preferences/auth');
 | 
			
		||||
      _config = resp.data['config']
 | 
			
		||||
          .map((k, v) => MapEntry(k, v as bool))
 | 
			
		||||
          .cast<String, bool>();
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _savePreferences() async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      await sn.client.put(
 | 
			
		||||
        '/cgi/id/preferences/auth',
 | 
			
		||||
        data: {
 | 
			
		||||
          'config': _config,
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar('accountSettingsApplied'.tr());
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _getPreferences();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: const PageBackButton(),
 | 
			
		||||
        title: Text('accountSettingsSecurity').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: Column(
 | 
			
		||||
        children: [
 | 
			
		||||
          LoadingIndicator(isActive: _isBusy),
 | 
			
		||||
          ListTile(
 | 
			
		||||
            tileColor: Theme.of(context).colorScheme.surfaceContainer,
 | 
			
		||||
            contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
            leading: const Icon(Icons.save),
 | 
			
		||||
            title: Text('save').tr(),
 | 
			
		||||
            enabled: !_isBusy,
 | 
			
		||||
            onTap: () {
 | 
			
		||||
              _savePreferences();
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: ListView(
 | 
			
		||||
              padding: EdgeInsets.zero,
 | 
			
		||||
              children: [
 | 
			
		||||
                ListTile(
 | 
			
		||||
                  title: Text('authMaximumAuthSteps').tr(),
 | 
			
		||||
                  subtitle: Text('authMaximumAuthStepsDescription')
 | 
			
		||||
                      .plural(_config['maximum_auth_steps'] ?? 2),
 | 
			
		||||
                  contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
                  trailing: Row(
 | 
			
		||||
                    mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      IconButton(
 | 
			
		||||
                        padding: EdgeInsets.zero,
 | 
			
		||||
                        visualDensity: const VisualDensity(
 | 
			
		||||
                          horizontal: -4,
 | 
			
		||||
                          vertical: -4,
 | 
			
		||||
                        ),
 | 
			
		||||
                        icon: const Icon(Symbols.remove),
 | 
			
		||||
                        onPressed: () {
 | 
			
		||||
                          if (_config['maximum_auth_steps'] > 1) {
 | 
			
		||||
                            setState(() => _config['maximum_auth_steps']--);
 | 
			
		||||
                          }
 | 
			
		||||
                        },
 | 
			
		||||
                      ),
 | 
			
		||||
                      IconButton(
 | 
			
		||||
                        padding: EdgeInsets.zero,
 | 
			
		||||
                        visualDensity: const VisualDensity(
 | 
			
		||||
                          horizontal: -4,
 | 
			
		||||
                          vertical: -4,
 | 
			
		||||
                        ),
 | 
			
		||||
                        icon: const Icon(Symbols.add),
 | 
			
		||||
                        onPressed: () {
 | 
			
		||||
                          if (_config['maximum_auth_steps'] < 99) {
 | 
			
		||||
                            setState(() => _config['maximum_auth_steps']++);
 | 
			
		||||
                          }
 | 
			
		||||
                        },
 | 
			
		||||
                      ),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
                CheckboxListTile(
 | 
			
		||||
                  title: Text('authAlwaysRisky').tr(),
 | 
			
		||||
                  subtitle: Text('authAlwaysRiskyDescription').tr(),
 | 
			
		||||
                  contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
                  value: _config['always_risky'] ?? false,
 | 
			
		||||
                  onChanged: (value) {
 | 
			
		||||
                    setState(() => _config['always_risky'] = value);
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -6,6 +6,7 @@ import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/cupertino.dart';
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_timezone/flutter_timezone.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:image_picker/image_picker.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
@@ -36,11 +37,16 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
			
		||||
  final _firstNameController = TextEditingController();
 | 
			
		||||
  final _lastNameController = TextEditingController();
 | 
			
		||||
  final _descriptionController = TextEditingController();
 | 
			
		||||
  final _timezoneController = TextEditingController();
 | 
			
		||||
  final _genderController = TextEditingController();
 | 
			
		||||
  final _pronounsController = TextEditingController();
 | 
			
		||||
  final _locationController = TextEditingController();
 | 
			
		||||
  final _birthdayController = TextEditingController();
 | 
			
		||||
 | 
			
		||||
  String? _avatar;
 | 
			
		||||
  String? _banner;
 | 
			
		||||
  DateTime? _birthday;
 | 
			
		||||
  List<(String, String)>? _links;
 | 
			
		||||
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
 | 
			
		||||
@@ -51,15 +57,21 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
			
		||||
    final prof = ua.user!;
 | 
			
		||||
    _usernameController.text = prof.name;
 | 
			
		||||
    _nicknameController.text = prof.nick;
 | 
			
		||||
    _descriptionController.text = prof.description;
 | 
			
		||||
    _descriptionController.text = prof.profile!.description;
 | 
			
		||||
    _firstNameController.text = prof.profile!.firstName;
 | 
			
		||||
    _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;
 | 
			
		||||
    _banner = prof.banner;
 | 
			
		||||
    if (prof.profile!.birthday != null) {
 | 
			
		||||
      _birthdayController.text = DateFormat(_kDateFormat).format(
 | 
			
		||||
        prof.profile!.birthday!.toLocal(),
 | 
			
		||||
      );
 | 
			
		||||
    _links =
 | 
			
		||||
        prof.profile!.links.entries.map((ele) => (ele.key, ele.value)).toList();
 | 
			
		||||
    _birthday = prof.profile!.birthday?.toLocal();
 | 
			
		||||
    if (_birthday != null) {
 | 
			
		||||
      _birthdayController.text =
 | 
			
		||||
          DateFormat(_kDateFormat).format(prof.profile!.birthday!.toLocal());
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -69,9 +81,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
			
		||||
      builder: (BuildContext context) => Container(
 | 
			
		||||
        height: 216,
 | 
			
		||||
        padding: const EdgeInsets.only(top: 6.0),
 | 
			
		||||
        margin: EdgeInsets.only(
 | 
			
		||||
          bottom: MediaQuery.of(context).viewInsets.bottom,
 | 
			
		||||
        ),
 | 
			
		||||
        margin:
 | 
			
		||||
            EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
 | 
			
		||||
        color: Theme.of(context).colorScheme.surface,
 | 
			
		||||
        child: SafeArea(
 | 
			
		||||
          top: false,
 | 
			
		||||
@@ -82,7 +93,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
			
		||||
            onDateTimeChanged: (DateTime newDate) {
 | 
			
		||||
              setState(() {
 | 
			
		||||
                _birthday = newDate;
 | 
			
		||||
                _birthdayController.text = DateFormat(_kDateFormat).format(_birthday!);
 | 
			
		||||
                _birthdayController.text =
 | 
			
		||||
                    DateFormat(_kDateFormat).format(_birthday!);
 | 
			
		||||
              });
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
@@ -96,32 +108,45 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
			
		||||
    if (image == null) return;
 | 
			
		||||
    if (!mounted) return;
 | 
			
		||||
 | 
			
		||||
    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,
 | 
			
		||||
          );
 | 
			
		||||
    final skipCrop = image.path.endsWith('.gif');
 | 
			
		||||
 | 
			
		||||
    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;
 | 
			
		||||
    final attach = context.read<SnAttachmentProvider>();
 | 
			
		||||
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final attachment = await attach.directUploadOne(
 | 
			
		||||
        rawBytes,
 | 
			
		||||
@@ -133,10 +158,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
			
		||||
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.put(
 | 
			
		||||
        '/cgi/id/users/me/$place',
 | 
			
		||||
        data: {'attachment': attachment.rid},
 | 
			
		||||
      );
 | 
			
		||||
      await sn.client
 | 
			
		||||
          .put('/cgi/id/users/me/$place', data: {'attachment': attachment.rid});
 | 
			
		||||
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      final ua = context.read<UserProvider>();
 | 
			
		||||
@@ -166,7 +189,16 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
			
		||||
          'description': _descriptionController.value.text,
 | 
			
		||||
          'first_name': _firstNameController.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(),
 | 
			
		||||
          'links': {
 | 
			
		||||
            for (final link in _links!
 | 
			
		||||
                .where((ele) => ele.$1.isNotEmpty && ele.$2.isNotEmpty))
 | 
			
		||||
              link.$1: link.$2,
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
@@ -197,6 +229,10 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
			
		||||
    _firstNameController.dispose();
 | 
			
		||||
    _lastNameController.dispose();
 | 
			
		||||
    _descriptionController.dispose();
 | 
			
		||||
    _timezoneController.dispose();
 | 
			
		||||
    _genderController.dispose();
 | 
			
		||||
    _pronounsController.dispose();
 | 
			
		||||
    _locationController.dispose();
 | 
			
		||||
    _birthdayController.dispose();
 | 
			
		||||
    super.dispose();
 | 
			
		||||
  }
 | 
			
		||||
@@ -208,10 +244,10 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: const PageBackButton(),
 | 
			
		||||
        title: Text('screenAccountProfileEdit').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
          leading: const PageBackButton(),
 | 
			
		||||
          title: Text('screenAccountProfileEdit').tr()),
 | 
			
		||||
      body: SingleChildScrollView(
 | 
			
		||||
        child: Column(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
@@ -229,12 +265,13 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
			
		||||
                      child: AspectRatio(
 | 
			
		||||
                        aspectRatio: 16 / 9,
 | 
			
		||||
                        child: Container(
 | 
			
		||||
                          color: Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
			
		||||
                          color: Theme.of(context)
 | 
			
		||||
                              .colorScheme
 | 
			
		||||
                              .surfaceContainerHigh,
 | 
			
		||||
                          child: _banner != null
 | 
			
		||||
                              ? AutoResizeUniversalImage(
 | 
			
		||||
                                  sn.getAttachmentUrl(_banner!),
 | 
			
		||||
                                  fit: BoxFit.cover,
 | 
			
		||||
                                )
 | 
			
		||||
                                  fit: BoxFit.cover)
 | 
			
		||||
                              : const SizedBox.shrink(),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
@@ -262,6 +299,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
			
		||||
            ).padding(horizontal: padding),
 | 
			
		||||
            const Gap(8 + 28),
 | 
			
		||||
            Column(
 | 
			
		||||
              spacing: 4,
 | 
			
		||||
              children: [
 | 
			
		||||
                TextField(
 | 
			
		||||
                  readOnly: true,
 | 
			
		||||
@@ -271,16 +309,17 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
			
		||||
                    labelText: 'fieldUsername'.tr(),
 | 
			
		||||
                    helperText: 'fieldUsernameCannotEditHint'.tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) =>
 | 
			
		||||
                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ),
 | 
			
		||||
                const Gap(4),
 | 
			
		||||
                TextField(
 | 
			
		||||
                  controller: _nicknameController,
 | 
			
		||||
                  decoration: InputDecoration(
 | 
			
		||||
                    border: const UnderlineInputBorder(),
 | 
			
		||||
                    labelText: 'fieldNickname'.tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                      border: const UnderlineInputBorder(),
 | 
			
		||||
                      labelText: 'fieldNickname'.tr()),
 | 
			
		||||
                  onTapOutside: (_) =>
 | 
			
		||||
                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ),
 | 
			
		||||
                const Gap(4),
 | 
			
		||||
                Row(
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Flexible(
 | 
			
		||||
@@ -291,6 +330,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
			
		||||
                          border: const UnderlineInputBorder(),
 | 
			
		||||
                          labelText: 'fieldFirstName'.tr(),
 | 
			
		||||
                        ),
 | 
			
		||||
                        onTapOutside: (_) =>
 | 
			
		||||
                            FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                    const Gap(8),
 | 
			
		||||
@@ -302,31 +343,189 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
			
		||||
                          border: const UnderlineInputBorder(),
 | 
			
		||||
                          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(
 | 
			
		||||
                  controller: _descriptionController,
 | 
			
		||||
                  keyboardType: TextInputType.multiline,
 | 
			
		||||
                  maxLines: null,
 | 
			
		||||
                  minLines: 3,
 | 
			
		||||
                  decoration: InputDecoration(
 | 
			
		||||
                    border: const UnderlineInputBorder(),
 | 
			
		||||
                    labelText: 'fieldDescription'.tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                      border: const UnderlineInputBorder(),
 | 
			
		||||
                      labelText: 'fieldDescription'.tr()),
 | 
			
		||||
                  onTapOutside: (_) =>
 | 
			
		||||
                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ),
 | 
			
		||||
                Row(
 | 
			
		||||
                  crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Expanded(
 | 
			
		||||
                      child: TextField(
 | 
			
		||||
                        controller: _timezoneController,
 | 
			
		||||
                        decoration: InputDecoration(
 | 
			
		||||
                          border: const UnderlineInputBorder(),
 | 
			
		||||
                          labelText: 'fieldTimeZone'.tr(),
 | 
			
		||||
                        ),
 | 
			
		||||
                        onTapOutside: (_) =>
 | 
			
		||||
                            FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                    const Gap(4),
 | 
			
		||||
                    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(
 | 
			
		||||
                  controller: _birthdayController,
 | 
			
		||||
                  readOnly: true,
 | 
			
		||||
                  decoration: InputDecoration(
 | 
			
		||||
                    border: const UnderlineInputBorder(),
 | 
			
		||||
                    labelText: 'fieldBirthday'.tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                      border: const UnderlineInputBorder(),
 | 
			
		||||
                      labelText: 'fieldBirthday'.tr()),
 | 
			
		||||
                  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),
 | 
			
		||||
            const Gap(12),
 | 
			
		||||
@@ -340,6 +539,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ).padding(horizontal: padding),
 | 
			
		||||
            Gap(MediaQuery.of(context).padding.bottom),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import 'dart:math' as math;
 | 
			
		||||
import 'dart:ui';
 | 
			
		||||
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
@@ -18,10 +19,13 @@ import 'package:surface/types/account.dart';
 | 
			
		||||
import 'package:surface/types/check_in.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/account/badge.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
import 'package:surface/theme.dart';
 | 
			
		||||
import 'package:url_launcher/url_launcher_string.dart';
 | 
			
		||||
 | 
			
		||||
const Map<String, (String, IconData, Color)> kBadgesMeta = {
 | 
			
		||||
final Map<String, (String, IconData, Color)> kBadgesMeta = {
 | 
			
		||||
  'company.staff': (
 | 
			
		||||
    'badgeCompanyStaff',
 | 
			
		||||
    Symbols.tools_wrench,
 | 
			
		||||
@@ -32,6 +36,31 @@ const Map<String, (String, IconData, Color)> kBadgesMeta = {
 | 
			
		||||
    Symbols.flag,
 | 
			
		||||
    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 {
 | 
			
		||||
@@ -43,7 +72,8 @@ class UserScreen extends StatefulWidget {
 | 
			
		||||
  State<UserScreen> createState() => _UserScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateMixin {
 | 
			
		||||
class _UserScreenState extends State<UserScreen>
 | 
			
		||||
    with SingleTickerProviderStateMixin {
 | 
			
		||||
  late final ScrollController _scrollController = ScrollController();
 | 
			
		||||
 | 
			
		||||
  SnAccount? _account;
 | 
			
		||||
@@ -64,13 +94,18 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<List<SnCheckInRecord>> _getCheckInRecords() async {
 | 
			
		||||
  List<SnCheckInRecord>? _records;
 | 
			
		||||
 | 
			
		||||
  Future<void> _getCheckInRecords() async {
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14');
 | 
			
		||||
      return List.from(
 | 
			
		||||
        resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [],
 | 
			
		||||
      );
 | 
			
		||||
      final resp =
 | 
			
		||||
          await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14');
 | 
			
		||||
      setState(() {
 | 
			
		||||
        _records = List.from(
 | 
			
		||||
          resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [],
 | 
			
		||||
        );
 | 
			
		||||
      });
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (mounted) context.showErrorDialog(err);
 | 
			
		||||
      rethrow;
 | 
			
		||||
@@ -98,7 +133,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
			
		||||
  Future<void> _fetchPublishers() async {
 | 
			
		||||
    try {
 | 
			
		||||
      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(
 | 
			
		||||
        resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
 | 
			
		||||
      );
 | 
			
		||||
@@ -144,7 +180,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
			
		||||
        'related': _account!.name,
 | 
			
		||||
      });
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar('userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
 | 
			
		||||
      context.showSnackbar(
 | 
			
		||||
          'userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
@@ -160,9 +197,11 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final rel = context.read<SnRelationshipProvider>();
 | 
			
		||||
      await rel.updateRelationship(_account!.id, 1, _accountRelationship?.permNodes ?? {});
 | 
			
		||||
      await rel.updateRelationship(
 | 
			
		||||
          _account!.id, 1, _accountRelationship?.permNodes ?? {});
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar('userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
 | 
			
		||||
      context.showSnackbar(
 | 
			
		||||
          'userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
@@ -188,12 +227,14 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
			
		||||
  double _appBarBlur = 0.0;
 | 
			
		||||
 | 
			
		||||
  late final _appBarWidth = MediaQuery.of(context).size.width;
 | 
			
		||||
  late final _appBarHeight = (_appBarWidth * kBannerAspectRatio).roundToDouble();
 | 
			
		||||
  late final _appBarHeight =
 | 
			
		||||
      math.min((_appBarWidth * kBannerAspectRatio), 360).roundToDouble();
 | 
			
		||||
 | 
			
		||||
  void _updateAppBarBlur() {
 | 
			
		||||
    if (_scrollController.offset > _appBarHeight) return;
 | 
			
		||||
    setState(() {
 | 
			
		||||
      _appBarBlur = (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
 | 
			
		||||
      _appBarBlur =
 | 
			
		||||
          (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -205,6 +246,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
			
		||||
 | 
			
		||||
      _fetchStatus();
 | 
			
		||||
      _fetchPublishers();
 | 
			
		||||
      _getCheckInRecords();
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        final rel = context.read<SnRelationshipProvider>();
 | 
			
		||||
@@ -260,18 +302,20 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
			
		||||
                      text: TextSpan(children: [
 | 
			
		||||
                        TextSpan(
 | 
			
		||||
                          text: _account!.nick,
 | 
			
		||||
                          style: Theme.of(context).textTheme.titleLarge!.copyWith(
 | 
			
		||||
                                color: Colors.white,
 | 
			
		||||
                                shadows: labelShadows,
 | 
			
		||||
                              ),
 | 
			
		||||
                          style:
 | 
			
		||||
                              Theme.of(context).textTheme.titleLarge!.copyWith(
 | 
			
		||||
                                    color: Colors.white,
 | 
			
		||||
                                    shadows: labelShadows,
 | 
			
		||||
                                  ),
 | 
			
		||||
                        ),
 | 
			
		||||
                        const TextSpan(text: '\n'),
 | 
			
		||||
                        TextSpan(
 | 
			
		||||
                          text: '@${_account!.name}',
 | 
			
		||||
                          style: Theme.of(context).textTheme.bodySmall!.copyWith(
 | 
			
		||||
                                color: Colors.white,
 | 
			
		||||
                                shadows: labelShadows,
 | 
			
		||||
                              ),
 | 
			
		||||
                          style:
 | 
			
		||||
                              Theme.of(context).textTheme.bodySmall!.copyWith(
 | 
			
		||||
                                    color: Colors.white,
 | 
			
		||||
                                    shadows: labelShadows,
 | 
			
		||||
                                  ),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ]),
 | 
			
		||||
                    ),
 | 
			
		||||
@@ -280,14 +324,21 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
			
		||||
                  ? Stack(
 | 
			
		||||
                      fit: StackFit.expand,
 | 
			
		||||
                      children: [
 | 
			
		||||
                        UniversalImage(
 | 
			
		||||
                          sn.getAttachmentUrl(_account!.banner),
 | 
			
		||||
                          fit: BoxFit.cover,
 | 
			
		||||
                          height: imageHeight,
 | 
			
		||||
                          width: _appBarWidth,
 | 
			
		||||
                          cacheHeight: imageHeight,
 | 
			
		||||
                          cacheWidth: _appBarWidth,
 | 
			
		||||
                        ),
 | 
			
		||||
                        if (_account!.banner.isNotEmpty)
 | 
			
		||||
                          UniversalImage(
 | 
			
		||||
                            sn.getAttachmentUrl(_account!.banner),
 | 
			
		||||
                            fit: BoxFit.cover,
 | 
			
		||||
                            height: imageHeight,
 | 
			
		||||
                            width: _appBarWidth,
 | 
			
		||||
                            cacheHeight: imageHeight,
 | 
			
		||||
                            cacheWidth: _appBarWidth,
 | 
			
		||||
                          )
 | 
			
		||||
                        else
 | 
			
		||||
                          Container(
 | 
			
		||||
                            color: Theme.of(context)
 | 
			
		||||
                                .colorScheme
 | 
			
		||||
                                .surfaceContainerHigh,
 | 
			
		||||
                          ),
 | 
			
		||||
                        Positioned(
 | 
			
		||||
                          top: 0,
 | 
			
		||||
                          left: 0,
 | 
			
		||||
@@ -339,7 +390,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
			
		||||
                      PopupMenuButton(
 | 
			
		||||
                        padding: EdgeInsets.zero,
 | 
			
		||||
                        style: ButtonStyle(
 | 
			
		||||
                          visualDensity: VisualDensity(horizontal: -4, vertical: -4),
 | 
			
		||||
                          visualDensity:
 | 
			
		||||
                              VisualDensity(horizontal: -4, vertical: -4),
 | 
			
		||||
                        ),
 | 
			
		||||
                        itemBuilder: (context) => [
 | 
			
		||||
                          PopupMenuItem(
 | 
			
		||||
@@ -389,27 +441,41 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
			
		||||
                      ),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ).padding(right: 8),
 | 
			
		||||
                  const Gap(12),
 | 
			
		||||
                  Text(_account!.description).padding(horizontal: 8),
 | 
			
		||||
                  if (_account!.profile!.description.isNotEmpty)
 | 
			
		||||
                    const Gap(12)
 | 
			
		||||
                  else
 | 
			
		||||
                    const Gap(8),
 | 
			
		||||
                  if (_account!.profile!.description.isNotEmpty)
 | 
			
		||||
                    Text(_account!.profile!.description).padding(horizontal: 8),
 | 
			
		||||
                  const Gap(4),
 | 
			
		||||
                  Card(
 | 
			
		||||
                    child: Row(
 | 
			
		||||
                      children: [
 | 
			
		||||
                        Icon(
 | 
			
		||||
                          Symbols.circle,
 | 
			
		||||
                          fill: 1,
 | 
			
		||||
                          (_status?.isDisturbable ?? true)
 | 
			
		||||
                              ? Symbols.circle
 | 
			
		||||
                              : Symbols.do_not_disturb_on,
 | 
			
		||||
                          fill: (_status?.isOnline ?? false) ? 1 : 0,
 | 
			
		||||
                          size: 16,
 | 
			
		||||
                          color: (_status?.isOnline ?? false) ? Colors.green : Colors.grey,
 | 
			
		||||
                          color: (_status?.isOnline ?? false)
 | 
			
		||||
                              ? (_status?.isDisturbable ?? true)
 | 
			
		||||
                                  ? Colors.green
 | 
			
		||||
                                  : Colors.red
 | 
			
		||||
                              : Colors.grey,
 | 
			
		||||
                        ).padding(all: 4),
 | 
			
		||||
                        const Gap(8),
 | 
			
		||||
                        Text(
 | 
			
		||||
                          _status != null
 | 
			
		||||
                              ? _status!.isOnline
 | 
			
		||||
                                  ? 'accountStatusOnline'.tr()
 | 
			
		||||
                                  : 'accountStatusOffline'.tr()
 | 
			
		||||
                              ? (_status!.status?.label.isNotEmpty ?? false)
 | 
			
		||||
                                  ? _status!.status!.label
 | 
			
		||||
                                  : _status!.isOnline
 | 
			
		||||
                                      ? 'accountStatusOnline'.tr()
 | 
			
		||||
                                      : 'accountStatusOffline'.tr()
 | 
			
		||||
                              : 'loading'.tr(),
 | 
			
		||||
                        ),
 | 
			
		||||
                        if (_status != null && !_status!.isOnline && _status!.lastSeenAt != null)
 | 
			
		||||
                        if (_status != null &&
 | 
			
		||||
                            !_status!.isOnline &&
 | 
			
		||||
                            _status!.lastSeenAt != null)
 | 
			
		||||
                          Text(
 | 
			
		||||
                            'accountStatusLastSeen'.tr(args: [
 | 
			
		||||
                              _status!.lastSeenAt != null
 | 
			
		||||
@@ -424,30 +490,10 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
			
		||||
                  ),
 | 
			
		||||
                  const Gap(8),
 | 
			
		||||
                  Wrap(
 | 
			
		||||
                    spacing: 4,
 | 
			
		||||
                    runSpacing: 4,
 | 
			
		||||
                    children: _account!.badges
 | 
			
		||||
                        .map(
 | 
			
		||||
                          (ele) => Tooltip(
 | 
			
		||||
                            richMessage: TextSpan(
 | 
			
		||||
                              children: [
 | 
			
		||||
                                TextSpan(text: kBadgesMeta[ele.type]?.$1.tr() ?? 'unknown'.tr()),
 | 
			
		||||
                                if (ele.metadata['title'] != null)
 | 
			
		||||
                                  TextSpan(
 | 
			
		||||
                                    text: '\n${ele.metadata['title']}',
 | 
			
		||||
                                    style: const TextStyle(fontWeight: FontWeight.bold),
 | 
			
		||||
                                  ),
 | 
			
		||||
                                TextSpan(text: '\n'),
 | 
			
		||||
                                TextSpan(
 | 
			
		||||
                                  text: DateFormat.yMEd().format(ele.createdAt),
 | 
			
		||||
                                ),
 | 
			
		||||
                              ],
 | 
			
		||||
                            ),
 | 
			
		||||
                            child: Icon(
 | 
			
		||||
                              kBadgesMeta[ele.type]?.$2 ?? Symbols.question_mark,
 | 
			
		||||
                              color: kBadgesMeta[ele.type]?.$3,
 | 
			
		||||
                              fill: 1,
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                        )
 | 
			
		||||
                        .map((ele) => AccountBadge(badge: ele))
 | 
			
		||||
                        .toList(),
 | 
			
		||||
                  ).padding(horizontal: 8),
 | 
			
		||||
                  const Gap(8),
 | 
			
		||||
@@ -458,7 +504,9 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
			
		||||
                        children: [
 | 
			
		||||
                          const Icon(Symbols.calendar_add_on),
 | 
			
		||||
                          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(
 | 
			
		||||
@@ -475,6 +523,44 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
			
		||||
                          ]),
 | 
			
		||||
                        ],
 | 
			
		||||
                      ),
 | 
			
		||||
                      if (_account!.profile!.gender.isNotEmpty ||
 | 
			
		||||
                          _account!.profile!.pronouns.isNotEmpty)
 | 
			
		||||
                        Row(
 | 
			
		||||
                          crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            const Icon(Symbols.wc),
 | 
			
		||||
                            const Gap(8),
 | 
			
		||||
                            Text(
 | 
			
		||||
                              _account!.profile!.gender.isNotEmpty
 | 
			
		||||
                                  ? _account!.profile!.gender
 | 
			
		||||
                                  : 'unknown'.tr(),
 | 
			
		||||
                            ),
 | 
			
		||||
                            Text(' · ').padding(horizontal: 4),
 | 
			
		||||
                            Text(
 | 
			
		||||
                              _account!.profile!.pronouns.isNotEmpty
 | 
			
		||||
                                  ? _account!.profile!.pronouns
 | 
			
		||||
                                  : 'unknown'.tr(),
 | 
			
		||||
                            ),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                      if (_account!.profile!.timeZone.isNotEmpty)
 | 
			
		||||
                        Row(
 | 
			
		||||
                          crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            const Icon(Symbols.schedule),
 | 
			
		||||
                            const Gap(8),
 | 
			
		||||
                            Text(_account!.profile!.timeZone),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                      if (_account!.profile!.location.isNotEmpty)
 | 
			
		||||
                        Row(
 | 
			
		||||
                          crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            const Icon(Symbols.location_on),
 | 
			
		||||
                            const Gap(8),
 | 
			
		||||
                            Text(_account!.profile!.location),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                      Row(
 | 
			
		||||
                        crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                        children: [
 | 
			
		||||
@@ -491,17 +577,24 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
			
		||||
                        children: [
 | 
			
		||||
                          const Icon(Symbols.star),
 | 
			
		||||
                          const Gap(8),
 | 
			
		||||
                          Text('Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'),
 | 
			
		||||
                          Text(
 | 
			
		||||
                              'Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'),
 | 
			
		||||
                          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),
 | 
			
		||||
                          Container(
 | 
			
		||||
                            width: double.infinity,
 | 
			
		||||
                            constraints: const BoxConstraints(maxWidth: 160),
 | 
			
		||||
                            child: LinearProgressIndicator(
 | 
			
		||||
                              value: calcLevelUpProgress(_account?.profile?.experience ?? 0),
 | 
			
		||||
                              value: calcLevelUpProgress(
 | 
			
		||||
                                  _account?.profile?.experience ?? 0),
 | 
			
		||||
                              borderRadius: BorderRadius.circular(8),
 | 
			
		||||
                              backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
 | 
			
		||||
                              backgroundColor: Theme.of(context)
 | 
			
		||||
                                  .colorScheme
 | 
			
		||||
                                  .surfaceContainer,
 | 
			
		||||
                            ).alignment(Alignment.centerLeft),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ],
 | 
			
		||||
@@ -511,24 +604,46 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
			
		||||
                ],
 | 
			
		||||
              ).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()),
 | 
			
		||||
          const SliverGap(12),
 | 
			
		||||
          SliverToBoxAdapter(
 | 
			
		||||
            child: FutureBuilder<List<SnCheckInRecord>>(
 | 
			
		||||
              future: _getCheckInRecords(),
 | 
			
		||||
              builder: (context, snapshot) {
 | 
			
		||||
                if (!snapshot.hasData) return const SizedBox.shrink();
 | 
			
		||||
                if (snapshot.data!.length <= 1) {
 | 
			
		||||
            child: Builder(
 | 
			
		||||
              builder: (context) {
 | 
			
		||||
                if (_records == null) return const SizedBox.shrink();
 | 
			
		||||
                if (_records!.length <= 1) {
 | 
			
		||||
                  return Text(
 | 
			
		||||
                    'accountCheckInNoRecords',
 | 
			
		||||
                    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(
 | 
			
		||||
                  width: double.infinity,
 | 
			
		||||
                  height: 240,
 | 
			
		||||
                  child: CheckInRecordChart(records: records),
 | 
			
		||||
                  child: CheckInRecordChart(records: _records!),
 | 
			
		||||
                ).padding(
 | 
			
		||||
                  right: 24,
 | 
			
		||||
                  left: 16,
 | 
			
		||||
@@ -540,45 +655,55 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
			
		||||
          const SliverGap(12),
 | 
			
		||||
          SliverToBoxAdapter(child: const Divider()),
 | 
			
		||||
          const SliverGap(12),
 | 
			
		||||
          SliverToBoxAdapter(
 | 
			
		||||
            child: Column(
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
              children: [
 | 
			
		||||
                Text('accountBadge').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                SizedBox(
 | 
			
		||||
                  height: 80,
 | 
			
		||||
                  width: double.infinity,
 | 
			
		||||
                  child: ListView(
 | 
			
		||||
                    padding: EdgeInsets.symmetric(horizontal: 8),
 | 
			
		||||
                    scrollDirection: Axis.horizontal,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      for (final badge in _account?.badges ?? [])
 | 
			
		||||
                        SizedBox(
 | 
			
		||||
                          width: 280,
 | 
			
		||||
                          child: Card(
 | 
			
		||||
                            child: ListTile(
 | 
			
		||||
                              leading: Icon(
 | 
			
		||||
                                kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark,
 | 
			
		||||
                                color: kBadgesMeta[badge.type]?.$3,
 | 
			
		||||
                                fill: 1,
 | 
			
		||||
          if (_account?.badges.isNotEmpty ?? false)
 | 
			
		||||
            SliverToBoxAdapter(
 | 
			
		||||
              child: Column(
 | 
			
		||||
                crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                children: [
 | 
			
		||||
                  Text('accountBadge')
 | 
			
		||||
                      .bold()
 | 
			
		||||
                      .fontSize(17)
 | 
			
		||||
                      .tr()
 | 
			
		||||
                      .padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                  SizedBox(
 | 
			
		||||
                    height: 80,
 | 
			
		||||
                    width: double.infinity,
 | 
			
		||||
                    child: ListView(
 | 
			
		||||
                      padding: EdgeInsets.symmetric(horizontal: 8),
 | 
			
		||||
                      scrollDirection: Axis.horizontal,
 | 
			
		||||
                      children: [
 | 
			
		||||
                        for (final badge in _account?.badges ?? [])
 | 
			
		||||
                          SizedBox(
 | 
			
		||||
                            width: 280,
 | 
			
		||||
                            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),
 | 
			
		||||
          SliverToBoxAdapter(child: const Divider()),
 | 
			
		||||
          SliverList.builder(
 | 
			
		||||
@@ -664,7 +789,8 @@ class CheckInRecordChart extends StatelessWidget {
 | 
			
		||||
                  ),
 | 
			
		||||
                )
 | 
			
		||||
                .toList(),
 | 
			
		||||
            getTooltipColor: (_) => Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
			
		||||
            getTooltipColor: (_) =>
 | 
			
		||||
                Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        titlesData: FlTitlesData(
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										291
									
								
								lib/screens/account/programs.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										291
									
								
								lib/screens/account/programs.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,291 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/experience.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/types/account.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/markdown_content.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
 | 
			
		||||
class AccountProgramScreen extends StatefulWidget {
 | 
			
		||||
  const AccountProgramScreen({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<AccountProgramScreen> createState() => _AccountProgramScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AccountProgramScreenState extends State<AccountProgramScreen> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
  final List<SnProgram> _programs = List.empty(growable: true);
 | 
			
		||||
  final List<SnProgramMember> _programMembers = List.empty(growable: true);
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchPrograms() async {
 | 
			
		||||
    _programs.clear();
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/id/programs');
 | 
			
		||||
      _programs.addAll(
 | 
			
		||||
        resp.data.map((ele) => SnProgram.fromJson(ele)).cast<SnProgram>(),
 | 
			
		||||
      );
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchProgramMembers() async {
 | 
			
		||||
    _programMembers.clear();
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/id/programs/members');
 | 
			
		||||
      _programMembers.addAll(
 | 
			
		||||
        resp.data
 | 
			
		||||
            .map((ele) => SnProgramMember.fromJson(ele))
 | 
			
		||||
            .cast<SnProgramMember>(),
 | 
			
		||||
      );
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _fetchPrograms();
 | 
			
		||||
    _fetchProgramMembers();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        title: Text('accountProgram').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: Column(
 | 
			
		||||
        children: [
 | 
			
		||||
          LoadingIndicator(isActive: _isBusy),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: ListView.builder(
 | 
			
		||||
              padding: EdgeInsets.zero,
 | 
			
		||||
              itemCount: _programs.length,
 | 
			
		||||
              itemBuilder: (context, idx) {
 | 
			
		||||
                final ele = _programs[idx];
 | 
			
		||||
                return Card(
 | 
			
		||||
                  child: InkWell(
 | 
			
		||||
                    borderRadius: BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                    onTap: () {
 | 
			
		||||
                      showModalBottomSheet(
 | 
			
		||||
                        isScrollControlled: true,
 | 
			
		||||
                        context: context,
 | 
			
		||||
                        builder: (context) => _ProgramJoinPopup(
 | 
			
		||||
                          program: ele,
 | 
			
		||||
                          isJoined:
 | 
			
		||||
                              _programMembers.any((e) => e.programId == ele.id),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ).then((value) {
 | 
			
		||||
                        if (value == true) {
 | 
			
		||||
                          _fetchProgramMembers();
 | 
			
		||||
                        }
 | 
			
		||||
                      });
 | 
			
		||||
                    },
 | 
			
		||||
                    child: Column(
 | 
			
		||||
                      children: [
 | 
			
		||||
                        if (ele.appearance['banner'] != null)
 | 
			
		||||
                          AspectRatio(
 | 
			
		||||
                            aspectRatio: 16 / 5,
 | 
			
		||||
                            child: ClipRRect(
 | 
			
		||||
                              borderRadius: BorderRadius.circular(8),
 | 
			
		||||
                              child: Container(
 | 
			
		||||
                                color: Theme.of(context)
 | 
			
		||||
                                    .colorScheme
 | 
			
		||||
                                    .surfaceVariant,
 | 
			
		||||
                                child: Image.network(
 | 
			
		||||
                                  ele.appearance['banner'],
 | 
			
		||||
                                  color: Theme.of(context)
 | 
			
		||||
                                      .colorScheme
 | 
			
		||||
                                      .onSurfaceVariant,
 | 
			
		||||
                                ),
 | 
			
		||||
                              ),
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                        Padding(
 | 
			
		||||
                          padding: const EdgeInsets.all(16),
 | 
			
		||||
                          child: Row(
 | 
			
		||||
                            crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                            children: [
 | 
			
		||||
                              Expanded(
 | 
			
		||||
                                child: Column(
 | 
			
		||||
                                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                                  children: [
 | 
			
		||||
                                    Text(
 | 
			
		||||
                                      ele.name,
 | 
			
		||||
                                      style: Theme.of(context)
 | 
			
		||||
                                          .textTheme
 | 
			
		||||
                                          .titleMedium,
 | 
			
		||||
                                    ).bold(),
 | 
			
		||||
                                    Text(
 | 
			
		||||
                                      ele.description,
 | 
			
		||||
                                      maxLines: 3,
 | 
			
		||||
                                      overflow: TextOverflow.ellipsis,
 | 
			
		||||
                                    ),
 | 
			
		||||
                                    if (_programMembers
 | 
			
		||||
                                        .any((e) => e.programId == ele.id))
 | 
			
		||||
                                      Text('accountProgramAlreadyJoined'.tr())
 | 
			
		||||
                                          .opacity(0.75),
 | 
			
		||||
                                  ],
 | 
			
		||||
                                ),
 | 
			
		||||
                              ),
 | 
			
		||||
                            ],
 | 
			
		||||
                          ),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ],
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                ).padding(horizontal: 8);
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ProgramJoinPopup extends StatefulWidget {
 | 
			
		||||
  final SnProgram program;
 | 
			
		||||
  final bool isJoined;
 | 
			
		||||
  const _ProgramJoinPopup({required this.program, required this.isJoined});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_ProgramJoinPopup> createState() => _ProgramJoinPopupState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ProgramJoinPopupState extends State<_ProgramJoinPopup> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
 | 
			
		||||
  Future<void> _joinProgram() async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.post('/cgi/id/programs/${widget.program.id}');
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      Navigator.pop(context, true);
 | 
			
		||||
      context.showSnackbar('accountProgramJoined'.tr());
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _leaveProgram() async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.delete('/cgi/id/programs/${widget.program.id}');
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      Navigator.pop(context, true);
 | 
			
		||||
      context.showSnackbar('accountProgramLeft'.tr());
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return SizedBox(
 | 
			
		||||
      height: MediaQuery.of(context).size.height * 0.75,
 | 
			
		||||
      child: SingleChildScrollView(
 | 
			
		||||
        child: Column(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
          children: [
 | 
			
		||||
            Row(
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
              children: [
 | 
			
		||||
                const Icon(Symbols.add, size: 24),
 | 
			
		||||
                const Gap(16),
 | 
			
		||||
                Text(
 | 
			
		||||
                  'accountProgramJoin',
 | 
			
		||||
                  style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
                ).tr(),
 | 
			
		||||
              ],
 | 
			
		||||
            ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
			
		||||
            Column(
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
              children: [
 | 
			
		||||
                if (widget.program.appearance['banner'] != null)
 | 
			
		||||
                  AspectRatio(
 | 
			
		||||
                    aspectRatio: 16 / 5,
 | 
			
		||||
                    child: ClipRRect(
 | 
			
		||||
                      borderRadius: BorderRadius.circular(8),
 | 
			
		||||
                      child: Container(
 | 
			
		||||
                        color: Theme.of(context).colorScheme.surfaceVariant,
 | 
			
		||||
                        child: Image.network(
 | 
			
		||||
                          widget.program.appearance['banner'],
 | 
			
		||||
                          color: Theme.of(context).colorScheme.onSurfaceVariant,
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ).padding(bottom: 12),
 | 
			
		||||
                Text(
 | 
			
		||||
                  widget.program.name,
 | 
			
		||||
                  style: Theme.of(context).textTheme.titleMedium,
 | 
			
		||||
                ).bold(),
 | 
			
		||||
                MarkdownTextContent(content: widget.program.description),
 | 
			
		||||
                const Gap(8),
 | 
			
		||||
                Text(
 | 
			
		||||
                  'accountProgramJoinRequirements',
 | 
			
		||||
                  style: Theme.of(context).textTheme.titleMedium,
 | 
			
		||||
                ).tr().bold(),
 | 
			
		||||
                Text('≥EXP ${widget.program.expRequirement}'),
 | 
			
		||||
                Text('≥Lv${getLevelFromExp(widget.program.expRequirement)}'),
 | 
			
		||||
                const Gap(8),
 | 
			
		||||
                Text(
 | 
			
		||||
                  'accountProgramJoinPricing',
 | 
			
		||||
                  style: Theme.of(context).textTheme.titleMedium,
 | 
			
		||||
                ).tr().bold(),
 | 
			
		||||
                Text('walletCurrency${widget.program.price['currency'].toString().capitalize().replaceFirst('Normal', '')}')
 | 
			
		||||
                    .plural(widget.program.price['amount'].toDouble()),
 | 
			
		||||
                Text('accountProgramJoinPricingHint').tr().opacity(0.75),
 | 
			
		||||
                const Gap(8),
 | 
			
		||||
                if (widget.isJoined)
 | 
			
		||||
                  Text('accountProgramLeaveHint')
 | 
			
		||||
                      .tr()
 | 
			
		||||
                      .opacity(0.75)
 | 
			
		||||
                      .padding(bottom: 8),
 | 
			
		||||
                if (!widget.isJoined)
 | 
			
		||||
                  ElevatedButton(
 | 
			
		||||
                    onPressed: _isBusy ? null : _joinProgram,
 | 
			
		||||
                    child: Text('join').tr(),
 | 
			
		||||
                  )
 | 
			
		||||
                else
 | 
			
		||||
                  ElevatedButton(
 | 
			
		||||
                    onPressed: _isBusy ? null : _leaveProgram,
 | 
			
		||||
                    child: Text('leave').tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
              ],
 | 
			
		||||
            ).padding(horizontal: 24),
 | 
			
		||||
            Gap(MediaQuery.of(context).padding.bottom),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -27,10 +27,12 @@ class AccountPublisherEditScreen extends StatefulWidget {
 | 
			
		||||
  const AccountPublisherEditScreen({super.key, required this.name});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<AccountPublisherEditScreen> createState() => _AccountPublisherEditScreenState();
 | 
			
		||||
  State<AccountPublisherEditScreen> createState() =>
 | 
			
		||||
      _AccountPublisherEditScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> {
 | 
			
		||||
class _AccountPublisherEditScreenState
 | 
			
		||||
    extends State<AccountPublisherEditScreen> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
 | 
			
		||||
  SnPublisher? _publisher;
 | 
			
		||||
@@ -68,16 +70,19 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      await sn.client.put('/cgi/co/publishers/${widget.name}', data: {
 | 
			
		||||
        'avatar': _avatar,
 | 
			
		||||
        'banner': _banner,
 | 
			
		||||
        'nick': _nickController.text,
 | 
			
		||||
        'name': _nameController.text,
 | 
			
		||||
        'description': _descriptionController.text,
 | 
			
		||||
      });
 | 
			
		||||
      await sn.client.put(
 | 
			
		||||
        '/cgi/co/publishers/${widget.name}',
 | 
			
		||||
        data: {
 | 
			
		||||
          'avatar': _avatar,
 | 
			
		||||
          'banner': _banner,
 | 
			
		||||
          'nick': _nickController.text,
 | 
			
		||||
          'name': _nameController.text,
 | 
			
		||||
          'description': _descriptionController.text,
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
      if (mounted) Navigator.pop(context, true);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if(mounted) context.showErrorDialog(err);
 | 
			
		||||
      if (mounted) context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
@@ -97,7 +102,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
 | 
			
		||||
    _banner = ua.user!.banner;
 | 
			
		||||
    _nickController.text = ua.user!.nick;
 | 
			
		||||
    _nameController.text = ua.user!.name;
 | 
			
		||||
    _descriptionController.text = ua.user!.description;
 | 
			
		||||
    _descriptionController.text = ua.user!.profile!.description;
 | 
			
		||||
    setState(() {});
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -108,32 +113,45 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
 | 
			
		||||
    if (image == null) return;
 | 
			
		||||
    if (!mounted) return;
 | 
			
		||||
 | 
			
		||||
    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,
 | 
			
		||||
          );
 | 
			
		||||
    final skipCrop = image.path.endsWith('.gif');
 | 
			
		||||
 | 
			
		||||
    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;
 | 
			
		||||
    final attach = context.read<SnAttachmentProvider>();
 | 
			
		||||
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final attachment = await attach.directUploadOne(
 | 
			
		||||
        rawBytes,
 | 
			
		||||
@@ -178,10 +196,10 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: PageBackButton(),
 | 
			
		||||
        title: Text('screenAccountPublisherEdit').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
          leading: PageBackButton(),
 | 
			
		||||
          title: Text('screenAccountPublisherEdit').tr()),
 | 
			
		||||
      body: SingleChildScrollView(
 | 
			
		||||
        child: Column(
 | 
			
		||||
          children: [
 | 
			
		||||
@@ -198,12 +216,13 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
 | 
			
		||||
                      child: AspectRatio(
 | 
			
		||||
                        aspectRatio: 16 / 9,
 | 
			
		||||
                        child: Container(
 | 
			
		||||
                          color: Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
			
		||||
                          color: Theme.of(context)
 | 
			
		||||
                              .colorScheme
 | 
			
		||||
                              .surfaceContainerHigh,
 | 
			
		||||
                          child: _banner != null
 | 
			
		||||
                              ? AutoResizeUniversalImage(
 | 
			
		||||
                                  sn.getAttachmentUrl(_banner!),
 | 
			
		||||
                                  fit: BoxFit.cover,
 | 
			
		||||
                                )
 | 
			
		||||
                                  fit: BoxFit.cover)
 | 
			
		||||
                              : const SizedBox.shrink(),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
@@ -237,25 +256,24 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
 | 
			
		||||
                labelText: 'fieldUsername'.tr(),
 | 
			
		||||
                helperText: 'fieldUsernameCannotEditHint'.tr(),
 | 
			
		||||
              ),
 | 
			
		||||
              onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
              onTapOutside: (_) =>
 | 
			
		||||
                  FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
            ),
 | 
			
		||||
            const Gap(4),
 | 
			
		||||
            TextField(
 | 
			
		||||
              controller: _nickController,
 | 
			
		||||
              decoration: InputDecoration(
 | 
			
		||||
                labelText: 'fieldNickname'.tr(),
 | 
			
		||||
              ),
 | 
			
		||||
              onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
              decoration: InputDecoration(labelText: 'fieldNickname'.tr()),
 | 
			
		||||
              onTapOutside: (_) =>
 | 
			
		||||
                  FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
            ),
 | 
			
		||||
            const Gap(4),
 | 
			
		||||
            TextField(
 | 
			
		||||
              controller: _descriptionController,
 | 
			
		||||
              maxLines: null,
 | 
			
		||||
              minLines: 3,
 | 
			
		||||
              decoration: InputDecoration(
 | 
			
		||||
                labelText: 'fieldDescription'.tr(),
 | 
			
		||||
              ),
 | 
			
		||||
              onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
              decoration: InputDecoration(labelText: 'fieldDescription'.tr()),
 | 
			
		||||
              onTapOutside: (_) =>
 | 
			
		||||
                  FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
            ),
 | 
			
		||||
            const Gap(12),
 | 
			
		||||
            Row(
 | 
			
		||||
@@ -275,7 +293,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
 | 
			
		||||
                  icon: const Icon(Symbols.save),
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            )
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ).padding(horizontal: 24, vertical: 12),
 | 
			
		||||
      ),
 | 
			
		||||
 
 | 
			
		||||
@@ -25,7 +25,8 @@ class _AccountPublisherNewScreenState extends State<AccountPublisherNewScreen> {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return  AppScaffold(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: const PageBackButton(),
 | 
			
		||||
        title: Text('screenAccountPublisherNew').tr(),
 | 
			
		||||
@@ -109,7 +110,7 @@ class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
 | 
			
		||||
 | 
			
		||||
    _nameController.text = ua.user!.name;
 | 
			
		||||
    _nickController.text = ua.user!.nick;
 | 
			
		||||
    _descriptionController.text = ua.user!.description;
 | 
			
		||||
    _descriptionController.text = ua.user!.profile!.description;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
 
 | 
			
		||||
@@ -33,7 +33,8 @@ class _PublisherScreenState extends State<PublisherScreen> {
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final resp = await sn.client.get('/cgi/co/publishers/me');
 | 
			
		||||
      final List<SnPublisher> out = List<SnPublisher>.from(resp.data?.map((e) => SnPublisher.fromJson(e)) ?? []);
 | 
			
		||||
      final List<SnPublisher> out = List<SnPublisher>.from(
 | 
			
		||||
          resp.data?.map((e) => SnPublisher.fromJson(e)) ?? []);
 | 
			
		||||
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
 | 
			
		||||
@@ -81,6 +82,7 @@ class _PublisherScreenState extends State<PublisherScreen> {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: const PageBackButton(),
 | 
			
		||||
        title: Text('screenAccountPublishers').tr(),
 | 
			
		||||
@@ -93,7 +95,9 @@ class _PublisherScreenState extends State<PublisherScreen> {
 | 
			
		||||
            contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
            leading: const Icon(Symbols.add_circle),
 | 
			
		||||
            onTap: () {
 | 
			
		||||
              GoRouter.of(context).pushNamed('accountPublisherNew').then((value) {
 | 
			
		||||
              GoRouter.of(context)
 | 
			
		||||
                  .pushNamed('accountPublisherNew')
 | 
			
		||||
                  .then((value) {
 | 
			
		||||
                if (value == true) {
 | 
			
		||||
                  _publishers.clear();
 | 
			
		||||
                  _fetchPublishers();
 | 
			
		||||
@@ -119,7 +123,8 @@ class _PublisherScreenState extends State<PublisherScreen> {
 | 
			
		||||
                    return ListTile(
 | 
			
		||||
                      title: Text(publisher.nick),
 | 
			
		||||
                      subtitle: Text('@${publisher.name}'),
 | 
			
		||||
                      contentPadding: const EdgeInsets.symmetric(horizontal: 16),
 | 
			
		||||
                      contentPadding:
 | 
			
		||||
                          const EdgeInsets.symmetric(horizontal: 16),
 | 
			
		||||
                      leading: AccountImage(content: publisher.avatar),
 | 
			
		||||
                      trailing: PopupMenuButton(
 | 
			
		||||
                        itemBuilder: (BuildContext context) => [
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										187
									
								
								lib/screens/account/punishments.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										187
									
								
								lib/screens/account/punishments.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,187 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/types/account.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
 | 
			
		||||
const kPunishmentIcons = [
 | 
			
		||||
  Symbols.warning,
 | 
			
		||||
  Symbols.emergency_home,
 | 
			
		||||
  Symbols.dangerous,
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
class PunishmentsScreen extends StatefulWidget {
 | 
			
		||||
  const PunishmentsScreen({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<PunishmentsScreen> createState() => _PunishmentsScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PunishmentsScreenState extends State<PunishmentsScreen> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
  List<SnPunishment>? _punishments;
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchPunishments() async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/id/punishments');
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      _punishments = List.from(
 | 
			
		||||
        resp.data.map((ele) => SnPunishment.fromJson(ele)),
 | 
			
		||||
      );
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _fetchPunishments();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        title: Text('accountPunishments').tr(),
 | 
			
		||||
        leading: PageBackButton(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: Column(
 | 
			
		||||
        children: [
 | 
			
		||||
          LoadingIndicator(isActive: _isBusy),
 | 
			
		||||
          Card(
 | 
			
		||||
            margin: EdgeInsets.only(bottom: 8, left: 8, right: 8),
 | 
			
		||||
            child: Column(
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
              children: [
 | 
			
		||||
                Row(
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Icon(Symbols.visibility, size: 20),
 | 
			
		||||
                    const Gap(6),
 | 
			
		||||
                    Expanded(
 | 
			
		||||
                      child: Text('punishmentOverall').tr().fontSize(16).bold(),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
                Builder(
 | 
			
		||||
                  builder: (context) {
 | 
			
		||||
                    if (_punishments == null) return Text('loading').tr();
 | 
			
		||||
                    if (_punishments!.any((ele) => ele.type == 2)) {
 | 
			
		||||
                      return Text('punishmentStatusBanned').tr();
 | 
			
		||||
                    }
 | 
			
		||||
                    if (_punishments!.any(
 | 
			
		||||
                      (ele) => ele.type == 1 && ele.permNodes.isEmpty,
 | 
			
		||||
                    )) {
 | 
			
		||||
                      return Text('punishmentStatusLimitedFully').tr();
 | 
			
		||||
                    } else if (_punishments!.any((ele) => ele.type == 1)) {
 | 
			
		||||
                      return Text('punishmentStatusLimited').tr();
 | 
			
		||||
                    }
 | 
			
		||||
                    if (_punishments!.any((ele) => ele.type == 0)) {
 | 
			
		||||
                      return Text('punishmentStatusWarned').tr();
 | 
			
		||||
                    }
 | 
			
		||||
                    return Text('punishmentStatusNormal').tr();
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ).padding(horizontal: 24, vertical: 16),
 | 
			
		||||
          ),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: RefreshIndicator(
 | 
			
		||||
              onRefresh: _fetchPunishments,
 | 
			
		||||
              child: ListView.separated(
 | 
			
		||||
                padding: EdgeInsets.zero,
 | 
			
		||||
                itemCount: _punishments?.length ?? 0,
 | 
			
		||||
                itemBuilder: (context, index) {
 | 
			
		||||
                  final ele = _punishments![index];
 | 
			
		||||
                  return Card(
 | 
			
		||||
                    margin: EdgeInsets.symmetric(horizontal: 8),
 | 
			
		||||
                    child: Column(
 | 
			
		||||
                      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                      children: [
 | 
			
		||||
                        Row(
 | 
			
		||||
                          children: [
 | 
			
		||||
                            Icon(kPunishmentIcons[ele.type], size: 20),
 | 
			
		||||
                            const Gap(6),
 | 
			
		||||
                            Expanded(
 | 
			
		||||
                              child: Text('punishmentType${ele.type}')
 | 
			
		||||
                                  .tr()
 | 
			
		||||
                                  .fontSize(16)
 | 
			
		||||
                                  .bold(),
 | 
			
		||||
                            ),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                        Text(ele.reason),
 | 
			
		||||
                        const Gap(4),
 | 
			
		||||
                        Text(
 | 
			
		||||
                          'punishmentCreatedAt'.tr(args: [
 | 
			
		||||
                            DateFormat().format(
 | 
			
		||||
                              ele.createdAt.toLocal(),
 | 
			
		||||
                            )
 | 
			
		||||
                          ]),
 | 
			
		||||
                        ).opacity(0.8),
 | 
			
		||||
                        Text(
 | 
			
		||||
                          ele.expiredAt == null
 | 
			
		||||
                              ? 'punishmentExpiredNever'.tr()
 | 
			
		||||
                              : 'punishmentExpiredAt'.tr(args: [
 | 
			
		||||
                                  DateFormat().format(
 | 
			
		||||
                                    ele.expiredAt!.toLocal(),
 | 
			
		||||
                                  )
 | 
			
		||||
                                ]),
 | 
			
		||||
                        ).opacity(0.8),
 | 
			
		||||
                        const Gap(8),
 | 
			
		||||
                        if (ele.moderator != null)
 | 
			
		||||
                          Column(
 | 
			
		||||
                            crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                            children: [
 | 
			
		||||
                              Text('punishmentModerator').tr().opacity(0.75),
 | 
			
		||||
                              InkWell(
 | 
			
		||||
                                child: Row(
 | 
			
		||||
                                  children: [
 | 
			
		||||
                                    AccountImage(
 | 
			
		||||
                                      content: ele.moderator!.avatar,
 | 
			
		||||
                                      radius: 8,
 | 
			
		||||
                                    ),
 | 
			
		||||
                                    const Gap(4),
 | 
			
		||||
                                    Text(ele.moderator?.nick ?? 'unknown'),
 | 
			
		||||
                                  ],
 | 
			
		||||
                                ),
 | 
			
		||||
                                onTap: () {
 | 
			
		||||
                                  GoRouter.of(context).pushNamed(
 | 
			
		||||
                                    'accountProfilePage',
 | 
			
		||||
                                    pathParameters: {
 | 
			
		||||
                                      'name': ele.moderator!.name,
 | 
			
		||||
                                    },
 | 
			
		||||
                                  );
 | 
			
		||||
                                },
 | 
			
		||||
                              ),
 | 
			
		||||
                            ],
 | 
			
		||||
                          )
 | 
			
		||||
                        else
 | 
			
		||||
                          Text('punishmentMadeBySystem').tr().opacity(0.75),
 | 
			
		||||
                      ],
 | 
			
		||||
                    ).padding(horizontal: 24, vertical: 16),
 | 
			
		||||
                  );
 | 
			
		||||
                },
 | 
			
		||||
                separatorBuilder: (_, __) => const Gap(8),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -37,6 +37,7 @@ class AccountSettingsScreen extends StatelessWidget {
 | 
			
		||||
    final ua = context.watch<UserProvider>();
 | 
			
		||||
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: PageBackButton(),
 | 
			
		||||
        title: Text('screenAccountSettings').tr(),
 | 
			
		||||
@@ -54,14 +55,20 @@ class AccountSettingsScreen extends StatelessWidget {
 | 
			
		||||
                child: DropdownButton2<Locale?>(
 | 
			
		||||
                  isExpanded: true,
 | 
			
		||||
                  items: [
 | 
			
		||||
                    ...EasyLocalization.of(context)!.supportedLocales.mapIndexed((idx, ele) {
 | 
			
		||||
                    ...EasyLocalization.of(context)!
 | 
			
		||||
                        .supportedLocales
 | 
			
		||||
                        .mapIndexed((idx, ele) {
 | 
			
		||||
                      return DropdownMenuItem<Locale?>(
 | 
			
		||||
                        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) {
 | 
			
		||||
                    if (value == null) return;
 | 
			
		||||
                    _setAccountLanguage(context, value);
 | 
			
		||||
@@ -81,6 +88,46 @@ class AccountSettingsScreen extends StatelessWidget {
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
            ListTile(
 | 
			
		||||
              title: Text('accountContactMethods').tr(),
 | 
			
		||||
              subtitle: Text('accountContactMethodsDescription').tr(),
 | 
			
		||||
              contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
              leading: const Icon(Symbols.contacts),
 | 
			
		||||
              trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
              onTap: () {
 | 
			
		||||
                GoRouter.of(context).pushNamed('accountContactMethods');
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
            ListTile(
 | 
			
		||||
              title: Text('accountSettingsNotify').tr(),
 | 
			
		||||
              subtitle: Text('accountSettingsNotifyDescription').tr(),
 | 
			
		||||
              contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
              leading: const Icon(Symbols.notifications),
 | 
			
		||||
              trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
              onTap: () {
 | 
			
		||||
                GoRouter.of(context).pushNamed('accountSettingsNotify');
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
            ListTile(
 | 
			
		||||
              title: Text('accountSettingsSecurity').tr(),
 | 
			
		||||
              subtitle: Text('accountSettingsSecurityDescription').tr(),
 | 
			
		||||
              contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
              leading: const Icon(Symbols.shield),
 | 
			
		||||
              trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
              onTap: () {
 | 
			
		||||
                GoRouter.of(context).pushNamed('accountSettingsSecurity');
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
            ListTile(
 | 
			
		||||
              title: Text('factorSettings').tr(),
 | 
			
		||||
              subtitle: Text('factorSettingsSubtitle').tr(),
 | 
			
		||||
              contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
              leading: const Icon(Symbols.lock),
 | 
			
		||||
              trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
              onTap: () {
 | 
			
		||||
                GoRouter.of(context).pushNamed('factorSettings');
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
            ListTile(
 | 
			
		||||
              title: Text('accountProfileEdit').tr(),
 | 
			
		||||
              subtitle: Text('accountProfileEditSubtitle').tr(),
 | 
			
		||||
@@ -2,12 +2,14 @@ import 'package:dismissible_page/dismissible_page.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.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:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/user_directory.dart';
 | 
			
		||||
import 'package:surface/types/attachment.dart';
 | 
			
		||||
import 'package:surface/widgets/app_bar_leading.dart';
 | 
			
		||||
import 'package:surface/widgets/attachment/attachment_zoom.dart';
 | 
			
		||||
import 'package:surface/widgets/attachment/attachment_item.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
@@ -27,9 +29,23 @@ class _AlbumScreenState extends State<AlbumScreen> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
  int? _totalCount;
 | 
			
		||||
 | 
			
		||||
  SnAttachmentBilling? _billing;
 | 
			
		||||
 | 
			
		||||
  final List<SnAttachment> _attachments = 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 {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
@@ -62,6 +78,7 @@ class _AlbumScreenState extends State<AlbumScreen> {
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _fetchBillingStatus();
 | 
			
		||||
    _fetchAttachments();
 | 
			
		||||
    _scrollController.addListener(() {
 | 
			
		||||
      if (_scrollController.position.atEdge) {
 | 
			
		||||
@@ -88,9 +105,53 @@ class _AlbumScreenState extends State<AlbumScreen> {
 | 
			
		||||
        controller: _scrollController,
 | 
			
		||||
        slivers: [
 | 
			
		||||
          SliverAppBar(
 | 
			
		||||
            leading: AutoAppBarLeading(),
 | 
			
		||||
            leading: PageBackButton(),
 | 
			
		||||
            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(
 | 
			
		||||
            childCount: _attachments.length,
 | 
			
		||||
            maxCrossAxisExtent: 320,
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/screens/captcha/captcha.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:url_launcher/url_launcher_string.dart';
 | 
			
		||||
@@ -33,10 +34,20 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
    final username = _usernameController.value.text;
 | 
			
		||||
    final nickname = _nicknameController.value.text;
 | 
			
		||||
    final password = _passwordController.value.text;
 | 
			
		||||
    if (email.isEmpty || username.isEmpty || nickname.isEmpty || password.isEmpty) {
 | 
			
		||||
    if (email.isEmpty ||
 | 
			
		||||
        username.isEmpty ||
 | 
			
		||||
        nickname.isEmpty ||
 | 
			
		||||
        password.isEmpty) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final captchaTk = await Navigator.of(context, rootNavigator: true).push(
 | 
			
		||||
      MaterialPageRoute(
 | 
			
		||||
        builder: (context) => CaptchaScreen(),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    if (captchaTk == null) return;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.post('/cgi/id/users', data: {
 | 
			
		||||
@@ -45,6 +56,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
        'email': email,
 | 
			
		||||
        'password': password,
 | 
			
		||||
        'language': EasyLocalization.of(context)!.currentLocale.toString(),
 | 
			
		||||
        'captcha_token': captchaTk,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (!context.mounted) return;
 | 
			
		||||
@@ -91,8 +103,11 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
                  children: [
 | 
			
		||||
                    TextFormField(
 | 
			
		||||
                      validator: (value) {
 | 
			
		||||
                        if (value == null || value.length < 4 || value.length > 32) {
 | 
			
		||||
                          return 'fieldUsernameLengthLimit'.tr(args: [4.toString(), 32.toString()]);
 | 
			
		||||
                        if (value == null ||
 | 
			
		||||
                            value.length < 4 ||
 | 
			
		||||
                            value.length > 32) {
 | 
			
		||||
                          return 'fieldUsernameLengthLimit'
 | 
			
		||||
                              .tr(args: [4.toString(), 32.toString()]);
 | 
			
		||||
                        }
 | 
			
		||||
                        if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value)) {
 | 
			
		||||
                          return 'fieldUsernameAlphanumOnly'.tr();
 | 
			
		||||
@@ -108,13 +123,17 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
                        border: const UnderlineInputBorder(),
 | 
			
		||||
                        labelText: 'fieldUsername'.tr(),
 | 
			
		||||
                      ),
 | 
			
		||||
                      onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                      onTapOutside: (_) =>
 | 
			
		||||
                          FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                    ),
 | 
			
		||||
                    const Gap(12),
 | 
			
		||||
                    TextFormField(
 | 
			
		||||
                      validator: (value) {
 | 
			
		||||
                        if (value == null || value.length < 4 || value.length > 32) {
 | 
			
		||||
                          return 'fieldNicknameLengthLimit'.tr(args: [4.toString(), 32.toString()]);
 | 
			
		||||
                        if (value == null ||
 | 
			
		||||
                            value.length < 4 ||
 | 
			
		||||
                            value.length > 32) {
 | 
			
		||||
                          return 'fieldNicknameLengthLimit'
 | 
			
		||||
                              .tr(args: [4.toString(), 32.toString()]);
 | 
			
		||||
                        }
 | 
			
		||||
                        return null;
 | 
			
		||||
                      },
 | 
			
		||||
@@ -127,7 +146,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
                        border: const UnderlineInputBorder(),
 | 
			
		||||
                        labelText: 'fieldNickname'.tr(),
 | 
			
		||||
                      ),
 | 
			
		||||
                      onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                      onTapOutside: (_) =>
 | 
			
		||||
                          FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                    ),
 | 
			
		||||
                    const Gap(12),
 | 
			
		||||
                    TextFormField(
 | 
			
		||||
@@ -149,7 +169,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
                        border: const UnderlineInputBorder(),
 | 
			
		||||
                        labelText: 'fieldEmail'.tr(),
 | 
			
		||||
                      ),
 | 
			
		||||
                      onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                      onTapOutside: (_) =>
 | 
			
		||||
                          FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                    ),
 | 
			
		||||
                    const Gap(12),
 | 
			
		||||
                    TextFormField(
 | 
			
		||||
@@ -169,7 +190,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
                        border: const UnderlineInputBorder(),
 | 
			
		||||
                        labelText: 'fieldPassword'.tr(),
 | 
			
		||||
                      ),
 | 
			
		||||
                      onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                      onTapOutside: (_) =>
 | 
			
		||||
                          FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ],
 | 
			
		||||
                ).padding(horizontal: 7),
 | 
			
		||||
@@ -186,9 +208,13 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
                        Text(
 | 
			
		||||
                          'termAcceptNextWithAgree'.tr(),
 | 
			
		||||
                          textAlign: TextAlign.end,
 | 
			
		||||
                          style: Theme.of(context).textTheme.bodySmall!.copyWith(
 | 
			
		||||
                                color: Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round()),
 | 
			
		||||
                              ),
 | 
			
		||||
                          style:
 | 
			
		||||
                              Theme.of(context).textTheme.bodySmall!.copyWith(
 | 
			
		||||
                                    color: Theme.of(context)
 | 
			
		||||
                                        .colorScheme
 | 
			
		||||
                                        .onSurface
 | 
			
		||||
                                        .withAlpha((255 * 0.75).round()),
 | 
			
		||||
                                  ),
 | 
			
		||||
                        ),
 | 
			
		||||
                        Material(
 | 
			
		||||
                          color: Colors.transparent,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										3
									
								
								lib/screens/captcha/captcha.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								lib/screens/captcha/captcha.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
import 'package:flutter/foundation.dart' show kIsWeb;
 | 
			
		||||
 | 
			
		||||
export 'captcha_native.dart' if (kIsWeb) 'captcha_web.dart';
 | 
			
		||||
							
								
								
									
										37
									
								
								lib/screens/captcha/captcha_native.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								lib/screens/captcha/captcha_native.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:surface/providers/config.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
 | 
			
		||||
class CaptchaScreen extends StatefulWidget {
 | 
			
		||||
  const CaptchaScreen({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<CaptchaScreen> createState() => _CaptchaScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _CaptchaScreenState extends State<CaptchaScreen> {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final cfg = context.read<ConfigProvider>();
 | 
			
		||||
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(title: Text("reCaptcha").tr()),
 | 
			
		||||
      body: InAppWebView(
 | 
			
		||||
        initialUrlRequest: URLRequest(
 | 
			
		||||
          url: WebUri('${cfg.serverUrl}/captcha?redirect_uri=solink://captcha'),
 | 
			
		||||
        ),
 | 
			
		||||
        shouldOverrideUrlLoading: (controller, navigationAction) async {
 | 
			
		||||
          Uri? url = navigationAction.request.url;
 | 
			
		||||
          if (url != null && url.queryParameters.containsKey('captcha_tk')) {
 | 
			
		||||
            Navigator.pop(context, url.queryParameters['captcha_tk']!);
 | 
			
		||||
            return NavigationActionPolicy.CANCEL;
 | 
			
		||||
          }
 | 
			
		||||
          return NavigationActionPolicy.ALLOW;
 | 
			
		||||
        },
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										54
									
								
								lib/screens/captcha/captcha_web.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								lib/screens/captcha/captcha_web.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,54 @@
 | 
			
		||||
import 'dart:html' as html;
 | 
			
		||||
import 'dart:ui_web' as ui;
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:surface/providers/config.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
 | 
			
		||||
class CaptchaScreen extends StatefulWidget {
 | 
			
		||||
  const CaptchaScreen({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<CaptchaScreen> createState() => _CaptchaScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _CaptchaScreenState extends State<CaptchaScreen> {
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _setupWebListener();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _setupWebListener() {
 | 
			
		||||
    html.window.onMessage.listen((event) {
 | 
			
		||||
      if (event.data != null && event.data is String) {
 | 
			
		||||
        final message = event.data as String;
 | 
			
		||||
        if (message.startsWith("captcha_tk=")) {
 | 
			
		||||
          String token = message.replaceFirst("captcha_tk=", "");
 | 
			
		||||
          Navigator.pop(context, token);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    final iframe = html.IFrameElement()
 | 
			
		||||
      ..src = '${context.read<ConfigProvider>().serverUrl}/captcha?redirect_uri=web'
 | 
			
		||||
      ..style.border = 'none'
 | 
			
		||||
      ..width = '100%'
 | 
			
		||||
      ..height = '100%';
 | 
			
		||||
 | 
			
		||||
    html.document.body!.append(iframe);
 | 
			
		||||
    ui.platformViewRegistry.registerViewFactory(
 | 
			
		||||
      'captcha-iframe',
 | 
			
		||||
          (int viewId) => iframe,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(title: Text("reCaptcha").tr()),
 | 
			
		||||
      body: HtmlElementView(viewType: 'captcha-iframe'),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,13 +1,21 @@
 | 
			
		||||
import 'package:animations/animations.dart';
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:google_fonts/google_fonts.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/channel.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/sn_realm.dart';
 | 
			
		||||
import 'package:surface/providers/user_directory.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/types/chat.dart';
 | 
			
		||||
import 'package:surface/types/realm.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_select.dart';
 | 
			
		||||
import 'package:surface/widgets/app_bar_leading.dart';
 | 
			
		||||
@@ -15,11 +23,9 @@ import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:surface/widgets/unauthorized_hint.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
import 'package:uuid/uuid.dart';
 | 
			
		||||
 | 
			
		||||
import '../providers/sn_network.dart';
 | 
			
		||||
import '../providers/userinfo.dart';
 | 
			
		||||
 | 
			
		||||
class ChatScreen extends StatefulWidget {
 | 
			
		||||
  const ChatScreen({super.key});
 | 
			
		||||
 | 
			
		||||
@@ -34,21 +40,65 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
			
		||||
 | 
			
		||||
  List<SnChannel>? _channels;
 | 
			
		||||
  Map<int, SnChatMessage>? _lastMessages;
 | 
			
		||||
  Map<int, int>? _unreadCounts;
 | 
			
		||||
  Map<int, int>? _unreadCountsGrouped;
 | 
			
		||||
 | 
			
		||||
  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 ??= {};
 | 
			
		||||
      _unreadCountsGrouped ??= {};
 | 
			
		||||
      for (var v in out) {
 | 
			
		||||
        _unreadCounts![v['channel_id']] = v['count'];
 | 
			
		||||
        final channel =
 | 
			
		||||
            _channels?.firstWhereOrNull((ele) => ele.id == v['channel_id']);
 | 
			
		||||
        if (channel != null) {
 | 
			
		||||
          if (channel.realmId != null) {
 | 
			
		||||
            _unreadCountsGrouped![channel.realmId!] ??= 0;
 | 
			
		||||
            _unreadCountsGrouped![channel.realmId!] =
 | 
			
		||||
                (_unreadCountsGrouped![channel.realmId!]! + v['count']).toInt();
 | 
			
		||||
          }
 | 
			
		||||
          if (channel.type == 1) {
 | 
			
		||||
            _unreadCountsGrouped![0] ??= 0;
 | 
			
		||||
            _unreadCountsGrouped![0] =
 | 
			
		||||
                (_unreadCountsGrouped![0]! + v['count']).toInt();
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _refreshChannels({bool withBoost = false, bool noRemote = false}) {
 | 
			
		||||
    final ct = context.read<ChatChannelProvider>();
 | 
			
		||||
    final ua = context.read<UserProvider>();
 | 
			
		||||
    if (!ua.isAuthorized) {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!withBoost) {
 | 
			
		||||
      if (!noRemote) {
 | 
			
		||||
        ct.refreshAvailableChannels();
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      setState(() {
 | 
			
		||||
        _channels = ct.availableChannels;
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final chan = context.read<ChatChannelProvider>();
 | 
			
		||||
    chan.fetchChannels().listen((channels) async {
 | 
			
		||||
    chan.fetchChannels(noRemote: true).listen((channels) async {
 | 
			
		||||
      final lastMessages = await chan.getLastMessages(channels);
 | 
			
		||||
      _lastMessages = {for (final val in lastMessages) val.channelId: val};
 | 
			
		||||
      channels.sort((a, b) {
 | 
			
		||||
        if (_lastMessages!.containsKey(a.id) && _lastMessages!.containsKey(b.id)) {
 | 
			
		||||
          return _lastMessages![b.id]!.createdAt.compareTo(_lastMessages![a.id]!.createdAt);
 | 
			
		||||
        if (_lastMessages!.containsKey(a.id) &&
 | 
			
		||||
            _lastMessages!.containsKey(b.id)) {
 | 
			
		||||
          return _lastMessages![b.id]!
 | 
			
		||||
              .createdAt
 | 
			
		||||
              .compareTo(_lastMessages![a.id]!.createdAt);
 | 
			
		||||
        }
 | 
			
		||||
        if (_lastMessages!.containsKey(a.id)) return -1;
 | 
			
		||||
        if (_lastMessages!.containsKey(b.id)) return 1;
 | 
			
		||||
@@ -57,18 +107,20 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
			
		||||
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      final ud = context.read<UserDirectoryProvider>();
 | 
			
		||||
      final idSet = <int>{};
 | 
			
		||||
      for (final channel in channels) {
 | 
			
		||||
        if (channel.type == 1) {
 | 
			
		||||
          await ud.listAccount(
 | 
			
		||||
          idSet.addAll(
 | 
			
		||||
            channel.members
 | 
			
		||||
                    ?.cast<SnChannelMember?>()
 | 
			
		||||
                    .map((ele) => ele?.accountId)
 | 
			
		||||
                    .where((ele) => ele != null)
 | 
			
		||||
                    .toSet() ??
 | 
			
		||||
                {},
 | 
			
		||||
                    .cast<int>() ??
 | 
			
		||||
                [],
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      if (idSet.isNotEmpty) await ud.listAccount(idSet);
 | 
			
		||||
 | 
			
		||||
      if (mounted) setState(() => _channels = channels);
 | 
			
		||||
    })
 | 
			
		||||
@@ -80,13 +132,15 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
			
		||||
      ..onDone(() {
 | 
			
		||||
        if (!mounted) return;
 | 
			
		||||
        setState(() => _isBusy = false);
 | 
			
		||||
        _fetchWhatsNew();
 | 
			
		||||
      });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _newDirectMessage() async {
 | 
			
		||||
    final user = await showModalBottomSheet(
 | 
			
		||||
      context: context,
 | 
			
		||||
      builder: (context) => AccountSelect(title: 'channelNewDirectMessage'.tr()),
 | 
			
		||||
      builder: (context) =>
 | 
			
		||||
          AccountSelect(title: 'channelNewDirectMessage'.tr()),
 | 
			
		||||
    );
 | 
			
		||||
    if (user == null) return;
 | 
			
		||||
    if (!mounted) return;
 | 
			
		||||
@@ -98,7 +152,8 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
			
		||||
      await sn.client.post('/cgi/im/channels/global/dm', data: {
 | 
			
		||||
        'alias': uuid.v4().replaceAll('-', '').substring(0, 12),
 | 
			
		||||
        '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,
 | 
			
		||||
      });
 | 
			
		||||
      _fabKey.currentState!.toggle();
 | 
			
		||||
@@ -112,13 +167,48 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _refreshChannels();
 | 
			
		||||
    _refreshChannels(withBoost: true);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _onTapChannel(SnChannel channel) {
 | 
			
		||||
    setState(() => _unreadCounts?[channel.id] = 0);
 | 
			
		||||
    if (ResponsiveScaffold.getIsExpand(context)) {
 | 
			
		||||
      GoRouter.of(context).pushReplacementNamed(
 | 
			
		||||
        'chatRoom',
 | 
			
		||||
        pathParameters: {
 | 
			
		||||
          'scope': channel.realm?.alias ?? 'global',
 | 
			
		||||
          'alias': channel.alias,
 | 
			
		||||
        },
 | 
			
		||||
      ).then((value) {
 | 
			
		||||
        if (mounted) {
 | 
			
		||||
          setState(() => _unreadCounts?[channel.id] = 0);
 | 
			
		||||
          _refreshChannels(noRemote: true);
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    } else {
 | 
			
		||||
      GoRouter.of(context).pushNamed(
 | 
			
		||||
        'chatRoom',
 | 
			
		||||
        pathParameters: {
 | 
			
		||||
          'scope': channel.realm?.alias ?? 'global',
 | 
			
		||||
          'alias': channel.alias,
 | 
			
		||||
        },
 | 
			
		||||
      ).then((value) {
 | 
			
		||||
        if (mounted) {
 | 
			
		||||
          setState(() => _unreadCounts?[channel.id] = 0);
 | 
			
		||||
          _refreshChannels(noRemote: true);
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  SnRealm? _focusedRealm;
 | 
			
		||||
  bool _isDirect = false;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final ud = context.read<UserDirectoryProvider>();
 | 
			
		||||
    final ua = context.read<UserProvider>();
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
    final rel = context.read<SnRealmProvider>();
 | 
			
		||||
 | 
			
		||||
    if (!ua.isAuthorized) {
 | 
			
		||||
      return AppScaffold(
 | 
			
		||||
@@ -133,6 +223,7 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: AutoAppBarLeading(),
 | 
			
		||||
        title: Text('screenChat').tr(),
 | 
			
		||||
@@ -144,21 +235,26 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
			
		||||
        type: ExpandableFabType.up,
 | 
			
		||||
        childrenAnimation: ExpandableFabAnimation.none,
 | 
			
		||||
        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(
 | 
			
		||||
          child: const Icon(Symbols.add, size: 28),
 | 
			
		||||
          fabSize: ExpandableFabSize.regular,
 | 
			
		||||
          foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
 | 
			
		||||
          backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
 | 
			
		||||
          shape: const CircleBorder(),
 | 
			
		||||
          foregroundColor:
 | 
			
		||||
              Theme.of(context).floatingActionButtonTheme.foregroundColor,
 | 
			
		||||
          backgroundColor:
 | 
			
		||||
              Theme.of(context).floatingActionButtonTheme.backgroundColor,
 | 
			
		||||
        ),
 | 
			
		||||
        closeButtonBuilder: DefaultFloatingActionButtonBuilder(
 | 
			
		||||
          child: const Icon(Symbols.close, size: 28),
 | 
			
		||||
          fabSize: ExpandableFabSize.regular,
 | 
			
		||||
          foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
 | 
			
		||||
          backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
 | 
			
		||||
          shape: const CircleBorder(),
 | 
			
		||||
          foregroundColor:
 | 
			
		||||
              Theme.of(context).floatingActionButtonTheme.foregroundColor,
 | 
			
		||||
          backgroundColor:
 | 
			
		||||
              Theme.of(context).floatingActionButtonTheme.backgroundColor,
 | 
			
		||||
        ),
 | 
			
		||||
        children: [
 | 
			
		||||
          Row(
 | 
			
		||||
@@ -195,94 +291,324 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
			
		||||
      body: Column(
 | 
			
		||||
        children: [
 | 
			
		||||
          LoadingIndicator(isActive: _isBusy),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: MediaQuery.removePadding(
 | 
			
		||||
              context: context,
 | 
			
		||||
              removeTop: true,
 | 
			
		||||
          if (_channels != null && ResponsiveScaffold.getIsExpand(context))
 | 
			
		||||
            Expanded(
 | 
			
		||||
              child: RefreshIndicator(
 | 
			
		||||
                onRefresh: () => Future.sync(() => _refreshChannels()),
 | 
			
		||||
                child: ListView.builder(
 | 
			
		||||
                  itemCount: _channels?.length ?? 0,
 | 
			
		||||
                  itemBuilder: (context, idx) {
 | 
			
		||||
                    final channel = _channels![idx];
 | 
			
		||||
                    final lastMessage = _lastMessages?[channel.id];
 | 
			
		||||
 | 
			
		||||
                    if (channel.type == 1) {
 | 
			
		||||
                      final otherMember = channel.members?.cast<SnChannelMember?>().firstWhere(
 | 
			
		||||
                            (ele) => ele?.accountId != ua.user?.id,
 | 
			
		||||
                            orElse: () => null,
 | 
			
		||||
                          );
 | 
			
		||||
 | 
			
		||||
                      return ListTile(
 | 
			
		||||
                        title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name),
 | 
			
		||||
                        subtitle: lastMessage != null
 | 
			
		||||
                            ? Text(
 | 
			
		||||
                                '${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
 | 
			
		||||
                                maxLines: 1,
 | 
			
		||||
                                overflow: TextOverflow.ellipsis,
 | 
			
		||||
                              )
 | 
			
		||||
                            : Text(
 | 
			
		||||
                                'channelDirectMessageDescription'.tr(args: [
 | 
			
		||||
                                  '@${ud.getAccountFromCache(otherMember?.accountId)?.name}',
 | 
			
		||||
                                ]),
 | 
			
		||||
                                maxLines: 1,
 | 
			
		||||
                                overflow: TextOverflow.ellipsis,
 | 
			
		||||
                child: Builder(builder: (context) {
 | 
			
		||||
                  final scopeList = ListView(
 | 
			
		||||
                    key: const Key('realm-list-view'),
 | 
			
		||||
                    padding: EdgeInsets.zero,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      ListTile(
 | 
			
		||||
                        minTileHeight: 48,
 | 
			
		||||
                        leading:
 | 
			
		||||
                            const Icon(Symbols.inbox_text).padding(right: 4),
 | 
			
		||||
                        contentPadding: EdgeInsets.only(left: 24, right: 24),
 | 
			
		||||
                        title: Text('chatDirect').tr(),
 | 
			
		||||
                        trailing: Row(
 | 
			
		||||
                          mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                          crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            if (_unreadCountsGrouped?[0] != null &&
 | 
			
		||||
                                (_unreadCountsGrouped?[0] ?? 0) > 0)
 | 
			
		||||
                              Badge(
 | 
			
		||||
                                label: Text(
 | 
			
		||||
                                  _unreadCountsGrouped![0].toString(),
 | 
			
		||||
                                ),
 | 
			
		||||
                              ),
 | 
			
		||||
                        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();
 | 
			
		||||
                          });
 | 
			
		||||
                          setState(() => _isDirect = true);
 | 
			
		||||
                        },
 | 
			
		||||
                      );
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    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: () {
 | 
			
		||||
                        GoRouter.of(context).pushNamed(
 | 
			
		||||
                          'chatRoom',
 | 
			
		||||
                          pathParameters: {
 | 
			
		||||
                            'scope': channel.realm?.alias ?? 'global',
 | 
			
		||||
                            'alias': channel.alias,
 | 
			
		||||
                      ...rel.availableRealms.map((ele) {
 | 
			
		||||
                        return ListTile(
 | 
			
		||||
                          minTileHeight: 48,
 | 
			
		||||
                          contentPadding: EdgeInsets.only(left: 20, right: 24),
 | 
			
		||||
                          leading: AccountImage(
 | 
			
		||||
                            content: ele.avatar,
 | 
			
		||||
                            radius: 16,
 | 
			
		||||
                          ),
 | 
			
		||||
                          trailing: Row(
 | 
			
		||||
                            mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                            crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                            children: [
 | 
			
		||||
                              if (_unreadCountsGrouped?[ele.id] != null &&
 | 
			
		||||
                                  (_unreadCountsGrouped?[ele.id] ?? 0) > 0)
 | 
			
		||||
                                Badge(
 | 
			
		||||
                                  label: Text(
 | 
			
		||||
                                    _unreadCountsGrouped![ele.id].toString(),
 | 
			
		||||
                                  ),
 | 
			
		||||
                                ),
 | 
			
		||||
                            ],
 | 
			
		||||
                          ),
 | 
			
		||||
                          title: Text(ele.name),
 | 
			
		||||
                          onTap: () {
 | 
			
		||||
                            setState(() => _focusedRealm = ele);
 | 
			
		||||
                          },
 | 
			
		||||
                        ).then((value) {
 | 
			
		||||
                          if (value == true) _refreshChannels();
 | 
			
		||||
                        });
 | 
			
		||||
                      },
 | 
			
		||||
                    );
 | 
			
		||||
                  },
 | 
			
		||||
                        );
 | 
			
		||||
                      }),
 | 
			
		||||
                    ],
 | 
			
		||||
                  );
 | 
			
		||||
 | 
			
		||||
                  final directChatList = ListView(
 | 
			
		||||
                    key: Key('direct-chat-list-view'),
 | 
			
		||||
                    padding: EdgeInsets.zero,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      ListTile(
 | 
			
		||||
                        minTileHeight: 48,
 | 
			
		||||
                        leading: const Icon(Symbols.arrow_left_alt),
 | 
			
		||||
                        contentPadding: EdgeInsets.only(left: 24),
 | 
			
		||||
                        title: Text('back').tr(),
 | 
			
		||||
                        onTap: () {
 | 
			
		||||
                          setState(() => _isDirect = false);
 | 
			
		||||
                        },
 | 
			
		||||
                      ),
 | 
			
		||||
                      const Divider(height: 1),
 | 
			
		||||
                      ..._channels!.where((ele) => ele.type == 1).map(
 | 
			
		||||
                        (ele) {
 | 
			
		||||
                          return _ChatChannelEntry(
 | 
			
		||||
                            channel: ele,
 | 
			
		||||
                            unreadCount: _unreadCounts?[ele.id],
 | 
			
		||||
                            lastMessage: _lastMessages?[ele.id],
 | 
			
		||||
                            isCompact: true,
 | 
			
		||||
                            onTap: () => _onTapChannel(ele),
 | 
			
		||||
                          );
 | 
			
		||||
                        },
 | 
			
		||||
                      )
 | 
			
		||||
                    ],
 | 
			
		||||
                  );
 | 
			
		||||
 | 
			
		||||
                  final realmScopedChatList = _focusedRealm == null
 | 
			
		||||
                      ? const SizedBox.shrink()
 | 
			
		||||
                      : ListView(
 | 
			
		||||
                          key: ValueKey(_focusedRealm),
 | 
			
		||||
                          padding: EdgeInsets.zero,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            if (_focusedRealm!.banner != null)
 | 
			
		||||
                              AspectRatio(
 | 
			
		||||
                                aspectRatio: 16 / 9,
 | 
			
		||||
                                child: AutoResizeUniversalImage(
 | 
			
		||||
                                  sn.getAttachmentUrl(
 | 
			
		||||
                                    _focusedRealm!.banner!,
 | 
			
		||||
                                  ),
 | 
			
		||||
                                  fit: BoxFit.cover,
 | 
			
		||||
                                ),
 | 
			
		||||
                              ),
 | 
			
		||||
                            ListTile(
 | 
			
		||||
                              minTileHeight: 48,
 | 
			
		||||
                              tileColor: Theme.of(context)
 | 
			
		||||
                                  .colorScheme
 | 
			
		||||
                                  .surfaceContainer,
 | 
			
		||||
                              leading: AccountImage(
 | 
			
		||||
                                content: _focusedRealm!.avatar,
 | 
			
		||||
                                radius: 16,
 | 
			
		||||
                              ),
 | 
			
		||||
                              contentPadding: EdgeInsets.only(
 | 
			
		||||
                                left: 20,
 | 
			
		||||
                                right: 16,
 | 
			
		||||
                              ),
 | 
			
		||||
                              trailing: IconButton(
 | 
			
		||||
                                icon: const Icon(Symbols.close),
 | 
			
		||||
                                padding: EdgeInsets.zero,
 | 
			
		||||
                                constraints: const BoxConstraints(),
 | 
			
		||||
                                visualDensity: VisualDensity.compact,
 | 
			
		||||
                                onPressed: () {
 | 
			
		||||
                                  setState(() => _focusedRealm = null);
 | 
			
		||||
                                },
 | 
			
		||||
                              ),
 | 
			
		||||
                              title: Text(_focusedRealm!.name),
 | 
			
		||||
                            ),
 | 
			
		||||
                            ...(_channels!
 | 
			
		||||
                                .where(
 | 
			
		||||
                                    (ele) => ele.realmId == _focusedRealm?.id)
 | 
			
		||||
                                .map(
 | 
			
		||||
                              (ele) {
 | 
			
		||||
                                return _ChatChannelEntry(
 | 
			
		||||
                                  channel: ele,
 | 
			
		||||
                                  unreadCount: _unreadCounts?[ele.id],
 | 
			
		||||
                                  lastMessage: _lastMessages?[ele.id],
 | 
			
		||||
                                  onTap: () => _onTapChannel(ele),
 | 
			
		||||
                                  isCompact: true,
 | 
			
		||||
                                );
 | 
			
		||||
                              },
 | 
			
		||||
                            ))
 | 
			
		||||
                          ],
 | 
			
		||||
                        );
 | 
			
		||||
 | 
			
		||||
                  return PageTransitionSwitcher(
 | 
			
		||||
                    duration: const Duration(milliseconds: 300),
 | 
			
		||||
                    transitionBuilder: (Widget child,
 | 
			
		||||
                        Animation<double> primaryAnimation,
 | 
			
		||||
                        Animation<double> secondaryAnimation) {
 | 
			
		||||
                      return SharedAxisTransition(
 | 
			
		||||
                        animation: primaryAnimation,
 | 
			
		||||
                        secondaryAnimation: secondaryAnimation,
 | 
			
		||||
                        fillColor: Colors.transparent,
 | 
			
		||||
                        transitionType: SharedAxisTransitionType.horizontal,
 | 
			
		||||
                        child: child,
 | 
			
		||||
                      );
 | 
			
		||||
                    },
 | 
			
		||||
                    child: (_focusedRealm == null && !_isDirect)
 | 
			
		||||
                        ? scopeList
 | 
			
		||||
                        : _isDirect
 | 
			
		||||
                            ? directChatList
 | 
			
		||||
                            : realmScopedChatList,
 | 
			
		||||
                  );
 | 
			
		||||
                }),
 | 
			
		||||
              ),
 | 
			
		||||
            )
 | 
			
		||||
          else if (_channels != null)
 | 
			
		||||
            Expanded(
 | 
			
		||||
              child: RefreshIndicator(
 | 
			
		||||
                onRefresh: () => Future.sync(() => _refreshChannels()),
 | 
			
		||||
                child: ListView(
 | 
			
		||||
                  key: const Key('chat-list-view'),
 | 
			
		||||
                  padding: EdgeInsets.zero,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    ...(_channels!.map((ele) {
 | 
			
		||||
                      return _ChatChannelEntry(
 | 
			
		||||
                        channel: ele,
 | 
			
		||||
                        unreadCount: _unreadCounts?[ele.id],
 | 
			
		||||
                        lastMessage: _lastMessages?[ele.id],
 | 
			
		||||
                        onTap: () => _onTapChannel(ele),
 | 
			
		||||
                      );
 | 
			
		||||
                    }))
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ChatChannelEntry extends StatelessWidget {
 | 
			
		||||
  final SnChannel channel;
 | 
			
		||||
  final int? unreadCount;
 | 
			
		||||
  final SnChatMessage? lastMessage;
 | 
			
		||||
  final Function? onTap;
 | 
			
		||||
  final bool isCompact;
 | 
			
		||||
  const _ChatChannelEntry({
 | 
			
		||||
    required this.channel,
 | 
			
		||||
    this.unreadCount,
 | 
			
		||||
    this.lastMessage,
 | 
			
		||||
    this.onTap,
 | 
			
		||||
    this.isCompact = false,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @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;
 | 
			
		||||
 | 
			
		||||
    if (isCompact) {
 | 
			
		||||
      return ListTile(
 | 
			
		||||
        minTileHeight: 48,
 | 
			
		||||
        contentPadding:
 | 
			
		||||
            EdgeInsets.only(left: otherMember != null ? 20 : 24, right: 24),
 | 
			
		||||
        leading: otherMember != null
 | 
			
		||||
            ? AccountImage(
 | 
			
		||||
                content: ud.getFromCache(otherMember.accountId)?.avatar,
 | 
			
		||||
                radius: 16,
 | 
			
		||||
              )
 | 
			
		||||
            : const Icon(Symbols.tag),
 | 
			
		||||
        trailing: Row(
 | 
			
		||||
          mainAxisSize: MainAxisSize.min,
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
          children: [
 | 
			
		||||
            if (unreadCount != null && (unreadCount ?? 0) > 0)
 | 
			
		||||
              Badge(
 | 
			
		||||
                label: Text(unreadCount.toString()),
 | 
			
		||||
              ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
        title: Text(title),
 | 
			
		||||
        onTap: () {
 | 
			
		||||
          onTap?.call();
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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.tag, size: 20),
 | 
			
		||||
      ),
 | 
			
		||||
      onTap: () => onTap?.call(),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -37,7 +37,8 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
 | 
			
		||||
    return Stack(
 | 
			
		||||
      children: [
 | 
			
		||||
        Container(
 | 
			
		||||
          color: Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.75),
 | 
			
		||||
          color:
 | 
			
		||||
              Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.75),
 | 
			
		||||
          child: call.focusTrack != null
 | 
			
		||||
              ? InteractiveParticipantWidget(
 | 
			
		||||
                  isFixedAvatar: false,
 | 
			
		||||
@@ -72,7 +73,8 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
 | 
			
		||||
                      color: Theme.of(context).cardColor,
 | 
			
		||||
                      participant: track,
 | 
			
		||||
                      onTap: () {
 | 
			
		||||
                        if (track.participant.sid != call.focusTrack?.participant.sid) {
 | 
			
		||||
                        if (track.participant.sid !=
 | 
			
		||||
                            call.focusTrack?.participant.sid) {
 | 
			
		||||
                          call.setFocusTrack(track);
 | 
			
		||||
                        }
 | 
			
		||||
                      },
 | 
			
		||||
@@ -114,10 +116,14 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
 | 
			
		||||
            child: ClipRRect(
 | 
			
		||||
              borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
              child: InteractiveParticipantWidget(
 | 
			
		||||
                color: Theme.of(context).colorScheme.surfaceContainerHigh.withOpacity(0.75),
 | 
			
		||||
                color: Theme.of(context)
 | 
			
		||||
                    .colorScheme
 | 
			
		||||
                    .surfaceContainerHigh
 | 
			
		||||
                    .withOpacity(0.75),
 | 
			
		||||
                participant: track,
 | 
			
		||||
                onTap: () {
 | 
			
		||||
                  if (track.participant.sid != call.focusTrack?.participant.sid) {
 | 
			
		||||
                  if (track.participant.sid !=
 | 
			
		||||
                      call.focusTrack?.participant.sid) {
 | 
			
		||||
                    call.setFocusTrack(track);
 | 
			
		||||
                  }
 | 
			
		||||
                },
 | 
			
		||||
@@ -149,6 +155,7 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
 | 
			
		||||
        listenable: call,
 | 
			
		||||
        builder: (context, _) {
 | 
			
		||||
          return AppScaffold(
 | 
			
		||||
            noBackground: true,
 | 
			
		||||
            appBar: AppBar(
 | 
			
		||||
              title: RichText(
 | 
			
		||||
                textAlign: TextAlign.center,
 | 
			
		||||
@@ -183,7 +190,8 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
 | 
			
		||||
                        Builder(builder: (context) {
 | 
			
		||||
                          final call = context.read<ChatCallProvider>();
 | 
			
		||||
                          final connectionQuality =
 | 
			
		||||
                              call.room.localParticipant?.connectionQuality ?? livekit.ConnectionQuality.unknown;
 | 
			
		||||
                              call.room.localParticipant?.connectionQuality ??
 | 
			
		||||
                                  livekit.ConnectionQuality.unknown;
 | 
			
		||||
                          return Expanded(
 | 
			
		||||
                            child: Column(
 | 
			
		||||
                              mainAxisSize: MainAxisSize.min,
 | 
			
		||||
@@ -205,24 +213,35 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
 | 
			
		||||
                                  children: [
 | 
			
		||||
                                    Text(
 | 
			
		||||
                                      {
 | 
			
		||||
                                        livekit.ConnectionState.disconnected: 'callStatusDisconnected'.tr(),
 | 
			
		||||
                                        livekit.ConnectionState.connected: 'callStatusConnected'.tr(),
 | 
			
		||||
                                        livekit.ConnectionState.connecting: 'callStatusConnecting'.tr(),
 | 
			
		||||
                                        livekit.ConnectionState.reconnecting: 'callStatusReconnecting'.tr(),
 | 
			
		||||
                                        livekit.ConnectionState.disconnected:
 | 
			
		||||
                                            'callStatusDisconnected'.tr(),
 | 
			
		||||
                                        livekit.ConnectionState.connected:
 | 
			
		||||
                                            'callStatusConnected'.tr(),
 | 
			
		||||
                                        livekit.ConnectionState.connecting:
 | 
			
		||||
                                            'callStatusConnecting'.tr(),
 | 
			
		||||
                                        livekit.ConnectionState.reconnecting:
 | 
			
		||||
                                            'callStatusReconnecting'.tr(),
 | 
			
		||||
                                      }[call.room.connectionState]!,
 | 
			
		||||
                                    ),
 | 
			
		||||
                                    const Gap(6),
 | 
			
		||||
                                    if (connectionQuality != livekit.ConnectionQuality.unknown)
 | 
			
		||||
                                    if (connectionQuality !=
 | 
			
		||||
                                        livekit.ConnectionQuality.unknown)
 | 
			
		||||
                                      Icon(
 | 
			
		||||
                                        {
 | 
			
		||||
                                          livekit.ConnectionQuality.excellent: Icons.signal_cellular_alt,
 | 
			
		||||
                                          livekit.ConnectionQuality.good: Icons.signal_cellular_alt_2_bar,
 | 
			
		||||
                                          livekit.ConnectionQuality.poor: Icons.signal_cellular_alt_1_bar,
 | 
			
		||||
                                          livekit.ConnectionQuality.excellent:
 | 
			
		||||
                                              Icons.signal_cellular_alt,
 | 
			
		||||
                                          livekit.ConnectionQuality.good:
 | 
			
		||||
                                              Icons.signal_cellular_alt_2_bar,
 | 
			
		||||
                                          livekit.ConnectionQuality.poor:
 | 
			
		||||
                                              Icons.signal_cellular_alt_1_bar,
 | 
			
		||||
                                        }[connectionQuality],
 | 
			
		||||
                                        color: {
 | 
			
		||||
                                          livekit.ConnectionQuality.excellent: Colors.green,
 | 
			
		||||
                                          livekit.ConnectionQuality.good: Colors.orange,
 | 
			
		||||
                                          livekit.ConnectionQuality.poor: Colors.red,
 | 
			
		||||
                                          livekit.ConnectionQuality.excellent:
 | 
			
		||||
                                              Colors.green,
 | 
			
		||||
                                          livekit.ConnectionQuality.good:
 | 
			
		||||
                                              Colors.orange,
 | 
			
		||||
                                          livekit.ConnectionQuality.poor:
 | 
			
		||||
                                              Colors.red,
 | 
			
		||||
                                        }[connectionQuality],
 | 
			
		||||
                                        size: 16,
 | 
			
		||||
                                      )
 | 
			
		||||
@@ -244,7 +263,9 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
 | 
			
		||||
                        Row(
 | 
			
		||||
                          children: [
 | 
			
		||||
                            IconButton(
 | 
			
		||||
                              icon: _layoutMode == 0 ? const Icon(Icons.view_list) : const Icon(Icons.grid_view),
 | 
			
		||||
                              icon: _layoutMode == 0
 | 
			
		||||
                                  ? const Icon(Icons.view_list)
 | 
			
		||||
                                  : const Icon(Icons.grid_view),
 | 
			
		||||
                              onPressed: () {
 | 
			
		||||
                                _switchLayout();
 | 
			
		||||
                              },
 | 
			
		||||
 
 | 
			
		||||
@@ -57,10 +57,10 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/im/channels/${_channel!.keyPath}/members/me');
 | 
			
		||||
      _profile = SnChannelMember.fromJson(resp.data);
 | 
			
		||||
      _notifyLevel = _profile!.notify;
 | 
			
		||||
      final ct = context.read<ChatChannelProvider>();
 | 
			
		||||
      final resp = await ct.getChannelProfile(_channel!);
 | 
			
		||||
      _profile = resp;
 | 
			
		||||
      _notifyLevel = resp.notify;
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      final ud = context.read<UserDirectoryProvider>();
 | 
			
		||||
      await ud.getAccount(_profile!.accountId);
 | 
			
		||||
@@ -102,10 +102,12 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
    if (!mounted) return;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final ct = context.read<ChatChannelProvider>();
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      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;
 | 
			
		||||
      Navigator.pop(context, false);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
@@ -129,12 +131,15 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
    setState(() => _isUpdatingNotifyLevel = true);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final ct = context.read<ChatChannelProvider>();
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.put(
 | 
			
		||||
      final resp = await sn.client.put(
 | 
			
		||||
        '/cgi/im/channels/${_channel!.keyPath}/members/me/notify',
 | 
			
		||||
        data: {'notify_level': value},
 | 
			
		||||
      );
 | 
			
		||||
      _profile = SnChannelMember.fromJson(resp.data);
 | 
			
		||||
      _notifyLevel = value;
 | 
			
		||||
      await ct.updateChannelProfile(_profile!);
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar('channelNotifyLevelApplied'.tr());
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
@@ -215,6 +220,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
    final isOwned = ua.isAuthorized && _channel?.accountId == ua.user?.id;
 | 
			
		||||
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        title: _channel != null ? Text(_channel!.name) : Text('loading').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
@@ -245,7 +251,11 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
              Column(
 | 
			
		||||
                crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                children: [
 | 
			
		||||
                  Text('channelDetailPersonalRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                  Text('channelDetailPersonalRegion')
 | 
			
		||||
                      .bold()
 | 
			
		||||
                      .fontSize(17)
 | 
			
		||||
                      .tr()
 | 
			
		||||
                      .padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                  ListTile(
 | 
			
		||||
                    leading: const Icon(Symbols.notifications),
 | 
			
		||||
                    trailing: DropdownButtonHideUnderline(
 | 
			
		||||
@@ -284,14 +294,14 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
                  ),
 | 
			
		||||
                  ListTile(
 | 
			
		||||
                    leading: AccountImage(
 | 
			
		||||
                      content: ud.getAccountFromCache(_profile!.accountId)?.avatar,
 | 
			
		||||
                      content: ud.getFromCache(_profile!.accountId)?.avatar,
 | 
			
		||||
                      radius: 18,
 | 
			
		||||
                    ),
 | 
			
		||||
                    trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
                    title: Text('channelEditProfile').tr(),
 | 
			
		||||
                    subtitle: Text(
 | 
			
		||||
                      (_profile?.nick?.isEmpty ?? true)
 | 
			
		||||
                          ? ud.getAccountFromCache(_profile!.accountId)!.nick
 | 
			
		||||
                          ? ud.getFromCache(_profile!.accountId)!.nick
 | 
			
		||||
                          : _profile!.nick!,
 | 
			
		||||
                    ),
 | 
			
		||||
                    contentPadding: const EdgeInsets.only(left: 20, right: 20),
 | 
			
		||||
@@ -303,7 +313,8 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
                      trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
                      title: Text('channelActionLeave').tr(),
 | 
			
		||||
                      subtitle: Text('channelActionLeaveDescription').tr(),
 | 
			
		||||
                      contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
                      contentPadding:
 | 
			
		||||
                          const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
                      onTap: _leaveChannel,
 | 
			
		||||
                    ),
 | 
			
		||||
                ],
 | 
			
		||||
@@ -311,7 +322,11 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
            Column(
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
              children: [
 | 
			
		||||
                Text('channelDetailMemberRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                Text('channelDetailMemberRegion')
 | 
			
		||||
                    .bold()
 | 
			
		||||
                    .fontSize(17)
 | 
			
		||||
                    .tr()
 | 
			
		||||
                    .padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                ListTile(
 | 
			
		||||
                  leading: const Icon(Symbols.group),
 | 
			
		||||
                  trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
@@ -333,7 +348,11 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
            Column(
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
              children: [
 | 
			
		||||
                Text('channelDetailAdminRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                Text('channelDetailAdminRegion')
 | 
			
		||||
                    .bold()
 | 
			
		||||
                    .fontSize(17)
 | 
			
		||||
                    .tr()
 | 
			
		||||
                    .padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                ListTile(
 | 
			
		||||
                  leading: const Icon(Symbols.edit),
 | 
			
		||||
                  trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
@@ -379,10 +398,12 @@ class _ChannelProfileDetailDialog extends StatefulWidget {
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_ChannelProfileDetailDialog> createState() => _ChannelProfileDetailDialogState();
 | 
			
		||||
  State<_ChannelProfileDetailDialog> createState() =>
 | 
			
		||||
      _ChannelProfileDetailDialogState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ChannelProfileDetailDialogState extends State<_ChannelProfileDetailDialog> {
 | 
			
		||||
class _ChannelProfileDetailDialogState
 | 
			
		||||
    extends State<_ChannelProfileDetailDialog> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
 | 
			
		||||
  final TextEditingController _nickController = TextEditingController();
 | 
			
		||||
@@ -391,11 +412,14 @@ class _ChannelProfileDetailDialogState extends State<_ChannelProfileDetailDialog
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final ct = context.read<ChatChannelProvider>();
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.put(
 | 
			
		||||
      final resp = await sn.client.put(
 | 
			
		||||
        '/cgi/im/channels/${widget.channel.keyPath}/members/me',
 | 
			
		||||
        data: {'nick': _nickController.text},
 | 
			
		||||
      );
 | 
			
		||||
      final out = SnChannelMember.fromJson(resp.data);
 | 
			
		||||
      await ct.updateChannelProfile(out);
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      Navigator.pop(context, true);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
@@ -457,7 +481,8 @@ class _ChannelMemberListWidget extends StatefulWidget {
 | 
			
		||||
  const _ChannelMemberListWidget({required this.channel});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_ChannelMemberListWidget> createState() => _ChannelMemberListWidgetState();
 | 
			
		||||
  State<_ChannelMemberListWidget> createState() =>
 | 
			
		||||
      _ChannelMemberListWidgetState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
			
		||||
@@ -472,10 +497,12 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
			
		||||
    try {
 | 
			
		||||
      final ud = context.read<UserDirectoryProvider>();
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/im/channels/${widget.channel.keyPath}/members', queryParameters: {
 | 
			
		||||
        'take': 10,
 | 
			
		||||
        'offset': _members.length,
 | 
			
		||||
      });
 | 
			
		||||
      final resp = await sn.client.get(
 | 
			
		||||
          '/cgi/im/channels/${widget.channel.keyPath}/members',
 | 
			
		||||
          queryParameters: {
 | 
			
		||||
            'take': 10,
 | 
			
		||||
            'offset': _members.length,
 | 
			
		||||
          });
 | 
			
		||||
      final out = List<SnChannelMember>.from(
 | 
			
		||||
        resp.data['data']?.map((e) => SnChannelMember.fromJson(e)) ?? [],
 | 
			
		||||
      );
 | 
			
		||||
@@ -533,7 +560,9 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
			
		||||
          children: [
 | 
			
		||||
            const Icon(Symbols.group, size: 24),
 | 
			
		||||
            const Gap(16),
 | 
			
		||||
            Text('channelMemberManage').tr().textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
			
		||||
            Text('channelMemberManage')
 | 
			
		||||
                .tr()
 | 
			
		||||
                .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
			
		||||
          ],
 | 
			
		||||
        ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
			
		||||
        Expanded(
 | 
			
		||||
@@ -544,7 +573,8 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
			
		||||
            },
 | 
			
		||||
            child: InfiniteList(
 | 
			
		||||
              itemCount: _members.length,
 | 
			
		||||
              hasReachedMax: _totalCount != null && _members.length >= _totalCount!,
 | 
			
		||||
              hasReachedMax:
 | 
			
		||||
                  _totalCount != null && _members.length >= _totalCount!,
 | 
			
		||||
              isLoading: _isBusy,
 | 
			
		||||
              onFetchData: _fetchMembers,
 | 
			
		||||
              itemBuilder: (context, index) {
 | 
			
		||||
@@ -552,10 +582,10 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
			
		||||
                return ListTile(
 | 
			
		||||
                  contentPadding: const EdgeInsets.only(right: 24, left: 16),
 | 
			
		||||
                  leading: AccountImage(
 | 
			
		||||
                    content: ud.getAccountFromCache(member.accountId)?.avatar,
 | 
			
		||||
                    content: ud.getFromCache(member.accountId)?.avatar,
 | 
			
		||||
                  ),
 | 
			
		||||
                  title: Text(
 | 
			
		||||
                    ud.getAccountFromCache(member.accountId)?.name ?? 'unknown'.tr(),
 | 
			
		||||
                    ud.getFromCache(member.accountId)?.name ?? 'unknown'.tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                  subtitle: Text(member.nick ?? 'unknown'.tr()),
 | 
			
		||||
                  trailing: SizedBox(
 | 
			
		||||
@@ -565,7 +595,8 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
			
		||||
                      mainAxisAlignment: MainAxisAlignment.end,
 | 
			
		||||
                      children: [
 | 
			
		||||
                        IconButton(
 | 
			
		||||
                          onPressed: _isUpdating ? null : () => _deleteMember(member),
 | 
			
		||||
                          onPressed:
 | 
			
		||||
                              _isUpdating ? null : () => _deleteMember(member),
 | 
			
		||||
                          icon: const Icon(Symbols.person_remove),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ],
 | 
			
		||||
 
 | 
			
		||||
@@ -49,7 +49,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
        resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
 | 
			
		||||
      );
 | 
			
		||||
      if (_editingChannel != null) {
 | 
			
		||||
        _belongToRealm = _realms?.firstWhereOrNull((e) => e.id == _editingChannel!.realmId);
 | 
			
		||||
        _belongToRealm =
 | 
			
		||||
            _realms?.firstWhereOrNull((e) => e.id == _editingChannel!.realmId);
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (mounted) context.showErrorDialog(err);
 | 
			
		||||
@@ -95,6 +96,11 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
      '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 {
 | 
			
		||||
@@ -135,8 +141,11 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        title: widget.editingChannelAlias != null ? Text('screenChatManage').tr() : Text('screenChatNew').tr(),
 | 
			
		||||
        title: widget.editingChannelAlias != null
 | 
			
		||||
            ? Text('screenChatManage').tr()
 | 
			
		||||
            : Text('screenChatNew').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: SingleChildScrollView(
 | 
			
		||||
        child: Column(
 | 
			
		||||
@@ -148,7 +157,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                leadingPadding: const EdgeInsets.only(left: 10, right: 20),
 | 
			
		||||
                dividerColor: Colors.transparent,
 | 
			
		||||
                content: Text(
 | 
			
		||||
                  'channelEditingNotice'.tr(args: ['#${_editingChannel!.alias}']),
 | 
			
		||||
                  'channelEditingNotice'
 | 
			
		||||
                      .tr(args: ['#${_editingChannel!.alias}']),
 | 
			
		||||
                ),
 | 
			
		||||
                actions: [
 | 
			
		||||
                  TextButton(
 | 
			
		||||
@@ -171,7 +181,6 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                items: [
 | 
			
		||||
                  ...(_realms?.map(
 | 
			
		||||
                        (SnRealm item) => DropdownMenuItem<SnRealm>(
 | 
			
		||||
                          enabled: _editingChannel == null || _editingChannel?.realmId == item.id,
 | 
			
		||||
                          value: item,
 | 
			
		||||
                          child: Row(
 | 
			
		||||
                            children: [
 | 
			
		||||
@@ -189,12 +198,15 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                                  mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                                  children: [
 | 
			
		||||
                                    Text(item.name).textStyle(Theme.of(context).textTheme.bodyMedium!),
 | 
			
		||||
                                    Text(item.name).textStyle(Theme.of(context)
 | 
			
		||||
                                        .textTheme
 | 
			
		||||
                                        .bodyMedium!),
 | 
			
		||||
                                    Text(
 | 
			
		||||
                                      item.description,
 | 
			
		||||
                                      maxLines: 1,
 | 
			
		||||
                                      overflow: TextOverflow.ellipsis,
 | 
			
		||||
                                    ).textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
			
		||||
                                    ).textStyle(
 | 
			
		||||
                                        Theme.of(context).textTheme.bodySmall!),
 | 
			
		||||
                                  ],
 | 
			
		||||
                                ),
 | 
			
		||||
                              ),
 | 
			
		||||
@@ -204,14 +216,14 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                      ) ??
 | 
			
		||||
                      []),
 | 
			
		||||
                  DropdownMenuItem<SnRealm>(
 | 
			
		||||
                    enabled: _editingChannel == null,
 | 
			
		||||
                    value: null,
 | 
			
		||||
                    child: Row(
 | 
			
		||||
                      children: [
 | 
			
		||||
                        CircleAvatar(
 | 
			
		||||
                          radius: 16,
 | 
			
		||||
                          backgroundColor: Colors.transparent,
 | 
			
		||||
                          foregroundColor: Theme.of(context).colorScheme.onSurface,
 | 
			
		||||
                          foregroundColor:
 | 
			
		||||
                              Theme.of(context).colorScheme.onSurface,
 | 
			
		||||
                          child: const Icon(Symbols.clear),
 | 
			
		||||
                        ),
 | 
			
		||||
                        const Gap(12),
 | 
			
		||||
@@ -220,7 +232,9 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                            mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                            crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                            children: [
 | 
			
		||||
                              Text('fieldChatBelongToRealmUnset').tr().textStyle(
 | 
			
		||||
                              Text('fieldChatBelongToRealmUnset')
 | 
			
		||||
                                  .tr()
 | 
			
		||||
                                  .textStyle(
 | 
			
		||||
                                    Theme.of(context).textTheme.bodyMedium!,
 | 
			
		||||
                                  ),
 | 
			
		||||
                            ],
 | 
			
		||||
@@ -255,7 +269,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                    helperText: 'fieldChatAliasHint'.tr(),
 | 
			
		||||
                    helperMaxLines: 2,
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                  onTapOutside: (_) =>
 | 
			
		||||
                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ),
 | 
			
		||||
                const Gap(4),
 | 
			
		||||
                TextField(
 | 
			
		||||
@@ -264,7 +279,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                    border: const UnderlineInputBorder(),
 | 
			
		||||
                    labelText: 'fieldChatName'.tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                  onTapOutside: (_) =>
 | 
			
		||||
                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ),
 | 
			
		||||
                const Gap(4),
 | 
			
		||||
                TextField(
 | 
			
		||||
@@ -275,7 +291,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                    border: const UnderlineInputBorder(),
 | 
			
		||||
                    labelText: 'fieldChatDescription'.tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                  onTapOutside: (_) =>
 | 
			
		||||
                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ),
 | 
			
		||||
                const Gap(12),
 | 
			
		||||
                CheckboxListTile(
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
import 'dart:async';
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
import 'dart:developer';
 | 
			
		||||
 | 
			
		||||
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/providers/channel.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/user_directory.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/providers/websocket.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/chat_message.dart';
 | 
			
		||||
import 'package:surface/widgets/chat/chat_message_input.dart';
 | 
			
		||||
@@ -39,7 +42,8 @@ class ChatRoomScreen extends StatefulWidget {
 | 
			
		||||
  final String alias;
 | 
			
		||||
  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
 | 
			
		||||
  State<ChatRoomScreen> createState() => _ChatRoomScreenState();
 | 
			
		||||
@@ -48,16 +52,41 @@ class ChatRoomScreen extends StatefulWidget {
 | 
			
		||||
class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
  bool _isCalling = false;
 | 
			
		||||
  bool _isJoining = false;
 | 
			
		||||
 | 
			
		||||
  SnChannel? _channel;
 | 
			
		||||
  SnChannelMember? _currentMember;
 | 
			
		||||
  SnChannelMember? _otherMember;
 | 
			
		||||
  SnChatCall? _ongoingCall;
 | 
			
		||||
 | 
			
		||||
  final GlobalKey<ChatMessageInputState> _inputGlobalKey = GlobalKey();
 | 
			
		||||
  late final ChatMessageController _messageController;
 | 
			
		||||
 | 
			
		||||
  late final NotificationProvider _nty = context.read<NotificationProvider>();
 | 
			
		||||
  late final WebSocketProvider _ws = context.read<WebSocketProvider>();
 | 
			
		||||
 | 
			
		||||
  bool _isEncrypted = false;
 | 
			
		||||
 | 
			
		||||
  StreamSubscription? _wsSubscription;
 | 
			
		||||
 | 
			
		||||
  Future<void> _joinChannel() async {
 | 
			
		||||
    try {
 | 
			
		||||
      setState(() => _isJoining = true);
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final ua = context.read<UserProvider>();
 | 
			
		||||
      await sn.client
 | 
			
		||||
          .post('/cgi/im/channels/${_channel!.keyPath}/members', data: {
 | 
			
		||||
        'related': ua.user?.name,
 | 
			
		||||
      });
 | 
			
		||||
      _initializeChat();
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isJoining = true);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchChannel() async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
@@ -66,6 +95,12 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
      _channel = await chan.getChannel('${widget.scope}:${widget.alias}');
 | 
			
		||||
 | 
			
		||||
      if (!mounted || _channel == null) return;
 | 
			
		||||
      final ct = context.read<ChatChannelProvider>();
 | 
			
		||||
      try {
 | 
			
		||||
        _currentMember = await ct.getChannelProfile(_channel!);
 | 
			
		||||
      } catch (_) {}
 | 
			
		||||
 | 
			
		||||
      if (!mounted || _currentMember == null) return;
 | 
			
		||||
      final ud = context.read<UserDirectoryProvider>();
 | 
			
		||||
      final ua = context.read<UserProvider>();
 | 
			
		||||
      if (_channel!.type == 1) {
 | 
			
		||||
@@ -82,6 +117,20 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
              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) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
@@ -180,21 +229,21 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
    return a.createdAt.difference(b.createdAt).inMinutes <= 3;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _messageController = ChatMessageController(context);
 | 
			
		||||
  Future<void> _initializeChat() async {
 | 
			
		||||
    _fetchChannel().then((_) async {
 | 
			
		||||
      if (_currentMember == null) return;
 | 
			
		||||
      await _messageController.initialize(_channel!);
 | 
			
		||||
 | 
			
		||||
      if (widget.extra != null) {
 | 
			
		||||
        WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
			
		||||
          log('[ChatInput] Setting initial text and attachments...');
 | 
			
		||||
          if (widget.extra!.initialText != null) {
 | 
			
		||||
            _inputGlobalKey.currentState?.setInitialText(widget.extra!.initialText!);
 | 
			
		||||
            _inputGlobalKey.currentState
 | 
			
		||||
                ?.setInitialText(widget.extra!.initialText!);
 | 
			
		||||
          }
 | 
			
		||||
          if (widget.extra!.initialAttachments != null) {
 | 
			
		||||
            _inputGlobalKey.currentState?.setInitialAttachments(widget.extra!.initialAttachments!);
 | 
			
		||||
            _inputGlobalKey.currentState
 | 
			
		||||
                ?.setInitialAttachments(widget.extra!.initialAttachments!);
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
@@ -204,9 +253,15 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
        _fetchOngoingCall(),
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    final ws = context.read<WebSocketProvider>();
 | 
			
		||||
    _wsSubscription = ws.pk.stream.listen((event) {
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _messageController = ChatMessageController(context);
 | 
			
		||||
    _initializeChat();
 | 
			
		||||
 | 
			
		||||
    _wsSubscription = _ws.pk.stream.listen((event) {
 | 
			
		||||
      switch (event.method) {
 | 
			
		||||
        case 'calls.new':
 | 
			
		||||
          final payload = SnChatCall.fromJson(event.payload!);
 | 
			
		||||
@@ -228,6 +283,18 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    _wsSubscription?.cancel();
 | 
			
		||||
    _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();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -237,21 +304,35 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
    final ud = context.read<UserDirectoryProvider>();
 | 
			
		||||
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        title: Text(
 | 
			
		||||
          _channel?.type == 1
 | 
			
		||||
              ? ud.getAccountFromCache(_otherMember?.accountId)?.nick ?? _channel!.name
 | 
			
		||||
              ? ud.getFromCache(_otherMember?.accountId)?.nick ?? _channel!.name
 | 
			
		||||
              : _channel?.name ?? 'loading'.tr(),
 | 
			
		||||
        ),
 | 
			
		||||
        actions: [
 | 
			
		||||
          IconButton(
 | 
			
		||||
            icon: _ongoingCall == null ? const Icon(Symbols.call) : const Icon(Symbols.call_end),
 | 
			
		||||
            onPressed: _isCalling
 | 
			
		||||
                ? null
 | 
			
		||||
                : _ongoingCall == null
 | 
			
		||||
                    ? _makeCall
 | 
			
		||||
                    : _endCall,
 | 
			
		||||
          ),
 | 
			
		||||
          if (_currentMember != null)
 | 
			
		||||
            IconButton(
 | 
			
		||||
              onPressed: () {
 | 
			
		||||
                setState(() => _isEncrypted = !_isEncrypted);
 | 
			
		||||
                _inputGlobalKey.currentState?.setEncrypted(_isEncrypted);
 | 
			
		||||
              },
 | 
			
		||||
              icon: _isEncrypted
 | 
			
		||||
                  ? const Icon(Symbols.lock)
 | 
			
		||||
                  : const Icon(Symbols.no_encryption),
 | 
			
		||||
            ),
 | 
			
		||||
          if (_currentMember != null)
 | 
			
		||||
            IconButton(
 | 
			
		||||
              icon: _ongoingCall == null
 | 
			
		||||
                  ? const Icon(Symbols.call)
 | 
			
		||||
                  : const Icon(Symbols.call_end),
 | 
			
		||||
              onPressed: _isCalling
 | 
			
		||||
                  ? null
 | 
			
		||||
                  : _ongoingCall == null
 | 
			
		||||
                      ? _makeCall
 | 
			
		||||
                      : _endCall,
 | 
			
		||||
            ),
 | 
			
		||||
          IconButton(
 | 
			
		||||
            icon: const Icon(Symbols.more_vert),
 | 
			
		||||
            onPressed: () {
 | 
			
		||||
@@ -275,7 +356,9 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
        builder: (context, _) {
 | 
			
		||||
          return Column(
 | 
			
		||||
            children: [
 | 
			
		||||
              LoadingIndicator(isActive: _isBusy),
 | 
			
		||||
              LoadingIndicator(
 | 
			
		||||
                isActive: _isBusy || _messageController.isAggressiveLoading,
 | 
			
		||||
              ),
 | 
			
		||||
              SingleChildScrollView(
 | 
			
		||||
                physics: const NeverScrollableScrollPhysics(),
 | 
			
		||||
                child: MaterialBanner(
 | 
			
		||||
@@ -295,14 +378,48 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
                      )
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              )
 | 
			
		||||
                  .height(_ongoingCall != null ? 54 : 0, animate: true)
 | 
			
		||||
                  .animate(const Duration(milliseconds: 300), Curves.fastLinearToSlowEaseIn),
 | 
			
		||||
              if (_messageController.isPending)
 | 
			
		||||
              ).height(_ongoingCall != null ? 54 : 0, animate: true).animate(
 | 
			
		||||
                  const Duration(milliseconds: 300),
 | 
			
		||||
                  Curves.fastLinearToSlowEaseIn),
 | 
			
		||||
              if (_currentMember == null && !_isBusy)
 | 
			
		||||
                Expanded(
 | 
			
		||||
                  child: Center(
 | 
			
		||||
                    child: Container(
 | 
			
		||||
                      constraints: const BoxConstraints(maxWidth: 280),
 | 
			
		||||
                      child: Column(
 | 
			
		||||
                        mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                        children: [
 | 
			
		||||
                          const Icon(Symbols.person_remove, size: 40, fill: 1),
 | 
			
		||||
                          const Gap(8),
 | 
			
		||||
                          Text('chatUnjoined'.tr(), textAlign: TextAlign.center)
 | 
			
		||||
                              .fontSize(16)
 | 
			
		||||
                              .bold(),
 | 
			
		||||
                          Text('chatUnjoinedDescription'.tr(),
 | 
			
		||||
                                  textAlign: TextAlign.center)
 | 
			
		||||
                              .fontSize(13),
 | 
			
		||||
                          if (_channel!.isPublic)
 | 
			
		||||
                            Text('chatUnjoinedPublicDescription'.tr(),
 | 
			
		||||
                                    textAlign: TextAlign.center)
 | 
			
		||||
                                .fontSize(13)
 | 
			
		||||
                                .padding(top: 8),
 | 
			
		||||
                          if (_channel!.isPublic)
 | 
			
		||||
                            TextButton(
 | 
			
		||||
                              style: ButtonStyle(
 | 
			
		||||
                                visualDensity: VisualDensity.compact,
 | 
			
		||||
                              ),
 | 
			
		||||
                              onPressed: _isJoining ? null : _joinChannel,
 | 
			
		||||
                              child: Text('chatJoin').tr(),
 | 
			
		||||
                            ),
 | 
			
		||||
                        ],
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                )
 | 
			
		||||
              else if (_messageController.isPending)
 | 
			
		||||
                Expanded(
 | 
			
		||||
                  child: const CircularProgressIndicator().center(),
 | 
			
		||||
                ),
 | 
			
		||||
              if (!_messageController.isPending)
 | 
			
		||||
                )
 | 
			
		||||
              else
 | 
			
		||||
                Expanded(
 | 
			
		||||
                  child: InfiniteList(
 | 
			
		||||
                    reverse: true,
 | 
			
		||||
@@ -315,6 +432,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
                    },
 | 
			
		||||
                    itemBuilder: (context, idx) {
 | 
			
		||||
                      final message = _messageController.messages[idx];
 | 
			
		||||
                      _messageController.readEvent(message.id);
 | 
			
		||||
 | 
			
		||||
                      bool canMerge = false, canMergePrevious = false;
 | 
			
		||||
                      if (idx > 0) {
 | 
			
		||||
@@ -336,7 +454,8 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
                          data: message,
 | 
			
		||||
                          isMerged: canMerge,
 | 
			
		||||
                          hasMerged: canMergePrevious,
 | 
			
		||||
                          isPending: _messageController.unconfirmedMessages.contains(message.uuid),
 | 
			
		||||
                          isPending: _messageController.unconfirmedMessages
 | 
			
		||||
                              .contains(message.uuid),
 | 
			
		||||
                          onReply: (value) {
 | 
			
		||||
                            _inputGlobalKey.currentState?.setReply(value);
 | 
			
		||||
                          },
 | 
			
		||||
@@ -351,7 +470,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              if (!_messageController.isPending)
 | 
			
		||||
              if (!_messageController.isPending && _currentMember != null)
 | 
			
		||||
                Material(
 | 
			
		||||
                  elevation: 2,
 | 
			
		||||
                  child: Column(
 | 
			
		||||
 
 | 
			
		||||
@@ -5,16 +5,28 @@ import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:responsive_framework/responsive_framework.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/config.dart';
 | 
			
		||||
import 'package:surface/providers/post.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/sn_realm.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/types/realm.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/app_bar_leading.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/feed/feed_news.dart';
 | 
			
		||||
import 'package:surface/widgets/feed/feed_unknown.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:surface/widgets/post/fediverse_post_item.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_item.dart';
 | 
			
		||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
			
		||||
 | 
			
		||||
const kPostChannels = ['Global', 'Friends', 'Following'];
 | 
			
		||||
const kPostChannelIcons = [Symbols.globe, Symbols.group, Symbols.subscriptions];
 | 
			
		||||
 | 
			
		||||
const Map<String, IconData> kCategoryIcons = {
 | 
			
		||||
  'technology': Symbols.tools_wrench,
 | 
			
		||||
  'gaming': Symbols.gamepad,
 | 
			
		||||
@@ -35,66 +47,117 @@ class ExploreScreen extends StatefulWidget {
 | 
			
		||||
  State<ExploreScreen> createState() => _ExploreScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ExploreScreenState extends State<ExploreScreen> {
 | 
			
		||||
class _ExploreScreenState extends State<ExploreScreen>
 | 
			
		||||
    with TickerProviderStateMixin {
 | 
			
		||||
  late TabController _tabController = TabController(
 | 
			
		||||
    length: kPostChannels.length,
 | 
			
		||||
    vsync: this,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  final _fabKey = GlobalKey<ExpandableFabState>();
 | 
			
		||||
  final _listKey = GlobalKey<_PostListWidgetState>();
 | 
			
		||||
 | 
			
		||||
  bool _isBusy = true;
 | 
			
		||||
  bool _showCategories = false;
 | 
			
		||||
 | 
			
		||||
  final List<SnPost> _posts = List.empty(growable: true);
 | 
			
		||||
  final List<SnPostCategory> _categories = List.empty(growable: true);
 | 
			
		||||
  int? _postCount;
 | 
			
		||||
 | 
			
		||||
  String? _selectedCategory;
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchCategories() async {
 | 
			
		||||
    _categories.clear();
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      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) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
      if (mounted) context.showErrorDialog(err);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchPosts() async {
 | 
			
		||||
    if (_postCount != null && _posts.length >= _postCount!) return;
 | 
			
		||||
  final List<SnRealm> _realms = List.empty(growable: true);
 | 
			
		||||
 | 
			
		||||
    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> _fetchRealms() async {
 | 
			
		||||
    try {
 | 
			
		||||
      final ua = context.read<UserProvider>();
 | 
			
		||||
      if (!ua.isAuthorized) return;
 | 
			
		||||
      final rels = context.read<SnRealmProvider>();
 | 
			
		||||
      final out = await rels.listAvailableRealms();
 | 
			
		||||
      setState(() {
 | 
			
		||||
        _realms.addAll(out);
 | 
			
		||||
      });
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
      rethrow;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _refreshPosts() {
 | 
			
		||||
    _postCount = null;
 | 
			
		||||
    _posts.clear();
 | 
			
		||||
    return _fetchPosts();
 | 
			
		||||
  void _toggleShowCategories() {
 | 
			
		||||
    _showCategories = !_showCategories;
 | 
			
		||||
    if (_showCategories) {
 | 
			
		||||
      _tabController = TabController(length: _categories.length, vsync: this);
 | 
			
		||||
      _listKey.currentState?.setCategory(_categories[_tabController.index]);
 | 
			
		||||
      _listKey.currentState?.refreshPosts();
 | 
			
		||||
    } else {
 | 
			
		||||
      _tabController = TabController(length: kPostChannels.length, vsync: this);
 | 
			
		||||
      _listKey.currentState?.setCategory(null);
 | 
			
		||||
      _listKey.currentState?.refreshPosts();
 | 
			
		||||
    }
 | 
			
		||||
    _tabListen();
 | 
			
		||||
    setState(() {});
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _tabListen() {
 | 
			
		||||
    _tabController.addListener(() {
 | 
			
		||||
      if (_tabController.indexIsChanging) {
 | 
			
		||||
        if (_showCategories) {
 | 
			
		||||
          _listKey.currentState?.setCategory(_categories[_tabController.index]);
 | 
			
		||||
          _listKey.currentState?.refreshPosts();
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        switch (_tabController.index) {
 | 
			
		||||
          case 0:
 | 
			
		||||
          case 3:
 | 
			
		||||
            _listKey.currentState?.setChannel(null);
 | 
			
		||||
            break;
 | 
			
		||||
          case 1:
 | 
			
		||||
            _listKey.currentState?.setChannel('friends');
 | 
			
		||||
            break;
 | 
			
		||||
          case 2:
 | 
			
		||||
            _listKey.currentState?.setChannel('following');
 | 
			
		||||
            break;
 | 
			
		||||
        }
 | 
			
		||||
        _listKey.currentState?.refreshPosts();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _fetchPosts();
 | 
			
		||||
    _tabListen();
 | 
			
		||||
    _fetchCategories();
 | 
			
		||||
    _fetchRealms();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    _tabController.dispose();
 | 
			
		||||
    super.dispose();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> refreshPosts() async {
 | 
			
		||||
    await _listKey.currentState?.refreshPosts();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final cfg = context.watch<ConfigProvider>();
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      floatingActionButtonLocation: ExpandableFab.location,
 | 
			
		||||
      floatingActionButton: ExpandableFab(
 | 
			
		||||
        key: _fabKey,
 | 
			
		||||
@@ -102,181 +165,485 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
			
		||||
        type: ExpandableFabType.up,
 | 
			
		||||
        childrenAnimation: ExpandableFabAnimation.none,
 | 
			
		||||
        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(
 | 
			
		||||
          child: const Icon(Symbols.add, size: 28),
 | 
			
		||||
          fabSize: ExpandableFabSize.regular,
 | 
			
		||||
          foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
 | 
			
		||||
          backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
 | 
			
		||||
          shape: const CircleBorder(),
 | 
			
		||||
          foregroundColor:
 | 
			
		||||
              Theme.of(context).floatingActionButtonTheme.foregroundColor,
 | 
			
		||||
          backgroundColor:
 | 
			
		||||
              Theme.of(context).floatingActionButtonTheme.backgroundColor,
 | 
			
		||||
        ),
 | 
			
		||||
        closeButtonBuilder: DefaultFloatingActionButtonBuilder(
 | 
			
		||||
          child: const Icon(Symbols.close, size: 28),
 | 
			
		||||
          fabSize: ExpandableFabSize.regular,
 | 
			
		||||
          foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
 | 
			
		||||
          backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
 | 
			
		||||
          shape: const CircleBorder(),
 | 
			
		||||
          foregroundColor:
 | 
			
		||||
              Theme.of(context).floatingActionButtonTheme.foregroundColor,
 | 
			
		||||
          backgroundColor:
 | 
			
		||||
              Theme.of(context).floatingActionButtonTheme.backgroundColor,
 | 
			
		||||
        ),
 | 
			
		||||
        children: [
 | 
			
		||||
          Row(
 | 
			
		||||
            children: [
 | 
			
		||||
              Text('writePostTypeStory').tr(),
 | 
			
		||||
              Text('writePost').tr(),
 | 
			
		||||
              const Gap(20),
 | 
			
		||||
              FloatingActionButton(
 | 
			
		||||
                heroTag: null,
 | 
			
		||||
                tooltip: 'writePostTypeStory'.tr(),
 | 
			
		||||
                tooltip: 'writePost'.tr(),
 | 
			
		||||
                onPressed: () {
 | 
			
		||||
                  GoRouter.of(context).pushNamed('postEditor', pathParameters: {
 | 
			
		||||
                    'mode': 'stories',
 | 
			
		||||
                  }).then((value) {
 | 
			
		||||
                  GoRouter.of(context).pushNamed('postEditor').then((value) {
 | 
			
		||||
                    if (value == true) {
 | 
			
		||||
                      _refreshPosts();
 | 
			
		||||
                      refreshPosts();
 | 
			
		||||
                    }
 | 
			
		||||
                  });
 | 
			
		||||
                  _fabKey.currentState!.toggle();
 | 
			
		||||
                },
 | 
			
		||||
                child: const Icon(Symbols.post_rounded),
 | 
			
		||||
                child: const Icon(Symbols.edit),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
          Row(
 | 
			
		||||
            children: [
 | 
			
		||||
              Text('writePostTypeArticle').tr(),
 | 
			
		||||
              Text('postDraftBox').tr(),
 | 
			
		||||
              const Gap(20),
 | 
			
		||||
              FloatingActionButton(
 | 
			
		||||
                heroTag: null,
 | 
			
		||||
                tooltip: 'writePostTypeArticle'.tr(),
 | 
			
		||||
                tooltip: 'postDraftBox'.tr(),
 | 
			
		||||
                onPressed: () {
 | 
			
		||||
                  GoRouter.of(context).pushNamed('postEditor', pathParameters: {
 | 
			
		||||
                    'mode': 'articles',
 | 
			
		||||
                  }).then((value) {
 | 
			
		||||
                    if (value == true) {
 | 
			
		||||
                      _refreshPosts();
 | 
			
		||||
                    }
 | 
			
		||||
                  });
 | 
			
		||||
                  GoRouter.of(context).pushNamed('postDraftBox');
 | 
			
		||||
                  _fabKey.currentState!.toggle();
 | 
			
		||||
                },
 | 
			
		||||
                child: const Icon(Symbols.news),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
          Row(
 | 
			
		||||
            children: [
 | 
			
		||||
              Text('writePostTypeQuestion').tr(),
 | 
			
		||||
              const Gap(20),
 | 
			
		||||
              FloatingActionButton(
 | 
			
		||||
                heroTag: null,
 | 
			
		||||
                tooltip: 'writePostTypeQuestion'.tr(),
 | 
			
		||||
                onPressed: () {
 | 
			
		||||
                  GoRouter.of(context).pushNamed('postEditor', pathParameters: {
 | 
			
		||||
                    'mode': 'questions',
 | 
			
		||||
                  }).then((value) {
 | 
			
		||||
                    if (value == true) {
 | 
			
		||||
                      _refreshPosts();
 | 
			
		||||
                    }
 | 
			
		||||
                  });
 | 
			
		||||
                  _fabKey.currentState!.toggle();
 | 
			
		||||
                },
 | 
			
		||||
                child: const Icon(Symbols.question_answer),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
          Row(
 | 
			
		||||
            children: [
 | 
			
		||||
              Text('writePostTypeVideo').tr(),
 | 
			
		||||
              const Gap(20),
 | 
			
		||||
              FloatingActionButton(
 | 
			
		||||
                heroTag: null,
 | 
			
		||||
                tooltip: 'writePostTypeVideo'.tr(),
 | 
			
		||||
                onPressed: () {
 | 
			
		||||
                  GoRouter.of(context).pushNamed('postEditor', pathParameters: {
 | 
			
		||||
                    'mode': 'videos',
 | 
			
		||||
                  }).then((value) {
 | 
			
		||||
                    if (value == true) {
 | 
			
		||||
                      _refreshPosts();
 | 
			
		||||
                    }
 | 
			
		||||
                  });
 | 
			
		||||
                  _fabKey.currentState!.toggle();
 | 
			
		||||
                },
 | 
			
		||||
                child: const Icon(Symbols.video_call),
 | 
			
		||||
                child: const Icon(Symbols.box_edit),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
      body: RefreshIndicator(
 | 
			
		||||
        displacement: 40 + MediaQuery.of(context).padding.top,
 | 
			
		||||
        onRefresh: () => _refreshPosts(),
 | 
			
		||||
        child: CustomScrollView(
 | 
			
		||||
          slivers: [
 | 
			
		||||
            SliverAppBar(
 | 
			
		||||
              leading: AutoAppBarLeading(),
 | 
			
		||||
              title: Text('screenExplore').tr(),
 | 
			
		||||
              floating: true,
 | 
			
		||||
              snap: true,
 | 
			
		||||
              actions: [
 | 
			
		||||
                IconButton(
 | 
			
		||||
                  icon: const Icon(Symbols.search),
 | 
			
		||||
                  onPressed: () {
 | 
			
		||||
                    GoRouter.of(context).pushNamed('postSearch');
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                const Gap(8),
 | 
			
		||||
              ],
 | 
			
		||||
              bottom: PreferredSize(
 | 
			
		||||
                preferredSize: const Size.fromHeight(50),
 | 
			
		||||
                child: SizedBox(
 | 
			
		||||
                  height: 50,
 | 
			
		||||
                  child: SingleChildScrollView(
 | 
			
		||||
                    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(),
 | 
			
		||||
      body: NestedScrollView(
 | 
			
		||||
        headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
 | 
			
		||||
          return [
 | 
			
		||||
            SliverOverlapAbsorber(
 | 
			
		||||
              handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
 | 
			
		||||
              sliver: SliverAppBar(
 | 
			
		||||
                leading:
 | 
			
		||||
                    ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)
 | 
			
		||||
                        ? AutoAppBarLeading()
 | 
			
		||||
                        : null,
 | 
			
		||||
                titleSpacing: 0,
 | 
			
		||||
                title: Row(
 | 
			
		||||
                  children: [
 | 
			
		||||
                    if (ResponsiveBreakpoints.of(context).largerThan(MOBILE))
 | 
			
		||||
                      const Gap(8),
 | 
			
		||||
                    IconButton(
 | 
			
		||||
                      icon: const Icon(Symbols.shuffle),
 | 
			
		||||
                      onPressed: () {
 | 
			
		||||
                        GoRouter.of(context).pushNamed('postShuffle');
 | 
			
		||||
                      },
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                    if (ResponsiveBreakpoints.of(context).largerThan(MOBILE))
 | 
			
		||||
                      const Gap(48),
 | 
			
		||||
                    Expanded(
 | 
			
		||||
                      child: Center(
 | 
			
		||||
                        child: IconButton(
 | 
			
		||||
                          padding: EdgeInsets.zero,
 | 
			
		||||
                          constraints: const BoxConstraints(),
 | 
			
		||||
                          visualDensity: VisualDensity.compact,
 | 
			
		||||
                          icon: _listKey.currentState?.realm != null
 | 
			
		||||
                              ? AccountImage(
 | 
			
		||||
                                  content: _listKey.currentState!.realm!.avatar,
 | 
			
		||||
                                  radius: 14,
 | 
			
		||||
                                )
 | 
			
		||||
                              : Image.asset(
 | 
			
		||||
                                  'assets/icon/icon-dark.png',
 | 
			
		||||
                                  width: 32,
 | 
			
		||||
                                  height: 32,
 | 
			
		||||
                                  color: Theme.of(context)
 | 
			
		||||
                                      .appBarTheme
 | 
			
		||||
                                      .foregroundColor,
 | 
			
		||||
                                ),
 | 
			
		||||
                          onPressed: () {
 | 
			
		||||
                            showModalBottomSheet(
 | 
			
		||||
                              context: context,
 | 
			
		||||
                              builder: (context) => _PostListRealmPopup(
 | 
			
		||||
                                realms: _realms,
 | 
			
		||||
                                onUpdate: (realm) {
 | 
			
		||||
                                  _listKey.currentState?.setRealm(realm);
 | 
			
		||||
                                  _listKey.currentState?.refreshPosts();
 | 
			
		||||
                                  Future.delayed(
 | 
			
		||||
                                      const Duration(milliseconds: 100), () {
 | 
			
		||||
                                    if (mounted) {
 | 
			
		||||
                                      setState(() {});
 | 
			
		||||
                                    }
 | 
			
		||||
                                  });
 | 
			
		||||
                                },
 | 
			
		||||
                                onMixedFeedChanged: (flag) {
 | 
			
		||||
                                  _listKey.currentState?.setRealm(null);
 | 
			
		||||
                                  _listKey.currentState?.setCategory(null);
 | 
			
		||||
                                  if (_showCategories && flag) {
 | 
			
		||||
                                    _toggleShowCategories();
 | 
			
		||||
                                  }
 | 
			
		||||
                                  _listKey.currentState?.refreshPosts();
 | 
			
		||||
                                },
 | 
			
		||||
                              ),
 | 
			
		||||
                            );
 | 
			
		||||
                          },
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
                floating: true,
 | 
			
		||||
                snap: true,
 | 
			
		||||
                actions: [
 | 
			
		||||
                  IconButton(
 | 
			
		||||
                    icon: const Icon(Symbols.category),
 | 
			
		||||
                    style: _showCategories
 | 
			
		||||
                        ? ButtonStyle(
 | 
			
		||||
                            foregroundColor: WidgetStateProperty.all(
 | 
			
		||||
                              Theme.of(context).colorScheme.primary,
 | 
			
		||||
                            ),
 | 
			
		||||
                            backgroundColor: MaterialStateProperty.all(
 | 
			
		||||
                              Theme.of(context).colorScheme.secondaryContainer,
 | 
			
		||||
                            ),
 | 
			
		||||
                          )
 | 
			
		||||
                        : null,
 | 
			
		||||
                    onPressed: cfg.mixedFeed
 | 
			
		||||
                        ? null
 | 
			
		||||
                        : () {
 | 
			
		||||
                            _toggleShowCategories();
 | 
			
		||||
                          },
 | 
			
		||||
                  ),
 | 
			
		||||
                  IconButton(
 | 
			
		||||
                    icon: const Icon(Symbols.search),
 | 
			
		||||
                    onPressed: () {
 | 
			
		||||
                      GoRouter.of(context).pushNamed('postSearch');
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
                  const Gap(8),
 | 
			
		||||
                ],
 | 
			
		||||
                bottom: cfg.mixedFeed
 | 
			
		||||
                    ? null
 | 
			
		||||
                    : TabBar(
 | 
			
		||||
                        isScrollable: _showCategories,
 | 
			
		||||
                        controller: _tabController,
 | 
			
		||||
                        tabs: _showCategories
 | 
			
		||||
                            ? [
 | 
			
		||||
                                for (final category in _categories)
 | 
			
		||||
                                  Tab(
 | 
			
		||||
                                    child: Row(
 | 
			
		||||
                                      mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                                      crossAxisAlignment:
 | 
			
		||||
                                          CrossAxisAlignment.center,
 | 
			
		||||
                                      children: [
 | 
			
		||||
                                        Icon(
 | 
			
		||||
                                          kCategoryIcons[category.alias] ??
 | 
			
		||||
                                              Symbols.question_mark,
 | 
			
		||||
                                          color: Theme.of(context)
 | 
			
		||||
                                              .appBarTheme
 | 
			
		||||
                                              .foregroundColor!,
 | 
			
		||||
                                        ),
 | 
			
		||||
                                        const Gap(8),
 | 
			
		||||
                                        Flexible(
 | 
			
		||||
                                          child: Text(
 | 
			
		||||
                                            'postCategory${category.alias.capitalize()}'
 | 
			
		||||
                                                    .trExists()
 | 
			
		||||
                                                ? 'postCategory${category.alias.capitalize()}'
 | 
			
		||||
                                                    .tr()
 | 
			
		||||
                                                : category.name,
 | 
			
		||||
                                            maxLines: 1,
 | 
			
		||||
                                          ).textColor(
 | 
			
		||||
                                            Theme.of(context)
 | 
			
		||||
                                                .appBarTheme
 | 
			
		||||
                                                .foregroundColor!,
 | 
			
		||||
                                          ),
 | 
			
		||||
                                        ),
 | 
			
		||||
                                      ],
 | 
			
		||||
                                    ),
 | 
			
		||||
                                  ),
 | 
			
		||||
                              ]
 | 
			
		||||
                            : [
 | 
			
		||||
                                for (final channel in kPostChannels)
 | 
			
		||||
                                  Tab(
 | 
			
		||||
                                    child: Row(
 | 
			
		||||
                                      mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                                      crossAxisAlignment:
 | 
			
		||||
                                          CrossAxisAlignment.center,
 | 
			
		||||
                                      children: [
 | 
			
		||||
                                        Icon(
 | 
			
		||||
                                          kPostChannelIcons[
 | 
			
		||||
                                              kPostChannels.indexOf(channel)],
 | 
			
		||||
                                          size: 20,
 | 
			
		||||
                                          color: Theme.of(context)
 | 
			
		||||
                                              .appBarTheme
 | 
			
		||||
                                              .foregroundColor,
 | 
			
		||||
                                        ),
 | 
			
		||||
                                        const Gap(8),
 | 
			
		||||
                                        Flexible(
 | 
			
		||||
                                          child: Text(
 | 
			
		||||
                                            'postChannel$channel',
 | 
			
		||||
                                            maxLines: 1,
 | 
			
		||||
                                          ).tr().textColor(
 | 
			
		||||
                                                Theme.of(context)
 | 
			
		||||
                                                    .appBarTheme
 | 
			
		||||
                                                    .foregroundColor,
 | 
			
		||||
                                              ),
 | 
			
		||||
                                        ),
 | 
			
		||||
                                      ],
 | 
			
		||||
                                    ),
 | 
			
		||||
                                  ),
 | 
			
		||||
                              ],
 | 
			
		||||
                      ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
            const SliverGap(12),
 | 
			
		||||
            SliverInfiniteList(
 | 
			
		||||
              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),
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
          ];
 | 
			
		||||
        },
 | 
			
		||||
        body: _PostListWidget(
 | 
			
		||||
          key: _listKey,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostListWidget extends StatefulWidget {
 | 
			
		||||
  const _PostListWidget({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_PostListWidget> createState() => _PostListWidgetState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostListWidgetState extends State<_PostListWidget> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
 | 
			
		||||
  SnRealm? get realm => _selectedRealm;
 | 
			
		||||
 | 
			
		||||
  final List<SnFeedEntry> _feed = List.empty(growable: true);
 | 
			
		||||
  SnRealm? _selectedRealm;
 | 
			
		||||
  String? _selectedChannel;
 | 
			
		||||
  SnPostCategory? _selectedCategory;
 | 
			
		||||
  bool _hasLoadedAll = false;
 | 
			
		||||
 | 
			
		||||
  // Called when using regular feed
 | 
			
		||||
  Future<void> _fetchPosts() async {
 | 
			
		||||
    if (_hasLoadedAll) return;
 | 
			
		||||
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    final pt = context.read<SnPostContentProvider>();
 | 
			
		||||
    final result = await pt.listPosts(
 | 
			
		||||
      take: 10,
 | 
			
		||||
      offset: _feed.length,
 | 
			
		||||
      categories: _selectedCategory != null ? [_selectedCategory!.alias] : null,
 | 
			
		||||
      channel: _selectedChannel,
 | 
			
		||||
      realm: _selectedRealm?.alias,
 | 
			
		||||
    );
 | 
			
		||||
    final out = result.$1;
 | 
			
		||||
 | 
			
		||||
    if (!mounted) return;
 | 
			
		||||
 | 
			
		||||
    final postCount = result.$2;
 | 
			
		||||
    _feed.addAll(
 | 
			
		||||
      out.map((ele) => SnFeedEntry(
 | 
			
		||||
          type: 'interactive.post',
 | 
			
		||||
          data: ele.toJson(),
 | 
			
		||||
          createdAt: ele.createdAt)),
 | 
			
		||||
    );
 | 
			
		||||
    _hasLoadedAll = _feed.length >= postCount;
 | 
			
		||||
 | 
			
		||||
    if (mounted) setState(() => _isBusy = false);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Called when mixed feed is enabled
 | 
			
		||||
  Future<void> _fetchFeed() async {
 | 
			
		||||
    if (_hasLoadedAll) return;
 | 
			
		||||
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    final pt = context.read<SnPostContentProvider>();
 | 
			
		||||
    final result = await pt.getFeed(
 | 
			
		||||
      cursor: _feed
 | 
			
		||||
          .where((ele) => !['reader.news'].contains(ele.type))
 | 
			
		||||
          .lastOrNull
 | 
			
		||||
          ?.createdAt,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (!mounted) return;
 | 
			
		||||
 | 
			
		||||
    _feed.addAll(result);
 | 
			
		||||
    _hasLoadedAll = result.isEmpty;
 | 
			
		||||
 | 
			
		||||
    if (mounted) setState(() => _isBusy = false);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setChannel(String? channel) {
 | 
			
		||||
    _selectedChannel = channel;
 | 
			
		||||
    setState(() {});
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setRealm(SnRealm? realm) {
 | 
			
		||||
    _selectedRealm = realm;
 | 
			
		||||
    setState(() {});
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setCategory(SnPostCategory? category) {
 | 
			
		||||
    _selectedCategory = category;
 | 
			
		||||
    setState(() {});
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> refreshPosts() {
 | 
			
		||||
    _hasLoadedAll = false;
 | 
			
		||||
    _feed.clear();
 | 
			
		||||
    final cfg = context.read<ConfigProvider>();
 | 
			
		||||
    if (cfg.mixedFeed) {
 | 
			
		||||
      return _fetchFeed();
 | 
			
		||||
    } else {
 | 
			
		||||
      return _fetchPosts();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    final cfg = context.read<ConfigProvider>();
 | 
			
		||||
    if (cfg.mixedFeed) {
 | 
			
		||||
      _fetchFeed();
 | 
			
		||||
    } else {
 | 
			
		||||
      _fetchPosts();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final cfg = context.watch<ConfigProvider>();
 | 
			
		||||
    return MediaQuery.removePadding(
 | 
			
		||||
      context: context,
 | 
			
		||||
      removeTop: true,
 | 
			
		||||
      child: RefreshIndicator(
 | 
			
		||||
        displacement: 40 + MediaQuery.of(context).padding.top,
 | 
			
		||||
        onRefresh: () => refreshPosts(),
 | 
			
		||||
        child: InfiniteList(
 | 
			
		||||
          padding: EdgeInsets.only(top: 8),
 | 
			
		||||
          itemCount: _feed.length,
 | 
			
		||||
          isLoading: _isBusy,
 | 
			
		||||
          centerLoading: true,
 | 
			
		||||
          hasReachedMax: _hasLoadedAll,
 | 
			
		||||
          onFetchData: cfg.mixedFeed ? _fetchFeed : _fetchPosts,
 | 
			
		||||
          itemBuilder: (context, idx) {
 | 
			
		||||
            final ele = _feed[idx];
 | 
			
		||||
            switch (ele.type) {
 | 
			
		||||
              case 'interactive.post':
 | 
			
		||||
                return OpenablePostItem(
 | 
			
		||||
                  useReplace: true,
 | 
			
		||||
                  data: SnPost.fromJson(ele.data),
 | 
			
		||||
                  maxWidth: 640,
 | 
			
		||||
                  onChanged: (data) {
 | 
			
		||||
                    setState(() {
 | 
			
		||||
                      _feed[idx] = _feed[idx].copyWith(data: data.toJson());
 | 
			
		||||
                    });
 | 
			
		||||
                  },
 | 
			
		||||
                  onDeleted: () {
 | 
			
		||||
                    refreshPosts();
 | 
			
		||||
                  },
 | 
			
		||||
                );
 | 
			
		||||
              case 'fediverse.post':
 | 
			
		||||
                return FediversePostWidget(
 | 
			
		||||
                  data: SnFediversePost.fromJson(ele.data),
 | 
			
		||||
                  maxWidth: 640,
 | 
			
		||||
                );
 | 
			
		||||
              case 'reader.news':
 | 
			
		||||
                return Center(
 | 
			
		||||
                  child: Container(
 | 
			
		||||
                    constraints: BoxConstraints(maxWidth: 640),
 | 
			
		||||
                    child: NewsFeedEntry(data: ele),
 | 
			
		||||
                  ),
 | 
			
		||||
                );
 | 
			
		||||
              default:
 | 
			
		||||
                return Container(
 | 
			
		||||
                  constraints: BoxConstraints(maxWidth: 640),
 | 
			
		||||
                  child: FeedUnknownEntry(data: ele),
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          separatorBuilder: (_, __) => const Divider().padding(vertical: 2),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostListRealmPopup extends StatelessWidget {
 | 
			
		||||
  final List<SnRealm>? realms;
 | 
			
		||||
  final Function(SnRealm?) onUpdate;
 | 
			
		||||
  final Function(bool) onMixedFeedChanged;
 | 
			
		||||
 | 
			
		||||
  const _PostListRealmPopup({
 | 
			
		||||
    required this.realms,
 | 
			
		||||
    required this.onUpdate,
 | 
			
		||||
    required this.onMixedFeedChanged,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final cfg = context.watch<ConfigProvider>();
 | 
			
		||||
 | 
			
		||||
    return Column(
 | 
			
		||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
      children: [
 | 
			
		||||
        Row(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
          children: [
 | 
			
		||||
            const Icon(Symbols.tune, size: 24),
 | 
			
		||||
            const Gap(16),
 | 
			
		||||
            Text('filterFeed', style: Theme.of(context).textTheme.titleLarge)
 | 
			
		||||
                .tr(),
 | 
			
		||||
          ],
 | 
			
		||||
        ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
			
		||||
        SwitchListTile(
 | 
			
		||||
          secondary: const Icon(Symbols.merge_type),
 | 
			
		||||
          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
          title: Text('mixedFeed').tr(),
 | 
			
		||||
          subtitle: Text('mixedFeedDescription').tr(),
 | 
			
		||||
          value: cfg.mixedFeed,
 | 
			
		||||
          onChanged: (value) {
 | 
			
		||||
            cfg.mixedFeed = value;
 | 
			
		||||
            onMixedFeedChanged.call(value);
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        if (!cfg.mixedFeed)
 | 
			
		||||
          ListTile(
 | 
			
		||||
            leading: const Icon(Symbols.close),
 | 
			
		||||
            title: Text('postInGlobal').tr(),
 | 
			
		||||
            subtitle: Text('postViewInGlobalDescription').tr(),
 | 
			
		||||
            contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
            onTap: () {
 | 
			
		||||
              onUpdate.call(null);
 | 
			
		||||
              Navigator.pop(context);
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
        if (!cfg.mixedFeed) const Divider(height: 1),
 | 
			
		||||
        if (!cfg.mixedFeed)
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: ListView.builder(
 | 
			
		||||
              itemCount: realms?.length ?? 0,
 | 
			
		||||
              itemBuilder: (context, idx) {
 | 
			
		||||
                final realm = realms![idx];
 | 
			
		||||
                return ListTile(
 | 
			
		||||
                  title: Text(realm.name),
 | 
			
		||||
                  subtitle: Text('@${realm.alias}'),
 | 
			
		||||
                  leading: AccountImage(content: realm.avatar, radius: 18),
 | 
			
		||||
                  onTap: () {
 | 
			
		||||
                    onUpdate.call(realm);
 | 
			
		||||
                    Navigator.pop(context);
 | 
			
		||||
                  },
 | 
			
		||||
                );
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -10,7 +10,6 @@ import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/types/account.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_select.dart';
 | 
			
		||||
import 'package:surface/widgets/app_bar_leading.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
@@ -47,8 +46,7 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/id/users/me/relations?status=1');
 | 
			
		||||
      _relations = List<SnRelationship>.from(
 | 
			
		||||
        resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [],
 | 
			
		||||
      );
 | 
			
		||||
          resp.data?.map((e) => SnRelationship.fromJson(e)) ?? []);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
@@ -67,8 +65,7 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/id/users/me/relations?status=0,3');
 | 
			
		||||
      _requests = List<SnRelationship>.from(
 | 
			
		||||
        resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [],
 | 
			
		||||
      );
 | 
			
		||||
          resp.data?.map((e) => SnRelationship.fromJson(e)) ?? []);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
@@ -87,8 +84,7 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/id/users/me/relations?status=2');
 | 
			
		||||
      _blocks = List<SnRelationship>.from(
 | 
			
		||||
        resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [],
 | 
			
		||||
      );
 | 
			
		||||
          resp.data?.map((e) => SnRelationship.fromJson(e)) ?? []);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
@@ -105,10 +101,7 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
    try {
 | 
			
		||||
      final rel = context.read<SnRelationshipProvider>();
 | 
			
		||||
      await rel.updateRelationship(
 | 
			
		||||
        relation.relatedId,
 | 
			
		||||
        dstStatus,
 | 
			
		||||
        relation.permNodes,
 | 
			
		||||
      );
 | 
			
		||||
          relation.relatedId, dstStatus, relation.permNodes);
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      _fetchRelations();
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
@@ -122,9 +115,8 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
  Future<void> _deleteRelation(SnRelationship relation) async {
 | 
			
		||||
    final confirm = await context.showConfirmDialog(
 | 
			
		||||
      'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
 | 
			
		||||
      'friendDeleteDescription'.tr(args: [
 | 
			
		||||
        relation.related?.nick ?? 'unknown'.tr(),
 | 
			
		||||
      ]),
 | 
			
		||||
      'friendDeleteDescription'
 | 
			
		||||
          .tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
 | 
			
		||||
    );
 | 
			
		||||
    if (!confirm) return;
 | 
			
		||||
    if (!mounted) return;
 | 
			
		||||
@@ -146,9 +138,11 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
 | 
			
		||||
  void _showRequests() {
 | 
			
		||||
    showModalBottomSheet(
 | 
			
		||||
      context: context,
 | 
			
		||||
      builder: (context) => _FriendshipListWidget(relations: _requests),
 | 
			
		||||
    ).then((value) {
 | 
			
		||||
            context: context,
 | 
			
		||||
            builder: (context) => _FriendshipListWidget(relations: _requests))
 | 
			
		||||
        .then((
 | 
			
		||||
      value,
 | 
			
		||||
    ) {
 | 
			
		||||
      if (value != null) {
 | 
			
		||||
        _fetchRequests();
 | 
			
		||||
        _fetchRelations();
 | 
			
		||||
@@ -158,9 +152,10 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
 | 
			
		||||
  void _showBlocks() {
 | 
			
		||||
    showModalBottomSheet(
 | 
			
		||||
      context: context,
 | 
			
		||||
      builder: (context) => _FriendshipListWidget(relations: _blocks),
 | 
			
		||||
    ).then((value) {
 | 
			
		||||
        context: context,
 | 
			
		||||
        builder: (context) => _FriendshipListWidget(relations: _blocks)).then((
 | 
			
		||||
      value,
 | 
			
		||||
    ) {
 | 
			
		||||
      if (value != null) {
 | 
			
		||||
        _fetchBlocks();
 | 
			
		||||
        _fetchRelations();
 | 
			
		||||
@@ -173,9 +168,8 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.post('/cgi/id/users/me/relations', data: {
 | 
			
		||||
        'related': user.name,
 | 
			
		||||
      });
 | 
			
		||||
      await sn.client
 | 
			
		||||
          .post('/cgi/id/users/me/relations', data: {'related': user.name});
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar('friendRequestSent'.tr());
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
@@ -201,18 +195,16 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
    if (!ua.isAuthorized) {
 | 
			
		||||
      return AppScaffold(
 | 
			
		||||
        appBar: AppBar(
 | 
			
		||||
          leading: AutoAppBarLeading(),
 | 
			
		||||
          leading: PageBackButton(),
 | 
			
		||||
          title: Text('screenFriend').tr(),
 | 
			
		||||
        ),
 | 
			
		||||
        body: Center(
 | 
			
		||||
          child: UnauthorizedHint(),
 | 
			
		||||
        ),
 | 
			
		||||
        body: Center(child: UnauthorizedHint()),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: AutoAppBarLeading(),
 | 
			
		||||
        leading: PageBackButton(),
 | 
			
		||||
        title: Text('screenFriend').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      floatingActionButton: FloatingActionButton(
 | 
			
		||||
@@ -220,9 +212,7 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
        onPressed: () async {
 | 
			
		||||
          final user = await showModalBottomSheet<SnAccount?>(
 | 
			
		||||
            context: context,
 | 
			
		||||
            builder: (context) => AccountSelect(
 | 
			
		||||
              title: 'friendNew'.tr(),
 | 
			
		||||
            ),
 | 
			
		||||
            builder: (context) => AccountSelect(title: 'friendNew'.tr()),
 | 
			
		||||
          );
 | 
			
		||||
          if (!mounted) return;
 | 
			
		||||
          if (user == null) return;
 | 
			
		||||
@@ -235,9 +225,8 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
          if (_requests.isNotEmpty)
 | 
			
		||||
            ListTile(
 | 
			
		||||
              title: Text('friendRequests').tr(),
 | 
			
		||||
              subtitle: Text(
 | 
			
		||||
                'friendRequestsDescription',
 | 
			
		||||
              ).plural(_requests.length),
 | 
			
		||||
              subtitle:
 | 
			
		||||
                  Text('friendRequestsDescription').plural(_requests.length),
 | 
			
		||||
              contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
              leading: const Icon(Symbols.group_add),
 | 
			
		||||
              trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
@@ -246,31 +235,30 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
          if (_blocks.isNotEmpty)
 | 
			
		||||
            ListTile(
 | 
			
		||||
              title: Text('friendBlocklist').tr(),
 | 
			
		||||
              subtitle: Text(
 | 
			
		||||
                'friendBlocklistDescription',
 | 
			
		||||
              ).plural(_blocks.length),
 | 
			
		||||
              subtitle:
 | 
			
		||||
                  Text('friendBlocklistDescription').plural(_blocks.length),
 | 
			
		||||
              contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
              leading: const Icon(Symbols.block),
 | 
			
		||||
              trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
              onTap: _showBlocks,
 | 
			
		||||
            ),
 | 
			
		||||
          if (_requests.isNotEmpty || _blocks.isNotEmpty) const Divider(height: 1),
 | 
			
		||||
          if (_requests.isNotEmpty || _blocks.isNotEmpty)
 | 
			
		||||
            const Divider(height: 1),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: MediaQuery.removePadding(
 | 
			
		||||
              context: context,
 | 
			
		||||
              removeTop: true,
 | 
			
		||||
              child: RefreshIndicator(
 | 
			
		||||
                onRefresh: () => Future.wait([
 | 
			
		||||
                  _fetchRelations(),
 | 
			
		||||
                  _fetchRequests(),
 | 
			
		||||
                ]),
 | 
			
		||||
                onRefresh: () =>
 | 
			
		||||
                    Future.wait([_fetchRelations(), _fetchRequests()]),
 | 
			
		||||
                child: ListView.builder(
 | 
			
		||||
                  itemCount: _relations.length,
 | 
			
		||||
                  itemBuilder: (context, index) {
 | 
			
		||||
                    final relation = _relations[index];
 | 
			
		||||
                    final other = relation.related;
 | 
			
		||||
                    return ListTile(
 | 
			
		||||
                      contentPadding: const EdgeInsets.only(right: 24, left: 16),
 | 
			
		||||
                      contentPadding:
 | 
			
		||||
                          const EdgeInsets.only(right: 24, left: 16),
 | 
			
		||||
                      leading: AccountImage(content: other?.avatar),
 | 
			
		||||
                      title: Text(other?.nick ?? 'unknown'),
 | 
			
		||||
                      subtitle: Text(other?.nick ?? 'unknown'),
 | 
			
		||||
@@ -286,12 +274,16 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
                              mainAxisAlignment: MainAxisAlignment.end,
 | 
			
		||||
                              children: [
 | 
			
		||||
                                InkWell(
 | 
			
		||||
                                  onTap: _isUpdating ? null : () => _changeRelation(relation, 2),
 | 
			
		||||
                                  onTap: _isUpdating
 | 
			
		||||
                                      ? null
 | 
			
		||||
                                      : () => _changeRelation(relation, 2),
 | 
			
		||||
                                  child: Text('friendBlock').tr(),
 | 
			
		||||
                                ),
 | 
			
		||||
                                const Gap(8),
 | 
			
		||||
                                InkWell(
 | 
			
		||||
                                  onTap: _isUpdating ? null : () => _deleteRelation(relation),
 | 
			
		||||
                                  onTap: _isUpdating
 | 
			
		||||
                                      ? null
 | 
			
		||||
                                      : () => _deleteRelation(relation),
 | 
			
		||||
                                  child: Text('friendDeleteAction').tr(),
 | 
			
		||||
                                ),
 | 
			
		||||
                              ],
 | 
			
		||||
@@ -361,10 +353,7 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
 | 
			
		||||
    try {
 | 
			
		||||
      final rel = context.read<SnRelationshipProvider>();
 | 
			
		||||
      await rel.updateRelationship(
 | 
			
		||||
        relation.relatedId,
 | 
			
		||||
        dstStatus,
 | 
			
		||||
        relation.permNodes,
 | 
			
		||||
      );
 | 
			
		||||
          relation.relatedId, dstStatus, relation.permNodes);
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      Navigator.pop(context, true);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
@@ -378,9 +367,8 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
 | 
			
		||||
  Future<void> _deleteRelation(SnRelationship relation) async {
 | 
			
		||||
    final confirm = await context.showConfirmDialog(
 | 
			
		||||
      'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
 | 
			
		||||
      'friendDeleteDescription'.tr(args: [
 | 
			
		||||
        relation.related?.nick ?? 'unknown'.tr(),
 | 
			
		||||
      ]),
 | 
			
		||||
      'friendDeleteDescription'
 | 
			
		||||
          .tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
 | 
			
		||||
    );
 | 
			
		||||
    if (!confirm) return;
 | 
			
		||||
    if (!mounted) return;
 | 
			
		||||
@@ -420,7 +408,9 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
 | 
			
		||||
              mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.end,
 | 
			
		||||
              children: [
 | 
			
		||||
                Text(kFriendStatus[relation.status] ?? 'unknown').tr().opacity(0.75),
 | 
			
		||||
                Text(kFriendStatus[relation.status] ?? 'unknown')
 | 
			
		||||
                    .tr()
 | 
			
		||||
                    .opacity(0.75),
 | 
			
		||||
                if (relation.status == 0)
 | 
			
		||||
                  Row(
 | 
			
		||||
                    mainAxisAlignment: MainAxisAlignment.end,
 | 
			
		||||
@@ -441,7 +431,8 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
 | 
			
		||||
                    mainAxisAlignment: MainAxisAlignment.end,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      InkWell(
 | 
			
		||||
                        onTap: _isBusy ? null : () => _changeRelation(relation, 1),
 | 
			
		||||
                        onTap:
 | 
			
		||||
                            _isBusy ? null : () => _changeRelation(relation, 1),
 | 
			
		||||
                        child: Text('friendUnblock').tr(),
 | 
			
		||||
                      ),
 | 
			
		||||
                      const Gap(8),
 | 
			
		||||
 
 | 
			
		||||
@@ -1,15 +1,11 @@
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
import 'dart:math' as math;
 | 
			
		||||
import 'dart:ui';
 | 
			
		||||
 | 
			
		||||
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:gap/gap.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:google_fonts/google_fonts.dart';
 | 
			
		||||
import 'package:html/parser.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:relative_time/relative_time.dart';
 | 
			
		||||
@@ -22,13 +18,16 @@ import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/special_day.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/providers/widget.dart';
 | 
			
		||||
import 'package:surface/screens/captcha/captcha.dart';
 | 
			
		||||
import 'package:surface/types/check_in.dart';
 | 
			
		||||
import 'package:surface/types/news.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/widgets/app_bar_leading.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_item.dart';
 | 
			
		||||
import 'package:surface/widgets/updater.dart';
 | 
			
		||||
import 'package:flutter_animate/flutter_animate.dart';
 | 
			
		||||
import 'package:url_launcher/url_launcher_string.dart';
 | 
			
		||||
 | 
			
		||||
class HomeScreenDashEntry {
 | 
			
		||||
  final String name;
 | 
			
		||||
@@ -68,7 +67,7 @@ class _HomeScreenState extends State<HomeScreen> {
 | 
			
		||||
    ),
 | 
			
		||||
    HomeScreenDashEntry(
 | 
			
		||||
      name: 'dashEntryTodayNews',
 | 
			
		||||
      child: _HomeDashTodayNews(),
 | 
			
		||||
      child: _HomeDashServiceStatus(),
 | 
			
		||||
      cols: MediaQuery.of(context).size.width >= 640 ? 3 : 2,
 | 
			
		||||
    ),
 | 
			
		||||
  ];
 | 
			
		||||
@@ -83,14 +82,25 @@ class _HomeScreenState extends State<HomeScreen> {
 | 
			
		||||
      body: LayoutBuilder(
 | 
			
		||||
        builder: (context, constraints) {
 | 
			
		||||
          return Align(
 | 
			
		||||
            alignment: constraints.maxWidth > 640 ? Alignment.center : Alignment.topCenter,
 | 
			
		||||
            alignment: constraints.maxWidth > 640
 | 
			
		||||
                ? Alignment.center
 | 
			
		||||
                : Alignment.topCenter,
 | 
			
		||||
            child: Container(
 | 
			
		||||
              constraints: const BoxConstraints(maxWidth: 640),
 | 
			
		||||
              child: SingleChildScrollView(
 | 
			
		||||
                child: Column(
 | 
			
		||||
                  mainAxisAlignment: constraints.maxWidth > 640 ? MainAxisAlignment.center : MainAxisAlignment.start,
 | 
			
		||||
                  mainAxisAlignment: constraints.maxWidth > 640
 | 
			
		||||
                      ? MainAxisAlignment.center
 | 
			
		||||
                      : MainAxisAlignment.start,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    _HomeDashUpdateWidget(padding: const EdgeInsets.only(bottom: 8, left: 8, right: 8)),
 | 
			
		||||
                    _HomeDashUpdateWidget(
 | 
			
		||||
                      padding: const EdgeInsets.only(
 | 
			
		||||
                        bottom: 8,
 | 
			
		||||
                        left: 8,
 | 
			
		||||
                        right: 8,
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                    _HomeDashUnconfirmedWidget().padding(horizontal: 8),
 | 
			
		||||
                    _HomeDashSpecialDayWidget().padding(horizontal: 8),
 | 
			
		||||
                    StaggeredGrid.extent(
 | 
			
		||||
                      maxCrossAxisExtent: 280,
 | 
			
		||||
@@ -115,6 +125,64 @@ class _HomeScreenState extends State<HomeScreen> {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _HomeDashUnconfirmedWidget extends StatelessWidget {
 | 
			
		||||
  const _HomeDashUnconfirmedWidget();
 | 
			
		||||
 | 
			
		||||
  Future<void> _resendConfirmationEmail(BuildContext context) async {
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.patch('/cgi/id/users/me/confirm');
 | 
			
		||||
      if (!context.mounted) return;
 | 
			
		||||
      context.showSnackbar('accountUnconfirmedResendSuccessful'.tr());
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!context.mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final ua = context.watch<UserProvider>();
 | 
			
		||||
    if (ua.user == null || ua.user?.confirmedAt != null) {
 | 
			
		||||
      return SizedBox.shrink();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return Card(
 | 
			
		||||
      margin: EdgeInsets.zero,
 | 
			
		||||
      child: ListTile(
 | 
			
		||||
        leading: const Icon(Symbols.shield),
 | 
			
		||||
        title: Text('accountUnconfirmedTitle').tr(),
 | 
			
		||||
        subtitle: Column(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
          children: [
 | 
			
		||||
            Text('accountUnconfirmedSubtitle').tr(),
 | 
			
		||||
            const Gap(4),
 | 
			
		||||
            Row(
 | 
			
		||||
              children: [
 | 
			
		||||
                Text('accountUnconfirmedUnreceived').tr(),
 | 
			
		||||
                const Gap(4),
 | 
			
		||||
                InkWell(
 | 
			
		||||
                  child: Text(
 | 
			
		||||
                    'accountUnconfirmedResend',
 | 
			
		||||
                    style: TextStyle(
 | 
			
		||||
                      decoration: TextDecoration.underline,
 | 
			
		||||
                      color: Theme.of(context).colorScheme.onSurface,
 | 
			
		||||
                    ),
 | 
			
		||||
                  ).tr(),
 | 
			
		||||
                  onTap: () {
 | 
			
		||||
                    _resendConfirmationEmail(context);
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
        contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
      ),
 | 
			
		||||
    ).padding(bottom: 8);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _HomeDashUpdateWidget extends StatelessWidget {
 | 
			
		||||
  final EdgeInsets? padding;
 | 
			
		||||
 | 
			
		||||
@@ -123,7 +191,6 @@ class _HomeDashUpdateWidget extends StatelessWidget {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final config = context.watch<ConfigProvider>();
 | 
			
		||||
 | 
			
		||||
    return ListenableBuilder(
 | 
			
		||||
      listenable: config,
 | 
			
		||||
      builder: (context, _) {
 | 
			
		||||
@@ -136,21 +203,15 @@ class _HomeDashUpdateWidget extends StatelessWidget {
 | 
			
		||||
                leading: Icon(Symbols.update),
 | 
			
		||||
                title: Text('updateAvailable').tr(),
 | 
			
		||||
                subtitle: Text(config.updatableVersion!),
 | 
			
		||||
                trailing: (kIsWeb || Platform.isWindows || Platform.isLinux)
 | 
			
		||||
                    ? null
 | 
			
		||||
                    : IconButton(
 | 
			
		||||
                        icon: const Icon(Symbols.arrow_right_alt),
 | 
			
		||||
                        onPressed: () {
 | 
			
		||||
                          final model = UpdateModel(
 | 
			
		||||
                            '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());
 | 
			
		||||
                        },
 | 
			
		||||
                      ),
 | 
			
		||||
                trailing: IconButton(
 | 
			
		||||
                  icon: const Icon(Symbols.arrow_right_alt),
 | 
			
		||||
                  onPressed: () {
 | 
			
		||||
                    showModalBottomSheet(
 | 
			
		||||
                      context: context,
 | 
			
		||||
                      builder: (context) => VersionUpdatePopup(),
 | 
			
		||||
                    );
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
@@ -166,7 +227,8 @@ class _HomeDashSpecialDayWidget extends StatefulWidget {
 | 
			
		||||
  const _HomeDashSpecialDayWidget();
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_HomeDashSpecialDayWidget> createState() => _HomeDashSpecialDayWidgetState();
 | 
			
		||||
  State<_HomeDashSpecialDayWidget> createState() =>
 | 
			
		||||
      _HomeDashSpecialDayWidgetState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
 | 
			
		||||
@@ -208,7 +270,9 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
 | 
			
		||||
        margin: EdgeInsets.zero,
 | 
			
		||||
        child: ListTile(
 | 
			
		||||
          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(
 | 
			
		||||
            crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
            children: [
 | 
			
		||||
@@ -240,21 +304,31 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _HomeDashTodayNews extends StatefulWidget {
 | 
			
		||||
  const _HomeDashTodayNews();
 | 
			
		||||
class _HomeDashServiceStatus extends StatefulWidget {
 | 
			
		||||
  const _HomeDashServiceStatus();
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_HomeDashTodayNews> createState() => _HomeDashTodayNewsState();
 | 
			
		||||
  State<_HomeDashServiceStatus> createState() => _HomeDashServiceStatusState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
 | 
			
		||||
  SnNewsArticle? _article;
 | 
			
		||||
class _HomeDashServiceStatusState extends State<_HomeDashServiceStatus> {
 | 
			
		||||
  Map<String, dynamic>? _statuses;
 | 
			
		||||
  ServiceStatus? _serviceStatus;
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchArticle() async {
 | 
			
		||||
  Future<void> _fetchStatuses() async {
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/re/news/today');
 | 
			
		||||
      _article = SnNewsArticle.fromJson(resp.data['data']);
 | 
			
		||||
      final resp = await sn.client.get('/directory/status');
 | 
			
		||||
      _statuses = resp.data;
 | 
			
		||||
      if (_statuses!.values.contains(false)) {
 | 
			
		||||
        if (_statuses!.values.contains(true)) {
 | 
			
		||||
          _serviceStatus = ServiceStatus.downgraded;
 | 
			
		||||
        } else {
 | 
			
		||||
          _serviceStatus = ServiceStatus.failed;
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        _serviceStatus = ServiceStatus.operational;
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
@@ -267,7 +341,7 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
 | 
			
		||||
  @override
 | 
			
		||||
  initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _fetchArticle();
 | 
			
		||||
    _fetchStatuses();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -279,62 +353,136 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
 | 
			
		||||
        children: [
 | 
			
		||||
          Row(
 | 
			
		||||
            children: [
 | 
			
		||||
              const Icon(Symbols.newspaper),
 | 
			
		||||
              const Icon(Symbols.flare),
 | 
			
		||||
              const Gap(8),
 | 
			
		||||
              Text(
 | 
			
		||||
                'newsToday',
 | 
			
		||||
                style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
              ).tr()
 | 
			
		||||
            ],
 | 
			
		||||
          ).padding(horizontal: 18, top: 12, bottom: 8),
 | 
			
		||||
          if (_article != null)
 | 
			
		||||
            Expanded(
 | 
			
		||||
              child: InkWell(
 | 
			
		||||
                borderRadius: BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                child: Column(
 | 
			
		||||
                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                  spacing: 4,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Text(
 | 
			
		||||
                      _article!.title,
 | 
			
		||||
                      style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 18),
 | 
			
		||||
                      maxLines: MediaQuery.of(context).size.width >= 640 ? 2 : 1,
 | 
			
		||||
                      overflow: TextOverflow.ellipsis,
 | 
			
		||||
                    ),
 | 
			
		||||
                    Text(
 | 
			
		||||
                      parse(_article!.description).children.map((e) => e.text.trim()).join(),
 | 
			
		||||
                      maxLines: 3,
 | 
			
		||||
                      overflow: TextOverflow.ellipsis,
 | 
			
		||||
                      style: Theme.of(context).textTheme.bodyMedium,
 | 
			
		||||
                    ),
 | 
			
		||||
                    Builder(builder: (context) {
 | 
			
		||||
                      final date = _article!.publishedAt ?? _article!.createdAt;
 | 
			
		||||
                      return Row(
 | 
			
		||||
                        crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                        spacing: 2,
 | 
			
		||||
                        children: [
 | 
			
		||||
                          Text(DateFormat().format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
			
		||||
                          Text(' · ').textStyle(Theme.of(context).textTheme.bodySmall!).bold(),
 | 
			
		||||
                          Text(RelativeTime(context).format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
			
		||||
                        ],
 | 
			
		||||
                      ).opacity(0.75);
 | 
			
		||||
                    }),
 | 
			
		||||
                  ],
 | 
			
		||||
                ).padding(horizontal: 16),
 | 
			
		||||
                onTap: () {
 | 
			
		||||
                  GoRouter.of(context).pushNamed(
 | 
			
		||||
                    'newsDetail',
 | 
			
		||||
                    pathParameters: {'hash': _article!.hash},
 | 
			
		||||
                  );
 | 
			
		||||
              Expanded(
 | 
			
		||||
                child: Text(
 | 
			
		||||
                  'serviceStatus',
 | 
			
		||||
                  style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
                ).tr(),
 | 
			
		||||
              ),
 | 
			
		||||
              IconButton(
 | 
			
		||||
                icon: const Icon(Symbols.launch, size: 20),
 | 
			
		||||
                visualDensity: VisualDensity(horizontal: -4, vertical: -4),
 | 
			
		||||
                constraints: const BoxConstraints(),
 | 
			
		||||
                padding: EdgeInsets.zero,
 | 
			
		||||
                onPressed: () {
 | 
			
		||||
                  launchUrlString('https://status.solsynth.dev');
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
            )
 | 
			
		||||
          else
 | 
			
		||||
            ],
 | 
			
		||||
          ).padding(horizontal: 18, top: 12, bottom: 8),
 | 
			
		||||
          Container(
 | 
			
		||||
            padding: EdgeInsets.symmetric(horizontal: 20, vertical: 6),
 | 
			
		||||
            width: double.infinity,
 | 
			
		||||
            color: _serviceStatus == null
 | 
			
		||||
                ? Theme.of(context).colorScheme.surfaceContainerHigh
 | 
			
		||||
                : switch (_serviceStatus) {
 | 
			
		||||
                    ServiceStatus.operational => Colors.green[300],
 | 
			
		||||
                    ServiceStatus.failed => Colors.red[300],
 | 
			
		||||
                    _ => Colors.orange[300],
 | 
			
		||||
                  },
 | 
			
		||||
            child: _serviceStatus == null
 | 
			
		||||
                ? Row(
 | 
			
		||||
                    children: [
 | 
			
		||||
                      const Icon(
 | 
			
		||||
                        Symbols.more_horiz,
 | 
			
		||||
                        size: 20,
 | 
			
		||||
                      ),
 | 
			
		||||
                      const Gap(10),
 | 
			
		||||
                      Text('loading').tr(),
 | 
			
		||||
                    ],
 | 
			
		||||
                  )
 | 
			
		||||
                : switch (_serviceStatus) {
 | 
			
		||||
                    ServiceStatus.operational => Row(
 | 
			
		||||
                        children: [
 | 
			
		||||
                          Icon(
 | 
			
		||||
                            Symbols.check,
 | 
			
		||||
                            size: 20,
 | 
			
		||||
                            color: Colors.green[900],
 | 
			
		||||
                          ),
 | 
			
		||||
                          const Gap(10),
 | 
			
		||||
                          Text('serviceStatusOperational')
 | 
			
		||||
                              .tr()
 | 
			
		||||
                              .textColor(Colors.green[900]),
 | 
			
		||||
                        ],
 | 
			
		||||
                      ),
 | 
			
		||||
                    ServiceStatus.failed => Tooltip(
 | 
			
		||||
                        message: 'serviceStatusFailedDescription'.tr(),
 | 
			
		||||
                        child: Row(
 | 
			
		||||
                          children: [
 | 
			
		||||
                            Icon(
 | 
			
		||||
                              Symbols.dangerous,
 | 
			
		||||
                              size: 20,
 | 
			
		||||
                              color: Colors.red[900],
 | 
			
		||||
                            ),
 | 
			
		||||
                            const Gap(10),
 | 
			
		||||
                            Text('serviceStatusFailed')
 | 
			
		||||
                                .tr()
 | 
			
		||||
                                .textColor(Colors.red[900]),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                    _ => Row(
 | 
			
		||||
                        children: [
 | 
			
		||||
                          Icon(
 | 
			
		||||
                            Symbols.error,
 | 
			
		||||
                            size: 20,
 | 
			
		||||
                            color: Colors.orange[900],
 | 
			
		||||
                          ),
 | 
			
		||||
                          const Gap(10),
 | 
			
		||||
                          Text('serviceStatusDowngraded')
 | 
			
		||||
                              .tr()
 | 
			
		||||
                              .textColor(Colors.orange[900]),
 | 
			
		||||
                        ],
 | 
			
		||||
                      ),
 | 
			
		||||
                  },
 | 
			
		||||
          ),
 | 
			
		||||
          if (_statuses != null)
 | 
			
		||||
            Expanded(
 | 
			
		||||
              child: Center(
 | 
			
		||||
                child: CircularProgressIndicator(),
 | 
			
		||||
              child: SingleChildScrollView(
 | 
			
		||||
                padding: EdgeInsets.only(top: 6),
 | 
			
		||||
                child: Wrap(
 | 
			
		||||
                  spacing: 8,
 | 
			
		||||
                  runSpacing: 8,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    for (final entry in _statuses!.entries)
 | 
			
		||||
                      Tooltip(
 | 
			
		||||
                        message: kServicesName[entry.key] != null
 | 
			
		||||
                            ? 'serviceName${kServicesName[entry.key]}'.tr()
 | 
			
		||||
                            : 'unknown'.tr(),
 | 
			
		||||
                        child: Chip(
 | 
			
		||||
                          visualDensity:
 | 
			
		||||
                              VisualDensity(horizontal: -4, vertical: -4),
 | 
			
		||||
                          avatar: entry.value
 | 
			
		||||
                              ? const Icon(
 | 
			
		||||
                                  Symbols.circle,
 | 
			
		||||
                                  color: Colors.green,
 | 
			
		||||
                                  fill: 1,
 | 
			
		||||
                                  size: 16,
 | 
			
		||||
                                )
 | 
			
		||||
                              : AnimateWidgetExtensions(const Icon(
 | 
			
		||||
                                  Symbols.error,
 | 
			
		||||
                                  color: Colors.red,
 | 
			
		||||
                                  fill: 1,
 | 
			
		||||
                                  size: 16,
 | 
			
		||||
                                ))
 | 
			
		||||
                                  .animate(onPlay: (e) => e.repeat())
 | 
			
		||||
                                  .fadeIn(
 | 
			
		||||
                                      duration: 500.ms, curve: Curves.easeOut)
 | 
			
		||||
                                  .then()
 | 
			
		||||
                                  .fadeOut(
 | 
			
		||||
                                    duration: 500.ms,
 | 
			
		||||
                                    delay: 1000.ms,
 | 
			
		||||
                                    curve: Curves.easeIn,
 | 
			
		||||
                                  ),
 | 
			
		||||
                          label: Text(kServicesName[entry.key] ?? entry.key),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                  ],
 | 
			
		||||
                ).padding(horizontal: 12),
 | 
			
		||||
              ),
 | 
			
		||||
            )
 | 
			
		||||
            ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
@@ -370,11 +518,20 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _doCheckIn() async {
 | 
			
		||||
    final captchaTk = await Navigator.of(context, rootNavigator: true).push(
 | 
			
		||||
      MaterialPageRoute(
 | 
			
		||||
        builder: (context) => CaptchaScreen(),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    if (captchaTk == null) return;
 | 
			
		||||
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final home = context.read<HomeWidgetProvider>();
 | 
			
		||||
      final resp = await sn.client.post('/cgi/id/check-in');
 | 
			
		||||
      final resp = await sn.client.post('/cgi/id/check-in', data: {
 | 
			
		||||
        'captcha_token': captchaTk,
 | 
			
		||||
      });
 | 
			
		||||
      _todayRecord = SnCheckInRecord.fromJson(resp.data);
 | 
			
		||||
      await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson());
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
@@ -386,15 +543,20 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Widget _buildDetailChunk(int index, bool positive) {
 | 
			
		||||
    final prefix = positive ? 'dailyCheckPositiveHint' : 'dailyCheckNegativeHint';
 | 
			
		||||
    final mod = positive ? kSuggestionPositiveHintCount : kSuggestionNegativeHintCount;
 | 
			
		||||
    final prefix =
 | 
			
		||||
        positive ? 'dailyCheckPositiveHint' : 'dailyCheckNegativeHint';
 | 
			
		||||
    final mod =
 | 
			
		||||
        positive ? kSuggestionPositiveHintCount : kSuggestionNegativeHintCount;
 | 
			
		||||
    final pos = math.max(1, _todayRecord!.resultModifiers[index] % mod);
 | 
			
		||||
    return Column(
 | 
			
		||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
      children: [
 | 
			
		||||
        Text(
 | 
			
		||||
          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(
 | 
			
		||||
          '$prefix${pos}Description',
 | 
			
		||||
@@ -429,7 +591,10 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
 | 
			
		||||
              else
 | 
			
		||||
                Text(
 | 
			
		||||
                  'dailyCheckEverythingIsNegative',
 | 
			
		||||
                  style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold),
 | 
			
		||||
                  style: Theme.of(context)
 | 
			
		||||
                      .textTheme
 | 
			
		||||
                      .titleMedium!
 | 
			
		||||
                      .copyWith(fontWeight: FontWeight.bold),
 | 
			
		||||
                ).tr(),
 | 
			
		||||
              const Gap(8),
 | 
			
		||||
              if (_todayRecord?.resultTier != 4)
 | 
			
		||||
@@ -445,7 +610,10 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
 | 
			
		||||
              else
 | 
			
		||||
                Text(
 | 
			
		||||
                  'dailyCheckEverythingIsPositive',
 | 
			
		||||
                  style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold),
 | 
			
		||||
                  style: Theme.of(context)
 | 
			
		||||
                      .textTheme
 | 
			
		||||
                      .titleMedium!
 | 
			
		||||
                      .copyWith(fontWeight: FontWeight.bold),
 | 
			
		||||
                ).tr(),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
@@ -519,11 +687,26 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
 | 
			
		||||
                          '+${_todayRecord!.resultExperience} EXP',
 | 
			
		||||
                          style: Theme.of(context).textTheme.bodyLarge,
 | 
			
		||||
                        ),
 | 
			
		||||
                        if (_todayRecord!.resultCoin >= 0)
 | 
			
		||||
                        if (_todayRecord!.resultCoin > 0)
 | 
			
		||||
                          Text(
 | 
			
		||||
                            '+${_todayRecord!.resultCoin} ${'walletCurrencyShort'.tr()}',
 | 
			
		||||
                            style: Theme.of(context).textTheme.bodyLarge,
 | 
			
		||||
                          )
 | 
			
		||||
                          ),
 | 
			
		||||
                        if (_todayRecord!.currentStreak > 0)
 | 
			
		||||
                          Row(
 | 
			
		||||
                            children: [
 | 
			
		||||
                              const Icon(
 | 
			
		||||
                                Symbols.local_fire_department,
 | 
			
		||||
                                size: 14,
 | 
			
		||||
                              ).padding(bottom: 2),
 | 
			
		||||
                              const Gap(4),
 | 
			
		||||
                              Text(
 | 
			
		||||
                                'checkInStreak'
 | 
			
		||||
                                    .plural(_todayRecord!.currentStreak),
 | 
			
		||||
                                style: Theme.of(context).textTheme.bodySmall,
 | 
			
		||||
                              ),
 | 
			
		||||
                            ],
 | 
			
		||||
                          ).padding(top: 4),
 | 
			
		||||
                      ],
 | 
			
		||||
                    ),
 | 
			
		||||
            ),
 | 
			
		||||
@@ -571,10 +754,12 @@ class _HomeDashNotificationWidget extends StatefulWidget {
 | 
			
		||||
  const _HomeDashNotificationWidget();
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_HomeDashNotificationWidget> createState() => _HomeDashNotificationWidgetState();
 | 
			
		||||
  State<_HomeDashNotificationWidget> createState() =>
 | 
			
		||||
      _HomeDashNotificationWidgetState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget> {
 | 
			
		||||
class _HomeDashNotificationWidgetState
 | 
			
		||||
    extends State<_HomeDashNotificationWidget> {
 | 
			
		||||
  int? _count;
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchNotificationCount() async {
 | 
			
		||||
@@ -612,7 +797,9 @@ class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget
 | 
			
		||||
                  style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
                ).tr(),
 | 
			
		||||
                Text(
 | 
			
		||||
                  _count == null ? 'loading'.tr() : 'notificationUnreadCount'.plural(_count ?? 0),
 | 
			
		||||
                  _count == null
 | 
			
		||||
                      ? 'loading'.tr()
 | 
			
		||||
                      : 'notificationUnreadCount'.plural(_count ?? 0),
 | 
			
		||||
                  style: Theme.of(context).textTheme.bodyLarge,
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
@@ -628,7 +815,7 @@ class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget
 | 
			
		||||
              child: IconButton(
 | 
			
		||||
                icon: const Icon(Symbols.arrow_right_alt),
 | 
			
		||||
                onPressed: () {
 | 
			
		||||
                  GoRouter.of(context).goNamed('notification');
 | 
			
		||||
                  GoRouter.of(context).pushNamed('notification');
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
@@ -643,10 +830,12 @@ class _HomeDashRecommendationPostWidget extends StatefulWidget {
 | 
			
		||||
  const _HomeDashRecommendationPostWidget();
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_HomeDashRecommendationPostWidget> createState() => _HomeDashRecommendationPostWidgetState();
 | 
			
		||||
  State<_HomeDashRecommendationPostWidget> createState() =>
 | 
			
		||||
      _HomeDashRecommendationPostWidgetState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendationPostWidget> {
 | 
			
		||||
class _HomeDashRecommendationPostWidgetState
 | 
			
		||||
    extends State<_HomeDashRecommendationPostWidget> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
  List<SnPost>? _posts;
 | 
			
		||||
 | 
			
		||||
@@ -710,13 +899,17 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati
 | 
			
		||||
                  ).tr(),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
              Text('${_currentPage + 1}/${_posts?.length ?? 0}', style: GoogleFonts.robotoMono())
 | 
			
		||||
              Text(
 | 
			
		||||
                '${_currentPage + 1}/${_posts?.length ?? 0}',
 | 
			
		||||
                style: GoogleFonts.robotoMono(),
 | 
			
		||||
              )
 | 
			
		||||
            ],
 | 
			
		||||
          ).padding(horizontal: 18, top: 12, bottom: 8),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: PageView.builder(
 | 
			
		||||
              controller: _pageController,
 | 
			
		||||
              scrollBehavior: ScrollConfiguration.of(context).copyWith(dragDevices: {
 | 
			
		||||
              scrollBehavior:
 | 
			
		||||
                  ScrollConfiguration.of(context).copyWith(dragDevices: {
 | 
			
		||||
                PointerDeviceKind.mouse,
 | 
			
		||||
                PointerDeviceKind.touch,
 | 
			
		||||
              }),
 | 
			
		||||
@@ -727,9 +920,11 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati
 | 
			
		||||
                    child: PostItem(
 | 
			
		||||
                      data: _posts![index],
 | 
			
		||||
                      showMenu: false,
 | 
			
		||||
                      showFullPost: true,
 | 
			
		||||
                    ).padding(bottom: 8),
 | 
			
		||||
                    onTap: () {
 | 
			
		||||
                      GoRouter.of(context).pushNamed('postDetail', pathParameters: {
 | 
			
		||||
                      GoRouter.of(context)
 | 
			
		||||
                          .pushNamed('postDetail', pathParameters: {
 | 
			
		||||
                        '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(),
 | 
			
		||||
                ),
 | 
			
		||||
              );
 | 
			
		||||
            },
 | 
			
		||||
          );
 | 
			
		||||
        },
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,18 +1,17 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/gestures.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:html/dom.dart' as dom;
 | 
			
		||||
import 'package:html/parser.dart';
 | 
			
		||||
import 'package:html2md/html2md.dart' as html2md;
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:relative_time/relative_time.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/types/news.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/markdown_content.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
import 'package:url_launcher/url_launcher_string.dart';
 | 
			
		||||
 | 
			
		||||
class NewsDetailScreen extends StatefulWidget {
 | 
			
		||||
@@ -26,14 +25,12 @@ class NewsDetailScreen extends StatefulWidget {
 | 
			
		||||
 | 
			
		||||
class _NewsDetailScreenState extends State<NewsDetailScreen> {
 | 
			
		||||
  SnNewsArticle? _article;
 | 
			
		||||
  dom.Document? _articleFragment;
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchArticle() async {
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/re/news/${widget.hash}');
 | 
			
		||||
      _article = SnNewsArticle.fromJson(resp.data);
 | 
			
		||||
      _articleFragment = parse(_article!.content);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err).then((_) {
 | 
			
		||||
@@ -45,104 +42,6 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  List<Widget> _parseHtmlToWidgets(Iterable<dom.Element>? elements) {
 | 
			
		||||
    if (elements == null) return [];
 | 
			
		||||
 | 
			
		||||
    final List<Widget> widgets = [];
 | 
			
		||||
 | 
			
		||||
    for (final node in elements) {
 | 
			
		||||
      switch (node.localName) {
 | 
			
		||||
        case 'h1':
 | 
			
		||||
        case 'h2':
 | 
			
		||||
        case 'h3':
 | 
			
		||||
        case 'h4':
 | 
			
		||||
        case 'h5':
 | 
			
		||||
        case 'h6':
 | 
			
		||||
          widgets.add(Text(node.text.trim(), style: Theme.of(context).textTheme.titleMedium));
 | 
			
		||||
          break;
 | 
			
		||||
        case 'p':
 | 
			
		||||
          if (node.text.trim().isEmpty) continue;
 | 
			
		||||
          widgets.add(
 | 
			
		||||
            Text.rich(
 | 
			
		||||
              TextSpan(
 | 
			
		||||
                text: node.text.trim(),
 | 
			
		||||
                children: [
 | 
			
		||||
                  for (final child in node.children)
 | 
			
		||||
                    switch (child.localName) {
 | 
			
		||||
                      'a' => TextSpan(
 | 
			
		||||
                          text: child.text.trim(),
 | 
			
		||||
                          style: const TextStyle(decoration: TextDecoration.underline),
 | 
			
		||||
                          recognizer: TapGestureRecognizer()
 | 
			
		||||
                            ..onTap = () {
 | 
			
		||||
                              launchUrlString(child.attributes['href']!);
 | 
			
		||||
                            },
 | 
			
		||||
                        ),
 | 
			
		||||
                      _ => TextSpan(text: child.text.trim()),
 | 
			
		||||
                    },
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
              style: Theme.of(context).textTheme.bodyLarge,
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
          break;
 | 
			
		||||
        case 'a':
 | 
			
		||||
          // drop single link
 | 
			
		||||
          break;
 | 
			
		||||
        case 'div':
 | 
			
		||||
          // ignore div text, normally it is not meaningful
 | 
			
		||||
          widgets.addAll(_parseHtmlToWidgets(node.children));
 | 
			
		||||
          break;
 | 
			
		||||
        case 'hr':
 | 
			
		||||
          widgets.add(const Divider());
 | 
			
		||||
          break;
 | 
			
		||||
        case 'img':
 | 
			
		||||
          var src = node.attributes['src'];
 | 
			
		||||
          if (src == null) break;
 | 
			
		||||
          final width = double.tryParse(node.attributes['width'] ?? 'null');
 | 
			
		||||
          final height = double.tryParse(node.attributes['height'] ?? 'null');
 | 
			
		||||
          final ratio = width != null && height != null ? width / height : 1.0;
 | 
			
		||||
          if (src.startsWith('//')) {
 | 
			
		||||
            src = 'https:$src';
 | 
			
		||||
          } else if (!src.startsWith('http')) {
 | 
			
		||||
            final baseUri = Uri.parse(_article!.url);
 | 
			
		||||
            final baseUrl = '${baseUri.scheme}://${baseUri.host}';
 | 
			
		||||
            src = '$baseUrl/$src';
 | 
			
		||||
          }
 | 
			
		||||
          widgets.add(
 | 
			
		||||
            AspectRatio(
 | 
			
		||||
              aspectRatio: ratio,
 | 
			
		||||
              child: Container(
 | 
			
		||||
                decoration: BoxDecoration(
 | 
			
		||||
                  borderRadius: BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                  border: Border.all(
 | 
			
		||||
                    color: Theme.of(context).dividerColor,
 | 
			
		||||
                    width: 1,
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
                height: height ?? double.infinity,
 | 
			
		||||
                child: ClipRRect(
 | 
			
		||||
                  borderRadius: BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                  child: Container(
 | 
			
		||||
                    color: Theme.of(context).colorScheme.surfaceContainer,
 | 
			
		||||
                    child: AutoResizeUniversalImage(
 | 
			
		||||
                      src,
 | 
			
		||||
                      fit: width != null && height != null ? BoxFit.cover : BoxFit.contain,
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
          break;
 | 
			
		||||
        default:
 | 
			
		||||
          widgets.addAll(_parseHtmlToWidgets(node.children));
 | 
			
		||||
          break;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return widgets;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
@@ -163,7 +62,9 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
 | 
			
		||||
          MaterialBanner(
 | 
			
		||||
            dividerColor: Colors.transparent,
 | 
			
		||||
            leading: const Icon(Icons.info),
 | 
			
		||||
            content: Text(_isReadingFromReader ? 'newsReadingFromReader'.tr() : 'newsReadingFromOriginal'.tr()),
 | 
			
		||||
            content: Text(_isReadingFromReader
 | 
			
		||||
                ? 'newsReadingFromReader'.tr()
 | 
			
		||||
                : 'newsReadingFromOriginal'.tr()),
 | 
			
		||||
            actions: [
 | 
			
		||||
              TextButton(
 | 
			
		||||
                child: Text('newsReadingProviderSwap').tr(),
 | 
			
		||||
@@ -173,7 +74,7 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
          if (_articleFragment != null && _isReadingFromReader)
 | 
			
		||||
          if (_article != null && _isReadingFromReader)
 | 
			
		||||
            Expanded(
 | 
			
		||||
              child: Container(
 | 
			
		||||
                constraints: BoxConstraints(maxWidth: 640),
 | 
			
		||||
@@ -182,28 +83,43 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
 | 
			
		||||
                    crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                    spacing: 8,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      Text(_article!.title, style: Theme.of(context).textTheme.titleLarge),
 | 
			
		||||
                      Text(_article!.title,
 | 
			
		||||
                          style: Theme.of(context).textTheme.titleLarge),
 | 
			
		||||
                      Builder(builder: (context) {
 | 
			
		||||
                        final htmlDescription = parse(_article!.description);
 | 
			
		||||
                        return Text(
 | 
			
		||||
                          htmlDescription.children.map((ele) => ele.text.trim()).join(),
 | 
			
		||||
                          htmlDescription.children
 | 
			
		||||
                              .map((ele) => ele.text.trim())
 | 
			
		||||
                              .join(),
 | 
			
		||||
                          style: Theme.of(context).textTheme.bodyMedium,
 | 
			
		||||
                        );
 | 
			
		||||
                      }),
 | 
			
		||||
                      Builder(builder: (context) {
 | 
			
		||||
                        final date = _article!.publishedAt ?? _article!.createdAt;
 | 
			
		||||
                        final date =
 | 
			
		||||
                            _article!.publishedAt ?? _article!.createdAt;
 | 
			
		||||
                        return Row(
 | 
			
		||||
                          spacing: 2,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            Text(DateFormat().format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
			
		||||
                            Text(' · ').textStyle(Theme.of(context).textTheme.bodySmall!).bold(),
 | 
			
		||||
                            Text(RelativeTime(context).format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
			
		||||
                            Text(DateFormat().format(date)).textStyle(
 | 
			
		||||
                                Theme.of(context).textTheme.bodySmall!),
 | 
			
		||||
                            Text(' · ')
 | 
			
		||||
                                .textStyle(
 | 
			
		||||
                                    Theme.of(context).textTheme.bodySmall!)
 | 
			
		||||
                                .bold(),
 | 
			
		||||
                            Text(RelativeTime(context).format(date)).textStyle(
 | 
			
		||||
                                Theme.of(context).textTheme.bodySmall!),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ).opacity(0.75);
 | 
			
		||||
                      }),
 | 
			
		||||
                      Text('newsDisclaimer').tr().textStyle(Theme.of(context).textTheme.bodySmall!).opacity(0.75),
 | 
			
		||||
                      Text('newsDisclaimer')
 | 
			
		||||
                          .tr()
 | 
			
		||||
                          .textStyle(Theme.of(context).textTheme.bodySmall!)
 | 
			
		||||
                          .opacity(0.75),
 | 
			
		||||
                      const Divider(),
 | 
			
		||||
                      ..._parseHtmlToWidgets(_articleFragment!.children),
 | 
			
		||||
                      MarkdownTextContent(
 | 
			
		||||
                        textScaler: TextScaler.linear(1.2),
 | 
			
		||||
                        content: html2md.convert(_article!.content),
 | 
			
		||||
                      ),
 | 
			
		||||
                      const Divider(),
 | 
			
		||||
                      InkWell(
 | 
			
		||||
                        child: Row(
 | 
			
		||||
@@ -211,7 +127,8 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
 | 
			
		||||
                          children: [
 | 
			
		||||
                            Text(
 | 
			
		||||
                              'Reference from original website',
 | 
			
		||||
                              style: TextStyle(decoration: TextDecoration.underline),
 | 
			
		||||
                              style: TextStyle(
 | 
			
		||||
                                  decoration: TextDecoration.underline),
 | 
			
		||||
                            ),
 | 
			
		||||
                            const Gap(4),
 | 
			
		||||
                            Icon(Icons.launch, size: 16),
 | 
			
		||||
 
 | 
			
		||||
@@ -29,6 +29,7 @@ const Map<String, IconData> kNotificationTopicIcons = {
 | 
			
		||||
  'passport.security.otp': Symbols.password,
 | 
			
		||||
  'interactive.subscription': Symbols.subscriptions,
 | 
			
		||||
  'interactive.feedback': Symbols.add_reaction,
 | 
			
		||||
  'interactive.reply': Symbols.reply,
 | 
			
		||||
  'messaging.callStart': Symbols.call_received,
 | 
			
		||||
  'wallet.transaction.new': Symbols.receipt,
 | 
			
		||||
};
 | 
			
		||||
@@ -57,11 +58,15 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final nty = context.read<NotificationProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/id/notifications?take=10');
 | 
			
		||||
      _totalCount = resp.data['count'];
 | 
			
		||||
      _notifications.addAll(
 | 
			
		||||
        resp.data['data']?.map((e) => SnNotification.fromJson(e)).cast<SnNotification>() ?? [],
 | 
			
		||||
      final resp = await sn.client.get(
 | 
			
		||||
        '/cgi/id/notifications',
 | 
			
		||||
        queryParameters: {'take': 10, 'offset': _notifications.length},
 | 
			
		||||
      );
 | 
			
		||||
      _totalCount = resp.data['count'];
 | 
			
		||||
      _notifications.addAll(resp.data['data']
 | 
			
		||||
              ?.map((e) => SnNotification.fromJson(e))
 | 
			
		||||
              .cast<SnNotification>() ??
 | 
			
		||||
          []);
 | 
			
		||||
      nty.updateTray();
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
@@ -97,8 +102,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar(
 | 
			
		||||
        'notificationMarkAllReadPrompt'.plural(resp.data['count']),
 | 
			
		||||
      );
 | 
			
		||||
          'notificationMarkAllReadPrompt'.plural(resp.data['count']));
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
@@ -123,8 +127,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar(
 | 
			
		||||
        'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']),
 | 
			
		||||
      );
 | 
			
		||||
          'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']));
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
@@ -146,12 +149,10 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
    if (!ua.isAuthorized) {
 | 
			
		||||
      return AppScaffold(
 | 
			
		||||
        appBar: AppBar(
 | 
			
		||||
          leading: AutoAppBarLeading(),
 | 
			
		||||
          leading: PageBackButton(),
 | 
			
		||||
          title: Text('screenNotification').tr(),
 | 
			
		||||
        ),
 | 
			
		||||
        body: Center(
 | 
			
		||||
          child: UnauthorizedHint(),
 | 
			
		||||
        ),
 | 
			
		||||
        body: Center(child: UnauthorizedHint()),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -161,9 +162,8 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
        title: Text('screenNotification').tr(),
 | 
			
		||||
        actions: [
 | 
			
		||||
          IconButton(
 | 
			
		||||
            icon: const Icon(Symbols.checklist),
 | 
			
		||||
            onPressed: _isSubmitting ? null : _markAllAsRead,
 | 
			
		||||
          ),
 | 
			
		||||
              icon: const Icon(Symbols.checklist),
 | 
			
		||||
              onPressed: _isSubmitting ? null : _markAllAsRead),
 | 
			
		||||
          const Gap(8),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
@@ -178,15 +178,16 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
              },
 | 
			
		||||
              child: InfiniteList(
 | 
			
		||||
                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,
 | 
			
		||||
                onFetchData: () {
 | 
			
		||||
                  _fetchNotifications();
 | 
			
		||||
                },
 | 
			
		||||
                isLoading: _isBusy,
 | 
			
		||||
                hasReachedMax: _totalCount != null && _notifications.length >= _totalCount!,
 | 
			
		||||
                hasReachedMax: _totalCount != null &&
 | 
			
		||||
                    _notifications.length >= _totalCount!,
 | 
			
		||||
                itemBuilder: (context, idx) {
 | 
			
		||||
                  final nty = _notifications[idx];
 | 
			
		||||
                  return Row(
 | 
			
		||||
@@ -200,50 +201,48 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
                          children: [
 | 
			
		||||
                            if (nty.readAt == null)
 | 
			
		||||
                              StyledWidget(Badge(
 | 
			
		||||
                                label: Text('notificationUnread').tr(),
 | 
			
		||||
                              )).padding(bottom: 4),
 | 
			
		||||
                            Text(
 | 
			
		||||
                              nty.title,
 | 
			
		||||
                              style: Theme.of(context).textTheme.titleMedium,
 | 
			
		||||
                            ),
 | 
			
		||||
                                      label: Text('notificationUnread').tr()))
 | 
			
		||||
                                  .padding(bottom: 4),
 | 
			
		||||
                            Text(nty.title,
 | 
			
		||||
                                style: Theme.of(context).textTheme.titleMedium),
 | 
			
		||||
                            if (nty.subtitle != null)
 | 
			
		||||
                              Text(
 | 
			
		||||
                                nty.subtitle!,
 | 
			
		||||
                                style: Theme.of(context).textTheme.titleSmall,
 | 
			
		||||
                              ),
 | 
			
		||||
                              Text(nty.subtitle!,
 | 
			
		||||
                                  style:
 | 
			
		||||
                                      Theme.of(context).textTheme.titleSmall),
 | 
			
		||||
                            if (nty.subtitle != null) const Gap(4),
 | 
			
		||||
                            SelectionArea(
 | 
			
		||||
                              child: MarkdownTextContent(
 | 
			
		||||
                                content: nty.body,
 | 
			
		||||
                                isAutoWarp: true,
 | 
			
		||||
                              ),
 | 
			
		||||
                            ),
 | 
			
		||||
                            if (['interactive.reply', 'interactive.feedback', 'interactive.subscription']
 | 
			
		||||
                                    .contains(nty.topic) &&
 | 
			
		||||
                                child: MarkdownTextContent(
 | 
			
		||||
                                    content: nty.body, isAutoWarp: true)),
 | 
			
		||||
                            if ([
 | 
			
		||||
                                  'interactive.reply',
 | 
			
		||||
                                  'interactive.feedback',
 | 
			
		||||
                                  'interactive.subscription',
 | 
			
		||||
                                ].contains(nty.topic) &&
 | 
			
		||||
                                nty.metadata['related_post'] != null)
 | 
			
		||||
                              GestureDetector(
 | 
			
		||||
                                child: Container(
 | 
			
		||||
                                  decoration: BoxDecoration(
 | 
			
		||||
                                    borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                                    borderRadius: const BorderRadius.all(
 | 
			
		||||
                                        Radius.circular(8)),
 | 
			
		||||
                                    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']!,
 | 
			
		||||
                                    ),
 | 
			
		||||
                                        nty.metadata['related_post']!),
 | 
			
		||||
                                    showComments: false,
 | 
			
		||||
                                    showReactions: false,
 | 
			
		||||
                                    showMenu: false,
 | 
			
		||||
                                  ),
 | 
			
		||||
                                  ).padding(vertical: 4),
 | 
			
		||||
                                ),
 | 
			
		||||
                                onTap: () {
 | 
			
		||||
                                  GoRouter.of(context).pushNamed(
 | 
			
		||||
                                    'postDetail',
 | 
			
		||||
                                    pathParameters: {
 | 
			
		||||
                                      'slug': nty.metadata['related_post']!['id'].toString(),
 | 
			
		||||
                                      'slug': nty
 | 
			
		||||
                                          .metadata['related_post']!['id']
 | 
			
		||||
                                          .toString()
 | 
			
		||||
                                    },
 | 
			
		||||
                                  );
 | 
			
		||||
                                },
 | 
			
		||||
@@ -251,18 +250,15 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
                            const Gap(8),
 | 
			
		||||
                            Row(
 | 
			
		||||
                              children: [
 | 
			
		||||
                                Text(
 | 
			
		||||
                                  DateFormat('yy/MM/dd').format(nty.createdAt),
 | 
			
		||||
                                ).fontSize(12),
 | 
			
		||||
                                Text(DateFormat('yy/MM/dd')
 | 
			
		||||
                                        .format(nty.createdAt))
 | 
			
		||||
                                    .fontSize(12),
 | 
			
		||||
                                const Gap(4),
 | 
			
		||||
                                Text(
 | 
			
		||||
                                  '·',
 | 
			
		||||
                                  style: TextStyle(fontSize: 12),
 | 
			
		||||
                                ),
 | 
			
		||||
                                Text('·', style: TextStyle(fontSize: 12)),
 | 
			
		||||
                                const Gap(4),
 | 
			
		||||
                                Text(
 | 
			
		||||
                                  RelativeTime(context).format(nty.createdAt),
 | 
			
		||||
                                ).fontSize(12),
 | 
			
		||||
                                Text(RelativeTime(context)
 | 
			
		||||
                                        .format(nty.createdAt))
 | 
			
		||||
                                    .fontSize(12),
 | 
			
		||||
                              ],
 | 
			
		||||
                            ).opacity(0.75),
 | 
			
		||||
                          ],
 | 
			
		||||
@@ -272,8 +268,10 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
                      IconButton(
 | 
			
		||||
                        icon: const Icon(Symbols.check),
 | 
			
		||||
                        padding: EdgeInsets.all(0),
 | 
			
		||||
                        visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
 | 
			
		||||
                        onPressed: _isSubmitting ? null : () => _markOneAsRead(nty),
 | 
			
		||||
                        visualDensity:
 | 
			
		||||
                            const VisualDensity(horizontal: -4, vertical: -4),
 | 
			
		||||
                        onPressed:
 | 
			
		||||
                            _isSubmitting ? null : () => _markOneAsRead(nty),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ).padding(horizontal: 16);
 | 
			
		||||
 
 | 
			
		||||
@@ -12,7 +12,6 @@ import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_background.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_comment_list.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_item.dart';
 | 
			
		||||
@@ -22,7 +21,8 @@ class PostDetailScreen extends StatefulWidget {
 | 
			
		||||
  final SnPost? preload;
 | 
			
		||||
  final Function? onBack;
 | 
			
		||||
 | 
			
		||||
  const PostDetailScreen({super.key, required this.slug, this.preload, this.onBack});
 | 
			
		||||
  const PostDetailScreen(
 | 
			
		||||
      {super.key, required this.slug, this.preload, this.onBack});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<PostDetailScreen> createState() => _PostDetailScreenState();
 | 
			
		||||
@@ -65,108 +65,111 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
 | 
			
		||||
 | 
			
		||||
    final double maxWidth = _data?.type == 'video' ? double.infinity : 640;
 | 
			
		||||
 | 
			
		||||
    return AppBackground(
 | 
			
		||||
      isRoot: widget.onBack != null,
 | 
			
		||||
      child: AppScaffold(
 | 
			
		||||
        appBar: AppBar(
 | 
			
		||||
          leading: BackButton(
 | 
			
		||||
            onPressed: () {
 | 
			
		||||
              if (widget.onBack != null) {
 | 
			
		||||
                widget.onBack!.call();
 | 
			
		||||
              }
 | 
			
		||||
              if (GoRouter.of(context).canPop()) {
 | 
			
		||||
                GoRouter.of(context).pop(context);
 | 
			
		||||
                return;
 | 
			
		||||
              }
 | 
			
		||||
              GoRouter.of(context).replaceNamed('explore');
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
          title: _data?.body['title'] != null
 | 
			
		||||
              ? RichText(
 | 
			
		||||
                  textAlign: TextAlign.center,
 | 
			
		||||
                  text: TextSpan(children: [
 | 
			
		||||
                    TextSpan(
 | 
			
		||||
                      text: _data?.body['title'] ?? 'postNoun'.tr(),
 | 
			
		||||
                      style: Theme.of(context).textTheme.titleLarge!.copyWith(
 | 
			
		||||
                            color: Theme.of(context).appBarTheme.foregroundColor!,
 | 
			
		||||
                          ),
 | 
			
		||||
                    ),
 | 
			
		||||
                    const TextSpan(text: '\n'),
 | 
			
		||||
                    TextSpan(
 | 
			
		||||
                      text: 'postDetail'.tr(),
 | 
			
		||||
                      style: Theme.of(context).textTheme.bodySmall!.copyWith(
 | 
			
		||||
                            color: Theme.of(context).appBarTheme.foregroundColor!,
 | 
			
		||||
                          ),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ]),
 | 
			
		||||
                  maxLines: 2,
 | 
			
		||||
                  overflow: TextOverflow.ellipsis,
 | 
			
		||||
                )
 | 
			
		||||
              : Text('postDetail').tr(),
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      noBackground: true,
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: BackButton(
 | 
			
		||||
          onPressed: () {
 | 
			
		||||
            if (widget.onBack != null) {
 | 
			
		||||
              widget.onBack!.call();
 | 
			
		||||
            }
 | 
			
		||||
            if (GoRouter.of(context).canPop()) {
 | 
			
		||||
              GoRouter.of(context).pop(context);
 | 
			
		||||
              return;
 | 
			
		||||
            }
 | 
			
		||||
            GoRouter.of(context).replaceNamed('explore');
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        body: CustomScrollView(
 | 
			
		||||
          slivers: [
 | 
			
		||||
            SliverToBoxAdapter(
 | 
			
		||||
              child: LoadingIndicator(isActive: _isBusy),
 | 
			
		||||
            ),
 | 
			
		||||
            if (_data != null)
 | 
			
		||||
              SliverToBoxAdapter(
 | 
			
		||||
                child: PostItem(
 | 
			
		||||
                  data: _data!,
 | 
			
		||||
                  maxWidth: maxWidth,
 | 
			
		||||
                  showComments: false,
 | 
			
		||||
                  showFullPost: true,
 | 
			
		||||
                  onChanged: (data) {
 | 
			
		||||
                    setState(() => _data = data);
 | 
			
		||||
                  },
 | 
			
		||||
                  onDeleted: () {
 | 
			
		||||
                    Navigator.pop(context);
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            if (_data != null && _data!.type != 'video') const SliverToBoxAdapter(child: Divider(height: 1)),
 | 
			
		||||
            if (_data != null && _data!.type != 'video')
 | 
			
		||||
              SliverToBoxAdapter(
 | 
			
		||||
                child: Container(
 | 
			
		||||
                  constraints: BoxConstraints(maxWidth: maxWidth),
 | 
			
		||||
                  child: Row(
 | 
			
		||||
                    crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      const Icon(Symbols.comment, size: 24),
 | 
			
		||||
                      const Gap(16),
 | 
			
		||||
                      Text('postCommentsDetailed')
 | 
			
		||||
                          .plural(_data!.metric.replyCount)
 | 
			
		||||
                          .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ).padding(horizontal: 20, vertical: 12).center(),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            if (_data != null && ua.isAuthorized && _data!.type != 'video')
 | 
			
		||||
              SliverToBoxAdapter(
 | 
			
		||||
                child: PostCommentQuickAction(
 | 
			
		||||
                  parentPost: _data!,
 | 
			
		||||
                  maxWidth: maxWidth,
 | 
			
		||||
                  onPosted: () {
 | 
			
		||||
                    setState(() {
 | 
			
		||||
                      _data = _data!.copyWith(
 | 
			
		||||
                        metric: _data!.metric.copyWith(
 | 
			
		||||
                          replyCount: _data!.metric.replyCount + 1,
 | 
			
		||||
        title: _data?.body['title'] != null
 | 
			
		||||
            ? RichText(
 | 
			
		||||
                textAlign: TextAlign.center,
 | 
			
		||||
                text: TextSpan(children: [
 | 
			
		||||
                  TextSpan(
 | 
			
		||||
                    text: _data?.body['title'] ?? 'postNoun'.tr(),
 | 
			
		||||
                    style: Theme.of(context).textTheme.titleLarge!.copyWith(
 | 
			
		||||
                          color: Theme.of(context).appBarTheme.foregroundColor!,
 | 
			
		||||
                        ),
 | 
			
		||||
                      );
 | 
			
		||||
                    });
 | 
			
		||||
                    _childListKey.currentState!.refresh();
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                  ),
 | 
			
		||||
                  const TextSpan(text: '\n'),
 | 
			
		||||
                  TextSpan(
 | 
			
		||||
                    text: 'postDetail'.tr(),
 | 
			
		||||
                    style: Theme.of(context).textTheme.bodySmall!.copyWith(
 | 
			
		||||
                          color: Theme.of(context).appBarTheme.foregroundColor!,
 | 
			
		||||
                        ),
 | 
			
		||||
                  ),
 | 
			
		||||
                ]),
 | 
			
		||||
                maxLines: 2,
 | 
			
		||||
                overflow: TextOverflow.ellipsis,
 | 
			
		||||
              )
 | 
			
		||||
            : Text('postDetail').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: CustomScrollView(
 | 
			
		||||
        slivers: [
 | 
			
		||||
          SliverToBoxAdapter(
 | 
			
		||||
            child: LoadingIndicator(isActive: _isBusy),
 | 
			
		||||
          ),
 | 
			
		||||
          if (_data != null)
 | 
			
		||||
            SliverToBoxAdapter(
 | 
			
		||||
              child: PostItem(
 | 
			
		||||
                data: _data!,
 | 
			
		||||
                maxWidth: maxWidth,
 | 
			
		||||
                showComments: false,
 | 
			
		||||
                showFullPost: true,
 | 
			
		||||
                onChanged: (data) {
 | 
			
		||||
                  setState(() => _data = data);
 | 
			
		||||
                },
 | 
			
		||||
                onDeleted: () {
 | 
			
		||||
                  Navigator.pop(context);
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
            if (_data != null && _data!.type != 'video')
 | 
			
		||||
              PostCommentSliverList(
 | 
			
		||||
                key: _childListKey,
 | 
			
		||||
            ),
 | 
			
		||||
          if (_data != null)
 | 
			
		||||
            SliverToBoxAdapter(
 | 
			
		||||
              child: Divider(height: 1).padding(top: 8),
 | 
			
		||||
            ),
 | 
			
		||||
          if (_data != null)
 | 
			
		||||
            SliverToBoxAdapter(
 | 
			
		||||
              child: Container(
 | 
			
		||||
                constraints: BoxConstraints(maxWidth: maxWidth),
 | 
			
		||||
                child: Row(
 | 
			
		||||
                  crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    const Icon(Symbols.comment, size: 24),
 | 
			
		||||
                    const Gap(16),
 | 
			
		||||
                    Text('postCommentsDetailed')
 | 
			
		||||
                        .plural(_data!.metric.replyCount)
 | 
			
		||||
                        .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
			
		||||
                  ],
 | 
			
		||||
                ).padding(horizontal: 20, vertical: 12).center(),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          if (_data != null && ua.isAuthorized)
 | 
			
		||||
            SliverToBoxAdapter(
 | 
			
		||||
              child: PostCommentQuickAction(
 | 
			
		||||
                parentPost: _data!,
 | 
			
		||||
                maxWidth: maxWidth,
 | 
			
		||||
                onPosted: () {
 | 
			
		||||
                  setState(() {
 | 
			
		||||
                    _data = _data!.copyWith(
 | 
			
		||||
                      metric: _data!.metric.copyWith(
 | 
			
		||||
                        replyCount: _data!.metric.replyCount + 1,
 | 
			
		||||
                      ),
 | 
			
		||||
                    );
 | 
			
		||||
                  });
 | 
			
		||||
                  _childListKey.currentState!.refresh();
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
            if (_data != null && _data!.type == 'video') SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
            ),
 | 
			
		||||
          if (_data != null) SliverGap(8),
 | 
			
		||||
          if (_data != null)
 | 
			
		||||
            PostCommentSliverList(
 | 
			
		||||
              key: _childListKey,
 | 
			
		||||
              parentPost: _data!,
 | 
			
		||||
              maxWidth: maxWidth,
 | 
			
		||||
            ),
 | 
			
		||||
          if (_data != null)
 | 
			
		||||
            SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user