Compare commits
	
		
			164 Commits
		
	
	
		
			2.2.2+62
			...
			92f7e92018
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| e2ecb573a2 | |||
| 8cb5dff498 | |||
| a5629975ed | |||
| 972b304969 | |||
| e8ded55055 | |||
| 04875eb164 | |||
| 54a59aa470 | |||
| 365f330629 | |||
| a7829d15b2 | |||
| a3868a4281 | |||
|  | 1d1d61d60c | ||
| 03c2491587 | |||
| 2c1adc988c | |||
| c0fbee55e4 | |||
| 6e544c0b6c | |||
| 7d56c5ef31 | |||
| c2df1af16d | |||
| a8143c6453 | |||
| 04065061e0 | |||
| 226eb452e5 | |||
| a6715b0872 | |||
| 43e3404dbb | |||
| c91cf7c813 | |||
|  | 9cd1cad695 | ||
|  | dde280833b | ||
| 42ac12b53e | |||
| 63567bf708 | |||
| 5d3cadefef | |||
| 251fbb2503 | |||
| 0b31d32217 | |||
| 5ddd4fed2e | |||
| 48b6d5f6c1 | |||
| b83b0b5efb | |||
| cb24bd953d | |||
| 4937dee182 | |||
| d612097bb1 | |||
| 058d668b6b | |||
| 8b19462c3a | |||
| 0a381ef09b | |||
| 9b84e912b2 | |||
| b3254e0f2f | |||
| f0a3bbe023 | |||
| df81c84438 | |||
| 8b12395fca | |||
| cb2b71d194 | |||
| 7ed508e2bb | |||
| dad869967e | |||
| 2d5b3b554e | |||
| 74882116e3 | |||
| a97c3bce3a | |||
| 1aa70827dc | |||
| fe028860e9 | |||
| a2d2ce4d38 | |||
| 167c11b9eb | |||
| 8cb3933fcc | |||
| 3818328afe | |||
| 11627e2455 | |||
| 3f82c06ff8 | |||
| 2350f59131 | |||
| 9fe7c9530a | 
							
								
								
									
										87
									
								
								.github/ISSUE_TEMPLATE/bug_report.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								.github/ISSUE_TEMPLATE/bug_report.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,87 @@ | |||||||
|  | name: Bug report | ||||||
|  | description: Create a report to help us address issues you are facing | ||||||
|  | title: "[Bug] " | ||||||
|  | labels: [Bug] | ||||||
|  | body: | ||||||
|  |   - type: markdown | ||||||
|  |     attributes: | ||||||
|  |       value: | | ||||||
|  |         Thanks for taking the time to make us better! | ||||||
|  |  | ||||||
|  |   - type: checkboxes | ||||||
|  |     id: duplication | ||||||
|  |     attributes: | ||||||
|  |       label: ⠀ | ||||||
|  |       options: | ||||||
|  |         - label: This issue is not duplicated with any other open or closed issues | ||||||
|  |           required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: description | ||||||
|  |     attributes: | ||||||
|  |       label: Describe the bug | ||||||
|  |       description: A clear and concise description of what the bug is | ||||||
|  |       placeholder: | | ||||||
|  |         Example: | ||||||
|  |           App crashes on startup every time after changing settings. | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: expected | ||||||
|  |     attributes: | ||||||
|  |       label: Expected behavior | ||||||
|  |       description: A clear and concise description of what you expected to happen | ||||||
|  |       placeholder: | | ||||||
|  |         Example: | ||||||
|  |           App started normally, everything worked fine. | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: reproduce | ||||||
|  |     attributes: | ||||||
|  |       label: Steps to reproduce | ||||||
|  |       description: Steps to reproduce the bug | ||||||
|  |       placeholder: | | ||||||
|  |         Example: | ||||||
|  |           1. Change "HyperNet Server" to "127.0.1" in "Network" settings | ||||||
|  |           2. Restart the app | ||||||
|  |           3. Crash | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: environment | ||||||
|  |     attributes: | ||||||
|  |       label: Device information | ||||||
|  |       description: Provide details about your system environment | ||||||
|  |       placeholder: | | ||||||
|  |         Example: | ||||||
|  |           Device: Google Pixel 8 Pro | ||||||
|  |           System: Baklava (BP22.250124.009) | ||||||
|  |           Version*: 2.3.2 | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: screenshots | ||||||
|  |     attributes: | ||||||
|  |       label: Screenshots | ||||||
|  |       description: If applicable, add screenshots to help explain your problem | ||||||
|  |       placeholder: | | ||||||
|  |         Example: | ||||||
|  |           setting_items.jpg | ||||||
|  |           crash_screen.jpg | ||||||
|  |     validations: | ||||||
|  |       required: false | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: additional | ||||||
|  |     attributes: | ||||||
|  |       label: Additional context | ||||||
|  |       description: Add any other context about the problem here | ||||||
|  |       placeholder: | | ||||||
|  |         Crash report or other useful informations | ||||||
|  |     validations: | ||||||
|  |       required: false | ||||||
							
								
								
									
										83
									
								
								.github/ISSUE_TEMPLATE/bug_report_zh.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								.github/ISSUE_TEMPLATE/bug_report_zh.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,83 @@ | |||||||
|  | name: 问题反馈 | ||||||
|  | description: 提交 Bug 或其它问题的反馈 | ||||||
|  | title: "[Bug] 标题" | ||||||
|  | labels: [Bug] | ||||||
|  | body: | ||||||
|  |   - type: markdown | ||||||
|  |     attributes: | ||||||
|  |       value: | | ||||||
|  |         非常感谢,你将要提交的反馈会让我们变得更好! | ||||||
|  |  | ||||||
|  |   - type: checkboxes | ||||||
|  |     id: duplication | ||||||
|  |     attributes: | ||||||
|  |       label: ⠀ | ||||||
|  |       options: | ||||||
|  |         - label: 我已经搜索并确认此 issue 不与其它任何 issue 重复 | ||||||
|  |           required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: description | ||||||
|  |     attributes: | ||||||
|  |       label: 问题描述 | ||||||
|  |       description: 清楚且详细地描述你遇到的 Bug 或问题 | ||||||
|  |       placeholder: | | ||||||
|  |         发生了什么?生动地描述你所看到的一切 | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: expected | ||||||
|  |     attributes: | ||||||
|  |       label: 期望表现 | ||||||
|  |       description: 清楚且详细地描述你期望发生的事 | ||||||
|  |       placeholder: | | ||||||
|  |         什么功能应该正常运行,运行后会有什么结果 | ||||||
|  |         什么界面应该正常显示,应该会显示什么内容 | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: reproduce | ||||||
|  |     attributes: | ||||||
|  |       label: 复现步骤 | ||||||
|  |       description: 能够复现问题的每一步 | ||||||
|  |       placeholder: | | ||||||
|  |         1. 尽可能详细地描述每一步 | ||||||
|  |         2. 更改的设置、添加的好友... | ||||||
|  |         3. 这里也可以描述你看到的界面 | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: environment | ||||||
|  |     attributes: | ||||||
|  |       label: 环境/版本 | ||||||
|  |       description: 提供运行时的环境信息 | ||||||
|  |       placeholder: | | ||||||
|  |         示例: | ||||||
|  |           设备型号: Google Pixel 8 Pro | ||||||
|  |           系统板本: Baklava (BP22.250124.009) | ||||||
|  |           程序版本: 2.3.2 | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: screenshots | ||||||
|  |     attributes: | ||||||
|  |       label: 屏幕截图/录制 | ||||||
|  |       description: 提供截屏或录屏来更好地描述问题 | ||||||
|  |       placeholder: | | ||||||
|  |         错误显示的界面/崩溃时的界面、先前改动的设置 | ||||||
|  |     validations: | ||||||
|  |       required: false | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: additional | ||||||
|  |     attributes: | ||||||
|  |       label: 更多信息 | ||||||
|  |       description: 任何与问题有关且有用的信息 | ||||||
|  |       placeholder: | | ||||||
|  |         崩溃报告、日志,或是你的用户名 | ||||||
|  |     validations: | ||||||
|  |       required: false | ||||||
							
								
								
									
										5
									
								
								.github/ISSUE_TEMPLATE/config.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.github/ISSUE_TEMPLATE/config.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | blank_issues_enabled: true | ||||||
|  | contact_links: | ||||||
|  |   - name: Solsynth Releases | ||||||
|  |     url: https://files.solsynth.dev/production01/solian | ||||||
|  |     about: Another place to download released apps | ||||||
							
								
								
									
										59
									
								
								.github/ISSUE_TEMPLATE/feature_request.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								.github/ISSUE_TEMPLATE/feature_request.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | |||||||
|  | name: Feature request | ||||||
|  | description: Suggest features you want to add or suggest to modify existing features | ||||||
|  | title: "[Feature] " | ||||||
|  | labels: [Feature] | ||||||
|  | body: | ||||||
|  |   - type: markdown | ||||||
|  |     attributes: | ||||||
|  |       value: | | ||||||
|  |         Thanks for taking the time to make us better! | ||||||
|  |  | ||||||
|  |   - type: checkboxes | ||||||
|  |     id: duplication | ||||||
|  |     attributes: | ||||||
|  |       label: ⠀ | ||||||
|  |       options: | ||||||
|  |         - label: This issue is not duplicated with any other open or closed issues | ||||||
|  |           required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: description | ||||||
|  |     attributes: | ||||||
|  |       label: Describe the feature | ||||||
|  |       description: A clear and concise description of what the feature is | ||||||
|  |       placeholder: | | ||||||
|  |         Example: | ||||||
|  |           A Quick Settings tile to start the service, long press to launch the app. | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: reasons | ||||||
|  |     attributes: | ||||||
|  |       label: Reason for adding | ||||||
|  |       description: Explain why this feature would be useful to you | ||||||
|  |       placeholder: | | ||||||
|  |         Example: | ||||||
|  |           Start the service quickly from the Quick Settings tile and save lots of time. | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: examples | ||||||
|  |     attributes: | ||||||
|  |       label: Example(s) | ||||||
|  |       description: Post screenshots/drawings/links/etc of the feature request, or proof-of-concept images about the feature | ||||||
|  |       placeholder: | | ||||||
|  |         Example: | ||||||
|  |           shazam_toggle.jpg | ||||||
|  |           nekobox_switch.jpg | ||||||
|  |     validations: | ||||||
|  |       required: false | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: additional | ||||||
|  |     attributes: | ||||||
|  |       label: Additional context | ||||||
|  |       description: Add any other context about the feature here | ||||||
|  |     validations: | ||||||
|  |       required: false | ||||||
							
								
								
									
										49
									
								
								.github/ISSUE_TEMPLATE/feature_request_zh.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								.github/ISSUE_TEMPLATE/feature_request_zh.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | |||||||
|  | name: 功能建议 | ||||||
|  | description: 提出你想要添加或更改的功能 | ||||||
|  | title: "[Feature] 标题" | ||||||
|  | labels: [Feature] | ||||||
|  | body: | ||||||
|  |   - type: markdown | ||||||
|  |     attributes: | ||||||
|  |       value: | | ||||||
|  |         非常感谢,你将要提交的请求会让我们变得更好! | ||||||
|  |  | ||||||
|  |   - type: checkboxes | ||||||
|  |     id: duplication | ||||||
|  |     attributes: | ||||||
|  |       label: ⠀ | ||||||
|  |       options: | ||||||
|  |         - label: 我已经搜索并确认此 issue 不与其它任何 issue 重复 | ||||||
|  |           required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: description | ||||||
|  |     attributes: | ||||||
|  |       label: 功能描述 | ||||||
|  |       description: 清楚且详细地描述要添加/更改后的功能 | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: reasons | ||||||
|  |     attributes: | ||||||
|  |       label: 添加/更改理由 | ||||||
|  |       description: 解释为什么要这样做,对用户有什么好处 | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: examples | ||||||
|  |     attributes: | ||||||
|  |       label: 功能示例 | ||||||
|  |       description: 相似/已存在功能的截图,或画出大致的界面 | ||||||
|  |     validations: | ||||||
|  |       required: false | ||||||
|  |  | ||||||
|  |   - type: textarea | ||||||
|  |     id: additional | ||||||
|  |     attributes: | ||||||
|  |       label: 更多信息 | ||||||
|  |       description: 任何与功能有关且有用的信息,或已存在功能的代码/仓库 | ||||||
|  |     validations: | ||||||
|  |       required: false | ||||||
							
								
								
									
										24
									
								
								.github/workflows/nightly.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										24
									
								
								.github/workflows/nightly.yml
									
									
									
									
										vendored
									
									
								
							| @@ -39,3 +39,27 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           name: build-output-windows |           name: build-output-windows | ||||||
|           path: build/windows/x64/runner/Release |           path: build/windows/x64/runner/Release | ||||||
|  |   build-linux: | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     steps: | ||||||
|  |       - name: Clone repository | ||||||
|  |         uses: actions/checkout@v4 | ||||||
|  |       - name: Set up Flutter | ||||||
|  |         uses: subosito/flutter-action@v2 | ||||||
|  |         with: | ||||||
|  |           channel: stable | ||||||
|  |           cache: true | ||||||
|  |       - run: | | ||||||
|  |           sudo apt-get update -y | ||||||
|  |           sudo apt-get install -y ninja-build libgtk-3-dev | ||||||
|  |           sudo apt-get install libmpv-dev mpv | ||||||
|  |           sudo apt-get install libayatana-appindicator3-dev | ||||||
|  |           sudo apt-get install keybinder-3.0 | ||||||
|  |           sudo apt-get install libnotify-dev | ||||||
|  |       - run: flutter pub get | ||||||
|  |       - run: flutter build linux | ||||||
|  |       - name: Archive production artifacts | ||||||
|  |         uses: actions/upload-artifact@v4 | ||||||
|  |         with: | ||||||
|  |           name: build-output-linux | ||||||
|  |           path: build/linux/x64/release/bundle | ||||||
							
								
								
									
										27
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | |||||||
|  | # Solar Network | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Hello there! Welcome to the main repository of the HyperNet (also known as the Solar Network). The code here is mainly about the frontend app (also known as Solian). But you can still post issues here to get help and request new features! | ||||||
|  |  | ||||||
|  | ## Sub Projects | ||||||
|  |  | ||||||
|  | HyperNet, the Solar Network is a microservices project in which the backends are stored in separate repositories. Here is a simple index for it. | ||||||
|  |  | ||||||
|  | - The Core, Gateway: [Nexus](https://github.com/Solsynth/HyperNet.Nexus) | ||||||
|  | - The Auth Service: [Passport](https://github.com/Solsynth/HyperNet.Passport) | ||||||
|  | - The Posting Service: [Interactive](https://github.com/Solsynth/HyperNet.Interactive) | ||||||
|  | - The Messaging Service: [Messaging](https://github.com/Solsynth/HyperNet.Messaging) | ||||||
|  | - The Wallet Service: [Wallet](https://github.com/Solsynth/HyperNet.Wallet) | ||||||
|  | - The Crawler: [Reader](https://github.com/Solsynth/HyperNet.Reader) | ||||||
|  | - Some others may not be listed, you can search in the organization with `HyperNet.` the prefix of all HyperNet projects. | ||||||
|  |  | ||||||
|  | ## Tech Stack | ||||||
|  |  | ||||||
|  | For those people who want to know the tech stack of this project, the frontend was built by Flutter, which provides the cross-platform ability. | ||||||
|  |  | ||||||
|  | The backend was built in Go and PostgreSQL with our very own microservice framework included in the nexus. | ||||||
|  |  | ||||||
|  | ----- | ||||||
|  |  | ||||||
|  | The readme will be updated in the future, to be determined. For now, you can check out the link of this repository to learn more on our official website. | ||||||
| @@ -17,7 +17,6 @@ | |||||||
|         android:label="Solian" |         android:label="Solian" | ||||||
|         android:name="${applicationName}" |         android:name="${applicationName}" | ||||||
|         android:icon="@mipmap/ic_launcher" |         android:icon="@mipmap/ic_launcher" | ||||||
|         android:enableOnBackInvokedCallback="true" |  | ||||||
|         android:requestLegacyExternalStorage="true"> |         android:requestLegacyExternalStorage="true"> | ||||||
|         <meta-data |         <meta-data | ||||||
|             android:name="flutterEmbedding" |             android:name="flutterEmbedding" | ||||||
|   | |||||||
| @@ -54,7 +54,7 @@ class CheckInWidget : GlanceAppWidget() { | |||||||
|                 .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) |                 .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) | ||||||
|                 .registerTypeAdapter(Instant::class.java, InstantAdapter()) |                 .registerTypeAdapter(Instant::class.java, InstantAdapter()) | ||||||
|                 .create() |                 .create() | ||||||
|         val resultTierSymbols = listOf("大凶", "凶", "中平", "吉", "大吉") |         val resultTierSymbols = listOf("Bad", "Poor", "Medium", "Good", "Great") | ||||||
|  |  | ||||||
|         val prefs = currentState.preferences |         val prefs = currentState.preferences | ||||||
|         val checkInRaw: String? = prefs.getString("pas_check_in_record", null) |         val checkInRaw: String? = prefs.getString("pas_check_in_record", null) | ||||||
| @@ -120,7 +120,7 @@ class CheckInWidget : GlanceAppWidget() { | |||||||
|             } |             } | ||||||
|  |  | ||||||
|             Text( |             Text( | ||||||
|                 text = "You haven't checked in today", |                 text = "You haven't divined today", | ||||||
|                 style = TextStyle(fontSize = 15.sp, color = GlanceTheme.colors.onSurface) |                 style = TextStyle(fontSize = 15.sp, color = GlanceTheme.colors.onSurface) | ||||||
|             ) |             ) | ||||||
|         } |         } | ||||||
|   | |||||||
							
								
								
									
										11
									
								
								api/Nexus/Check Status.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								api/Nexus/Check Status.bru
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | meta { | ||||||
|  |   name: Check Status | ||||||
|  |   type: http | ||||||
|  |   seq: 1 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | get { | ||||||
|  |   url: {{endpoint}}/directory/status | ||||||
|  |   body: none | ||||||
|  |   auth: none | ||||||
|  | } | ||||||
							
								
								
									
										11
									
								
								api/Nexus/List Services.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								api/Nexus/List Services.bru
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | meta { | ||||||
|  |   name: List Services | ||||||
|  |   type: http | ||||||
|  |   seq: 2 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | get { | ||||||
|  |   url: {{endpoint}}/directory/services | ||||||
|  |   body: none | ||||||
|  |   auth: none | ||||||
|  | } | ||||||
| @@ -12,9 +12,9 @@ post { | |||||||
|  |  | ||||||
| body:json { | body:json { | ||||||
|   { |   { | ||||||
|     "alias": "Meltdown", |     "alias": "Deadge", | ||||||
|     "name": "Meltdown", |     "name": "Dead", | ||||||
|     "attachment_id": "IpDPHEbWDDCbBofX", |     "attachment_id": "pcbFd0u4zgdM39HM", | ||||||
|     "pack_id": 4 |     "pack_id": 4 | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										18
									
								
								api/Passport/Deal Abuse Report.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								api/Passport/Deal Abuse Report.bru
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | meta { | ||||||
|  |   name: Deal Abuse Report | ||||||
|  |   type: http | ||||||
|  |   seq: 3 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | put { | ||||||
|  |   url: {{endpoint}}/cgi/id/reports/abuse/3/status | ||||||
|  |   body: json | ||||||
|  |   auth: inherit | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body:json { | ||||||
|  |   { | ||||||
|  |     "status": "processed", | ||||||
|  |     "message": "相关附件已经进行评级处理,未来会将该项权限下放到帖主以及社区成员。" | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -5,7 +5,7 @@ meta { | |||||||
| } | } | ||||||
|  |  | ||||||
| post { | post { | ||||||
|   url: {{endpoint}}/cgi/id/dev/notify/1 |   url: {{endpoint}}/cgi/id/dev/notify/328 | ||||||
|   body: json |   body: json | ||||||
|   auth: inherit |   auth: inherit | ||||||
| } | } | ||||||
| @@ -15,12 +15,9 @@ body:json { | |||||||
|     "client_id": "{{third_client_id}}", |     "client_id": "{{third_client_id}}", | ||||||
|     "client_secret":"{{third_client_tk}}", |     "client_secret":"{{third_client_tk}}", | ||||||
|     "type": "general", |     "type": "general", | ||||||
|     "subject": "测试", |     "subject": "处理该发布者 @vedal987 的决定", | ||||||
|     "subtitle": "Alphabot です", |     "subtitle": "一条来自 Solar Network 客户支持的信息", | ||||||
|     "content": "全新通知动画", |     "content": "您的发布者违反了我们用户协议中的「禁止冒充他人」的相关条例,经管理决定,将相关内容隐藏。冒充他人的判定无论作者是否有主观意志,只要造成了误解我们就有责任处理。希望您能理解,本次决定未作出任何帐号相关的连带处罚。", | ||||||
|     "metadata": { |  | ||||||
|       "image": "D2EDbcrsTugs3xk5" |  | ||||||
|     }, |  | ||||||
|     "priority": 10 |     "priority": 10 | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -12,10 +12,10 @@ post { | |||||||
|  |  | ||||||
| body:json { | body:json { | ||||||
|   { |   { | ||||||
|     "client_id": "highland-mc", |     "client_id": "alphabot", | ||||||
|     "client_secret": "(3^DLAvo3v", |     "client_secret": "_uR0sVnHTh", | ||||||
|     "remark": "秦始皇的赞赏", |     "remark": "新年红包", | ||||||
|     "amount": 500, |     "amount": 150, | ||||||
|     "payee_id": 1 |     "payee_id": 18 | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Bold.ttf
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Bold.ttf
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Italic.ttf
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Italic.ttf
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Regular.ttf
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/fonts/Nunito-Regular.ttf
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							| @@ -27,6 +27,7 @@ | |||||||
|   "screenChatNew": "New Channel", |   "screenChatNew": "New Channel", | ||||||
|   "screenRealm": "Realm", |   "screenRealm": "Realm", | ||||||
|   "screenRealmManage": "Edit Realm", |   "screenRealmManage": "Edit Realm", | ||||||
|  |   "screenRealmDiscovery": "Realm Discovery", | ||||||
|   "screenRealmNew": "New Realm", |   "screenRealmNew": "New Realm", | ||||||
|   "screenNotification": "Notification", |   "screenNotification": "Notification", | ||||||
|   "screenPostSearch": "Search Posts", |   "screenPostSearch": "Search Posts", | ||||||
| @@ -152,11 +153,19 @@ | |||||||
|   "publisherRunBy": "Run by {}", |   "publisherRunBy": "Run by {}", | ||||||
|   "fieldPublisherBelongToRealm": "Belongs to", |   "fieldPublisherBelongToRealm": "Belongs to", | ||||||
|   "fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm", |   "fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm", | ||||||
|  |   "writePost": "Compose", | ||||||
|  |   "postTypeStory": "Story", | ||||||
|  |   "postTypeArticle": "Article", | ||||||
|  |   "postTypeQuestion": "Question", | ||||||
|  |   "postTypeVideo": "Video", | ||||||
|   "writePostTypeStory": "Post a story", |   "writePostTypeStory": "Post a story", | ||||||
|   "writePostTypeArticle": "Write an article", |   "writePostTypeArticle": "Write an article", | ||||||
|  |   "writePostTypeQuestion": "Ask a question", | ||||||
|  |   "writePostTypeVideo": "Post a video", | ||||||
|   "fieldPostPublisher": "Post publisher", |   "fieldPostPublisher": "Post publisher", | ||||||
|   "fieldPostContent": "What happened?!", |   "fieldPostContent": "What happened?!", | ||||||
|   "fieldPostTitle": "Title", |   "fieldPostTitle": "Title", | ||||||
|  |   "fieldPostQuestionReward": "Answer Rewards (Source Points)", | ||||||
|   "fieldPostDescription": "Description", |   "fieldPostDescription": "Description", | ||||||
|   "fieldPostTags": "Tags", |   "fieldPostTags": "Tags", | ||||||
|   "fieldPostCategories": "Categories", |   "fieldPostCategories": "Categories", | ||||||
| @@ -166,9 +175,9 @@ | |||||||
|   "postPosted": "Post has been posted.", |   "postPosted": "Post has been posted.", | ||||||
|   "postPublishedAt": "Published At", |   "postPublishedAt": "Published At", | ||||||
|   "postPublishedUntil": "Published Until", |   "postPublishedUntil": "Published Until", | ||||||
|   "postEditingNotice": "You're about to editing a post that posted {}.", |   "postEditingNotice": "You're about to editing a post that posted by {}.", | ||||||
|   "postReplyingNotice": "You're about to reply to a post that posted {}.", |   "postReplyingNotice": "You're about to reply to a post that posted by {}.", | ||||||
|   "postRepostingNotice": "You're about to repost a post that posted {}.", |   "postRepostingNotice": "You're about to repost a post that posted by {}.", | ||||||
|   "postReact": "React", |   "postReact": "React", | ||||||
|   "postReactions": "Reactions of Post", |   "postReactions": "Reactions of Post", | ||||||
|   "postReactionUpvote": { |   "postReactionUpvote": { | ||||||
| @@ -199,6 +208,11 @@ | |||||||
|     "other": "{} comments" |     "other": "{} comments" | ||||||
|   }, |   }, | ||||||
|   "settingsAppearance": "Appearance", |   "settingsAppearance": "Appearance", | ||||||
|  |   "settingsCustomFonts": "Custom Fonts", | ||||||
|  |   "settingsCustomFontsDescription": "Set custom fonts for the application.", | ||||||
|  |   "settingsCustomFontFamily": "Custom Font Family", | ||||||
|  |   "settingsCustomFontFamilyHint": "Use comma to separate fonts, higher priority comes first", | ||||||
|  |   "settingsCustomFontApplied": "Custom font has been applied.", | ||||||
|   "settingsDisplayLanguage": "Display Language", |   "settingsDisplayLanguage": "Display Language", | ||||||
|   "settingsDisplayLanguageDescription": "Set the application language.", |   "settingsDisplayLanguageDescription": "Set the application language.", | ||||||
|   "settingsDisplayLanguageSystem": "Follow System", |   "settingsDisplayLanguageSystem": "Follow System", | ||||||
| @@ -329,6 +343,7 @@ | |||||||
|   "addAttachmentFromRandomId": "Link via RID", |   "addAttachmentFromRandomId": "Link via RID", | ||||||
|   "attachmentDetailInfo": "Attachment details", |   "attachmentDetailInfo": "Attachment details", | ||||||
|   "attachmentPastedImage": "Pasted Image", |   "attachmentPastedImage": "Pasted Image", | ||||||
|  |   "attachmentInsertedImage": "Inserted Image", | ||||||
|   "attachmentInsertLink": "Insert Link", |   "attachmentInsertLink": "Insert Link", | ||||||
|   "attachmentSetAsPostThumbnail": "Set as post thumbnail", |   "attachmentSetAsPostThumbnail": "Set as post thumbnail", | ||||||
|   "attachmentUnsetAsPostThumbnail": "Unset as post thumbnail", |   "attachmentUnsetAsPostThumbnail": "Unset as post thumbnail", | ||||||
| @@ -415,7 +430,7 @@ | |||||||
|   "callMessageEnded": "Call lasted {}", |   "callMessageEnded": "Call lasted {}", | ||||||
|   "callMessageStarted": "Call started", |   "callMessageStarted": "Call started", | ||||||
|   "dailyCheckIn": "Check In", |   "dailyCheckIn": "Check In", | ||||||
|   "dailyCheckInNone": "You haven't checked in today", |   "dailyCheckInNone": "You haven't divined today", | ||||||
|   "dailyCheckAction": "Check in right now!", |   "dailyCheckAction": "Check in right now!", | ||||||
|   "dailyCheckDetail": "Can't understand the symbol? Master, help me understand it!", |   "dailyCheckDetail": "Can't understand the symbol? Master, help me understand it!", | ||||||
|   "dailyCheckDetailTitle": "{}'s fortune details", |   "dailyCheckDetailTitle": "{}'s fortune details", | ||||||
| @@ -507,8 +522,13 @@ | |||||||
|   "accountBirthday": "Born on {}", |   "accountBirthday": "Born on {}", | ||||||
|   "accountBadge": "Badge", |   "accountBadge": "Badge", | ||||||
|   "accountCheckInNoRecords": "No check-in records", |   "accountCheckInNoRecords": "No check-in records", | ||||||
|   "badgeCompanyStaff": "Solsynth Staff", |   "badgeCompanyStaff": "Staff", | ||||||
|   "badgeSiteMigration": "Solar Network Native", |   "badgeSiteMigration": "Solar Network Native", | ||||||
|  |   "badgeCommunitySurvey": "Survey Participant", | ||||||
|  |   "badgeCommunityVerified": "Verified User", | ||||||
|  |   "badgeCommunityContributor": "Great Contributor", | ||||||
|  |   "badgeSiteAnniversary": "Anniversary", | ||||||
|  |   "badgeUserBirthday": "Birthday", | ||||||
|   "accountStatus": "Status", |   "accountStatus": "Status", | ||||||
|   "accountStatusOnline": "Online", |   "accountStatusOnline": "Online", | ||||||
|   "accountStatusOffline": "Offline", |   "accountStatusOffline": "Offline", | ||||||
| @@ -543,6 +563,7 @@ | |||||||
|   "termAcceptNextWithAgree": "By clicking the \"Next\", it means you agree to our terms and its updates.", |   "termAcceptNextWithAgree": "By clicking the \"Next\", it means you agree to our terms and its updates.", | ||||||
|   "unauthorized": "Unauthorized", |   "unauthorized": "Unauthorized", | ||||||
|   "unauthorizedDescription": "Login to explore the entire Solar Network.", |   "unauthorizedDescription": "Login to explore the entire Solar Network.", | ||||||
|  |   "projectDetail": "Project Details", | ||||||
|   "serviceStatus": "Service Status", |   "serviceStatus": "Service Status", | ||||||
|   "termRelated": "Related Terms", |   "termRelated": "Related Terms", | ||||||
|   "appDetails": "App Details", |   "appDetails": "App Details", | ||||||
| @@ -578,6 +599,7 @@ | |||||||
|   "colorSchemeBlack": "Black", |   "colorSchemeBlack": "Black", | ||||||
|   "colorSchemeApplied": "Color scheme has been applied, may need restart the app to take effect.", |   "colorSchemeApplied": "Color scheme has been applied, may need restart the app to take effect.", | ||||||
|   "postFeaturedComment": "Featured Comment", |   "postFeaturedComment": "Featured Comment", | ||||||
|  |   "postCategory": "Category", | ||||||
|   "postCategoryTechnology": "Technology", |   "postCategoryTechnology": "Technology", | ||||||
|   "postCategoryGaming": "Gaming", |   "postCategoryGaming": "Gaming", | ||||||
|   "postCategoryLife": "Life", |   "postCategoryLife": "Life", | ||||||
| @@ -610,5 +632,141 @@ | |||||||
|   }, |   }, | ||||||
|   "aiThinkingProcess": "AI Thinking Process", |   "aiThinkingProcess": "AI Thinking Process", | ||||||
|   "accountSettingsApplied": "Account settings have been applied.", |   "accountSettingsApplied": "Account settings have been applied.", | ||||||
|   "trayMenuExit": "Exit" |   "trayMenuExit": "Exit", | ||||||
|  |   "postQuestionUnanswered": "Unanswered Question", | ||||||
|  |   "postQuestionUnansweredWithReward": "Unanswered Question, reward source points {}", | ||||||
|  |   "postQuestionAnswered": "Answered Question", | ||||||
|  |   "postQuestionAnswerSelect": "Select as Answer", | ||||||
|  |   "postQuestionAnswerSelected": "Answer has been selected, reward has been applied.", | ||||||
|  |   "postVideoUpload": "Upload Video", | ||||||
|  |   "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", | ||||||
|  |   "pollEditorEdit": "Edit Poll", | ||||||
|  |   "pollEditorDelete": "Delete Poll", | ||||||
|  |   "pollEditorDeleteDescription": "Are you sure you want to delete this poll? This operation is irreversible.", | ||||||
|  |   "pollEditorUnlink": "Unlink Poll", | ||||||
|  |   "pollOptionAdd": "Add Option", | ||||||
|  |   "pollOptionName": "Option Name", | ||||||
|  |   "pollLinkExisting": "Link existing poll", | ||||||
|  |   "pollAnswered": "Answered the poll.", | ||||||
|  |   "pollVotes": { | ||||||
|  |     "one": "{} vote", | ||||||
|  |     "other": "{} votes" | ||||||
|  |   }, | ||||||
|  |   "publisherDelete": "Delete Publisher {}", | ||||||
|  |   "publisherDeleteDescription": "Are you sure you want to delete this publisher? This operation is irreversible.", | ||||||
|  |   "channelIsPublic": "Public Channel", | ||||||
|  |   "channelIsPublicDescription": "The channel is public, anyone can join.", | ||||||
|  |   "channelIsCommunity": "Community Channel", | ||||||
|  |   "channelIsCommunityDescription": "Currently, community channel has nothing special yet.", | ||||||
|  |   "realmIsPublic": "Public Realm", | ||||||
|  |   "realmIsPublicDescription": "The realm is public, anyone can join.", | ||||||
|  |   "realmIsCommunity": "Community Realm", | ||||||
|  |   "realmIsCommunityDescription": "Community realm will be displayed on the discover page.", | ||||||
|  |   "realmLeave": "Leave Realm", | ||||||
|  |   "realmLeaveDescription": "Leave the current realm and delete the realm's identity.", | ||||||
|  |   "checkInResultTier1": "Worst", | ||||||
|  |   "checkInResultTier2": "Worse", | ||||||
|  |   "checkInResultTier3": "Normal", | ||||||
|  |   "checkInResultTier4": "Better", | ||||||
|  |   "checkInResultTier5": "Best", | ||||||
|  |   "flagPostAction": "Flag the Post", | ||||||
|  |   "flagPost": "Flag objectionable content", | ||||||
|  |   "flagPostDescription": "If flagged users takes 50% or more of the views, the post will be collapsed. You cannot revoke the action.", | ||||||
|  |   "flaggedPost": "Post has been flagged.", | ||||||
|  |   "postViews": { | ||||||
|  |     "zero": "No views", | ||||||
|  |     "one": "{} view", | ||||||
|  |     "other": "{} views" | ||||||
|  |   }, | ||||||
|  |   "attachmentBillingUploaded": "Used space", | ||||||
|  |   "attachmentBillingDiscount": "Free space", | ||||||
|  |   "attachmentBillingRatio": "Usage", | ||||||
|  |   "attachmentBillingHint": "Sliding Window Pricing®\nFees will only apply if the size of the file uploaded within 24 hours exceeds the free space.", | ||||||
|  |   "postThumbnail": "Post Thumbnail", | ||||||
|  |   "accountRealms": "Realms", | ||||||
|  |   "postInGlobal": "Global", | ||||||
|  |   "postInGlobalDescription": "Do not link this post with any realm.", | ||||||
|  |   "postChannelGlobal": "Global", | ||||||
|  |   "postChannelFriends": "Friends", | ||||||
|  |   "postChannelFollowing": "Following", | ||||||
|  |   "postChannelRealm": "Realms", | ||||||
|  |   "postFilterReset": "Reset Filter", | ||||||
|  |   "postFilterResetDescription": "Clear filter and show all posts.", | ||||||
|  |   "postFilterWithCategory": "Viewing posts in {}", | ||||||
|  |   "databaseSize": "Database Size", | ||||||
|  |   "databaseDelete": "Delete Database", | ||||||
|  |   "databaseDeleteDescription": "Remove the database on your local disk, the content will be fetched from server again.", | ||||||
|  |   "databaseDeleted": "The local database has been deleted.", | ||||||
|  |   "settingsEnablePushNotifications": "Enable Push Notifications", | ||||||
|  |   "settingsEnablePushNotificationsDescription": "Re-enable and request permission to receive push notifications. Just in case it didn't run automatically.", | ||||||
|  |   "settingsEnabledPushNotifications": "Push notification has been enabled.", | ||||||
|  |   "screenStickers": "Stickers", | ||||||
|  |   "stickersDiscovery": "Discovery", | ||||||
|  |   "stickersOwned": "Owned", | ||||||
|  |   "stickersCreated": "Created", | ||||||
|  |   "stickersAdd": "Add Sticker Pack", | ||||||
|  |   "stickersAdded": "Sticker pack has been added.", | ||||||
|  |   "add": "Add", | ||||||
|  |   "stickersRemoved": "Sticker pack has been removed, you can add it again anytime.", | ||||||
|  |   "stickersReload": "Reload Stickers", | ||||||
|  |   "stickersReloadDescription": "Reload stickers from the server, update the sticker picker.", | ||||||
|  |   "stickersReloaded": "Sticker packs has been reloaded.", | ||||||
|  |   "stickersPackDelete": "Delete Pack {}", | ||||||
|  |   "stickersPackDeleteDescription": "Are you sure you want to delete this sticker pack? This operation is irreversible.", | ||||||
|  |   "stickersPackDeleted": "Sticker pack has been deleted.", | ||||||
|  |   "stickersDelete": "Delete Sticker {}", | ||||||
|  |   "stickersDeleteDescription": "Are you sure you want to delete this sticker? This operation is irreversible.", | ||||||
|  |   "stickersDeleted": "Sticker has been deleted.", | ||||||
|  |   "fieldStickerName": "Sticker Name", | ||||||
|  |   "fieldStickerAlias": "Sticker Alias", | ||||||
|  |   "fieldStickerAliasHint": "The unique sticker placeholder with the pack prefix.", | ||||||
|  |   "fieldStickerPackName": "Name", | ||||||
|  |   "fieldStickerPackDescription": "Description", | ||||||
|  |   "fieldStickerPackPrefix": "Prefix", | ||||||
|  |   "fieldStickerAttachment": "Attachment", | ||||||
|  |   "stickersNew": "New Sticker", | ||||||
|  |   "stickersNewDescription": "Create a new sticker belongs to this pack.", | ||||||
|  |   "stickersPackNew": "New Sticker Pack", | ||||||
|  |   "trayMenuShow": "Show", | ||||||
|  |   "trayMenuMuteNotification": "Do Not Disturb", | ||||||
|  |   "update": "Update", | ||||||
|  |   "forceUpdate": "Force Update", | ||||||
|  |   "forceUpdateDescription": "Force to show the application update popup, even the new version is not available.", | ||||||
|  |   "debugLogging": "Runtime Logs", | ||||||
|  |   "runtimeLogsOpen": "Open Logs", | ||||||
|  |   "runtimeLogsDescription": "Show the runtime logs to help debugging.", | ||||||
|  |   "signinResetPasswordHint": "Please enter the username / email address to help us to find your account and reset your password.", | ||||||
|  |   "cacheSize": "Cache Size", | ||||||
|  |   "cacheDelete": "Clean Cache", | ||||||
|  |   "cacheDeleteDescription": "Remove the cached images and other resources from your disk, the content will be downloaded from server again.", | ||||||
|  |   "cacheDeleted": "All cache has been cleaned up.", | ||||||
|  |   "userNoDescription": "No description.", | ||||||
|  |   "fieldTimeZone": "Time Zone", | ||||||
|  |   "fieldGender": "Gender", | ||||||
|  |   "fieldPronouns": "Pronouns", | ||||||
|  |   "fieldLocation": "Location", | ||||||
|  |   "fieldLinks": "Links", | ||||||
|  |   "fieldLinkName": "Name", | ||||||
|  |   "fieldLinkUrl": "URL", | ||||||
|  |   "screenAccountBadges": "Badges", | ||||||
|  |   "accountBadges": "Badges", | ||||||
|  |   "accountBadgesDescription": "View and manage your badges.", | ||||||
|  |   "badgeActivated": "Activated badge {}.", | ||||||
|  |   "viewDetailedAttachment": "Details", | ||||||
|  |   "screenKeyPairs": "Key Pairs", | ||||||
|  |   "accountKeyPairs": "Key Pairs", | ||||||
|  |   "accountKeyPairsDescription": "Manage the key pairs which used to encrypt messages.", | ||||||
|  |   "enrollNewKeyPair": "Enroll New One", | ||||||
|  |   "enrollNewKeyPairDescription": "Generate a new key pair.", | ||||||
|  |   "keyPairHasPrivateKey": "With private key", | ||||||
|  |   "decrypting": "Decrypting……", | ||||||
|  |   "decryptingKeyNotFound": "Key not found or exchange failed, the other party may not be online", | ||||||
|  |   "messageUnablePreview": "Unable preview", | ||||||
|  |   "messageUnablePreviewEncrypted": "Unable preview encrypted message" | ||||||
| } | } | ||||||
|   | |||||||
| @@ -25,6 +25,7 @@ | |||||||
|   "screenChatNew": "新建聊天频道", |   "screenChatNew": "新建聊天频道", | ||||||
|   "screenRealm": "领域", |   "screenRealm": "领域", | ||||||
|   "screenRealmManage": "编辑领域", |   "screenRealmManage": "编辑领域", | ||||||
|  |   "screenRealmDiscovery": "发现领域", | ||||||
|   "screenRealmNew": "新建领域", |   "screenRealmNew": "新建领域", | ||||||
|   "screenNotification": "通知", |   "screenNotification": "通知", | ||||||
|   "screenPostSearch": "搜索帖子", |   "screenPostSearch": "搜索帖子", | ||||||
| @@ -136,11 +137,19 @@ | |||||||
|   "publisherRunBy": "由 {} 管理", |   "publisherRunBy": "由 {} 管理", | ||||||
|   "fieldPublisherBelongToRealm": "所属领域", |   "fieldPublisherBelongToRealm": "所属领域", | ||||||
|   "fieldPublisherBelongToRealmUnset": "未设置发布者所属领域", |   "fieldPublisherBelongToRealmUnset": "未设置发布者所属领域", | ||||||
|  |   "writePost": "撰写", | ||||||
|  |   "postTypeStory": "动态", | ||||||
|  |   "postTypeArticle": "文章", | ||||||
|  |   "postTypeQuestion": "问题", | ||||||
|  |   "postTypeVideo": "视频", | ||||||
|   "writePostTypeStory": "发动态", |   "writePostTypeStory": "发动态", | ||||||
|   "writePostTypeArticle": "写文章", |   "writePostTypeArticle": "写文章", | ||||||
|  |   "writePostTypeQuestion": "提问题", | ||||||
|  |   "writePostTypeVideo": "发视频", | ||||||
|   "fieldPostPublisher": "帖子发布者", |   "fieldPostPublisher": "帖子发布者", | ||||||
|   "fieldPostContent": "发生什么事了?!", |   "fieldPostContent": "发生什么事了?!", | ||||||
|   "fieldPostTitle": "标题", |   "fieldPostTitle": "标题", | ||||||
|  |   "fieldPostQuestionReward": "回答奖励源点", | ||||||
|   "fieldPostDescription": "描述", |   "fieldPostDescription": "描述", | ||||||
|   "fieldPostTags": "标签", |   "fieldPostTags": "标签", | ||||||
|   "fieldPostCategories": "分类", |   "fieldPostCategories": "分类", | ||||||
| @@ -197,6 +206,11 @@ | |||||||
|     "other": "{} 条评论" |     "other": "{} 条评论" | ||||||
|   }, |   }, | ||||||
|   "settingsAppearance": "外观", |   "settingsAppearance": "外观", | ||||||
|  |   "settingsCustomFonts": "自定义字体", | ||||||
|  |   "settingsCustomFontsDescription": "设置应用程序使用的字体。", | ||||||
|  |   "settingsCustomFontFamily": "应用字体", | ||||||
|  |   "settingsCustomFontFamilyHint": "使用英文逗号分割每一种字体,越前优先级越高", | ||||||
|  |   "settingsCustomFontApplied": "自定义字体已经应用。", | ||||||
|   "settingsDisplayLanguage": "显示语言", |   "settingsDisplayLanguage": "显示语言", | ||||||
|   "settingsDisplayLanguageDescription": "设置应用程序使用的语言", |   "settingsDisplayLanguageDescription": "设置应用程序使用的语言", | ||||||
|   "settingsDisplayLanguageSystem": "跟随系统", |   "settingsDisplayLanguageSystem": "跟随系统", | ||||||
| @@ -327,6 +341,7 @@ | |||||||
|   "addAttachmentFromRandomId": "通过访问 ID 链接", |   "addAttachmentFromRandomId": "通过访问 ID 链接", | ||||||
|   "attachmentDetailInfo": "附件详细信息", |   "attachmentDetailInfo": "附件详细信息", | ||||||
|   "attachmentPastedImage": "粘贴的图片", |   "attachmentPastedImage": "粘贴的图片", | ||||||
|  |   "attachmentInsertedImage": "插入的图片", | ||||||
|   "attachmentInsertLink": "插入连接", |   "attachmentInsertLink": "插入连接", | ||||||
|   "attachmentSetAsPostThumbnail": "设置为帖子缩略图", |   "attachmentSetAsPostThumbnail": "设置为帖子缩略图", | ||||||
|   "attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图", |   "attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图", | ||||||
| @@ -505,8 +520,13 @@ | |||||||
|   "accountBirthday": "出生于 {}", |   "accountBirthday": "出生于 {}", | ||||||
|   "accountBadge": "徽章", |   "accountBadge": "徽章", | ||||||
|   "accountCheckInNoRecords": "暂无运势记录", |   "accountCheckInNoRecords": "暂无运势记录", | ||||||
|   "badgeCompanyStaff": "索尔辛茨士大夫 · 员工", |   "badgeCompanyStaff": "工作人员", | ||||||
|   "badgeSiteMigration": "Solar Network 原住民", |   "badgeSiteMigration": "Solar Network 原住民", | ||||||
|  |   "badgeCommunitySurvey": "调研参与者", | ||||||
|  |   "badgeCommunityVerified": "认证用户", | ||||||
|  |   "badgeCommunityContributor": "优秀社区贡献者", | ||||||
|  |   "badgeSiteAnniversary": "周年纪念", | ||||||
|  |   "badgeUserBirthday": "生日纪念", | ||||||
|   "accountStatus": "状态", |   "accountStatus": "状态", | ||||||
|   "accountStatusOnline": "在线", |   "accountStatusOnline": "在线", | ||||||
|   "accountStatusOffline": "离线", |   "accountStatusOffline": "离线", | ||||||
| @@ -541,6 +561,7 @@ | |||||||
|   "termAcceptNextWithAgree": "点击 “下一步”,即表示你同意我们的各项条款,包括其之后的更新。", |   "termAcceptNextWithAgree": "点击 “下一步”,即表示你同意我们的各项条款,包括其之后的更新。", | ||||||
|   "unauthorized": "未登陆", |   "unauthorized": "未登陆", | ||||||
|   "unauthorizedDescription": "登陆以探索整个 Solar Network。", |   "unauthorizedDescription": "登陆以探索整个 Solar Network。", | ||||||
|  |   "projectDetail": "项目详情", | ||||||
|   "serviceStatus": "服务状态", |   "serviceStatus": "服务状态", | ||||||
|   "termRelated": "相关条款", |   "termRelated": "相关条款", | ||||||
|   "appDetails": "应用程序详情", |   "appDetails": "应用程序详情", | ||||||
| @@ -576,6 +597,7 @@ | |||||||
|   "colorSchemeBlack": "黑色", |   "colorSchemeBlack": "黑色", | ||||||
|   "colorSchemeApplied": "主题色已应用,可能需要重启来生效。", |   "colorSchemeApplied": "主题色已应用,可能需要重启来生效。", | ||||||
|   "postFeaturedComment": "精选评论", |   "postFeaturedComment": "精选评论", | ||||||
|  |   "postCategory": "分类", | ||||||
|   "postCategoryTechnology": "技术", |   "postCategoryTechnology": "技术", | ||||||
|   "postCategoryGaming": "游戏", |   "postCategoryGaming": "游戏", | ||||||
|   "postCategoryLife": "生活", |   "postCategoryLife": "生活", | ||||||
| @@ -608,5 +630,141 @@ | |||||||
|   }, |   }, | ||||||
|   "aiThinkingProcess": "AI 思考过程", |   "aiThinkingProcess": "AI 思考过程", | ||||||
|   "accountSettingsApplied": "帐号设置已应用。", |   "accountSettingsApplied": "帐号设置已应用。", | ||||||
|   "trayMenuExit": "退出" |   "trayMenuExit": "退出", | ||||||
|  |   "postQuestionUnanswered": "未解答的问题", | ||||||
|  |   "postQuestionUnansweredWithReward": "未解答的问题,悬赏源点 {}", | ||||||
|  |   "postQuestionAnswered": "已解答的问题", | ||||||
|  |   "postQuestionAnswerTitle": "精选解答", | ||||||
|  |   "postQuestionAnswerSelect": "选择解答", | ||||||
|  |   "postQuestionAnswerSelected": "解答已选择,奖励已发放。", | ||||||
|  |   "postVideoUpload": "上传视频", | ||||||
|  |   "realmJoin": "加入领域", | ||||||
|  |   "realmCommunityHint": "该领域是一个社区领域,你可以自由加入。", | ||||||
|  |   "realmCommunityPublicChannelsHint": "该领域包含的公共频道", | ||||||
|  |   "realmCommunityPublishersHint": "该领域的发布者", | ||||||
|  |   "realmJoined": "已加入领域 {}。", | ||||||
|  |   "join": "加入", | ||||||
|  |   "pollEditorNew": "新投票", | ||||||
|  |   "pollEditorEdit": "编辑投票", | ||||||
|  |   "pollEditorDelete": "删除投票", | ||||||
|  |   "pollEditorDeleteDescription": "你确定要删除这个投票吗?该操作不可撤销。", | ||||||
|  |   "pollEditorUnlink": "解除链接", | ||||||
|  |   "pollOptionAdd": "添加选项", | ||||||
|  |   "pollOptionName": "选项名称", | ||||||
|  |   "pollLinkExisting": "链接现有投票", | ||||||
|  |   "pollAnswered": "答案已经反馈。", | ||||||
|  |   "pollVotes": { | ||||||
|  |     "one": "{} 票", | ||||||
|  |     "other": "{} 票" | ||||||
|  |   }, | ||||||
|  |   "publisherDelete": "删除发布者 {}", | ||||||
|  |   "publisherDeleteDescription": "你确定要删除这个发布者吗?该操作不可撤销。", | ||||||
|  |   "channelIsPublic": "公开频道", | ||||||
|  |   "channelIsPublicDescription": "该频道是公开的,任何人都可以加入。", | ||||||
|  |   "channelIsCommunity": "社区频道", | ||||||
|  |   "channelIsCommunityDescription": "目前来说,社区频道还没有什么特别之处。", | ||||||
|  |   "realmIsPublic": "公开领域", | ||||||
|  |   "realmIsPublicDescription": "该领域是公开的,任何人都可以加入。", | ||||||
|  |   "realmIsCommunity": "社区领域", | ||||||
|  |   "realmIsCommunityDescription": "社区领域会显示在发现页面上。", | ||||||
|  |   "realmLeave": "离开领域", | ||||||
|  |   "realmLeaveDescription": "离开当前领域,并且删除领域中的身份。", | ||||||
|  |   "checkInResultTier1": "大凶", | ||||||
|  |   "checkInResultTier2": "凶", | ||||||
|  |   "checkInResultTier3": "中平", | ||||||
|  |   "checkInResultTier4": "吉", | ||||||
|  |   "checkInResultTier5": "大吉", | ||||||
|  |   "flagPostAction": "吹哨", | ||||||
|  |   "flagPost": "吹哨不良内容", | ||||||
|  |   "flagPostDescription": "吹哨不良内容,如果吹哨用户占浏览量的 50% 或以上,则帖子会被折叠。吹哨后不可撤销。", | ||||||
|  |   "flaggedPost": "哨子已经吹响。", | ||||||
|  |   "postViews": { | ||||||
|  |     "zero": "{} 次浏览", | ||||||
|  |     "one": "{} 次浏览", | ||||||
|  |     "other": "{} 次浏览" | ||||||
|  |   }, | ||||||
|  |   "attachmentBillingUploaded": "已占用的字节数", | ||||||
|  |   "attachmentBillingDiscount": "免费的字节数", | ||||||
|  |   "attachmentBillingHint": "滑动窗口计价®\n在24小时内上传的文件大小超出免费空间才会适用扣费。", | ||||||
|  |   "postThumbnail": "帖子缩略图", | ||||||
|  |   "accountRealms": "领域", | ||||||
|  |   "postInGlobal": "全站", | ||||||
|  |   "postInGlobalDescription": "不关联此帖子与任何领域。", | ||||||
|  |   "postChannelGlobal": "全站", | ||||||
|  |   "postChannelFriends": "好友", | ||||||
|  |   "postChannelFollowing": "关注", | ||||||
|  |   "postChannelRealm": "领域", | ||||||
|  |   "postFilterReset": "重置过滤器", | ||||||
|  |   "postFilterResetDescription": "清除过滤器并显示所有帖子。", | ||||||
|  |   "postFilterWithCategory": "查看{}区中的帖子", | ||||||
|  |   "databaseSize": "数据库大小", | ||||||
|  |   "databaseDelete": "删除数据库", | ||||||
|  |   "databaseDeleteDescription": "删除本地数据库,内容将从服务器重新获取。", | ||||||
|  |   "databaseDeleted": "本地数据库已被删除。", | ||||||
|  |   "settingsEnablePushNotifications": "启用推送数据", | ||||||
|  |   "settingsEnablePushNotificationsDescription": "重新启用并请求推送权限,以防自动激活失败。", | ||||||
|  |   "settingsEnabledPushNotifications": "推送通知已经注册。", | ||||||
|  |   "screenStickers": "贴图", | ||||||
|  |   "stickersDiscovery": "发现", | ||||||
|  |   "stickersOwned": "由我拥有", | ||||||
|  |   "stickersCreated": "由我发布", | ||||||
|  |   "stickersAdd": "添加贴图包", | ||||||
|  |   "stickersAdded": "贴图包已添加。", | ||||||
|  |   "add": "添加", | ||||||
|  |   "stickersRemoved": "贴图包已被移除,你可以随时再次添加回来。", | ||||||
|  |   "stickersReload": "重载贴图包", | ||||||
|  |   "stickersReloadDescription": "从服务器重新加载添加过的贴图,更新贴图选择器。", | ||||||
|  |   "stickersReloaded": "贴图包已重载。", | ||||||
|  |   "stickersPackDelete": "删除贴图包 {}", | ||||||
|  |   "stickersPackDeleteDescription": "你确定要删除这个贴图包吗?这个操作不可撤销。", | ||||||
|  |   "stickersPackDeleted": "贴图包已被删除。", | ||||||
|  |   "stickersDelete": "删除贴图 {}", | ||||||
|  |   "stickersDeleteDescription": "你确定要删除这个贴图吗?这个操作不可撤销。", | ||||||
|  |   "stickersDeleted": "贴图已被删除。", | ||||||
|  |   "fieldStickerName": "贴图名称", | ||||||
|  |   "fieldStickerAlias": "贴图别名", | ||||||
|  |   "fieldStickerAliasHint": "和贴图包前缀组合成为本贴图的唯一占位符。", | ||||||
|  |   "fieldStickerPackName": "名称", | ||||||
|  |   "fieldStickerPackDescription": "描述", | ||||||
|  |   "fieldStickerPackPrefix": "贴图包前缀", | ||||||
|  |   "fieldStickerAttachment": "附件", | ||||||
|  |   "stickersNew": "新建贴图", | ||||||
|  |   "stickersNewDescription": "创建一个新的贴图。", | ||||||
|  |   "stickersPackNew": "新建贴图包", | ||||||
|  |   "trayMenuShow": "显示", | ||||||
|  |   "trayMenuMuteNotification": "静音通知", | ||||||
|  |   "update": "更新", | ||||||
|  |   "forceUpdate": "强制更新", | ||||||
|  |   "forceUpdateDescription": "强制更新应用程序,即使有更新的版本可能不可用。", | ||||||
|  |   "runtimeLogs": "运行时日志", | ||||||
|  |   "runtimeLogsOpen": "打开日志文件", | ||||||
|  |   "runtimeLogsDescription": "显示运行时的日志记录。", | ||||||
|  |   "signinResetPasswordHint": "请输入用户名/电子邮箱地址以帮助我们找到您的帐户并重置密码。", | ||||||
|  |   "cacheSize": "缓存资源大小", | ||||||
|  |   "cacheDelete": "清除缓存", | ||||||
|  |   "cacheDeleteDescription": "从磁盘中移除缓存的图片和其他资源,内容将从服务器重新下载。", | ||||||
|  |   "cacheDeleted": "所有缓存已被清除。", | ||||||
|  |   "userNoDescription": "这个人很懒,没有留下什么……", | ||||||
|  |   "fieldTimeZone": "时区", | ||||||
|  |   "fieldGender": "性别", | ||||||
|  |   "fieldPronouns": "人称代词", | ||||||
|  |   "fieldLocation": "位置", | ||||||
|  |   "fieldLinks": "链接", | ||||||
|  |   "fieldLinkName": "名称", | ||||||
|  |   "fieldLinkUrl": "链接", | ||||||
|  |   "screenAccountBadges": "徽章", | ||||||
|  |   "accountBadges": "徽章", | ||||||
|  |   "accountBadgesDescription": "查看并管理你的徽章。", | ||||||
|  |   "badgeActivated": "已佩戴徽章 {}。", | ||||||
|  |   "viewDetailedAttachment": "查看附件详情", | ||||||
|  |   "screenKeyPairs": "密钥对", | ||||||
|  |   "accountKeyPairs": "密钥对", | ||||||
|  |   "accountKeyPairsDescription": "管理用于加密信息的密钥对。", | ||||||
|  |   "enrollNewKeyPair": "新建密钥对", | ||||||
|  |   "enrollNewKeyPairDescription": "生成一对新密钥对。", | ||||||
|  |   "keyPairHasPrivateKey": "有私钥", | ||||||
|  |   "decrypting": "解密中……", | ||||||
|  |   "decryptingKeyNotFound": "未找到密钥对或交换失败,对方可能不在线", | ||||||
|  |   "messageUnablePreview": "无法预览消息", | ||||||
|  |   "messageUnablePreviewEncrypted": "无法预览加密消息" | ||||||
| } | } | ||||||
|   | |||||||
| @@ -25,6 +25,7 @@ | |||||||
|   "screenChatNew": "新建聊天頻道", |   "screenChatNew": "新建聊天頻道", | ||||||
|   "screenRealm": "領域", |   "screenRealm": "領域", | ||||||
|   "screenRealmManage": "編輯領域", |   "screenRealmManage": "編輯領域", | ||||||
|  |   "screenRealmDiscovery": "發現領域", | ||||||
|   "screenRealmNew": "新建領域", |   "screenRealmNew": "新建領域", | ||||||
|   "screenNotification": "通知", |   "screenNotification": "通知", | ||||||
|   "screenPostSearch": "搜索帖子", |   "screenPostSearch": "搜索帖子", | ||||||
| @@ -138,9 +139,12 @@ | |||||||
|   "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域", |   "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域", | ||||||
|   "writePostTypeStory": "發動態", |   "writePostTypeStory": "發動態", | ||||||
|   "writePostTypeArticle": "寫文章", |   "writePostTypeArticle": "寫文章", | ||||||
|  |   "writePostTypeQuestion": "提問題", | ||||||
|  |   "writePostTypeVideo": "發視頻", | ||||||
|   "fieldPostPublisher": "帖子發佈者", |   "fieldPostPublisher": "帖子發佈者", | ||||||
|   "fieldPostContent": "發生什麼事了?!", |   "fieldPostContent": "發生什麼事了?!", | ||||||
|   "fieldPostTitle": "標題", |   "fieldPostTitle": "標題", | ||||||
|  |   "fieldPostQuestionReward": "回答獎勵源點", | ||||||
|   "fieldPostDescription": "描述", |   "fieldPostDescription": "描述", | ||||||
|   "fieldPostTags": "標籤", |   "fieldPostTags": "標籤", | ||||||
|   "fieldPostCategories": "分類", |   "fieldPostCategories": "分類", | ||||||
| @@ -197,6 +201,11 @@ | |||||||
|     "other": "{} 條評論" |     "other": "{} 條評論" | ||||||
|   }, |   }, | ||||||
|   "settingsAppearance": "外觀", |   "settingsAppearance": "外觀", | ||||||
|  |   "settingsCustomFonts": "自定義字體", | ||||||
|  |   "settingsCustomFontsDescription": "設置應用程序使用的字體。", | ||||||
|  |   "settingsCustomFontFamily": "應用字體", | ||||||
|  |   "settingsCustomFontFamilyHint": "使用英文逗號分割每一種字體,越前優先級越高", | ||||||
|  |   "settingsCustomFontApplied": "自定義字體已經應用。", | ||||||
|   "settingsDisplayLanguage": "顯示語言", |   "settingsDisplayLanguage": "顯示語言", | ||||||
|   "settingsDisplayLanguageDescription": "設置應用程序使用的語言", |   "settingsDisplayLanguageDescription": "設置應用程序使用的語言", | ||||||
|   "settingsDisplayLanguageSystem": "跟隨系統", |   "settingsDisplayLanguageSystem": "跟隨系統", | ||||||
| @@ -327,6 +336,7 @@ | |||||||
|   "addAttachmentFromRandomId": "通過訪問 ID 鏈接", |   "addAttachmentFromRandomId": "通過訪問 ID 鏈接", | ||||||
|   "attachmentDetailInfo": "附件詳細信息", |   "attachmentDetailInfo": "附件詳細信息", | ||||||
|   "attachmentPastedImage": "粘貼的圖片", |   "attachmentPastedImage": "粘貼的圖片", | ||||||
|  |   "attachmentInsertedImage": "插入的圖片", | ||||||
|   "attachmentInsertLink": "插入連接", |   "attachmentInsertLink": "插入連接", | ||||||
|   "attachmentSetAsPostThumbnail": "設置為帖子縮略圖", |   "attachmentSetAsPostThumbnail": "設置為帖子縮略圖", | ||||||
|   "attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖", |   "attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖", | ||||||
| @@ -505,8 +515,13 @@ | |||||||
|   "accountBirthday": "出生於 {}", |   "accountBirthday": "出生於 {}", | ||||||
|   "accountBadge": "徽章", |   "accountBadge": "徽章", | ||||||
|   "accountCheckInNoRecords": "暫無運勢記錄", |   "accountCheckInNoRecords": "暫無運勢記錄", | ||||||
|   "badgeCompanyStaff": "索爾辛茨士大夫 · 員工", |   "badgeCompanyStaff": "工作人員", | ||||||
|   "badgeSiteMigration": "Solar Network 原住民", |   "badgeSiteMigration": "Solar Network 原住民", | ||||||
|  |   "badgeCommunitySurvey": "調研參與者", | ||||||
|  |   "badgeCommunityVerified": "認證用户", | ||||||
|  |   "badgeCommunityContributor": "優秀社區貢獻者", | ||||||
|  |   "badgeSiteAnniversary": "週年紀念", | ||||||
|  |   "badgeUserBirthday": "生日紀念", | ||||||
|   "accountStatus": "狀態", |   "accountStatus": "狀態", | ||||||
|   "accountStatusOnline": "在線", |   "accountStatusOnline": "在線", | ||||||
|   "accountStatusOffline": "離線", |   "accountStatusOffline": "離線", | ||||||
| @@ -541,6 +556,7 @@ | |||||||
|   "termAcceptNextWithAgree": "點擊 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。", |   "termAcceptNextWithAgree": "點擊 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。", | ||||||
|   "unauthorized": "未登陸", |   "unauthorized": "未登陸", | ||||||
|   "unauthorizedDescription": "登陸以探索整個 Solar Network。", |   "unauthorizedDescription": "登陸以探索整個 Solar Network。", | ||||||
|  |   "projectDetail": "項目詳情", | ||||||
|   "serviceStatus": "服務狀態", |   "serviceStatus": "服務狀態", | ||||||
|   "termRelated": "相關條款", |   "termRelated": "相關條款", | ||||||
|   "appDetails": "應用程序詳情", |   "appDetails": "應用程序詳情", | ||||||
| @@ -576,6 +592,7 @@ | |||||||
|   "colorSchemeBlack": "黑色", |   "colorSchemeBlack": "黑色", | ||||||
|   "colorSchemeApplied": "主題色已應用,可能需要重啓來生效。", |   "colorSchemeApplied": "主題色已應用,可能需要重啓來生效。", | ||||||
|   "postFeaturedComment": "精選評論", |   "postFeaturedComment": "精選評論", | ||||||
|  |   "postCategory": "分類", | ||||||
|   "postCategoryTechnology": "技術", |   "postCategoryTechnology": "技術", | ||||||
|   "postCategoryGaming": "遊戲", |   "postCategoryGaming": "遊戲", | ||||||
|   "postCategoryLife": "生活", |   "postCategoryLife": "生活", | ||||||
| @@ -607,5 +624,142 @@ | |||||||
|     "other": "{} 源點" |     "other": "{} 源點" | ||||||
|   }, |   }, | ||||||
|   "aiThinkingProcess": "AI 思考過程", |   "aiThinkingProcess": "AI 思考過程", | ||||||
|   "accountSettingsApplied": "帳號設置已應用。" |   "accountSettingsApplied": "帳號設置已應用。", | ||||||
|  |   "trayMenuExit": "退出", | ||||||
|  |   "postQuestionUnanswered": "未解答的問題", | ||||||
|  |   "postQuestionUnansweredWithReward": "未解答的問題,懸賞源點 {}", | ||||||
|  |   "postQuestionAnswered": "已解答的問題", | ||||||
|  |   "postQuestionAnswerTitle": "精選解答", | ||||||
|  |   "postQuestionAnswerSelect": "選擇解答", | ||||||
|  |   "postQuestionAnswerSelected": "解答已選擇,獎勵已發放。", | ||||||
|  |   "postVideoUpload": "上傳視頻", | ||||||
|  |   "realmJoin": "加入領域", | ||||||
|  |   "realmCommunityHint": "該領域是一個社區領域,你可以自由加入。", | ||||||
|  |   "realmCommunityPublicChannelsHint": "該領域包含的公共頻道", | ||||||
|  |   "realmCommunityPublishersHint": "該領域的發佈者", | ||||||
|  |   "realmJoined": "已加入領域 {}。", | ||||||
|  |   "join": "加入", | ||||||
|  |   "pollEditorNew": "新投票", | ||||||
|  |   "pollEditorEdit": "編輯投票", | ||||||
|  |   "pollEditorDelete": "刪除投票", | ||||||
|  |   "pollEditorDeleteDescription": "你確定要刪除這個投票嗎?該操作不可撤銷。", | ||||||
|  |   "pollEditorUnlink": "解除鏈接", | ||||||
|  |   "pollOptionAdd": "添加選項", | ||||||
|  |   "pollOptionName": "選項名稱", | ||||||
|  |   "pollLinkExisting": "鏈接現有投票", | ||||||
|  |   "pollAnswered": "答案已經反饋。", | ||||||
|  |   "pollVotes": { | ||||||
|  |     "one": "{} 票", | ||||||
|  |     "other": "{} 票" | ||||||
|  |   }, | ||||||
|  |   "publisherDelete": "刪除發佈者 {}", | ||||||
|  |   "publisherDeleteDescription": "你確定要刪除這個發佈者嗎?該操作不可撤銷。", | ||||||
|  |   "channelIsPublic": "公開頻道", | ||||||
|  |   "channelIsPublicDescription": "該頻道是公開的,任何人都可以加入。", | ||||||
|  |   "channelIsCommunity": "社區頻道", | ||||||
|  |   "channelIsCommunityDescription": "目前來説,社區頻道還沒有什麼特別之處。", | ||||||
|  |   "realmIsPublic": "公開領域", | ||||||
|  |   "realmIsPublicDescription": "該領域是公開的,任何人都可以加入。", | ||||||
|  |   "realmIsCommunity": "社區領域", | ||||||
|  |   "realmIsCommunityDescription": "社區領域會顯示在發現頁面上。", | ||||||
|  |   "realmLeave": "離開領域", | ||||||
|  |   "realmLeaveDescription": "離開當前領域,並且刪除領域中的身份。", | ||||||
|  |   "checkInResultTier1": "大凶", | ||||||
|  |   "checkInResultTier2": "兇", | ||||||
|  |   "checkInResultTier3": "中平", | ||||||
|  |   "checkInResultTier4": "吉", | ||||||
|  |   "checkInResultTier5": "大吉", | ||||||
|  |   "flagPostAction": "吹哨", | ||||||
|  |   "flagPost": "吹哨不良內容", | ||||||
|  |   "flagPostDescription": "吹哨不良內容,如果吹哨用户佔瀏覽量的 50% 或以上,則帖子會被摺疊。吹哨後不可撤銷。", | ||||||
|  |   "flaggedPost": "哨子已經吹響。", | ||||||
|  |   "postViews": { | ||||||
|  |     "zero": "{} 次瀏覽", | ||||||
|  |     "one": "{} 次瀏覽", | ||||||
|  |     "other": "{} 次瀏覽" | ||||||
|  |   }, | ||||||
|  |   "attachmentBillingUploaded": "已佔用的字節數", | ||||||
|  |   "attachmentBillingDiscount": "免費的字節數", | ||||||
|  |   "attachmentBillingHint": "滑動窗口計價®\n在24小時內上傳的文件大小超出免費空間才會適用扣費。", | ||||||
|  |   "postThumbnail": "帖子縮略圖", | ||||||
|  |   "accountRealms": "領域", | ||||||
|  |   "postInGlobal": "全站", | ||||||
|  |   "postInGlobalDescription": "不關聯此帖子與任何領域。", | ||||||
|  |   "postChannelGlobal": "全站", | ||||||
|  |   "postChannelFriends": "好友", | ||||||
|  |   "postChannelFollowing": "關注", | ||||||
|  |   "postChannelRealm": "領域", | ||||||
|  |   "postFilterReset": "重置過濾器", | ||||||
|  |   "postFilterResetDescription": "清除過濾器並顯示所有帖子。", | ||||||
|  |   "postFilterWithCategory": "查看{}區中的帖子", | ||||||
|  |   "databaseSize": "數據庫大小", | ||||||
|  |   "databaseDelete": "刪除數據庫", | ||||||
|  |   "databaseDeleteDescription": "刪除本地數據庫,內容將從服務器重新獲取。", | ||||||
|  |   "databaseDeleted": "本地數據庫已被刪除。", | ||||||
|  |   "settingsEnablePushNotifications": "啓用推送數據", | ||||||
|  |   "settingsEnablePushNotificationsDescription": "重新啓用並請求推送權限,以防自動激活失敗。", | ||||||
|  |   "settingsEnabledPushNotifications": "推送通知已經註冊。", | ||||||
|  |   "screenStickers": "貼圖", | ||||||
|  |   "stickersDiscovery": "發現", | ||||||
|  |   "stickersOwned": "由我擁有", | ||||||
|  |   "stickersCreated": "由我發佈", | ||||||
|  |   "stickersAdd": "添加貼圖包", | ||||||
|  |   "stickersAdded": "貼圖包已添加。", | ||||||
|  |   "add": "添加", | ||||||
|  |   "stickersRemoved": "貼圖包已被移除,你可以隨時再次添加回來。", | ||||||
|  |   "stickersReload": "重載貼圖包", | ||||||
|  |   "stickersReloadDescription": "從服務器重新加載添加過的貼圖,更新貼圖選擇器。", | ||||||
|  |   "stickersReloaded": "貼圖包已重載。", | ||||||
|  |   "stickersPackDelete": "刪除貼圖包 {}", | ||||||
|  |   "stickersPackDeleteDescription": "你確定要刪除這個貼圖包嗎?這個操作不可撤銷。", | ||||||
|  |   "stickersPackDeleted": "貼圖包已被刪除。", | ||||||
|  |   "stickersDelete": "刪除貼圖 {}", | ||||||
|  |   "stickersDeleteDescription": "你確定要刪除這個貼圖嗎?這個操作不可撤銷。", | ||||||
|  |   "stickersDeleted": "貼圖已被刪除。", | ||||||
|  |   "fieldStickerName": "貼圖名稱", | ||||||
|  |   "fieldStickerAlias": "貼圖別名", | ||||||
|  |   "fieldStickerAliasHint": "和貼圖包前綴組合成為本貼圖的唯一佔位符。", | ||||||
|  |   "fieldStickerPackName": "名稱", | ||||||
|  |   "fieldStickerPackDescription": "描述", | ||||||
|  |   "fieldStickerPackPrefix": "貼圖包前綴", | ||||||
|  |   "fieldStickerAttachment": "附件", | ||||||
|  |   "stickersNew": "新建貼圖", | ||||||
|  |   "stickersNewDescription": "創建一個新的貼圖。", | ||||||
|  |   "stickersPackNew": "新建貼圖包", | ||||||
|  |   "trayMenuShow": "顯示", | ||||||
|  |   "trayMenuMuteNotification": "靜音通知", | ||||||
|  |   "update": "更新", | ||||||
|  |   "forceUpdate": "強制更新", | ||||||
|  |   "forceUpdateDescription": "強制更新應用程序,即使有更新的版本可能不可用。", | ||||||
|  |   "runtimeLogs": "運行時日誌", | ||||||
|  |   "runtimeLogsOpen": "打開日誌文件", | ||||||
|  |   "runtimeLogsDescription": "顯示運行時的日誌記錄。", | ||||||
|  |   "signinResetPasswordHint": "請輸入用户名/電子郵箱地址以幫助我們找到您的帳户並重置密碼。", | ||||||
|  |   "cacheSize": "緩存資源大小", | ||||||
|  |   "cacheDelete": "清除緩存", | ||||||
|  |   "cacheDeleteDescription": "從磁盤中移除緩存的圖片和其他資源,內容將從服務器重新下載。", | ||||||
|  |   "cacheDeleted": "所有緩存已被清除。", | ||||||
|  |   "userNoDescription": "這個人很懶,沒有留下什麼……", | ||||||
|  |   "fieldTimeZone": "時區", | ||||||
|  |   "fieldGender": "性別", | ||||||
|  |   "fieldPronouns": "人稱代詞", | ||||||
|  |   "fieldLocation": "位置", | ||||||
|  |   "fieldLinks": "鏈接", | ||||||
|  |   "fieldLinkName": "名稱", | ||||||
|  |   "fieldLinkUrl": "鏈接", | ||||||
|  |   "screenAccountBadges": "徽章", | ||||||
|  |   "accountBadges": "徽章", | ||||||
|  |   "accountBadgesDescription": "查看並管理你的徽章。", | ||||||
|  |   "badgeActivated": "已佩戴徽章 {}。", | ||||||
|  |   "viewDetailedAttachment": "查看附件詳情", | ||||||
|  |   "screenKeyPairs": "密鑰對", | ||||||
|  |   "accountKeyPairs": "密鑰對", | ||||||
|  |   "accountKeyPairsDescription": "管理用於加密信息的密鑰對。", | ||||||
|  |   "enrollNewKeyPair": "新建密鑰對", | ||||||
|  |   "enrollNewKeyPairDescription": "生成一對新密鑰對。", | ||||||
|  |   "keyPairHasPrivateKey": "有私鑰", | ||||||
|  |   "decrypting": "解密中……", | ||||||
|  |   "decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線", | ||||||
|  |   "messageUnablePreview": "無法預覽消息", | ||||||
|  |   "messageUnablePreviewEncrypted": "無法預覽加密消息" | ||||||
| } | } | ||||||
|   | |||||||
| @@ -25,6 +25,7 @@ | |||||||
|   "screenChatNew": "新建聊天頻道", |   "screenChatNew": "新建聊天頻道", | ||||||
|   "screenRealm": "領域", |   "screenRealm": "領域", | ||||||
|   "screenRealmManage": "編輯領域", |   "screenRealmManage": "編輯領域", | ||||||
|  |   "screenRealmDiscovery": "發現領域", | ||||||
|   "screenRealmNew": "新建領域", |   "screenRealmNew": "新建領域", | ||||||
|   "screenNotification": "通知", |   "screenNotification": "通知", | ||||||
|   "screenPostSearch": "搜索帖子", |   "screenPostSearch": "搜索帖子", | ||||||
| @@ -138,9 +139,12 @@ | |||||||
|   "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域", |   "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域", | ||||||
|   "writePostTypeStory": "發動態", |   "writePostTypeStory": "發動態", | ||||||
|   "writePostTypeArticle": "寫文章", |   "writePostTypeArticle": "寫文章", | ||||||
|  |   "writePostTypeQuestion": "提問題", | ||||||
|  |   "writePostTypeVideo": "發視頻", | ||||||
|   "fieldPostPublisher": "帖子發佈者", |   "fieldPostPublisher": "帖子發佈者", | ||||||
|   "fieldPostContent": "發生什麼事了?!", |   "fieldPostContent": "發生什麼事了?!", | ||||||
|   "fieldPostTitle": "標題", |   "fieldPostTitle": "標題", | ||||||
|  |   "fieldPostQuestionReward": "回答獎勵源點", | ||||||
|   "fieldPostDescription": "描述", |   "fieldPostDescription": "描述", | ||||||
|   "fieldPostTags": "標籤", |   "fieldPostTags": "標籤", | ||||||
|   "fieldPostCategories": "分類", |   "fieldPostCategories": "分類", | ||||||
| @@ -197,6 +201,11 @@ | |||||||
|     "other": "{} 條評論" |     "other": "{} 條評論" | ||||||
|   }, |   }, | ||||||
|   "settingsAppearance": "外觀", |   "settingsAppearance": "外觀", | ||||||
|  |   "settingsCustomFonts": "自定義字體", | ||||||
|  |   "settingsCustomFontsDescription": "設置應用程序使用的字體。", | ||||||
|  |   "settingsCustomFontFamily": "應用字體", | ||||||
|  |   "settingsCustomFontFamilyHint": "使用英文逗號分割每一種字體,越前優先級越高", | ||||||
|  |   "settingsCustomFontApplied": "自定義字體已經應用。", | ||||||
|   "settingsDisplayLanguage": "顯示語言", |   "settingsDisplayLanguage": "顯示語言", | ||||||
|   "settingsDisplayLanguageDescription": "設置應用程序使用的語言", |   "settingsDisplayLanguageDescription": "設置應用程序使用的語言", | ||||||
|   "settingsDisplayLanguageSystem": "跟隨系統", |   "settingsDisplayLanguageSystem": "跟隨系統", | ||||||
| @@ -327,6 +336,7 @@ | |||||||
|   "addAttachmentFromRandomId": "通過訪問 ID 鏈接", |   "addAttachmentFromRandomId": "通過訪問 ID 鏈接", | ||||||
|   "attachmentDetailInfo": "附件詳細信息", |   "attachmentDetailInfo": "附件詳細信息", | ||||||
|   "attachmentPastedImage": "粘貼的圖片", |   "attachmentPastedImage": "粘貼的圖片", | ||||||
|  |   "attachmentInsertedImage": "插入的圖片", | ||||||
|   "attachmentInsertLink": "插入連接", |   "attachmentInsertLink": "插入連接", | ||||||
|   "attachmentSetAsPostThumbnail": "設置為帖子縮略圖", |   "attachmentSetAsPostThumbnail": "設置為帖子縮略圖", | ||||||
|   "attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖", |   "attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖", | ||||||
| @@ -505,8 +515,13 @@ | |||||||
|   "accountBirthday": "出生於 {}", |   "accountBirthday": "出生於 {}", | ||||||
|   "accountBadge": "徽章", |   "accountBadge": "徽章", | ||||||
|   "accountCheckInNoRecords": "暫無運勢記錄", |   "accountCheckInNoRecords": "暫無運勢記錄", | ||||||
|   "badgeCompanyStaff": "索爾辛茨士大夫 · 員工", |   "badgeCompanyStaff": "工作人員", | ||||||
|   "badgeSiteMigration": "Solar Network 原住民", |   "badgeSiteMigration": "Solar Network 原住民", | ||||||
|  |   "badgeCommunitySurvey": "調研參與者", | ||||||
|  |   "badgeCommunityVerified": "認證用戶", | ||||||
|  |   "badgeCommunityContributor": "優秀社區貢獻者", | ||||||
|  |   "badgeSiteAnniversary": "週年紀念", | ||||||
|  |   "badgeUserBirthday": "生日紀念", | ||||||
|   "accountStatus": "狀態", |   "accountStatus": "狀態", | ||||||
|   "accountStatusOnline": "在線", |   "accountStatusOnline": "在線", | ||||||
|   "accountStatusOffline": "離線", |   "accountStatusOffline": "離線", | ||||||
| @@ -541,6 +556,7 @@ | |||||||
|   "termAcceptNextWithAgree": "點擊 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。", |   "termAcceptNextWithAgree": "點擊 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。", | ||||||
|   "unauthorized": "未登陸", |   "unauthorized": "未登陸", | ||||||
|   "unauthorizedDescription": "登陸以探索整個 Solar Network。", |   "unauthorizedDescription": "登陸以探索整個 Solar Network。", | ||||||
|  |   "projectDetail": "項目詳情", | ||||||
|   "serviceStatus": "服務狀態", |   "serviceStatus": "服務狀態", | ||||||
|   "termRelated": "相關條款", |   "termRelated": "相關條款", | ||||||
|   "appDetails": "應用程序詳情", |   "appDetails": "應用程序詳情", | ||||||
| @@ -576,6 +592,7 @@ | |||||||
|   "colorSchemeBlack": "黑色", |   "colorSchemeBlack": "黑色", | ||||||
|   "colorSchemeApplied": "主題色已應用,可能需要重啟來生效。", |   "colorSchemeApplied": "主題色已應用,可能需要重啟來生效。", | ||||||
|   "postFeaturedComment": "精選評論", |   "postFeaturedComment": "精選評論", | ||||||
|  |   "postCategory": "分類", | ||||||
|   "postCategoryTechnology": "技術", |   "postCategoryTechnology": "技術", | ||||||
|   "postCategoryGaming": "遊戲", |   "postCategoryGaming": "遊戲", | ||||||
|   "postCategoryLife": "生活", |   "postCategoryLife": "生活", | ||||||
| @@ -607,5 +624,142 @@ | |||||||
|     "other": "{} 源點" |     "other": "{} 源點" | ||||||
|   }, |   }, | ||||||
|   "aiThinkingProcess": "AI 思考過程", |   "aiThinkingProcess": "AI 思考過程", | ||||||
|   "accountSettingsApplied": "帳號設置已應用。" |   "accountSettingsApplied": "帳號設置已應用。", | ||||||
|  |   "trayMenuExit": "退出", | ||||||
|  |   "postQuestionUnanswered": "未解答的問題", | ||||||
|  |   "postQuestionUnansweredWithReward": "未解答的問題,懸賞源點 {}", | ||||||
|  |   "postQuestionAnswered": "已解答的問題", | ||||||
|  |   "postQuestionAnswerTitle": "精選解答", | ||||||
|  |   "postQuestionAnswerSelect": "選擇解答", | ||||||
|  |   "postQuestionAnswerSelected": "解答已選擇,獎勵已發放。", | ||||||
|  |   "postVideoUpload": "上傳視頻", | ||||||
|  |   "realmJoin": "加入領域", | ||||||
|  |   "realmCommunityHint": "該領域是一個社區領域,你可以自由加入。", | ||||||
|  |   "realmCommunityPublicChannelsHint": "該領域包含的公共頻道", | ||||||
|  |   "realmCommunityPublishersHint": "該領域的發佈者", | ||||||
|  |   "realmJoined": "已加入領域 {}。", | ||||||
|  |   "join": "加入", | ||||||
|  |   "pollEditorNew": "新投票", | ||||||
|  |   "pollEditorEdit": "編輯投票", | ||||||
|  |   "pollEditorDelete": "刪除投票", | ||||||
|  |   "pollEditorDeleteDescription": "你確定要刪除這個投票嗎?該操作不可撤銷。", | ||||||
|  |   "pollEditorUnlink": "解除鏈接", | ||||||
|  |   "pollOptionAdd": "添加選項", | ||||||
|  |   "pollOptionName": "選項名稱", | ||||||
|  |   "pollLinkExisting": "鏈接現有投票", | ||||||
|  |   "pollAnswered": "答案已經反饋。", | ||||||
|  |   "pollVotes": { | ||||||
|  |     "one": "{} 票", | ||||||
|  |     "other": "{} 票" | ||||||
|  |   }, | ||||||
|  |   "publisherDelete": "刪除發佈者 {}", | ||||||
|  |   "publisherDeleteDescription": "你確定要刪除這個發佈者嗎?該操作不可撤銷。", | ||||||
|  |   "channelIsPublic": "公開頻道", | ||||||
|  |   "channelIsPublicDescription": "該頻道是公開的,任何人都可以加入。", | ||||||
|  |   "channelIsCommunity": "社區頻道", | ||||||
|  |   "channelIsCommunityDescription": "目前來說,社區頻道還沒有什麼特別之處。", | ||||||
|  |   "realmIsPublic": "公開領域", | ||||||
|  |   "realmIsPublicDescription": "該領域是公開的,任何人都可以加入。", | ||||||
|  |   "realmIsCommunity": "社區領域", | ||||||
|  |   "realmIsCommunityDescription": "社區領域會顯示在發現頁面上。", | ||||||
|  |   "realmLeave": "離開領域", | ||||||
|  |   "realmLeaveDescription": "離開當前領域,並且刪除領域中的身份。", | ||||||
|  |   "checkInResultTier1": "大凶", | ||||||
|  |   "checkInResultTier2": "兇", | ||||||
|  |   "checkInResultTier3": "中平", | ||||||
|  |   "checkInResultTier4": "吉", | ||||||
|  |   "checkInResultTier5": "大吉", | ||||||
|  |   "flagPostAction": "吹哨", | ||||||
|  |   "flagPost": "吹哨不良內容", | ||||||
|  |   "flagPostDescription": "吹哨不良內容,如果吹哨用戶佔瀏覽量的 50% 或以上,則帖子會被摺疊。吹哨後不可撤銷。", | ||||||
|  |   "flaggedPost": "哨子已經吹響。", | ||||||
|  |   "postViews": { | ||||||
|  |     "zero": "{} 次瀏覽", | ||||||
|  |     "one": "{} 次瀏覽", | ||||||
|  |     "other": "{} 次瀏覽" | ||||||
|  |   }, | ||||||
|  |   "attachmentBillingUploaded": "已佔用的字節數", | ||||||
|  |   "attachmentBillingDiscount": "免費的字節數", | ||||||
|  |   "attachmentBillingHint": "滑動窗口計價®\n在24小時內上傳的文件大小超出免費空間才會適用扣費。", | ||||||
|  |   "postThumbnail": "帖子縮略圖", | ||||||
|  |   "accountRealms": "領域", | ||||||
|  |   "postInGlobal": "全站", | ||||||
|  |   "postInGlobalDescription": "不關聯此帖子與任何領域。", | ||||||
|  |   "postChannelGlobal": "全站", | ||||||
|  |   "postChannelFriends": "好友", | ||||||
|  |   "postChannelFollowing": "關注", | ||||||
|  |   "postChannelRealm": "領域", | ||||||
|  |   "postFilterReset": "重置過濾器", | ||||||
|  |   "postFilterResetDescription": "清除過濾器並顯示所有帖子。", | ||||||
|  |   "postFilterWithCategory": "查看{}區中的帖子", | ||||||
|  |   "databaseSize": "數據庫大小", | ||||||
|  |   "databaseDelete": "刪除數據庫", | ||||||
|  |   "databaseDeleteDescription": "刪除本地數據庫,內容將從服務器重新獲取。", | ||||||
|  |   "databaseDeleted": "本地數據庫已被刪除。", | ||||||
|  |   "settingsEnablePushNotifications": "啟用推送數據", | ||||||
|  |   "settingsEnablePushNotificationsDescription": "重新啟用並請求推送權限,以防自動激活失敗。", | ||||||
|  |   "settingsEnabledPushNotifications": "推送通知已經註冊。", | ||||||
|  |   "screenStickers": "貼圖", | ||||||
|  |   "stickersDiscovery": "發現", | ||||||
|  |   "stickersOwned": "由我擁有", | ||||||
|  |   "stickersCreated": "由我發佈", | ||||||
|  |   "stickersAdd": "添加貼圖包", | ||||||
|  |   "stickersAdded": "貼圖包已添加。", | ||||||
|  |   "add": "添加", | ||||||
|  |   "stickersRemoved": "貼圖包已被移除,你可以隨時再次添加回來。", | ||||||
|  |   "stickersReload": "重載貼圖包", | ||||||
|  |   "stickersReloadDescription": "從服務器重新加載添加過的貼圖,更新貼圖選擇器。", | ||||||
|  |   "stickersReloaded": "貼圖包已重載。", | ||||||
|  |   "stickersPackDelete": "刪除貼圖包 {}", | ||||||
|  |   "stickersPackDeleteDescription": "你確定要刪除這個貼圖包嗎?這個操作不可撤銷。", | ||||||
|  |   "stickersPackDeleted": "貼圖包已被刪除。", | ||||||
|  |   "stickersDelete": "刪除貼圖 {}", | ||||||
|  |   "stickersDeleteDescription": "你確定要刪除這個貼圖嗎?這個操作不可撤銷。", | ||||||
|  |   "stickersDeleted": "貼圖已被刪除。", | ||||||
|  |   "fieldStickerName": "貼圖名稱", | ||||||
|  |   "fieldStickerAlias": "貼圖別名", | ||||||
|  |   "fieldStickerAliasHint": "和貼圖包前綴組合成為本貼圖的唯一佔位符。", | ||||||
|  |   "fieldStickerPackName": "名稱", | ||||||
|  |   "fieldStickerPackDescription": "描述", | ||||||
|  |   "fieldStickerPackPrefix": "貼圖包前綴", | ||||||
|  |   "fieldStickerAttachment": "附件", | ||||||
|  |   "stickersNew": "新建貼圖", | ||||||
|  |   "stickersNewDescription": "創建一個新的貼圖。", | ||||||
|  |   "stickersPackNew": "新建貼圖包", | ||||||
|  |   "trayMenuShow": "顯示", | ||||||
|  |   "trayMenuMuteNotification": "靜音通知", | ||||||
|  |   "update": "更新", | ||||||
|  |   "forceUpdate": "強制更新", | ||||||
|  |   "forceUpdateDescription": "強制更新應用程序,即使有更新的版本可能不可用。", | ||||||
|  |   "runtimeLogs": "運行時日誌", | ||||||
|  |   "runtimeLogsOpen": "打開日誌文件", | ||||||
|  |   "runtimeLogsDescription": "顯示運行時的日誌記錄。", | ||||||
|  |   "signinResetPasswordHint": "請輸入用戶名/電子郵箱地址以幫助我們找到您的帳戶並重置密碼。", | ||||||
|  |   "cacheSize": "緩存資源大小", | ||||||
|  |   "cacheDelete": "清除緩存", | ||||||
|  |   "cacheDeleteDescription": "從磁盤中移除緩存的圖片和其他資源,內容將從服務器重新下載。", | ||||||
|  |   "cacheDeleted": "所有緩存已被清除。", | ||||||
|  |   "userNoDescription": "這個人很懶,沒有留下什麼……", | ||||||
|  |   "fieldTimeZone": "時區", | ||||||
|  |   "fieldGender": "性別", | ||||||
|  |   "fieldPronouns": "人稱代詞", | ||||||
|  |   "fieldLocation": "位置", | ||||||
|  |   "fieldLinks": "鏈接", | ||||||
|  |   "fieldLinkName": "名稱", | ||||||
|  |   "fieldLinkUrl": "鏈接", | ||||||
|  |   "screenAccountBadges": "徽章", | ||||||
|  |   "accountBadges": "徽章", | ||||||
|  |   "accountBadgesDescription": "查看並管理你的徽章。", | ||||||
|  |   "badgeActivated": "已佩戴徽章 {}。", | ||||||
|  |   "viewDetailedAttachment": "查看附件詳情", | ||||||
|  |   "screenKeyPairs": "密鑰對", | ||||||
|  |   "accountKeyPairs": "密鑰對", | ||||||
|  |   "accountKeyPairsDescription": "管理用於加密信息的密鑰對。", | ||||||
|  |   "enrollNewKeyPair": "新建密鑰對", | ||||||
|  |   "enrollNewKeyPairDescription": "生成一對新密鑰對。", | ||||||
|  |   "keyPairHasPrivateKey": "有私鑰", | ||||||
|  |   "decrypting": "解密中……", | ||||||
|  |   "decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線", | ||||||
|  |   "messageUnablePreview": "無法預覽消息", | ||||||
|  |   "messageUnablePreviewEncrypted": "無法預覽加密消息" | ||||||
| } | } | ||||||
|   | |||||||
| @@ -5,3 +5,7 @@ targets: | |||||||
|         options: |         options: | ||||||
|           explicit_to_json: true |           explicit_to_json: true | ||||||
|           field_rename: snake |           field_rename: snake | ||||||
|  |       drift_dev: | ||||||
|  |         options: | ||||||
|  |           databases: | ||||||
|  |             my_database: lib/database/database.dart | ||||||
							
								
								
									
										14
									
								
								debian/debian.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								debian/debian.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | |||||||
|  | flutter_app:  | ||||||
|  |   command: surface | ||||||
|  |   arch: x64 | ||||||
|  |   parent: /usr/local/lib | ||||||
|  |   nonInteractive: false | ||||||
|  |  | ||||||
|  | control: | ||||||
|  |   Package: solian | ||||||
|  |   Version: 2.3.2 | ||||||
|  |   Architecture: amd64 | ||||||
|  |   Priority: optional | ||||||
|  |   Depends: mpv keybinder-3.0 | ||||||
|  |   Maintainer: Solsynth LLC | ||||||
|  |   Description: The Solar Network Desktop Application | ||||||
							
								
								
									
										9
									
								
								debian/gui/surface.desktop
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								debian/gui/surface.desktop
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | |||||||
|  | [Desktop Entry] | ||||||
|  | Version=2.3.2 | ||||||
|  | Name=Solian | ||||||
|  | GenericName=Solian | ||||||
|  | Comment=The Solar Network Desktop Application | ||||||
|  | Terminal=false | ||||||
|  | Type=Application | ||||||
|  | Categories=Social Networking | ||||||
|  | Keywords=social;social network;chat;solar network | ||||||
							
								
								
									
										23
									
								
								debian/gui/surface.svg
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								debian/gui/surface.svg
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| After Width: | Height: | Size: 232 KiB | 
							
								
								
									
										1
									
								
								drift_schemas/my_database/drift_schema_v1.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								drift_schemas/my_database/drift_schema_v1.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | {"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"sn_local_chat_channel","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"alias","getter_name":"alias","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnChannelConverter()","dart_type_name":"SnChannel"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"sn_local_chat_message","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"channel_id","getter_name":"channelId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnMessageConverter()","dart_type_name":"SnChatMessage"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}}]} | ||||||
							
								
								
									
										1
									
								
								drift_schemas/my_database/drift_schema_v2.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								drift_schemas/my_database/drift_schema_v2.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | {"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"sn_local_chat_channel","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"alias","getter_name":"alias","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnChannelConverter()","dart_type_name":"SnChannel"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"sn_local_chat_message","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"channel_id","getter_name":"channelId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnMessageConverter()","dart_type_name":"SnChatMessage"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":2,"references":[],"type":"table","data":{"name":"sn_local_key_pair","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"account_id","getter_name":"accountId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"public_key","getter_name":"publicKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"private_key","getter_name":"privateKey","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_active","getter_name":"isActive","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_active\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_active\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["id"]}}]} | ||||||
							
								
								
									
										1
									
								
								drift_schemas/my_database/drift_schema_v3.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								drift_schemas/my_database/drift_schema_v3.json
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										150
									
								
								ios/Podfile.lock
									
									
									
									
									
								
							
							
						
						
									
										150
									
								
								ios/Podfile.lock
									
									
									
									
									
								
							| @@ -2,7 +2,6 @@ PODS: | |||||||
|   - Alamofire (5.10.2) |   - Alamofire (5.10.2) | ||||||
|   - connectivity_plus (0.0.1): |   - connectivity_plus (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|     - FlutterMacOS |  | ||||||
|   - croppy (0.0.1): |   - croppy (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - device_info_plus (0.0.1): |   - device_info_plus (0.0.1): | ||||||
| @@ -38,63 +37,65 @@ PODS: | |||||||
|   - DKPhotoGallery/Resource (0.0.19): |   - DKPhotoGallery/Resource (0.0.19): | ||||||
|     - SDWebImage |     - SDWebImage | ||||||
|     - SwiftyGif |     - SwiftyGif | ||||||
|  |   - fast_rsa (0.6.0): | ||||||
|  |     - Flutter | ||||||
|   - file_picker (0.0.1): |   - file_picker (0.0.1): | ||||||
|     - DKImagePickerController/PhotoGallery |     - DKImagePickerController/PhotoGallery | ||||||
|     - Flutter |     - Flutter | ||||||
|   - file_saver (0.0.1): |   - file_saver (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - Firebase/Analytics (11.6.0): |   - Firebase/Analytics (11.8.0): | ||||||
|     - Firebase/Core |     - Firebase/Core | ||||||
|   - Firebase/Core (11.6.0): |   - Firebase/Core (11.8.0): | ||||||
|     - Firebase/CoreOnly |     - Firebase/CoreOnly | ||||||
|     - FirebaseAnalytics (~> 11.6.0) |     - FirebaseAnalytics (~> 11.8.0) | ||||||
|   - Firebase/CoreOnly (11.6.0): |   - Firebase/CoreOnly (11.8.0): | ||||||
|     - FirebaseCore (~> 11.6.0) |     - FirebaseCore (~> 11.8.0) | ||||||
|   - Firebase/Messaging (11.6.0): |   - Firebase/Messaging (11.8.0): | ||||||
|     - Firebase/CoreOnly |     - Firebase/CoreOnly | ||||||
|     - FirebaseMessaging (~> 11.6.0) |     - FirebaseMessaging (~> 11.8.0) | ||||||
|   - firebase_analytics (11.4.1): |   - firebase_analytics (11.4.4): | ||||||
|     - Firebase/Analytics (= 11.6.0) |     - Firebase/Analytics (= 11.8.0) | ||||||
|     - firebase_core |     - firebase_core | ||||||
|     - Flutter |     - Flutter | ||||||
|   - firebase_core (3.10.1): |   - firebase_core (3.12.1): | ||||||
|     - Firebase/CoreOnly (= 11.6.0) |     - Firebase/CoreOnly (= 11.8.0) | ||||||
|     - Flutter |     - Flutter | ||||||
|   - firebase_messaging (15.2.1): |   - firebase_messaging (15.2.4): | ||||||
|     - Firebase/Messaging (= 11.6.0) |     - Firebase/Messaging (= 11.8.0) | ||||||
|     - firebase_core |     - firebase_core | ||||||
|     - Flutter |     - Flutter | ||||||
|   - FirebaseAnalytics (11.6.0): |   - FirebaseAnalytics (11.8.0): | ||||||
|     - FirebaseAnalytics/AdIdSupport (= 11.6.0) |     - FirebaseAnalytics/AdIdSupport (= 11.8.0) | ||||||
|     - FirebaseCore (~> 11.6.0) |     - FirebaseCore (~> 11.8.0) | ||||||
|     - FirebaseInstallations (~> 11.0) |     - FirebaseInstallations (~> 11.0) | ||||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.0) |     - GoogleUtilities/AppDelegateSwizzler (~> 8.0) | ||||||
|     - GoogleUtilities/MethodSwizzler (~> 8.0) |     - GoogleUtilities/MethodSwizzler (~> 8.0) | ||||||
|     - GoogleUtilities/Network (~> 8.0) |     - GoogleUtilities/Network (~> 8.0) | ||||||
|     - "GoogleUtilities/NSData+zlib (~> 8.0)" |     - "GoogleUtilities/NSData+zlib (~> 8.0)" | ||||||
|     - nanopb (~> 3.30910.0) |     - nanopb (~> 3.30910.0) | ||||||
|   - FirebaseAnalytics/AdIdSupport (11.6.0): |   - FirebaseAnalytics/AdIdSupport (11.8.0): | ||||||
|     - FirebaseCore (~> 11.6.0) |     - FirebaseCore (~> 11.8.0) | ||||||
|     - FirebaseInstallations (~> 11.0) |     - FirebaseInstallations (~> 11.0) | ||||||
|     - GoogleAppMeasurement (= 11.6.0) |     - GoogleAppMeasurement (= 11.8.0) | ||||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.0) |     - GoogleUtilities/AppDelegateSwizzler (~> 8.0) | ||||||
|     - GoogleUtilities/MethodSwizzler (~> 8.0) |     - GoogleUtilities/MethodSwizzler (~> 8.0) | ||||||
|     - GoogleUtilities/Network (~> 8.0) |     - GoogleUtilities/Network (~> 8.0) | ||||||
|     - "GoogleUtilities/NSData+zlib (~> 8.0)" |     - "GoogleUtilities/NSData+zlib (~> 8.0)" | ||||||
|     - nanopb (~> 3.30910.0) |     - nanopb (~> 3.30910.0) | ||||||
|   - FirebaseCore (11.6.0): |   - FirebaseCore (11.8.1): | ||||||
|     - FirebaseCoreInternal (~> 11.6.0) |     - FirebaseCoreInternal (~> 11.8.0) | ||||||
|     - GoogleUtilities/Environment (~> 8.0) |     - GoogleUtilities/Environment (~> 8.0) | ||||||
|     - GoogleUtilities/Logger (~> 8.0) |     - GoogleUtilities/Logger (~> 8.0) | ||||||
|   - FirebaseCoreInternal (11.6.0): |   - FirebaseCoreInternal (11.8.0): | ||||||
|     - "GoogleUtilities/NSData+zlib (~> 8.0)" |     - "GoogleUtilities/NSData+zlib (~> 8.0)" | ||||||
|   - FirebaseInstallations (11.6.0): |   - FirebaseInstallations (11.8.0): | ||||||
|     - FirebaseCore (~> 11.6.0) |     - FirebaseCore (~> 11.8.0) | ||||||
|     - GoogleUtilities/Environment (~> 8.0) |     - GoogleUtilities/Environment (~> 8.0) | ||||||
|     - GoogleUtilities/UserDefaults (~> 8.0) |     - GoogleUtilities/UserDefaults (~> 8.0) | ||||||
|     - PromisesObjC (~> 2.4) |     - PromisesObjC (~> 2.4) | ||||||
|   - FirebaseMessaging (11.6.0): |   - FirebaseMessaging (11.8.0): | ||||||
|     - FirebaseCore (~> 11.6.0) |     - FirebaseCore (~> 11.8.0) | ||||||
|     - FirebaseInstallations (~> 11.0) |     - FirebaseInstallations (~> 11.0) | ||||||
|     - GoogleDataTransport (~> 10.0) |     - GoogleDataTransport (~> 10.0) | ||||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.0) |     - GoogleUtilities/AppDelegateSwizzler (~> 8.0) | ||||||
| @@ -114,6 +115,8 @@ PODS: | |||||||
|     - OrderedSet (~> 6.0.3) |     - OrderedSet (~> 6.0.3) | ||||||
|   - flutter_native_splash (2.4.3): |   - flutter_native_splash (2.4.3): | ||||||
|     - Flutter |     - Flutter | ||||||
|  |   - flutter_timezone (0.0.1): | ||||||
|  |     - Flutter | ||||||
|   - flutter_udid (0.0.1): |   - flutter_udid (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|     - SAMKeychain |     - SAMKeychain | ||||||
| @@ -123,21 +126,23 @@ PODS: | |||||||
|   - gal (1.0.0): |   - gal (1.0.0): | ||||||
|     - Flutter |     - Flutter | ||||||
|     - FlutterMacOS |     - FlutterMacOS | ||||||
|   - GoogleAppMeasurement (11.6.0): |   - geolocator_apple (1.2.0): | ||||||
|     - GoogleAppMeasurement/AdIdSupport (= 11.6.0) |     - Flutter | ||||||
|  |   - GoogleAppMeasurement (11.8.0): | ||||||
|  |     - GoogleAppMeasurement/AdIdSupport (= 11.8.0) | ||||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.0) |     - GoogleUtilities/AppDelegateSwizzler (~> 8.0) | ||||||
|     - GoogleUtilities/MethodSwizzler (~> 8.0) |     - GoogleUtilities/MethodSwizzler (~> 8.0) | ||||||
|     - GoogleUtilities/Network (~> 8.0) |     - GoogleUtilities/Network (~> 8.0) | ||||||
|     - "GoogleUtilities/NSData+zlib (~> 8.0)" |     - "GoogleUtilities/NSData+zlib (~> 8.0)" | ||||||
|     - nanopb (~> 3.30910.0) |     - nanopb (~> 3.30910.0) | ||||||
|   - GoogleAppMeasurement/AdIdSupport (11.6.0): |   - GoogleAppMeasurement/AdIdSupport (11.8.0): | ||||||
|     - GoogleAppMeasurement/WithoutAdIdSupport (= 11.6.0) |     - GoogleAppMeasurement/WithoutAdIdSupport (= 11.8.0) | ||||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.0) |     - GoogleUtilities/AppDelegateSwizzler (~> 8.0) | ||||||
|     - GoogleUtilities/MethodSwizzler (~> 8.0) |     - GoogleUtilities/MethodSwizzler (~> 8.0) | ||||||
|     - GoogleUtilities/Network (~> 8.0) |     - GoogleUtilities/Network (~> 8.0) | ||||||
|     - "GoogleUtilities/NSData+zlib (~> 8.0)" |     - "GoogleUtilities/NSData+zlib (~> 8.0)" | ||||||
|     - nanopb (~> 3.30910.0) |     - nanopb (~> 3.30910.0) | ||||||
|   - GoogleAppMeasurement/WithoutAdIdSupport (11.6.0): |   - GoogleAppMeasurement/WithoutAdIdSupport (11.8.0): | ||||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.0) |     - GoogleUtilities/AppDelegateSwizzler (~> 8.0) | ||||||
|     - GoogleUtilities/MethodSwizzler (~> 8.0) |     - GoogleUtilities/MethodSwizzler (~> 8.0) | ||||||
|     - GoogleUtilities/Network (~> 8.0) |     - GoogleUtilities/Network (~> 8.0) | ||||||
| @@ -179,8 +184,8 @@ PODS: | |||||||
|     - Flutter |     - Flutter | ||||||
|   - in_app_review (2.0.0): |   - in_app_review (2.0.0): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - Kingfisher (8.1.3) |   - Kingfisher (8.2.0) | ||||||
|   - livekit_client (2.3.5): |   - livekit_client (2.4.0): | ||||||
|     - Flutter |     - Flutter | ||||||
|     - flutter_webrtc |     - flutter_webrtc | ||||||
|     - WebRTC-SDK (= 125.6422.06) |     - WebRTC-SDK (= 125.6422.06) | ||||||
| @@ -211,9 +216,9 @@ PODS: | |||||||
|   - SAMKeychain (1.5.3) |   - SAMKeychain (1.5.3) | ||||||
|   - screen_brightness_ios (0.1.0): |   - screen_brightness_ios (0.1.0): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - SDWebImage (5.20.0): |   - SDWebImage (5.20.1): | ||||||
|     - SDWebImage/Core (= 5.20.0) |     - SDWebImage/Core (= 5.20.1) | ||||||
|   - SDWebImage/Core (5.20.0) |   - SDWebImage/Core (5.20.1) | ||||||
|   - share_plus (0.0.1): |   - share_plus (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - shared_preferences_foundation (0.0.1): |   - shared_preferences_foundation (0.0.1): | ||||||
| @@ -222,6 +227,25 @@ PODS: | |||||||
|   - sqflite_darwin (0.0.4): |   - sqflite_darwin (0.0.4): | ||||||
|     - Flutter |     - Flutter | ||||||
|     - FlutterMacOS |     - FlutterMacOS | ||||||
|  |   - sqlite3 (3.49.1): | ||||||
|  |     - sqlite3/common (= 3.49.1) | ||||||
|  |   - sqlite3/common (3.49.1) | ||||||
|  |   - sqlite3/dbstatvtab (3.49.1): | ||||||
|  |     - sqlite3/common | ||||||
|  |   - sqlite3/fts5 (3.49.1): | ||||||
|  |     - sqlite3/common | ||||||
|  |   - sqlite3/perf-threadsafe (3.49.1): | ||||||
|  |     - sqlite3/common | ||||||
|  |   - sqlite3/rtree (3.49.1): | ||||||
|  |     - sqlite3/common | ||||||
|  |   - sqlite3_flutter_libs (0.0.1): | ||||||
|  |     - Flutter | ||||||
|  |     - FlutterMacOS | ||||||
|  |     - sqlite3 (~> 3.49.1) | ||||||
|  |     - sqlite3/dbstatvtab | ||||||
|  |     - sqlite3/fts5 | ||||||
|  |     - sqlite3/perf-threadsafe | ||||||
|  |     - sqlite3/rtree | ||||||
|   - SwiftyGif (5.4.5) |   - SwiftyGif (5.4.5) | ||||||
|   - url_launcher_ios (0.0.1): |   - url_launcher_ios (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
| @@ -237,9 +261,10 @@ PODS: | |||||||
|  |  | ||||||
| DEPENDENCIES: | DEPENDENCIES: | ||||||
|   - Alamofire |   - Alamofire | ||||||
|   - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`) |   - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) | ||||||
|   - croppy (from `.symlinks/plugins/croppy/ios`) |   - croppy (from `.symlinks/plugins/croppy/ios`) | ||||||
|   - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) |   - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) | ||||||
|  |   - fast_rsa (from `.symlinks/plugins/fast_rsa/ios`) | ||||||
|   - file_picker (from `.symlinks/plugins/file_picker/ios`) |   - file_picker (from `.symlinks/plugins/file_picker/ios`) | ||||||
|   - file_saver (from `.symlinks/plugins/file_saver/ios`) |   - file_saver (from `.symlinks/plugins/file_saver/ios`) | ||||||
|   - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) |   - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) | ||||||
| @@ -249,9 +274,11 @@ DEPENDENCIES: | |||||||
|   - flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`) |   - flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`) | ||||||
|   - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) |   - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) | ||||||
|   - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) |   - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) | ||||||
|  |   - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`) | ||||||
|   - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) |   - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) | ||||||
|   - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) |   - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) | ||||||
|   - gal (from `.symlinks/plugins/gal/darwin`) |   - gal (from `.symlinks/plugins/gal/darwin`) | ||||||
|  |   - geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`) | ||||||
|   - home_widget (from `.symlinks/plugins/home_widget/ios`) |   - home_widget (from `.symlinks/plugins/home_widget/ios`) | ||||||
|   - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) |   - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) | ||||||
|   - in_app_review (from `.symlinks/plugins/in_app_review/ios`) |   - in_app_review (from `.symlinks/plugins/in_app_review/ios`) | ||||||
| @@ -269,6 +296,7 @@ DEPENDENCIES: | |||||||
|   - share_plus (from `.symlinks/plugins/share_plus/ios`) |   - share_plus (from `.symlinks/plugins/share_plus/ios`) | ||||||
|   - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) |   - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) | ||||||
|   - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) |   - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) | ||||||
|  |   - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) | ||||||
|   - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) |   - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) | ||||||
|   - video_compress (from `.symlinks/plugins/video_compress/ios`) |   - video_compress (from `.symlinks/plugins/video_compress/ios`) | ||||||
|   - volume_controller (from `.symlinks/plugins/volume_controller/ios`) |   - volume_controller (from `.symlinks/plugins/volume_controller/ios`) | ||||||
| @@ -295,16 +323,19 @@ SPEC REPOS: | |||||||
|     - PromisesObjC |     - PromisesObjC | ||||||
|     - SAMKeychain |     - SAMKeychain | ||||||
|     - SDWebImage |     - SDWebImage | ||||||
|  |     - sqlite3 | ||||||
|     - SwiftyGif |     - SwiftyGif | ||||||
|     - WebRTC-SDK |     - WebRTC-SDK | ||||||
|  |  | ||||||
| EXTERNAL SOURCES: | EXTERNAL SOURCES: | ||||||
|   connectivity_plus: |   connectivity_plus: | ||||||
|     :path: ".symlinks/plugins/connectivity_plus/darwin" |     :path: ".symlinks/plugins/connectivity_plus/ios" | ||||||
|   croppy: |   croppy: | ||||||
|     :path: ".symlinks/plugins/croppy/ios" |     :path: ".symlinks/plugins/croppy/ios" | ||||||
|   device_info_plus: |   device_info_plus: | ||||||
|     :path: ".symlinks/plugins/device_info_plus/ios" |     :path: ".symlinks/plugins/device_info_plus/ios" | ||||||
|  |   fast_rsa: | ||||||
|  |     :path: ".symlinks/plugins/fast_rsa/ios" | ||||||
|   file_picker: |   file_picker: | ||||||
|     :path: ".symlinks/plugins/file_picker/ios" |     :path: ".symlinks/plugins/file_picker/ios" | ||||||
|   file_saver: |   file_saver: | ||||||
| @@ -323,12 +354,16 @@ EXTERNAL SOURCES: | |||||||
|     :path: ".symlinks/plugins/flutter_inappwebview_ios/ios" |     :path: ".symlinks/plugins/flutter_inappwebview_ios/ios" | ||||||
|   flutter_native_splash: |   flutter_native_splash: | ||||||
|     :path: ".symlinks/plugins/flutter_native_splash/ios" |     :path: ".symlinks/plugins/flutter_native_splash/ios" | ||||||
|  |   flutter_timezone: | ||||||
|  |     :path: ".symlinks/plugins/flutter_timezone/ios" | ||||||
|   flutter_udid: |   flutter_udid: | ||||||
|     :path: ".symlinks/plugins/flutter_udid/ios" |     :path: ".symlinks/plugins/flutter_udid/ios" | ||||||
|   flutter_webrtc: |   flutter_webrtc: | ||||||
|     :path: ".symlinks/plugins/flutter_webrtc/ios" |     :path: ".symlinks/plugins/flutter_webrtc/ios" | ||||||
|   gal: |   gal: | ||||||
|     :path: ".symlinks/plugins/gal/darwin" |     :path: ".symlinks/plugins/gal/darwin" | ||||||
|  |   geolocator_apple: | ||||||
|  |     :path: ".symlinks/plugins/geolocator_apple/ios" | ||||||
|   home_widget: |   home_widget: | ||||||
|     :path: ".symlinks/plugins/home_widget/ios" |     :path: ".symlinks/plugins/home_widget/ios" | ||||||
|   image_picker_ios: |   image_picker_ios: | ||||||
| @@ -361,6 +396,8 @@ EXTERNAL SOURCES: | |||||||
|     :path: ".symlinks/plugins/shared_preferences_foundation/darwin" |     :path: ".symlinks/plugins/shared_preferences_foundation/darwin" | ||||||
|   sqflite_darwin: |   sqflite_darwin: | ||||||
|     :path: ".symlinks/plugins/sqflite_darwin/darwin" |     :path: ".symlinks/plugins/sqflite_darwin/darwin" | ||||||
|  |   sqlite3_flutter_libs: | ||||||
|  |     :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin" | ||||||
|   url_launcher_ios: |   url_launcher_ios: | ||||||
|     :path: ".symlinks/plugins/url_launcher_ios/ios" |     :path: ".symlinks/plugins/url_launcher_ios/ios" | ||||||
|   video_compress: |   video_compress: | ||||||
| @@ -374,37 +411,40 @@ EXTERNAL SOURCES: | |||||||
|  |  | ||||||
| SPEC CHECKSUMS: | SPEC CHECKSUMS: | ||||||
|   Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496 |   Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496 | ||||||
|   connectivity_plus: 18382e7311ba19efcaee94442b23b32507b20695 |   connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d | ||||||
|   croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321 |   croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321 | ||||||
|   device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 |   device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 | ||||||
|   DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c |   DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c | ||||||
|   DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 |   DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 | ||||||
|  |   fast_rsa: dc48fb05f26bb108863de122b2a9f5554e8e2591 | ||||||
|   file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 |   file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 | ||||||
|   file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808 |   file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808 | ||||||
|   Firebase: 374a441a91ead896215703a674d58cdb3e9d772b |   Firebase: d80354ed7f6df5f9aca55e9eb47cc4b634735eaf | ||||||
|   firebase_analytics: 13ea4ad8a42c5060bad7e6694304dabb8b02fe7e |   firebase_analytics: e3b6782e70e32b7fa18f7cd233e3201975dd86aa | ||||||
|   firebase_core: e2aa06dbd854d961f8ce46c2e20933bee1bf2d2b |   firebase_core: ac395f994af4e28f6a38b59e05a88ca57abeb874 | ||||||
|   firebase_messaging: 96cf6d67121b3f39746b2a4f29a26c0eee4af70e |   firebase_messaging: 7e223f4ee7ca053bf4ce43748e84a6d774ec9728 | ||||||
|   FirebaseAnalytics: 7114c698cac995602e3b1b96663473e50d54d6e7 |   FirebaseAnalytics: 4fd42def128146e24e480e89f310e3d8534ea42b | ||||||
|   FirebaseCore: 48b0dd707581cf9c1a1220da68223fb0a562afaa |   FirebaseCore: 99fe0c4b44a39f37d99e6404e02009d2db5d718d | ||||||
|   FirebaseCoreInternal: d98ab91e2d80a56d7b246856a8885443b302c0c2 |   FirebaseCoreInternal: df24ce5af28864660ecbd13596fc8dd3a8c34629 | ||||||
|   FirebaseInstallations: efc0946fc756e4d22d8113f7c761948120322e8c |   FirebaseInstallations: 6c963bd2a86aca0481eef4f48f5a4df783ae5917 | ||||||
|   FirebaseMessaging: e1aca1fcc23e8b9eddb0e33f375ff90944623021 |   FirebaseMessaging: 487b634ccdf6f7b7ff180fdcb2a9935490f764e8 | ||||||
|   Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 |   Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 | ||||||
|   flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc |   flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc | ||||||
|   flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4 |   flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4 | ||||||
|   flutter_native_splash: f71420956eb811e6d310720fee915f1d42852e7a |   flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29 | ||||||
|  |   flutter_timezone: ac3da59ac941ff1c98a2e1f0293420e020120282 | ||||||
|   flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab |   flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab | ||||||
|   flutter_webrtc: 90260f83024b1b96d239a575ea4e3708e79344d1 |   flutter_webrtc: 90260f83024b1b96d239a575ea4e3708e79344d1 | ||||||
|   gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5 |   gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5 | ||||||
|   GoogleAppMeasurement: 6a9e6317b6a6d810ad03d4a66564ca6c4c5818a3 |   geolocator_apple: 9bcea1918ff7f0062d98345d238ae12718acfbc1 | ||||||
|  |   GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896 | ||||||
|   GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 |   GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 | ||||||
|   GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d |   GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d | ||||||
|   home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57 |   home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57 | ||||||
|   image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 |   image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 | ||||||
|   in_app_review: a31b5257259646ea78e0e35fc914979b0031d011 |   in_app_review: a31b5257259646ea78e0e35fc914979b0031d011 | ||||||
|   Kingfisher: f2af9028b16baf9dc6c07c570072bc41cbf009ef |   Kingfisher: 323e5c4ec7983aaace12af655a7b51a7f88a599d | ||||||
|   livekit_client: dcc5fd47ba69c98fc6baeb12e862c9d43807d976 |   livekit_client: 9819ebc8be8ef00ed0fae7d806bf8938ec689573 | ||||||
|   media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1 |   media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1 | ||||||
|   media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a |   media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a | ||||||
|   media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e |   media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e | ||||||
| @@ -418,10 +458,12 @@ SPEC CHECKSUMS: | |||||||
|   receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1 |   receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1 | ||||||
|   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c |   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c | ||||||
|   screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625 |   screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625 | ||||||
|   SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8 |   SDWebImage: 33d0f23bddeb5d209ae959153883247be6703713 | ||||||
|   share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f |   share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f | ||||||
|   shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 |   shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 | ||||||
|   sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d |   sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d | ||||||
|  |   sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983 | ||||||
|  |   sqlite3_flutter_libs: cc304edcb8e1d8c595d1b08c7aeb46a47691d9db | ||||||
|   SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 |   SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 | ||||||
|   url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe |   url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe | ||||||
|   video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe |   video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe | ||||||
|   | |||||||
| @@ -59,6 +59,7 @@ | |||||||
|       ignoresPersistentStateOnLaunch = "NO" |       ignoresPersistentStateOnLaunch = "NO" | ||||||
|       debugDocumentVersioning = "YES" |       debugDocumentVersioning = "YES" | ||||||
|       debugServiceExtension = "internal" |       debugServiceExtension = "internal" | ||||||
|  |       enableGPUValidationMode = "1" | ||||||
|       allowLocationSimulation = "YES"> |       allowLocationSimulation = "YES"> | ||||||
|       <BuildableProductRunnable |       <BuildableProductRunnable | ||||||
|          runnableDebuggingMode = "0"> |          runnableDebuggingMode = "0"> | ||||||
|   | |||||||
| @@ -123,25 +123,35 @@ class NotificationService: UNNotificationServiceExtension { | |||||||
|         } |         } | ||||||
|          |          | ||||||
|         if let imageIdentifier = metadata["image"] as? String { |         if let imageIdentifier = metadata["image"] as? String { | ||||||
|             attachMedia(to: content, withIdentifier: imageIdentifier, fileType: UTType.jpeg, doScaleDown: true) |             attachMedia(to: content, withIdentifier: [imageIdentifier], fileType: UTType.jpeg, doScaleDown: true) | ||||||
|         } else if let avatarIdentifier = metadata["avatar"] as? String { |         } else if let avatarIdentifier = metadata["avatar"] as? String { | ||||||
|             attachMedia(to: content, withIdentifier: avatarIdentifier, fileType: UTType.jpeg, doScaleDown: true) |             attachMedia(to: content, withIdentifier: [avatarIdentifier], fileType: UTType.jpeg, doScaleDown: true) | ||||||
|  |         } else if let imagesIdentifier = metadata["images"] as? Array<String> { | ||||||
|  |             attachMedia(to: content, withIdentifier: imagesIdentifier, fileType: UTType.jpeg, doScaleDown: true) | ||||||
|         } else { |         } else { | ||||||
|             contentHandler?(content) |             contentHandler?(content) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|      |      | ||||||
|     private func attachMedia(to content: UNMutableNotificationContent, withIdentifier identifier: String, fileType type: UTType?, doScaleDown scaleDown: Bool = false) { |     private func attachMedia(to content: UNMutableNotificationContent, withIdentifier identifier: Array<String>, fileType type: UTType?, doScaleDown scaleDown: Bool = false) { | ||||||
|         let attachmentUrl = getAttachmentUrl(for: identifier) |         let attachmentUrls = identifier.compactMap { element in | ||||||
|  |             return getAttachmentUrl(for: element) | ||||||
|  |         } | ||||||
|  |  | ||||||
|         guard let remoteUrl = URL(string: attachmentUrl) else { |         guard !attachmentUrls.isEmpty else { | ||||||
|             print("Invalid URL for attachment: \(attachmentUrl)") |             print("Invalid URLs for attachments: \(attachmentUrls)") | ||||||
|             return |             return | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         let targetSize = 800 |         let targetSize = 800 | ||||||
|         let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit) |         let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit) | ||||||
|  |  | ||||||
|  |         for attachmentUrl in attachmentUrls { | ||||||
|  |             guard let remoteUrl = URL(string: attachmentUrl) else { | ||||||
|  |                 print("Invalid URL for attachment: \(attachmentUrl)") | ||||||
|  |                 continue // Skip this URL and move to the next one | ||||||
|  |             } | ||||||
|  |  | ||||||
|             KingfisherManager.shared.retrieveImage(with: remoteUrl, options: scaleDown ? [ |             KingfisherManager.shared.retrieveImage(with: remoteUrl, options: scaleDown ? [ | ||||||
|                 .processor(scaleProcessor) |                 .processor(scaleProcessor) | ||||||
|             ] : nil) { [weak self] result in |             ] : nil) { [weak self] result in | ||||||
| @@ -151,12 +161,12 @@ class NotificationService: UNNotificationServiceExtension { | |||||||
|                 case .success(let retrievalResult): |                 case .success(let retrievalResult): | ||||||
|                     // The image is either retrieved from cache or downloaded |                     // The image is either retrieved from cache or downloaded | ||||||
|                     let tempDirectory = FileManager.default.temporaryDirectory |                     let tempDirectory = FileManager.default.temporaryDirectory | ||||||
|                 let cachedFileUrl = tempDirectory.appendingPathComponent(identifier) |                     let cachedFileUrl = tempDirectory.appendingPathComponent(UUID().uuidString) // Unique identifier for each file | ||||||
|  |  | ||||||
|                     do { |                     do { | ||||||
|                         // Write the image data to a temporary file for UNNotificationAttachment |                         // Write the image data to a temporary file for UNNotificationAttachment | ||||||
|                         try retrievalResult.image.pngData()?.write(to: cachedFileUrl) |                         try retrievalResult.image.pngData()?.write(to: cachedFileUrl) | ||||||
|                     self.attachLocalMedia(to: content, fileType: type?.identifier, from: cachedFileUrl, withIdentifier: identifier) |                         self.attachLocalMedia(to: content, fileType: type?.identifier, from: cachedFileUrl, withIdentifier: attachmentUrl) | ||||||
|                     } catch { |                     } catch { | ||||||
|                         print("Failed to write media to temporary file: \(error.localizedDescription)") |                         print("Failed to write media to temporary file: \(error.localizedDescription)") | ||||||
|                         self.contentHandler?(content) |                         self.contentHandler?(content) | ||||||
| @@ -168,6 +178,7 @@ class NotificationService: UNNotificationServiceExtension { | |||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |     } | ||||||
|      |      | ||||||
|     private func attachLocalMedia(to content: UNMutableNotificationContent, fileType type: String?, from localUrl: URL, withIdentifier identifier: String) { |     private func attachLocalMedia(to content: UNMutableNotificationContent, fileType type: String?, from localUrl: URL, withIdentifier identifier: String) { | ||||||
|         do { |         do { | ||||||
|   | |||||||
| @@ -55,7 +55,7 @@ struct CheckInEntry: TimelineEntry { | |||||||
| struct CheckInWidgetEntryView : View { | struct CheckInWidgetEntryView : View { | ||||||
|     var entry: CheckInProvider.Entry |     var entry: CheckInProvider.Entry | ||||||
|  |  | ||||||
|     private let resultTierSymbols: [String] = ["大凶", "凶", "中平", "吉", "大吉"] |     private let resultTierSymbols: [String] = ["Bad", "Poor", "Medium", "Good", "Great"] | ||||||
|  |  | ||||||
|     func checkIn() -> Void {} |     func checkIn() -> Void {} | ||||||
|  |  | ||||||
| @@ -91,7 +91,7 @@ struct CheckInWidgetEntryView : View { | |||||||
|             } else { |             } else { | ||||||
|                 VStack(alignment: .leading) { |                 VStack(alignment: .leading) { | ||||||
|                     Text("Check In").font(.system(size: 19, weight: .bold)) |                     Text("Check In").font(.system(size: 19, weight: .bold)) | ||||||
|                     Text("You haven't check in today").font(.system(size: 15)) |                     Text("You haven't divined today").font(.system(size: 15)) | ||||||
|                 }.padding(.horizontal, 4) |                 }.padding(.horizontal, 4) | ||||||
|  |  | ||||||
|                 Spacer() |                 Spacer() | ||||||
|   | |||||||
| @@ -2,11 +2,15 @@ import 'dart:async'; | |||||||
| import 'dart:convert'; | import 'dart:convert'; | ||||||
| import 'dart:math' as math; | import 'dart:math' as math; | ||||||
|  |  | ||||||
| import 'package:collection/collection.dart'; |  | ||||||
| import 'package:dio/dio.dart'; | import 'package:dio/dio.dart'; | ||||||
|  | import 'package:drift/drift.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:hive/hive.dart'; |  | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/database/database.dart'; | ||||||
|  | import 'package:surface/logger.dart'; | ||||||
|  | import 'package:surface/providers/channel.dart'; | ||||||
|  | import 'package:surface/providers/database.dart'; | ||||||
|  | import 'package:surface/providers/keypair.dart'; | ||||||
| import 'package:surface/providers/sn_attachment.dart'; | import 'package:surface/providers/sn_attachment.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/providers/user_directory.dart'; | import 'package:surface/providers/user_directory.dart'; | ||||||
| @@ -16,13 +20,15 @@ import 'package:surface/types/websocket.dart'; | |||||||
| import 'package:uuid/uuid.dart'; | import 'package:uuid/uuid.dart'; | ||||||
|  |  | ||||||
| class ChatMessageController extends ChangeNotifier { | class ChatMessageController extends ChangeNotifier { | ||||||
|   static const kChatMessageBoxPrefix = 'nex_chat_messages_'; |  | ||||||
|   static const kSingleBatchLoadLimit = 100; |   static const kSingleBatchLoadLimit = 100; | ||||||
|  |  | ||||||
|   late final SnNetworkProvider _sn; |   late final SnNetworkProvider _sn; | ||||||
|   late final UserDirectoryProvider _ud; |   late final UserDirectoryProvider _ud; | ||||||
|   late final WebSocketProvider _ws; |   late final WebSocketProvider _ws; | ||||||
|   late final SnAttachmentProvider _attach; |   late final SnAttachmentProvider _attach; | ||||||
|  |   late final DatabaseProvider _dt; | ||||||
|  |   late final ChatChannelProvider _ct; | ||||||
|  |   late final KeyPairProvider _kp; | ||||||
|  |  | ||||||
|   StreamSubscription? _wsSubscription; |   StreamSubscription? _wsSubscription; | ||||||
|  |  | ||||||
| @@ -31,16 +37,20 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|     _ud = context.read<UserDirectoryProvider>(); |     _ud = context.read<UserDirectoryProvider>(); | ||||||
|     _ws = context.read<WebSocketProvider>(); |     _ws = context.read<WebSocketProvider>(); | ||||||
|     _attach = context.read<SnAttachmentProvider>(); |     _attach = context.read<SnAttachmentProvider>(); | ||||||
|  |     _ct = context.read<ChatChannelProvider>(); | ||||||
|  |     _dt = context.read<DatabaseProvider>(); | ||||||
|  |     _kp = context.read<KeyPairProvider>(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   bool isPending = true; |   bool isPending = true; | ||||||
|   bool isLoading = false; |   bool isLoading = false; | ||||||
|  |   bool isAggressiveLoading = false; | ||||||
|  |  | ||||||
|   int? messageTotal; |   int? messageTotal; | ||||||
|  |  | ||||||
|   bool get isAllLoaded => messageTotal != null && messages.length >= messageTotal!; |   bool get isAllLoaded => | ||||||
|  |       messageTotal != null && messages.length >= messageTotal!; | ||||||
|  |  | ||||||
|   String? _boxKey; |  | ||||||
|   SnChannel? channel; |   SnChannel? channel; | ||||||
|   SnChannelMember? profile; |   SnChannelMember? profile; | ||||||
|  |  | ||||||
| @@ -51,27 +61,16 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|   /// Stored as a list of nonce to provide the loading state |   /// Stored as a list of nonce to provide the loading state | ||||||
|   final List<String> unconfirmedMessages = List.empty(growable: true); |   final List<String> unconfirmedMessages = List.empty(growable: true); | ||||||
|  |  | ||||||
|   Box<SnChatMessage>? get _box => (_boxKey == null || isPending) ? null : Hive.box<SnChatMessage>(_boxKey!); |  | ||||||
|  |  | ||||||
|   final List<SnChannelMember> typingMembers = List.empty(growable: true); |   final List<SnChannelMember> typingMembers = List.empty(growable: true); | ||||||
|   final Map<int, Timer> typingInactiveTimer = {}; |   final Map<int, Timer> typingInactiveTimer = {}; | ||||||
|  |  | ||||||
|   Future<void> initialize(SnChannel chan) async { |   Future<void> initialize(SnChannel chan) async { | ||||||
|     channel = chan; |     channel = chan; | ||||||
|  |  | ||||||
|     // Initialize local data |  | ||||||
|     _boxKey = '$kChatMessageBoxPrefix${chan.id}'; |  | ||||||
|     await Hive.openBox<SnChatMessage>(_boxKey!); |  | ||||||
|  |  | ||||||
|     // Fetch channel profile |     // Fetch channel profile | ||||||
|     final resp = await _sn.client.get( |     profile = await _ct.getChannelProfile(channel!); | ||||||
|       '/cgi/im/channels/${chan.keyPath}/me', |  | ||||||
|     ); |  | ||||||
|     profile = SnChannelMember.fromJson( |  | ||||||
|       resp.data as Map<String, dynamic>, |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     _wsSubscription = _ws.stream.stream.listen((event) { |     _wsSubscription = _ws.pk.stream.listen((event) { | ||||||
|       switch (event.method) { |       switch (event.method) { | ||||||
|         case 'events.new': |         case 'events.new': | ||||||
|           if (event.payload?['channel_id'] != channel?.id) break; |           if (event.payload?['channel_id'] != channel?.id) break; | ||||||
| @@ -87,7 +86,8 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|             notifyListeners(); |             notifyListeners(); | ||||||
|           } |           } | ||||||
|           typingInactiveTimer[member.id]?.cancel(); |           typingInactiveTimer[member.id]?.cancel(); | ||||||
|           typingInactiveTimer[member.id] = Timer(const Duration(seconds: 3), () { |           typingInactiveTimer[member.id] = | ||||||
|  |               Timer(const Duration(seconds: 3), () { | ||||||
|             typingMembers.removeWhere((x) => x.id == member.id); |             typingMembers.removeWhere((x) => x.id == member.id); | ||||||
|             typingInactiveTimer.remove(member.id); |             typingInactiveTimer.remove(member.id); | ||||||
|             notifyListeners(); |             notifyListeners(); | ||||||
| @@ -129,10 +129,16 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> _saveMessageToLocal(Iterable<SnChatMessage> messages) async { |   Future<void> _saveMessageToLocal(Iterable<SnChatMessage> messages) async { | ||||||
|     if (_box == null) return; |     await _dt.db.snLocalChatMessage.insertAll( | ||||||
|     await _box!.putAll({ |         messages.map( | ||||||
|       for (final message in messages) message.id: message, |           (ele) => SnLocalChatMessageCompanion.insert( | ||||||
|     }); |             id: Value(ele.id), | ||||||
|  |             content: ele, | ||||||
|  |             channelId: channel!.id, | ||||||
|  |             createdAt: Value(ele.createdAt), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |         onConflict: DoNothing()); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> _addUnconfirmedMessage(SnChatMessage message) async { |   Future<void> _addUnconfirmedMessage(SnChatMessage message) async { | ||||||
| @@ -181,11 +187,27 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|     } else { |     } else { | ||||||
|       messages.insert(0, message); |       messages.insert(0, message); | ||||||
|     } |     } | ||||||
|  |     notifyListeners(); | ||||||
|     await _applyMessage(message); |     await _applyMessage(message); | ||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|  |  | ||||||
|     if (_box == null) return; |     if (isCheckedUpdate) { | ||||||
|     await _box!.put(message.id, message); |       await _dt.db.snLocalChatMessage.insertOne( | ||||||
|  |         SnLocalChatMessageCompanion.insert( | ||||||
|  |           id: Value(message.id), | ||||||
|  |           content: message, | ||||||
|  |           channelId: channel!.id, | ||||||
|  |           createdAt: Value(message.createdAt), | ||||||
|  |         ), | ||||||
|  |         onConflict: DoUpdate( | ||||||
|  |           (_) => SnLocalChatMessageCompanion.custom( | ||||||
|  |             content: Constant(jsonEncode(message.toJson())), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |     } else { | ||||||
|  |       incomeStrandedQueue.add(message); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> _applyMessage(SnChatMessage message) async { |   Future<void> _applyMessage(SnChatMessage message) async { | ||||||
| @@ -194,29 +216,56 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|     switch (message.type) { |     switch (message.type) { | ||||||
|       case 'messages.edit': |       case 'messages.edit': | ||||||
|         if (message.relatedEventId != null) { |         if (message.relatedEventId != null) { | ||||||
|           final idx = messages.indexWhere((x) => x.id == message.relatedEventId); |           final idx = | ||||||
|  |               messages.indexWhere((x) => x.id == message.relatedEventId); | ||||||
|           if (idx != -1) { |           if (idx != -1) { | ||||||
|             final newBody = message.body; |             final newBody = Map<String, dynamic>.from(message.body); | ||||||
|             newBody.remove('related_event'); |             newBody.remove('related_event'); | ||||||
|             messages[idx] = messages[idx].copyWith( |             messages[idx] = messages[idx].copyWith( | ||||||
|               body: newBody, |               body: newBody, | ||||||
|               updatedAt: message.updatedAt, |               updatedAt: message.updatedAt, | ||||||
|             ); |             ); | ||||||
|             if (_box!.containsKey(message.relatedEventId)) { |  | ||||||
|               await _box!.put(message.relatedEventId, messages[idx]); |  | ||||||
|           } |           } | ||||||
|  |           if (message.relatedEventId != null) { | ||||||
|  |             await (_dt.db.snLocalChatMessage.update() | ||||||
|  |                   ..where((e) => e.id.equals(message.relatedEventId!))) | ||||||
|  |                 .write( | ||||||
|  |               SnLocalChatMessageCompanion.custom( | ||||||
|  |                 content: Constant(jsonEncode(messages[idx].toJson())), | ||||||
|  |               ), | ||||||
|  |             ); | ||||||
|           } |           } | ||||||
|         } |         } | ||||||
|       case 'messages.delete': |       case 'messages.delete': | ||||||
|         if (message.relatedEventId != null) { |         if (message.relatedEventId != null) { | ||||||
|           messages.removeWhere((x) => x.id == message.relatedEventId); |           messages.removeWhere((x) => x.id == message.relatedEventId); | ||||||
|           if (_box!.containsKey(message.relatedEventId)) { |           if (message.relatedEventId != null) { | ||||||
|             await _box!.delete(message.relatedEventId); |             await (_dt.db.snLocalChatMessage.delete() | ||||||
|  |                   ..where((e) => e.id.equals(message.relatedEventId!))) | ||||||
|  |                 .go(); | ||||||
|           } |           } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   Future<Map<String, dynamic>> _encodeMessageBody( | ||||||
|  |     String text, | ||||||
|  |     bool isEncrypted, | ||||||
|  |   ) async { | ||||||
|  |     if (!isEncrypted || _kp.activeKp == null) { | ||||||
|  |       return { | ||||||
|  |         'text': text, | ||||||
|  |         'algorithm': 'plain', | ||||||
|  |       }; | ||||||
|  |     } else { | ||||||
|  |       return { | ||||||
|  |         'text': await _kp.encryptText(text), | ||||||
|  |         'algorithm': 'rsa', | ||||||
|  |         'keypair_id': _kp.activeKp!.id, | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   Future<void> sendMessage( |   Future<void> sendMessage( | ||||||
|     String type, |     String type, | ||||||
|     String content, { |     String content, { | ||||||
| @@ -224,19 +273,22 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|     int? relatedId, |     int? relatedId, | ||||||
|     List<String>? attachments, |     List<String>? attachments, | ||||||
|     SnChatMessage? editingMessage, |     SnChatMessage? editingMessage, | ||||||
|  |     bool isEncrypted = false, | ||||||
|   }) async { |   }) async { | ||||||
|     if (channel == null) return; |     if (channel == null) return; | ||||||
|     const uuid = Uuid(); |     const uuid = Uuid(); | ||||||
|     final nonce = uuid.v4(); |     final nonce = uuid.v4(); | ||||||
|     final body = { |     final body = { | ||||||
|       'text': content, |       ...(await _encodeMessageBody(content, isEncrypted)), | ||||||
|       'algorithm': 'plain', |  | ||||||
|       if (quoteId != null) 'quote_event': quoteId, |       if (quoteId != null) 'quote_event': quoteId, | ||||||
|       if (relatedId != null) 'related_event': relatedId, |       if (relatedId != null) 'related_event': relatedId, | ||||||
|       if (attachments != null && attachments.isNotEmpty) 'attachments': attachments, |       if (attachments != null && attachments.isNotEmpty) | ||||||
|  |         'attachments': attachments, | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Mock the message locally |     // Mock the message locally | ||||||
|  |     // Do not mock the editing message | ||||||
|  |     if (editingMessage == null) { | ||||||
|       final createdAt = DateTime.now(); |       final createdAt = DateTime.now(); | ||||||
|       final message = SnChatMessage( |       final message = SnChatMessage( | ||||||
|         id: 0, |         id: 0, | ||||||
| @@ -254,6 +306,7 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|         relatedEventId: relatedId, |         relatedEventId: relatedId, | ||||||
|       ); |       ); | ||||||
|       _addUnconfirmedMessage(message); |       _addUnconfirmedMessage(message); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     // Send to server |     // Send to server | ||||||
|     try { |     try { | ||||||
| @@ -287,20 +340,36 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   bool isCheckedUpdate = false; | ||||||
|  |   List<SnChatMessage> incomeStrandedQueue = List.empty(growable: true); | ||||||
|  |  | ||||||
|   /// Check the local storage is up to date with the server. |   /// Check the local storage is up to date with the server. | ||||||
|   /// If the local storage is not up to date, it will be updated. |   /// If the local storage is not up to date, it will be updated. | ||||||
|   Future<void> checkUpdate() async { |   Future<void> checkUpdate() async { | ||||||
|     if (_box == null) return; |     isAggressiveLoading = true; | ||||||
|     if (_box!.isEmpty) return; |  | ||||||
|  |  | ||||||
|     isLoading = true; |  | ||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|  |  | ||||||
|  |     final mostRecentMessage = await (_dt.db.snLocalChatMessage.select() | ||||||
|  |           ..where((e) => e.channelId.equals(channel!.id)) | ||||||
|  |           ..limit(1) | ||||||
|  |           ..orderBy([ | ||||||
|  |             (e) => | ||||||
|  |                 OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc) | ||||||
|  |           ])) | ||||||
|  |         .getSingleOrNull(); | ||||||
|  |     if (mostRecentMessage == null) { | ||||||
|  |       // Initial load | ||||||
|  |       await loadMessages(take: 20); | ||||||
|  |       isAggressiveLoading = false; | ||||||
|  |       isCheckedUpdate = true; | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       final resp = await _sn.client.get( |       final resp = await _sn.client.get( | ||||||
|         '/cgi/im/channels/${channel!.keyPath}/events/update', |         '/cgi/im/channels/${channel!.keyPath}/events/update', | ||||||
|         queryParameters: { |         queryParameters: { | ||||||
|           'pivot': _box!.values.last.id, |           'pivot': mostRecentMessage.content.id, | ||||||
|         }, |         }, | ||||||
|       ); |       ); | ||||||
|       if (resp.data['up_to_date'] == true) return; |       if (resp.data['up_to_date'] == true) return; | ||||||
| @@ -309,13 +378,25 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|       final countToFetch = math.min(resp.data['count'] as int, 100); |       final countToFetch = math.min(resp.data['count'] as int, 100); | ||||||
|  |  | ||||||
|       for (int idx = 0; idx < countToFetch; idx += kSingleBatchLoadLimit) { |       for (int idx = 0; idx < countToFetch; idx += kSingleBatchLoadLimit) { | ||||||
|         await getMessages(kSingleBatchLoadLimit, idx, forceRemote: true); |         final out = await getMessages( | ||||||
|  |           kSingleBatchLoadLimit, | ||||||
|  |           idx, | ||||||
|  |           forceRemote: true, | ||||||
|  |         ); | ||||||
|  |         messages.insertAll(0, out); | ||||||
|  |         notifyListeners(); | ||||||
|       } |       } | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       rethrow; |       rethrow; | ||||||
|     } finally { |     } finally { | ||||||
|       await loadMessages(); |       await loadMessages(); | ||||||
|       isLoading = false; |       isAggressiveLoading = false; | ||||||
|  |  | ||||||
|  |       isCheckedUpdate = true; | ||||||
|  |       _saveMessageToLocal(incomeStrandedQueue).then((_) { | ||||||
|  |         incomeStrandedQueue.clear(); | ||||||
|  |       }); | ||||||
|  |  | ||||||
|       notifyListeners(); |       notifyListeners(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| @@ -324,13 +405,18 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|   /// If it was not found in local storage we will look it up in remote |   /// If it was not found in local storage we will look it up in remote | ||||||
|   Future<SnChatMessage?> getMessage(int id) async { |   Future<SnChatMessage?> getMessage(int id) async { | ||||||
|     SnChatMessage? out; |     SnChatMessage? out; | ||||||
|     if (_box != null && _box!.containsKey(id)) { |     final local = await (_dt.db.snLocalChatMessage.select() | ||||||
|       out = _box!.get(id); |           ..limit(1) | ||||||
|  |           ..where((e) => e.id.equals(id))) | ||||||
|  |         .getSingleOrNull(); | ||||||
|  |     if (local != null) { | ||||||
|  |       out = local.content; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (out == null) { |     if (out == null) { | ||||||
|       try { |       try { | ||||||
|         final resp = await _sn.client.get('/cgi/im/channels/${channel!.keyPath}/events/$id'); |         final resp = await _sn.client | ||||||
|  |             .get('/cgi/im/channels/${channel!.keyPath}/events/$id'); | ||||||
|         out = SnChatMessage.fromJson(resp.data); |         out = SnChatMessage.fromJson(resp.data); | ||||||
|         _saveMessageToLocal([out]); |         _saveMessageToLocal([out]); | ||||||
|       } catch (_) { |       } catch (_) { | ||||||
| @@ -364,16 +450,21 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|     bool forceLocal = false, |     bool forceLocal = false, | ||||||
|     bool forceRemote = false, |     bool forceRemote = false, | ||||||
|   }) async { |   }) async { | ||||||
|  |     final localTotal = await _dt.db.snLocalChatMessage | ||||||
|  |         .count(where: (e) => e.channelId.equals(channel!.id)) | ||||||
|  |         .getSingle(); | ||||||
|  |  | ||||||
|     late List<SnChatMessage> out; |     late List<SnChatMessage> out; | ||||||
|     if (_box != null && (_box!.length >= take + offset || forceLocal) && !forceRemote) { |     if ((localTotal >= take + offset || forceLocal) && !forceRemote) { | ||||||
|       out = _box!.keys |       final result = await (_dt.db.snLocalChatMessage.select() | ||||||
|           .toList() |             ..where((e) => e.channelId.equals(channel!.id)) | ||||||
|           .cast<int>() |             ..orderBy([ | ||||||
|           .sorted((a, b) => b.compareTo(a)) |               (e) => | ||||||
|           .skip(offset) |                   OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc) | ||||||
|           .take(take) |             ]) | ||||||
|           .map((key) => _box!.get(key)!) |             ..limit(take, offset: offset)) | ||||||
|           .toList(); |           .get(); | ||||||
|  |       out = result.map((e) => e.content).toList(); | ||||||
|     } else { |     } else { | ||||||
|       final resp = await _sn.client.get( |       final resp = await _sn.client.get( | ||||||
|         '/cgi/im/channels/${channel!.keyPath}/events', |         '/cgi/im/channels/${channel!.keyPath}/events', | ||||||
| @@ -408,7 +499,8 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|           quoteEvent: quoteEvent, |           quoteEvent: quoteEvent, | ||||||
|           attachments: attachments |           attachments: attachments | ||||||
|               .where( |               .where( | ||||||
|                 (ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false, |                 (ele) => | ||||||
|  |                     out[i].body['attachments']?.contains(ele?.rid) ?? false, | ||||||
|               ) |               ) | ||||||
|               .toList(), |               .toList(), | ||||||
|         ), |         ), | ||||||
| @@ -416,7 +508,10 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Preload sender accounts |     // Preload sender accounts | ||||||
|     final accountId = out.where((ele) => ele.sender.accountId >= 0).map((ele) => ele.sender.accountId).toSet(); |     final accountId = out | ||||||
|  |         .where((ele) => ele.sender.accountId >= 0) | ||||||
|  |         .map((ele) => ele.sender.accountId) | ||||||
|  |         .toSet(); | ||||||
|     await _ud.listAccount(accountId); |     await _ud.listAccount(accountId); | ||||||
|  |  | ||||||
|     return out; |     return out; | ||||||
| @@ -441,10 +536,45 @@ class ChatMessageController extends ChangeNotifier { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   Timer? _readEventDebounce; | ||||||
|  |   int? _readEventAnchor; | ||||||
|  |  | ||||||
|  |   void readEvent(int id) { | ||||||
|  |     if (_readEventAnchor != null) { | ||||||
|  |       _readEventAnchor = math.max(_readEventAnchor!, id); | ||||||
|  |     } else { | ||||||
|  |       _readEventAnchor = id; | ||||||
|  |     } | ||||||
|  |     if (_readEventDebounce?.isActive ?? false) { | ||||||
|  |       _readEventDebounce?.cancel(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     _readEventDebounce = Timer(const Duration(milliseconds: 500), () { | ||||||
|  |       _sendReadEvent(); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void _sendReadEvent() { | ||||||
|  |     _ws.conn?.sink.add(jsonEncode( | ||||||
|  |       WebSocketPackage( | ||||||
|  |         method: 'events.read', | ||||||
|  |         endpoint: 'im', | ||||||
|  |         payload: { | ||||||
|  |           'channel_member_id': profile!.id, | ||||||
|  |           'event_id': _readEventAnchor, | ||||||
|  |         }, | ||||||
|  |       ).toJson(), | ||||||
|  |     )); | ||||||
|  |     logging.debug('[Messaging] Send read event request: $_readEventAnchor'); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void dispose() { |   void dispose() { | ||||||
|     _box?.close(); |  | ||||||
|     _wsSubscription?.cancel(); |     _wsSubscription?.cancel(); | ||||||
|  |     if (_readEventDebounce?.isActive ?? false) { | ||||||
|  |       _sendReadEvent(); | ||||||
|  |     } | ||||||
|  |     _readEventDebounce?.cancel(); | ||||||
|     super.dispose(); |     super.dispose(); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -16,7 +16,9 @@ import 'package:surface/providers/post.dart'; | |||||||
| import 'package:surface/providers/sn_attachment.dart'; | import 'package:surface/providers/sn_attachment.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/types/attachment.dart'; | import 'package:surface/types/attachment.dart'; | ||||||
|  | import 'package:surface/types/poll.dart'; | ||||||
| import 'package:surface/types/post.dart'; | import 'package:surface/types/post.dart'; | ||||||
|  | import 'package:surface/types/realm.dart'; | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
| import 'package:surface/widgets/universal_image.dart'; | import 'package:surface/widgets/universal_image.dart'; | ||||||
| import 'package:video_compress/video_compress.dart'; | import 'package:video_compress/video_compress.dart'; | ||||||
| @@ -69,7 +71,8 @@ class PostWriteMedia { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   PostWriteMedia.fromBytes(this.raw, this.name, this.type, {this.attachment, this.file}); |   PostWriteMedia.fromBytes(this.raw, this.name, this.type, | ||||||
|  |       {this.attachment, this.file}); | ||||||
|  |  | ||||||
|   bool get isEmpty => attachment == null && file == null && raw == null; |   bool get isEmpty => attachment == null && file == null && raw == null; | ||||||
|  |  | ||||||
| @@ -103,7 +106,8 @@ class PostWriteMedia { | |||||||
|   }) { |   }) { | ||||||
|     if (attachment != null) { |     if (attachment != null) { | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final ImageProvider provider = UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid)); |       final ImageProvider provider = | ||||||
|  |           UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid)); | ||||||
|       if (width != null && height != null && !kIsWeb) { |       if (width != null && height != null && !kIsWeb) { | ||||||
|         return ResizeImage( |         return ResizeImage( | ||||||
|           provider, |           provider, | ||||||
| @@ -114,7 +118,8 @@ class PostWriteMedia { | |||||||
|       } |       } | ||||||
|       return provider; |       return provider; | ||||||
|     } else if (file != null) { |     } else if (file != null) { | ||||||
|       final ImageProvider provider = kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path)); |       final ImageProvider provider = | ||||||
|  |           kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path)); | ||||||
|       if (width != null && height != null) { |       if (width != null && height != null) { | ||||||
|         return ResizeImage( |         return ResizeImage( | ||||||
|           provider, |           provider, | ||||||
| @@ -144,6 +149,8 @@ class PostWriteController extends ChangeNotifier { | |||||||
|   static const Map<String, String> kTitleMap = { |   static const Map<String, String> kTitleMap = { | ||||||
|     'stories': 'writePostTypeStory', |     'stories': 'writePostTypeStory', | ||||||
|     'articles': 'writePostTypeArticle', |     'articles': 'writePostTypeArticle', | ||||||
|  |     'questions': 'writePostTypeQuestion', | ||||||
|  |     'videos': 'writePostTypeVideo', | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   static const kAttachmentProgressWeight = 0.9; |   static const kAttachmentProgressWeight = 0.9; | ||||||
| @@ -153,6 +160,19 @@ class PostWriteController extends ChangeNotifier { | |||||||
|   final TextEditingController titleController = TextEditingController(); |   final TextEditingController titleController = TextEditingController(); | ||||||
|   final TextEditingController descriptionController = TextEditingController(); |   final TextEditingController descriptionController = TextEditingController(); | ||||||
|   final TextEditingController aliasController = TextEditingController(); |   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) | ||||||
|  |             ]); | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|   bool _temporarySaveActive = false; |   bool _temporarySaveActive = false; | ||||||
|  |  | ||||||
| @@ -168,6 +188,7 @@ class PostWriteController extends ChangeNotifier { | |||||||
|     }); |     }); | ||||||
|     contentController.addListener(() { |     contentController.addListener(() { | ||||||
|       _temporaryPlanSave(); |       _temporaryPlanSave(); | ||||||
|  |       notifyListeners(); | ||||||
|     }); |     }); | ||||||
|     if (doLoadFromTemporary) _temporaryLoad(); |     if (doLoadFromTemporary) _temporaryLoad(); | ||||||
|   } |   } | ||||||
| @@ -178,11 +199,13 @@ class PostWriteController extends ChangeNotifier { | |||||||
|  |  | ||||||
|   String get description => descriptionController.text; |   String get description => descriptionController.text; | ||||||
|  |  | ||||||
|   bool get isRelatedNull => ![editingPost, repostingPost, replyingPost].any((ele) => ele != null); |   bool get isRelatedNull => | ||||||
|  |       ![editingPost, repostingPost, replyingPost].any((ele) => ele != null); | ||||||
|  |  | ||||||
|   bool isLoading = false, isBusy = false; |   bool isLoading = false, isBusy = false; | ||||||
|   double? progress; |   double? progress; | ||||||
|  |  | ||||||
|  |   SnRealm? realm; | ||||||
|   SnPublisher? publisher; |   SnPublisher? publisher; | ||||||
|   SnPost? editingPost, repostingPost, replyingPost; |   SnPost? editingPost, repostingPost, replyingPost; | ||||||
|  |  | ||||||
| @@ -194,6 +217,8 @@ class PostWriteController extends ChangeNotifier { | |||||||
|   PostWriteMedia? thumbnail; |   PostWriteMedia? thumbnail; | ||||||
|   List<PostWriteMedia> attachments = List.empty(growable: true); |   List<PostWriteMedia> attachments = List.empty(growable: true); | ||||||
|   DateTime? publishedAt, publishedUntil; |   DateTime? publishedAt, publishedUntil; | ||||||
|  |   SnAttachment? videoAttachment; | ||||||
|  |   SnPoll? poll; | ||||||
|  |  | ||||||
|   Future<void> fetchRelatedPost( |   Future<void> fetchRelatedPost( | ||||||
|     BuildContext context, { |     BuildContext context, { | ||||||
| @@ -214,18 +239,28 @@ class PostWriteController extends ChangeNotifier { | |||||||
|         descriptionController.text = post.body['description'] ?? ''; |         descriptionController.text = post.body['description'] ?? ''; | ||||||
|         contentController.text = post.body['content'] ?? ''; |         contentController.text = post.body['content'] ?? ''; | ||||||
|         aliasController.text = post.alias ?? ''; |         aliasController.text = post.alias ?? ''; | ||||||
|  |         rewardController.text = post.body['reward']?.toString() ?? ''; | ||||||
|  |         videoAttachment = post.preload?.video; | ||||||
|         publishedAt = post.publishedAt; |         publishedAt = post.publishedAt; | ||||||
|         publishedUntil = post.publishedUntil; |         publishedUntil = post.publishedUntil; | ||||||
|         visibleUsers = List.from(post.visibleUsersList ?? [], growable: true); |         visibleUsers = List.from(post.visibleUsersList ?? [], growable: true); | ||||||
|         invisibleUsers = List.from(post.invisibleUsersList ?? [], growable: true); |         invisibleUsers = | ||||||
|  |             List.from(post.invisibleUsersList ?? [], growable: true); | ||||||
|         visibility = post.visibility; |         visibility = post.visibility; | ||||||
|         tags = List.from(post.tags.map((ele) => ele.alias), growable: true); |         tags = List.from(post.tags.map((ele) => ele.alias), growable: true); | ||||||
|         categories = List.from(post.categories.map((ele) => ele.alias), growable: true); |         categories = | ||||||
|         attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []); |             List.from(post.categories.map((ele) => ele.alias), growable: true); | ||||||
|  |         attachments.addAll( | ||||||
|  |             post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []); | ||||||
|  |         poll = post.preload?.poll; | ||||||
|  |  | ||||||
|         if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) { |         if (post.preload?.thumbnail != null && | ||||||
|  |             (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) { | ||||||
|           thumbnail = PostWriteMedia(post.preload!.thumbnail); |           thumbnail = PostWriteMedia(post.preload!.thumbnail); | ||||||
|         } |         } | ||||||
|  |         if (post.preload?.realm != null) { | ||||||
|  |           realm = post.preload!.realm!; | ||||||
|  |         } | ||||||
|  |  | ||||||
|         editingPost = post; |         editingPost = post; | ||||||
|       } |       } | ||||||
| @@ -248,7 +283,8 @@ class PostWriteController extends ChangeNotifier { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<SnAttachment> _uploadAttachment(BuildContext context, PostWriteMedia media, |   Future<SnAttachment> _uploadAttachment( | ||||||
|  |       BuildContext context, PostWriteMedia media, | ||||||
|       {bool isCompressed = false}) async { |       {bool isCompressed = false}) async { | ||||||
|     final attach = context.read<SnAttachmentProvider>(); |     final attach = context.read<SnAttachmentProvider>(); | ||||||
|  |  | ||||||
| @@ -257,7 +293,9 @@ class PostWriteController extends ChangeNotifier { | |||||||
|       media.name, |       media.name, | ||||||
|       'interactive', |       'interactive', | ||||||
|       null, |       null, | ||||||
|       mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null, |       mimetype: media.raw != null && media.type == SnMediaType.image | ||||||
|  |           ? 'image/png' | ||||||
|  |           : null, | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     var item = await attach.chunkedUploadParts( |     var item = await attach.chunkedUploadParts( | ||||||
| @@ -273,9 +311,11 @@ class PostWriteController extends ChangeNotifier { | |||||||
|  |  | ||||||
|     if (media.type == SnMediaType.video && !isCompressed && context.mounted) { |     if (media.type == SnMediaType.video && !isCompressed && context.mounted) { | ||||||
|       try { |       try { | ||||||
|         final compressedAttachment = await _tryCompressVideoCopy(context, media); |         final compressedAttachment = | ||||||
|  |             await _tryCompressVideoCopy(context, media); | ||||||
|         if (compressedAttachment != null) { |         if (compressedAttachment != null) { | ||||||
|           item = await attach.updateOne(item, compressedId: compressedAttachment.id); |           item = await attach.updateOne(item, | ||||||
|  |               compressedId: compressedAttachment.id); | ||||||
|         } |         } | ||||||
|       } catch (err) { |       } catch (err) { | ||||||
|         if (context.mounted) context.showErrorDialog(err); |         if (context.mounted) context.showErrorDialog(err); | ||||||
| @@ -285,8 +325,10 @@ class PostWriteController extends ChangeNotifier { | |||||||
|     return item; |     return item; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<SnAttachment?> _tryCompressVideoCopy(BuildContext context, PostWriteMedia media) async { |   Future<SnAttachment?> _tryCompressVideoCopy( | ||||||
|     if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) return null; |       BuildContext context, PostWriteMedia media) async { | ||||||
|  |     if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) | ||||||
|  |       return null; | ||||||
|     if (media.type != SnMediaType.video) return null; |     if (media.type != SnMediaType.video) return null; | ||||||
|     if (media.file == null) return null; |     if (media.file == null) return null; | ||||||
|     if (VideoCompress.isCompressing) return null; |     if (VideoCompress.isCompressing) return null; | ||||||
| @@ -310,7 +352,8 @@ class PostWriteController extends ChangeNotifier { | |||||||
|     if (!context.mounted) return null; |     if (!context.mounted) return null; | ||||||
|  |  | ||||||
|     final compressedMedia = PostWriteMedia.fromFile(XFile(mediaInfo.path!)); |     final compressedMedia = PostWriteMedia.fromFile(XFile(mediaInfo.path!)); | ||||||
|     final compressedAttachment = await _uploadAttachment(context, compressedMedia, isCompressed: true); |     final compressedAttachment = | ||||||
|  |         await _uploadAttachment(context, compressedMedia, isCompressed: true); | ||||||
|  |  | ||||||
|     return compressedAttachment; |     return compressedAttachment; | ||||||
|   } |   } | ||||||
| @@ -346,24 +389,40 @@ class PostWriteController extends ChangeNotifier { | |||||||
|           'content': contentController.text, |           'content': contentController.text, | ||||||
|           if (aliasController.text.isNotEmpty) 'alias': aliasController.text, |           if (aliasController.text.isNotEmpty) 'alias': aliasController.text, | ||||||
|           if (titleController.text.isNotEmpty) 'title': titleController.text, |           if (titleController.text.isNotEmpty) 'title': titleController.text, | ||||||
|           if (descriptionController.text.isNotEmpty) 'description': descriptionController.text, |           if (descriptionController.text.isNotEmpty) | ||||||
|           if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.toJson(), |             'description': descriptionController.text, | ||||||
|           'attachments': |           if (rewardController.text.isNotEmpty) 'reward': rewardController.text, | ||||||
|               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), |           'tags': tags.map((ele) => {'alias': ele}).toList(growable: true), | ||||||
|           'categories': categories.map((ele) => {'alias': ele}).toList(growable: true), |           'categories': | ||||||
|  |               categories.map((ele) => {'alias': ele}).toList(growable: true), | ||||||
|           'visibility': visibility, |           'visibility': visibility, | ||||||
|           'visible_users_list': visibleUsers, |           'visible_users_list': visibleUsers, | ||||||
|           'invisible_users_list': invisibleUsers, |           'invisible_users_list': invisibleUsers, | ||||||
|           if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(), |           if (publishedAt != null) | ||||||
|           if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(), |             'published_at': publishedAt!.toUtc().toIso8601String(), | ||||||
|  |           if (publishedUntil != null) | ||||||
|  |             'published_until': publishedAt!.toUtc().toIso8601String(), | ||||||
|           if (replyingPost != null) 'reply_to': replyingPost!.toJson(), |           if (replyingPost != null) 'reply_to': replyingPost!.toJson(), | ||||||
|           if (repostingPost != null) 'repost_to': repostingPost!.toJson(), |           if (repostingPost != null) 'repost_to': repostingPost!.toJson(), | ||||||
|  |           if (poll != null) 'poll': poll!.toJson(), | ||||||
|  |           if (realm != null) 'realm': realm!.toJson(), | ||||||
|         }), |         }), | ||||||
|       ); |       ); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   bool get isNotEmpty => | ||||||
|  |       title.isNotEmpty || | ||||||
|  |       description.isNotEmpty || | ||||||
|  |       contentController.text.isNotEmpty || | ||||||
|  |       attachments.isNotEmpty; | ||||||
|  |  | ||||||
|   bool temporaryRestored = false; |   bool temporaryRestored = false; | ||||||
|  |  | ||||||
|   void _temporaryLoad() { |   void _temporaryLoad() { | ||||||
| @@ -375,18 +434,27 @@ class PostWriteController extends ChangeNotifier { | |||||||
|       aliasController.text = data['alias'] ?? ''; |       aliasController.text = data['alias'] ?? ''; | ||||||
|       titleController.text = data['title'] ?? ''; |       titleController.text = data['title'] ?? ''; | ||||||
|       descriptionController.text = data['description'] ?? ''; |       descriptionController.text = data['description'] ?? ''; | ||||||
|       if (data['thumbnail'] != null) thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail'])); |       rewardController.text = data['reward']?.toString() ?? ''; | ||||||
|       attachments |       if (data['thumbnail'] != null) | ||||||
|           .addAll(data['attachments'].map((ele) => PostWriteMedia(SnAttachment.fromJson(ele))).cast<PostWriteMedia>()); |         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'])); |       tags = List.from(data['tags'].map((ele) => ele['alias'])); | ||||||
|       categories = List.from(data['categories'].map((ele) => ele['alias'])); |       categories = List.from(data['categories'].map((ele) => ele['alias'])); | ||||||
|       visibility = data['visibility']; |       visibility = data['visibility']; | ||||||
|       visibleUsers = List.from(data['visible_users_list'] ?? []); |       visibleUsers = List.from(data['visible_users_list'] ?? []); | ||||||
|       invisibleUsers = List.from(data['invisible_users_list'] ?? []); |       invisibleUsers = List.from(data['invisible_users_list'] ?? []); | ||||||
|       if (data['published_at'] != null) publishedAt = DateTime.tryParse(data['published_at'])?.toLocal(); |       if (data['published_at'] != null) | ||||||
|       if (data['published_until'] != null) publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal(); |         publishedAt = DateTime.tryParse(data['published_at'])?.toLocal(); | ||||||
|       replyingPost = data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null; |       if (data['published_until'] != null) | ||||||
|       repostingPost = data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null; |         publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal(); | ||||||
|  |       replyingPost = | ||||||
|  |           data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null; | ||||||
|  |       repostingPost = | ||||||
|  |           data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null; | ||||||
|  |       poll = data['poll'] != null ? SnPoll.fromJson(data['poll']) : null; | ||||||
|  |       realm = data['realm'] != null ? SnRealm.fromJson(data['realm']) : null; | ||||||
|       temporaryRestored = true; |       temporaryRestored = true; | ||||||
|       notifyListeners(); |       notifyListeners(); | ||||||
|     }); |     }); | ||||||
| @@ -433,7 +501,9 @@ class PostWriteController extends ChangeNotifier { | |||||||
|           media.name, |           media.name, | ||||||
|           'interactive', |           'interactive', | ||||||
|           null, |           null, | ||||||
|           mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null, |           mimetype: media.raw != null && media.type == SnMediaType.image | ||||||
|  |               ? 'image/png' | ||||||
|  |               : null, | ||||||
|         ); |         ); | ||||||
|  |  | ||||||
|         var item = await attach.chunkedUploadParts( |         var item = await attach.chunkedUploadParts( | ||||||
| @@ -442,16 +512,20 @@ class PostWriteController extends ChangeNotifier { | |||||||
|           place.$2, |           place.$2, | ||||||
|           onProgress: (value) { |           onProgress: (value) { | ||||||
|             // Calculate overall progress for attachments |             // Calculate overall progress for attachments | ||||||
|             progress = math.max(((i + value) / attachments.length) * kAttachmentProgressWeight, value); |             progress = math.max( | ||||||
|  |                 ((i + value) / attachments.length) * kAttachmentProgressWeight, | ||||||
|  |                 value); | ||||||
|             notifyListeners(); |             notifyListeners(); | ||||||
|           }, |           }, | ||||||
|         ); |         ); | ||||||
|  |  | ||||||
|         try { |         try { | ||||||
|           if (context.mounted) { |           if (context.mounted) { | ||||||
|             final compressedAttachment = await _tryCompressVideoCopy(context, media); |             final compressedAttachment = | ||||||
|  |                 await _tryCompressVideoCopy(context, media); | ||||||
|             if (compressedAttachment != null) { |             if (compressedAttachment != null) { | ||||||
|               item = await attach.updateOne(item, compressedId: compressedAttachment.id); |               item = await attach.updateOne(item, | ||||||
|  |                   compressedId: compressedAttachment.id); | ||||||
|             } |             } | ||||||
|           } |           } | ||||||
|         } catch (err) { |         } catch (err) { | ||||||
| @@ -473,6 +547,8 @@ class PostWriteController extends ChangeNotifier { | |||||||
|     progress = kAttachmentProgressWeight; |     progress = kAttachmentProgressWeight; | ||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|  |  | ||||||
|  |     final reward = double.tryParse(rewardController.text); | ||||||
|  |  | ||||||
|     // Posting the content |     // Posting the content | ||||||
|     try { |     try { | ||||||
|       final baseProgressVal = progress!; |       final baseProgressVal = progress!; | ||||||
| @@ -486,25 +562,39 @@ class PostWriteController extends ChangeNotifier { | |||||||
|           'content': contentController.text, |           'content': contentController.text, | ||||||
|           if (aliasController.text.isNotEmpty) 'alias': aliasController.text, |           if (aliasController.text.isNotEmpty) 'alias': aliasController.text, | ||||||
|           if (titleController.text.isNotEmpty) 'title': titleController.text, |           if (titleController.text.isNotEmpty) 'title': titleController.text, | ||||||
|           if (descriptionController.text.isNotEmpty) 'description': descriptionController.text, |           if (descriptionController.text.isNotEmpty) | ||||||
|           if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid, |             'description': descriptionController.text, | ||||||
|           'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(), |           if (thumbnail != null && thumbnail!.attachment != null) | ||||||
|  |             'thumbnail': thumbnail!.attachment!.rid, | ||||||
|  |           'attachments': attachments | ||||||
|  |               .where((e) => e.attachment != null) | ||||||
|  |               .map((e) => e.attachment!.rid) | ||||||
|  |               .toList(), | ||||||
|           'tags': tags.map((ele) => {'alias': ele}).toList(), |           'tags': tags.map((ele) => {'alias': ele}).toList(), | ||||||
|           'categories': categories.map((ele) => {'alias': ele}).toList(), |           'categories': categories.map((ele) => {'alias': ele}).toList(), | ||||||
|           'visibility': visibility, |           'visibility': visibility, | ||||||
|           'visible_users_list': visibleUsers, |           'visible_users_list': visibleUsers, | ||||||
|           'invisible_users_list': invisibleUsers, |           'invisible_users_list': invisibleUsers, | ||||||
|           if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(), |           if (publishedAt != null) | ||||||
|           if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(), |             'published_at': publishedAt!.toUtc().toIso8601String(), | ||||||
|  |           if (publishedUntil != null) | ||||||
|  |             'published_until': publishedAt!.toUtc().toIso8601String(), | ||||||
|           if (replyingPost != null) 'reply_to': replyingPost!.id, |           if (replyingPost != null) 'reply_to': replyingPost!.id, | ||||||
|           if (repostingPost != null) 'repost_to': repostingPost!.id, |           if (repostingPost != null) 'repost_to': repostingPost!.id, | ||||||
|  |           if (reward != null) 'reward': reward, | ||||||
|  |           if (videoAttachment != null) 'video': videoAttachment!.rid, | ||||||
|  |           if (poll != null) 'poll': poll!.id, | ||||||
|  |           if (realm != null) 'realm': realm!.id, | ||||||
|         }, |         }, | ||||||
|         onSendProgress: (count, total) { |         onSendProgress: (count, total) { | ||||||
|           progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2); |           progress = | ||||||
|  |               baseProgressVal + (count / total) * (kPostingProgressWeight / 2); | ||||||
|           notifyListeners(); |           notifyListeners(); | ||||||
|         }, |         }, | ||||||
|         onReceiveProgress: (count, total) { |         onReceiveProgress: (count, total) { | ||||||
|           progress = baseProgressVal + (kPostingProgressWeight / 2) + (count / total) * (kPostingProgressWeight / 2); |           progress = baseProgressVal + | ||||||
|  |               (kPostingProgressWeight / 2) + | ||||||
|  |               (count / total) * (kPostingProgressWeight / 2); | ||||||
|           notifyListeners(); |           notifyListeners(); | ||||||
|         }, |         }, | ||||||
|         options: Options( |         options: Options( | ||||||
| @@ -544,17 +634,8 @@ class PostWriteController extends ChangeNotifier { | |||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void setThumbnail(int? idx) { |   void setThumbnail(SnAttachment? value) { | ||||||
|     if (idx == null) { |     thumbnail = value == null ? null : PostWriteMedia(value); | ||||||
|       attachments.add(thumbnail!); |  | ||||||
|       thumbnail = null; |  | ||||||
|     } else { |  | ||||||
|       if (thumbnail != null) { |  | ||||||
|         attachments.add(thumbnail!); |  | ||||||
|       } |  | ||||||
|       thumbnail = attachments[idx]; |  | ||||||
|       attachments.removeAt(idx); |  | ||||||
|     } |  | ||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -606,6 +687,11 @@ class PostWriteController extends ChangeNotifier { | |||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   void setRealm(SnRealm? value) { | ||||||
|  |     realm = value; | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   void setProgress(double? value) { |   void setProgress(double? value) { | ||||||
|     progress = value; |     progress = value; | ||||||
|     _temporaryPlanSave(); |     _temporaryPlanSave(); | ||||||
| @@ -624,6 +710,16 @@ class PostWriteController extends ChangeNotifier { | |||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   void setVideoAttachment(SnAttachment? value) { | ||||||
|  |     videoAttachment = value; | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void setPoll(SnPoll? value) { | ||||||
|  |     poll = value; | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   void reset() { |   void reset() { | ||||||
|     publishedAt = null; |     publishedAt = null; | ||||||
|     publishedUntil = null; |     publishedUntil = null; | ||||||
| @@ -641,7 +737,8 @@ class PostWriteController extends ChangeNotifier { | |||||||
|     repostingPost = null; |     repostingPost = null; | ||||||
|     mode = kTitleMap.keys.first; |     mode = kTitleMap.keys.first; | ||||||
|     temporaryRestored = false; |     temporaryRestored = false; | ||||||
|     SharedPreferences.getInstance().then((prefs) => prefs.remove(kTemporaryStorageKey)); |     SharedPreferences.getInstance() | ||||||
|  |         .then((prefs) => prefs.remove(kTemporaryStorageKey)); | ||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										42
									
								
								lib/database/account.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								lib/database/account.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  |  | ||||||
|  | import 'package:drift/drift.dart'; | ||||||
|  | import 'package:surface/types/account.dart'; | ||||||
|  |  | ||||||
|  | class SnAccountConverter extends TypeConverter<SnAccount, String> | ||||||
|  |     with JsonTypeConverter2<SnAccount, String, Map<String, Object?>> { | ||||||
|  |   const SnAccountConverter(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnAccount fromSql(String fromDb) { | ||||||
|  |     return fromJson(jsonDecode(fromDb) as Map<String, dynamic>); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String toSql(SnAccount value) { | ||||||
|  |     return jsonEncode(toJson(value)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnAccount fromJson(Map<String, Object?> json) { | ||||||
|  |     return SnAccount.fromJson(json); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Map<String, Object?> toJson(SnAccount value) { | ||||||
|  |     return value.toJson(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @TableIndex(name: 'idx_account_name', columns: {#name}) | ||||||
|  | class SnLocalAccount extends Table { | ||||||
|  |   IntColumn get id => integer().autoIncrement()(); | ||||||
|  |  | ||||||
|  |   TextColumn get name => text()(); | ||||||
|  |  | ||||||
|  |   TextColumn get content => text().map(const SnAccountConverter())(); | ||||||
|  |  | ||||||
|  |   DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); | ||||||
|  |  | ||||||
|  |   DateTimeColumn get cacheExpiredAt => dateTime()(); | ||||||
|  | } | ||||||
							
								
								
									
										47
									
								
								lib/database/attachment.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								lib/database/attachment.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  |  | ||||||
|  | import 'package:drift/drift.dart'; | ||||||
|  | import 'package:surface/types/attachment.dart'; | ||||||
|  |  | ||||||
|  | class SnAttachmentConverter extends TypeConverter<SnAttachment, String> | ||||||
|  |     with JsonTypeConverter2<SnAttachment, String, Map<String, Object?>> { | ||||||
|  |   const SnAttachmentConverter(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnAttachment fromSql(String fromDb) { | ||||||
|  |     return fromJson(jsonDecode(fromDb) as Map<String, dynamic>); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String toSql(SnAttachment value) { | ||||||
|  |     return jsonEncode(toJson(value)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnAttachment fromJson(Map<String, Object?> json) { | ||||||
|  |     return SnAttachment.fromJson(json); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Map<String, Object?> toJson(SnAttachment value) { | ||||||
|  |     return value.toJson(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @TableIndex(name: 'idx_attachment_rid', columns: {#rid}) | ||||||
|  | @TableIndex(name: 'idx_attachment_account', columns: {#accountId}) | ||||||
|  | class SnLocalAttachment extends Table { | ||||||
|  |   IntColumn get id => integer().autoIncrement()(); | ||||||
|  |  | ||||||
|  |   TextColumn get rid => text().unique()(); | ||||||
|  |  | ||||||
|  |   TextColumn get uuid => text().unique()(); | ||||||
|  |  | ||||||
|  |   TextColumn get content => text().map(const SnAttachmentConverter())(); | ||||||
|  |  | ||||||
|  |   IntColumn get accountId => integer()(); | ||||||
|  |  | ||||||
|  |   DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); | ||||||
|  |  | ||||||
|  |   DateTimeColumn get cacheExpiredAt => dateTime()(); | ||||||
|  | } | ||||||
							
								
								
									
										117
									
								
								lib/database/chat.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								lib/database/chat.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,117 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  |  | ||||||
|  | import 'package:drift/drift.dart'; | ||||||
|  | import 'package:surface/types/chat.dart'; | ||||||
|  |  | ||||||
|  | class SnChannelConverter extends TypeConverter<SnChannel, String> | ||||||
|  |     with JsonTypeConverter2<SnChannel, String, Map<String, Object?>> { | ||||||
|  |   const SnChannelConverter(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnChannel fromSql(String fromDb) { | ||||||
|  |     return fromJson(jsonDecode(fromDb) as Map<String, dynamic>); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String toSql(SnChannel value) { | ||||||
|  |     return jsonEncode(toJson(value)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnChannel fromJson(Map<String, Object?> json) { | ||||||
|  |     return SnChannel.fromJson(json); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Map<String, Object?> toJson(SnChannel value) { | ||||||
|  |     return value.toJson(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @TableIndex(name: 'idx_channel_alias', columns: {#alias}) | ||||||
|  | class SnLocalChatChannel extends Table { | ||||||
|  |   IntColumn get id => integer().autoIncrement()(); | ||||||
|  |  | ||||||
|  |   TextColumn get alias => text()(); | ||||||
|  |  | ||||||
|  |   TextColumn get content => text().map(const SnChannelConverter())(); | ||||||
|  |  | ||||||
|  |   DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class SnMessageConverter extends TypeConverter<SnChatMessage, String> | ||||||
|  |     with JsonTypeConverter2<SnChatMessage, String, Map<String, Object?>> { | ||||||
|  |   const SnMessageConverter(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnChatMessage fromSql(String fromDb) { | ||||||
|  |     return fromJson(jsonDecode(fromDb) as Map<String, dynamic>); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String toSql(SnChatMessage value) { | ||||||
|  |     return jsonEncode(toJson(value)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnChatMessage fromJson(Map<String, Object?> json) { | ||||||
|  |     return SnChatMessage.fromJson(json); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Map<String, Object?> toJson(SnChatMessage value) { | ||||||
|  |     return value.toJson(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @TableIndex(name: 'idx_chat_channel', columns: {#channelId}) | ||||||
|  | class SnLocalChatMessage extends Table { | ||||||
|  |   IntColumn get id => integer().autoIncrement()(); | ||||||
|  |  | ||||||
|  |   IntColumn get channelId => integer()(); | ||||||
|  |  | ||||||
|  |   IntColumn get senderId => integer().nullable()(); | ||||||
|  |  | ||||||
|  |   TextColumn get content => text().map(const SnMessageConverter())(); | ||||||
|  |  | ||||||
|  |   DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class SnChannelMemberConverter extends TypeConverter<SnChannelMember, String> | ||||||
|  |     with JsonTypeConverter2<SnChannelMember, String, Map<String, Object?>> { | ||||||
|  |   const SnChannelMemberConverter(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnChannelMember fromSql(String fromDb) { | ||||||
|  |     return fromJson(jsonDecode(fromDb) as Map<String, dynamic>); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String toSql(SnChannelMember value) { | ||||||
|  |     return jsonEncode(toJson(value)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnChannelMember fromJson(Map<String, Object?> json) { | ||||||
|  |     return SnChannelMember.fromJson(json); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Map<String, Object?> toJson(SnChannelMember value) { | ||||||
|  |     return value.toJson(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class SnLocalChannelMember extends Table { | ||||||
|  |   IntColumn get id => integer().autoIncrement()(); | ||||||
|  |  | ||||||
|  |   IntColumn get channelId => integer()(); | ||||||
|  |  | ||||||
|  |   IntColumn get accountId => integer()(); | ||||||
|  |  | ||||||
|  |   TextColumn get content => text().map(SnChannelMemberConverter())(); | ||||||
|  |  | ||||||
|  |   DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); | ||||||
|  |  | ||||||
|  |   DateTimeColumn get cacheExpiredAt => dateTime()(); | ||||||
|  | } | ||||||
							
								
								
									
										55
									
								
								lib/database/database.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								lib/database/database.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | |||||||
|  | import 'package:drift/drift.dart'; | ||||||
|  | import 'package:drift_flutter/drift_flutter.dart'; | ||||||
|  | import 'package:path_provider/path_provider.dart'; | ||||||
|  | import 'package:surface/database/account.dart'; | ||||||
|  | import 'package:surface/database/attachment.dart'; | ||||||
|  | import 'package:surface/database/chat.dart'; | ||||||
|  | import 'package:surface/database/database.steps.dart'; | ||||||
|  | import 'package:surface/database/keypair.dart'; | ||||||
|  | import 'package:surface/database/sticker.dart'; | ||||||
|  | import 'package:surface/types/chat.dart'; | ||||||
|  | import 'package:surface/types/attachment.dart'; | ||||||
|  | import 'package:surface/types/account.dart'; | ||||||
|  |  | ||||||
|  | part 'database.g.dart'; | ||||||
|  |  | ||||||
|  | @DriftDatabase(tables: [ | ||||||
|  |   SnLocalChatChannel, | ||||||
|  |   SnLocalChatMessage, | ||||||
|  |   SnLocalChannelMember, | ||||||
|  |   SnLocalKeyPair, | ||||||
|  |   SnLocalAccount, | ||||||
|  |   SnLocalAttachment, | ||||||
|  |   SnLocalSticker, | ||||||
|  |   SnLocalStickerPack, | ||||||
|  | ]) | ||||||
|  | class AppDatabase extends _$AppDatabase { | ||||||
|  |   AppDatabase([QueryExecutor? e]) : super(e ?? _openConnection()); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   int get schemaVersion => 3; | ||||||
|  |  | ||||||
|  |   static QueryExecutor _openConnection() { | ||||||
|  |     return driftDatabase( | ||||||
|  |       name: 'solar_network_data', | ||||||
|  |       native: const DriftNativeOptions( | ||||||
|  |         databaseDirectory: getApplicationSupportDirectory, | ||||||
|  |       ), | ||||||
|  |       web: DriftWebOptions( | ||||||
|  |         sqlite3Wasm: Uri.parse('sqlite3.wasm'), | ||||||
|  |         driftWorker: Uri.parse('drift_worker.dart.js'), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   MigrationStrategy get migration { | ||||||
|  |     return MigrationStrategy( | ||||||
|  |       onUpgrade: stepByStep(from1To2: (m, schema) async { | ||||||
|  |         // Nothing else to do here | ||||||
|  |       }, from2To3: (m, schema) async { | ||||||
|  |         // Nothing else to do here, too | ||||||
|  |       }), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										3932
									
								
								lib/database/database.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3932
									
								
								lib/database/database.g.dart
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										445
									
								
								lib/database/database.steps.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										445
									
								
								lib/database/database.steps.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,445 @@ | |||||||
|  | // dart format width=80 | ||||||
|  | import 'package:drift/internal/versioned_schema.dart' as i0; | ||||||
|  | import 'package:drift/drift.dart' as i1; | ||||||
|  | import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import | ||||||
|  |  | ||||||
|  | // GENERATED BY drift_dev, DO NOT MODIFY. | ||||||
|  | final class Schema2 extends i0.VersionedSchema { | ||||||
|  |   Schema2({required super.database}) : super(version: 2); | ||||||
|  |   @override | ||||||
|  |   late final List<i1.DatabaseSchemaEntity> entities = [ | ||||||
|  |     snLocalChatChannel, | ||||||
|  |     snLocalChatMessage, | ||||||
|  |     snLocalKeyPair, | ||||||
|  |   ]; | ||||||
|  |   late final Shape0 snLocalChatChannel = Shape0( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_chat_channel', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_1, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape1 snLocalChatMessage = Shape1( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_chat_message', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_4, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape2 snLocalKeyPair = Shape2( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_key_pair', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [ | ||||||
|  |           'PRIMARY KEY(id)', | ||||||
|  |         ], | ||||||
|  |         columns: [ | ||||||
|  |           _column_5, | ||||||
|  |           _column_6, | ||||||
|  |           _column_7, | ||||||
|  |           _column_8, | ||||||
|  |           _column_9, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class Shape0 extends i0.VersionedTable { | ||||||
|  |   Shape0({required super.source, required super.alias}) : super.aliased(); | ||||||
|  |   i1.GeneratedColumn<int> get id => | ||||||
|  |       columnsByName['id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<String> get alias => | ||||||
|  |       columnsByName['alias']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<String> get content => | ||||||
|  |       columnsByName['content']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get createdAt => | ||||||
|  |       columnsByName['created_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | i1.GeneratedColumn<int> _column_0(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<int>('id', aliasedName, false, | ||||||
|  |         hasAutoIncrement: true, | ||||||
|  |         type: i1.DriftSqlType.int, | ||||||
|  |         defaultConstraints: | ||||||
|  |             i1.GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); | ||||||
|  | i1.GeneratedColumn<String> _column_1(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<String>('alias', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.string); | ||||||
|  | i1.GeneratedColumn<String> _column_2(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<String>('content', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.string); | ||||||
|  | i1.GeneratedColumn<DateTime> _column_3(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<DateTime>('created_at', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.dateTime, | ||||||
|  |         defaultValue: const CustomExpression( | ||||||
|  |             'CAST(strftime(\'%s\', CURRENT_TIMESTAMP) AS INTEGER)')); | ||||||
|  |  | ||||||
|  | class Shape1 extends i0.VersionedTable { | ||||||
|  |   Shape1({required super.source, required super.alias}) : super.aliased(); | ||||||
|  |   i1.GeneratedColumn<int> get id => | ||||||
|  |       columnsByName['id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<int> get channelId => | ||||||
|  |       columnsByName['channel_id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<String> get content => | ||||||
|  |       columnsByName['content']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get createdAt => | ||||||
|  |       columnsByName['created_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | i1.GeneratedColumn<int> _column_4(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<int>('channel_id', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.int); | ||||||
|  |  | ||||||
|  | class Shape2 extends i0.VersionedTable { | ||||||
|  |   Shape2({required super.source, required super.alias}) : super.aliased(); | ||||||
|  |   i1.GeneratedColumn<String> get id => | ||||||
|  |       columnsByName['id']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<int> get accountId => | ||||||
|  |       columnsByName['account_id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<String> get publicKey => | ||||||
|  |       columnsByName['public_key']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<String> get privateKey => | ||||||
|  |       columnsByName['private_key']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<bool> get isActive => | ||||||
|  |       columnsByName['is_active']! as i1.GeneratedColumn<bool>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | i1.GeneratedColumn<String> _column_5(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<String>('id', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.string); | ||||||
|  | i1.GeneratedColumn<int> _column_6(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<int>('account_id', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.int); | ||||||
|  | i1.GeneratedColumn<String> _column_7(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<String>('public_key', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.string); | ||||||
|  | i1.GeneratedColumn<String> _column_8(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<String>('private_key', aliasedName, true, | ||||||
|  |         type: i1.DriftSqlType.string); | ||||||
|  | i1.GeneratedColumn<bool> _column_9(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<bool>('is_active', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.bool, | ||||||
|  |         defaultConstraints: i1.GeneratedColumn.constraintIsAlways( | ||||||
|  |             'CHECK ("is_active" IN (0, 1))'), | ||||||
|  |         defaultValue: const CustomExpression('0')); | ||||||
|  |  | ||||||
|  | final class Schema3 extends i0.VersionedSchema { | ||||||
|  |   Schema3({required super.database}) : super(version: 3); | ||||||
|  |   @override | ||||||
|  |   late final List<i1.DatabaseSchemaEntity> entities = [ | ||||||
|  |     snLocalChatChannel, | ||||||
|  |     snLocalChatMessage, | ||||||
|  |     snLocalChannelMember, | ||||||
|  |     snLocalKeyPair, | ||||||
|  |     snLocalAccount, | ||||||
|  |     snLocalAttachment, | ||||||
|  |     snLocalSticker, | ||||||
|  |     snLocalStickerPack, | ||||||
|  |     idxChannelAlias, | ||||||
|  |     idxChatChannel, | ||||||
|  |     idxAccountName, | ||||||
|  |     idxAttachmentRid, | ||||||
|  |     idxAttachmentAccount, | ||||||
|  |   ]; | ||||||
|  |   late final Shape0 snLocalChatChannel = Shape0( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_chat_channel', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_1, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape3 snLocalChatMessage = Shape3( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_chat_message', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_4, | ||||||
|  |           _column_10, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape4 snLocalChannelMember = Shape4( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_channel_member', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_4, | ||||||
|  |           _column_6, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |           _column_11, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape2 snLocalKeyPair = Shape2( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_key_pair', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [ | ||||||
|  |           'PRIMARY KEY(id)', | ||||||
|  |         ], | ||||||
|  |         columns: [ | ||||||
|  |           _column_5, | ||||||
|  |           _column_6, | ||||||
|  |           _column_7, | ||||||
|  |           _column_8, | ||||||
|  |           _column_9, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape5 snLocalAccount = Shape5( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_account', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_12, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |           _column_11, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape6 snLocalAttachment = Shape6( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_attachment', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_13, | ||||||
|  |           _column_14, | ||||||
|  |           _column_2, | ||||||
|  |           _column_6, | ||||||
|  |           _column_3, | ||||||
|  |           _column_11, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape7 snLocalSticker = Shape7( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_sticker', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_1, | ||||||
|  |           _column_15, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   late final Shape8 snLocalStickerPack = Shape8( | ||||||
|  |       source: i0.VersionedTable( | ||||||
|  |         entityName: 'sn_local_sticker_pack', | ||||||
|  |         withoutRowId: false, | ||||||
|  |         isStrict: false, | ||||||
|  |         tableConstraints: [], | ||||||
|  |         columns: [ | ||||||
|  |           _column_0, | ||||||
|  |           _column_2, | ||||||
|  |           _column_3, | ||||||
|  |         ], | ||||||
|  |         attachedDatabase: database, | ||||||
|  |       ), | ||||||
|  |       alias: null); | ||||||
|  |   final i1.Index idxChannelAlias = i1.Index('idx_channel_alias', | ||||||
|  |       'CREATE INDEX idx_channel_alias ON sn_local_chat_channel (alias)'); | ||||||
|  |   final i1.Index idxChatChannel = i1.Index('idx_chat_channel', | ||||||
|  |       'CREATE INDEX idx_chat_channel ON sn_local_chat_message (channel_id)'); | ||||||
|  |   final i1.Index idxAccountName = i1.Index('idx_account_name', | ||||||
|  |       'CREATE INDEX idx_account_name ON sn_local_account (name)'); | ||||||
|  |   final i1.Index idxAttachmentRid = i1.Index('idx_attachment_rid', | ||||||
|  |       'CREATE INDEX idx_attachment_rid ON sn_local_attachment (rid)'); | ||||||
|  |   final i1.Index idxAttachmentAccount = i1.Index('idx_attachment_account', | ||||||
|  |       'CREATE INDEX idx_attachment_account ON sn_local_attachment (account_id)'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class Shape3 extends i0.VersionedTable { | ||||||
|  |   Shape3({required super.source, required super.alias}) : super.aliased(); | ||||||
|  |   i1.GeneratedColumn<int> get id => | ||||||
|  |       columnsByName['id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<int> get channelId => | ||||||
|  |       columnsByName['channel_id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<int> get senderId => | ||||||
|  |       columnsByName['sender_id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<String> get content => | ||||||
|  |       columnsByName['content']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get createdAt => | ||||||
|  |       columnsByName['created_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | i1.GeneratedColumn<int> _column_10(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<int>('sender_id', aliasedName, true, | ||||||
|  |         type: i1.DriftSqlType.int); | ||||||
|  |  | ||||||
|  | class Shape4 extends i0.VersionedTable { | ||||||
|  |   Shape4({required super.source, required super.alias}) : super.aliased(); | ||||||
|  |   i1.GeneratedColumn<int> get id => | ||||||
|  |       columnsByName['id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<int> get channelId => | ||||||
|  |       columnsByName['channel_id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<int> get accountId => | ||||||
|  |       columnsByName['account_id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<String> get content => | ||||||
|  |       columnsByName['content']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get createdAt => | ||||||
|  |       columnsByName['created_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get cacheExpiredAt => | ||||||
|  |       columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | i1.GeneratedColumn<DateTime> _column_11(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<DateTime>('cache_expired_at', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.dateTime); | ||||||
|  |  | ||||||
|  | class Shape5 extends i0.VersionedTable { | ||||||
|  |   Shape5({required super.source, required super.alias}) : super.aliased(); | ||||||
|  |   i1.GeneratedColumn<int> get id => | ||||||
|  |       columnsByName['id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<String> get name => | ||||||
|  |       columnsByName['name']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<String> get content => | ||||||
|  |       columnsByName['content']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get createdAt => | ||||||
|  |       columnsByName['created_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get cacheExpiredAt => | ||||||
|  |       columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | i1.GeneratedColumn<String> _column_12(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<String>('name', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.string); | ||||||
|  |  | ||||||
|  | class Shape6 extends i0.VersionedTable { | ||||||
|  |   Shape6({required super.source, required super.alias}) : super.aliased(); | ||||||
|  |   i1.GeneratedColumn<int> get id => | ||||||
|  |       columnsByName['id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<String> get rid => | ||||||
|  |       columnsByName['rid']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<String> get uuid => | ||||||
|  |       columnsByName['uuid']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<String> get content => | ||||||
|  |       columnsByName['content']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<int> get accountId => | ||||||
|  |       columnsByName['account_id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get createdAt => | ||||||
|  |       columnsByName['created_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get cacheExpiredAt => | ||||||
|  |       columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | i1.GeneratedColumn<String> _column_13(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<String>('rid', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.string, | ||||||
|  |         defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE')); | ||||||
|  | i1.GeneratedColumn<String> _column_14(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<String>('uuid', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.string, | ||||||
|  |         defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE')); | ||||||
|  |  | ||||||
|  | class Shape7 extends i0.VersionedTable { | ||||||
|  |   Shape7({required super.source, required super.alias}) : super.aliased(); | ||||||
|  |   i1.GeneratedColumn<int> get id => | ||||||
|  |       columnsByName['id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<String> get alias => | ||||||
|  |       columnsByName['alias']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<String> get fullAlias => | ||||||
|  |       columnsByName['full_alias']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<String> get content => | ||||||
|  |       columnsByName['content']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get createdAt => | ||||||
|  |       columnsByName['created_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | i1.GeneratedColumn<String> _column_15(String aliasedName) => | ||||||
|  |     i1.GeneratedColumn<String>('full_alias', aliasedName, false, | ||||||
|  |         type: i1.DriftSqlType.string); | ||||||
|  |  | ||||||
|  | class Shape8 extends i0.VersionedTable { | ||||||
|  |   Shape8({required super.source, required super.alias}) : super.aliased(); | ||||||
|  |   i1.GeneratedColumn<int> get id => | ||||||
|  |       columnsByName['id']! as i1.GeneratedColumn<int>; | ||||||
|  |   i1.GeneratedColumn<String> get content => | ||||||
|  |       columnsByName['content']! as i1.GeneratedColumn<String>; | ||||||
|  |   i1.GeneratedColumn<DateTime> get createdAt => | ||||||
|  |       columnsByName['created_at']! as i1.GeneratedColumn<DateTime>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | i0.MigrationStepWithVersion migrationSteps({ | ||||||
|  |   required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, | ||||||
|  |   required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, | ||||||
|  | }) { | ||||||
|  |   return (currentVersion, database) async { | ||||||
|  |     switch (currentVersion) { | ||||||
|  |       case 1: | ||||||
|  |         final schema = Schema2(database: database); | ||||||
|  |         final migrator = i1.Migrator(database, schema); | ||||||
|  |         await from1To2(migrator, schema); | ||||||
|  |         return 2; | ||||||
|  |       case 2: | ||||||
|  |         final schema = Schema3(database: database); | ||||||
|  |         final migrator = i1.Migrator(database, schema); | ||||||
|  |         await from2To3(migrator, schema); | ||||||
|  |         return 3; | ||||||
|  |       default: | ||||||
|  |         throw ArgumentError.value('Unknown migration from $currentVersion'); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | i1.OnUpgrade stepByStep({ | ||||||
|  |   required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, | ||||||
|  |   required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, | ||||||
|  | }) => | ||||||
|  |     i0.VersionedSchema.stepByStepHelper( | ||||||
|  |         step: migrationSteps( | ||||||
|  |       from1To2: from1To2, | ||||||
|  |       from2To3: from2To3, | ||||||
|  |     )); | ||||||
							
								
								
									
										8
									
								
								lib/database/drift_worker.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								lib/database/drift_worker.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | |||||||
|  | import 'package:drift/wasm.dart'; | ||||||
|  |  | ||||||
|  | // Use `dart compile js -O4 ./drift_worker.dart` to compile this file. | ||||||
|  | // And place it in the web/ directory. | ||||||
|  |  | ||||||
|  | // When compiled with dart2js, this file defines a dedicated or shared web | ||||||
|  | // worker used by drift. | ||||||
|  | void main() => WasmDatabase.workerMainForOpen(); | ||||||
							
								
								
									
										16
									
								
								lib/database/keypair.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								lib/database/keypair.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | import 'package:drift/drift.dart'; | ||||||
|  |  | ||||||
|  | class SnLocalKeyPair extends Table { | ||||||
|  |   TextColumn get id => text()(); | ||||||
|  |  | ||||||
|  |   IntColumn get accountId => integer()(); | ||||||
|  |  | ||||||
|  |   TextColumn get publicKey => text()(); | ||||||
|  |  | ||||||
|  |   TextColumn get privateKey => text().nullable()(); | ||||||
|  |  | ||||||
|  |   BoolColumn get isActive => boolean().withDefault(Constant(false))(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Set<Column<Object>> get primaryKey => {id}; | ||||||
|  | } | ||||||
							
								
								
									
										74
									
								
								lib/database/sticker.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								lib/database/sticker.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,74 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  |  | ||||||
|  | import 'package:drift/drift.dart'; | ||||||
|  | import 'package:surface/types/attachment.dart'; | ||||||
|  |  | ||||||
|  | class SnStickerConverter extends TypeConverter<SnSticker, String> | ||||||
|  |     with JsonTypeConverter2<SnSticker, String, Map<String, Object?>> { | ||||||
|  |   const SnStickerConverter(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnSticker fromSql(String fromDb) { | ||||||
|  |     return fromJson(jsonDecode(fromDb) as Map<String, dynamic>); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String toSql(SnSticker value) { | ||||||
|  |     return jsonEncode(toJson(value)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnSticker fromJson(Map<String, Object?> json) { | ||||||
|  |     return SnSticker.fromJson(json); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Map<String, Object?> toJson(SnSticker value) { | ||||||
|  |     return value.toJson(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class SnLocalSticker extends Table { | ||||||
|  |   IntColumn get id => integer().autoIncrement()(); | ||||||
|  |  | ||||||
|  |   TextColumn get alias => text()(); | ||||||
|  |  | ||||||
|  |   TextColumn get fullAlias => text()(); | ||||||
|  |  | ||||||
|  |   TextColumn get content => text().map(const SnStickerConverter())(); | ||||||
|  |  | ||||||
|  |   DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class SnStickerPackConverter extends TypeConverter<SnStickerPack, String> | ||||||
|  |     with JsonTypeConverter2<SnStickerPack, String, Map<String, Object?>> { | ||||||
|  |   const SnStickerPackConverter(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnStickerPack fromSql(String fromDb) { | ||||||
|  |     return fromJson(jsonDecode(fromDb) as Map<String, dynamic>); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String toSql(SnStickerPack value) { | ||||||
|  |     return jsonEncode(toJson(value)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   SnStickerPack fromJson(Map<String, Object?> json) { | ||||||
|  |     return SnStickerPack.fromJson(json); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Map<String, Object?> toJson(SnStickerPack value) { | ||||||
|  |     return value.toJson(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class SnLocalStickerPack extends Table { | ||||||
|  |   IntColumn get id => integer().autoIncrement()(); | ||||||
|  |  | ||||||
|  |   TextColumn get content => text().map(const SnStickerPackConverter())(); | ||||||
|  |  | ||||||
|  |   DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); | ||||||
|  | } | ||||||
							
								
								
									
										10
									
								
								lib/logger.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								lib/logger.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | |||||||
|  | import 'package:talker/talker.dart'; | ||||||
|  |  | ||||||
|  | final logging = Talker( | ||||||
|  |   settings: TalkerSettings( | ||||||
|  |     enabled: true, | ||||||
|  |     useHistory: true, | ||||||
|  |     maxHistoryItems: 1000, | ||||||
|  |     useConsoleLogs: true, | ||||||
|  |   ), | ||||||
|  | ); | ||||||
							
								
								
									
										194
									
								
								lib/main.dart
									
									
									
									
									
								
							
							
						
						
									
										194
									
								
								lib/main.dart
									
									
									
									
									
								
							| @@ -13,16 +13,19 @@ import 'package:flutter/foundation.dart'; | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter/services.dart'; | import 'package:flutter/services.dart'; | ||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
| import 'package:hive_flutter/hive_flutter.dart'; | import 'package:hotkey_manager/hotkey_manager.dart'; | ||||||
| import 'package:package_info_plus/package_info_plus.dart'; | import 'package:package_info_plus/package_info_plus.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:relative_time/relative_time.dart'; | import 'package:relative_time/relative_time.dart'; | ||||||
| import 'package:responsive_framework/responsive_framework.dart'; | import 'package:responsive_framework/responsive_framework.dart'; | ||||||
| import 'package:shared_preferences/shared_preferences.dart'; | import 'package:shared_preferences/shared_preferences.dart'; | ||||||
| import 'package:surface/firebase_options.dart'; | import 'package:surface/firebase_options.dart'; | ||||||
|  | import 'package:surface/logger.dart'; | ||||||
| import 'package:surface/providers/channel.dart'; | import 'package:surface/providers/channel.dart'; | ||||||
| import 'package:surface/providers/chat_call.dart'; | import 'package:surface/providers/chat_call.dart'; | ||||||
| import 'package:surface/providers/config.dart'; | import 'package:surface/providers/config.dart'; | ||||||
|  | import 'package:surface/providers/database.dart'; | ||||||
|  | import 'package:surface/providers/keypair.dart'; | ||||||
| import 'package:surface/providers/link_preview.dart'; | import 'package:surface/providers/link_preview.dart'; | ||||||
| import 'package:surface/providers/navigation.dart'; | import 'package:surface/providers/navigation.dart'; | ||||||
| import 'package:surface/providers/notification.dart'; | import 'package:surface/providers/notification.dart'; | ||||||
| @@ -30,6 +33,7 @@ import 'package:surface/providers/post.dart'; | |||||||
| import 'package:surface/providers/relationship.dart'; | import 'package:surface/providers/relationship.dart'; | ||||||
| import 'package:surface/providers/sn_attachment.dart'; | import 'package:surface/providers/sn_attachment.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
|  | import 'package:surface/providers/sn_realm.dart'; | ||||||
| import 'package:surface/providers/sn_sticker.dart'; | import 'package:surface/providers/sn_sticker.dart'; | ||||||
| import 'package:surface/providers/special_day.dart'; | import 'package:surface/providers/special_day.dart'; | ||||||
| import 'package:surface/providers/theme.dart'; | import 'package:surface/providers/theme.dart'; | ||||||
| @@ -38,14 +42,15 @@ import 'package:surface/providers/userinfo.dart'; | |||||||
| import 'package:surface/providers/websocket.dart'; | import 'package:surface/providers/websocket.dart'; | ||||||
| import 'package:surface/providers/widget.dart'; | import 'package:surface/providers/widget.dart'; | ||||||
| import 'package:surface/router.dart'; | import 'package:surface/router.dart'; | ||||||
| import 'package:surface/types/chat.dart'; |  | ||||||
| import 'package:surface/types/realm.dart'; |  | ||||||
| import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy; | import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy; | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
| import 'package:tray_manager/tray_manager.dart'; | import 'package:tray_manager/tray_manager.dart'; | ||||||
| import 'package:version/version.dart'; | import 'package:version/version.dart'; | ||||||
| import 'package:workmanager/workmanager.dart'; | import 'package:workmanager/workmanager.dart'; | ||||||
| import 'package:in_app_review/in_app_review.dart'; | import 'package:in_app_review/in_app_review.dart'; | ||||||
|  | import 'package:image_picker_android/image_picker_android.dart'; | ||||||
|  | import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; | ||||||
|  | import 'package:local_notifier/local_notifier.dart'; | ||||||
|  |  | ||||||
| @pragma('vm:entry-point') | @pragma('vm:entry-point') | ||||||
| void appBackgroundDispatcher() { | void appBackgroundDispatcher() { | ||||||
| @@ -66,20 +71,6 @@ void appBackgroundDispatcher() { | |||||||
|  |  | ||||||
| void main() async { | void main() async { | ||||||
|   WidgetsFlutterBinding.ensureInitialized(); |   WidgetsFlutterBinding.ensureInitialized(); | ||||||
|   await EasyLocalization.ensureInitialized(); |  | ||||||
|  |  | ||||||
|   await Hive.initFlutter(); |  | ||||||
|   Hive.registerAdapter(SnChannelImplAdapter()); |  | ||||||
|   Hive.registerAdapter(SnRealmImplAdapter()); |  | ||||||
|   Hive.registerAdapter(SnChannelMemberImplAdapter()); |  | ||||||
|   Hive.registerAdapter(SnChatMessageImplAdapter()); |  | ||||||
|  |  | ||||||
|   await Firebase.initializeApp( |  | ||||||
|     options: DefaultFirebaseOptions.currentPlatform, |  | ||||||
|   ); |  | ||||||
|  |  | ||||||
|   GoRouter.optionURLReflectsImperativeAPIs = true; |  | ||||||
|   usePathUrlStrategy(); |  | ||||||
|  |  | ||||||
|   if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { |   if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { | ||||||
|     doWhenWindowReady(() { |     doWhenWindowReady(() { | ||||||
| @@ -90,6 +81,17 @@ void main() async { | |||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   await EasyLocalization.ensureInitialized(); | ||||||
|  |  | ||||||
|  |   if (!kIsWeb && !Platform.isLinux) { | ||||||
|  |     await Firebase.initializeApp( | ||||||
|  |       options: DefaultFirebaseOptions.currentPlatform, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   GoRouter.optionURLReflectsImperativeAPIs = true; | ||||||
|  |   usePathUrlStrategy(); | ||||||
|  |  | ||||||
|   if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { |   if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { | ||||||
|     Workmanager().initialize( |     Workmanager().initialize( | ||||||
|       appBackgroundDispatcher, |       appBackgroundDispatcher, | ||||||
| @@ -106,6 +108,14 @@ void main() async { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   if (!kIsWeb && Platform.isAndroid) { | ||||||
|  |     final ImagePickerPlatform imagePickerImplementation = | ||||||
|  |         ImagePickerPlatform.instance; | ||||||
|  |     if (imagePickerImplementation is ImagePickerAndroid) { | ||||||
|  |       imagePickerImplementation.useAndroidPhotoPicker = true; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   runApp(const SolianApp()); |   runApp(const SolianApp()); | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -128,6 +138,9 @@ class SolianApp extends StatelessWidget { | |||||||
|         assetLoader: JsonAssetLoader(), |         assetLoader: JsonAssetLoader(), | ||||||
|         child: MultiProvider( |         child: MultiProvider( | ||||||
|           providers: [ |           providers: [ | ||||||
|  |             // Infrastructure layer | ||||||
|  |             Provider(create: (ctx) => DatabaseProvider(ctx)), | ||||||
|  |  | ||||||
|             // System extensions layer |             // System extensions layer | ||||||
|             Provider(create: (ctx) => HomeWidgetProvider(ctx)), |             Provider(create: (ctx) => HomeWidgetProvider(ctx)), | ||||||
|  |  | ||||||
| @@ -142,12 +155,14 @@ class SolianApp extends StatelessWidget { | |||||||
|             Provider(create: (ctx) => SnNetworkProvider(ctx)), |             Provider(create: (ctx) => SnNetworkProvider(ctx)), | ||||||
|             Provider(create: (ctx) => UserDirectoryProvider(ctx)), |             Provider(create: (ctx) => UserDirectoryProvider(ctx)), | ||||||
|             Provider(create: (ctx) => SnAttachmentProvider(ctx)), |             Provider(create: (ctx) => SnAttachmentProvider(ctx)), | ||||||
|  |             Provider(create: (ctx) => SnRealmProvider(ctx)), | ||||||
|             Provider(create: (ctx) => SnPostContentProvider(ctx)), |             Provider(create: (ctx) => SnPostContentProvider(ctx)), | ||||||
|             Provider(create: (ctx) => SnRelationshipProvider(ctx)), |             Provider(create: (ctx) => SnRelationshipProvider(ctx)), | ||||||
|             Provider(create: (ctx) => SnLinkPreviewProvider(ctx)), |             Provider(create: (ctx) => SnLinkPreviewProvider(ctx)), | ||||||
|             Provider(create: (ctx) => SnStickerProvider(ctx)), |             Provider(create: (ctx) => SnStickerProvider(ctx)), | ||||||
|             ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)), |             ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)), | ||||||
|             ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)), |             ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)), | ||||||
|  |             Provider(create: (ctx) => KeyPairProvider(ctx)), | ||||||
|             ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)), |             ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)), | ||||||
|             ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)), |             ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)), | ||||||
|             ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)), |             ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)), | ||||||
| @@ -159,8 +174,8 @@ class SolianApp extends StatelessWidget { | |||||||
|         ), |         ), | ||||||
|       ), |       ), | ||||||
|       breakpoints: [ |       breakpoints: [ | ||||||
|         const Breakpoint(start: 0, end: 450, name: MOBILE), |         const Breakpoint(start: 0, end: 600, name: MOBILE), | ||||||
|         const Breakpoint(start: 451, end: 800, name: TABLET), |         const Breakpoint(start: 601, end: 800, name: TABLET), | ||||||
|         const Breakpoint(start: 801, end: 1920, name: DESKTOP), |         const Breakpoint(start: 801, end: 1920, name: DESKTOP), | ||||||
|       ], |       ], | ||||||
|     ); |     ); | ||||||
| @@ -215,14 +230,15 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | |||||||
|     if (prefs.containsKey('first_boot_time')) { |     if (prefs.containsKey('first_boot_time')) { | ||||||
|       final rawTime = prefs.getString('first_boot_time'); |       final rawTime = prefs.getString('first_boot_time'); | ||||||
|       final time = DateTime.tryParse(rawTime ?? ''); |       final time = DateTime.tryParse(rawTime ?? ''); | ||||||
|       if (time != null && time.isBefore(DateTime.now().subtract(const Duration(days: 3)))) { |       if (time != null && | ||||||
|  |           time.isBefore(DateTime.now().subtract(const Duration(days: 3)))) { | ||||||
|         final inAppReview = InAppReview.instance; |         final inAppReview = InAppReview.instance; | ||||||
|         if (prefs.getBool('rating_requested') == true) return; |         if (prefs.getBool('rating_requested') == true) return; | ||||||
|         if (await inAppReview.isAvailable()) { |         if (await inAppReview.isAvailable()) { | ||||||
|           await inAppReview.requestReview(); |           await inAppReview.requestReview(); | ||||||
|           prefs.setBool('rating_requested', true); |           prefs.setBool('rating_requested', true); | ||||||
|         } else { |         } else { | ||||||
|           log('Unable request app review, unavailable'); |           logging.error('Unable request app review, unavailable'); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } else { |     } else { | ||||||
| @@ -241,20 +257,27 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | |||||||
|           receiveTimeout: const Duration(seconds: 60), |           receiveTimeout: const Duration(seconds: 60), | ||||||
|         ), |         ), | ||||||
|       ).get( |       ).get( | ||||||
|         'https://git.solsynth.dev/api/v1/repos/HyperNet/Surface/tags?page=1&limit=1', |         'https://api.github.com/repos/Solsynth/HyperNet.Surface/releases/latest', | ||||||
|       ); |       ); | ||||||
|       final remoteVersionString = (resp.data as List).firstOrNull?['name'] ?? '0.0.0+0'; |       final remoteVersionString = resp.data?['tag_name'] ?? '0.0.0+0'; | ||||||
|       final remoteVersion = Version.parse(remoteVersionString.split('+').first); |       final remoteVersion = Version.parse(remoteVersionString.split('+').first); | ||||||
|       final localVersion = Version.parse(localVersionString.split('+').first); |       final localVersion = Version.parse(localVersionString.split('+').first); | ||||||
|       final remoteBuildNumber = int.tryParse(remoteVersionString.split('+').last) ?? 0; |       final remoteBuildNumber = | ||||||
|       final localBuildNumber = int.tryParse(localVersionString.split('+').last) ?? 0; |           int.tryParse(remoteVersionString.split('+').last) ?? 0; | ||||||
|       log("[Update] Local: $localVersionString, Remote: $remoteVersionString"); |       final localBuildNumber = | ||||||
|       if ((remoteVersion > localVersion || remoteBuildNumber > localBuildNumber) && mounted) { |           int.tryParse(localVersionString.split('+').last) ?? 0; | ||||||
|  |       logging.info( | ||||||
|  |           "[Update] Local: $localVersionString, Remote: $remoteVersionString"); | ||||||
|  |       if ((remoteVersion > localVersion || | ||||||
|  |               remoteBuildNumber > localBuildNumber) && | ||||||
|  |           mounted) { | ||||||
|         final config = context.read<ConfigProvider>(); |         final config = context.read<ConfigProvider>(); | ||||||
|         config.setUpdate(remoteVersionString); |         config.setUpdate( | ||||||
|         log("[Update] Update available: $remoteVersionString"); |             remoteVersionString, resp.data?['body'] ?? 'No changelog'); | ||||||
|  |         logging.info("[Update] Update available: $remoteVersionString"); | ||||||
|       } |       } | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|  |       logging.error('[Error] Unable to check update...', e); | ||||||
|       if (mounted) context.showErrorDialog('Unable to check update: $e'); |       if (mounted) context.showErrorDialog('Unable to check update: $e'); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| @@ -285,8 +308,17 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | |||||||
|       notify.listen(); |       notify.listen(); | ||||||
|       await notify.registerPushNotifications(); |       await notify.registerPushNotifications(); | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|  |       final kp = context.read<KeyPairProvider>(); | ||||||
|  |       await kp.reloadActive(); | ||||||
|  |       kp.listen(); | ||||||
|  |       if (!mounted) return; | ||||||
|       final sticker = context.read<SnStickerProvider>(); |       final sticker = context.read<SnStickerProvider>(); | ||||||
|       await sticker.listStickerEagerly(); |       await sticker.listSticker(); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       final ud = context.read<UserDirectoryProvider>(); | ||||||
|  |       final userCacheSize = await ud.loadAccountCache(); | ||||||
|  |       logging.info('[Users] Loaded local user cache, size: $userCacheSize'); | ||||||
|  |       logging.info('[Bootstrap] Everything initialized!'); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       await context.showErrorDialog(err); |       await context.showErrorDialog(err); | ||||||
| @@ -297,30 +329,74 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | |||||||
|     await widgetUpdateRandomPost(); |     await widgetUpdateRandomPost(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> _trayInitialization() async { |   Future<void> _hotkeyInitialization() async { | ||||||
|     if (kIsWeb || Platform.isAndroid || Platform.isIOS) return; |     if (kIsWeb) return; | ||||||
|  |  | ||||||
|     final icon = Platform.isWindows ? 'assets/icon/tray-icon.ico' : 'assets/icon/tray-icon.png'; |     if (Platform.isMacOS) { | ||||||
|     final appVersion = await PackageInfo.fromPlatform(); |       HotKey quitHotKey = HotKey( | ||||||
|  |         key: PhysicalKeyboardKey.keyQ, | ||||||
|  |         modifiers: [HotKeyModifier.meta], | ||||||
|  |         scope: HotKeyScope.inapp, | ||||||
|  |       ); | ||||||
|  |       await hotKeyManager.register(quitHotKey, keyUpHandler: (_) { | ||||||
|  |         _appLifecycleListener?.dispose(); | ||||||
|  |         SystemChannels.platform.invokeMethod('SystemNavigator.pop'); | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|     trayManager.addListener(this); |   final Menu _appTrayMenu = Menu( | ||||||
|     await trayManager.setIcon(icon); |  | ||||||
|  |  | ||||||
|     Menu menu = Menu( |  | ||||||
|     items: [ |     items: [ | ||||||
|       MenuItem( |       MenuItem( | ||||||
|         key: 'version_label', |         key: 'version_label', | ||||||
|           label: 'Solian ${appVersion.version}+${appVersion.buildNumber}', |         label: 'Solian', | ||||||
|         disabled: true, |         disabled: true, | ||||||
|       ), |       ), | ||||||
|       MenuItem.separator(), |       MenuItem.separator(), | ||||||
|  |       MenuItem.checkbox( | ||||||
|  |         checked: false, | ||||||
|  |         key: 'mute_notification', | ||||||
|  |         label: 'trayMenuMuteNotification'.tr(), | ||||||
|  |       ), | ||||||
|  |       MenuItem.separator(), | ||||||
|  |       MenuItem( | ||||||
|  |         key: 'window_show', | ||||||
|  |         label: 'trayMenuShow'.tr(), | ||||||
|  |       ), | ||||||
|       MenuItem( |       MenuItem( | ||||||
|         key: 'exit', |         key: 'exit', | ||||||
|         label: 'trayMenuExit'.tr(), |         label: 'trayMenuExit'.tr(), | ||||||
|       ), |       ), | ||||||
|     ], |     ], | ||||||
|   ); |   ); | ||||||
|     await trayManager.setContextMenu(menu); |  | ||||||
|  |   Future<void> _trayInitialization() async { | ||||||
|  |     if (kIsWeb || Platform.isAndroid || Platform.isIOS) return; | ||||||
|  |  | ||||||
|  |     final icon = Platform.isWindows | ||||||
|  |         ? 'assets/icon/tray-icon.ico' | ||||||
|  |         : 'assets/icon/tray-icon.png'; | ||||||
|  |     final appVersion = await PackageInfo.fromPlatform(); | ||||||
|  |  | ||||||
|  |     trayManager.addListener(this); | ||||||
|  |     await trayManager.setIcon(icon); | ||||||
|  |  | ||||||
|  |     _appTrayMenu.items![0] = MenuItem( | ||||||
|  |       key: 'version_label', | ||||||
|  |       label: 'Solian ${appVersion.version}+${appVersion.buildNumber}', | ||||||
|  |       disabled: true, | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     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; |   AppLifecycleListener? _appLifecycleListener; | ||||||
| @@ -336,6 +412,8 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     _trayInitialization(); |     _trayInitialization(); | ||||||
|  |     _hotkeyInitialization(); | ||||||
|  |     _notifyInitialization(); | ||||||
|     _initialize().then((_) { |     _initialize().then((_) { | ||||||
|       _postInitialization(); |       _postInitialization(); | ||||||
|       _tryRequestRating(); |       _tryRequestRating(); | ||||||
| @@ -351,6 +429,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | |||||||
|   @override |   @override | ||||||
|   void onTrayIconMouseDown() { |   void onTrayIconMouseDown() { | ||||||
|     if (Platform.isWindows) { |     if (Platform.isWindows) { | ||||||
|  |       context.read<NotificationProvider>().clearTray(); | ||||||
|       appWindow.show(); |       appWindow.show(); | ||||||
|     } else { |     } else { | ||||||
|       trayManager.popUpContextMenu(); |       trayManager.popUpContextMenu(); | ||||||
| @@ -362,6 +441,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | |||||||
|     if (Platform.isWindows) { |     if (Platform.isWindows) { | ||||||
|       trayManager.popUpContextMenu(); |       trayManager.popUpContextMenu(); | ||||||
|     } else { |     } else { | ||||||
|  |       context.read<NotificationProvider>().clearTray(); | ||||||
|       appWindow.show(); |       appWindow.show(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| @@ -369,16 +449,33 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | |||||||
|   @override |   @override | ||||||
|   void onTrayMenuItemClick(MenuItem menuItem) { |   void onTrayMenuItemClick(MenuItem menuItem) { | ||||||
|     switch (menuItem.key) { |     switch (menuItem.key) { | ||||||
|  |       case 'mute_notification': | ||||||
|  |         final nty = context.read<NotificationProvider>(); | ||||||
|  |         nty.isMuted = !nty.isMuted; | ||||||
|  |         _appTrayMenu.items![2].checked = nty.isMuted; | ||||||
|  |         trayManager.setContextMenu(_appTrayMenu); | ||||||
|  |         break; | ||||||
|  |       case 'window_show': | ||||||
|  |         // To prevent the window from being hide after just show on macOS | ||||||
|  |         Timer(const Duration(milliseconds: 100), () => appWindow.show()); | ||||||
|  |         break; | ||||||
|       case 'exit': |       case 'exit': | ||||||
|         _appLifecycleListener?.dispose(); |         _appLifecycleListener?.dispose(); | ||||||
|  |         if (Platform.isWindows) { | ||||||
|  |           appWindow.close(); | ||||||
|  |         } else { | ||||||
|           SystemChannels.platform.invokeMethod('SystemNavigator.pop'); |           SystemChannels.platform.invokeMethod('SystemNavigator.pop'); | ||||||
|  |         } | ||||||
|         break; |         break; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void dispose() { |   void dispose() { | ||||||
|     if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) trayManager.removeListener(this); |     if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) { | ||||||
|  |       trayManager.removeListener(this); | ||||||
|  |       hotKeyManager.unregisterAll(); | ||||||
|  |     } | ||||||
|     super.dispose(); |     super.dispose(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -392,8 +489,21 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { | |||||||
|         }); |         }); | ||||||
|         return false; |         return false; | ||||||
|       }, |       }, | ||||||
|       child: SizeChangedLayoutNotifier( |       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: widget.child, |             child: widget.child, | ||||||
|  |           ); | ||||||
|  |         }, | ||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -1,48 +1,57 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  |  | ||||||
|  | import 'package:drift/drift.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:hive_flutter/hive_flutter.dart'; |  | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:surface/controllers/chat_message_controller.dart'; | import 'package:surface/database/database.dart'; | ||||||
|  | import 'package:surface/providers/database.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
|  | import 'package:surface/providers/sn_realm.dart'; | ||||||
| import 'package:surface/providers/user_directory.dart'; | import 'package:surface/providers/user_directory.dart'; | ||||||
|  | import 'package:surface/providers/userinfo.dart'; | ||||||
| import 'package:surface/types/chat.dart'; | import 'package:surface/types/chat.dart'; | ||||||
| import 'package:surface/types/realm.dart'; |  | ||||||
|  |  | ||||||
| class ChatChannelProvider extends ChangeNotifier { | class ChatChannelProvider extends ChangeNotifier { | ||||||
|   static const kChatChannelBoxName = 'nex_chat_channels'; |   static const kChatChannelBoxName = 'nex_chat_channels'; | ||||||
|  |  | ||||||
|   late final SnNetworkProvider _sn; |   late final SnNetworkProvider _sn; | ||||||
|   late final UserDirectoryProvider _ud; |   late final UserDirectoryProvider _ud; | ||||||
|  |   late final UserProvider _ua; | ||||||
|   Box<SnChannel>? get _channelBox => Hive.box<SnChannel>(kChatChannelBoxName); |   late final DatabaseProvider _dt; | ||||||
|  |   late final SnRealmProvider _rels; | ||||||
|  |  | ||||||
|   ChatChannelProvider(BuildContext context) { |   ChatChannelProvider(BuildContext context) { | ||||||
|     _sn = context.read<SnNetworkProvider>(); |     _sn = context.read<SnNetworkProvider>(); | ||||||
|     _ud = context.read<UserDirectoryProvider>(); |     _ud = context.read<UserDirectoryProvider>(); | ||||||
|     _initializeLocalData(); |     _ua = context.read<UserProvider>(); | ||||||
|   } |     _dt = context.read<DatabaseProvider>(); | ||||||
|  |     _rels = context.read<SnRealmProvider>(); | ||||||
|   Future<void> _initializeLocalData() async { |  | ||||||
|     await Hive.openBox<SnChannel>(kChatChannelBoxName); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async { |   Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async { | ||||||
|     if (_channelBox == null) return; |     await Future.wait( | ||||||
|     await _channelBox!.putAll({ |       channels.map( | ||||||
|       for (final channel in channels) channel.key: channel, |         (ele) => _dt.db.snLocalChatChannel.insertOne( | ||||||
|     }); |           SnLocalChatChannelCompanion.insert( | ||||||
|  |             id: Value(ele.id), | ||||||
|  |             alias: ele.key, | ||||||
|  |             content: ele, | ||||||
|  |             createdAt: Value(ele.createdAt), | ||||||
|  |           ), | ||||||
|  |           onConflict: DoUpdate( | ||||||
|  |             (_) => SnLocalChatChannelCompanion.custom( | ||||||
|  |               content: Constant(jsonEncode(ele.toJson())), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<List<SnChannel>> _fetchChannelsFromServer({ |   Future<List<SnChannel>> _fetchChannelsFromServer({ | ||||||
|     String scope = 'global', |  | ||||||
|     bool direct = false, |  | ||||||
|     bool doNotSave = false, |     bool doNotSave = false, | ||||||
|   }) async { |   }) async { | ||||||
|     final resp = await _sn.client.get( |     final resp = await _sn.client.get('/cgi/im/channels/me/available'); | ||||||
|       '/cgi/im/channels/$scope/me/available', |  | ||||||
|       queryParameters: { |  | ||||||
|         'direct': direct, |  | ||||||
|       }, |  | ||||||
|     ); |  | ||||||
|     final out = List<SnChannel>.from( |     final out = List<SnChannel>.from( | ||||||
|       resp.data?.map((e) => SnChannel.fromJson(e)) ?? [], |       resp.data?.map((e) => SnChannel.fromJson(e)) ?? [], | ||||||
|     ); |     ); | ||||||
| @@ -54,18 +63,25 @@ class ChatChannelProvider extends ChangeNotifier { | |||||||
|   /// It will use the local storage as much as possible. |   /// It will use the local storage as much as possible. | ||||||
|   /// The alias should include the scope, formatted as `scope:alias`. |   /// The alias should include the scope, formatted as `scope:alias`. | ||||||
|   Future<SnChannel> getChannel(String key) async { |   Future<SnChannel> getChannel(String key) async { | ||||||
|     if (_channelBox != null) { |     final local = await (_dt.db.snLocalChatChannel.select() | ||||||
|       final local = _channelBox!.get(key); |           ..where((e) => e.alias.equals(key))) | ||||||
|       if (local != null) return local; |         .getSingleOrNull(); | ||||||
|  |     if (local != null) { | ||||||
|  |       final out = local.content; | ||||||
|  |       if (out.realmId != null) { | ||||||
|  |         return out.copyWith(realm: await _rels.getRealm(out.realmId!)); | ||||||
|  |       } else { | ||||||
|  |         return out; | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     var resp = await _sn.client.get('/cgi/im/channels/$key'); |     var resp = | ||||||
|  |         await _sn.client.get('/cgi/im/channels/${key.replaceAll(':', '/')}'); | ||||||
|     var out = SnChannel.fromJson(resp.data); |     var out = SnChannel.fromJson(resp.data); | ||||||
|  |  | ||||||
|     // Preload realm of the channel |     // Preload realm of the channel | ||||||
|     if (out.realmId != null) { |     if (out.realmId != null) { | ||||||
|       resp = await _sn.client.get('/cgi/id/realms/${out.realmId}'); |       out = out.copyWith(realm: await _rels.getRealm(out.realmId!)); | ||||||
|       out = out.copyWith(realm: SnRealm.fromJson(resp.data)); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     _saveChannelToLocal([out]); |     _saveChannelToLocal([out]); | ||||||
| @@ -77,42 +93,39 @@ class ChatChannelProvider extends ChangeNotifier { | |||||||
|   /// And the second time is when the data was fetched from the server. |   /// And the second time is when the data was fetched from the server. | ||||||
|   /// But there is some exception that will only cause one of them to be emitted. |   /// But there is some exception that will only cause one of them to be emitted. | ||||||
|   /// Like the local storage is broken or the server is down. |   /// Like the local storage is broken or the server is down. | ||||||
|   Stream<List<SnChannel>> fetchChannels() async* { |   Stream<List<SnChannel>> fetchChannels( | ||||||
|     if (_channelBox != null) yield _channelBox!.values.toList(); |       {bool noRemote = false, bool noLocal = false}) async* { | ||||||
|  |     if (!noLocal) { | ||||||
|     var resp = await _sn.client.get('/cgi/id/realms/me/available'); |       final local = await (_dt.db.snLocalChatChannel.select() | ||||||
|     final realms = List<SnRealm>.from( |             ..orderBy([ | ||||||
|       resp.data?.map((e) => SnRealm.fromJson(e)) ?? [], |               (e) => | ||||||
|  |                   OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc) | ||||||
|  |             ])) | ||||||
|  |           .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!), | ||||||
|           ); |           ); | ||||||
|     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); |  | ||||||
|         } |         } | ||||||
|  |       } | ||||||
|  |       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; |     yield result; | ||||||
|   } |   } | ||||||
| @@ -120,23 +133,79 @@ class ChatChannelProvider extends ChangeNotifier { | |||||||
|   Future<List<SnChatMessage>> getLastMessages( |   Future<List<SnChatMessage>> getLastMessages( | ||||||
|     Iterable<SnChannel> channels, |     Iterable<SnChannel> channels, | ||||||
|   ) async { |   ) async { | ||||||
|     final result = List<SnChatMessage>.empty(growable: true); |     final result = List<Future<SnLocalChatMessageData?>>.empty(growable: true); | ||||||
|     for (final channel in channels) { |     for (final channel in channels) { | ||||||
|       final channelBox = await Hive.openBox<SnChatMessage>( |       final out = (_dt.db.snLocalChatMessage.select() | ||||||
|         '${ChatMessageController.kChatMessageBoxPrefix}${channel.id}', |             ..where((e) => e.channelId.equals(channel.id)) | ||||||
|       ); |             ..orderBy([ | ||||||
|       final lastMessage = |               (e) => | ||||||
|           channelBox.isNotEmpty ? channelBox.values.reduce((a, b) => a.createdAt.isAfter(b.createdAt) ? a : b) : null; |                   OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc) | ||||||
|       if (lastMessage != null) result.add(lastMessage); |             ]) | ||||||
|       channelBox.close(); |             ..limit(1)) | ||||||
|  |           .getSingleOrNull(); | ||||||
|  |       result.add(out); | ||||||
|     } |     } | ||||||
|     await _ud.listAccount(result.map((ele) => ele.sender.accountId).toSet()); |     final out = (await Future.wait(result)) | ||||||
|     return result; |         .where((e) => e != null) | ||||||
|  |         .map((e) => e!.content) | ||||||
|  |         .toList(); | ||||||
|  |     await _ud.listAccount(out.map((ele) => ele.sender.accountId).toSet()); | ||||||
|  |     return out; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   Future<void> _saveMemberToLocal(Iterable<SnChannelMember> members) async { | ||||||
|   void dispose() { |     final queries = members.map((ele) { | ||||||
|     _channelBox?.close(); |       return _dt.db.snLocalChannelMember.insertOne( | ||||||
|     super.dispose(); |         SnLocalChannelMemberCompanion.insert( | ||||||
|  |           id: Value(ele.id), | ||||||
|  |           channelId: ele.channelId, | ||||||
|  |           accountId: ele.accountId, | ||||||
|  |           content: ele, | ||||||
|  |           cacheExpiredAt: DateTime.now().add(const Duration(days: 7)), | ||||||
|  |         ), | ||||||
|  |         onConflict: DoUpdate( | ||||||
|  |           (_) => SnLocalChannelMemberCompanion.custom( | ||||||
|  |             content: Constant(jsonEncode(ele.toJson())), | ||||||
|  |             cacheExpiredAt: | ||||||
|  |                 Constant(DateTime.now().add(const Duration(days: 7))), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |     await Future.wait(queries); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> removeLocalChannel(SnChannel channel) async { | ||||||
|  |     await _dt.db.transaction(() async { | ||||||
|  |       await (_dt.db.snLocalChannelMember.delete() | ||||||
|  |             ..where((e) => e.channelId.equals(channel.id))) | ||||||
|  |           .go(); | ||||||
|  |       await (_dt.db.snLocalChatChannel.delete() | ||||||
|  |             ..where((e) => e.id.equals(channel.id))) | ||||||
|  |           .go(); | ||||||
|  |       await (_dt.db.snLocalChatMessage.delete() | ||||||
|  |             ..where((e) => e.channelId.equals(channel.id))) | ||||||
|  |           .go(); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> updateChannelProfile(SnChannelMember member) { | ||||||
|  |     return _saveMemberToLocal([member]); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<SnChannelMember> getChannelProfile(SnChannel channel) async { | ||||||
|  |     if (_ua.user == null) throw Exception('User not logged in'); | ||||||
|  |     final local = await (_dt.db.snLocalChannelMember.select() | ||||||
|  |           ..where((e) => e.channelId.equals(channel.id)) | ||||||
|  |           ..where((e) => e.accountId.equals(_ua.user!.id))) | ||||||
|  |         .getSingleOrNull(); | ||||||
|  |     if (local != null) { | ||||||
|  |       return local.content; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     final resp = await _sn.client.get('/cgi/im/channels/${channel.keyPath}/me'); | ||||||
|  |     final out = SnChannelMember.fromJson(resp.data); | ||||||
|  |     _saveMemberToLocal([out]); | ||||||
|  |     return out; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -17,6 +17,8 @@ const kAppDrawerPreferCollapse = 'app_drawer_prefer_collapse'; | |||||||
| const kAppNotifyWithHaptic = 'app_notify_with_haptic'; | const kAppNotifyWithHaptic = 'app_notify_with_haptic'; | ||||||
| const kAppExpandPostLink = 'app_expand_post_link'; | const kAppExpandPostLink = 'app_expand_post_link'; | ||||||
| const kAppExpandChatLink = 'app_expand_chat_link'; | const kAppExpandChatLink = 'app_expand_chat_link'; | ||||||
|  | const kAppRealmCompactView = 'app_realm_compact_view'; | ||||||
|  | const kAppCustomFonts = 'app_custom_fonts'; | ||||||
|  |  | ||||||
| const Map<String, FilterQuality> kImageQualityLevel = { | const Map<String, FilterQuality> kImageQualityLevel = { | ||||||
|   'settingsImageQualityLowest': FilterQuality.none, |   'settingsImageQualityLowest': FilterQuality.none, | ||||||
| @@ -45,8 +47,8 @@ class ConfigProvider extends ChangeNotifier { | |||||||
|     bool newDrawerIsCollapsed = false; |     bool newDrawerIsCollapsed = false; | ||||||
|     bool newDrawerIsExpanded = false; |     bool newDrawerIsExpanded = false; | ||||||
|     if (withMediaQuery) { |     if (withMediaQuery) { | ||||||
|       newDrawerIsCollapsed = MediaQuery.of(context).size.width < 450; |       newDrawerIsCollapsed = MediaQuery.of(context).size.width < 600; | ||||||
|       newDrawerIsExpanded = MediaQuery.of(context).size.width >= 451; |       newDrawerIsExpanded = MediaQuery.of(context).size.width >= 601; | ||||||
|     } else { |     } else { | ||||||
|       final rpb = ResponsiveBreakpoints.of(context); |       final rpb = ResponsiveBreakpoints.of(context); | ||||||
|       newDrawerIsCollapsed = rpb.smallerOrEqualTo(MOBILE); |       newDrawerIsCollapsed = rpb.smallerOrEqualTo(MOBILE); | ||||||
| @@ -57,7 +59,8 @@ class ConfigProvider extends ChangeNotifier { | |||||||
|           : false; |           : false; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (newDrawerIsExpanded != drawerIsExpanded || newDrawerIsCollapsed != drawerIsCollapsed) { |     if (newDrawerIsExpanded != drawerIsExpanded || | ||||||
|  |         newDrawerIsCollapsed != drawerIsCollapsed) { | ||||||
|       drawerIsExpanded = newDrawerIsExpanded; |       drawerIsExpanded = newDrawerIsExpanded; | ||||||
|       drawerIsCollapsed = newDrawerIsCollapsed; |       drawerIsCollapsed = newDrawerIsCollapsed; | ||||||
|       notifyListeners(); |       notifyListeners(); | ||||||
| @@ -65,22 +68,34 @@ class ConfigProvider extends ChangeNotifier { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   FilterQuality get imageQuality { |   FilterQuality get imageQuality { | ||||||
|     return kImageQualityLevel.values.elementAtOrNull(prefs.getInt('app_image_quality') ?? 3) ?? FilterQuality.high; |     return kImageQualityLevel.values | ||||||
|  |             .elementAtOrNull(prefs.getInt('app_image_quality') ?? 3) ?? | ||||||
|  |         FilterQuality.high; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   String get serverUrl { |   String get serverUrl { | ||||||
|     return prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault; |     return prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   bool get realmCompactView { | ||||||
|  |     return prefs.getBool(kAppRealmCompactView) ?? false; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   set realmCompactView(bool value) { | ||||||
|  |     prefs.setBool(kAppRealmCompactView, value); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   set serverUrl(String url) { |   set serverUrl(String url) { | ||||||
|     prefs.setString(kNetworkServerStoreKey, url); |     prefs.setString(kNetworkServerStoreKey, url); | ||||||
|     _home.saveWidgetData("nex_server_url", url); |     _home.saveWidgetData("nex_server_url", url); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   String? updatableVersion; |   String? updatableVersion; | ||||||
|  |   String? updatableChangelog; | ||||||
|  |  | ||||||
|   void setUpdate(String newVersion) { |   void setUpdate(String newVersion, String newChangelog) { | ||||||
|     updatableVersion = newVersion; |     updatableVersion = newVersion; | ||||||
|  |     updatableChangelog = newChangelog; | ||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										31
									
								
								lib/providers/database.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								lib/providers/database.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | |||||||
|  | import 'dart:io'; | ||||||
|  |  | ||||||
|  | import 'package:flutter/foundation.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:path/path.dart' show join; | ||||||
|  | import 'package:path_provider/path_provider.dart'; | ||||||
|  | import 'package:surface/database/database.dart'; | ||||||
|  |  | ||||||
|  | class DatabaseProvider { | ||||||
|  |   late AppDatabase db; | ||||||
|  |  | ||||||
|  |   DatabaseProvider(BuildContext context) { | ||||||
|  |     db = AppDatabase(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<int> getDatabaseSize() async { | ||||||
|  |     if (kIsWeb) return 0; | ||||||
|  |     final basepath = await getApplicationSupportDirectory(); | ||||||
|  |     return await File(join(basepath.path, 'solar_network_data.sqlite')) | ||||||
|  |         .length(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> removeDatabase() async { | ||||||
|  |     if (kIsWeb) return; | ||||||
|  |     final basepath = await getApplicationSupportDirectory(); | ||||||
|  |     final file = File(join(basepath.path, 'solar_network_data.sqlite')); | ||||||
|  |     db.close(); | ||||||
|  |     await file.delete(); | ||||||
|  |     db = AppDatabase(); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										245
									
								
								lib/providers/keypair.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										245
									
								
								lib/providers/keypair.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,245 @@ | |||||||
|  | import 'dart:async'; | ||||||
|  | import 'dart:convert'; | ||||||
|  |  | ||||||
|  | import 'package:drift/drift.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/database/database.dart'; | ||||||
|  | import 'package:surface/logger.dart'; | ||||||
|  | import 'package:surface/providers/database.dart'; | ||||||
|  | import 'package:surface/providers/userinfo.dart'; | ||||||
|  | import 'package:surface/providers/websocket.dart'; | ||||||
|  | import 'package:surface/types/keypair.dart'; | ||||||
|  | import 'package:fast_rsa/fast_rsa.dart'; | ||||||
|  | import 'package:surface/types/websocket.dart'; | ||||||
|  | import 'package:uuid/uuid.dart'; | ||||||
|  |  | ||||||
|  | // Currently the keypair only provide RSA encryption | ||||||
|  | // Supported by the `fast_rsa` package | ||||||
|  | class KeyPairProvider { | ||||||
|  |   late final DatabaseProvider _dt; | ||||||
|  |   late final UserProvider _ua; | ||||||
|  |   late final WebSocketProvider _ws; | ||||||
|  |  | ||||||
|  |   SnKeyPair? activeKp; | ||||||
|  |  | ||||||
|  |   KeyPairProvider(BuildContext context) { | ||||||
|  |     _dt = context.read<DatabaseProvider>(); | ||||||
|  |     _ua = context.read<UserProvider>(); | ||||||
|  |     _ws = context.read<WebSocketProvider>(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void listen() { | ||||||
|  |     _ws.pk.stream.listen((event) { | ||||||
|  |       switch (event.method) { | ||||||
|  |         case 'kex.ack': | ||||||
|  |           ackKeyExchange(event); | ||||||
|  |           break; | ||||||
|  |         case 'kex.ask': | ||||||
|  |           replyAskKeyExchange(event); | ||||||
|  |           break; | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<String> decryptText(String text, String kpId, {int? kpOwner}) async { | ||||||
|  |     String? publicKey; | ||||||
|  |     final kp = await (_dt.db.snLocalKeyPair.select() | ||||||
|  |           ..where((e) => e.id.equals(kpId))) | ||||||
|  |         .getSingleOrNull(); | ||||||
|  |     if (kp == null) { | ||||||
|  |       if (kpOwner != null) { | ||||||
|  |         final out = await askKeyExchange(kpOwner, kpId); | ||||||
|  |         publicKey = out.publicKey; | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       publicKey = kp.publicKey; | ||||||
|  |     } | ||||||
|  |     if (publicKey == null) { | ||||||
|  |       throw Exception('Key pair not found'); | ||||||
|  |     } | ||||||
|  |     return await RSA.decryptPKCS1v15(text, publicKey); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<String> encryptText(String text) async { | ||||||
|  |     if (activeKp == null) throw Exception('No active key pair'); | ||||||
|  |     return await RSA.encryptPKCS1v15(text, activeKp!.privateKey!); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   final Map<String, Completer<SnKeyPair>> _requests = {}; | ||||||
|  |  | ||||||
|  |   Future<SnKeyPair> askKeyExchange(int kpOwner, String kpId) async { | ||||||
|  |     if (_requests.containsKey(kpId)) return await _requests[kpId]!.future; | ||||||
|  |  | ||||||
|  |     final completer = Completer<SnKeyPair>(); | ||||||
|  |     _requests[kpId] = completer; | ||||||
|  |  | ||||||
|  |     _ws.conn?.sink.add( | ||||||
|  |       jsonEncode(WebSocketPackage( | ||||||
|  |         method: 'kex.ask', | ||||||
|  |         endpoint: 'id', | ||||||
|  |         payload: { | ||||||
|  |           'keypair_id': kpId, | ||||||
|  |           'user_id': kpOwner, | ||||||
|  |         }, | ||||||
|  |       )), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     return Future.any([ | ||||||
|  |       _requests[kpId]!.future, | ||||||
|  |       Future.delayed(const Duration(seconds: 60), () { | ||||||
|  |         _requests.remove(kpId); | ||||||
|  |         throw TimeoutException("Key exchange timed out"); | ||||||
|  |       }), | ||||||
|  |     ]); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> ackKeyExchange(WebSocketPackage pkt) async { | ||||||
|  |     if (pkt.payload == null) return; | ||||||
|  |     final kpMeta = SnKeyPair( | ||||||
|  |       id: pkt.payload!['keypair_id'] as String, | ||||||
|  |       accountId: pkt.payload!['user_id'] as int, | ||||||
|  |       publicKey: pkt.payload!['public_key'] as String, | ||||||
|  |       privateKey: pkt.payload?['private_key'] as String?, | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (_requests.containsKey(kpMeta.id)) { | ||||||
|  |       _requests[kpMeta.id]!.complete(kpMeta); | ||||||
|  |       _requests.remove(kpMeta.id); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Save the keypair to the local database | ||||||
|  |     await _dt.db.snLocalKeyPair.insertOne( | ||||||
|  |       SnLocalKeyPairCompanion.insert( | ||||||
|  |         id: kpMeta.id, | ||||||
|  |         accountId: kpMeta.accountId, | ||||||
|  |         publicKey: kpMeta.publicKey, | ||||||
|  |         privateKey: Value(kpMeta.privateKey), | ||||||
|  |       ), | ||||||
|  |       onConflict: DoNothing(), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> replyAskKeyExchange(WebSocketPackage pkt) async { | ||||||
|  |     final kpId = pkt.payload!['keypair_id'] as String; | ||||||
|  |     final userId = pkt.payload!['user_id'] as int; | ||||||
|  |     final clientId = pkt.payload!['client_id'] as String; | ||||||
|  |  | ||||||
|  |     final localKp = await (_dt.db.snLocalKeyPair.select() | ||||||
|  |           ..where((e) => e.id.equals(kpId)) | ||||||
|  |           ..limit(1)) | ||||||
|  |         .getSingleOrNull(); | ||||||
|  |     if (localKp == null) return; | ||||||
|  |  | ||||||
|  |     logging.info( | ||||||
|  |       '[Kex] Reply to key exchange request of $kpId from user $userId', | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     // We do not give the private key to the client | ||||||
|  |     _ws.conn?.sink.add(jsonEncode( | ||||||
|  |       WebSocketPackage( | ||||||
|  |         method: 'kex.ack', | ||||||
|  |         endpoint: 'id', | ||||||
|  |         payload: { | ||||||
|  |           'keypair_id': localKp.id, | ||||||
|  |           'user_id': localKp.accountId, | ||||||
|  |           'public_key': localKp.publicKey, | ||||||
|  |           'client_id': clientId, | ||||||
|  |         }, | ||||||
|  |       ).toJson(), | ||||||
|  |     )); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<SnKeyPair?> reloadActive({bool autoEnroll = true}) async { | ||||||
|  |     final kp = await (_dt.db.snLocalKeyPair.select() | ||||||
|  |           ..where((e) => e.accountId.equals(_ua.user!.id)) | ||||||
|  |           ..where((e) => e.privateKey.isNotNull()) | ||||||
|  |           ..where((e) => e.isActive.equals(true)) | ||||||
|  |           ..limit(1)) | ||||||
|  |         .getSingleOrNull(); | ||||||
|  |  | ||||||
|  |     if (kp != null) { | ||||||
|  |       activeKp = SnKeyPair( | ||||||
|  |         id: kp.id, | ||||||
|  |         accountId: kp.accountId, | ||||||
|  |         publicKey: kp.publicKey, | ||||||
|  |         privateKey: kp.privateKey, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (kp == null && autoEnroll) { | ||||||
|  |       return await enrollNew(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return activeKp; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<List<SnKeyPair>> listKeyPair() async { | ||||||
|  |     final kps = await (_dt.db.snLocalKeyPair.select()).get(); | ||||||
|  |     return kps | ||||||
|  |         .map((e) => SnKeyPair( | ||||||
|  |               id: e.id, | ||||||
|  |               accountId: e.accountId, | ||||||
|  |               publicKey: e.publicKey, | ||||||
|  |               privateKey: e.privateKey, | ||||||
|  |               isActive: e.isActive, | ||||||
|  |             )) | ||||||
|  |         .toList(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> activeKeyPair(String kpId) async { | ||||||
|  |     final kp = await (_dt.db.snLocalKeyPair.select() | ||||||
|  |           ..where((e) => e.id.equals(kpId)) | ||||||
|  |           ..where((e) => e.privateKey.isNotNull()) | ||||||
|  |           ..limit(1)) | ||||||
|  |         .getSingleOrNull(); | ||||||
|  |     if (kp == null) return; | ||||||
|  |  | ||||||
|  |     await _dt.db.transaction(() async { | ||||||
|  |       await (_dt.db.update(_dt.db.snLocalKeyPair) | ||||||
|  |             ..where((e) => e.isActive.equals(true))) | ||||||
|  |           .write(SnLocalKeyPairCompanion(isActive: Value(false))); | ||||||
|  |  | ||||||
|  |       await (_dt.db.update(_dt.db.snLocalKeyPair) | ||||||
|  |             ..where((e) => e.id.equals(kp.id))) | ||||||
|  |           .write(SnLocalKeyPairCompanion(isActive: Value(true))); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<SnKeyPair> enrollNew() async { | ||||||
|  |     if (!_ua.isAuthorized) throw Exception('Unauthorized'); | ||||||
|  |  | ||||||
|  |     final id = const Uuid().v4(); | ||||||
|  |     final kp = await RSA.generate(2048); | ||||||
|  |     final kpMeta = SnKeyPair( | ||||||
|  |       id: id, | ||||||
|  |       accountId: _ua.user!.id, | ||||||
|  |       // This is work as expected | ||||||
|  |       // We need to share private key to let everyone can decode the message | ||||||
|  |       publicKey: kp.privateKey, | ||||||
|  |       privateKey: kp.publicKey, | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     // Save the keypair to the local database | ||||||
|  |     // If there is already one with private key, it will be overwritten | ||||||
|  |     await _dt.db.transaction(() async { | ||||||
|  |       await (_dt.db.update(_dt.db.snLocalKeyPair) | ||||||
|  |             ..where((e) => e.isActive.equals(true))) | ||||||
|  |           .write(SnLocalKeyPairCompanion(isActive: Value(false))); | ||||||
|  |  | ||||||
|  |       await _dt.db.snLocalKeyPair.insertOne( | ||||||
|  |         SnLocalKeyPairCompanion.insert( | ||||||
|  |           id: kpMeta.id, | ||||||
|  |           accountId: kpMeta.accountId, | ||||||
|  |           publicKey: kpMeta.publicKey, | ||||||
|  |           privateKey: Value(kpMeta.privateKey), | ||||||
|  |           isActive: Value(true), | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     await reloadActive(autoEnroll: false); | ||||||
|  |  | ||||||
|  |     return kpMeta; | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,8 +1,8 @@ | |||||||
| import 'dart:convert'; | import 'dart:convert'; | ||||||
| import 'dart:developer'; |  | ||||||
|  |  | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/logger.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/types/link.dart'; | import 'package:surface/types/link.dart'; | ||||||
|  |  | ||||||
| @@ -20,7 +20,7 @@ class SnLinkPreviewProvider { | |||||||
|     final target = b64.encode(url); |     final target = b64.encode(url); | ||||||
|     if (_cache.containsKey(target)) return _cache[target]; |     if (_cache.containsKey(target)) return _cache[target]; | ||||||
|  |  | ||||||
|     log('[LinkPreview] Fetching $url ($target)'); |     logging.debug('[LinkPreview] Fetching $url ($target)'); | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       final resp = await _sn.client.get('/cgi/re/link/$target'); |       final resp = await _sn.client.get('/cgi/re/link/$target'); | ||||||
| @@ -28,7 +28,7 @@ class SnLinkPreviewProvider { | |||||||
|       _cache[url] = meta; |       _cache[url] = meta; | ||||||
|       return meta; |       return meta; | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       log('[LinkPreview] Failed to fetch $url ($target)...'); |       logging.warning('[LinkPreview] Failed to fetch $url ($target)...', err); | ||||||
|       return null; |       return null; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -63,6 +63,11 @@ class NavigationProvider extends ChangeNotifier { | |||||||
|       screen: 'news', |       screen: 'news', | ||||||
|       label: 'screenNews', |       label: 'screenNews', | ||||||
|     ), |     ), | ||||||
|  |     AppNavDestination( | ||||||
|  |       icon: Icon(Symbols.emoji_emotions, weight: 400, opticalSize: 20), | ||||||
|  |       screen: 'stickers', | ||||||
|  |       label: 'screenStickers', | ||||||
|  |     ), | ||||||
|     AppNavDestination( |     AppNavDestination( | ||||||
|       icon: Icon(Symbols.photo_library, weight: 400, opticalSize: 20), |       icon: Icon(Symbols.photo_library, weight: 400, opticalSize: 20), | ||||||
|       screen: 'album', |       screen: 'album', | ||||||
| @@ -88,7 +93,8 @@ class NavigationProvider extends ChangeNotifier { | |||||||
|  |  | ||||||
|   List<AppNavDestination> destinations = []; |   List<AppNavDestination> destinations = []; | ||||||
|  |  | ||||||
|   int get pinnedDestinationCount => destinations.where((ele) => ele.isPinned).length; |   int get pinnedDestinationCount => | ||||||
|  |       destinations.where((ele) => ele.isPinned).length; | ||||||
|  |  | ||||||
|   NavigationProvider() { |   NavigationProvider() { | ||||||
|     buildDestinations(kDefaultPinnedDestination); |     buildDestinations(kDefaultPinnedDestination); | ||||||
| @@ -117,13 +123,17 @@ class NavigationProvider extends ChangeNotifier { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   bool isIndexInRange(int min, int max) { |   bool isIndexInRange(int min, int max) { | ||||||
|     return _currentIndex != null && _currentIndex! >= min && _currentIndex! < max; |     return _currentIndex != null && | ||||||
|  |         _currentIndex! >= min && | ||||||
|  |         _currentIndex! < max; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void autoDetectIndex(GoRouter? state) { |   void autoDetectIndex(GoRouter? state) { | ||||||
|     if (state == null) return; |     if (state == null) return; | ||||||
|     final idx = destinations.indexWhere( |     final idx = destinations.indexWhere( | ||||||
|       (ele) => ele.screen == state.routerDelegate.currentConfiguration.last.route.name, |       (ele) => | ||||||
|  |           ele.screen == | ||||||
|  |           state.routerDelegate.currentConfiguration.last.route.name, | ||||||
|     ); |     ); | ||||||
|     _currentIndex = idx == -1 ? null : idx; |     _currentIndex = idx == -1 ? null : idx; | ||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|   | |||||||
| @@ -1,12 +1,14 @@ | |||||||
| import 'dart:developer'; |  | ||||||
| import 'dart:io'; | import 'dart:io'; | ||||||
|  |  | ||||||
|  | import 'package:bitsdojo_window/bitsdojo_window.dart'; | ||||||
| import 'package:firebase_messaging/firebase_messaging.dart'; | import 'package:firebase_messaging/firebase_messaging.dart'; | ||||||
| import 'package:flutter/foundation.dart'; | import 'package:flutter/foundation.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter/services.dart'; | import 'package:flutter/services.dart'; | ||||||
| import 'package:flutter_udid/flutter_udid.dart'; | import 'package:flutter_udid/flutter_udid.dart'; | ||||||
|  | import 'package:local_notifier/local_notifier.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/logger.dart'; | ||||||
| import 'package:surface/providers/config.dart'; | import 'package:surface/providers/config.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/providers/userinfo.dart'; | import 'package:surface/providers/userinfo.dart'; | ||||||
| @@ -46,11 +48,13 @@ class NotificationProvider extends ChangeNotifier { | |||||||
|     var deviceUuid = await FlutterUdid.consistentUdid; |     var deviceUuid = await FlutterUdid.consistentUdid; | ||||||
|  |  | ||||||
|     if (deviceUuid.isEmpty) { |     if (deviceUuid.isEmpty) { | ||||||
|       log("Unable to active push notifications, couldn't get device uuid"); |       logging.warning( | ||||||
|  |           '[Push Notification] Unable to active push notifications, couldn\'t get device uuid'); | ||||||
|       return; |       return; | ||||||
|     } else { |     } else { | ||||||
|       log('Device UUID is $deviceUuid'); |       logging.info('[Push Notification] Device UUID is $deviceUuid'); | ||||||
|       log('Registering device push notifications...'); |       logging | ||||||
|  |           .info('[Push Notification] Registering device push notifications...'); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (Platform.isIOS || Platform.isMacOS) { |     if (Platform.isIOS || Platform.isMacOS) { | ||||||
| @@ -60,7 +64,7 @@ class NotificationProvider extends ChangeNotifier { | |||||||
|       provider = 'fcm'; |       provider = 'fcm'; | ||||||
|       token = await FirebaseMessaging.instance.getToken(); |       token = await FirebaseMessaging.instance.getToken(); | ||||||
|     } |     } | ||||||
|     log('Device Push Token is $token'); |     logging.info('[Push Notification] Device Push Token is $token'); | ||||||
|  |  | ||||||
|     await _sn.client.post( |     await _sn.client.post( | ||||||
|       '/cgi/id/notifications/subscription', |       '/cgi/id/notifications/subscription', | ||||||
| @@ -73,33 +77,67 @@ class NotificationProvider extends ChangeNotifier { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   int showingCount = 0; |   int showingCount = 0; | ||||||
|  |   int showingTrayCount = 0; | ||||||
|   List<SnNotification> notifications = List.empty(growable: true); |   List<SnNotification> notifications = List.empty(growable: true); | ||||||
|  |  | ||||||
|  |   int? skippableNotifyChannel; | ||||||
|  |   bool isMuted = false; | ||||||
|  |  | ||||||
|   void listen() { |   void listen() { | ||||||
|     _ws.stream.stream.listen((event) { |     _ws.pk.stream.listen((event) { | ||||||
|       if (event.method == 'notifications.new') { |       if (event.method == 'notifications.new') { | ||||||
|         final notification = SnNotification.fromJson(event.payload!); |         final notification = SnNotification.fromJson(event.payload!); | ||||||
|  |  | ||||||
|  |         final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true; | ||||||
|  |         if (doHaptic) HapticFeedback.mediumImpact(); | ||||||
|  |  | ||||||
|  |         if (notification.topic == 'messaging.message' && | ||||||
|  |             skippableNotifyChannel != null) { | ||||||
|  |           if (notification.metadata['channel_id'] != null && | ||||||
|  |               notification.metadata['channel_id'] == skippableNotifyChannel) { | ||||||
|  |             return; | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |  | ||||||
|         if (showingCount < 0) showingCount = 0; |         if (showingCount < 0) showingCount = 0; | ||||||
|         showingCount++; |         showingCount++; | ||||||
|  |         showingTrayCount++; | ||||||
|         notifications.add(notification); |         notifications.add(notification); | ||||||
|         Future.delayed(const Duration(seconds: 3), () { |         Future.delayed(const Duration(seconds: 5), () { | ||||||
|           if (showingCount >= 0) showingCount--; |           if (showingCount >= 0) showingCount--; | ||||||
|           notifyListeners(); |           notifyListeners(); | ||||||
|         }); |         }); | ||||||
|         notifyListeners(); |         notifyListeners(); | ||||||
|         updateTray(); |         updateTray(); | ||||||
|         final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true; |  | ||||||
|         if (doHaptic) HapticFeedback.mediumImpact(); |         if (!kIsWeb && !isMuted) { | ||||||
|  |           if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { | ||||||
|  |             LocalNotification notify = LocalNotification( | ||||||
|  |               title: notification.title, | ||||||
|  |               subtitle: notification.subtitle, | ||||||
|  |               body: notification.body, | ||||||
|  |             ); | ||||||
|  |             notify.onClick = () { | ||||||
|  |               appWindow.show(); | ||||||
|  |             }; | ||||||
|  |             notify.show(); | ||||||
|  |           } | ||||||
|  |         } | ||||||
|       } |       } | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   void clearTray() { | ||||||
|  |     showingTrayCount = 0; | ||||||
|  |     updateTray(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   void updateTray() { |   void updateTray() { | ||||||
|     if (kIsWeb || Platform.isAndroid || Platform.isIOS) return; |     if (kIsWeb || Platform.isAndroid || Platform.isIOS) return; | ||||||
|     if (notifications.isEmpty) { |     if (showingTrayCount == 0) { | ||||||
|       trayManager.setTitle(''); |       trayManager.setTitle(''); | ||||||
|     } else { |     } else { | ||||||
|       trayManager.setTitle(' ${notifications.length.toString()}'); |       trayManager.setTitle(' $showingTrayCount'); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,71 +2,122 @@ import 'package:flutter/material.dart'; | |||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:surface/providers/sn_attachment.dart'; | import 'package:surface/providers/sn_attachment.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
|  | import 'package:surface/providers/sn_realm.dart'; | ||||||
| import 'package:surface/providers/user_directory.dart'; | import 'package:surface/providers/user_directory.dart'; | ||||||
|  | import 'package:surface/types/poll.dart'; | ||||||
| import 'package:surface/types/post.dart'; | import 'package:surface/types/post.dart'; | ||||||
|  | import 'package:surface/types/realm.dart'; | ||||||
|  |  | ||||||
| class SnPostContentProvider { | class SnPostContentProvider { | ||||||
|   late final SnNetworkProvider _sn; |   late final SnNetworkProvider _sn; | ||||||
|   late final UserDirectoryProvider _ud; |   late final UserDirectoryProvider _ud; | ||||||
|   late final SnAttachmentProvider _attach; |   late final SnAttachmentProvider _attach; | ||||||
|  |   late final SnRealmProvider _realm; | ||||||
|  |  | ||||||
|   SnPostContentProvider(BuildContext context) { |   SnPostContentProvider(BuildContext context) { | ||||||
|     _sn = context.read<SnNetworkProvider>(); |     _sn = context.read<SnNetworkProvider>(); | ||||||
|     _ud = context.read<UserDirectoryProvider>(); |     _ud = context.read<UserDirectoryProvider>(); | ||||||
|     _attach = context.read<SnAttachmentProvider>(); |     _attach = context.read<SnAttachmentProvider>(); | ||||||
|  |     _realm = context.read<SnRealmProvider>(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<SnPoll> _fetchPoll(int id) async { | ||||||
|  |     final resp = await _sn.client.get('/cgi/co/polls/$id'); | ||||||
|  |     return SnPoll.fromJson(resp.data); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<List<SnPost>> _preloadRelatedDataInBatch(List<SnPost> out) async { |   Future<List<SnPost>> _preloadRelatedDataInBatch(List<SnPost> out) async { | ||||||
|     Set<String> rids = {}; |     Set<String> rids = {}; | ||||||
|  |     Set<int> uids = {}; | ||||||
|     for (var i = 0; i < out.length; i++) { |     for (var i = 0; i < out.length; i++) { | ||||||
|       rids.addAll(out[i].body['attachments']?.cast<String>() ?? []); |       rids.addAll(out[i].body['attachments']?.cast<String>() ?? []); | ||||||
|       if (out[i].body['thumbnail'] != null) { |       if (out[i].body['thumbnail'] != null) { | ||||||
|         rids.add(out[i].body['thumbnail']); |         rids.add(out[i].body['thumbnail']); | ||||||
|       } |       } | ||||||
|  |       if (out[i].body['video'] != null) { | ||||||
|  |         rids.add(out[i].body['video']); | ||||||
|  |       } | ||||||
|       if (out[i].repostTo != null) { |       if (out[i].repostTo != null) { | ||||||
|         out[i] = out[i].copyWith( |         out[i] = out[i].copyWith( | ||||||
|           repostTo: await _preloadRelatedDataSingle(out[i].repostTo!), |           repostTo: await _preloadRelatedDataSingle(out[i].repostTo!), | ||||||
|         ); |         ); | ||||||
|       } |       } | ||||||
|  |       if (out[i].publisher.type == 0) { | ||||||
|  |         uids.add(out[i].publisher.accountId); | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     final attachments = await _attach.getMultiple(rids.toList()); |     final attachments = await _attach.getMultiple(rids.toList()); | ||||||
|     for (var i = 0; i < out.length; i++) { |     for (var i = 0; i < out.length; i++) { | ||||||
|  |       SnPoll? poll; | ||||||
|  |       SnRealm? realm; | ||||||
|  |       if (out[i].pollId != null) { | ||||||
|  |         poll = await _fetchPoll(out[i].pollId!); | ||||||
|  |       } | ||||||
|  |       if (out[i].realmId != null) { | ||||||
|  |         realm = await _realm.getRealm(out[i].realmId!); | ||||||
|  |       } | ||||||
|  |  | ||||||
|       out[i] = out[i].copyWith( |       out[i] = out[i].copyWith( | ||||||
|         preload: SnPostPreload( |         preload: SnPostPreload( | ||||||
|           thumbnail: attachments.where((ele) => ele?.rid == out[i].body['thumbnail']).firstOrNull, |           thumbnail: attachments.where((ele) => ele?.rid == out[i].body['thumbnail']).firstOrNull, | ||||||
|           attachments: attachments.where((ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false).toList(), |           attachments: attachments.where((ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false).toList(), | ||||||
|  |           video: attachments.where((ele) => ele?.rid == out[i].body['video']).firstOrNull, | ||||||
|  |           poll: poll, | ||||||
|  |           realm: realm, | ||||||
|         ), |         ), | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     await _ud.listAccount( |     uids.addAll(attachments.where((ele) => ele != null).map((ele) => ele!.accountId)); | ||||||
|       attachments.where((ele) => ele != null).map((ele) => ele!.accountId).toSet(), |     await _ud.listAccount(uids); | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     return out; |     return out; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<SnPost> _preloadRelatedDataSingle(SnPost out) async { |   Future<SnPost> _preloadRelatedDataSingle(SnPost out) async { | ||||||
|     Set<String> rids = {}; |     Set<String> rids = {}; | ||||||
|  |     Set<int> uids = {}; | ||||||
|     rids.addAll(out.body['attachments']?.cast<String>() ?? []); |     rids.addAll(out.body['attachments']?.cast<String>() ?? []); | ||||||
|     if (out.body['thumbnail'] != null) { |     if (out.body['thumbnail'] != null) { | ||||||
|       rids.add(out.body['thumbnail']); |       rids.add(out.body['thumbnail']); | ||||||
|     } |     } | ||||||
|  |     if (out.body['video'] != null) { | ||||||
|  |       rids.add(out.body['video']); | ||||||
|  |     } | ||||||
|     if (out.repostTo != null) { |     if (out.repostTo != null) { | ||||||
|       out = out.copyWith( |       out = out.copyWith( | ||||||
|         repostTo: await _preloadRelatedDataSingle(out.repostTo!), |         repostTo: await _preloadRelatedDataSingle(out.repostTo!), | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |     if (out.publisher.type == 0) { | ||||||
|  |       uids.add(out.publisher.accountId); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     final attachments = await _attach.getMultiple(rids.toList()); |     final attachments = await _attach.getMultiple(rids.toList()); | ||||||
|  |  | ||||||
|  |     SnPoll? poll; | ||||||
|  |     SnRealm? realm; | ||||||
|  |     if (out.pollId != null) { | ||||||
|  |       poll = await _fetchPoll(out.pollId!); | ||||||
|  |     } | ||||||
|  |     if (out.realmId != null) { | ||||||
|  |       realm = await _realm.getRealm(out.realmId!); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     out = out.copyWith( |     out = out.copyWith( | ||||||
|       preload: SnPostPreload( |       preload: SnPostPreload( | ||||||
|         thumbnail: attachments.where((ele) => ele?.rid == out.body['thumbnail']).firstOrNull, |         thumbnail: attachments.where((ele) => ele?.rid == out.body['thumbnail']).firstOrNull, | ||||||
|         attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(), |         attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(), | ||||||
|  |         video: attachments.where((ele) => ele?.rid == out.body['video']).firstOrNull, | ||||||
|  |         poll: poll, | ||||||
|  |         realm: realm, | ||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|  |     uids.addAll(attachments.where((ele) => ele != null).map((ele) => ele!.accountId)); | ||||||
|  |     await _ud.listAccount(uids); | ||||||
|  |  | ||||||
|     return out; |     return out; | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -85,6 +136,8 @@ class SnPostContentProvider { | |||||||
|     String? author, |     String? author, | ||||||
|     Iterable<String>? categories, |     Iterable<String>? categories, | ||||||
|     Iterable<String>? tags, |     Iterable<String>? tags, | ||||||
|  |     String? realm, | ||||||
|  |     String? channel, | ||||||
|   }) async { |   }) async { | ||||||
|     final resp = await _sn.client.get('/cgi/co/posts', queryParameters: { |     final resp = await _sn.client.get('/cgi/co/posts', queryParameters: { | ||||||
|       'take': take, |       'take': take, | ||||||
| @@ -93,6 +146,8 @@ class SnPostContentProvider { | |||||||
|       if (author != null) 'author': author, |       if (author != null) 'author': author, | ||||||
|       if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','), |       if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','), | ||||||
|       if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','), |       if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','), | ||||||
|  |       if (realm != null) 'realm': realm, | ||||||
|  |       if (channel != null) 'channel': channel, | ||||||
|     }); |     }); | ||||||
|     final List<SnPost> out = await _preloadRelatedDataInBatch( |     final List<SnPost> out = await _preloadRelatedDataInBatch( | ||||||
|       List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []), |       List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []), | ||||||
|   | |||||||
| @@ -1,11 +1,14 @@ | |||||||
| import 'dart:collection'; | import 'dart:collection'; | ||||||
|  | import 'dart:convert'; | ||||||
| import 'dart:math' as math; | import 'dart:math' as math; | ||||||
| import 'dart:typed_data'; |  | ||||||
|  |  | ||||||
| import 'package:dio/dio.dart'; | import 'package:dio/dio.dart'; | ||||||
|  | import 'package:drift/drift.dart'; | ||||||
| import 'package:flutter/widgets.dart'; | import 'package:flutter/widgets.dart'; | ||||||
| import 'package:cross_file/cross_file.dart'; | import 'package:cross_file/cross_file.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/database/database.dart'; | ||||||
|  | import 'package:surface/providers/database.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/types/attachment.dart'; | import 'package:surface/types/attachment.dart'; | ||||||
|  |  | ||||||
| @@ -13,10 +16,12 @@ const kConcurrentUploadChunks = 5; | |||||||
|  |  | ||||||
| class SnAttachmentProvider { | class SnAttachmentProvider { | ||||||
|   late final SnNetworkProvider _sn; |   late final SnNetworkProvider _sn; | ||||||
|  |   late final DatabaseProvider _dt; | ||||||
|   final Map<String, SnAttachment> _cache = {}; |   final Map<String, SnAttachment> _cache = {}; | ||||||
|  |  | ||||||
|   SnAttachmentProvider(BuildContext context) { |   SnAttachmentProvider(BuildContext context) { | ||||||
|     _sn = context.read<SnNetworkProvider>(); |     _sn = context.read<SnNetworkProvider>(); | ||||||
|  |     _dt = context.read<DatabaseProvider>(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) { |   void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) { | ||||||
| @@ -28,20 +33,33 @@ class SnAttachmentProvider { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<SnAttachment> getOne(String rid, {noCache = false}) async { |   Future<SnAttachment> getOne(String rid, {noCache = false}) async { | ||||||
|  |     // In-memory cache | ||||||
|     if (!noCache && _cache.containsKey(rid)) { |     if (!noCache && _cache.containsKey(rid)) { | ||||||
|       return _cache[rid]!; |       return _cache[rid]!; | ||||||
|     } |     } | ||||||
|  |     // On-disk cache | ||||||
|  |     final dbResp = await (_dt.db.snLocalAttachment.select() | ||||||
|  |           ..where((e) => e.rid.equals(rid)) | ||||||
|  |           ..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now()))) | ||||||
|  |         .getSingleOrNull(); | ||||||
|  |     if (dbResp != null) { | ||||||
|  |       _cache[rid] = dbResp.content; | ||||||
|  |       return dbResp.content; | ||||||
|  |     } | ||||||
|  |     // Remote server | ||||||
|     final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta'); |     final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta'); | ||||||
|     final out = SnAttachment.fromJson(resp.data); |     final out = SnAttachment.fromJson(resp.data); | ||||||
|     if (out.isAnalyzed) { |     if (out.isAnalyzed) { | ||||||
|       _cache[rid] = out; |       _cache[rid] = out; | ||||||
|     } |     } | ||||||
|  |     _saveToLocal([out]); | ||||||
|  |  | ||||||
|     return out; |     return out; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<List<SnAttachment?>> getMultiple(List<String> rids, {noCache = false}) async { |   Future<List<SnAttachment?>> getMultiple(List<String> rids, | ||||||
|  |       {bool noCache = false}) async { | ||||||
|  |     // In-memory cache | ||||||
|     final result = List<SnAttachment?>.filled(rids.length, null); |     final result = List<SnAttachment?>.filled(rids.length, null); | ||||||
|     final Map<String, int> randomMapping = {}; |     final Map<String, int> randomMapping = {}; | ||||||
|     for (int i = 0; i < rids.length; i++) { |     for (int i = 0; i < rids.length; i++) { | ||||||
| @@ -52,9 +70,25 @@ class SnAttachmentProvider { | |||||||
|         result[i] = _cache[rid]!; |         result[i] = _cache[rid]!; | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     final pendingFetch = randomMapping.keys; |     var pendingFetch = randomMapping.keys; | ||||||
|  |     // On-disk cache | ||||||
|     if (pendingFetch.isNotEmpty) { |     if (pendingFetch.isEmpty) return result; | ||||||
|  |     if (!noCache) { | ||||||
|  |       final dbResp = await (_dt.db.snLocalAttachment.select() | ||||||
|  |             ..where((e) => e.rid.isIn(pendingFetch)) | ||||||
|  |             ..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now()))) | ||||||
|  |           .get(); | ||||||
|  |       for (final item in dbResp) { | ||||||
|  |         if (item.content.isAnalyzed) { | ||||||
|  |           _cache[item.rid] = item.content; | ||||||
|  |         } | ||||||
|  |         result[randomMapping[item.rid]!] = item.content; | ||||||
|  |         randomMapping.remove(item.rid); | ||||||
|  |       } | ||||||
|  |       pendingFetch = randomMapping.keys; | ||||||
|  |     } | ||||||
|  |     // Remote server | ||||||
|  |     if (pendingFetch.isEmpty) return result; | ||||||
|     final resp = await _sn.client.get( |     final resp = await _sn.client.get( | ||||||
|       '/cgi/uc/attachments', |       '/cgi/uc/attachments', | ||||||
|       queryParameters: { |       queryParameters: { | ||||||
| @@ -62,9 +96,10 @@ class SnAttachmentProvider { | |||||||
|         'id': pendingFetch.join(','), |         'id': pendingFetch.join(','), | ||||||
|       }, |       }, | ||||||
|     ); |     ); | ||||||
|       final List<SnAttachment?> out = |     final List<SnAttachment?> out = resp.data['data'] | ||||||
|           resp.data['data'].map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e)).cast<SnAttachment?>().toList(); |         .map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e)) | ||||||
|  |         .cast<SnAttachment?>() | ||||||
|  |         .toList(); | ||||||
|     for (final item in out) { |     for (final item in out) { | ||||||
|       if (item == null) continue; |       if (item == null) continue; | ||||||
|       if (item.isAnalyzed) { |       if (item.isAnalyzed) { | ||||||
| @@ -72,12 +107,18 @@ class SnAttachmentProvider { | |||||||
|       } |       } | ||||||
|       result[randomMapping[item.rid]!] = item; |       result[randomMapping[item.rid]!] = item; | ||||||
|     } |     } | ||||||
|     } |     _saveToLocal(out.where((ele) => ele != null).cast()); | ||||||
|  |  | ||||||
|     return result; |     return result; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   static Map<String, String> mimetypeOverrides = {'mov': 'video/quicktime', 'mp4': 'video/mp4'}; |   static Map<String, String> mimetypeOverrides = { | ||||||
|  |     'mov': 'video/quicktime', | ||||||
|  |     'mp4': 'video/mp4', | ||||||
|  |     'm4a': 'audio/mp4', | ||||||
|  |     'apng': 'image/apng', | ||||||
|  |     'webp': 'image/webp', | ||||||
|  |   }; | ||||||
|  |  | ||||||
|   Future<SnAttachment> directUploadOne( |   Future<SnAttachment> directUploadOne( | ||||||
|     Uint8List data, |     Uint8List data, | ||||||
| @@ -89,8 +130,11 @@ class SnAttachmentProvider { | |||||||
|     bool analyzeNow = false, |     bool analyzeNow = false, | ||||||
|   }) async { |   }) async { | ||||||
|     final filePayload = MultipartFile.fromBytes(data, filename: filename); |     final filePayload = MultipartFile.fromBytes(data, filename: filename); | ||||||
|     final fileAlt = filename.contains('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename; |     final fileAlt = filename.contains('.') | ||||||
|     final fileExt = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); |         ? filename.substring(0, filename.lastIndexOf('.')) | ||||||
|  |         : filename; | ||||||
|  |     final fileExt = | ||||||
|  |         filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); | ||||||
|  |  | ||||||
|     String? mimetypeOverride; |     String? mimetypeOverride; | ||||||
|     if (mimetype != null) { |     if (mimetype != null) { | ||||||
| @@ -127,8 +171,11 @@ class SnAttachmentProvider { | |||||||
|     Map<String, dynamic>? metadata, { |     Map<String, dynamic>? metadata, { | ||||||
|     String? mimetype, |     String? mimetype, | ||||||
|   }) async { |   }) async { | ||||||
|     final fileAlt = filename.contains('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename; |     final fileAlt = filename.contains('.') | ||||||
|     final fileExt = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); |         ? filename.substring(0, filename.lastIndexOf('.')) | ||||||
|  |         : filename; | ||||||
|  |     final fileExt = | ||||||
|  |         filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); | ||||||
|  |  | ||||||
|     String? mimetypeOverride; |     String? mimetypeOverride; | ||||||
|     if (mimetype == null && mimetypeOverrides.keys.contains(fileExt)) { |     if (mimetype == null && mimetypeOverrides.keys.contains(fileExt)) { | ||||||
| @@ -146,7 +193,10 @@ class SnAttachmentProvider { | |||||||
|       if (mimetypeOverride != null) 'mimetype': mimetypeOverride, |       if (mimetypeOverride != null) 'mimetype': mimetypeOverride, | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     return (SnAttachmentFragment.fromJson(resp.data['meta']), resp.data['chunk_size'] as int); |     return ( | ||||||
|  |       SnAttachmentFragment.fromJson(resp.data['meta']), | ||||||
|  |       resp.data['chunk_size'] as int | ||||||
|  |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<dynamic> _chunkedUploadOnePart( |   Future<dynamic> _chunkedUploadOnePart( | ||||||
| @@ -197,7 +247,10 @@ class SnAttachmentProvider { | |||||||
|           (entry.value + 1) * chunkSize, |           (entry.value + 1) * chunkSize, | ||||||
|           await file.length(), |           await file.length(), | ||||||
|         ); |         ); | ||||||
|         final data = Uint8List.fromList(await file.openRead(beginCursor, endCursor).expand((chunk) => chunk).toList()); |         final data = Uint8List.fromList(await file | ||||||
|  |             .openRead(beginCursor, endCursor) | ||||||
|  |             .expand((chunk) => chunk) | ||||||
|  |             .toList()); | ||||||
|  |  | ||||||
|         final result = await _chunkedUploadOnePart( |         final result = await _chunkedUploadOnePart( | ||||||
|           data, |           data, | ||||||
| @@ -253,6 +306,31 @@ class SnAttachmentProvider { | |||||||
|       'metadata': metadata ?? item.usermeta, |       'metadata': metadata ?? item.usermeta, | ||||||
|       'is_indexable': isIndexable ?? item.isIndexable, |       'is_indexable': isIndexable ?? item.isIndexable, | ||||||
|     }); |     }); | ||||||
|     return SnAttachment.fromJson(resp.data); |     final out = SnAttachment.fromJson(resp.data); | ||||||
|  |     _saveToLocal([out]); | ||||||
|  |     return out; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> _saveToLocal(Iterable<SnAttachment> out) async { | ||||||
|  |     for (final ele in out) { | ||||||
|  |       if (!ele.isAnalyzed || ele.destination == 0) continue; | ||||||
|  |       await _dt.db.snLocalAttachment.insertOne( | ||||||
|  |         SnLocalAttachmentCompanion.insert( | ||||||
|  |           id: Value(ele.id), | ||||||
|  |           rid: ele.rid, | ||||||
|  |           uuid: ele.uuid, | ||||||
|  |           content: ele, | ||||||
|  |           accountId: ele.accountId, | ||||||
|  |           cacheExpiredAt: DateTime.now().add(const Duration(days: 7)), | ||||||
|  |         ), | ||||||
|  |         onConflict: DoUpdate( | ||||||
|  |           (_) => SnLocalAttachmentCompanion.custom( | ||||||
|  |             content: Constant(jsonEncode(ele.toJson())), | ||||||
|  |             cacheExpiredAt: | ||||||
|  |                 Constant(DateTime.now().add(const Duration(days: 7))), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,6 +1,5 @@ | |||||||
| import 'dart:async'; | import 'dart:async'; | ||||||
| import 'dart:convert'; | import 'dart:convert'; | ||||||
| import 'dart:developer'; |  | ||||||
| import 'dart:io'; | import 'dart:io'; | ||||||
|  |  | ||||||
| import 'package:dio/dio.dart'; | import 'package:dio/dio.dart'; | ||||||
| @@ -11,9 +10,12 @@ import 'package:package_info_plus/package_info_plus.dart'; | |||||||
| import 'package:device_info_plus/device_info_plus.dart'; | import 'package:device_info_plus/device_info_plus.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:shared_preferences/shared_preferences.dart'; | import 'package:shared_preferences/shared_preferences.dart'; | ||||||
|  | import 'package:surface/logger.dart'; | ||||||
| import 'package:surface/providers/config.dart'; | import 'package:surface/providers/config.dart'; | ||||||
| import 'package:surface/providers/widget.dart'; | import 'package:surface/providers/widget.dart'; | ||||||
| import 'package:synchronized/synchronized.dart'; | import 'package:synchronized/synchronized.dart'; | ||||||
|  | import 'package:talker_dio_logger/talker_dio_logger_interceptor.dart'; | ||||||
|  | import 'package:talker_dio_logger/talker_dio_logger_settings.dart'; | ||||||
|  |  | ||||||
| const kNetworkServerDirectory = [ | const kNetworkServerDirectory = [ | ||||||
|   ('Solar Network', 'https://api.sn.solsynth.dev'), |   ('Solar Network', 'https://api.sn.solsynth.dev'), | ||||||
| @@ -36,6 +38,19 @@ class SnNetworkProvider { | |||||||
|  |  | ||||||
|     client = Dio(); |     client = Dio(); | ||||||
|  |  | ||||||
|  |     client.interceptors.add( | ||||||
|  |       TalkerDioLogger( | ||||||
|  |         talker: logging, | ||||||
|  |         settings: const TalkerDioLoggerSettings( | ||||||
|  |           printRequestHeaders: false, | ||||||
|  |           printResponseHeaders: false, | ||||||
|  |           printResponseMessage: false, | ||||||
|  |           printResponseData: false, | ||||||
|  |           printRequestData: false, | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     client.interceptors.add(RetryInterceptor( |     client.interceptors.add(RetryInterceptor( | ||||||
|       dio: client, |       dio: client, | ||||||
|       retries: 3, |       retries: 3, | ||||||
| @@ -69,7 +84,6 @@ class SnNetworkProvider { | |||||||
|       _prefs = _config.prefs; |       _prefs = _config.prefs; | ||||||
|       client.options.baseUrl = _config.serverUrl; |       client.options.baseUrl = _config.serverUrl; | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   static Future<Dio> createOffContextClient() async { |   static Future<Dio> createOffContextClient() async { | ||||||
| @@ -91,7 +105,8 @@ class SnNetworkProvider { | |||||||
|           RequestOptions options, |           RequestOptions options, | ||||||
|           RequestInterceptorHandler handler, |           RequestInterceptorHandler handler, | ||||||
|         ) async { |         ) async { | ||||||
|           final atk = await _getFreshAtk(client, prefs.getString(kAtkStoreKey), prefs.getString(kRtkStoreKey), (atk, rtk) { |           final atk = await _getFreshAtk(client, prefs.getString(kAtkStoreKey), | ||||||
|  |               prefs.getString(kRtkStoreKey), (atk, rtk) { | ||||||
|             prefs.setString(kAtkStoreKey, atk); |             prefs.setString(kAtkStoreKey, atk); | ||||||
|             prefs.setString(kRtkStoreKey, rtk); |             prefs.setString(kRtkStoreKey, rtk); | ||||||
|           }); |           }); | ||||||
| @@ -103,7 +118,8 @@ class SnNetworkProvider { | |||||||
|         }, |         }, | ||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
|     client.options.baseUrl = prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault; |     client.options.baseUrl = | ||||||
|  |         prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault; | ||||||
|  |  | ||||||
|     return client; |     return client; | ||||||
|   } |   } | ||||||
| @@ -119,7 +135,8 @@ class SnNetworkProvider { | |||||||
|       platformInfo = 'Web; ${deviceInfo.vendor}'; |       platformInfo = 'Web; ${deviceInfo.vendor}'; | ||||||
|     } else if (Platform.isAndroid) { |     } else if (Platform.isAndroid) { | ||||||
|       final deviceInfo = await DeviceInfoPlugin().androidInfo; |       final deviceInfo = await DeviceInfoPlugin().androidInfo; | ||||||
|       platformInfo = 'Android; ${deviceInfo.brand} ${deviceInfo.model}; ${deviceInfo.id}'; |       platformInfo = | ||||||
|  |           'Android; ${deviceInfo.brand} ${deviceInfo.model}; ${deviceInfo.id}'; | ||||||
|     } else if (Platform.isIOS) { |     } else if (Platform.isIOS) { | ||||||
|       final deviceInfo = await DeviceInfoPlugin().iosInfo; |       final deviceInfo = await DeviceInfoPlugin().iosInfo; | ||||||
|       platformInfo = 'iOS; ${deviceInfo.model}; ${deviceInfo.name}'; |       platformInfo = 'iOS; ${deviceInfo.model}; ${deviceInfo.name}'; | ||||||
| @@ -128,7 +145,8 @@ class SnNetworkProvider { | |||||||
|       platformInfo = 'MacOS; ${deviceInfo.model}; ${deviceInfo.hostName}'; |       platformInfo = 'MacOS; ${deviceInfo.model}; ${deviceInfo.hostName}'; | ||||||
|     } else if (Platform.isWindows) { |     } else if (Platform.isWindows) { | ||||||
|       final deviceInfo = await DeviceInfoPlugin().windowsInfo; |       final deviceInfo = await DeviceInfoPlugin().windowsInfo; | ||||||
|       platformInfo = 'Windows NT; ${deviceInfo.productName}; ${deviceInfo.computerName}'; |       platformInfo = | ||||||
|  |           'Windows NT; ${deviceInfo.productName}; ${deviceInfo.computerName}'; | ||||||
|     } else if (Platform.isLinux) { |     } else if (Platform.isLinux) { | ||||||
|       final deviceInfo = await DeviceInfoPlugin().linuxInfo; |       final deviceInfo = await DeviceInfoPlugin().linuxInfo; | ||||||
|       platformInfo = 'Linux; ${deviceInfo.prettyName}'; |       platformInfo = 'Linux; ${deviceInfo.prettyName}'; | ||||||
| @@ -148,12 +166,15 @@ class SnNetworkProvider { | |||||||
|   final tkLock = Lock(); |   final tkLock = Lock(); | ||||||
|  |  | ||||||
|   Future<String?> getFreshAtk() async { |   Future<String?> getFreshAtk() async { | ||||||
|     return await _getFreshAtk(client, _prefs.getString(kAtkStoreKey), _prefs.getString(kRtkStoreKey), (atk, rtk) { |     return await _getFreshAtk( | ||||||
|  |         client, _prefs.getString(kAtkStoreKey), _prefs.getString(kRtkStoreKey), | ||||||
|  |         (atk, rtk) { | ||||||
|       setTokenPair(atk, rtk); |       setTokenPair(atk, rtk); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   static Future<String?> _getFreshAtk(Dio client, String? atk, String? rtk, Function(String atk, String rtk)? onRefresh) async { |   static Future<String?> _getFreshAtk(Dio client, String? atk, String? rtk, | ||||||
|  |       Function(String atk, String rtk)? onRefresh) async { | ||||||
|     if (_refreshCompleter != null) { |     if (_refreshCompleter != null) { | ||||||
|       return await _refreshCompleter!.future; |       return await _refreshCompleter!.future; | ||||||
|     } else { |     } else { | ||||||
| @@ -185,7 +206,8 @@ class SnNetworkProvider { | |||||||
|         final payload = b64.decode(rawPayload); |         final payload = b64.decode(rawPayload); | ||||||
|         final exp = jsonDecode(payload)['exp']; |         final exp = jsonDecode(payload)['exp']; | ||||||
|         if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) { |         if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) { | ||||||
|           log('Access token need refresh, doing it at ${DateTime.now()}'); |           logging.debug( | ||||||
|  |               '[Auth] Access token need refresh, doing it at ${DateTime.now()}'); | ||||||
|           final result = await _refreshToken(client.options.baseUrl, rtk); |           final result = await _refreshToken(client.options.baseUrl, rtk); | ||||||
|           if (result == null) { |           if (result == null) { | ||||||
|             atk = null; |             atk = null; | ||||||
| @@ -199,12 +221,12 @@ class SnNetworkProvider { | |||||||
|           _refreshCompleter!.complete(atk); |           _refreshCompleter!.complete(atk); | ||||||
|           return atk; |           return atk; | ||||||
|         } else { |         } else { | ||||||
|           log('Access token refresh failed...'); |           logging.error('[Auth] Access token refresh failed...'); | ||||||
|           _refreshCompleter!.complete(null); |           _refreshCompleter!.complete(null); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       log('Failed to authenticate user: $err'); |       logging.error('[Auth] Failed to authenticate user...', err); | ||||||
|       _refreshCompleter!.completeError(err); |       _refreshCompleter!.completeError(err); | ||||||
|     } finally { |     } finally { | ||||||
|       _refreshCompleter = null; |       _refreshCompleter = null; | ||||||
| @@ -237,7 +259,8 @@ class SnNetworkProvider { | |||||||
|     return result.$1; |     return result.$1; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   static Future<(String, String)?> _refreshToken(String baseUrl, String? rtk) async { |   static Future<(String, String)?> _refreshToken( | ||||||
|  |       String baseUrl, String? rtk) async { | ||||||
|     if (rtk == null) return null; |     if (rtk == null) return null; | ||||||
|  |  | ||||||
|     final dio = Dio(); |     final dio = Dio(); | ||||||
|   | |||||||
							
								
								
									
										37
									
								
								lib/providers/sn_realm.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								lib/providers/sn_realm.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/providers/sn_network.dart'; | ||||||
|  | import 'package:surface/types/realm.dart'; | ||||||
|  |  | ||||||
|  | class SnRealmProvider { | ||||||
|  |   late final SnNetworkProvider _sn; | ||||||
|  |  | ||||||
|  |   SnRealmProvider(BuildContext context) { | ||||||
|  |     _sn = context.read<SnNetworkProvider>(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   final Map<String, SnRealm> _cache = {}; | ||||||
|  |  | ||||||
|  |   Future<List<SnRealm>> listAvailableRealms() async { | ||||||
|  |     final resp = await _sn.client.get('/cgi/id/realms/me/available'); | ||||||
|  |     final out = List<SnRealm>.from( | ||||||
|  |       resp.data?.map((e) => SnRealm.fromJson(e)) ?? [], | ||||||
|  |     ); | ||||||
|  |     for (final realm in out) { | ||||||
|  |       _cache[realm.alias] = realm; | ||||||
|  |       _cache[realm.id.toString()] = realm; | ||||||
|  |     } | ||||||
|  |     return out; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<SnRealm> getRealm(dynamic aliasOrId) async { | ||||||
|  |     if (_cache.containsKey(aliasOrId.toString())) { | ||||||
|  |       return _cache[aliasOrId.toString()]!; | ||||||
|  |     } | ||||||
|  |     final resp = await _sn.client.get('/cgi/id/realms/$aliasOrId'); | ||||||
|  |     final out = SnRealm.fromJson(resp.data); | ||||||
|  |     _cache[out.alias] = out; | ||||||
|  |     _cache[out.id.toString()] = out; | ||||||
|  |     return out; | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,20 +1,27 @@ | |||||||
| import 'dart:developer'; | import 'dart:convert'; | ||||||
|  |  | ||||||
|  | import 'package:drift/drift.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/database/database.dart'; | ||||||
|  | import 'package:surface/logger.dart'; | ||||||
|  | import 'package:surface/providers/database.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/types/attachment.dart'; | import 'package:surface/types/attachment.dart'; | ||||||
|  |  | ||||||
| class SnStickerProvider { | class SnStickerProvider { | ||||||
|   late final SnNetworkProvider _sn; |   late final SnNetworkProvider _sn; | ||||||
|  |   late final DatabaseProvider _dt; | ||||||
|   final Map<String, SnSticker?> _cache = {}; |   final Map<String, SnSticker?> _cache = {}; | ||||||
|  |  | ||||||
|   final Map<int, List<SnSticker>> stickersByPack = {}; |   final Map<int, List<SnSticker>> stickersByPack = {}; | ||||||
|  |  | ||||||
|   List<SnSticker> get stickers => _cache.values.where((ele) => ele != null).cast<SnSticker>().toList(); |   List<SnSticker> get stickers => | ||||||
|  |       _cache.values.where((ele) => ele != null).cast<SnSticker>().toList(); | ||||||
|  |  | ||||||
|   SnStickerProvider(BuildContext context) { |   SnStickerProvider(BuildContext context) { | ||||||
|     _sn = context.read<SnNetworkProvider>(); |     _sn = context.read<SnNetworkProvider>(); | ||||||
|  |     _dt = context.read<DatabaseProvider>(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   bool hasNotSticker(String alias) { |   bool hasNotSticker(String alias) { | ||||||
| @@ -23,52 +30,103 @@ class SnStickerProvider { | |||||||
|  |  | ||||||
|   void _cacheSticker(SnSticker sticker) { |   void _cacheSticker(SnSticker sticker) { | ||||||
|     _cache['${sticker.pack.prefix}:${sticker.alias}'] = sticker; |     _cache['${sticker.pack.prefix}:${sticker.alias}'] = sticker; | ||||||
|     if (stickersByPack[sticker.pack.id] == null) stickersByPack[sticker.pack.id] = List.empty(growable: true); |     if (stickersByPack[sticker.pack.id] == null) { | ||||||
|     if (!stickersByPack[sticker.pack.id]!.contains(sticker)) stickersByPack[sticker.pack.id]!.add(sticker); |       stickersByPack[sticker.pack.id] = List.empty(growable: true); | ||||||
|  |     } | ||||||
|  |     if (!stickersByPack[sticker.pack.id]!.contains(sticker)) { | ||||||
|  |       stickersByPack[sticker.pack.id]!.add(sticker); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void putSticker(Iterable<SnSticker> stickers) { | ||||||
|  |     for (final ele in stickers) { | ||||||
|  |       _cacheSticker(ele); | ||||||
|  |     } | ||||||
|  |     _saveStickerToLocal(stickers); | ||||||
|  |     _saveStickerPackToLocal(stickers.map((ele) => ele.pack).toSet()); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<SnSticker?> lookupSticker(String alias) async { |   Future<SnSticker?> lookupSticker(String alias) async { | ||||||
|  |     // In-memory cache | ||||||
|     if (_cache.containsKey(alias)) { |     if (_cache.containsKey(alias)) { | ||||||
|       return _cache[alias]; |       return _cache[alias]; | ||||||
|     } |     } | ||||||
|  |     // On-disk cache | ||||||
|  |     final localStickers = await (_dt.db.snLocalSticker.select() | ||||||
|  |           ..where((e) => e.fullAlias.equals(alias))) | ||||||
|  |         .getSingleOrNull(); | ||||||
|  |     if (localStickers != null) { | ||||||
|  |       _cache[alias] = localStickers.content; | ||||||
|  |       return localStickers.content; | ||||||
|  |     } | ||||||
|  |     // Remote server | ||||||
|     try { |     try { | ||||||
|       final resp = await _sn.client.get('/cgi/uc/stickers/lookup/$alias'); |       final resp = await _sn.client.get('/cgi/uc/stickers/lookup/$alias'); | ||||||
|       final sticker = SnSticker.fromJson(resp.data); |       final sticker = SnSticker.fromJson(resp.data); | ||||||
|       _cacheSticker(sticker); |       putSticker([sticker]); | ||||||
|  |  | ||||||
|       return sticker; |       return sticker; | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       _cache[alias] = null; |       _cache[alias] = null; | ||||||
|       log('[Sticker] Failed to lookup sticker $alias: $err'); |       logging.warning('[Sticker] Failed to lookup sticker $alias', err); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return null; |     return null; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> listStickerEagerly() async { |   Future<void> listSticker() async { | ||||||
|     var count = await listSticker(); |     final localPacks = await _dt.db.snLocalStickerPack.select().get(); | ||||||
|     for (var page = 1; count > 0; count -= 10) { |     final localStickers = await _dt.db.snLocalSticker.select().get(); | ||||||
|       await listSticker(page: page); |     final local = localStickers.map((ele) { | ||||||
|       page++; |       return ele.content.copyWith( | ||||||
|     } |         pack: localPacks | ||||||
|   } |             .firstWhere((pk) => pk.content.id == ele.content.packId) | ||||||
|  |             .content, | ||||||
|   Future<int> listSticker({int page = 0}) async { |       ); | ||||||
|     try { |  | ||||||
|       final resp = await _sn.client.get('/cgi/uc/stickers', queryParameters: { |  | ||||||
|         'take': 10, |  | ||||||
|         'offset': page * 10, |  | ||||||
|     }); |     }); | ||||||
|  |     for (final sticker in local) { | ||||||
|  |       _cacheSticker(sticker); | ||||||
|  |     } | ||||||
|  |     try { | ||||||
|  |       final resp = await _sn.client.get('/cgi/uc/stickers'); | ||||||
|       final data = resp.data; |       final data = resp.data; | ||||||
|       final stickers = List.from(data['data']).map((ele) => SnSticker.fromJson(ele)); |       final stickers = List.from(data).map((ele) => SnSticker.fromJson(ele)); | ||||||
|       for (final sticker in stickers) { |       for (final sticker in stickers) { | ||||||
|         _cacheSticker(sticker); |         _cacheSticker(sticker); | ||||||
|       } |       } | ||||||
|       return data['count'] as int; |  | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       log('[Sticker] Failed to list stickers: $err'); |       logging.error('[Sticker] Failed to list stickers...', err); | ||||||
|       rethrow; |       rethrow; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   Future<void> _saveStickerToLocal(Iterable<SnSticker> stickers) async { | ||||||
|  |     await _dt.db.snLocalSticker.insertAll( | ||||||
|  |       stickers.map( | ||||||
|  |         (ele) => SnLocalStickerCompanion.insert( | ||||||
|  |           id: Value(ele.id), | ||||||
|  |           alias: ele.alias, | ||||||
|  |           fullAlias: '${ele.pack.prefix}${ele.alias}', | ||||||
|  |           content: ele, | ||||||
|  |           createdAt: Value(ele.createdAt), | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |       onConflict: DoNothing(), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> _saveStickerPackToLocal(Iterable<SnStickerPack> packs) async { | ||||||
|  |     final queries = packs | ||||||
|  |         .map( | ||||||
|  |           (ele) => _dt.db.snLocalStickerPack.insertOne( | ||||||
|  |               SnLocalStickerPackCompanion.insert( | ||||||
|  |                 id: Value(ele.id), | ||||||
|  |                 content: ele, | ||||||
|  |                 createdAt: Value(ele.createdAt), | ||||||
|  |               ), | ||||||
|  |               onConflict: DoUpdate((_) => SnLocalStickerPackCompanion.custom( | ||||||
|  |                   content: Constant(jsonEncode(ele.toJson()))))), | ||||||
|  |         ) | ||||||
|  |         .toList(); | ||||||
|  |     await Future.wait(queries); | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -13,8 +13,16 @@ class ThemeProvider extends ChangeNotifier { | |||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void reloadTheme({Color? seedColorOverride, bool? useMaterial3}) { |   void reloadTheme({ | ||||||
|     createAppThemeSet(seedColorOverride: seedColorOverride, useMaterial3: useMaterial3).then((value) { |     Color? seedColorOverride, | ||||||
|  |     bool? useMaterial3, | ||||||
|  |     String? customFonts, | ||||||
|  |   }) { | ||||||
|  |     createAppThemeSet( | ||||||
|  |       seedColorOverride: seedColorOverride, | ||||||
|  |       useMaterial3: useMaterial3, | ||||||
|  |       customFonts: customFonts, | ||||||
|  |     ).then((value) { | ||||||
|       theme = value; |       theme = value; | ||||||
|       notifyListeners(); |       notifyListeners(); | ||||||
|     }); |     }); | ||||||
|   | |||||||
| @@ -1,33 +1,106 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  |  | ||||||
|  | import 'package:drift/drift.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/database/database.dart'; | ||||||
|  | import 'package:surface/providers/database.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/types/account.dart'; | import 'package:surface/types/account.dart'; | ||||||
|  |  | ||||||
| class UserDirectoryProvider { | class UserDirectoryProvider { | ||||||
|   late final SnNetworkProvider _sn; |   late final SnNetworkProvider _sn; | ||||||
|  |   late final DatabaseProvider _dt; | ||||||
|  |  | ||||||
|   UserDirectoryProvider(BuildContext context) { |   UserDirectoryProvider(BuildContext context) { | ||||||
|     _sn = context.read<SnNetworkProvider>(); |     _sn = context.read<SnNetworkProvider>(); | ||||||
|  |     _dt = context.read<DatabaseProvider>(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   final Map<String, int> _idCache = {}; |   final Map<String, int> _idCache = {}; | ||||||
|   final Map<int, SnAccount> _cache = {}; |   final Map<int, SnAccount> _cache = {}; | ||||||
|  |  | ||||||
|  |   Future<int> loadAccountCache({int max = 100}) async { | ||||||
|  |     final out = await (_dt.db.snLocalAccount.select()..limit(max)).get(); | ||||||
|  |     for (final ele in out) { | ||||||
|  |       _cache[ele.id] = ele.content; | ||||||
|  |       _idCache[ele.name] = ele.id; | ||||||
|  |     } | ||||||
|  |     return out.length; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async { |   Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async { | ||||||
|     final out = await Future.wait( |     // In-memory cache | ||||||
|       id.map((e) => getAccount(e)), |     final out = List<SnAccount?>.generate(id.length, (e) => null); | ||||||
|     ); |     final plannedQuery = <int>{}; | ||||||
|  |     for (var idx = 0; idx < out.length; idx++) { | ||||||
|  |       var item = id.elementAt(idx); | ||||||
|  |       if (item is String && _idCache.containsKey(item)) { | ||||||
|  |         item = _idCache[item]; | ||||||
|  |       } | ||||||
|  |       if (_cache.containsKey(item)) { | ||||||
|  |         out[idx] = _cache[item]; | ||||||
|  |       } else { | ||||||
|  |         plannedQuery.add(item); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     // On-disk cache | ||||||
|  |     if (plannedQuery.isEmpty) return out; | ||||||
|  |     final dbResp = await (_dt.db.snLocalAccount.select() | ||||||
|  |           ..where((e) => e.id.isIn(plannedQuery)) | ||||||
|  |           ..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())) | ||||||
|  |           ..limit(plannedQuery.length)) | ||||||
|  |         .get(); | ||||||
|  |     for (var idx = 0; idx < out.length; idx++) { | ||||||
|  |       if (out[idx] != null) continue; | ||||||
|  |       if (dbResp.length <= idx) { | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |       out[idx] = dbResp[idx].content; | ||||||
|  |       _cache[dbResp[idx].id] = dbResp[idx].content; | ||||||
|  |       _idCache[dbResp[idx].name] = dbResp[idx].id; | ||||||
|  |       plannedQuery.remove(dbResp[idx].id); | ||||||
|  |     } | ||||||
|  |     // Remote server | ||||||
|  |     if (plannedQuery.isEmpty) return out; | ||||||
|  |     final resp = await _sn.client | ||||||
|  |         .get('/cgi/id/users', queryParameters: {'id': plannedQuery.join(',')}); | ||||||
|  |     final respDecoded = | ||||||
|  |         resp.data.map((e) => SnAccount.fromJson(e)).cast<SnAccount>().toList(); | ||||||
|  |     var sideIdx = 0; | ||||||
|  |     for (var idx = 0; idx < out.length; idx++) { | ||||||
|  |       if (out[idx] != null) continue; | ||||||
|  |       if (respDecoded.length <= sideIdx) { | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |       out[idx] = respDecoded[sideIdx]; | ||||||
|  |       _cache[respDecoded[sideIdx].id] = out[idx]!; | ||||||
|  |       _idCache[respDecoded[sideIdx].name] = respDecoded[sideIdx].id; | ||||||
|  |       sideIdx++; | ||||||
|  |     } | ||||||
|  |     if (respDecoded.isNotEmpty) _saveToLocal(respDecoded); | ||||||
|     return out; |     return out; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<SnAccount?> getAccount(dynamic id) async { |   Future<SnAccount?> getAccount(dynamic id) async { | ||||||
|  |     // In-memory cache | ||||||
|     if (id is String && _idCache.containsKey(id)) { |     if (id is String && _idCache.containsKey(id)) { | ||||||
|       id = _idCache[id]; |       id = _idCache[id]; | ||||||
|     } |     } | ||||||
|     if (_cache.containsKey(id)) { |     if (_cache.containsKey(id)) { | ||||||
|       return _cache[id]; |       return _cache[id]; | ||||||
|     } |     } | ||||||
|  |     // On-disk cache | ||||||
|  |     final dbResp = await (_dt.db.snLocalAccount.select() | ||||||
|  |           ..where((e) => e.id.equals(id)) | ||||||
|  |           ..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now()))) | ||||||
|  |         .getSingleOrNull(); | ||||||
|  |     if (dbResp != null) { | ||||||
|  |       _cache[dbResp.id] = dbResp.content; | ||||||
|  |       _idCache[dbResp.name] = dbResp.id; | ||||||
|  |       return dbResp.content; | ||||||
|  |     } | ||||||
|  |     // Remote server | ||||||
|     try { |     try { | ||||||
|       final resp = await _sn.client.get('/cgi/id/users/$id'); |       final resp = await _sn.client.get('/cgi/id/users/$id'); | ||||||
|       final account = SnAccount.fromJson( |       final account = SnAccount.fromJson( | ||||||
| @@ -35,16 +108,42 @@ class UserDirectoryProvider { | |||||||
|       ); |       ); | ||||||
|       _cache[account.id] = account; |       _cache[account.id] = account; | ||||||
|       if (id is String) _idCache[id] = account.id; |       if (id is String) _idCache[id] = account.id; | ||||||
|  |       _saveToLocal([account]); | ||||||
|       return account; |       return account; | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       return null; |       return null; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   SnAccount? getAccountFromCache(dynamic id) { |   SnAccount? getFromCache(dynamic id) { | ||||||
|     if (id is String && _idCache.containsKey(id)) { |     if (id is String && _idCache.containsKey(id)) { | ||||||
|       id = _idCache[id]; |       id = _idCache[id]; | ||||||
|     } |     } | ||||||
|     return _cache[id]; |     return _cache[id]; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   Future<void> _saveToLocal(Iterable<SnAccount> out) async { | ||||||
|  |     // For better on conflict resolution | ||||||
|  |     // And consider the method usually called with usually small amount of data | ||||||
|  |     // Use for to insert each record instead of bulk insert | ||||||
|  |     List<Future<int>> queries = out.map((ele) { | ||||||
|  |       return _dt.db.snLocalAccount.insertOne( | ||||||
|  |         SnLocalAccountCompanion.insert( | ||||||
|  |           id: Value(ele.id), | ||||||
|  |           name: ele.name, | ||||||
|  |           content: ele, | ||||||
|  |           cacheExpiredAt: DateTime.now().add(const Duration(hours: 1)), | ||||||
|  |         ), | ||||||
|  |         onConflict: DoUpdate( | ||||||
|  |           (_) => SnLocalAccountCompanion.custom( | ||||||
|  |             name: Constant(ele.name), | ||||||
|  |             content: Constant(jsonEncode(ele.toJson())), | ||||||
|  |             cacheExpiredAt: | ||||||
|  |                 Constant(DateTime.now().add(const Duration(hours: 1))), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |     }).toList(); | ||||||
|  |     await Future.wait(queries); | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,8 +1,7 @@ | |||||||
| import 'dart:developer'; |  | ||||||
|  |  | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:shared_preferences/shared_preferences.dart'; | import 'package:shared_preferences/shared_preferences.dart'; | ||||||
|  | import 'package:surface/logger.dart'; | ||||||
| import 'package:surface/providers/config.dart'; | import 'package:surface/providers/config.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/types/account.dart'; | import 'package:surface/types/account.dart'; | ||||||
| @@ -30,8 +29,8 @@ class UserProvider extends ChangeNotifier { | |||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|     refreshUser().then((value) async { |     refreshUser().then((value) async { | ||||||
|       if (value != null) { |       if (value != null) { | ||||||
|         log('Logged in as @${value.name}'); |         logging.info('[Auth] Logged in as @${value.name}'); | ||||||
|         log('Atk: ${await atk}'); |         logging.debug('[Auth] Access token: ${await atk}'); | ||||||
|       } |       } | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -1,12 +1,15 @@ | |||||||
| import 'dart:async'; | import 'dart:async'; | ||||||
| import 'dart:convert'; | import 'dart:convert'; | ||||||
| import 'dart:developer'; |  | ||||||
|  |  | ||||||
|  | import 'package:flutter/foundation.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_udid/flutter_udid.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/logger.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/providers/userinfo.dart'; | import 'package:surface/providers/userinfo.dart'; | ||||||
| import 'package:surface/types/websocket.dart'; | import 'package:surface/types/websocket.dart'; | ||||||
|  | import 'package:web_socket_channel/io.dart'; | ||||||
| import 'package:web_socket_channel/web_socket_channel.dart'; | import 'package:web_socket_channel/web_socket_channel.dart'; | ||||||
|  |  | ||||||
| class WebSocketProvider extends ChangeNotifier { | class WebSocketProvider extends ChangeNotifier { | ||||||
| @@ -18,7 +21,8 @@ class WebSocketProvider extends ChangeNotifier { | |||||||
|   late final SnNetworkProvider _sn; |   late final SnNetworkProvider _sn; | ||||||
|   late final UserProvider _ua; |   late final UserProvider _ua; | ||||||
|  |  | ||||||
|   StreamController<WebSocketPackage> stream = StreamController.broadcast(); |   StreamController<WebSocketPackage> pk = StreamController.broadcast(); | ||||||
|  |   Stream<dynamic>? _wsStream; | ||||||
|  |  | ||||||
|   WebSocketProvider(BuildContext context) { |   WebSocketProvider(BuildContext context) { | ||||||
|     _sn = context.read<SnNetworkProvider>(); |     _sn = context.read<SnNetworkProvider>(); | ||||||
| @@ -29,48 +33,61 @@ class WebSocketProvider extends ChangeNotifier { | |||||||
|     if (isConnected) return; |     if (isConnected) return; | ||||||
|     if (!_ua.isAuthorized) return; |     if (!_ua.isAuthorized) return; | ||||||
|  |  | ||||||
|     log('[WebSocket] Connecting to the server...'); |     logging.debug('[WebSocket] Connecting to the server...'); | ||||||
|     await connect(); |     await connect(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Completer<void>? _connectCompleter; |   Completer<void>? _connectCompleter; | ||||||
|  |  | ||||||
|   Future<void> connect({noRetry = false}) async { |   Future<void> connect({noRetry = false}) async { | ||||||
|     if(_connectCompleter != null) { |     if (_connectCompleter != null) { | ||||||
|       await _connectCompleter!.future; |       await _connectCompleter!.future; | ||||||
|       _connectCompleter = null; |       return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     _connectCompleter = Completer<void>(); |  | ||||||
|  |  | ||||||
|     if (!_ua.isAuthorized) return; |     if (!_ua.isAuthorized) return; | ||||||
|     if (isConnected || conn != null) { |     if (isConnected || conn != null) { | ||||||
|       disconnect(); |       disconnect(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       _connectCompleter = Completer<void>(); | ||||||
|  |  | ||||||
|       final atk = await _sn.getFreshAtk(); |       final atk = await _sn.getFreshAtk(); | ||||||
|       final uri = Uri.parse( |       final uri = Uri.parse( | ||||||
|       '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk', |         kIsWeb | ||||||
|  |             ? '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk' | ||||||
|  |             : '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?clientId=${await FlutterUdid.consistentUdid}tk=$atk', | ||||||
|       ); |       ); | ||||||
|  |  | ||||||
|       isBusy = true; |       isBusy = true; | ||||||
|       notifyListeners(); |       notifyListeners(); | ||||||
|  |  | ||||||
|     try { |       conn = kIsWeb | ||||||
|       conn = WebSocketChannel.connect(uri); |           ? WebSocketChannel.connect(uri) | ||||||
|  |           : IOWebSocketChannel.connect( | ||||||
|  |               uri, | ||||||
|  |               headers: {'Authorization': 'Bearer $atk'}, | ||||||
|  |             ); | ||||||
|       await conn!.ready; |       await conn!.ready; | ||||||
|  |       _wsStream = conn!.stream.asBroadcastStream(); | ||||||
|       listen(); |       listen(); | ||||||
|       log('[WebSocket] Connected to server!'); |       logging.info('[WebSocket] Connected to server!'); | ||||||
|       isConnected = true; |       isConnected = true; | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (err is WebSocketChannelException) { |       if (err is WebSocketChannelException) { | ||||||
|         log('Failed to connect to websocket: ${(err.inner as dynamic).message}'); |         logging.error( | ||||||
|  |           '[WebSocket] Failed to connect to websocket...', | ||||||
|  |           err.inner, | ||||||
|  |         ); | ||||||
|       } else { |       } else { | ||||||
|         log('Failed to connect to websocket: $err'); |         logging.error('[WebSocket] Failed to connect to websocket...', err); | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       if (!noRetry) { |       if (!noRetry) { | ||||||
|         log('Retry connecting to websocket in 3 seconds...'); |         logging.warning( | ||||||
|  |           '[WebSocket] Retry connecting to websocket in 3 seconds...', | ||||||
|  |         ); | ||||||
|         return Future.delayed( |         return Future.delayed( | ||||||
|           const Duration(seconds: 3), |           const Duration(seconds: 3), | ||||||
|           () => connect(noRetry: true), |           () => connect(noRetry: true), | ||||||
| @@ -80,6 +97,7 @@ class WebSocketProvider extends ChangeNotifier { | |||||||
|       isBusy = false; |       isBusy = false; | ||||||
|       notifyListeners(); |       notifyListeners(); | ||||||
|       _connectCompleter!.complete(); |       _connectCompleter!.complete(); | ||||||
|  |       _connectCompleter = null; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -93,11 +111,14 @@ class WebSocketProvider extends ChangeNotifier { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   void listen() { |   void listen() { | ||||||
|     conn?.stream.listen( |     if (_wsStream == null) return; | ||||||
|  |     _wsStream!.listen( | ||||||
|       (event) { |       (event) { | ||||||
|         final packet = WebSocketPackage.fromJson(jsonDecode(event)); |         final packet = WebSocketPackage.fromJson(jsonDecode(event)); | ||||||
|         log('Websocket incoming message: ${packet.method} ${packet.message}'); |         logging.debug( | ||||||
|         stream.sink.add(packet); |           '[Websocket] Incoming message: ${packet.method} ${packet.message}', | ||||||
|  |         ); | ||||||
|  |         pk.sink.add(packet); | ||||||
|       }, |       }, | ||||||
|       onDone: () { |       onDone: () { | ||||||
|         isConnected = false; |         isConnected = false; | ||||||
|   | |||||||
| @@ -4,7 +4,9 @@ import 'package:go_router/go_router.dart'; | |||||||
| import 'package:surface/screens/abuse_report.dart'; | import 'package:surface/screens/abuse_report.dart'; | ||||||
| import 'package:surface/screens/account.dart'; | import 'package:surface/screens/account.dart'; | ||||||
| import 'package:surface/screens/account/account_settings.dart'; | import 'package:surface/screens/account/account_settings.dart'; | ||||||
|  | import 'package:surface/screens/account/badges.dart'; | ||||||
| import 'package:surface/screens/account/factor_settings.dart'; | import 'package:surface/screens/account/factor_settings.dart'; | ||||||
|  | import 'package:surface/screens/account/keypairs.dart'; | ||||||
| import 'package:surface/screens/account/profile_page.dart'; | import 'package:surface/screens/account/profile_page.dart'; | ||||||
| import 'package:surface/screens/account/profile_edit.dart'; | import 'package:surface/screens/account/profile_edit.dart'; | ||||||
| import 'package:surface/screens/account/publishers/publisher_edit.dart'; | import 'package:surface/screens/account/publishers/publisher_edit.dart'; | ||||||
| @@ -21,6 +23,7 @@ import 'package:surface/screens/chat/room.dart'; | |||||||
| import 'package:surface/screens/explore.dart'; | import 'package:surface/screens/explore.dart'; | ||||||
| import 'package:surface/screens/friend.dart'; | import 'package:surface/screens/friend.dart'; | ||||||
| import 'package:surface/screens/home.dart'; | import 'package:surface/screens/home.dart'; | ||||||
|  | import 'package:surface/screens/logging.dart'; | ||||||
| import 'package:surface/screens/news/news_detail.dart'; | import 'package:surface/screens/news/news_detail.dart'; | ||||||
| import 'package:surface/screens/news/news_list.dart'; | import 'package:surface/screens/news/news_list.dart'; | ||||||
| import 'package:surface/screens/notification.dart'; | import 'package:surface/screens/notification.dart'; | ||||||
| @@ -31,15 +34,18 @@ import 'package:surface/screens/post/post_search.dart'; | |||||||
| import 'package:surface/screens/realm.dart'; | import 'package:surface/screens/realm.dart'; | ||||||
| import 'package:surface/screens/realm/manage.dart'; | import 'package:surface/screens/realm/manage.dart'; | ||||||
| import 'package:surface/screens/realm/realm_detail.dart'; | import 'package:surface/screens/realm/realm_detail.dart'; | ||||||
|  | import 'package:surface/screens/realm/realm_discovery.dart'; | ||||||
| import 'package:surface/screens/settings.dart'; | import 'package:surface/screens/settings.dart'; | ||||||
| import 'package:surface/screens/sharing.dart'; | import 'package:surface/screens/sharing.dart'; | ||||||
|  | import 'package:surface/screens/stickers.dart'; | ||||||
|  | import 'package:surface/screens/stickers/pack_detail.dart'; | ||||||
| import 'package:surface/screens/wallet.dart'; | import 'package:surface/screens/wallet.dart'; | ||||||
| import 'package:surface/types/post.dart'; | import 'package:surface/types/post.dart'; | ||||||
| import 'package:surface/widgets/about.dart'; | import 'package:surface/widgets/about.dart'; | ||||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  |  | ||||||
| Widget _fadeThroughTransition( | Widget _fadeThroughTransition(BuildContext context, Animation<double> animation, | ||||||
|     BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) { |     Animation<double> secondaryAnimation, Widget child) { | ||||||
|   return FadeThroughTransition( |   return FadeThroughTransition( | ||||||
|     animation: animation, |     animation: animation, | ||||||
|     secondaryAnimation: secondaryAnimation, |     secondaryAnimation: secondaryAnimation, | ||||||
| @@ -60,10 +66,10 @@ final _appRoutes = [ | |||||||
|     builder: (context, state) => const ExploreScreen(), |     builder: (context, state) => const ExploreScreen(), | ||||||
|     routes: [ |     routes: [ | ||||||
|       GoRoute( |       GoRoute( | ||||||
|         path: '/write/:mode', |         path: '/write', | ||||||
|         name: 'postEditor', |         name: 'postEditor', | ||||||
|         builder: (context, state) => PostEditorScreen( |         builder: (context, state) => PostEditorScreen( | ||||||
|           mode: state.pathParameters['mode']!, |           mode: state.uri.queryParameters['mode'], | ||||||
|           postEditId: int.tryParse( |           postEditId: int.tryParse( | ||||||
|             state.uri.queryParameters['editing'] ?? '', |             state.uri.queryParameters['editing'] ?? '', | ||||||
|           ), |           ), | ||||||
| @@ -81,13 +87,15 @@ final _appRoutes = [ | |||||||
|         name: 'postSearch', |         name: 'postSearch', | ||||||
|         builder: (context, state) => PostSearchScreen( |         builder: (context, state) => PostSearchScreen( | ||||||
|           initialTags: state.uri.queryParameters['tags']?.split(','), |           initialTags: state.uri.queryParameters['tags']?.split(','), | ||||||
|           initialCategories: state.uri.queryParameters['categories']?.split(','), |           initialCategories: | ||||||
|  |               state.uri.queryParameters['categories']?.split(','), | ||||||
|         ), |         ), | ||||||
|       ), |       ), | ||||||
|       GoRoute( |       GoRoute( | ||||||
|         path: '/publishers/:name', |         path: '/publishers/:name', | ||||||
|         name: 'postPublisher', |         name: 'postPublisher', | ||||||
|         builder: (context, state) => PostPublisherScreen(name: state.pathParameters['name']!), |         builder: (context, state) => | ||||||
|  |             PostPublisherScreen(name: state.pathParameters['name']!), | ||||||
|       ), |       ), | ||||||
|       GoRoute( |       GoRoute( | ||||||
|         path: '/:slug', |         path: '/:slug', | ||||||
| @@ -99,12 +107,26 @@ final _appRoutes = [ | |||||||
|       ), |       ), | ||||||
|     ], |     ], | ||||||
|   ), |   ), | ||||||
|   GoRoute(path: '/account', name: 'account', builder: (context, state) => const AccountScreen(), routes: [ |   GoRoute( | ||||||
|  |     path: '/account', | ||||||
|  |     name: 'account', | ||||||
|  |     builder: (context, state) => const AccountScreen(), | ||||||
|  |     routes: [ | ||||||
|  |       GoRoute( | ||||||
|  |         path: '/badges', | ||||||
|  |         name: 'accountBadges', | ||||||
|  |         builder: (context, state) => const AccountBadgesScreen(), | ||||||
|  |       ), | ||||||
|       GoRoute( |       GoRoute( | ||||||
|         path: '/wallet', |         path: '/wallet', | ||||||
|         name: 'accountWallet', |         name: 'accountWallet', | ||||||
|         builder: (context, state) => const WalletScreen(), |         builder: (context, state) => const WalletScreen(), | ||||||
|       ), |       ), | ||||||
|  |       GoRoute( | ||||||
|  |         path: '/keypairs', | ||||||
|  |         name: 'accountKeyPairs', | ||||||
|  |         builder: (context, state) => const KeyPairScreen(), | ||||||
|  |       ), | ||||||
|       GoRoute( |       GoRoute( | ||||||
|         path: '/settings', |         path: '/settings', | ||||||
|         name: 'accountSettings', |         name: 'accountSettings', | ||||||
| @@ -144,7 +166,8 @@ final _appRoutes = [ | |||||||
|           child: UserScreen(name: state.pathParameters['name']!), |           child: UserScreen(name: state.pathParameters['name']!), | ||||||
|         ), |         ), | ||||||
|       ), |       ), | ||||||
|   ]), |     ], | ||||||
|  |   ), | ||||||
|   GoRoute( |   GoRoute( | ||||||
|     path: '/chat', |     path: '/chat', | ||||||
|     name: 'chat', |     name: 'chat', | ||||||
| @@ -192,11 +215,6 @@ final _appRoutes = [ | |||||||
|       child: const RealmScreen(), |       child: const RealmScreen(), | ||||||
|     ), |     ), | ||||||
|     routes: [ |     routes: [ | ||||||
|       GoRoute( |  | ||||||
|         path: '/:alias', |  | ||||||
|         name: 'realmDetail', |  | ||||||
|         builder: (context, state) => RealmDetailScreen(alias: state.pathParameters['alias']!), |  | ||||||
|       ), |  | ||||||
|       GoRoute( |       GoRoute( | ||||||
|         path: '/manage', |         path: '/manage', | ||||||
|         name: 'realmManage', |         name: 'realmManage', | ||||||
| @@ -204,9 +222,24 @@ final _appRoutes = [ | |||||||
|           editingRealmAlias: state.uri.queryParameters['editing'], |           editingRealmAlias: state.uri.queryParameters['editing'], | ||||||
|         ), |         ), | ||||||
|       ), |       ), | ||||||
|  |       GoRoute( | ||||||
|  |         path: '/discovery', | ||||||
|  |         name: 'realmDiscovery', | ||||||
|  |         builder: (context, state) => const RealmDiscoveryScreen(), | ||||||
|  |       ), | ||||||
|  |       GoRoute( | ||||||
|  |         path: '/:alias', | ||||||
|  |         name: 'realmDetail', | ||||||
|  |         builder: (context, state) => | ||||||
|  |             RealmDetailScreen(alias: state.pathParameters['alias']!), | ||||||
|  |       ), | ||||||
|     ], |     ], | ||||||
|   ), |   ), | ||||||
|   GoRoute(path: '/news', name: 'news', builder: (context, state) => const NewsScreen(), routes: [ |   GoRoute( | ||||||
|  |     path: '/news', | ||||||
|  |     name: 'news', | ||||||
|  |     builder: (context, state) => const NewsScreen(), | ||||||
|  |     routes: [ | ||||||
|       GoRoute( |       GoRoute( | ||||||
|         path: '/:hash', |         path: '/:hash', | ||||||
|         name: 'newsDetail', |         name: 'newsDetail', | ||||||
| @@ -214,7 +247,27 @@ final _appRoutes = [ | |||||||
|           hash: state.pathParameters['hash']!, |           hash: state.pathParameters['hash']!, | ||||||
|         ), |         ), | ||||||
|       ), |       ), | ||||||
|   ]), |     ], | ||||||
|  |   ), | ||||||
|  |   GoRoute( | ||||||
|  |     path: '/stickers', | ||||||
|  |     name: 'stickers', | ||||||
|  |     builder: (context, state) => const StickerScreen(), | ||||||
|  |     routes: [ | ||||||
|  |       GoRoute( | ||||||
|  |         path: '/packs/:id', | ||||||
|  |         name: 'stickerPack', | ||||||
|  |         builder: (context, state) => StickerPackScreen( | ||||||
|  |           id: int.tryParse(state.pathParameters['id']!)!, | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ], | ||||||
|  |   ), | ||||||
|  |   GoRoute( | ||||||
|  |     path: '/debug/logging', | ||||||
|  |     name: 'debugLogging', | ||||||
|  |     builder: (context, state) => const DebugLoggingScreen(), | ||||||
|  |   ), | ||||||
|   GoRoute( |   GoRoute( | ||||||
|     path: '/album', |     path: '/album', | ||||||
|     name: 'album', |     name: 'album', | ||||||
|   | |||||||
| @@ -74,7 +74,10 @@ class _AbuseReportScreenState extends State<AbuseReportScreen> { | |||||||
|           ), |           ), | ||||||
|           const Divider(height: 1), |           const Divider(height: 1), | ||||||
|           if (_isBusy) |           if (_isBusy) | ||||||
|             const CircularProgressIndicator().padding(all: 24).center() |             Padding( | ||||||
|  |               padding: const EdgeInsets.all(24), | ||||||
|  |               child: const CircularProgressIndicator(), | ||||||
|  |             ).center() | ||||||
|           else |           else | ||||||
|             Expanded( |             Expanded( | ||||||
|               child: ListView.builder( |               child: ListView.builder( | ||||||
|   | |||||||
| @@ -4,10 +4,10 @@ import 'package:easy_localization/easy_localization.dart'; | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:gap/gap.dart'; | import 'package:gap/gap.dart'; | ||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
| import 'package:hive_flutter/hive_flutter.dart'; |  | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:surface/providers/database.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/providers/userinfo.dart'; | import 'package:surface/providers/userinfo.dart'; | ||||||
| import 'package:surface/providers/websocket.dart'; | import 'package:surface/providers/websocket.dart'; | ||||||
| @@ -45,7 +45,8 @@ class AccountScreen extends StatelessWidget { | |||||||
|             ? Stack( |             ? Stack( | ||||||
|                 fit: StackFit.expand, |                 fit: StackFit.expand, | ||||||
|                 children: [ |                 children: [ | ||||||
|                   AutoResizeUniversalImage(sn.getAttachmentUrl(ua.user!.banner), fit: BoxFit.cover), |                   AutoResizeUniversalImage(sn.getAttachmentUrl(ua.user!.banner), | ||||||
|  |                       fit: BoxFit.cover), | ||||||
|                   Positioned( |                   Positioned( | ||||||
|                     top: 0, |                     top: 0, | ||||||
|                     left: 0, |                     left: 0, | ||||||
| @@ -79,7 +80,9 @@ class AccountScreen extends StatelessWidget { | |||||||
|         ], |         ], | ||||||
|       ), |       ), | ||||||
|       body: SingleChildScrollView( |       body: SingleChildScrollView( | ||||||
|         child: ua.isAuthorized ? _AuthorizedAccountScreen() : _UnauthorizedAccountScreen(), |         child: ua.isAuthorized | ||||||
|  |             ? _AuthorizedAccountScreen() | ||||||
|  |             : _UnauthorizedAccountScreen(), | ||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| @@ -115,12 +118,21 @@ class _AuthorizedAccountScreen extends StatelessWidget { | |||||||
|                     crossAxisAlignment: CrossAxisAlignment.baseline, |                     crossAxisAlignment: CrossAxisAlignment.baseline, | ||||||
|                     textBaseline: TextBaseline.alphabetic, |                     textBaseline: TextBaseline.alphabetic, | ||||||
|                     children: [ |                     children: [ | ||||||
|                       Text(ua.user!.nick).textStyle(Theme.of(context).textTheme.titleLarge!), |                       Text(ua.user!.nick) | ||||||
|  |                           .textStyle(Theme.of(context).textTheme.titleLarge!), | ||||||
|                       const Gap(4), |                       const Gap(4), | ||||||
|                       Text('@${ua.user!.name}').textStyle(Theme.of(context).textTheme.bodySmall!), |                       Text('@${ua.user!.name}') | ||||||
|  |                           .textStyle(Theme.of(context).textTheme.bodySmall!), | ||||||
|                     ], |                     ], | ||||||
|                   ), |                   ), | ||||||
|                   Text(ua.user!.description).textStyle(Theme.of(context).textTheme.bodyMedium!), |                   Text( | ||||||
|  |                     (ua.user!.profile?.description.isNotEmpty ?? false) | ||||||
|  |                         ? ua.user!.profile!.description | ||||||
|  |                         : 'userNoDescription'.tr(), | ||||||
|  |                     style: (ua.user!.profile?.description.isEmpty ?? true) | ||||||
|  |                         ? TextStyle(fontStyle: FontStyle.italic) | ||||||
|  |                         : null, | ||||||
|  |                   ).textStyle(Theme.of(context).textTheme.bodyMedium!), | ||||||
|                 ], |                 ], | ||||||
|               ), |               ), | ||||||
|             ); |             ); | ||||||
| @@ -166,6 +178,26 @@ class _AuthorizedAccountScreen extends StatelessWidget { | |||||||
|             GoRouter.of(context).pushNamed('accountWallet'); |             GoRouter.of(context).pushNamed('accountWallet'); | ||||||
|           }, |           }, | ||||||
|         ), |         ), | ||||||
|  |         ListTile( | ||||||
|  |           title: Text('accountBadges').tr(), | ||||||
|  |           subtitle: Text('accountBadgesDescription').tr(), | ||||||
|  |           contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |           leading: const Icon(Symbols.award_star), | ||||||
|  |           trailing: const Icon(Symbols.chevron_right), | ||||||
|  |           onTap: () { | ||||||
|  |             GoRouter.of(context).pushNamed('accountBadges'); | ||||||
|  |           }, | ||||||
|  |         ), | ||||||
|  |         ListTile( | ||||||
|  |           title: Text('accountKeyPairs').tr(), | ||||||
|  |           subtitle: Text('accountKeyPairsDescription').tr(), | ||||||
|  |           contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |           leading: const Icon(Symbols.key), | ||||||
|  |           trailing: const Icon(Symbols.chevron_right), | ||||||
|  |           onTap: () { | ||||||
|  |             GoRouter.of(context).pushNamed('accountKeyPairs'); | ||||||
|  |           }, | ||||||
|  |         ), | ||||||
|         ListTile( |         ListTile( | ||||||
|           title: Text('accountSettings').tr(), |           title: Text('accountSettings').tr(), | ||||||
|           subtitle: Text('accountSettingsSubtitle').tr(), |           subtitle: Text('accountSettingsSubtitle').tr(), | ||||||
| @@ -193,8 +225,7 @@ class _AuthorizedAccountScreen extends StatelessWidget { | |||||||
|             ua.logoutUser(); |             ua.logoutUser(); | ||||||
|             final ws = context.read<WebSocketProvider>(); |             final ws = context.read<WebSocketProvider>(); | ||||||
|             ws.disconnect(); |             ws.disconnect(); | ||||||
|             await Hive.deleteFromDisk(); |             context.read<DatabaseProvider>().removeDatabase(); | ||||||
|             await Hive.initFlutter(); |  | ||||||
|           }, |           }, | ||||||
|         ), |         ), | ||||||
|       ], |       ], | ||||||
| @@ -220,7 +251,9 @@ class _UnauthorizedAccountScreen extends StatelessWidget { | |||||||
|                   child: Icon(Symbols.waving_hand, size: 28), |                   child: Icon(Symbols.waving_hand, size: 28), | ||||||
|                 ), |                 ), | ||||||
|                 const Gap(8), |                 const Gap(8), | ||||||
|                 Text('accountIntroTitle').tr().textStyle(Theme.of(context).textTheme.titleLarge!), |                 Text('accountIntroTitle') | ||||||
|  |                     .tr() | ||||||
|  |                     .textStyle(Theme.of(context).textTheme.titleLarge!), | ||||||
|                 Text('accountIntroSubtitle').tr(), |                 Text('accountIntroSubtitle').tr(), | ||||||
|               ], |               ], | ||||||
|             ).padding(all: 20), |             ).padding(all: 20), | ||||||
|   | |||||||
| @@ -54,14 +54,20 @@ class AccountSettingsScreen extends StatelessWidget { | |||||||
|                 child: DropdownButton2<Locale?>( |                 child: DropdownButton2<Locale?>( | ||||||
|                   isExpanded: true, |                   isExpanded: true, | ||||||
|                   items: [ |                   items: [ | ||||||
|                     ...EasyLocalization.of(context)!.supportedLocales.mapIndexed((idx, ele) { |                     ...EasyLocalization.of(context)! | ||||||
|  |                         .supportedLocales | ||||||
|  |                         .mapIndexed((idx, ele) { | ||||||
|                       return DropdownMenuItem<Locale?>( |                       return DropdownMenuItem<Locale?>( | ||||||
|                         value: Locale.parse(ele.toString()), |                         value: Locale.parse(ele.toString()), | ||||||
|                         child: Text('${ele.languageCode}-${ele.countryCode}').fontSize(14), |                         child: Text('${ele.languageCode}-${ele.countryCode}') | ||||||
|  |                             .fontSize(14), | ||||||
|                       ); |                       ); | ||||||
|                     }), |                     }), | ||||||
|                   ], |                   ], | ||||||
|                   value: ua.user?.language != null ? Locale.parse(ua.user!.language) : Locale.parse('en-US'), |                   value: ua.user?.language != null | ||||||
|  |                       ? (Locale.tryParse(ua.user!.language) ?? | ||||||
|  |                           Locale.parse('en-US')) | ||||||
|  |                       : Locale.parse('en-US'), | ||||||
|                   onChanged: (Locale? value) { |                   onChanged: (Locale? value) { | ||||||
|                     if (value == null) return; |                     if (value == null) return; | ||||||
|                     _setAccountLanguage(context, value); |                     _setAccountLanguage(context, value); | ||||||
|   | |||||||
							
								
								
									
										140
									
								
								lib/screens/account/badges.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										140
									
								
								lib/screens/account/badges.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,140 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:google_fonts/google_fonts.dart'; | ||||||
|  | import 'package:material_symbols_icons/material_symbols_icons.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:surface/providers/sn_network.dart'; | ||||||
|  | import 'package:surface/screens/account/profile_page.dart' show kBadgesMeta; | ||||||
|  | import 'package:surface/theme.dart'; | ||||||
|  | import 'package:surface/types/account.dart'; | ||||||
|  | import 'package:surface/widgets/dialog.dart'; | ||||||
|  | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  |  | ||||||
|  | class AccountBadgesScreen extends StatefulWidget { | ||||||
|  |   const AccountBadgesScreen({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<AccountBadgesScreen> createState() => _AccountBadgesScreenState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _AccountBadgesScreenState extends State<AccountBadgesScreen> { | ||||||
|  |   bool _isBusy = false; | ||||||
|  |   List<SnAccountBadge>? _badges; | ||||||
|  |  | ||||||
|  |   Future<void> _fetchBadges() async { | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       final resp = await sn.client.get('/cgi/id/badges/me'); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       setState( | ||||||
|  |         () => _badges = List<SnAccountBadge>.from( | ||||||
|  |           resp.data?.map((e) => SnAccountBadge.fromJson(e)) ?? [], | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   bool _isActivating = false; | ||||||
|  |  | ||||||
|  |   Future<void> _activateBadge(SnAccountBadge badge) async { | ||||||
|  |     try { | ||||||
|  |       setState(() => _isActivating = true); | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       await sn.client.post('/cgi/id/badges/${badge.id}/active'); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showSnackbar('badgeActivated' | ||||||
|  |           .tr(args: [(kBadgesMeta[badge.type]?.$1 ?? 'unknown').tr()])); | ||||||
|  |       await _fetchBadges(); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isActivating = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _fetchBadges(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AppScaffold( | ||||||
|  |       appBar: AppBar( | ||||||
|  |         title: Text('screenAccountBadges').tr(), | ||||||
|  |       ), | ||||||
|  |       body: Column( | ||||||
|  |         children: [ | ||||||
|  |           LoadingIndicator(isActive: _isBusy), | ||||||
|  |           if (_badges != null) | ||||||
|  |             Expanded( | ||||||
|  |               child: MediaQuery.removePadding( | ||||||
|  |                 context: context, | ||||||
|  |                 removeTop: true, | ||||||
|  |                 child: RefreshIndicator( | ||||||
|  |                   onRefresh: _fetchBadges, | ||||||
|  |                   child: ListView.builder( | ||||||
|  |                     itemCount: _badges!.length, | ||||||
|  |                     itemBuilder: (context, idx) { | ||||||
|  |                       final badge = _badges![idx]; | ||||||
|  |                       return ListTile( | ||||||
|  |                         title: Text( | ||||||
|  |                           kBadgesMeta[badge.type]?.$1 ?? 'unknown', | ||||||
|  |                         ).tr(), | ||||||
|  |                         contentPadding: const EdgeInsets.only( | ||||||
|  |                           left: 24, | ||||||
|  |                           right: 16, | ||||||
|  |                           top: 4, | ||||||
|  |                           bottom: 4, | ||||||
|  |                         ), | ||||||
|  |                         subtitle: Column( | ||||||
|  |                           crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                           children: [ | ||||||
|  |                             if (badge.metadata['title'] != null) | ||||||
|  |                               Text(badge.metadata['title']).fontSize(14).bold() | ||||||
|  |                             else | ||||||
|  |                               Text( | ||||||
|  |                                 '#${badge.id.toString().padLeft(8, '0')}', | ||||||
|  |                                 style: GoogleFonts.robotoMono(), | ||||||
|  |                               ).fontSize(14).bold(), | ||||||
|  |                             Text( | ||||||
|  |                               DateFormat('y/M/d').format(badge.createdAt), | ||||||
|  |                             ) | ||||||
|  |                           ], | ||||||
|  |                         ), | ||||||
|  |                         trailing: IconButton( | ||||||
|  |                           icon: const Icon(Symbols.check), | ||||||
|  |                           onPressed: (badge.isActive || _isActivating) | ||||||
|  |                               ? null | ||||||
|  |                               : () { | ||||||
|  |                                   _activateBadge(badge); | ||||||
|  |                                 }, | ||||||
|  |                         ), | ||||||
|  |                         leading: Icon( | ||||||
|  |                           kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark, | ||||||
|  |                           color: badge.metadata['color'] != null | ||||||
|  |                               ? HexColor.fromHex(badge.metadata['color']!) | ||||||
|  |                               : kBadgesMeta[badge.type]?.$3, | ||||||
|  |                           fill: 1, | ||||||
|  |                         ), | ||||||
|  |                       ); | ||||||
|  |                     }, | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										106
									
								
								lib/screens/account/keypairs.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								lib/screens/account/keypairs.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,106 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:google_fonts/google_fonts.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:surface/providers/keypair.dart'; | ||||||
|  | import 'package:surface/types/keypair.dart'; | ||||||
|  | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  |  | ||||||
|  | class KeyPairScreen extends StatefulWidget { | ||||||
|  |   const KeyPairScreen({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<KeyPairScreen> createState() => _KeyPairScreenState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _KeyPairScreenState extends State<KeyPairScreen> { | ||||||
|  |   bool _isBusy = false; | ||||||
|  |   List<SnKeyPair>? _keyPairs; | ||||||
|  |  | ||||||
|  |   Future<void> _loadKeyPairs() async { | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |     final kps = await context.read<KeyPairProvider>().listKeyPair(); | ||||||
|  |     setState(() { | ||||||
|  |       _keyPairs = kps; | ||||||
|  |       _isBusy = false; | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _loadKeyPairs(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AppScaffold( | ||||||
|  |       appBar: AppBar( | ||||||
|  |         title: Text('screenKeyPairs').tr(), | ||||||
|  |       ), | ||||||
|  |       body: Column( | ||||||
|  |         children: [ | ||||||
|  |           LoadingIndicator(isActive: _isBusy), | ||||||
|  |           ListTile( | ||||||
|  |             leading: const Icon(Symbols.add), | ||||||
|  |             title: Text('enrollNewKeyPair').tr(), | ||||||
|  |             subtitle: Text('enrollNewKeyPairDescription').tr(), | ||||||
|  |             onTap: () async { | ||||||
|  |               await context.read<KeyPairProvider>().enrollNew(); | ||||||
|  |               _loadKeyPairs(); | ||||||
|  |             }, | ||||||
|  |           ), | ||||||
|  |           const Divider(height: 1), | ||||||
|  |           if (_keyPairs != null) | ||||||
|  |             Expanded( | ||||||
|  |               child: MediaQuery.removePadding( | ||||||
|  |                 context: context, | ||||||
|  |                 removeTop: true, | ||||||
|  |                 child: RefreshIndicator( | ||||||
|  |                   onRefresh: _loadKeyPairs, | ||||||
|  |                   child: ListView.builder( | ||||||
|  |                     itemCount: _keyPairs!.length, | ||||||
|  |                     itemBuilder: (context, index) { | ||||||
|  |                       final kp = _keyPairs![index]; | ||||||
|  |                       return ListTile( | ||||||
|  |                         title: Text(kp.id.toUpperCase()), | ||||||
|  |                         subtitle: Row( | ||||||
|  |                           spacing: 8, | ||||||
|  |                           children: [ | ||||||
|  |                             if (kp.privateKey != null) | ||||||
|  |                               Text( | ||||||
|  |                                 'keyPairHasPrivateKey'.tr(), | ||||||
|  |                               ), | ||||||
|  |                             if (kp.privateKey != null) Text('·'), | ||||||
|  |                             Flexible( | ||||||
|  |                               flex: 1, | ||||||
|  |                               child: Text( | ||||||
|  |                                 'UID #${kp.accountId.toString().padLeft(8, '0')}', | ||||||
|  |                                 style: GoogleFonts.robotoMono(), | ||||||
|  |                               ), | ||||||
|  |                             ), | ||||||
|  |                           ], | ||||||
|  |                         ), | ||||||
|  |                         trailing: IconButton( | ||||||
|  |                           icon: const Icon(Symbols.check), | ||||||
|  |                           onPressed: kp.isActive == true | ||||||
|  |                               ? null | ||||||
|  |                               : () async { | ||||||
|  |                                   final k = context.read<KeyPairProvider>(); | ||||||
|  |                                   await k.activeKeyPair(kp.id); | ||||||
|  |                                   _loadKeyPairs(); | ||||||
|  |                                 }, | ||||||
|  |                         ), | ||||||
|  |                       ); | ||||||
|  |                     }, | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -6,6 +6,7 @@ import 'package:easy_localization/easy_localization.dart'; | |||||||
| import 'package:flutter/cupertino.dart'; | import 'package:flutter/cupertino.dart'; | ||||||
| import 'package:flutter/foundation.dart'; | import 'package:flutter/foundation.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_timezone/flutter_timezone.dart'; | ||||||
| import 'package:gap/gap.dart'; | import 'package:gap/gap.dart'; | ||||||
| import 'package:image_picker/image_picker.dart'; | import 'package:image_picker/image_picker.dart'; | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| @@ -36,11 +37,16 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|   final _firstNameController = TextEditingController(); |   final _firstNameController = TextEditingController(); | ||||||
|   final _lastNameController = TextEditingController(); |   final _lastNameController = TextEditingController(); | ||||||
|   final _descriptionController = TextEditingController(); |   final _descriptionController = TextEditingController(); | ||||||
|  |   final _timezoneController = TextEditingController(); | ||||||
|  |   final _genderController = TextEditingController(); | ||||||
|  |   final _pronounsController = TextEditingController(); | ||||||
|  |   final _locationController = TextEditingController(); | ||||||
|   final _birthdayController = TextEditingController(); |   final _birthdayController = TextEditingController(); | ||||||
|  |  | ||||||
|   String? _avatar; |   String? _avatar; | ||||||
|   String? _banner; |   String? _banner; | ||||||
|   DateTime? _birthday; |   DateTime? _birthday; | ||||||
|  |   List<(String, String)>? _links; | ||||||
|  |  | ||||||
|   bool _isBusy = false; |   bool _isBusy = false; | ||||||
|  |  | ||||||
| @@ -51,27 +57,30 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|     final prof = ua.user!; |     final prof = ua.user!; | ||||||
|     _usernameController.text = prof.name; |     _usernameController.text = prof.name; | ||||||
|     _nicknameController.text = prof.nick; |     _nicknameController.text = prof.nick; | ||||||
|     _descriptionController.text = prof.description; |     _descriptionController.text = prof.profile!.description; | ||||||
|     _firstNameController.text = prof.profile!.firstName; |     _firstNameController.text = prof.profile!.firstName; | ||||||
|     _lastNameController.text = prof.profile!.lastName; |     _lastNameController.text = prof.profile!.lastName; | ||||||
|  |     _timezoneController.text = prof.profile!.timeZone; | ||||||
|  |     _genderController.text = prof.profile!.gender; | ||||||
|  |     _pronounsController.text = prof.profile!.pronouns; | ||||||
|  |     _locationController.text = prof.profile!.location; | ||||||
|     _avatar = prof.avatar; |     _avatar = prof.avatar; | ||||||
|     _banner = prof.banner; |     _banner = prof.banner; | ||||||
|     if (prof.profile!.birthday != null) { |     _links = prof.profile!.links.entries.map((ele) => (ele.key, ele.value)).toList(); | ||||||
|       _birthdayController.text = DateFormat(_kDateFormat).format( |     _birthday = prof.profile!.birthday?.toLocal(); | ||||||
|         prof.profile!.birthday!.toLocal(), |     if (_birthday != null) { | ||||||
|       ); |       _birthdayController.text = DateFormat(_kDateFormat).format(prof.profile!.birthday!.toLocal()); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void _selectBirthday() async { |   void _selectBirthday() async { | ||||||
|     await showCupertinoModalPopup<DateTime?>( |     await showCupertinoModalPopup<DateTime?>( | ||||||
|       context: context, |       context: context, | ||||||
|       builder: (BuildContext context) => Container( |       builder: | ||||||
|  |           (BuildContext context) => Container( | ||||||
|             height: 216, |             height: 216, | ||||||
|             padding: const EdgeInsets.only(top: 6.0), |             padding: const EdgeInsets.only(top: 6.0), | ||||||
|         margin: EdgeInsets.only( |             margin: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), | ||||||
|           bottom: MediaQuery.of(context).viewInsets.bottom, |  | ||||||
|         ), |  | ||||||
|             color: Theme.of(context).colorScheme.surface, |             color: Theme.of(context).colorScheme.surface, | ||||||
|             child: SafeArea( |             child: SafeArea( | ||||||
|               top: false, |               top: false, | ||||||
| @@ -96,10 +105,15 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|     if (image == null) return; |     if (image == null) return; | ||||||
|     if (!mounted) return; |     if (!mounted) return; | ||||||
|  |  | ||||||
|  |     final skipCrop = image.path.endsWith('.gif'); | ||||||
|  |  | ||||||
|  |     Uint8List? rawBytes; | ||||||
|  |     if (!skipCrop) { | ||||||
|       final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path)); |       final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path)); | ||||||
|       final aspectRatios = |       final aspectRatios = | ||||||
|           place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)]; |           place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)]; | ||||||
|     final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) |       final result = | ||||||
|  |           (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) | ||||||
|               ? await showCupertinoImageCropper( |               ? await showCupertinoImageCropper( | ||||||
|                 // ignore: use_build_context_synchronously |                 // ignore: use_build_context_synchronously | ||||||
|                 context, |                 context, | ||||||
| @@ -116,11 +130,16 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|       if (result == null) return; |       if (result == null) return; | ||||||
|  |  | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|     final attach = context.read<SnAttachmentProvider>(); |  | ||||||
|  |  | ||||||
|       setState(() => _isBusy = true); |       setState(() => _isBusy = true); | ||||||
|  |       rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List(); | ||||||
|  |     } else { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       setState(() => _isBusy = true); | ||||||
|  |       rawBytes = await image.readAsBytes(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List(); |     if (!mounted) return; | ||||||
|  |     final attach = context.read<SnAttachmentProvider>(); | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       final attachment = await attach.directUploadOne( |       final attachment = await attach.directUploadOne( | ||||||
| @@ -133,10 +152,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|  |  | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       await sn.client.put( |       await sn.client.put('/cgi/id/users/me/$place', data: {'attachment': attachment.rid}); | ||||||
|         '/cgi/id/users/me/$place', |  | ||||||
|         data: {'attachment': attachment.rid}, |  | ||||||
|       ); |  | ||||||
|  |  | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       final ua = context.read<UserProvider>(); |       final ua = context.read<UserProvider>(); | ||||||
| @@ -166,7 +182,14 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|           'description': _descriptionController.value.text, |           'description': _descriptionController.value.text, | ||||||
|           'first_name': _firstNameController.value.text, |           'first_name': _firstNameController.value.text, | ||||||
|           'last_name': _lastNameController.value.text, |           'last_name': _lastNameController.value.text, | ||||||
|  |           'time_zone': _timezoneController.value.text, | ||||||
|  |           'gender': _genderController.value.text, | ||||||
|  |           'pronouns': _pronounsController.value.text, | ||||||
|  |           'location': _locationController.value.text, | ||||||
|           'birthday': _birthday?.toUtc().toIso8601String(), |           'birthday': _birthday?.toUtc().toIso8601String(), | ||||||
|  |           'links': { | ||||||
|  |             for (final link in _links!.where((ele) => ele.$1.isNotEmpty && ele.$2.isNotEmpty)) link.$1: link.$2, | ||||||
|  |           }, | ||||||
|         }, |         }, | ||||||
|       ); |       ); | ||||||
|  |  | ||||||
| @@ -197,6 +220,10 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|     _firstNameController.dispose(); |     _firstNameController.dispose(); | ||||||
|     _lastNameController.dispose(); |     _lastNameController.dispose(); | ||||||
|     _descriptionController.dispose(); |     _descriptionController.dispose(); | ||||||
|  |     _timezoneController.dispose(); | ||||||
|  |     _genderController.dispose(); | ||||||
|  |     _pronounsController.dispose(); | ||||||
|  |     _locationController.dispose(); | ||||||
|     _birthdayController.dispose(); |     _birthdayController.dispose(); | ||||||
|     super.dispose(); |     super.dispose(); | ||||||
|   } |   } | ||||||
| @@ -208,10 +235,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|     final sn = context.read<SnNetworkProvider>(); |     final sn = context.read<SnNetworkProvider>(); | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       appBar: AppBar( |       appBar: AppBar(leading: const PageBackButton(), title: Text('screenAccountProfileEdit').tr()), | ||||||
|         leading: const PageBackButton(), |  | ||||||
|         title: Text('screenAccountProfileEdit').tr(), |  | ||||||
|       ), |  | ||||||
|       body: SingleChildScrollView( |       body: SingleChildScrollView( | ||||||
|         child: Column( |         child: Column( | ||||||
|           crossAxisAlignment: CrossAxisAlignment.start, |           crossAxisAlignment: CrossAxisAlignment.start, | ||||||
| @@ -230,11 +254,9 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|                         aspectRatio: 16 / 9, |                         aspectRatio: 16 / 9, | ||||||
|                         child: Container( |                         child: Container( | ||||||
|                           color: Theme.of(context).colorScheme.surfaceContainerHigh, |                           color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||||
|                           child: _banner != null |                           child: | ||||||
|                               ? AutoResizeUniversalImage( |                               _banner != null | ||||||
|                                   sn.getAttachmentUrl(_banner!), |                                   ? AutoResizeUniversalImage(sn.getAttachmentUrl(_banner!), fit: BoxFit.cover) | ||||||
|                                   fit: BoxFit.cover, |  | ||||||
|                                 ) |  | ||||||
|                                   : const SizedBox.shrink(), |                                   : const SizedBox.shrink(), | ||||||
|                         ), |                         ), | ||||||
|                       ), |                       ), | ||||||
| @@ -262,6 +284,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|             ).padding(horizontal: padding), |             ).padding(horizontal: padding), | ||||||
|             const Gap(8 + 28), |             const Gap(8 + 28), | ||||||
|             Column( |             Column( | ||||||
|  |               spacing: 4, | ||||||
|               children: [ |               children: [ | ||||||
|                 TextField( |                 TextField( | ||||||
|                   readOnly: true, |                   readOnly: true, | ||||||
| @@ -271,16 +294,13 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|                     labelText: 'fieldUsername'.tr(), |                     labelText: 'fieldUsername'.tr(), | ||||||
|                     helperText: 'fieldUsernameCannotEditHint'.tr(), |                     helperText: 'fieldUsernameCannotEditHint'.tr(), | ||||||
|                   ), |                   ), | ||||||
|  |                   onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                 ), |                 ), | ||||||
|                 const Gap(4), |  | ||||||
|                 TextField( |                 TextField( | ||||||
|                   controller: _nicknameController, |                   controller: _nicknameController, | ||||||
|                   decoration: InputDecoration( |                   decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldNickname'.tr()), | ||||||
|                     border: const UnderlineInputBorder(), |                   onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                     labelText: 'fieldNickname'.tr(), |  | ||||||
|                 ), |                 ), | ||||||
|                 ), |  | ||||||
|                 const Gap(4), |  | ||||||
|                 Row( |                 Row( | ||||||
|                   children: [ |                   children: [ | ||||||
|                     Flexible( |                     Flexible( | ||||||
| @@ -291,6 +311,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|                           border: const UnderlineInputBorder(), |                           border: const UnderlineInputBorder(), | ||||||
|                           labelText: 'fieldFirstName'.tr(), |                           labelText: 'fieldFirstName'.tr(), | ||||||
|                         ), |                         ), | ||||||
|  |                         onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                       ), |                       ), | ||||||
|                     ), |                     ), | ||||||
|                     const Gap(8), |                     const Gap(8), | ||||||
| @@ -302,31 +323,165 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|                           border: const UnderlineInputBorder(), |                           border: const UnderlineInputBorder(), | ||||||
|                           labelText: 'fieldLastName'.tr(), |                           labelText: 'fieldLastName'.tr(), | ||||||
|                         ), |                         ), | ||||||
|  |                         onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                       ), |                       ), | ||||||
|                     ), |                     ), | ||||||
|                   ], |                   ], | ||||||
|                 ), |                 ), | ||||||
|  |                 Row( | ||||||
|  |                   children: [ | ||||||
|  |                     Flexible( | ||||||
|  |                       flex: 1, | ||||||
|  |                       child: TextField( | ||||||
|  |                         controller: _genderController, | ||||||
|  |                         decoration: InputDecoration( | ||||||
|  |                           border: const UnderlineInputBorder(), | ||||||
|  |                           labelText: 'fieldGender'.tr(), | ||||||
|  |                         ), | ||||||
|  |                         onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|                     const Gap(4), |                     const Gap(4), | ||||||
|  |                     Flexible( | ||||||
|  |                       flex: 1, | ||||||
|  |                       child: TextField( | ||||||
|  |                         controller: _pronounsController, | ||||||
|  |                         decoration: InputDecoration( | ||||||
|  |                           border: const UnderlineInputBorder(), | ||||||
|  |                           labelText: 'fieldPronouns'.tr(), | ||||||
|  |                         ), | ||||||
|  |                         onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                   ], | ||||||
|  |                 ), | ||||||
|                 TextField( |                 TextField( | ||||||
|                   controller: _descriptionController, |                   controller: _descriptionController, | ||||||
|                   keyboardType: TextInputType.multiline, |                   keyboardType: TextInputType.multiline, | ||||||
|                   maxLines: null, |                   maxLines: null, | ||||||
|                   minLines: 3, |                   minLines: 3, | ||||||
|  |                   decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldDescription'.tr()), | ||||||
|  |                   onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |                 ), | ||||||
|  |                 Row( | ||||||
|  |                   crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |                   children: [ | ||||||
|  |                     Expanded( | ||||||
|  |                       child: TextField( | ||||||
|  |                         controller: _timezoneController, | ||||||
|                         decoration: InputDecoration( |                         decoration: InputDecoration( | ||||||
|                           border: const UnderlineInputBorder(), |                           border: const UnderlineInputBorder(), | ||||||
|                     labelText: 'fieldDescription'.tr(), |                           labelText: 'fieldTimeZone'.tr(), | ||||||
|  |                         ), | ||||||
|  |                         onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                       ), |                       ), | ||||||
|                     ), |                     ), | ||||||
|                     const Gap(4), |                     const Gap(4), | ||||||
|  |                     StyledWidget( | ||||||
|  |                       IconButton( | ||||||
|  |                         icon: const Icon(Symbols.calendar_month), | ||||||
|  |                         visualDensity: VisualDensity(horizontal: -4, vertical: -4), | ||||||
|  |                         padding: EdgeInsets.zero, | ||||||
|  |                         constraints: const BoxConstraints(), | ||||||
|  |                         onPressed: () async { | ||||||
|  |                           _timezoneController.text = await FlutterTimezone.getLocalTimezone(); | ||||||
|  |                         }, | ||||||
|  |                       ), | ||||||
|  |                     ).padding(top: 6), | ||||||
|  |                     const Gap(4), | ||||||
|  |                     StyledWidget( | ||||||
|  |                       IconButton( | ||||||
|  |                         icon: const Icon(Symbols.clear), | ||||||
|  |                         visualDensity: VisualDensity(horizontal: -4, vertical: -4), | ||||||
|  |                         padding: EdgeInsets.zero, | ||||||
|  |                         constraints: const BoxConstraints(), | ||||||
|  |                         onPressed: () { | ||||||
|  |                           _timezoneController.clear(); | ||||||
|  |                         }, | ||||||
|  |                       ), | ||||||
|  |                     ).padding(top: 6), | ||||||
|  |                   ], | ||||||
|  |                 ), | ||||||
|  |                 TextField( | ||||||
|  |                   controller: _locationController, | ||||||
|  |                   decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldLocation'.tr()), | ||||||
|  |                   onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |                 ), | ||||||
|                 TextField( |                 TextField( | ||||||
|                   controller: _birthdayController, |                   controller: _birthdayController, | ||||||
|                   readOnly: true, |                   readOnly: true, | ||||||
|                   decoration: InputDecoration( |                   decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldBirthday'.tr()), | ||||||
|                     border: const UnderlineInputBorder(), |  | ||||||
|                     labelText: 'fieldBirthday'.tr(), |  | ||||||
|                   ), |  | ||||||
|                   onTap: () => _selectBirthday(), |                   onTap: () => _selectBirthday(), | ||||||
|                 ), |                 ), | ||||||
|  |                 if (_links != null) | ||||||
|  |                   Card( | ||||||
|  |                     margin: const EdgeInsets.only(top: 16, bottom: 4), | ||||||
|  |                     child: Container( | ||||||
|  |                       width: double.infinity, | ||||||
|  |                       padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), | ||||||
|  |                       child: Column( | ||||||
|  |                         crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                         children: [ | ||||||
|  |                           Row( | ||||||
|  |                             children: [ | ||||||
|  |                               Expanded( | ||||||
|  |                                 child: Text( | ||||||
|  |                                   'fieldLinks'.tr(), | ||||||
|  |                                   style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 17), | ||||||
|  |                                 ), | ||||||
|  |                               ), | ||||||
|  |                               IconButton( | ||||||
|  |                                 padding: EdgeInsets.zero, | ||||||
|  |                                 constraints: const BoxConstraints(), | ||||||
|  |                                 visualDensity: VisualDensity(horizontal: -4, vertical: -4), | ||||||
|  |                                 icon: const Icon(Symbols.add), | ||||||
|  |                                 onPressed: () { | ||||||
|  |                                   setState(() => _links!.add(('', ''))); | ||||||
|  |                                 }, | ||||||
|  |                               ), | ||||||
|  |                             ], | ||||||
|  |                           ), | ||||||
|  |                           const Gap(8), | ||||||
|  |                           for (var idx = 0; idx < _links!.length; idx++) | ||||||
|  |                             Row( | ||||||
|  |                               children: [ | ||||||
|  |                                 Flexible( | ||||||
|  |                                   flex: 1, | ||||||
|  |                                   child: TextFormField( | ||||||
|  |                                     initialValue: _links![idx].$1, | ||||||
|  |                                     decoration: InputDecoration( | ||||||
|  |                                       isDense: true, | ||||||
|  |                                       border: const OutlineInputBorder(), | ||||||
|  |                                       labelText: 'fieldLinkName'.tr(), | ||||||
|  |                                     ), | ||||||
|  |                                     onChanged: (value) { | ||||||
|  |                                       _links![idx] = (value, _links![idx].$2); | ||||||
|  |                                     }, | ||||||
|  |                                     onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |                                   ), | ||||||
|  |                                 ), | ||||||
|  |                                 const Gap(8), | ||||||
|  |                                 Flexible( | ||||||
|  |                                   flex: 1, | ||||||
|  |                                   child: TextFormField( | ||||||
|  |                                     initialValue: _links![idx].$2, | ||||||
|  |                                     decoration: InputDecoration( | ||||||
|  |                                       isDense: true, | ||||||
|  |                                       border: const OutlineInputBorder(), | ||||||
|  |                                       labelText: 'fieldLinkUrl'.tr(), | ||||||
|  |                                     ), | ||||||
|  |                                     onChanged: (value) { | ||||||
|  |                                       _links![idx] = (_links![idx].$1, value); | ||||||
|  |                                     }, | ||||||
|  |                                     onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |                                   ), | ||||||
|  |                                 ), | ||||||
|  |                               ], | ||||||
|  |                             ), | ||||||
|  |                         ], | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|               ], |               ], | ||||||
|             ).padding(horizontal: padding + 8), |             ).padding(horizontal: padding + 8), | ||||||
|             const Gap(12), |             const Gap(12), | ||||||
| @@ -340,6 +495,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> { | |||||||
|                 ), |                 ), | ||||||
|               ], |               ], | ||||||
|             ).padding(horizontal: padding), |             ).padding(horizontal: padding), | ||||||
|  |             Gap(MediaQuery.of(context).padding.bottom), | ||||||
|           ], |           ], | ||||||
|         ), |         ), | ||||||
|       ), |       ), | ||||||
|   | |||||||
| @@ -20,8 +20,10 @@ import 'package:surface/types/post.dart'; | |||||||
| import 'package:surface/widgets/account/account_image.dart'; | import 'package:surface/widgets/account/account_image.dart'; | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
| import 'package:surface/widgets/universal_image.dart'; | import 'package:surface/widgets/universal_image.dart'; | ||||||
|  | import 'package:surface/theme.dart'; | ||||||
|  | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
|  |  | ||||||
| const Map<String, (String, IconData, Color)> kBadgesMeta = { | final Map<String, (String, IconData, Color)> kBadgesMeta = { | ||||||
|   'company.staff': ( |   'company.staff': ( | ||||||
|     'badgeCompanyStaff', |     'badgeCompanyStaff', | ||||||
|     Symbols.tools_wrench, |     Symbols.tools_wrench, | ||||||
| @@ -32,6 +34,31 @@ const Map<String, (String, IconData, Color)> kBadgesMeta = { | |||||||
|     Symbols.flag, |     Symbols.flag, | ||||||
|     Colors.orange, |     Colors.orange, | ||||||
|   ), |   ), | ||||||
|  |   'site.anniversary': ( | ||||||
|  |     'badgeSiteAnniversary', | ||||||
|  |     Symbols.celebration, | ||||||
|  |     Colors.orangeAccent, | ||||||
|  |   ), | ||||||
|  |   'user.birthday': ( | ||||||
|  |     'badgeUserBirthday', | ||||||
|  |     Symbols.cake, | ||||||
|  |     Colors.red[400]!, | ||||||
|  |   ), | ||||||
|  |   'community.survey': ( | ||||||
|  |     'badgeCommunitySurvey', | ||||||
|  |     Symbols.star, | ||||||
|  |     Colors.yellow[700]!, | ||||||
|  |   ), | ||||||
|  |   'community.verified': ( | ||||||
|  |     'badgeCommunityVerified', | ||||||
|  |     Symbols.verified, | ||||||
|  |     Colors.blue, | ||||||
|  |   ), | ||||||
|  |   'community.contributor': ( | ||||||
|  |     'badgeCommunityContributor', | ||||||
|  |     Symbols.thumb_up, | ||||||
|  |     Colors.lightGreen, | ||||||
|  |   ), | ||||||
| }; | }; | ||||||
|  |  | ||||||
| class UserScreen extends StatefulWidget { | class UserScreen extends StatefulWidget { | ||||||
| @@ -43,7 +70,8 @@ class UserScreen extends StatefulWidget { | |||||||
|   State<UserScreen> createState() => _UserScreenState(); |   State<UserScreen> createState() => _UserScreenState(); | ||||||
| } | } | ||||||
|  |  | ||||||
| class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateMixin { | class _UserScreenState extends State<UserScreen> | ||||||
|  |     with SingleTickerProviderStateMixin { | ||||||
|   late final ScrollController _scrollController = ScrollController(); |   late final ScrollController _scrollController = ScrollController(); | ||||||
|  |  | ||||||
|   SnAccount? _account; |   SnAccount? _account; | ||||||
| @@ -64,13 +92,18 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<List<SnCheckInRecord>> _getCheckInRecords() async { |   List<SnCheckInRecord>? _records; | ||||||
|  |  | ||||||
|  |   Future<void> _getCheckInRecords() async { | ||||||
|     try { |     try { | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final resp = await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14'); |       final resp = | ||||||
|       return List.from( |           await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14'); | ||||||
|  |       setState(() { | ||||||
|  |         _records = List.from( | ||||||
|           resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [], |           resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [], | ||||||
|         ); |         ); | ||||||
|  |       }); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (mounted) context.showErrorDialog(err); |       if (mounted) context.showErrorDialog(err); | ||||||
|       rethrow; |       rethrow; | ||||||
| @@ -98,7 +131,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|   Future<void> _fetchPublishers() async { |   Future<void> _fetchPublishers() async { | ||||||
|     try { |     try { | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final resp = await sn.client.get('/cgi/co/publishers?user=${widget.name}'); |       final resp = | ||||||
|  |           await sn.client.get('/cgi/co/publishers?user=${widget.name}'); | ||||||
|       _publishers = List<SnPublisher>.from( |       _publishers = List<SnPublisher>.from( | ||||||
|         resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], |         resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], | ||||||
|       ); |       ); | ||||||
| @@ -144,7 +178,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|         'related': _account!.name, |         'related': _account!.name, | ||||||
|       }); |       }); | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showSnackbar('userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'}'])); |       context.showSnackbar( | ||||||
|  |           'userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'}'])); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showErrorDialog(err); |       context.showErrorDialog(err); | ||||||
| @@ -160,9 +195,11 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       final rel = context.read<SnRelationshipProvider>(); |       final rel = context.read<SnRelationshipProvider>(); | ||||||
|       await rel.updateRelationship(_account!.id, 1, _accountRelationship?.permNodes ?? {}); |       await rel.updateRelationship( | ||||||
|  |           _account!.id, 1, _accountRelationship?.permNodes ?? {}); | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showSnackbar('userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'}'])); |       context.showSnackbar( | ||||||
|  |           'userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'}'])); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showErrorDialog(err); |       context.showErrorDialog(err); | ||||||
| @@ -188,12 +225,14 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|   double _appBarBlur = 0.0; |   double _appBarBlur = 0.0; | ||||||
|  |  | ||||||
|   late final _appBarWidth = MediaQuery.of(context).size.width; |   late final _appBarWidth = MediaQuery.of(context).size.width; | ||||||
|   late final _appBarHeight = (_appBarWidth * kBannerAspectRatio).roundToDouble(); |   late final _appBarHeight = | ||||||
|  |       (_appBarWidth * kBannerAspectRatio).roundToDouble(); | ||||||
|  |  | ||||||
|   void _updateAppBarBlur() { |   void _updateAppBarBlur() { | ||||||
|     if (_scrollController.offset > _appBarHeight) return; |     if (_scrollController.offset > _appBarHeight) return; | ||||||
|     setState(() { |     setState(() { | ||||||
|       _appBarBlur = (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0); |       _appBarBlur = | ||||||
|  |           (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -205,6 +244,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|  |  | ||||||
|       _fetchStatus(); |       _fetchStatus(); | ||||||
|       _fetchPublishers(); |       _fetchPublishers(); | ||||||
|  |       _getCheckInRecords(); | ||||||
|  |  | ||||||
|       try { |       try { | ||||||
|         final rel = context.read<SnRelationshipProvider>(); |         final rel = context.read<SnRelationshipProvider>(); | ||||||
| @@ -260,7 +300,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                       text: TextSpan(children: [ |                       text: TextSpan(children: [ | ||||||
|                         TextSpan( |                         TextSpan( | ||||||
|                           text: _account!.nick, |                           text: _account!.nick, | ||||||
|                           style: Theme.of(context).textTheme.titleLarge!.copyWith( |                           style: | ||||||
|  |                               Theme.of(context).textTheme.titleLarge!.copyWith( | ||||||
|                                     color: Colors.white, |                                     color: Colors.white, | ||||||
|                                     shadows: labelShadows, |                                     shadows: labelShadows, | ||||||
|                                   ), |                                   ), | ||||||
| @@ -268,7 +309,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                         const TextSpan(text: '\n'), |                         const TextSpan(text: '\n'), | ||||||
|                         TextSpan( |                         TextSpan( | ||||||
|                           text: '@${_account!.name}', |                           text: '@${_account!.name}', | ||||||
|                           style: Theme.of(context).textTheme.bodySmall!.copyWith( |                           style: | ||||||
|  |                               Theme.of(context).textTheme.bodySmall!.copyWith( | ||||||
|                                     color: Colors.white, |                                     color: Colors.white, | ||||||
|                                     shadows: labelShadows, |                                     shadows: labelShadows, | ||||||
|                                   ), |                                   ), | ||||||
| @@ -280,6 +322,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                   ? Stack( |                   ? Stack( | ||||||
|                       fit: StackFit.expand, |                       fit: StackFit.expand, | ||||||
|                       children: [ |                       children: [ | ||||||
|  |                         if (_account!.banner.isNotEmpty) | ||||||
|                           UniversalImage( |                           UniversalImage( | ||||||
|                             sn.getAttachmentUrl(_account!.banner), |                             sn.getAttachmentUrl(_account!.banner), | ||||||
|                             fit: BoxFit.cover, |                             fit: BoxFit.cover, | ||||||
| @@ -287,6 +330,12 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                             width: _appBarWidth, |                             width: _appBarWidth, | ||||||
|                             cacheHeight: imageHeight, |                             cacheHeight: imageHeight, | ||||||
|                             cacheWidth: _appBarWidth, |                             cacheWidth: _appBarWidth, | ||||||
|  |                           ) | ||||||
|  |                         else | ||||||
|  |                           Container( | ||||||
|  |                             color: Theme.of(context) | ||||||
|  |                                 .colorScheme | ||||||
|  |                                 .surfaceContainerHigh, | ||||||
|                           ), |                           ), | ||||||
|                         Positioned( |                         Positioned( | ||||||
|                           top: 0, |                           top: 0, | ||||||
| @@ -339,7 +388,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                       PopupMenuButton( |                       PopupMenuButton( | ||||||
|                         padding: EdgeInsets.zero, |                         padding: EdgeInsets.zero, | ||||||
|                         style: ButtonStyle( |                         style: ButtonStyle( | ||||||
|                           visualDensity: VisualDensity(horizontal: -4, vertical: -4), |                           visualDensity: | ||||||
|  |                               VisualDensity(horizontal: -4, vertical: -4), | ||||||
|                         ), |                         ), | ||||||
|                         itemBuilder: (context) => [ |                         itemBuilder: (context) => [ | ||||||
|                           PopupMenuItem( |                           PopupMenuItem( | ||||||
| @@ -389,8 +439,12 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                       ), |                       ), | ||||||
|                     ], |                     ], | ||||||
|                   ).padding(right: 8), |                   ).padding(right: 8), | ||||||
|                   const Gap(12), |                   if (_account!.profile!.description.isNotEmpty) | ||||||
|                   Text(_account!.description).padding(horizontal: 8), |                     const Gap(12) | ||||||
|  |                   else | ||||||
|  |                     const Gap(8), | ||||||
|  |                   if (_account!.profile!.description.isNotEmpty) | ||||||
|  |                     Text(_account!.profile!.description).padding(horizontal: 8), | ||||||
|                   const Gap(4), |                   const Gap(4), | ||||||
|                   Card( |                   Card( | ||||||
|                     child: Row( |                     child: Row( | ||||||
| @@ -399,7 +453,9 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                           Symbols.circle, |                           Symbols.circle, | ||||||
|                           fill: 1, |                           fill: 1, | ||||||
|                           size: 16, |                           size: 16, | ||||||
|                           color: (_status?.isOnline ?? false) ? Colors.green : Colors.grey, |                           color: (_status?.isOnline ?? false) | ||||||
|  |                               ? Colors.green | ||||||
|  |                               : Colors.grey, | ||||||
|                         ).padding(all: 4), |                         ).padding(all: 4), | ||||||
|                         const Gap(8), |                         const Gap(8), | ||||||
|                         Text( |                         Text( | ||||||
| @@ -409,7 +465,9 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                                   : 'accountStatusOffline'.tr() |                                   : 'accountStatusOffline'.tr() | ||||||
|                               : 'loading'.tr(), |                               : 'loading'.tr(), | ||||||
|                         ), |                         ), | ||||||
|                         if (_status != null && !_status!.isOnline && _status!.lastSeenAt != null) |                         if (_status != null && | ||||||
|  |                             !_status!.isOnline && | ||||||
|  |                             _status!.lastSeenAt != null) | ||||||
|                           Text( |                           Text( | ||||||
|                             'accountStatusLastSeen'.tr(args: [ |                             'accountStatusLastSeen'.tr(args: [ | ||||||
|                               _status!.lastSeenAt != null |                               _status!.lastSeenAt != null | ||||||
| @@ -429,11 +487,15 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                           (ele) => Tooltip( |                           (ele) => Tooltip( | ||||||
|                             richMessage: TextSpan( |                             richMessage: TextSpan( | ||||||
|                               children: [ |                               children: [ | ||||||
|                                 TextSpan(text: kBadgesMeta[ele.type]?.$1.tr() ?? 'unknown'.tr()), |                                 TextSpan( | ||||||
|  |                                   text: kBadgesMeta[ele.type]?.$1.tr() ?? | ||||||
|  |                                       'unknown'.tr(), | ||||||
|  |                                 ), | ||||||
|                                 if (ele.metadata['title'] != null) |                                 if (ele.metadata['title'] != null) | ||||||
|                                   TextSpan( |                                   TextSpan( | ||||||
|                                     text: '\n${ele.metadata['title']}', |                                     text: '\n${ele.metadata['title']}', | ||||||
|                                     style: const TextStyle(fontWeight: FontWeight.bold), |                                     style: const TextStyle( | ||||||
|  |                                         fontWeight: FontWeight.bold), | ||||||
|                                   ), |                                   ), | ||||||
|                                 TextSpan(text: '\n'), |                                 TextSpan(text: '\n'), | ||||||
|                                 TextSpan( |                                 TextSpan( | ||||||
| @@ -442,8 +504,11 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                               ], |                               ], | ||||||
|                             ), |                             ), | ||||||
|                             child: Icon( |                             child: Icon( | ||||||
|                               kBadgesMeta[ele.type]?.$2 ?? Symbols.question_mark, |                               kBadgesMeta[ele.type]?.$2 ?? | ||||||
|                               color: kBadgesMeta[ele.type]?.$3, |                                   Symbols.question_mark, | ||||||
|  |                               color: ele.metadata['color'] != null | ||||||
|  |                                   ? HexColor.fromHex(ele.metadata['color']!) | ||||||
|  |                                   : kBadgesMeta[ele.type]?.$3, | ||||||
|                               fill: 1, |                               fill: 1, | ||||||
|                             ), |                             ), | ||||||
|                           ), |                           ), | ||||||
| @@ -458,7 +523,9 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                         children: [ |                         children: [ | ||||||
|                           const Icon(Symbols.calendar_add_on), |                           const Icon(Symbols.calendar_add_on), | ||||||
|                           const Gap(8), |                           const Gap(8), | ||||||
|                           Text('publisherJoinedAt').tr(args: [DateFormat('y/M/d').format(_account!.createdAt)]), |                           Text('publisherJoinedAt').tr(args: [ | ||||||
|  |                             DateFormat('y/M/d').format(_account!.createdAt) | ||||||
|  |                           ]), | ||||||
|                         ], |                         ], | ||||||
|                       ), |                       ), | ||||||
|                       Row( |                       Row( | ||||||
| @@ -475,6 +542,44 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                           ]), |                           ]), | ||||||
|                         ], |                         ], | ||||||
|                       ), |                       ), | ||||||
|  |                       if (_account!.profile!.gender.isNotEmpty || | ||||||
|  |                           _account!.profile!.pronouns.isNotEmpty) | ||||||
|  |                         Row( | ||||||
|  |                           crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |                           children: [ | ||||||
|  |                             const Icon(Symbols.wc), | ||||||
|  |                             const Gap(8), | ||||||
|  |                             Text( | ||||||
|  |                               _account!.profile!.gender.isNotEmpty | ||||||
|  |                                   ? _account!.profile!.gender | ||||||
|  |                                   : 'unknown'.tr(), | ||||||
|  |                             ), | ||||||
|  |                             Text(' · ').padding(horizontal: 4), | ||||||
|  |                             Text( | ||||||
|  |                               _account!.profile!.pronouns.isNotEmpty | ||||||
|  |                                   ? _account!.profile!.pronouns | ||||||
|  |                                   : 'unknown'.tr(), | ||||||
|  |                             ), | ||||||
|  |                           ], | ||||||
|  |                         ), | ||||||
|  |                       if (_account!.profile!.timeZone.isNotEmpty) | ||||||
|  |                         Row( | ||||||
|  |                           crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |                           children: [ | ||||||
|  |                             const Icon(Symbols.schedule), | ||||||
|  |                             const Gap(8), | ||||||
|  |                             Text(_account!.profile!.timeZone), | ||||||
|  |                           ], | ||||||
|  |                         ), | ||||||
|  |                       if (_account!.profile!.location.isNotEmpty) | ||||||
|  |                         Row( | ||||||
|  |                           crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |                           children: [ | ||||||
|  |                             const Icon(Symbols.location_on), | ||||||
|  |                             const Gap(8), | ||||||
|  |                             Text(_account!.profile!.location), | ||||||
|  |                           ], | ||||||
|  |                         ), | ||||||
|                       Row( |                       Row( | ||||||
|                         crossAxisAlignment: CrossAxisAlignment.center, |                         crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|                         children: [ |                         children: [ | ||||||
| @@ -491,17 +596,24 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                         children: [ |                         children: [ | ||||||
|                           const Icon(Symbols.star), |                           const Icon(Symbols.star), | ||||||
|                           const Gap(8), |                           const Gap(8), | ||||||
|                           Text('Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'), |                           Text( | ||||||
|  |                               'Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'), | ||||||
|                           const Gap(8), |                           const Gap(8), | ||||||
|                           Text(calcLevelUpProgressLevel(_account?.profile?.experience ?? 0)).fontSize(11).opacity(0.5), |                           Text(calcLevelUpProgressLevel( | ||||||
|  |                                   _account?.profile?.experience ?? 0)) | ||||||
|  |                               .fontSize(11) | ||||||
|  |                               .opacity(0.5), | ||||||
|                           const Gap(8), |                           const Gap(8), | ||||||
|                           Container( |                           Container( | ||||||
|                             width: double.infinity, |                             width: double.infinity, | ||||||
|                             constraints: const BoxConstraints(maxWidth: 160), |                             constraints: const BoxConstraints(maxWidth: 160), | ||||||
|                             child: LinearProgressIndicator( |                             child: LinearProgressIndicator( | ||||||
|                               value: calcLevelUpProgress(_account?.profile?.experience ?? 0), |                               value: calcLevelUpProgress( | ||||||
|  |                                   _account?.profile?.experience ?? 0), | ||||||
|                               borderRadius: BorderRadius.circular(8), |                               borderRadius: BorderRadius.circular(8), | ||||||
|                               backgroundColor: Theme.of(context).colorScheme.surfaceContainer, |                               backgroundColor: Theme.of(context) | ||||||
|  |                                   .colorScheme | ||||||
|  |                                   .surfaceContainer, | ||||||
|                             ).alignment(Alignment.centerLeft), |                             ).alignment(Alignment.centerLeft), | ||||||
|                           ), |                           ), | ||||||
|                         ], |                         ], | ||||||
| @@ -511,24 +623,46 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                 ], |                 ], | ||||||
|               ).padding(all: 16), |               ).padding(all: 16), | ||||||
|             ), |             ), | ||||||
|  |           if (_account?.profile?.links.isNotEmpty ?? false) | ||||||
|  |             SliverToBoxAdapter(child: const Divider()), | ||||||
|  |           if (_account?.profile?.links.isNotEmpty ?? false) | ||||||
|  |             SliverToBoxAdapter( | ||||||
|  |               child: Column( | ||||||
|  |                 mainAxisSize: MainAxisSize.min, | ||||||
|  |                 crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                 children: _account!.profile!.links.entries.map((ele) { | ||||||
|  |                   return ListTile( | ||||||
|  |                     leading: const Icon(Symbols.link), | ||||||
|  |                     title: Text(ele.key), | ||||||
|  |                     subtitle: Text(ele.value), | ||||||
|  |                     contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                     onTap: () { | ||||||
|  |                       launchUrlString(ele.value); | ||||||
|  |                     }, | ||||||
|  |                   ); | ||||||
|  |                 }).toList(), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|           SliverToBoxAdapter(child: const Divider()), |           SliverToBoxAdapter(child: const Divider()), | ||||||
|           const SliverGap(12), |           const SliverGap(12), | ||||||
|           SliverToBoxAdapter( |           SliverToBoxAdapter( | ||||||
|             child: FutureBuilder<List<SnCheckInRecord>>( |             child: Builder( | ||||||
|               future: _getCheckInRecords(), |               builder: (context) { | ||||||
|               builder: (context, snapshot) { |                 if (_records == null) return const SizedBox.shrink(); | ||||||
|                 if (!snapshot.hasData) return const SizedBox.shrink(); |                 if (_records!.length <= 1) { | ||||||
|                 if (snapshot.data!.length <= 1) { |  | ||||||
|                   return Text( |                   return Text( | ||||||
|                     'accountCheckInNoRecords', |                     'accountCheckInNoRecords', | ||||||
|                     textAlign: TextAlign.center, |                     textAlign: TextAlign.center, | ||||||
|                   ).tr().fontWeight(FontWeight.bold).center().padding(horizontal: 20, vertical: 8); |                   ) | ||||||
|  |                       .tr() | ||||||
|  |                       .fontWeight(FontWeight.bold) | ||||||
|  |                       .center() | ||||||
|  |                       .padding(horizontal: 20, vertical: 8); | ||||||
|                 } |                 } | ||||||
|                 final records = snapshot.data!; |  | ||||||
|                 return SizedBox( |                 return SizedBox( | ||||||
|                   width: double.infinity, |                   width: double.infinity, | ||||||
|                   height: 240, |                   height: 240, | ||||||
|                   child: CheckInRecordChart(records: records), |                   child: CheckInRecordChart(records: _records!), | ||||||
|                 ).padding( |                 ).padding( | ||||||
|                   right: 24, |                   right: 24, | ||||||
|                   left: 16, |                   left: 16, | ||||||
| @@ -540,11 +674,16 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|           const SliverGap(12), |           const SliverGap(12), | ||||||
|           SliverToBoxAdapter(child: const Divider()), |           SliverToBoxAdapter(child: const Divider()), | ||||||
|           const SliverGap(12), |           const SliverGap(12), | ||||||
|  |           if (_account?.badges.isNotEmpty ?? false) | ||||||
|             SliverToBoxAdapter( |             SliverToBoxAdapter( | ||||||
|               child: Column( |               child: Column( | ||||||
|                 crossAxisAlignment: CrossAxisAlignment.start, |                 crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|                 children: [ |                 children: [ | ||||||
|                 Text('accountBadge').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4), |                   Text('accountBadge') | ||||||
|  |                       .bold() | ||||||
|  |                       .fontSize(17) | ||||||
|  |                       .tr() | ||||||
|  |                       .padding(horizontal: 20, bottom: 4), | ||||||
|                   SizedBox( |                   SizedBox( | ||||||
|                     height: 80, |                     height: 80, | ||||||
|                     width: double.infinity, |                     width: double.infinity, | ||||||
| @@ -558,8 +697,12 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                             child: Card( |                             child: Card( | ||||||
|                               child: ListTile( |                               child: ListTile( | ||||||
|                                 leading: Icon( |                                 leading: Icon( | ||||||
|                                 kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark, |                                   kBadgesMeta[badge.type]?.$2 ?? | ||||||
|                                 color: kBadgesMeta[badge.type]?.$3, |                                       Symbols.question_mark, | ||||||
|  |                                   color: badge.metadata['color'] != null | ||||||
|  |                                       ? HexColor.fromHex( | ||||||
|  |                                           badge.metadata['color']!) | ||||||
|  |                                       : kBadgesMeta[badge.type]?.$3, | ||||||
|                                   fill: 1, |                                   fill: 1, | ||||||
|                                 ), |                                 ), | ||||||
|                                 title: Text( |                                 title: Text( | ||||||
| @@ -568,7 +711,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | |||||||
|                                 subtitle: badge.metadata['title'] != null |                                 subtitle: badge.metadata['title'] != null | ||||||
|                                     ? Text(badge.metadata['title']) |                                     ? Text(badge.metadata['title']) | ||||||
|                                     : Text( |                                     : Text( | ||||||
|                                       DateFormat('y/M/d').format(badge.createdAt), |                                         DateFormat('y/M/d') | ||||||
|  |                                             .format(badge.createdAt), | ||||||
|                                       ), |                                       ), | ||||||
|                               ), |                               ), | ||||||
|                             ), |                             ), | ||||||
| @@ -664,7 +808,8 @@ class CheckInRecordChart extends StatelessWidget { | |||||||
|                   ), |                   ), | ||||||
|                 ) |                 ) | ||||||
|                 .toList(), |                 .toList(), | ||||||
|             getTooltipColor: (_) => Theme.of(context).colorScheme.surfaceContainerHigh, |             getTooltipColor: (_) => | ||||||
|  |                 Theme.of(context).colorScheme.surfaceContainerHigh, | ||||||
|           ), |           ), | ||||||
|         ), |         ), | ||||||
|         titlesData: FlTitlesData( |         titlesData: FlTitlesData( | ||||||
|   | |||||||
| @@ -68,16 +68,19 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> | |||||||
|     setState(() => _isBusy = true); |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       await sn.client.put('/cgi/co/publishers/${widget.name}', data: { |       await sn.client.put( | ||||||
|  |         '/cgi/co/publishers/${widget.name}', | ||||||
|  |         data: { | ||||||
|           'avatar': _avatar, |           'avatar': _avatar, | ||||||
|           'banner': _banner, |           'banner': _banner, | ||||||
|           'nick': _nickController.text, |           'nick': _nickController.text, | ||||||
|           'name': _nameController.text, |           'name': _nameController.text, | ||||||
|           'description': _descriptionController.text, |           'description': _descriptionController.text, | ||||||
|       }); |         }, | ||||||
|  |       ); | ||||||
|       if (mounted) Navigator.pop(context, true); |       if (mounted) Navigator.pop(context, true); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if(mounted) context.showErrorDialog(err); |       if (mounted) context.showErrorDialog(err); | ||||||
|     } finally { |     } finally { | ||||||
|       setState(() => _isBusy = false); |       setState(() => _isBusy = false); | ||||||
|     } |     } | ||||||
| @@ -97,7 +100,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> | |||||||
|     _banner = ua.user!.banner; |     _banner = ua.user!.banner; | ||||||
|     _nickController.text = ua.user!.nick; |     _nickController.text = ua.user!.nick; | ||||||
|     _nameController.text = ua.user!.name; |     _nameController.text = ua.user!.name; | ||||||
|     _descriptionController.text = ua.user!.description; |     _descriptionController.text = ua.user!.profile!.description; | ||||||
|     setState(() {}); |     setState(() {}); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -108,10 +111,15 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> | |||||||
|     if (image == null) return; |     if (image == null) return; | ||||||
|     if (!mounted) return; |     if (!mounted) return; | ||||||
|  |  | ||||||
|  |     final skipCrop = image.path.endsWith('.gif'); | ||||||
|  |  | ||||||
|  |     Uint8List? rawBytes; | ||||||
|  |     if (!skipCrop) { | ||||||
|       final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path)); |       final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path)); | ||||||
|       final aspectRatios = |       final aspectRatios = | ||||||
|           place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)]; |           place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)]; | ||||||
|     final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) |       final result = | ||||||
|  |           (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) | ||||||
|               ? await showCupertinoImageCropper( |               ? await showCupertinoImageCropper( | ||||||
|                 // ignore: use_build_context_synchronously |                 // ignore: use_build_context_synchronously | ||||||
|                 context, |                 context, | ||||||
| @@ -128,11 +136,16 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> | |||||||
|       if (result == null) return; |       if (result == null) return; | ||||||
|  |  | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|     final attach = context.read<SnAttachmentProvider>(); |  | ||||||
|  |  | ||||||
|       setState(() => _isBusy = true); |       setState(() => _isBusy = true); | ||||||
|  |       rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List(); | ||||||
|  |     } else { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       setState(() => _isBusy = true); | ||||||
|  |       rawBytes = await image.readAsBytes(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List(); |     if (!mounted) return; | ||||||
|  |     final attach = context.read<SnAttachmentProvider>(); | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       final attachment = await attach.directUploadOne( |       final attachment = await attach.directUploadOne( | ||||||
| @@ -178,6 +191,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> | |||||||
|     final sn = context.read<SnNetworkProvider>(); |     final sn = context.read<SnNetworkProvider>(); | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|  |       appBar: AppBar(leading: PageBackButton(), title: Text('screenAccountPublisherEdit').tr()), | ||||||
|       body: SingleChildScrollView( |       body: SingleChildScrollView( | ||||||
|         child: Column( |         child: Column( | ||||||
|           children: [ |           children: [ | ||||||
| @@ -195,11 +209,9 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> | |||||||
|                         aspectRatio: 16 / 9, |                         aspectRatio: 16 / 9, | ||||||
|                         child: Container( |                         child: Container( | ||||||
|                           color: Theme.of(context).colorScheme.surfaceContainerHigh, |                           color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||||
|                           child: _banner != null |                           child: | ||||||
|                               ? AutoResizeUniversalImage( |                               _banner != null | ||||||
|                                   sn.getAttachmentUrl(_banner!), |                                   ? AutoResizeUniversalImage(sn.getAttachmentUrl(_banner!), fit: BoxFit.cover) | ||||||
|                                   fit: BoxFit.cover, |  | ||||||
|                                 ) |  | ||||||
|                                   : const SizedBox.shrink(), |                                   : const SizedBox.shrink(), | ||||||
|                         ), |                         ), | ||||||
|                       ), |                       ), | ||||||
| @@ -238,9 +250,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> | |||||||
|             const Gap(4), |             const Gap(4), | ||||||
|             TextField( |             TextField( | ||||||
|               controller: _nickController, |               controller: _nickController, | ||||||
|               decoration: InputDecoration( |               decoration: InputDecoration(labelText: 'fieldNickname'.tr()), | ||||||
|                 labelText: 'fieldNickname'.tr(), |  | ||||||
|               ), |  | ||||||
|               onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |               onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|             ), |             ), | ||||||
|             const Gap(4), |             const Gap(4), | ||||||
| @@ -248,9 +258,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> | |||||||
|               controller: _descriptionController, |               controller: _descriptionController, | ||||||
|               maxLines: null, |               maxLines: null, | ||||||
|               minLines: 3, |               minLines: 3, | ||||||
|               decoration: InputDecoration( |               decoration: InputDecoration(labelText: 'fieldDescription'.tr()), | ||||||
|                 labelText: 'fieldDescription'.tr(), |  | ||||||
|               ), |  | ||||||
|               onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |               onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|             ), |             ), | ||||||
|             const Gap(12), |             const Gap(12), | ||||||
| @@ -271,7 +279,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> | |||||||
|                   icon: const Icon(Symbols.save), |                   icon: const Icon(Symbols.save), | ||||||
|                 ), |                 ), | ||||||
|               ], |               ], | ||||||
|             ) |             ), | ||||||
|           ], |           ], | ||||||
|         ).padding(horizontal: 24, vertical: 12), |         ).padding(horizontal: 24, vertical: 12), | ||||||
|       ), |       ), | ||||||
|   | |||||||
| @@ -109,7 +109,7 @@ class _PublisherNewPersonalState extends State<_PublisherNewPersonal> { | |||||||
|  |  | ||||||
|     _nameController.text = ua.user!.name; |     _nameController.text = ua.user!.name; | ||||||
|     _nickController.text = ua.user!.nick; |     _nickController.text = ua.user!.nick; | ||||||
|     _descriptionController.text = ua.user!.description; |     _descriptionController.text = ua.user!.profile!.description; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   | |||||||
| @@ -45,6 +45,33 @@ class _PublisherScreenState extends State<PublisherScreen> { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   Future<void> _deletePublisher(SnPublisher publisher) async { | ||||||
|  |     final confirm = await context.showConfirmDialog( | ||||||
|  |       'publisherDelete'.tr(args: ['#${publisher.name}']), | ||||||
|  |       'publisherDeleteDescription'.tr(), | ||||||
|  |     ); | ||||||
|  |     if (!confirm) return; | ||||||
|  |  | ||||||
|  |     if (!mounted) return; | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       await context | ||||||
|  |           .read<SnNetworkProvider>() | ||||||
|  |           .client | ||||||
|  |           .delete('/cgi/co/publishers/${publisher.name}'); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showSnackbar('publisherDeleted'.tr(args: ['#${publisher.name}'])); | ||||||
|  |       _publishers.remove(publisher); | ||||||
|  |       _fetchPublishers(); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void initState() { |   void initState() { | ||||||
|     super.initState(); |     super.initState(); | ||||||
| @@ -118,6 +145,18 @@ class _PublisherScreenState extends State<PublisherScreen> { | |||||||
|                               }); |                               }); | ||||||
|                             }, |                             }, | ||||||
|                           ), |                           ), | ||||||
|  |                           PopupMenuItem( | ||||||
|  |                             child: Row( | ||||||
|  |                               children: [ | ||||||
|  |                                 const Icon(Symbols.delete), | ||||||
|  |                                 const Gap(16), | ||||||
|  |                                 Text('delete').tr(), | ||||||
|  |                               ], | ||||||
|  |                             ), | ||||||
|  |                             onTap: () { | ||||||
|  |                               _deletePublisher(publisher); | ||||||
|  |                             }, | ||||||
|  |                           ), | ||||||
|                         ], |                         ], | ||||||
|                       ), |                       ), | ||||||
|                     ); |                     ); | ||||||
|   | |||||||
| @@ -2,6 +2,9 @@ import 'package:dismissible_page/dismissible_page.dart'; | |||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; | import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; | ||||||
|  | import 'package:gap/gap.dart'; | ||||||
|  | import 'package:google_fonts/google_fonts.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| @@ -27,9 +30,23 @@ class _AlbumScreenState extends State<AlbumScreen> { | |||||||
|   bool _isBusy = false; |   bool _isBusy = false; | ||||||
|   int? _totalCount; |   int? _totalCount; | ||||||
|  |  | ||||||
|  |   SnAttachmentBilling? _billing; | ||||||
|  |  | ||||||
|   final List<SnAttachment> _attachments = List.empty(growable: true); |   final List<SnAttachment> _attachments = List.empty(growable: true); | ||||||
|   final List<String> _heroTags = List.empty(growable: true); |   final List<String> _heroTags = List.empty(growable: true); | ||||||
|  |  | ||||||
|  |   Future<void> _fetchBillingStatus() async { | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       final resp = await sn.client.get('/cgi/uc/billing'); | ||||||
|  |       final out = SnAttachmentBilling.fromJson(resp.data); | ||||||
|  |       setState(() => _billing = out); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   Future<void> _fetchAttachments() async { |   Future<void> _fetchAttachments() async { | ||||||
|     setState(() => _isBusy = true); |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
| @@ -62,6 +79,7 @@ class _AlbumScreenState extends State<AlbumScreen> { | |||||||
|   @override |   @override | ||||||
|   void initState() { |   void initState() { | ||||||
|     super.initState(); |     super.initState(); | ||||||
|  |     _fetchBillingStatus(); | ||||||
|     _fetchAttachments(); |     _fetchAttachments(); | ||||||
|     _scrollController.addListener(() { |     _scrollController.addListener(() { | ||||||
|       if (_scrollController.position.atEdge) { |       if (_scrollController.position.atEdge) { | ||||||
| @@ -91,6 +109,48 @@ class _AlbumScreenState extends State<AlbumScreen> { | |||||||
|             leading: AutoAppBarLeading(), |             leading: AutoAppBarLeading(), | ||||||
|             title: Text('screenAlbum').tr(), |             title: Text('screenAlbum').tr(), | ||||||
|           ), |           ), | ||||||
|  |           SliverToBoxAdapter( | ||||||
|  |             child: Card( | ||||||
|  |               child: Row( | ||||||
|  |                 children: [ | ||||||
|  |                   SizedBox( | ||||||
|  |                     width: 80, | ||||||
|  |                     height: 80, | ||||||
|  |                     child: CircularProgressIndicator( | ||||||
|  |                       value: _billing?.includedRatio ?? 0, | ||||||
|  |                       strokeWidth: 8, | ||||||
|  |                       backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||||
|  |                     ), | ||||||
|  |                   ).padding(all: 12), | ||||||
|  |                   const Gap(24), | ||||||
|  |                   Expanded( | ||||||
|  |                     child: Column( | ||||||
|  |                       crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                       children: [ | ||||||
|  |                         Text('attachmentBillingUploaded').tr().bold(), | ||||||
|  |                         Text( | ||||||
|  |                           (_billing?.currentBytes ?? 0).formatBytes(decimals: 4), | ||||||
|  |                           style: GoogleFonts.robotoMono(), | ||||||
|  |                         ), | ||||||
|  |                         Text('attachmentBillingDiscount').tr().bold(), | ||||||
|  |                         Text( | ||||||
|  |                           '${(_billing?.discountFileSize ?? 0).formatBytes(decimals: 2)} · ${((_billing?.includedRatio ?? 0) * 100).toStringAsFixed(2)}%', | ||||||
|  |                           style: GoogleFonts.robotoMono(), | ||||||
|  |                         ), | ||||||
|  |                       ], | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|  |                   Tooltip( | ||||||
|  |                     message: 'attachmentBillingHint'.tr(), | ||||||
|  |                     child: IconButton( | ||||||
|  |                       icon: const Icon(Symbols.info), | ||||||
|  |                       onPressed: () {}, | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|  |                 ], | ||||||
|  |               ).padding(horizontal: 24, vertical: 8), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|           SliverMasonryGrid.extent( |           SliverMasonryGrid.extent( | ||||||
|             childCount: _attachments.length, |             childCount: _attachments.length, | ||||||
|             maxCrossAxisExtent: 320, |             maxCrossAxisExtent: 320, | ||||||
| @@ -123,8 +183,10 @@ class _AlbumScreenState extends State<AlbumScreen> { | |||||||
|           ), |           ), | ||||||
|           if (_isBusy) |           if (_isBusy) | ||||||
|             SliverToBoxAdapter( |             SliverToBoxAdapter( | ||||||
|               child: |               child: Padding( | ||||||
|                   const CircularProgressIndicator().padding(all: 24).center(), |                 padding: const EdgeInsets.all(24), | ||||||
|  |                 child: const CircularProgressIndicator(), | ||||||
|  |               ).center(), | ||||||
|             ), |             ), | ||||||
|         ], |         ], | ||||||
|       ), |       ), | ||||||
|   | |||||||
| @@ -3,23 +3,26 @@ import 'package:flutter/material.dart'; | |||||||
| import 'package:flutter_expandable_fab/flutter_expandable_fab.dart'; | import 'package:flutter_expandable_fab/flutter_expandable_fab.dart'; | ||||||
| import 'package:gap/gap.dart'; | import 'package:gap/gap.dart'; | ||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
|  | import 'package:google_fonts/google_fonts.dart'; | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:responsive_framework/responsive_framework.dart'; | ||||||
| import 'package:surface/providers/channel.dart'; | import 'package:surface/providers/channel.dart'; | ||||||
|  | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/providers/user_directory.dart'; | import 'package:surface/providers/user_directory.dart'; | ||||||
|  | import 'package:surface/providers/userinfo.dart'; | ||||||
|  | import 'package:surface/screens/chat/room.dart'; | ||||||
| import 'package:surface/types/chat.dart'; | import 'package:surface/types/chat.dart'; | ||||||
| import 'package:surface/widgets/account/account_image.dart'; | import 'package:surface/widgets/account/account_image.dart'; | ||||||
| import 'package:surface/widgets/account/account_select.dart'; | import 'package:surface/widgets/account/account_select.dart'; | ||||||
| import 'package:surface/widgets/app_bar_leading.dart'; | import 'package:surface/widgets/app_bar_leading.dart'; | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
| import 'package:surface/widgets/loading_indicator.dart'; | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_background.dart'; | ||||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
| import 'package:surface/widgets/unauthorized_hint.dart'; | import 'package:surface/widgets/unauthorized_hint.dart'; | ||||||
| import 'package:uuid/uuid.dart'; | import 'package:uuid/uuid.dart'; | ||||||
|  |  | ||||||
| import '../providers/sn_network.dart'; |  | ||||||
| import '../providers/userinfo.dart'; |  | ||||||
|  |  | ||||||
| class ChatScreen extends StatefulWidget { | class ChatScreen extends StatefulWidget { | ||||||
|   const ChatScreen({super.key}); |   const ChatScreen({super.key}); | ||||||
|  |  | ||||||
| @@ -34,8 +37,19 @@ class _ChatScreenState extends State<ChatScreen> { | |||||||
|  |  | ||||||
|   List<SnChannel>? _channels; |   List<SnChannel>? _channels; | ||||||
|   Map<int, SnChatMessage>? _lastMessages; |   Map<int, SnChatMessage>? _lastMessages; | ||||||
|  |   Map<int, int>? _unreadCounts; | ||||||
|  |  | ||||||
|   void _refreshChannels() { |   Future<void> _fetchWhatsNew() async { | ||||||
|  |     final sn = context.read<SnNetworkProvider>(); | ||||||
|  |     final resp = await sn.client.get('/cgi/im/whats-new'); | ||||||
|  |     if (resp.data == null) return; | ||||||
|  |     final List<dynamic> out = resp.data; | ||||||
|  |     setState(() { | ||||||
|  |       _unreadCounts = {for (var v in out) v['channel_id']: v['count']}; | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void _refreshChannels({bool noRemote = false}) { | ||||||
|     final ua = context.read<UserProvider>(); |     final ua = context.read<UserProvider>(); | ||||||
|     if (!ua.isAuthorized) { |     if (!ua.isAuthorized) { | ||||||
|       setState(() => _isBusy = false); |       setState(() => _isBusy = false); | ||||||
| @@ -43,12 +57,15 @@ class _ChatScreenState extends State<ChatScreen> { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     final chan = context.read<ChatChannelProvider>(); |     final chan = context.read<ChatChannelProvider>(); | ||||||
|     chan.fetchChannels().listen((channels) async { |     chan.fetchChannels(noRemote: noRemote).listen((channels) async { | ||||||
|       final lastMessages = await chan.getLastMessages(channels); |       final lastMessages = await chan.getLastMessages(channels); | ||||||
|       _lastMessages = {for (final val in lastMessages) val.channelId: val}; |       _lastMessages = {for (final val in lastMessages) val.channelId: val}; | ||||||
|       channels.sort((a, b) { |       channels.sort((a, b) { | ||||||
|         if (_lastMessages!.containsKey(a.id) && _lastMessages!.containsKey(b.id)) { |         if (_lastMessages!.containsKey(a.id) && | ||||||
|           return _lastMessages![b.id]!.createdAt.compareTo(_lastMessages![a.id]!.createdAt); |             _lastMessages!.containsKey(b.id)) { | ||||||
|  |           return _lastMessages![b.id]! | ||||||
|  |               .createdAt | ||||||
|  |               .compareTo(_lastMessages![a.id]!.createdAt); | ||||||
|         } |         } | ||||||
|         if (_lastMessages!.containsKey(a.id)) return -1; |         if (_lastMessages!.containsKey(a.id)) return -1; | ||||||
|         if (_lastMessages!.containsKey(b.id)) return 1; |         if (_lastMessages!.containsKey(b.id)) return 1; | ||||||
| @@ -57,18 +74,20 @@ class _ChatScreenState extends State<ChatScreen> { | |||||||
|  |  | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       final ud = context.read<UserDirectoryProvider>(); |       final ud = context.read<UserDirectoryProvider>(); | ||||||
|  |       final idSet = <int>{}; | ||||||
|       for (final channel in channels) { |       for (final channel in channels) { | ||||||
|         if (channel.type == 1) { |         if (channel.type == 1) { | ||||||
|           await ud.listAccount( |           idSet.addAll( | ||||||
|             channel.members |             channel.members | ||||||
|                     ?.cast<SnChannelMember?>() |                     ?.cast<SnChannelMember?>() | ||||||
|                     .map((ele) => ele?.accountId) |                     .map((ele) => ele?.accountId) | ||||||
|                     .where((ele) => ele != null) |                     .where((ele) => ele != null) | ||||||
|                     .toSet() ?? |                     .cast<int>() ?? | ||||||
|                 {}, |                 [], | ||||||
|           ); |           ); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|  |       if (idSet.isNotEmpty) await ud.listAccount(idSet); | ||||||
|  |  | ||||||
|       if (mounted) setState(() => _channels = channels); |       if (mounted) setState(() => _channels = channels); | ||||||
|     }) |     }) | ||||||
| @@ -86,7 +105,8 @@ class _ChatScreenState extends State<ChatScreen> { | |||||||
|   void _newDirectMessage() async { |   void _newDirectMessage() async { | ||||||
|     final user = await showModalBottomSheet( |     final user = await showModalBottomSheet( | ||||||
|       context: context, |       context: context, | ||||||
|       builder: (context) => AccountSelect(title: 'channelNewDirectMessage'.tr()), |       builder: (context) => | ||||||
|  |           AccountSelect(title: 'channelNewDirectMessage'.tr()), | ||||||
|     ); |     ); | ||||||
|     if (user == null) return; |     if (user == null) return; | ||||||
|     if (!mounted) return; |     if (!mounted) return; | ||||||
| @@ -98,7 +118,8 @@ class _ChatScreenState extends State<ChatScreen> { | |||||||
|       await sn.client.post('/cgi/im/channels/global/dm', data: { |       await sn.client.post('/cgi/im/channels/global/dm', data: { | ||||||
|         'alias': uuid.v4().replaceAll('-', '').substring(0, 12), |         'alias': uuid.v4().replaceAll('-', '').substring(0, 12), | ||||||
|         'name': 'DM', |         'name': 'DM', | ||||||
|         'description': 'A direct message channel between @${ua.user?.name} and @${user.name}', |         'description': | ||||||
|  |             'A direct message channel between @${ua.user?.name} and @${user.name}', | ||||||
|         'related_user': user.id, |         'related_user': user.id, | ||||||
|       }); |       }); | ||||||
|       _fabKey.currentState!.toggle(); |       _fabKey.currentState!.toggle(); | ||||||
| @@ -109,15 +130,39 @@ class _ChatScreenState extends State<ChatScreen> { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   SnChannel? _focusChannel; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void initState() { |   void initState() { | ||||||
|     super.initState(); |     super.initState(); | ||||||
|     _refreshChannels(); |     _refreshChannels(); | ||||||
|  |     _fetchWhatsNew(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void _onTapChannel(SnChannel channel) { | ||||||
|  |     final doExpand = ResponsiveBreakpoints.of(context).largerOrEqualTo(DESKTOP); | ||||||
|  |  | ||||||
|  |     if (doExpand) { | ||||||
|  |       setState(() => _focusChannel = channel); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     GoRouter.of(context).pushNamed( | ||||||
|  |       'chatRoom', | ||||||
|  |       pathParameters: { | ||||||
|  |         'scope': channel.realm?.alias ?? 'global', | ||||||
|  |         'alias': channel.alias, | ||||||
|  |       }, | ||||||
|  |     ).then((value) { | ||||||
|  |       if (mounted) { | ||||||
|  |         _unreadCounts?[channel.id] = 0; | ||||||
|  |         setState(() => _unreadCounts?[channel.id] = 0); | ||||||
|  |         _refreshChannels(noRemote: true); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     final ud = context.read<UserDirectoryProvider>(); |  | ||||||
|     final ua = context.read<UserProvider>(); |     final ua = context.read<UserProvider>(); | ||||||
|  |  | ||||||
|     if (!ua.isAuthorized) { |     if (!ua.isAuthorized) { | ||||||
| @@ -132,7 +177,10 @@ class _ChatScreenState extends State<ChatScreen> { | |||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return AppScaffold( |     final doExpand = ResponsiveBreakpoints.of(context).largerOrEqualTo(DESKTOP); | ||||||
|  |  | ||||||
|  |     final chatList = AppScaffold( | ||||||
|  |       noBackground: doExpand, | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         leading: AutoAppBarLeading(), |         leading: AutoAppBarLeading(), | ||||||
|         title: Text('screenChat').tr(), |         title: Text('screenChat').tr(), | ||||||
| @@ -144,21 +192,26 @@ class _ChatScreenState extends State<ChatScreen> { | |||||||
|         type: ExpandableFabType.up, |         type: ExpandableFabType.up, | ||||||
|         childrenAnimation: ExpandableFabAnimation.none, |         childrenAnimation: ExpandableFabAnimation.none, | ||||||
|         overlayStyle: ExpandableFabOverlayStyle( |         overlayStyle: ExpandableFabOverlayStyle( | ||||||
|           color: Theme.of(context).colorScheme.surface.withAlpha((255 * 0.5).round()), |           color: Theme.of(context) | ||||||
|  |               .colorScheme | ||||||
|  |               .surface | ||||||
|  |               .withAlpha((255 * 0.5).round()), | ||||||
|         ), |         ), | ||||||
|         openButtonBuilder: RotateFloatingActionButtonBuilder( |         openButtonBuilder: RotateFloatingActionButtonBuilder( | ||||||
|           child: const Icon(Symbols.add, size: 28), |           child: const Icon(Symbols.add, size: 28), | ||||||
|           fabSize: ExpandableFabSize.regular, |           fabSize: ExpandableFabSize.regular, | ||||||
|           foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor, |           foregroundColor: | ||||||
|           backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor, |               Theme.of(context).floatingActionButtonTheme.foregroundColor, | ||||||
|           shape: const CircleBorder(), |           backgroundColor: | ||||||
|  |               Theme.of(context).floatingActionButtonTheme.backgroundColor, | ||||||
|         ), |         ), | ||||||
|         closeButtonBuilder: DefaultFloatingActionButtonBuilder( |         closeButtonBuilder: DefaultFloatingActionButtonBuilder( | ||||||
|           child: const Icon(Symbols.close, size: 28), |           child: const Icon(Symbols.close, size: 28), | ||||||
|           fabSize: ExpandableFabSize.regular, |           fabSize: ExpandableFabSize.regular, | ||||||
|           foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor, |           foregroundColor: | ||||||
|           backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor, |               Theme.of(context).floatingActionButtonTheme.foregroundColor, | ||||||
|           shape: const CircleBorder(), |           backgroundColor: | ||||||
|  |               Theme.of(context).floatingActionButtonTheme.backgroundColor, | ||||||
|         ), |         ), | ||||||
|         children: [ |         children: [ | ||||||
|           Row( |           Row( | ||||||
| @@ -200,80 +253,27 @@ class _ChatScreenState extends State<ChatScreen> { | |||||||
|               context: context, |               context: context, | ||||||
|               removeTop: true, |               removeTop: true, | ||||||
|               child: RefreshIndicator( |               child: RefreshIndicator( | ||||||
|                 onRefresh: () => Future.sync(() => _refreshChannels()), |                 onRefresh: () => Future.wait([ | ||||||
|  |                   Future.sync(() => _refreshChannels()), | ||||||
|  |                   _fetchWhatsNew(), | ||||||
|  |                 ]), | ||||||
|                 child: ListView.builder( |                 child: ListView.builder( | ||||||
|                   itemCount: _channels?.length ?? 0, |                   itemCount: _channels?.length ?? 0, | ||||||
|                   itemBuilder: (context, idx) { |                   itemBuilder: (context, idx) { | ||||||
|                     final channel = _channels![idx]; |                     final channel = _channels![idx]; | ||||||
|                     final lastMessage = _lastMessages?[channel.id]; |                     final lastMessage = _lastMessages?[channel.id]; | ||||||
|  |  | ||||||
|                     if (channel.type == 1) { |                     return _ChatChannelEntry( | ||||||
|                       final otherMember = channel.members?.cast<SnChannelMember?>().firstWhere( |                       channel: channel, | ||||||
|                             (ele) => ele?.accountId != ua.user?.id, |                       lastMessage: lastMessage, | ||||||
|                             orElse: () => null, |                       unreadCount: _unreadCounts?[channel.id], | ||||||
|                           ); |  | ||||||
|  |  | ||||||
|                       return ListTile( |  | ||||||
|                         title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name), |  | ||||||
|                         subtitle: lastMessage != null |  | ||||||
|                             ? Text( |  | ||||||
|                                 '${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}', |  | ||||||
|                                 maxLines: 1, |  | ||||||
|                                 overflow: TextOverflow.ellipsis, |  | ||||||
|                               ) |  | ||||||
|                             : Text( |  | ||||||
|                                 'channelDirectMessageDescription'.tr(args: [ |  | ||||||
|                                   '@${ud.getAccountFromCache(otherMember?.accountId)?.name}', |  | ||||||
|                                 ]), |  | ||||||
|                                 maxLines: 1, |  | ||||||
|                                 overflow: TextOverflow.ellipsis, |  | ||||||
|                               ), |  | ||||||
|                         contentPadding: const EdgeInsets.symmetric(horizontal: 16), |  | ||||||
|                         leading: AccountImage( |  | ||||||
|                           content: ud.getAccountFromCache(otherMember?.accountId)?.avatar, |  | ||||||
|                         ), |  | ||||||
|                       onTap: () { |                       onTap: () { | ||||||
|                           GoRouter.of(context).pushNamed( |                         if (doExpand) { | ||||||
|                             'chatRoom', |                           _unreadCounts?[channel.id] = 0; | ||||||
|                             pathParameters: { |                           setState(() => _focusChannel = channel); | ||||||
|                               'scope': channel.realm?.alias ?? 'global', |                           return; | ||||||
|                               'alias': channel.alias, |  | ||||||
|                             }, |  | ||||||
|                           ).then((value) { |  | ||||||
|                             if (mounted) _refreshChannels(); |  | ||||||
|                           }); |  | ||||||
|                         }, |  | ||||||
|                       ); |  | ||||||
|                         } |                         } | ||||||
|  |                         _onTapChannel(channel); | ||||||
|                     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, |  | ||||||
|                           }, |  | ||||||
|                         ).then((value) { |  | ||||||
|                           if (value == true) _refreshChannels(); |  | ||||||
|                         }); |  | ||||||
|                       }, |                       }, | ||||||
|                     ); |                     ); | ||||||
|                   }, |                   }, | ||||||
| @@ -284,5 +284,124 @@ class _ChatScreenState extends State<ChatScreen> { | |||||||
|         ], |         ], | ||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|  |     if (doExpand) { | ||||||
|  |       return AppBackground( | ||||||
|  |         isRoot: true, | ||||||
|  |         child: Row( | ||||||
|  |           children: [ | ||||||
|  |             SizedBox(width: 340, child: chatList), | ||||||
|  |             const VerticalDivider(width: 1), | ||||||
|  |             if (_focusChannel != null) | ||||||
|  |               Expanded( | ||||||
|  |                 child: ChatRoomScreen( | ||||||
|  |                   key: ValueKey(_focusChannel!.id), | ||||||
|  |                   scope: _focusChannel!.realm?.alias ?? 'global', | ||||||
|  |                   alias: _focusChannel!.alias, | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return chatList; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _ChatChannelEntry extends StatelessWidget { | ||||||
|  |   final SnChannel channel; | ||||||
|  |   final int? unreadCount; | ||||||
|  |   final SnChatMessage? lastMessage; | ||||||
|  |   final Function? onTap; | ||||||
|  |   const _ChatChannelEntry({ | ||||||
|  |     required this.channel, | ||||||
|  |     this.unreadCount, | ||||||
|  |     this.lastMessage, | ||||||
|  |     this.onTap, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     final ud = context.read<UserDirectoryProvider>(); | ||||||
|  |     final ua = context.read<UserProvider>(); | ||||||
|  |  | ||||||
|  |     final otherMember = channel.type == 1 | ||||||
|  |         ? channel.members?.cast<SnChannelMember?>().firstWhere( | ||||||
|  |               (ele) => ele?.accountId != ua.user?.id, | ||||||
|  |               orElse: () => null, | ||||||
|  |             ) | ||||||
|  |         : null; | ||||||
|  |  | ||||||
|  |     final title = otherMember != null | ||||||
|  |         ? ud.getFromCache(otherMember.accountId)?.nick ?? channel.name | ||||||
|  |         : channel.name; | ||||||
|  |  | ||||||
|  |     return ListTile( | ||||||
|  |       title: Row( | ||||||
|  |         children: [ | ||||||
|  |           Expanded(child: Text(title)), | ||||||
|  |           const Gap(8), | ||||||
|  |           if (unreadCount != null && unreadCount! > 0) | ||||||
|  |             Badge( | ||||||
|  |               label: Text(unreadCount.toString()), | ||||||
|  |             ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |       subtitle: lastMessage != null | ||||||
|  |           ? Row( | ||||||
|  |               children: [ | ||||||
|  |                 Badge( | ||||||
|  |                   label: Text( | ||||||
|  |                       ud.getFromCache(lastMessage!.sender.accountId)?.nick ?? | ||||||
|  |                           'unknown'.tr()), | ||||||
|  |                   backgroundColor: Theme.of(context).colorScheme.primary, | ||||||
|  |                   textColor: Theme.of(context).colorScheme.onPrimary, | ||||||
|  |                 ), | ||||||
|  |                 const Gap(6), | ||||||
|  |                 Expanded( | ||||||
|  |                   child: Text( | ||||||
|  |                     lastMessage!.body['algorithm'] == 'plain' | ||||||
|  |                         ? lastMessage!.body['text'] ?? | ||||||
|  |                             'messageUnablePreview'.tr() | ||||||
|  |                         : 'messageUnablePreviewEncrypted'.tr(), | ||||||
|  |                     maxLines: 1, | ||||||
|  |                     overflow: TextOverflow.ellipsis, | ||||||
|  |                     style: lastMessage!.body['algorithm'] != 'plain' || | ||||||
|  |                             lastMessage!.body['text'] == null | ||||||
|  |                         ? TextStyle(fontStyle: FontStyle.italic) | ||||||
|  |                         : null, | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |                 const Gap(4), | ||||||
|  |                 Text( | ||||||
|  |                   DateFormat( | ||||||
|  |                     lastMessage!.createdAt.toLocal().day == DateTime.now().day | ||||||
|  |                         ? 'HH:mm' | ||||||
|  |                         : lastMessage!.createdAt.toLocal().year == | ||||||
|  |                                 DateTime.now().year | ||||||
|  |                             ? 'MM/dd' | ||||||
|  |                             : 'yy/MM/dd', | ||||||
|  |                   ).format(lastMessage!.createdAt.toLocal()), | ||||||
|  |                   style: GoogleFonts.robotoMono( | ||||||
|  |                     fontSize: 12, | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |               ], | ||||||
|  |             ) | ||||||
|  |           : Text( | ||||||
|  |               channel.description, | ||||||
|  |               maxLines: 1, | ||||||
|  |               overflow: TextOverflow.ellipsis, | ||||||
|  |             ), | ||||||
|  |       contentPadding: const EdgeInsets.symmetric(horizontal: 16), | ||||||
|  |       leading: AccountImage( | ||||||
|  |         content: otherMember != null | ||||||
|  |             ? ud.getFromCache(otherMember.accountId)?.avatar | ||||||
|  |             : channel.realm?.avatar, | ||||||
|  |         fallbackWidget: const Icon(Symbols.chat, size: 20), | ||||||
|  |       ), | ||||||
|  |       onTap: () => onTap?.call(), | ||||||
|  |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -155,12 +155,16 @@ class _CallRoomScreenState extends State<CallRoomScreen> { | |||||||
|                 text: TextSpan(children: [ |                 text: TextSpan(children: [ | ||||||
|                   TextSpan( |                   TextSpan( | ||||||
|                     text: 'call'.tr(), |                     text: 'call'.tr(), | ||||||
|                     style: Theme.of(context).textTheme.titleLarge!.copyWith(color: Colors.white), |                     style: Theme.of(context).textTheme.titleLarge!.copyWith( | ||||||
|  |                           color: Theme.of(context).appBarTheme.foregroundColor, | ||||||
|  |                         ), | ||||||
|                   ), |                   ), | ||||||
|                   const TextSpan(text: '\n'), |                   const TextSpan(text: '\n'), | ||||||
|                   TextSpan( |                   TextSpan( | ||||||
|                     text: call.lastDuration.toString(), |                     text: call.lastDuration.toString(), | ||||||
|                     style: Theme.of(context).textTheme.bodySmall!.copyWith(color: Colors.white), |                     style: Theme.of(context).textTheme.bodySmall!.copyWith( | ||||||
|  |                           color: Theme.of(context).appBarTheme.foregroundColor, | ||||||
|  |                         ), | ||||||
|                   ), |                   ), | ||||||
|                 ]), |                 ]), | ||||||
|               ), |               ), | ||||||
|   | |||||||
| @@ -10,8 +10,10 @@ import 'package:surface/providers/channel.dart'; | |||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/providers/user_directory.dart'; | import 'package:surface/providers/user_directory.dart'; | ||||||
| import 'package:surface/providers/userinfo.dart'; | import 'package:surface/providers/userinfo.dart'; | ||||||
|  | import 'package:surface/types/account.dart'; | ||||||
| import 'package:surface/types/chat.dart'; | import 'package:surface/types/chat.dart'; | ||||||
| import 'package:surface/widgets/account/account_image.dart'; | import 'package:surface/widgets/account/account_image.dart'; | ||||||
|  | import 'package:surface/widgets/account/account_select.dart'; | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
| import 'package:surface/widgets/loading_indicator.dart'; | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
| @@ -20,6 +22,7 @@ import 'package:very_good_infinite_list/very_good_infinite_list.dart'; | |||||||
| class ChannelDetailScreen extends StatefulWidget { | class ChannelDetailScreen extends StatefulWidget { | ||||||
|   final String scope; |   final String scope; | ||||||
|   final String alias; |   final String alias; | ||||||
|  |  | ||||||
|   const ChannelDetailScreen({ |   const ChannelDetailScreen({ | ||||||
|     super.key, |     super.key, | ||||||
|     required this.scope, |     required this.scope, | ||||||
| @@ -54,11 +57,10 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> { | |||||||
|     setState(() => _isBusy = true); |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final ct = context.read<ChatChannelProvider>(); | ||||||
|       final resp = await sn.client |       final resp = await ct.getChannelProfile(_channel!); | ||||||
|           .get('/cgi/im/channels/${_channel!.keyPath}/members/me'); |       _profile = resp; | ||||||
|       _profile = SnChannelMember.fromJson(resp.data); |       _notifyLevel = resp.notify; | ||||||
|       _notifyLevel = _profile!.notify; |  | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       final ud = context.read<UserDirectoryProvider>(); |       final ud = context.read<UserDirectoryProvider>(); | ||||||
|       await ud.getAccount(_profile!.accountId); |       await ud.getAccount(_profile!.accountId); | ||||||
| @@ -100,10 +102,12 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> { | |||||||
|     if (!mounted) return; |     if (!mounted) return; | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|  |       final ct = context.read<ChatChannelProvider>(); | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       await sn.client.delete( |       await sn.client.delete( | ||||||
|         '/cgi/im/channels/${_channel!.realm?.alias ?? 'global'}/${_channel!.id}/members/me', |         '/cgi/im/channels/${_channel!.realm?.alias ?? 'global'}/${_channel!.alias}/me', | ||||||
|       ); |       ); | ||||||
|  |       await ct.removeLocalChannel(_channel!); | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       Navigator.pop(context, false); |       Navigator.pop(context, false); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
| @@ -127,12 +131,15 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> { | |||||||
|     setState(() => _isUpdatingNotifyLevel = true); |     setState(() => _isUpdatingNotifyLevel = true); | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|  |       final ct = context.read<ChatChannelProvider>(); | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       await sn.client.put( |       final resp = await sn.client.put( | ||||||
|         '/cgi/im/channels/${_channel!.keyPath}/members/me/notify', |         '/cgi/im/channels/${_channel!.keyPath}/members/me/notify', | ||||||
|         data: {'notify_level': value}, |         data: {'notify_level': value}, | ||||||
|       ); |       ); | ||||||
|  |       _profile = SnChannelMember.fromJson(resp.data); | ||||||
|       _notifyLevel = value; |       _notifyLevel = value; | ||||||
|  |       await ct.updateChannelProfile(_profile!); | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showSnackbar('channelNotifyLevelApplied'.tr()); |       context.showSnackbar('channelNotifyLevelApplied'.tr()); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
| @@ -143,6 +150,25 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   Future<void> _addMember(SnAccount related) async { | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       await sn.client.post( | ||||||
|  |         '/cgi/im/channels/${_channel!.keyPath}/members', | ||||||
|  |         data: {'related': related.name}, | ||||||
|  |       ); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showSnackbar('channelMemberAdded'.tr()); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   void _showChannelProfileDetail() { |   void _showChannelProfileDetail() { | ||||||
|     showDialog( |     showDialog( | ||||||
|       context: context, |       context: context, | ||||||
| @@ -166,13 +192,16 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> { | |||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void _showMemberAdd() { |   void _showMemberAdd() async { | ||||||
|     showModalBottomSheet( |     final user = await showModalBottomSheet<SnAccount?>( | ||||||
|       context: context, |       context: context, | ||||||
|       builder: (context) => _NewChannelMemberWidget( |       builder: (context) => AccountSelect( | ||||||
|         channel: _channel!, |         title: 'channelMemberAdd'.tr(), | ||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
|  |     if (!mounted) return; | ||||||
|  |     if (user == null) return; | ||||||
|  |     _addMember(user); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
| @@ -264,15 +293,14 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> { | |||||||
|                   ), |                   ), | ||||||
|                   ListTile( |                   ListTile( | ||||||
|                     leading: AccountImage( |                     leading: AccountImage( | ||||||
|                       content: |                       content: ud.getFromCache(_profile!.accountId)?.avatar, | ||||||
|                           ud.getAccountFromCache(_profile!.accountId)?.avatar, |  | ||||||
|                       radius: 18, |                       radius: 18, | ||||||
|                     ), |                     ), | ||||||
|                     trailing: const Icon(Symbols.chevron_right), |                     trailing: const Icon(Symbols.chevron_right), | ||||||
|                     title: Text('channelEditProfile').tr(), |                     title: Text('channelEditProfile').tr(), | ||||||
|                     subtitle: Text( |                     subtitle: Text( | ||||||
|                       (_profile?.nick?.isEmpty ?? true) |                       (_profile?.nick?.isEmpty ?? true) | ||||||
|                           ? ud.getAccountFromCache(_profile!.accountId)!.nick |                           ? ud.getFromCache(_profile!.accountId)!.nick | ||||||
|                           : _profile!.nick!, |                           : _profile!.nick!, | ||||||
|                     ), |                     ), | ||||||
|                     contentPadding: const EdgeInsets.only(left: 20, right: 20), |                     contentPadding: const EdgeInsets.only(left: 20, right: 20), | ||||||
| @@ -362,6 +390,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> { | |||||||
| class _ChannelProfileDetailDialog extends StatefulWidget { | class _ChannelProfileDetailDialog extends StatefulWidget { | ||||||
|   final SnChannel channel; |   final SnChannel channel; | ||||||
|   final SnChannelMember current; |   final SnChannelMember current; | ||||||
|  |  | ||||||
|   const _ChannelProfileDetailDialog({ |   const _ChannelProfileDetailDialog({ | ||||||
|     required this.channel, |     required this.channel, | ||||||
|     required this.current, |     required this.current, | ||||||
| @@ -382,11 +411,14 @@ class _ChannelProfileDetailDialogState | |||||||
|     setState(() => _isBusy = true); |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|  |       final ct = context.read<ChatChannelProvider>(); | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       await sn.client.put( |       final resp = await sn.client.put( | ||||||
|         '/cgi/im/channels/${widget.channel.keyPath}/members/me', |         '/cgi/im/channels/${widget.channel.keyPath}/members/me', | ||||||
|         data: {'nick': _nickController.text}, |         data: {'nick': _nickController.text}, | ||||||
|       ); |       ); | ||||||
|  |       final out = SnChannelMember.fromJson(resp.data); | ||||||
|  |       await ct.updateChannelProfile(out); | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       Navigator.pop(context, true); |       Navigator.pop(context, true); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
| @@ -444,6 +476,7 @@ class _ChannelProfileDetailDialogState | |||||||
|  |  | ||||||
| class _ChannelMemberListWidget extends StatefulWidget { | class _ChannelMemberListWidget extends StatefulWidget { | ||||||
|   final SnChannel channel; |   final SnChannel channel; | ||||||
|  |  | ||||||
|   const _ChannelMemberListWidget({required this.channel}); |   const _ChannelMemberListWidget({required this.channel}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
| @@ -467,7 +500,7 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> { | |||||||
|           '/cgi/im/channels/${widget.channel.keyPath}/members', |           '/cgi/im/channels/${widget.channel.keyPath}/members', | ||||||
|           queryParameters: { |           queryParameters: { | ||||||
|             'take': 10, |             'take': 10, | ||||||
|             'offset': 0, |             'offset': _members.length, | ||||||
|           }); |           }); | ||||||
|       final out = List<SnChannelMember>.from( |       final out = List<SnChannelMember>.from( | ||||||
|         resp.data['data']?.map((e) => SnChannelMember.fromJson(e)) ?? [], |         resp.data['data']?.map((e) => SnChannelMember.fromJson(e)) ?? [], | ||||||
| @@ -548,11 +581,10 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> { | |||||||
|                 return ListTile( |                 return ListTile( | ||||||
|                   contentPadding: const EdgeInsets.only(right: 24, left: 16), |                   contentPadding: const EdgeInsets.only(right: 24, left: 16), | ||||||
|                   leading: AccountImage( |                   leading: AccountImage( | ||||||
|                     content: ud.getAccountFromCache(member.accountId)?.avatar, |                     content: ud.getFromCache(member.accountId)?.avatar, | ||||||
|                   ), |                   ), | ||||||
|                   title: Text( |                   title: Text( | ||||||
|                     ud.getAccountFromCache(member.accountId)?.name ?? |                     ud.getFromCache(member.accountId)?.name ?? 'unknown'.tr(), | ||||||
|                         'unknown'.tr(), |  | ||||||
|                   ), |                   ), | ||||||
|                   subtitle: Text(member.nick ?? 'unknown'.tr()), |                   subtitle: Text(member.nick ?? 'unknown'.tr()), | ||||||
|                   trailing: SizedBox( |                   trailing: SizedBox( | ||||||
| @@ -578,83 +610,3 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> { | |||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| class _NewChannelMemberWidget extends StatefulWidget { |  | ||||||
|   final SnChannel channel; |  | ||||||
|   const _NewChannelMemberWidget({required this.channel}); |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   State<_NewChannelMemberWidget> createState() => |  | ||||||
|       _NewChannelMemberWidgetState(); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class _NewChannelMemberWidgetState extends State<_NewChannelMemberWidget> { |  | ||||||
|   bool _isBusy = false; |  | ||||||
|  |  | ||||||
|   final TextEditingController _relatedController = TextEditingController(); |  | ||||||
|  |  | ||||||
|   Future<void> _performAction() async { |  | ||||||
|     if (_relatedController.text.isEmpty) return; |  | ||||||
|  |  | ||||||
|     setState(() => _isBusy = true); |  | ||||||
|  |  | ||||||
|     try { |  | ||||||
|       final sn = context.read<SnNetworkProvider>(); |  | ||||||
|       await sn.client.post( |  | ||||||
|         '/cgi/im/channels/${widget.channel.keyPath}/members', |  | ||||||
|         data: { |  | ||||||
|           'related': _relatedController.text, |  | ||||||
|         }, |  | ||||||
|       ); |  | ||||||
|       if (!mounted) return; |  | ||||||
|       Navigator.pop(context, true); |  | ||||||
|       context.showSnackbar('channelMemberAdded'.tr()); |  | ||||||
|     } catch (err) { |  | ||||||
|       if (!mounted) return; |  | ||||||
|       context.showErrorDialog(err); |  | ||||||
|     } finally { |  | ||||||
|       setState(() => _isBusy = false); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   void dispose() { |  | ||||||
|     super.dispose(); |  | ||||||
|     _relatedController.dispose(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   Widget build(BuildContext context) { |  | ||||||
|     return StyledWidget(Column( |  | ||||||
|       crossAxisAlignment: CrossAxisAlignment.start, |  | ||||||
|       children: [ |  | ||||||
|         Text( |  | ||||||
|           'channelMemberAdd', |  | ||||||
|           style: Theme.of(context).textTheme.titleLarge, |  | ||||||
|         ).tr(), |  | ||||||
|         const Gap(12), |  | ||||||
|         TextField( |  | ||||||
|           controller: _relatedController, |  | ||||||
|           readOnly: _isBusy, |  | ||||||
|           autocorrect: false, |  | ||||||
|           autofocus: true, |  | ||||||
|           textCapitalization: TextCapitalization.none, |  | ||||||
|           decoration: InputDecoration( |  | ||||||
|             labelText: 'fieldMemberRelatedName'.tr(), |  | ||||||
|             suffix: SizedBox( |  | ||||||
|               height: 24, |  | ||||||
|               child: IconButton( |  | ||||||
|                 onPressed: _isBusy ? null : () => _performAction(), |  | ||||||
|                 icon: Icon(Symbols.send), |  | ||||||
|                 visualDensity: |  | ||||||
|                     const VisualDensity(horizontal: -4, vertical: -4), |  | ||||||
|                 padding: EdgeInsets.zero, |  | ||||||
|               ), |  | ||||||
|             ), |  | ||||||
|           ), |  | ||||||
|           onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |  | ||||||
|         ) |  | ||||||
|       ], |  | ||||||
|     )).padding(all: 24); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | import 'package:collection/collection.dart'; | ||||||
| import 'package:dio/dio.dart'; | import 'package:dio/dio.dart'; | ||||||
| import 'package:dropdown_button2/dropdown_button2.dart'; | import 'package:dropdown_button2/dropdown_button2.dart'; | ||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
| @@ -17,6 +18,7 @@ import 'package:uuid/uuid.dart'; | |||||||
|  |  | ||||||
| class ChatManageScreen extends StatefulWidget { | class ChatManageScreen extends StatefulWidget { | ||||||
|   final String? editingChannelAlias; |   final String? editingChannelAlias; | ||||||
|  |  | ||||||
|   const ChatManageScreen({super.key, this.editingChannelAlias}); |   const ChatManageScreen({super.key, this.editingChannelAlias}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
| @@ -33,6 +35,11 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|   List<SnRealm>? _realms; |   List<SnRealm>? _realms; | ||||||
|   SnRealm? _belongToRealm; |   SnRealm? _belongToRealm; | ||||||
|  |  | ||||||
|  |   SnChannel? _editingChannel; | ||||||
|  |  | ||||||
|  |   bool _isPublic = false; | ||||||
|  |   bool _isCommunity = false; | ||||||
|  |  | ||||||
|   Future<void> _fetchRealms() async { |   Future<void> _fetchRealms() async { | ||||||
|     setState(() => _isBusy = true); |     setState(() => _isBusy = true); | ||||||
|     try { |     try { | ||||||
| @@ -41,6 +48,9 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|       _realms = List<SnRealm>.from( |       _realms = List<SnRealm>.from( | ||||||
|         resp.data?.map((e) => SnRealm.fromJson(e)) ?? [], |         resp.data?.map((e) => SnRealm.fromJson(e)) ?? [], | ||||||
|       ); |       ); | ||||||
|  |       if (_editingChannel != null) { | ||||||
|  |         _belongToRealm = _realms?.firstWhereOrNull((e) => e.id == _editingChannel!.realmId); | ||||||
|  |       } | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (mounted) context.showErrorDialog(err); |       if (mounted) context.showErrorDialog(err); | ||||||
|     } finally { |     } finally { | ||||||
| @@ -48,8 +58,6 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   SnChannel? _editingChannel; |  | ||||||
|  |  | ||||||
|   Future<void> _fetchChannel() async { |   Future<void> _fetchChannel() async { | ||||||
|     setState(() => _isBusy = true); |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
| @@ -62,6 +70,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|       _aliasController.text = _editingChannel!.alias; |       _aliasController.text = _editingChannel!.alias; | ||||||
|       _nameController.text = _editingChannel!.name; |       _nameController.text = _editingChannel!.name; | ||||||
|       _descriptionController.text = _editingChannel!.description; |       _descriptionController.text = _editingChannel!.description; | ||||||
|  |       _isPublic = _editingChannel!.isPublic; | ||||||
|  |       _isCommunity = _editingChannel!.isCommunity; | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showErrorDialog(err); |       context.showErrorDialog(err); | ||||||
| @@ -83,6 +93,12 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|           : uuid.v4().replaceAll('-', '').substring(0, 12), |           : uuid.v4().replaceAll('-', '').substring(0, 12), | ||||||
|       'name': _nameController.text, |       'name': _nameController.text, | ||||||
|       'description': _descriptionController.text, |       'description': _descriptionController.text, | ||||||
|  |       'is_public': _isPublic, | ||||||
|  |       'is_community': _isCommunity, | ||||||
|  |       if (_editingChannel != null && _belongToRealm == null) | ||||||
|  |         'new_belongs_realm': 'global' | ||||||
|  |       else if (_editingChannel != null && _belongToRealm?.id != _editingChannel?.realm?.id) | ||||||
|  |         'new_belongs_realm': _belongToRealm!.alias, | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
| @@ -124,9 +140,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         title: widget.editingChannelAlias != null |         title: widget.editingChannelAlias != null ? Text('screenChatManage').tr() : Text('screenChatNew').tr(), | ||||||
|             ? Text('screenChatManage').tr() |  | ||||||
|             : Text('screenChatNew').tr(), |  | ||||||
|       ), |       ), | ||||||
|       body: SingleChildScrollView( |       body: SingleChildScrollView( | ||||||
|         child: Column( |         child: Column( | ||||||
| @@ -138,8 +152,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|                 leadingPadding: const EdgeInsets.only(left: 10, right: 20), |                 leadingPadding: const EdgeInsets.only(left: 10, right: 20), | ||||||
|                 dividerColor: Colors.transparent, |                 dividerColor: Colors.transparent, | ||||||
|                 content: Text( |                 content: Text( | ||||||
|                   'channelEditingNotice' |                   'channelEditingNotice'.tr(args: ['#${_editingChannel!.alias}']), | ||||||
|                       .tr(args: ['#${_editingChannel!.alias}']), |  | ||||||
|                 ), |                 ), | ||||||
|                 actions: [ |                 actions: [ | ||||||
|                   TextButton( |                   TextButton( | ||||||
| @@ -179,15 +192,12 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|                                   mainAxisSize: MainAxisSize.min, |                                   mainAxisSize: MainAxisSize.min, | ||||||
|                                   crossAxisAlignment: CrossAxisAlignment.start, |                                   crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|                                   children: [ |                                   children: [ | ||||||
|                                     Text(item.name).textStyle(Theme.of(context) |                                     Text(item.name).textStyle(Theme.of(context).textTheme.bodyMedium!), | ||||||
|                                         .textTheme |  | ||||||
|                                         .bodyMedium!), |  | ||||||
|                                     Text( |                                     Text( | ||||||
|                                       item.description, |                                       item.description, | ||||||
|                                       maxLines: 1, |                                       maxLines: 1, | ||||||
|                                       overflow: TextOverflow.ellipsis, |                                       overflow: TextOverflow.ellipsis, | ||||||
|                                     ).textStyle( |                                     ).textStyle(Theme.of(context).textTheme.bodySmall!), | ||||||
|                                         Theme.of(context).textTheme.bodySmall!), |  | ||||||
|                                   ], |                                   ], | ||||||
|                                 ), |                                 ), | ||||||
|                               ), |                               ), | ||||||
| @@ -203,8 +213,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|                         CircleAvatar( |                         CircleAvatar( | ||||||
|                           radius: 16, |                           radius: 16, | ||||||
|                           backgroundColor: Colors.transparent, |                           backgroundColor: Colors.transparent, | ||||||
|                           foregroundColor: |                           foregroundColor: Theme.of(context).colorScheme.onSurface, | ||||||
|                               Theme.of(context).colorScheme.onSurface, |  | ||||||
|                           child: const Icon(Symbols.clear), |                           child: const Icon(Symbols.clear), | ||||||
|                         ), |                         ), | ||||||
|                         const Gap(12), |                         const Gap(12), | ||||||
| @@ -213,9 +222,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|                             mainAxisSize: MainAxisSize.min, |                             mainAxisSize: MainAxisSize.min, | ||||||
|                             crossAxisAlignment: CrossAxisAlignment.start, |                             crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|                             children: [ |                             children: [ | ||||||
|                               Text('fieldChatBelongToRealmUnset') |                               Text('fieldChatBelongToRealmUnset').tr().textStyle( | ||||||
|                                   .tr() |  | ||||||
|                                   .textStyle( |  | ||||||
|                                     Theme.of(context).textTheme.bodyMedium!, |                                     Theme.of(context).textTheme.bodyMedium!, | ||||||
|                                   ), |                                   ), | ||||||
|                             ], |                             ], | ||||||
| @@ -231,10 +238,10 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|                 }, |                 }, | ||||||
|                 buttonStyleData: const ButtonStyleData( |                 buttonStyleData: const ButtonStyleData( | ||||||
|                   padding: EdgeInsets.only(right: 16), |                   padding: EdgeInsets.only(right: 16), | ||||||
|                   height: 60, |                   height: 48, | ||||||
|                 ), |                 ), | ||||||
|                 menuItemStyleData: const MenuItemStyleData( |                 menuItemStyleData: const MenuItemStyleData( | ||||||
|                   height: 60, |                   height: 48, | ||||||
|                 ), |                 ), | ||||||
|               ), |               ), | ||||||
|             ), |             ), | ||||||
| @@ -250,8 +257,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|                     helperText: 'fieldChatAliasHint'.tr(), |                     helperText: 'fieldChatAliasHint'.tr(), | ||||||
|                     helperMaxLines: 2, |                     helperMaxLines: 2, | ||||||
|                   ), |                   ), | ||||||
|                   onTapOutside: (_) => |                   onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                       FocusManager.instance.primaryFocus?.unfocus(), |  | ||||||
|                 ), |                 ), | ||||||
|                 const Gap(4), |                 const Gap(4), | ||||||
|                 TextField( |                 TextField( | ||||||
| @@ -260,8 +266,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|                     border: const UnderlineInputBorder(), |                     border: const UnderlineInputBorder(), | ||||||
|                     labelText: 'fieldChatName'.tr(), |                     labelText: 'fieldChatName'.tr(), | ||||||
|                   ), |                   ), | ||||||
|                   onTapOutside: (_) => |                   onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                       FocusManager.instance.primaryFocus?.unfocus(), |  | ||||||
|                 ), |                 ), | ||||||
|                 const Gap(4), |                 const Gap(4), | ||||||
|                 TextField( |                 TextField( | ||||||
| @@ -272,8 +277,24 @@ class _ChatManageScreenState extends State<ChatManageScreen> { | |||||||
|                     border: const UnderlineInputBorder(), |                     border: const UnderlineInputBorder(), | ||||||
|                     labelText: 'fieldChatDescription'.tr(), |                     labelText: 'fieldChatDescription'.tr(), | ||||||
|                   ), |                   ), | ||||||
|                   onTapOutside: (_) => |                   onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                       FocusManager.instance.primaryFocus?.unfocus(), |                 ), | ||||||
|  |                 const Gap(12), | ||||||
|  |                 CheckboxListTile( | ||||||
|  |                   value: _isPublic, | ||||||
|  |                   title: Text('channelIsPublic'.tr()), | ||||||
|  |                   subtitle: Text('channelIsPublicDescription'.tr()), | ||||||
|  |                   onChanged: (value) { | ||||||
|  |                     setState(() => _isPublic = value ?? false); | ||||||
|  |                   }, | ||||||
|  |                 ), | ||||||
|  |                 CheckboxListTile( | ||||||
|  |                   value: _isCommunity, | ||||||
|  |                   title: Text('channelIsCommunity'.tr()), | ||||||
|  |                   subtitle: Text('channelIsCommunityDescription'.tr()), | ||||||
|  |                   onChanged: (value) { | ||||||
|  |                     setState(() => _isCommunity = value ?? false); | ||||||
|  |                   }, | ||||||
|                 ), |                 ), | ||||||
|                 const Gap(12), |                 const Gap(12), | ||||||
|                 Row( |                 Row( | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| import 'dart:async'; | import 'dart:async'; | ||||||
|  | import 'dart:convert'; | ||||||
| import 'dart:developer'; | import 'dart:developer'; | ||||||
|  |  | ||||||
| import 'package:dio/dio.dart'; | import 'package:dio/dio.dart'; | ||||||
| @@ -13,11 +14,13 @@ import 'package:surface/controllers/chat_message_controller.dart'; | |||||||
| import 'package:surface/controllers/post_write_controller.dart'; | import 'package:surface/controllers/post_write_controller.dart'; | ||||||
| import 'package:surface/providers/channel.dart'; | import 'package:surface/providers/channel.dart'; | ||||||
| import 'package:surface/providers/chat_call.dart'; | import 'package:surface/providers/chat_call.dart'; | ||||||
|  | import 'package:surface/providers/notification.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/providers/user_directory.dart'; | import 'package:surface/providers/user_directory.dart'; | ||||||
| import 'package:surface/providers/userinfo.dart'; | import 'package:surface/providers/userinfo.dart'; | ||||||
| import 'package:surface/providers/websocket.dart'; | import 'package:surface/providers/websocket.dart'; | ||||||
| import 'package:surface/types/chat.dart'; | import 'package:surface/types/chat.dart'; | ||||||
|  | import 'package:surface/types/websocket.dart'; | ||||||
| import 'package:surface/widgets/chat/call/call_prejoin.dart'; | import 'package:surface/widgets/chat/call/call_prejoin.dart'; | ||||||
| import 'package:surface/widgets/chat/chat_message.dart'; | import 'package:surface/widgets/chat/chat_message.dart'; | ||||||
| import 'package:surface/widgets/chat/chat_message_input.dart'; | import 'package:surface/widgets/chat/chat_message_input.dart'; | ||||||
| @@ -39,7 +42,8 @@ class ChatRoomScreen extends StatefulWidget { | |||||||
|   final String alias; |   final String alias; | ||||||
|   final ChatRoomScreenExtra? extra; |   final ChatRoomScreenExtra? extra; | ||||||
|  |  | ||||||
|   const ChatRoomScreen({super.key, required this.scope, required this.alias, this.extra}); |   const ChatRoomScreen( | ||||||
|  |       {super.key, required this.scope, required this.alias, this.extra}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   State<ChatRoomScreen> createState() => _ChatRoomScreenState(); |   State<ChatRoomScreen> createState() => _ChatRoomScreenState(); | ||||||
| @@ -56,8 +60,14 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |||||||
|   final GlobalKey<ChatMessageInputState> _inputGlobalKey = GlobalKey(); |   final GlobalKey<ChatMessageInputState> _inputGlobalKey = GlobalKey(); | ||||||
|   late final ChatMessageController _messageController; |   late final ChatMessageController _messageController; | ||||||
|  |  | ||||||
|  |   late final NotificationProvider _nty = context.read<NotificationProvider>(); | ||||||
|  |   late final WebSocketProvider _ws = context.read<WebSocketProvider>(); | ||||||
|  |  | ||||||
|  |   bool _isEncrypted = false; | ||||||
|  |  | ||||||
|   StreamSubscription? _wsSubscription; |   StreamSubscription? _wsSubscription; | ||||||
|  |  | ||||||
|  |   // TODO fetch user identity and ask them to join the channel or not | ||||||
|   Future<void> _fetchChannel() async { |   Future<void> _fetchChannel() async { | ||||||
|     setState(() => _isBusy = true); |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
| @@ -82,6 +92,20 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |||||||
|               orElse: () => null, |               orElse: () => null, | ||||||
|             ); |             ); | ||||||
|       } |       } | ||||||
|  |  | ||||||
|  |       if (!mounted) return; | ||||||
|  |       _nty.skippableNotifyChannel = _channel!.id; | ||||||
|  |       final ws = context.read<WebSocketProvider>(); | ||||||
|  |       if (_channel != null) { | ||||||
|  |         ws.conn?.sink.add( | ||||||
|  |           jsonEncode(WebSocketPackage( | ||||||
|  |               method: 'events.subscribe', | ||||||
|  |               endpoint: 'im', | ||||||
|  |               payload: { | ||||||
|  |                 'channel_id': _channel!.id, | ||||||
|  |               })), | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showErrorDialog(err); |       context.showErrorDialog(err); | ||||||
| @@ -191,10 +215,12 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |||||||
|         WidgetsBinding.instance.addPostFrameCallback((_) { |         WidgetsBinding.instance.addPostFrameCallback((_) { | ||||||
|           log('[ChatInput] Setting initial text and attachments...'); |           log('[ChatInput] Setting initial text and attachments...'); | ||||||
|           if (widget.extra!.initialText != null) { |           if (widget.extra!.initialText != null) { | ||||||
|             _inputGlobalKey.currentState?.setInitialText(widget.extra!.initialText!); |             _inputGlobalKey.currentState | ||||||
|  |                 ?.setInitialText(widget.extra!.initialText!); | ||||||
|           } |           } | ||||||
|           if (widget.extra!.initialAttachments != null) { |           if (widget.extra!.initialAttachments != null) { | ||||||
|             _inputGlobalKey.currentState?.setInitialAttachments(widget.extra!.initialAttachments!); |             _inputGlobalKey.currentState | ||||||
|  |                 ?.setInitialAttachments(widget.extra!.initialAttachments!); | ||||||
|           } |           } | ||||||
|         }); |         }); | ||||||
|       } |       } | ||||||
| @@ -205,8 +231,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |||||||
|       ]); |       ]); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     final ws = context.read<WebSocketProvider>(); |     _wsSubscription = _ws.pk.stream.listen((event) { | ||||||
|     _wsSubscription = ws.stream.stream.listen((event) { |  | ||||||
|       switch (event.method) { |       switch (event.method) { | ||||||
|         case 'calls.new': |         case 'calls.new': | ||||||
|           final payload = SnChatCall.fromJson(event.payload!); |           final payload = SnChatCall.fromJson(event.payload!); | ||||||
| @@ -228,6 +253,18 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |||||||
|   void dispose() { |   void dispose() { | ||||||
|     _wsSubscription?.cancel(); |     _wsSubscription?.cancel(); | ||||||
|     _messageController.dispose(); |     _messageController.dispose(); | ||||||
|  |     _nty.skippableNotifyChannel = null; | ||||||
|  |     if (_channel != null) { | ||||||
|  |       _ws.conn?.sink.add( | ||||||
|  |         jsonEncode(WebSocketPackage( | ||||||
|  |           method: 'events.unsubscribe', | ||||||
|  |           endpoint: 'im', | ||||||
|  |           payload: { | ||||||
|  |             'channel_id': _channel!.id, | ||||||
|  |           }, | ||||||
|  |         )), | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|     super.dispose(); |     super.dispose(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -240,12 +277,23 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         title: Text( |         title: Text( | ||||||
|           _channel?.type == 1 |           _channel?.type == 1 | ||||||
|               ? ud.getAccountFromCache(_otherMember?.accountId)?.nick ?? _channel!.name |               ? ud.getFromCache(_otherMember?.accountId)?.nick ?? _channel!.name | ||||||
|               : _channel?.name ?? 'loading'.tr(), |               : _channel?.name ?? 'loading'.tr(), | ||||||
|         ), |         ), | ||||||
|         actions: [ |         actions: [ | ||||||
|           IconButton( |           IconButton( | ||||||
|             icon: _ongoingCall == null ? const Icon(Symbols.call) : const Icon(Symbols.call_end), |             onPressed: () { | ||||||
|  |               setState(() => _isEncrypted = !_isEncrypted); | ||||||
|  |               _inputGlobalKey.currentState?.setEncrypted(_isEncrypted); | ||||||
|  |             }, | ||||||
|  |             icon: _isEncrypted | ||||||
|  |                 ? const Icon(Symbols.lock) | ||||||
|  |                 : const Icon(Symbols.no_encryption), | ||||||
|  |           ), | ||||||
|  |           IconButton( | ||||||
|  |             icon: _ongoingCall == null | ||||||
|  |                 ? const Icon(Symbols.call) | ||||||
|  |                 : const Icon(Symbols.call_end), | ||||||
|             onPressed: _isCalling |             onPressed: _isCalling | ||||||
|                 ? null |                 ? null | ||||||
|                 : _ongoingCall == null |                 : _ongoingCall == null | ||||||
| @@ -275,7 +323,9 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |||||||
|         builder: (context, _) { |         builder: (context, _) { | ||||||
|           return Column( |           return Column( | ||||||
|             children: [ |             children: [ | ||||||
|               LoadingIndicator(isActive: _isBusy), |               LoadingIndicator( | ||||||
|  |                 isActive: _isBusy || _messageController.isAggressiveLoading, | ||||||
|  |               ), | ||||||
|               SingleChildScrollView( |               SingleChildScrollView( | ||||||
|                 physics: const NeverScrollableScrollPhysics(), |                 physics: const NeverScrollableScrollPhysics(), | ||||||
|                 child: MaterialBanner( |                 child: MaterialBanner( | ||||||
| @@ -295,14 +345,14 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |||||||
|                       ) |                       ) | ||||||
|                   ], |                   ], | ||||||
|                 ), |                 ), | ||||||
|               ) |               ).height(_ongoingCall != null ? 54 : 0, animate: true).animate( | ||||||
|                   .height(_ongoingCall != null ? 54 : 0, animate: true) |                   const Duration(milliseconds: 300), | ||||||
|                   .animate(const Duration(milliseconds: 300), Curves.fastLinearToSlowEaseIn), |                   Curves.fastLinearToSlowEaseIn), | ||||||
|               if (_messageController.isPending) |               if (_messageController.isPending) | ||||||
|                 Expanded( |                 Expanded( | ||||||
|                   child: const CircularProgressIndicator().center(), |                   child: const CircularProgressIndicator().center(), | ||||||
|                 ), |                 ) | ||||||
|               if (!_messageController.isPending) |               else | ||||||
|                 Expanded( |                 Expanded( | ||||||
|                   child: InfiniteList( |                   child: InfiniteList( | ||||||
|                     reverse: true, |                     reverse: true, | ||||||
| @@ -315,6 +365,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |||||||
|                     }, |                     }, | ||||||
|                     itemBuilder: (context, idx) { |                     itemBuilder: (context, idx) { | ||||||
|                       final message = _messageController.messages[idx]; |                       final message = _messageController.messages[idx]; | ||||||
|  |                       _messageController.readEvent(message.id); | ||||||
|  |  | ||||||
|                       bool canMerge = false, canMergePrevious = false; |                       bool canMerge = false, canMergePrevious = false; | ||||||
|                       if (idx > 0) { |                       if (idx > 0) { | ||||||
| @@ -336,7 +387,8 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |||||||
|                           data: message, |                           data: message, | ||||||
|                           isMerged: canMerge, |                           isMerged: canMerge, | ||||||
|                           hasMerged: canMergePrevious, |                           hasMerged: canMergePrevious, | ||||||
|                           isPending: _messageController.unconfirmedMessages.contains(message.uuid), |                           isPending: _messageController.unconfirmedMessages | ||||||
|  |                               .contains(message.uuid), | ||||||
|                           onReply: (value) { |                           onReply: (value) { | ||||||
|                             _inputGlobalKey.currentState?.setReply(value); |                             _inputGlobalKey.currentState?.setReply(value); | ||||||
|                           }, |                           }, | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import 'package:animations/animations.dart'; | import 'package:dropdown_button2/dropdown_button2.dart'; | ||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter_expandable_fab/flutter_expandable_fab.dart'; | import 'package:flutter_expandable_fab/flutter_expandable_fab.dart'; | ||||||
| @@ -7,11 +7,12 @@ import 'package:go_router/go_router.dart'; | |||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
| import 'package:surface/providers/config.dart'; |  | ||||||
| import 'package:surface/providers/post.dart'; | import 'package:surface/providers/post.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/screens/post/post_detail.dart'; | import 'package:surface/providers/sn_realm.dart'; | ||||||
| import 'package:surface/types/post.dart'; | import 'package:surface/types/post.dart'; | ||||||
|  | import 'package:surface/types/realm.dart'; | ||||||
|  | import 'package:surface/widgets/account/account_image.dart'; | ||||||
| import 'package:surface/widgets/app_bar_leading.dart'; | import 'package:surface/widgets/app_bar_leading.dart'; | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
| @@ -38,26 +39,303 @@ class ExploreScreen extends StatefulWidget { | |||||||
|   State<ExploreScreen> createState() => _ExploreScreenState(); |   State<ExploreScreen> createState() => _ExploreScreenState(); | ||||||
| } | } | ||||||
|  |  | ||||||
| class _ExploreScreenState extends State<ExploreScreen> { | // You know what? I'm not going to make this a global variable. | ||||||
|  | // Cuz the global key make the selected category not update to child widget when the category is changed. | ||||||
|  | SnPostCategory? _selectedCategory; | ||||||
|  |  | ||||||
|  | class _ExploreScreenState extends State<ExploreScreen> | ||||||
|  |     with SingleTickerProviderStateMixin { | ||||||
|  |   late final TabController _tabController = | ||||||
|  |       TabController(length: 4, vsync: this); | ||||||
|  |  | ||||||
|   final _fabKey = GlobalKey<ExpandableFabState>(); |   final _fabKey = GlobalKey<ExpandableFabState>(); | ||||||
|  |   final _listKeys = List.generate(4, (_) => GlobalKey<_PostListWidgetState>()); | ||||||
|  |  | ||||||
|   bool _isBusy = true; |  | ||||||
|  |  | ||||||
|   final List<SnPost> _posts = List.empty(growable: true); |  | ||||||
|   final List<SnPostCategory> _categories = List.empty(growable: true); |   final List<SnPostCategory> _categories = List.empty(growable: true); | ||||||
|   int? _postCount; |  | ||||||
|  |  | ||||||
|   String? _selectedCategory; |  | ||||||
|  |  | ||||||
|   Future<void> _fetchCategories() async { |   Future<void> _fetchCategories() async { | ||||||
|     _categories.clear(); |     _categories.clear(); | ||||||
|     try { |     try { | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final resp = await sn.client.get('/cgi/co/categories?take=100'); |       final resp = await sn.client.get('/cgi/co/categories?take=100'); | ||||||
|       _categories.addAll(resp.data.map((e) => SnPostCategory.fromJson(e)).cast<SnPostCategory>() ?? []); |       setState(() { | ||||||
|  |         _categories.addAll(resp.data | ||||||
|  |                 .map((e) => SnPostCategory.fromJson(e)) | ||||||
|  |                 .cast<SnPostCategory>() ?? | ||||||
|  |             []); | ||||||
|  |       }); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (mounted) context.showErrorDialog(err); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void _clearFilter() { | ||||||
|  |     _selectedCategory = null; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     _fetchCategories(); | ||||||
|  |     super.initState(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void dispose() { | ||||||
|  |     _tabController.dispose(); | ||||||
|  |     super.dispose(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> refreshPosts() async { | ||||||
|  |     await _listKeys[_tabController.index].currentState?.refreshPosts(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AppScaffold( | ||||||
|  |       floatingActionButtonLocation: ExpandableFab.location, | ||||||
|  |       floatingActionButton: ExpandableFab( | ||||||
|  |         key: _fabKey, | ||||||
|  |         distance: 75, | ||||||
|  |         type: ExpandableFabType.up, | ||||||
|  |         childrenAnimation: ExpandableFabAnimation.none, | ||||||
|  |         overlayStyle: ExpandableFabOverlayStyle( | ||||||
|  |           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, | ||||||
|  |         ), | ||||||
|  |         closeButtonBuilder: DefaultFloatingActionButtonBuilder( | ||||||
|  |           child: const Icon(Symbols.close, size: 28), | ||||||
|  |           fabSize: ExpandableFabSize.regular, | ||||||
|  |           foregroundColor: | ||||||
|  |               Theme.of(context).floatingActionButtonTheme.foregroundColor, | ||||||
|  |           backgroundColor: | ||||||
|  |               Theme.of(context).floatingActionButtonTheme.backgroundColor, | ||||||
|  |         ), | ||||||
|  |         children: [ | ||||||
|  |           Row( | ||||||
|  |             children: [ | ||||||
|  |               Text('writePost').tr(), | ||||||
|  |               const Gap(20), | ||||||
|  |               FloatingActionButton( | ||||||
|  |                 heroTag: null, | ||||||
|  |                 tooltip: 'writePost'.tr(), | ||||||
|  |                 onPressed: () { | ||||||
|  |                   GoRouter.of(context).pushNamed('postEditor').then((value) { | ||||||
|  |                     if (value == true) { | ||||||
|  |                       refreshPosts(); | ||||||
|  |                     } | ||||||
|  |                   }); | ||||||
|  |                   _fabKey.currentState!.toggle(); | ||||||
|  |                 }, | ||||||
|  |                 child: const Icon(Symbols.edit), | ||||||
|  |               ), | ||||||
|  |             ], | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |       body: NestedScrollView( | ||||||
|  |         headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { | ||||||
|  |           return [ | ||||||
|  |             SliverOverlapAbsorber( | ||||||
|  |               handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), | ||||||
|  |               sliver: SliverAppBar( | ||||||
|  |                 leading: AutoAppBarLeading(), | ||||||
|  |                 title: Text('screenExplore').tr(), | ||||||
|  |                 floating: true, | ||||||
|  |                 snap: true, | ||||||
|  |                 actions: [ | ||||||
|  |                   IconButton( | ||||||
|  |                     icon: const Icon(Symbols.category), | ||||||
|  |                     onPressed: () { | ||||||
|  |                       showModalBottomSheet( | ||||||
|  |                         context: context, | ||||||
|  |                         builder: (context) => _PostCategoryPickerPopup( | ||||||
|  |                           categories: _categories, | ||||||
|  |                           selected: _selectedCategory, | ||||||
|  |                         ), | ||||||
|  |                       ).then((value) { | ||||||
|  |                         if (value != null && context.mounted) { | ||||||
|  |                           _selectedCategory = value == false ? null : value; | ||||||
|  |                           refreshPosts(); | ||||||
|  |                         } | ||||||
|  |                       }); | ||||||
|  |                     }, | ||||||
|  |                   ), | ||||||
|  |                   IconButton( | ||||||
|  |                     icon: const Icon(Symbols.search), | ||||||
|  |                     onPressed: () { | ||||||
|  |                       GoRouter.of(context).pushNamed('postSearch'); | ||||||
|  |                     }, | ||||||
|  |                   ), | ||||||
|  |                   const Gap(8), | ||||||
|  |                 ], | ||||||
|  |                 bottom: TabBar( | ||||||
|  |                   controller: _tabController, | ||||||
|  |                   tabs: [ | ||||||
|  |                     Tab( | ||||||
|  |                       child: Row( | ||||||
|  |                         mainAxisSize: MainAxisSize.min, | ||||||
|  |                         crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |                         children: [ | ||||||
|  |                           Icon(Symbols.globe, | ||||||
|  |                               size: 20, | ||||||
|  |                               color: Theme.of(context) | ||||||
|  |                                   .appBarTheme | ||||||
|  |                                   .foregroundColor), | ||||||
|  |                           const Gap(8), | ||||||
|  |                           Flexible( | ||||||
|  |                             child: Text( | ||||||
|  |                               'postChannelGlobal', | ||||||
|  |                               maxLines: 1, | ||||||
|  |                             ).tr().textColor( | ||||||
|  |                                 Theme.of(context).appBarTheme.foregroundColor), | ||||||
|  |                           ), | ||||||
|  |                         ], | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                     Tab( | ||||||
|  |                       child: Row( | ||||||
|  |                         mainAxisSize: MainAxisSize.min, | ||||||
|  |                         crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |                         children: [ | ||||||
|  |                           Icon(Symbols.group, | ||||||
|  |                               size: 20, | ||||||
|  |                               color: Theme.of(context) | ||||||
|  |                                   .appBarTheme | ||||||
|  |                                   .foregroundColor), | ||||||
|  |                           const Gap(8), | ||||||
|  |                           Flexible( | ||||||
|  |                             child: Text( | ||||||
|  |                               'postChannelFriends', | ||||||
|  |                               maxLines: 1, | ||||||
|  |                               textAlign: TextAlign.center, | ||||||
|  |                             ).tr().textColor( | ||||||
|  |                                 Theme.of(context).appBarTheme.foregroundColor), | ||||||
|  |                           ), | ||||||
|  |                         ], | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                     Tab( | ||||||
|  |                       child: Row( | ||||||
|  |                         mainAxisSize: MainAxisSize.min, | ||||||
|  |                         crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |                         children: [ | ||||||
|  |                           Icon(Symbols.subscriptions, | ||||||
|  |                               size: 20, | ||||||
|  |                               color: Theme.of(context) | ||||||
|  |                                   .appBarTheme | ||||||
|  |                                   .foregroundColor), | ||||||
|  |                           const Gap(8), | ||||||
|  |                           Flexible( | ||||||
|  |                             child: Text( | ||||||
|  |                               'postChannelFollowing', | ||||||
|  |                               maxLines: 1, | ||||||
|  |                             ).tr().textColor( | ||||||
|  |                                 Theme.of(context).appBarTheme.foregroundColor), | ||||||
|  |                           ), | ||||||
|  |                         ], | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                     Tab( | ||||||
|  |                       child: Row( | ||||||
|  |                         mainAxisSize: MainAxisSize.min, | ||||||
|  |                         crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |                         children: [ | ||||||
|  |                           Icon(Symbols.workspaces, | ||||||
|  |                               size: 20, | ||||||
|  |                               color: Theme.of(context) | ||||||
|  |                                   .appBarTheme | ||||||
|  |                                   .foregroundColor), | ||||||
|  |                           const Gap(8), | ||||||
|  |                           Flexible( | ||||||
|  |                             child: Text( | ||||||
|  |                               'postChannelRealm', | ||||||
|  |                               maxLines: 1, | ||||||
|  |                             ).tr().textColor( | ||||||
|  |                                 Theme.of(context).appBarTheme.foregroundColor), | ||||||
|  |                           ), | ||||||
|  |                         ], | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                   ], | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ]; | ||||||
|  |         }, | ||||||
|  |         body: TabBarView( | ||||||
|  |           controller: _tabController, | ||||||
|  |           children: [ | ||||||
|  |             _PostListWidget( | ||||||
|  |               key: _listKeys[0], | ||||||
|  |               onClearFilter: _clearFilter, | ||||||
|  |             ), | ||||||
|  |             _PostListWidget( | ||||||
|  |               key: _listKeys[1], | ||||||
|  |               channel: 'friends', | ||||||
|  |               onClearFilter: _clearFilter, | ||||||
|  |             ), | ||||||
|  |             _PostListWidget( | ||||||
|  |               key: _listKeys[2], | ||||||
|  |               channel: 'following', | ||||||
|  |               onClearFilter: _clearFilter, | ||||||
|  |             ), | ||||||
|  |             _PostListWidget( | ||||||
|  |               key: _listKeys[3], | ||||||
|  |               withRealm: true, | ||||||
|  |               onClearFilter: _clearFilter, | ||||||
|  |             ), | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _PostListWidget extends StatefulWidget { | ||||||
|  |   final String? channel; | ||||||
|  |   final bool withRealm; | ||||||
|  |   final Function onClearFilter; | ||||||
|  |  | ||||||
|  |   const _PostListWidget( | ||||||
|  |       {super.key, | ||||||
|  |       this.channel, | ||||||
|  |       this.withRealm = false, | ||||||
|  |       required this.onClearFilter}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<_PostListWidget> createState() => _PostListWidgetState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _PostListWidgetState extends State<_PostListWidget> { | ||||||
|  |   bool _isBusy = false; | ||||||
|  |  | ||||||
|  |   final List<SnPost> _posts = List.empty(growable: true); | ||||||
|  |   final List<SnRealm> _realms = List.empty(growable: true); | ||||||
|  |   SnRealm? _selectedRealm; | ||||||
|  |   int? _postCount; | ||||||
|  |  | ||||||
|  |   Future<void> _fetchRealms() async { | ||||||
|  |     try { | ||||||
|  |       final rels = context.read<SnRealmProvider>(); | ||||||
|  |       final out = await rels.listAvailableRealms(); | ||||||
|  |       setState(() { | ||||||
|  |         _realms.addAll(out); | ||||||
|  |         _selectedRealm = out.firstOrNull; | ||||||
|  |       }); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showErrorDialog(err); |       context.showErrorDialog(err); | ||||||
|  |       rethrow; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -70,7 +348,9 @@ class _ExploreScreenState extends State<ExploreScreen> { | |||||||
|     final result = await pt.listPosts( |     final result = await pt.listPosts( | ||||||
|       take: 10, |       take: 10, | ||||||
|       offset: _posts.length, |       offset: _posts.length, | ||||||
|       categories: _selectedCategory != null ? [_selectedCategory!] : null, |       categories: _selectedCategory != null ? [_selectedCategory!.alias] : null, | ||||||
|  |       channel: widget.channel, | ||||||
|  |       realm: _selectedRealm?.alias, | ||||||
|     ); |     ); | ||||||
|     final out = result.$1; |     final out = result.$1; | ||||||
|  |  | ||||||
| @@ -82,7 +362,7 @@ class _ExploreScreenState extends State<ExploreScreen> { | |||||||
|     if (mounted) setState(() => _isBusy = false); |     if (mounted) setState(() => _isBusy = false); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> _refreshPosts() { |   Future<void> refreshPosts() { | ||||||
|     _postCount = null; |     _postCount = null; | ||||||
|     _posts.clear(); |     _posts.clear(); | ||||||
|     return _fetchPosts(); |     return _fetchPosts(); | ||||||
| @@ -91,176 +371,190 @@ class _ExploreScreenState extends State<ExploreScreen> { | |||||||
|   @override |   @override | ||||||
|   void initState() { |   void initState() { | ||||||
|     super.initState(); |     super.initState(); | ||||||
|  |     if (widget.withRealm) { | ||||||
|  |       _fetchRealms().then((_) { | ||||||
|         _fetchPosts(); |         _fetchPosts(); | ||||||
|     _fetchCategories(); |       }); | ||||||
|  |     } else { | ||||||
|  |       _fetchPosts(); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     final cfg = context.read<ConfigProvider>(); |     return Column( | ||||||
|  |  | ||||||
|     return AppScaffold( |  | ||||||
|       floatingActionButtonLocation: ExpandableFab.location, |  | ||||||
|       floatingActionButton: ExpandableFab( |  | ||||||
|         key: _fabKey, |  | ||||||
|         distance: 75, |  | ||||||
|         type: ExpandableFabType.up, |  | ||||||
|         childrenAnimation: ExpandableFabAnimation.none, |  | ||||||
|         overlayStyle: ExpandableFabOverlayStyle( |  | ||||||
|           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(), |  | ||||||
|         ), |  | ||||||
|         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(), |  | ||||||
|         ), |  | ||||||
|       children: [ |       children: [ | ||||||
|           Row( |         if (_selectedCategory != null) | ||||||
|             children: [ |           MaterialBanner( | ||||||
|               Text('writePostTypeStory').tr(), |             content: Text( | ||||||
|               const Gap(20), |               'postFilterWithCategory'.tr(args: [ | ||||||
|               FloatingActionButton( |                 'postCategory${_selectedCategory!.alias.capitalize()}'.trExists() | ||||||
|                 heroTag: null, |                     ? 'postCategory${_selectedCategory!.alias.capitalize()}' | ||||||
|                 tooltip: 'writePostTypeStory'.tr(), |                         .tr() | ||||||
|                 onPressed: () { |                     : _selectedCategory!.name, | ||||||
|                   GoRouter.of(context).pushNamed('postEditor', pathParameters: { |               ]), | ||||||
|                     'mode': 'stories', |  | ||||||
|                   }).then((value) { |  | ||||||
|                     if (value == true) { |  | ||||||
|                       _refreshPosts(); |  | ||||||
|                     } |  | ||||||
|                   }); |  | ||||||
|                   _fabKey.currentState!.toggle(); |  | ||||||
|                 }, |  | ||||||
|                 child: const Icon(Symbols.post_rounded), |  | ||||||
|             ), |             ), | ||||||
|             ], |             leading: Icon(kCategoryIcons[_selectedCategory!.alias] ?? | ||||||
|           ), |                 Symbols.question_mark), | ||||||
|           Row( |  | ||||||
|             children: [ |  | ||||||
|               Text('writePostTypeArticle').tr(), |  | ||||||
|               const Gap(20), |  | ||||||
|               FloatingActionButton( |  | ||||||
|                 heroTag: null, |  | ||||||
|                 tooltip: 'writePostTypeArticle'.tr(), |  | ||||||
|                 onPressed: () { |  | ||||||
|                   GoRouter.of(context).pushNamed('postEditor', pathParameters: { |  | ||||||
|                     'mode': 'articles', |  | ||||||
|                   }).then((value) { |  | ||||||
|                     if (value == true) { |  | ||||||
|                       _refreshPosts(); |  | ||||||
|                     } |  | ||||||
|                   }); |  | ||||||
|                   _fabKey.currentState!.toggle(); |  | ||||||
|                 }, |  | ||||||
|                 child: const Icon(Symbols.news), |  | ||||||
|               ), |  | ||||||
|             ], |  | ||||||
|           ), |  | ||||||
|         ], |  | ||||||
|       ), |  | ||||||
|       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: [ |             actions: [ | ||||||
|               IconButton( |               IconButton( | ||||||
|                   icon: const Icon(Symbols.search), |                 icon: const Icon(Symbols.clear), | ||||||
|                 onPressed: () { |                 onPressed: () { | ||||||
|                     GoRouter.of(context).pushNamed('postSearch'); |                   widget.onClearFilter.call(); | ||||||
|  |                   refreshPosts(); | ||||||
|                 }, |                 }, | ||||||
|               ), |               ), | ||||||
|  |             ], | ||||||
|  |             padding: const EdgeInsets.only(left: 20, right: 4), | ||||||
|  |           ), | ||||||
|  |         if (widget.withRealm) | ||||||
|  |           DropdownButtonHideUnderline( | ||||||
|  |             child: DropdownButton2<SnRealm>( | ||||||
|  |               isExpanded: true, | ||||||
|  |               items: _realms | ||||||
|  |                   .map( | ||||||
|  |                     (ele) => DropdownMenuItem<SnRealm>( | ||||||
|  |                       value: ele, | ||||||
|  |                       child: Row( | ||||||
|  |                         children: [ | ||||||
|  |                           AccountImage( | ||||||
|  |                             content: ele.avatar, | ||||||
|  |                             fallbackWidget: const Icon(Symbols.group, size: 16), | ||||||
|  |                             radius: 14, | ||||||
|  |                           ), | ||||||
|                           const Gap(8), |                           const Gap(8), | ||||||
|  |                           Text( | ||||||
|  |                             ele.name, | ||||||
|  |                             style: Theme.of(context).textTheme.bodyMedium, | ||||||
|  |                           ), | ||||||
|                         ], |                         ], | ||||||
|               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; |                   .toList(), | ||||||
|                             _refreshPosts(); |               value: _selectedRealm, | ||||||
|  |               onChanged: (SnRealm? value) { | ||||||
|  |                 setState(() => _selectedRealm = value); | ||||||
|  |                 refreshPosts(); | ||||||
|               }, |               }, | ||||||
|                         )).padding(horizontal: 4); |               buttonStyleData: const ButtonStyleData( | ||||||
|                       }).toList(), |                 padding: EdgeInsets.only(left: 4, right: 12), | ||||||
|  |               ), | ||||||
|  |               menuItemStyleData: const MenuItemStyleData( | ||||||
|  |                 height: 48, | ||||||
|               ), |               ), | ||||||
|             ), |             ), | ||||||
|           ), |           ), | ||||||
|               ), |         if (widget.withRealm) const Divider(height: 1), | ||||||
|             ), |         Expanded( | ||||||
|             const SliverGap(12), |           child: MediaQuery.removePadding( | ||||||
|             SliverInfiniteList( |             context: context, | ||||||
|  |             removeTop: true, | ||||||
|  |             child: RefreshIndicator( | ||||||
|  |               displacement: 40 + MediaQuery.of(context).padding.top, | ||||||
|  |               onRefresh: () => refreshPosts(), | ||||||
|  |               child: InfiniteList( | ||||||
|                 itemCount: _posts.length, |                 itemCount: _posts.length, | ||||||
|                 isLoading: _isBusy, |                 isLoading: _isBusy, | ||||||
|                 centerLoading: true, |                 centerLoading: true, | ||||||
|               hasReachedMax: _postCount != null && _posts.length >= _postCount!, |                 hasReachedMax: | ||||||
|  |                     _postCount != null && _posts.length >= _postCount!, | ||||||
|                 onFetchData: _fetchPosts, |                 onFetchData: _fetchPosts, | ||||||
|                 itemBuilder: (context, idx) { |                 itemBuilder: (context, idx) { | ||||||
|                 return Center( |                   return OpenablePostItem( | ||||||
|                   child: OpenContainer( |  | ||||||
|                     closedBuilder: (_, __) => Container( |  | ||||||
|                       constraints: const BoxConstraints(maxWidth: 640), |  | ||||||
|                       child: PostItem( |  | ||||||
|                     data: _posts[idx], |                     data: _posts[idx], | ||||||
|                     maxWidth: 640, |                     maxWidth: 640, | ||||||
|                     onChanged: (data) { |                     onChanged: (data) { | ||||||
|                       setState(() => _posts[idx] = data); |                       setState(() => _posts[idx] = data); | ||||||
|                     }, |                     }, | ||||||
|                     onDeleted: () { |                     onDeleted: () { | ||||||
|                           _refreshPosts(); |                       refreshPosts(); | ||||||
|                     }, |                     }, | ||||||
|                       ), |  | ||||||
|                     ), |  | ||||||
|                     openBuilder: (_, close) => PostDetailScreen( |  | ||||||
|                       slug: _posts[idx].id.toString(), |  | ||||||
|                       preload: _posts[idx], |  | ||||||
|                       onBack: close, |  | ||||||
|                     ), |  | ||||||
|                     openColor: Colors.transparent, |  | ||||||
|                     openElevation: 0, |  | ||||||
|                     transitionType: ContainerTransitionType.fade, |  | ||||||
|                     closedColor: Theme.of(context).colorScheme.surfaceContainerLow.withOpacity( |  | ||||||
|                           cfg.prefs.getBool(kAppBackgroundStoreKey) == true ? 0.75 : 1, |  | ||||||
|                         ), |  | ||||||
|                     closedShape: const RoundedRectangleBorder( |  | ||||||
|                       borderRadius: BorderRadius.all(Radius.circular(16)), |  | ||||||
|                     ), |  | ||||||
|                   ), |  | ||||||
|                   ); |                   ); | ||||||
|                 }, |                 }, | ||||||
|                 separatorBuilder: (_, __) => const Gap(8), |                 separatorBuilder: (_, __) => const Gap(8), | ||||||
|               ), |               ), | ||||||
|  |             ), | ||||||
|  |           ).padding(top: 8), | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _PostCategoryPickerPopup extends StatelessWidget { | ||||||
|  |   final List<SnPostCategory> categories; | ||||||
|  |   final SnPostCategory? selected; | ||||||
|  |  | ||||||
|  |   const _PostCategoryPickerPopup({required this.categories, this.selected}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return Column( | ||||||
|  |       crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |       children: [ | ||||||
|  |         Row( | ||||||
|  |           crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |           children: [ | ||||||
|  |             const Icon(Symbols.category, size: 24), | ||||||
|  |             const Gap(16), | ||||||
|  |             Text('postCategory') | ||||||
|  |                 .tr() | ||||||
|  |                 .textStyle(Theme.of(context).textTheme.titleLarge!), | ||||||
|  |           ], | ||||||
|  |         ).padding(horizontal: 20, top: 16, bottom: 12), | ||||||
|  |         ListTile( | ||||||
|  |           leading: const Icon(Symbols.clear), | ||||||
|  |           title: Text('postFilterReset').tr(), | ||||||
|  |           subtitle: Text('postFilterResetDescription').tr(), | ||||||
|  |           contentPadding: const EdgeInsets.symmetric(horizontal: 20), | ||||||
|  |           onTap: () { | ||||||
|  |             Navigator.pop(context, false); | ||||||
|  |           }, | ||||||
|  |         ), | ||||||
|  |         const Divider(height: 1), | ||||||
|  |         Expanded( | ||||||
|  |           child: GridView.count( | ||||||
|  |             crossAxisCount: 4, | ||||||
|  |             shrinkWrap: true, | ||||||
|  |             physics: const NeverScrollableScrollPhysics(), | ||||||
|  |             childAspectRatio: 1, | ||||||
|  |             children: categories | ||||||
|  |                 .map( | ||||||
|  |                   (ele) => InkWell( | ||||||
|  |                     onTap: () { | ||||||
|  |                       _selectedCategory = ele; | ||||||
|  |                       Navigator.pop(context, ele); | ||||||
|  |                     }, | ||||||
|  |                     child: Column( | ||||||
|  |                       crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |                       mainAxisAlignment: MainAxisAlignment.center, | ||||||
|  |                       mainAxisSize: MainAxisSize.min, | ||||||
|  |                       children: [ | ||||||
|  |                         Icon( | ||||||
|  |                           kCategoryIcons[ele.alias] ?? Symbols.question_mark, | ||||||
|  |                           color: selected == ele | ||||||
|  |                               ? Theme.of(context).colorScheme.primary | ||||||
|  |                               : null, | ||||||
|  |                         ), | ||||||
|  |                         const Gap(4), | ||||||
|  |                         Text( | ||||||
|  |                           'postCategory${ele.alias.capitalize()}'.trExists() | ||||||
|  |                               ? 'postCategory${ele.alias.capitalize()}'.tr() | ||||||
|  |                               : ele.name, | ||||||
|  |                         ) | ||||||
|  |                             .textStyle(Theme.of(context).textTheme.titleMedium!) | ||||||
|  |                             .textColor(selected == ele | ||||||
|  |                                 ? Theme.of(context).colorScheme.primary | ||||||
|  |                                 : null), | ||||||
|  |                       ], | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|  |                 ) | ||||||
|  |                 .toList(), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|       ], |       ], | ||||||
|         ), |  | ||||||
|       ), |  | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -6,15 +6,15 @@ import 'package:provider/provider.dart'; | |||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
| import 'package:surface/providers/relationship.dart'; | import 'package:surface/providers/relationship.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
|  | import 'package:surface/providers/userinfo.dart'; | ||||||
| import 'package:surface/types/account.dart'; | import 'package:surface/types/account.dart'; | ||||||
| import 'package:surface/widgets/account/account_image.dart'; | import 'package:surface/widgets/account/account_image.dart'; | ||||||
|  | import 'package:surface/widgets/account/account_select.dart'; | ||||||
| import 'package:surface/widgets/app_bar_leading.dart'; | import 'package:surface/widgets/app_bar_leading.dart'; | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
| import 'package:surface/widgets/loading_indicator.dart'; | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  | import 'package:surface/widgets/unauthorized_hint.dart'; | ||||||
| import '../providers/userinfo.dart'; |  | ||||||
| import '../widgets/unauthorized_hint.dart'; |  | ||||||
|  |  | ||||||
| const kFriendStatus = { | const kFriendStatus = { | ||||||
|   0: 'friendStatusPending', |   0: 'friendStatusPending', | ||||||
| @@ -168,6 +168,24 @@ class _FriendScreenState extends State<FriendScreen> { | |||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   Future<void> _sendRequest(SnAccount user) async { | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       await sn.client.post('/cgi/id/users/me/relations', data: { | ||||||
|  |         'related': user.name, | ||||||
|  |       }); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showSnackbar('friendRequestSent'.tr()); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void initState() { |   void initState() { | ||||||
|     super.initState(); |     super.initState(); | ||||||
| @@ -199,11 +217,16 @@ class _FriendScreenState extends State<FriendScreen> { | |||||||
|       ), |       ), | ||||||
|       floatingActionButton: FloatingActionButton( |       floatingActionButton: FloatingActionButton( | ||||||
|         child: const Icon(Symbols.add), |         child: const Icon(Symbols.add), | ||||||
|         onPressed: () { |         onPressed: () async { | ||||||
|           showModalBottomSheet( |           final user = await showModalBottomSheet<SnAccount?>( | ||||||
|             context: context, |             context: context, | ||||||
|             builder: (context) => _NewFriendWidget(), |             builder: (context) => AccountSelect( | ||||||
|  |               title: 'friendNew'.tr(), | ||||||
|  |             ), | ||||||
|           ); |           ); | ||||||
|  |           if (!mounted) return; | ||||||
|  |           if (user == null) return; | ||||||
|  |           _sendRequest(user); | ||||||
|         }, |         }, | ||||||
|       ), |       ), | ||||||
|       body: Column( |       body: Column( | ||||||
| @@ -231,8 +254,7 @@ class _FriendScreenState extends State<FriendScreen> { | |||||||
|               trailing: const Icon(Symbols.chevron_right), |               trailing: const Icon(Symbols.chevron_right), | ||||||
|               onTap: _showBlocks, |               onTap: _showBlocks, | ||||||
|             ), |             ), | ||||||
|           if (_requests.isNotEmpty || _blocks.isNotEmpty) |           if (_requests.isNotEmpty || _blocks.isNotEmpty) const Divider(height: 1), | ||||||
|             const Divider(height: 1), |  | ||||||
|           Expanded( |           Expanded( | ||||||
|             child: MediaQuery.removePadding( |             child: MediaQuery.removePadding( | ||||||
|               context: context, |               context: context, | ||||||
| @@ -264,16 +286,12 @@ class _FriendScreenState extends State<FriendScreen> { | |||||||
|                               mainAxisAlignment: MainAxisAlignment.end, |                               mainAxisAlignment: MainAxisAlignment.end, | ||||||
|                               children: [ |                               children: [ | ||||||
|                                 InkWell( |                                 InkWell( | ||||||
|                                   onTap: _isUpdating |                                   onTap: _isUpdating ? null : () => _changeRelation(relation, 2), | ||||||
|                                       ? null |  | ||||||
|                                       : () => _changeRelation(relation, 2), |  | ||||||
|                                   child: Text('friendBlock').tr(), |                                   child: Text('friendBlock').tr(), | ||||||
|                                 ), |                                 ), | ||||||
|                                 const Gap(8), |                                 const Gap(8), | ||||||
|                                 InkWell( |                                 InkWell( | ||||||
|                                   onTap: _isUpdating |                                   onTap: _isUpdating ? null : () => _deleteRelation(relation), | ||||||
|                                       ? null |  | ||||||
|                                       : () => _deleteRelation(relation), |  | ||||||
|                                   child: Text('friendDeleteAction').tr(), |                                   child: Text('friendDeleteAction').tr(), | ||||||
|                                 ), |                                 ), | ||||||
|                               ], |                               ], | ||||||
| @@ -293,83 +311,9 @@ class _FriendScreenState extends State<FriendScreen> { | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| class _NewFriendWidget extends StatefulWidget { |  | ||||||
|   const _NewFriendWidget(); |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   State<_NewFriendWidget> createState() => _NewFriendWidgetState(); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class _NewFriendWidgetState extends State<_NewFriendWidget> { |  | ||||||
|   bool _isBusy = false; |  | ||||||
|  |  | ||||||
|   final TextEditingController _relatedController = TextEditingController(); |  | ||||||
|  |  | ||||||
|   Future<void> _sendRequest() async { |  | ||||||
|     if (_relatedController.text.isEmpty) return; |  | ||||||
|  |  | ||||||
|     setState(() => _isBusy = true); |  | ||||||
|  |  | ||||||
|     try { |  | ||||||
|       final sn = context.read<SnNetworkProvider>(); |  | ||||||
|       await sn.client.post('/cgi/id/users/me/relations', data: { |  | ||||||
|         'related': _relatedController.text, |  | ||||||
|       }); |  | ||||||
|       if (!mounted) return; |  | ||||||
|       Navigator.pop(context, true); |  | ||||||
|       context.showSnackbar('friendRequestSent'.tr()); |  | ||||||
|     } catch (err) { |  | ||||||
|       if (!mounted) return; |  | ||||||
|       context.showErrorDialog(err); |  | ||||||
|     } finally { |  | ||||||
|       setState(() => _isBusy = false); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   void dispose() { |  | ||||||
|     super.dispose(); |  | ||||||
|     _relatedController.dispose(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   Widget build(BuildContext context) { |  | ||||||
|     return StyledWidget(Column( |  | ||||||
|       crossAxisAlignment: CrossAxisAlignment.start, |  | ||||||
|       children: [ |  | ||||||
|         Text( |  | ||||||
|           'friendNew', |  | ||||||
|           style: Theme.of(context).textTheme.titleLarge, |  | ||||||
|         ).tr(), |  | ||||||
|         const Gap(12), |  | ||||||
|         TextField( |  | ||||||
|           controller: _relatedController, |  | ||||||
|           readOnly: _isBusy, |  | ||||||
|           autocorrect: false, |  | ||||||
|           autofocus: true, |  | ||||||
|           textCapitalization: TextCapitalization.none, |  | ||||||
|           decoration: InputDecoration( |  | ||||||
|             labelText: 'fieldFriendRelatedName'.tr(), |  | ||||||
|             suffix: SizedBox( |  | ||||||
|               height: 24, |  | ||||||
|               child: IconButton( |  | ||||||
|                 onPressed: _isBusy ? null : () => _sendRequest(), |  | ||||||
|                 icon: Icon(Symbols.send), |  | ||||||
|                 visualDensity: |  | ||||||
|                     const VisualDensity(horizontal: -4, vertical: -4), |  | ||||||
|                 padding: EdgeInsets.zero, |  | ||||||
|               ), |  | ||||||
|             ), |  | ||||||
|           ), |  | ||||||
|           onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |  | ||||||
|         ) |  | ||||||
|       ], |  | ||||||
|     )).padding(all: 24); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class _FriendshipListWidget extends StatefulWidget { | class _FriendshipListWidget extends StatefulWidget { | ||||||
|   final List<SnRelationship> relations; |   final List<SnRelationship> relations; | ||||||
|  |  | ||||||
|   const _FriendshipListWidget({required this.relations}); |   const _FriendshipListWidget({required this.relations}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
| @@ -476,9 +420,7 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> { | |||||||
|               mainAxisAlignment: MainAxisAlignment.center, |               mainAxisAlignment: MainAxisAlignment.center, | ||||||
|               crossAxisAlignment: CrossAxisAlignment.end, |               crossAxisAlignment: CrossAxisAlignment.end, | ||||||
|               children: [ |               children: [ | ||||||
|                 Text(kFriendStatus[relation.status] ?? 'unknown') |                 Text(kFriendStatus[relation.status] ?? 'unknown').tr().opacity(0.75), | ||||||
|                     .tr() |  | ||||||
|                     .opacity(0.75), |  | ||||||
|                 if (relation.status == 0) |                 if (relation.status == 0) | ||||||
|                   Row( |                   Row( | ||||||
|                     mainAxisAlignment: MainAxisAlignment.end, |                     mainAxisAlignment: MainAxisAlignment.end, | ||||||
| @@ -499,8 +441,7 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> { | |||||||
|                     mainAxisAlignment: MainAxisAlignment.end, |                     mainAxisAlignment: MainAxisAlignment.end, | ||||||
|                     children: [ |                     children: [ | ||||||
|                       InkWell( |                       InkWell( | ||||||
|                         onTap: |                         onTap: _isBusy ? null : () => _changeRelation(relation, 1), | ||||||
|                             _isBusy ? null : () => _changeRelation(relation, 1), |  | ||||||
|                         child: Text('friendUnblock').tr(), |                         child: Text('friendUnblock').tr(), | ||||||
|                       ), |                       ), | ||||||
|                       const Gap(8), |                       const Gap(8), | ||||||
|   | |||||||
| @@ -1,10 +1,7 @@ | |||||||
| import 'dart:io'; |  | ||||||
| import 'dart:math' as math; | import 'dart:math' as math; | ||||||
| import 'dart:ui'; | import 'dart:ui'; | ||||||
|  |  | ||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/foundation.dart'; |  | ||||||
| import 'package:flutter_app_update/flutter_app_update.dart'; |  | ||||||
| import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; | import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; | ||||||
| import 'package:gap/gap.dart'; | import 'package:gap/gap.dart'; | ||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
| @@ -29,6 +26,7 @@ import 'package:surface/widgets/app_bar_leading.dart'; | |||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
| import 'package:surface/widgets/post/post_item.dart'; | import 'package:surface/widgets/post/post_item.dart'; | ||||||
|  | import 'package:surface/widgets/updater.dart'; | ||||||
|  |  | ||||||
| class HomeScreenDashEntry { | class HomeScreenDashEntry { | ||||||
|   final String name; |   final String name; | ||||||
| @@ -83,14 +81,24 @@ class _HomeScreenState extends State<HomeScreen> { | |||||||
|       body: LayoutBuilder( |       body: LayoutBuilder( | ||||||
|         builder: (context, constraints) { |         builder: (context, constraints) { | ||||||
|           return Align( |           return Align( | ||||||
|             alignment: constraints.maxWidth > 640 ? Alignment.center : Alignment.topCenter, |             alignment: constraints.maxWidth > 640 | ||||||
|  |                 ? Alignment.center | ||||||
|  |                 : Alignment.topCenter, | ||||||
|             child: Container( |             child: Container( | ||||||
|               constraints: const BoxConstraints(maxWidth: 640), |               constraints: const BoxConstraints(maxWidth: 640), | ||||||
|               child: SingleChildScrollView( |               child: SingleChildScrollView( | ||||||
|                 child: Column( |                 child: Column( | ||||||
|                   mainAxisAlignment: constraints.maxWidth > 640 ? MainAxisAlignment.center : MainAxisAlignment.start, |                   mainAxisAlignment: constraints.maxWidth > 640 | ||||||
|  |                       ? MainAxisAlignment.center | ||||||
|  |                       : MainAxisAlignment.start, | ||||||
|                   children: [ |                   children: [ | ||||||
|                     _HomeDashUpdateWidget(padding: const EdgeInsets.only(bottom: 8, left: 8, right: 8)), |                     _HomeDashUpdateWidget( | ||||||
|  |                       padding: const EdgeInsets.only( | ||||||
|  |                         bottom: 8, | ||||||
|  |                         left: 8, | ||||||
|  |                         right: 8, | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|                     _HomeDashSpecialDayWidget().padding(horizontal: 8), |                     _HomeDashSpecialDayWidget().padding(horizontal: 8), | ||||||
|                     StaggeredGrid.extent( |                     StaggeredGrid.extent( | ||||||
|                       maxCrossAxisExtent: 280, |                       maxCrossAxisExtent: 280, | ||||||
| @@ -131,23 +139,18 @@ class _HomeDashUpdateWidget extends StatelessWidget { | |||||||
|           return Container( |           return Container( | ||||||
|             padding: padding, |             padding: padding, | ||||||
|             child: Card( |             child: Card( | ||||||
|  |               margin: EdgeInsets.zero, | ||||||
|               child: ListTile( |               child: ListTile( | ||||||
|                 leading: Icon(Symbols.update), |                 leading: Icon(Symbols.update), | ||||||
|                 title: Text('updateAvailable').tr(), |                 title: Text('updateAvailable').tr(), | ||||||
|                 subtitle: Text(config.updatableVersion!), |                 subtitle: Text(config.updatableVersion!), | ||||||
|                 trailing: (kIsWeb || Platform.isWindows || Platform.isLinux) |                 trailing: IconButton( | ||||||
|                     ? null |  | ||||||
|                     : IconButton( |  | ||||||
|                   icon: const Icon(Symbols.arrow_right_alt), |                   icon: const Icon(Symbols.arrow_right_alt), | ||||||
|                   onPressed: () { |                   onPressed: () { | ||||||
|                           final model = UpdateModel( |                     showModalBottomSheet( | ||||||
|                             'https://files.solsynth.dev/d/production01/solian/app-arm64-v8a-release.apk', |                       context: context, | ||||||
|                             'solian-app-release-${config.updatableVersion!}.apk', |                       builder: (context) => VersionUpdatePopup(), | ||||||
|                             'ic_launcher', |  | ||||||
|                             'https://apps.apple.com/us/app/solian/id6499032345', |  | ||||||
|                     ); |                     ); | ||||||
|                           AzhonAppUpdate.update(model); |  | ||||||
|                           context.showSnackbar('updateOngoing'.tr()); |  | ||||||
|                   }, |                   }, | ||||||
|                 ), |                 ), | ||||||
|               ), |               ), | ||||||
| @@ -165,7 +168,8 @@ class _HomeDashSpecialDayWidget extends StatefulWidget { | |||||||
|   const _HomeDashSpecialDayWidget(); |   const _HomeDashSpecialDayWidget(); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   State<_HomeDashSpecialDayWidget> createState() => _HomeDashSpecialDayWidgetState(); |   State<_HomeDashSpecialDayWidget> createState() => | ||||||
|  |       _HomeDashSpecialDayWidgetState(); | ||||||
| } | } | ||||||
|  |  | ||||||
| class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> { | class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> { | ||||||
| @@ -180,6 +184,7 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> { | |||||||
|       return Column( |       return Column( | ||||||
|           children: days.map((ele) { |           children: days.map((ele) { | ||||||
|         return Card( |         return Card( | ||||||
|  |           margin: EdgeInsets.zero, | ||||||
|           child: ListTile( |           child: ListTile( | ||||||
|             leading: Text(kSpecialDaysSymbol[ele] ?? '🎉').fontSize(24), |             leading: Text(kSpecialDaysSymbol[ele] ?? '🎉').fontSize(24), | ||||||
|             title: Text('celebrate$ele').tr(args: [ua.user?.nick ?? 'user']), |             title: Text('celebrate$ele').tr(args: [ua.user?.nick ?? 'user']), | ||||||
| @@ -203,9 +208,12 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> { | |||||||
|       final progress = dayz.getSpecialDayProgress(lastOne.$2, date); |       final progress = dayz.getSpecialDayProgress(lastOne.$2, date); | ||||||
|       final diff = nextOne.$2.difference(DateTime.now()); |       final diff = nextOne.$2.difference(DateTime.now()); | ||||||
|       return Card( |       return Card( | ||||||
|  |         margin: EdgeInsets.zero, | ||||||
|         child: ListTile( |         child: ListTile( | ||||||
|           leading: Text(kSpecialDaysSymbol[name] ?? '🎉').fontSize(24), |           leading: Text(kSpecialDaysSymbol[name] ?? '🎉').fontSize(24), | ||||||
|           title: Text('pending$name').tr(args: [RelativeTime(context).format(date).replaceFirst('in', '').trim()]), |           title: Text('pending$name').tr(args: [ | ||||||
|  |             RelativeTime(context).format(date).replaceFirst('in', '').trim() | ||||||
|  |           ]), | ||||||
|           subtitle: Row( |           subtitle: Row( | ||||||
|             crossAxisAlignment: CrossAxisAlignment.center, |             crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|             children: [ |             children: [ | ||||||
| @@ -270,6 +278,7 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return Card( |     return Card( | ||||||
|  |       margin: EdgeInsets.zero, | ||||||
|       child: Column( |       child: Column( | ||||||
|         crossAxisAlignment: CrossAxisAlignment.start, |         crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|         children: [ |         children: [ | ||||||
| @@ -293,12 +302,19 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> { | |||||||
|                   children: [ |                   children: [ | ||||||
|                     Text( |                     Text( | ||||||
|                       _article!.title, |                       _article!.title, | ||||||
|                       style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 18), |                       style: Theme.of(context) | ||||||
|                       maxLines: MediaQuery.of(context).size.width >= 640 ? 2 : 1, |                           .textTheme | ||||||
|  |                           .titleMedium! | ||||||
|  |                           .copyWith(fontSize: 18), | ||||||
|  |                       maxLines: | ||||||
|  |                           MediaQuery.of(context).size.width >= 640 ? 2 : 1, | ||||||
|                       overflow: TextOverflow.ellipsis, |                       overflow: TextOverflow.ellipsis, | ||||||
|                     ), |                     ), | ||||||
|                     Text( |                     Text( | ||||||
|                       parse(_article!.description).children.map((e) => e.text.trim()).join(), |                       parse(_article!.description) | ||||||
|  |                           .children | ||||||
|  |                           .map((e) => e.text.trim()) | ||||||
|  |                           .join(), | ||||||
|                       maxLines: 3, |                       maxLines: 3, | ||||||
|                       overflow: TextOverflow.ellipsis, |                       overflow: TextOverflow.ellipsis, | ||||||
|                       style: Theme.of(context).textTheme.bodyMedium, |                       style: Theme.of(context).textTheme.bodyMedium, | ||||||
| @@ -309,9 +325,13 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> { | |||||||
|                         crossAxisAlignment: CrossAxisAlignment.center, |                         crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|                         spacing: 2, |                         spacing: 2, | ||||||
|                         children: [ |                         children: [ | ||||||
|                           Text(DateFormat().format(date)).textStyle(Theme.of(context).textTheme.bodySmall!), |                           Text(DateFormat().format(date)).textStyle( | ||||||
|                           Text(' · ').textStyle(Theme.of(context).textTheme.bodySmall!).bold(), |                               Theme.of(context).textTheme.bodySmall!), | ||||||
|                           Text(RelativeTime(context).format(date)).textStyle(Theme.of(context).textTheme.bodySmall!), |                           Text(' · ') | ||||||
|  |                               .textStyle(Theme.of(context).textTheme.bodySmall!) | ||||||
|  |                               .bold(), | ||||||
|  |                           Text(RelativeTime(context).format(date)).textStyle( | ||||||
|  |                               Theme.of(context).textTheme.bodySmall!), | ||||||
|                         ], |                         ], | ||||||
|                       ).opacity(0.75); |                       ).opacity(0.75); | ||||||
|                     }), |                     }), | ||||||
| @@ -382,15 +402,20 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   Widget _buildDetailChunk(int index, bool positive) { |   Widget _buildDetailChunk(int index, bool positive) { | ||||||
|     final prefix = positive ? 'dailyCheckPositiveHint' : 'dailyCheckNegativeHint'; |     final prefix = | ||||||
|     final mod = positive ? kSuggestionPositiveHintCount : kSuggestionNegativeHintCount; |         positive ? 'dailyCheckPositiveHint' : 'dailyCheckNegativeHint'; | ||||||
|  |     final mod = | ||||||
|  |         positive ? kSuggestionPositiveHintCount : kSuggestionNegativeHintCount; | ||||||
|     final pos = math.max(1, _todayRecord!.resultModifiers[index] % mod); |     final pos = math.max(1, _todayRecord!.resultModifiers[index] % mod); | ||||||
|     return Column( |     return Column( | ||||||
|       crossAxisAlignment: CrossAxisAlignment.start, |       crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|       children: [ |       children: [ | ||||||
|         Text( |         Text( | ||||||
|           prefix.tr(args: ['$prefix$pos'.tr()]), |           prefix.tr(args: ['$prefix$pos'.tr()]), | ||||||
|           style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold), |           style: Theme.of(context) | ||||||
|  |               .textTheme | ||||||
|  |               .titleMedium! | ||||||
|  |               .copyWith(fontWeight: FontWeight.bold), | ||||||
|         ), |         ), | ||||||
|         Text( |         Text( | ||||||
|           '$prefix${pos}Description', |           '$prefix${pos}Description', | ||||||
| @@ -425,7 +450,10 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> { | |||||||
|               else |               else | ||||||
|                 Text( |                 Text( | ||||||
|                   'dailyCheckEverythingIsNegative', |                   'dailyCheckEverythingIsNegative', | ||||||
|                   style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold), |                   style: Theme.of(context) | ||||||
|  |                       .textTheme | ||||||
|  |                       .titleMedium! | ||||||
|  |                       .copyWith(fontWeight: FontWeight.bold), | ||||||
|                 ).tr(), |                 ).tr(), | ||||||
|               const Gap(8), |               const Gap(8), | ||||||
|               if (_todayRecord?.resultTier != 4) |               if (_todayRecord?.resultTier != 4) | ||||||
| @@ -441,7 +469,10 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> { | |||||||
|               else |               else | ||||||
|                 Text( |                 Text( | ||||||
|                   'dailyCheckEverythingIsPositive', |                   'dailyCheckEverythingIsPositive', | ||||||
|                   style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold), |                   style: Theme.of(context) | ||||||
|  |                       .textTheme | ||||||
|  |                       .titleMedium! | ||||||
|  |                       .copyWith(fontWeight: FontWeight.bold), | ||||||
|                 ).tr(), |                 ).tr(), | ||||||
|             ], |             ], | ||||||
|           ), |           ), | ||||||
| @@ -469,6 +500,7 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return Card( |     return Card( | ||||||
|  |       margin: EdgeInsets.zero, | ||||||
|       child: Column( |       child: Column( | ||||||
|         crossAxisAlignment: CrossAxisAlignment.start, |         crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|         children: [ |         children: [ | ||||||
| @@ -566,10 +598,12 @@ class _HomeDashNotificationWidget extends StatefulWidget { | |||||||
|   const _HomeDashNotificationWidget(); |   const _HomeDashNotificationWidget(); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   State<_HomeDashNotificationWidget> createState() => _HomeDashNotificationWidgetState(); |   State<_HomeDashNotificationWidget> createState() => | ||||||
|  |       _HomeDashNotificationWidgetState(); | ||||||
| } | } | ||||||
|  |  | ||||||
| class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget> { | class _HomeDashNotificationWidgetState | ||||||
|  |     extends State<_HomeDashNotificationWidget> { | ||||||
|   int? _count; |   int? _count; | ||||||
|  |  | ||||||
|   Future<void> _fetchNotificationCount() async { |   Future<void> _fetchNotificationCount() async { | ||||||
| @@ -594,6 +628,7 @@ class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return Card( |     return Card( | ||||||
|  |       margin: EdgeInsets.zero, | ||||||
|       child: Column( |       child: Column( | ||||||
|         crossAxisAlignment: CrossAxisAlignment.start, |         crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|         children: [ |         children: [ | ||||||
| @@ -606,7 +641,9 @@ class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget | |||||||
|                   style: Theme.of(context).textTheme.titleLarge, |                   style: Theme.of(context).textTheme.titleLarge, | ||||||
|                 ).tr(), |                 ).tr(), | ||||||
|                 Text( |                 Text( | ||||||
|                   _count == null ? 'loading'.tr() : 'notificationUnreadCount'.plural(_count ?? 0), |                   _count == null | ||||||
|  |                       ? 'loading'.tr() | ||||||
|  |                       : 'notificationUnreadCount'.plural(_count ?? 0), | ||||||
|                   style: Theme.of(context).textTheme.bodyLarge, |                   style: Theme.of(context).textTheme.bodyLarge, | ||||||
|                 ), |                 ), | ||||||
|               ], |               ], | ||||||
| @@ -637,10 +674,12 @@ class _HomeDashRecommendationPostWidget extends StatefulWidget { | |||||||
|   const _HomeDashRecommendationPostWidget(); |   const _HomeDashRecommendationPostWidget(); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   State<_HomeDashRecommendationPostWidget> createState() => _HomeDashRecommendationPostWidgetState(); |   State<_HomeDashRecommendationPostWidget> createState() => | ||||||
|  |       _HomeDashRecommendationPostWidgetState(); | ||||||
| } | } | ||||||
|  |  | ||||||
| class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendationPostWidget> { | class _HomeDashRecommendationPostWidgetState | ||||||
|  |     extends State<_HomeDashRecommendationPostWidget> { | ||||||
|   bool _isBusy = false; |   bool _isBusy = false; | ||||||
|   List<SnPost>? _posts; |   List<SnPost>? _posts; | ||||||
|  |  | ||||||
| @@ -657,23 +696,42 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   int _currentPage = 0; | ||||||
|  |   final PageController _pageController = PageController(); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void initState() { |   void initState() { | ||||||
|     super.initState(); |     super.initState(); | ||||||
|     _fetchRecommendationPosts(); |     _fetchRecommendationPosts(); | ||||||
|  |     _pageController.addListener(() { | ||||||
|  |       setState(() { | ||||||
|  |         _currentPage = _pageController.page?.round() ?? 0; | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void dispose() { | ||||||
|  |     _pageController.dispose(); | ||||||
|  |     super.dispose(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     if (_isBusy) { |     if (_isBusy) { | ||||||
|       return Card( |       return Card( | ||||||
|  |         margin: EdgeInsets.zero, | ||||||
|         child: CircularProgressIndicator().center(), |         child: CircularProgressIndicator().center(), | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return Card( |     return Card( | ||||||
|  |       margin: EdgeInsets.zero, | ||||||
|       child: Column( |       child: Column( | ||||||
|         crossAxisAlignment: CrossAxisAlignment.start, |         crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |         children: [ | ||||||
|  |           Row( | ||||||
|  |             mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||||
|             children: [ |             children: [ | ||||||
|               Row( |               Row( | ||||||
|                 children: [ |                 children: [ | ||||||
| @@ -682,12 +740,18 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati | |||||||
|                   Text( |                   Text( | ||||||
|                     'postRecommendation', |                     'postRecommendation', | ||||||
|                     style: Theme.of(context).textTheme.titleLarge, |                     style: Theme.of(context).textTheme.titleLarge, | ||||||
|               ).tr() |                   ).tr(), | ||||||
|  |                 ], | ||||||
|  |               ), | ||||||
|  |               Text('${_currentPage + 1}/${_posts?.length ?? 0}', | ||||||
|  |                   style: GoogleFonts.robotoMono()) | ||||||
|             ], |             ], | ||||||
|           ).padding(horizontal: 18, top: 12, bottom: 8), |           ).padding(horizontal: 18, top: 12, bottom: 8), | ||||||
|           Expanded( |           Expanded( | ||||||
|             child: PageView.builder( |             child: PageView.builder( | ||||||
|               scrollBehavior: ScrollConfiguration.of(context).copyWith(dragDevices: { |               controller: _pageController, | ||||||
|  |               scrollBehavior: | ||||||
|  |                   ScrollConfiguration.of(context).copyWith(dragDevices: { | ||||||
|                 PointerDeviceKind.mouse, |                 PointerDeviceKind.mouse, | ||||||
|                 PointerDeviceKind.touch, |                 PointerDeviceKind.touch, | ||||||
|               }), |               }), | ||||||
| @@ -700,7 +764,8 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati | |||||||
|                       showMenu: false, |                       showMenu: false, | ||||||
|                     ).padding(bottom: 8), |                     ).padding(bottom: 8), | ||||||
|                     onTap: () { |                     onTap: () { | ||||||
|                       GoRouter.of(context).pushNamed('postDetail', pathParameters: { |                       GoRouter.of(context) | ||||||
|  |                           .pushNamed('postDetail', pathParameters: { | ||||||
|                         'slug': _posts![index].id.toString(), |                         'slug': _posts![index].id.toString(), | ||||||
|                       }); |                       }); | ||||||
|                     }, |                     }, | ||||||
|   | |||||||
							
								
								
									
										167
									
								
								lib/screens/logging.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								lib/screens/logging.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,167 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter/services.dart'; | ||||||
|  | import 'package:google_fonts/google_fonts.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
|  | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:surface/logger.dart'; | ||||||
|  | import 'package:surface/widgets/dialog.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  | import 'package:talker_dio_logger/dio_logs.dart'; | ||||||
|  | import 'package:talker_flutter/talker_flutter.dart'; | ||||||
|  |  | ||||||
|  | final Map<LogLevel, IconData> kLogLevelIcons = { | ||||||
|  |   LogLevel.error: Symbols.error, | ||||||
|  |   LogLevel.critical: Symbols.error, | ||||||
|  |   LogLevel.warning: Symbols.warning, | ||||||
|  |   LogLevel.info: Symbols.info, | ||||||
|  |   LogLevel.debug: Symbols.info_i, | ||||||
|  |   LogLevel.verbose: Symbols.info_i, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | final Map<LogLevel, bool> kLogLevelFilled = { | ||||||
|  |   LogLevel.error: false, | ||||||
|  |   LogLevel.critical: true, | ||||||
|  |   LogLevel.warning: true, | ||||||
|  |   LogLevel.info: true, | ||||||
|  |   LogLevel.debug: false, | ||||||
|  |   LogLevel.verbose: false, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | class DebugLoggingScreen extends StatelessWidget { | ||||||
|  |   const DebugLoggingScreen({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     final talkerTheme = TalkerScreenTheme.fromTheme(Theme.of(context)); | ||||||
|  |  | ||||||
|  |     return AppScaffold( | ||||||
|  |       appBar: AppBar( | ||||||
|  |         leading: const PageBackButton(), | ||||||
|  |         title: Text('debugLogging').tr(), | ||||||
|  |         actions: [ | ||||||
|  |           IconButton( | ||||||
|  |             onPressed: () { | ||||||
|  |               logging.cleanHistory(); | ||||||
|  |               Navigator.pop(context); | ||||||
|  |             }, | ||||||
|  |             icon: const Icon(Symbols.delete), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |       body: ListView.builder( | ||||||
|  |         reverse: true, | ||||||
|  |         padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom), | ||||||
|  |         itemCount: logging.history.length, | ||||||
|  |         itemBuilder: (context, index) { | ||||||
|  |           final log = logging.history[index]; | ||||||
|  |           final color = log.getFlutterColor(talkerTheme); | ||||||
|  |           return ListTile( | ||||||
|  |             minTileHeight: 0, | ||||||
|  |             tileColor: color.withOpacity(0.2), | ||||||
|  |             leading: Icon( | ||||||
|  |               kLogLevelIcons[log.logLevel ?? LogLevel.debug] ?? Symbols.help, | ||||||
|  |               color: color, | ||||||
|  |               fill: (kLogLevelFilled[log.logLevel ?? LogLevel.debug] ?? false) | ||||||
|  |                   ? 1 | ||||||
|  |                   : 0, | ||||||
|  |             ), | ||||||
|  |             title: Column( | ||||||
|  |               crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |               children: [ | ||||||
|  |                 if (log is DioRequestLog) | ||||||
|  |                   Column( | ||||||
|  |                     crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                     children: [ | ||||||
|  |                       Text( | ||||||
|  |                         '${log.requestOptions.method} ${log.displayMessage}', | ||||||
|  |                         style: GoogleFonts.robotoMono(fontSize: 13), | ||||||
|  |                       ), | ||||||
|  |                       if (log.requestOptions.data != null) | ||||||
|  |                         Theme( | ||||||
|  |                           data: Theme.of(context).copyWith( | ||||||
|  |                             dividerColor: Colors.transparent, | ||||||
|  |                           ), | ||||||
|  |                           child: ExpansionTile( | ||||||
|  |                             title: Text('Payload').fontSize(13), | ||||||
|  |                             minTileHeight: 0, | ||||||
|  |                             tilePadding: EdgeInsets.zero, | ||||||
|  |                             expandedCrossAxisAlignment: | ||||||
|  |                                 CrossAxisAlignment.start, | ||||||
|  |                             children: [ | ||||||
|  |                               Text( | ||||||
|  |                                 log.requestOptions.data.toString(), | ||||||
|  |                                 style: GoogleFonts.robotoMono(fontSize: 13), | ||||||
|  |                               ), | ||||||
|  |                             ], | ||||||
|  |                           ), | ||||||
|  |                         ), | ||||||
|  |                     ], | ||||||
|  |                   ) | ||||||
|  |                 else if (log is DioResponseLog) | ||||||
|  |                   Column( | ||||||
|  |                     crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                     children: [ | ||||||
|  |                       Text( | ||||||
|  |                         '${log.response.statusCode} ${log.displayMessage}', | ||||||
|  |                         style: GoogleFonts.robotoMono(fontSize: 13), | ||||||
|  |                       ), | ||||||
|  |                       if (log.response.data != null) | ||||||
|  |                         Theme( | ||||||
|  |                           data: Theme.of(context).copyWith( | ||||||
|  |                             dividerColor: Colors.transparent, | ||||||
|  |                           ), | ||||||
|  |                           child: ExpansionTile( | ||||||
|  |                             title: Text('Payload').fontSize(13), | ||||||
|  |                             minTileHeight: 0, | ||||||
|  |                             tilePadding: EdgeInsets.zero, | ||||||
|  |                             expandedCrossAxisAlignment: | ||||||
|  |                                 CrossAxisAlignment.start, | ||||||
|  |                             children: [ | ||||||
|  |                               Text( | ||||||
|  |                                 log.response.data.toString(), | ||||||
|  |                                 style: GoogleFonts.robotoMono(fontSize: 13), | ||||||
|  |                               ), | ||||||
|  |                             ], | ||||||
|  |                           ), | ||||||
|  |                         ), | ||||||
|  |                     ], | ||||||
|  |                   ) | ||||||
|  |                 else | ||||||
|  |                   Text( | ||||||
|  |                     log.displayMessage, | ||||||
|  |                     style: GoogleFonts.robotoMono(fontSize: 13), | ||||||
|  |                   ), | ||||||
|  |                 if (log.exception != null) | ||||||
|  |                   Text( | ||||||
|  |                     log.displayException, | ||||||
|  |                     style: GoogleFonts.robotoMono(fontSize: 13), | ||||||
|  |                   ).bold(), | ||||||
|  |                 if (log.error != null) | ||||||
|  |                   Text( | ||||||
|  |                     log.displayException, | ||||||
|  |                     style: GoogleFonts.robotoMono(fontSize: 13), | ||||||
|  |                   ).bold(), | ||||||
|  |                 if (log.stackTrace != null) | ||||||
|  |                   Text( | ||||||
|  |                     log.displayStackTrace, | ||||||
|  |                     style: GoogleFonts.robotoMono(fontSize: 12), | ||||||
|  |                   ).padding(top: 4), | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |             subtitle: Text( | ||||||
|  |               '${(log.title?.replaceAll('-', ' ') ?? 'default').capitalizeEachWord()} · ${log.displayTime()}', | ||||||
|  |             ).fontSize(11), | ||||||
|  |             onTap: () { | ||||||
|  |               Clipboard.setData( | ||||||
|  |                 ClipboardData( | ||||||
|  |                   text: log.generateTextMessage(), | ||||||
|  |                 ), | ||||||
|  |               ); | ||||||
|  |             }, | ||||||
|  |           ); | ||||||
|  |         }, | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -3,6 +3,7 @@ import 'dart:math' as math; | |||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:gap/gap.dart'; | import 'package:gap/gap.dart'; | ||||||
|  | import 'package:go_router/go_router.dart'; | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:relative_time/relative_time.dart'; | import 'package:relative_time/relative_time.dart'; | ||||||
| @@ -28,6 +29,7 @@ const Map<String, IconData> kNotificationTopicIcons = { | |||||||
|   'passport.security.otp': Symbols.password, |   'passport.security.otp': Symbols.password, | ||||||
|   'interactive.subscription': Symbols.subscriptions, |   'interactive.subscription': Symbols.subscriptions, | ||||||
|   'interactive.feedback': Symbols.add_reaction, |   'interactive.feedback': Symbols.add_reaction, | ||||||
|  |   'interactive.reply': Symbols.reply, | ||||||
|   'messaging.callStart': Symbols.call_received, |   'messaging.callStart': Symbols.call_received, | ||||||
|   'wallet.transaction.new': Symbols.receipt, |   'wallet.transaction.new': Symbols.receipt, | ||||||
| }; | }; | ||||||
| @@ -56,14 +58,12 @@ class _NotificationScreenState extends State<NotificationScreen> { | |||||||
|     try { |     try { | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final nty = context.read<NotificationProvider>(); |       final nty = context.read<NotificationProvider>(); | ||||||
|       final resp = await sn.client.get('/cgi/id/notifications?take=10'); |       final resp = await sn.client.get( | ||||||
|       _totalCount = resp.data['count']; |         '/cgi/id/notifications', | ||||||
|       _notifications.addAll( |         queryParameters: {'take': 10, 'offset': _notifications.length}, | ||||||
|         resp.data['data'] |  | ||||||
|                 ?.map((e) => SnNotification.fromJson(e)) |  | ||||||
|                 .cast<SnNotification>() ?? |  | ||||||
|             [], |  | ||||||
|       ); |       ); | ||||||
|  |       _totalCount = resp.data['count']; | ||||||
|  |       _notifications.addAll(resp.data['data']?.map((e) => SnNotification.fromJson(e)).cast<SnNotification>() ?? []); | ||||||
|       nty.updateTray(); |       nty.updateTray(); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
| @@ -98,9 +98,7 @@ class _NotificationScreenState extends State<NotificationScreen> { | |||||||
|       nty.clear(); |       nty.clear(); | ||||||
|  |  | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showSnackbar( |       context.showSnackbar('notificationMarkAllReadPrompt'.plural(resp.data['count'])); | ||||||
|         'notificationMarkAllReadPrompt'.plural(resp.data['count']), |  | ||||||
|       ); |  | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showErrorDialog(err); |       context.showErrorDialog(err); | ||||||
| @@ -124,9 +122,7 @@ class _NotificationScreenState extends State<NotificationScreen> { | |||||||
|       _fetchNotifications(); |       _fetchNotifications(); | ||||||
|  |  | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showSnackbar( |       context.showSnackbar('notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}'])); | ||||||
|         'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']), |  | ||||||
|       ); |  | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showErrorDialog(err); |       context.showErrorDialog(err); | ||||||
| @@ -147,13 +143,8 @@ class _NotificationScreenState extends State<NotificationScreen> { | |||||||
|  |  | ||||||
|     if (!ua.isAuthorized) { |     if (!ua.isAuthorized) { | ||||||
|       return AppScaffold( |       return AppScaffold( | ||||||
|         appBar: AppBar( |         appBar: AppBar(leading: AutoAppBarLeading(), title: Text('screenNotification').tr()), | ||||||
|           leading: AutoAppBarLeading(), |         body: Center(child: UnauthorizedHint()), | ||||||
|           title: Text('screenNotification').tr(), |  | ||||||
|         ), |  | ||||||
|         body: Center( |  | ||||||
|           child: UnauthorizedHint(), |  | ||||||
|         ), |  | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -162,10 +153,7 @@ class _NotificationScreenState extends State<NotificationScreen> { | |||||||
|         leading: AutoAppBarLeading(), |         leading: AutoAppBarLeading(), | ||||||
|         title: Text('screenNotification').tr(), |         title: Text('screenNotification').tr(), | ||||||
|         actions: [ |         actions: [ | ||||||
|           IconButton( |           IconButton(icon: const Icon(Symbols.checklist), onPressed: _isSubmitting ? null : _markAllAsRead), | ||||||
|             icon: const Icon(Symbols.checklist), |  | ||||||
|             onPressed: _isSubmitting ? null : _markAllAsRead, |  | ||||||
|           ), |  | ||||||
|           const Gap(8), |           const Gap(8), | ||||||
|         ], |         ], | ||||||
|       ), |       ), | ||||||
| @@ -179,17 +167,13 @@ class _NotificationScreenState extends State<NotificationScreen> { | |||||||
|                 return _fetchNotifications(); |                 return _fetchNotifications(); | ||||||
|               }, |               }, | ||||||
|               child: InfiniteList( |               child: InfiniteList( | ||||||
|                 padding: EdgeInsets.only( |                 padding: EdgeInsets.only(top: 16, bottom: math.max(MediaQuery.of(context).padding.bottom, 16)), | ||||||
|                   top: 16, |  | ||||||
|                   bottom: math.max(MediaQuery.of(context).padding.bottom, 16), |  | ||||||
|                 ), |  | ||||||
|                 itemCount: _notifications.length, |                 itemCount: _notifications.length, | ||||||
|                 onFetchData: () { |                 onFetchData: () { | ||||||
|                   _fetchNotifications(); |                   _fetchNotifications(); | ||||||
|                 }, |                 }, | ||||||
|                 isLoading: _isBusy, |                 isLoading: _isBusy, | ||||||
|                 hasReachedMax: _totalCount != null && |                 hasReachedMax: _totalCount != null && _notifications.length >= _totalCount!, | ||||||
|                     _notifications.length >= _totalCount!, |  | ||||||
|                 itemBuilder: (context, idx) { |                 itemBuilder: (context, idx) { | ||||||
|                   final nty = _notifications[idx]; |                   final nty = _notifications[idx]; | ||||||
|                   return Row( |                   return Row( | ||||||
| @@ -202,63 +186,46 @@ class _NotificationScreenState extends State<NotificationScreen> { | |||||||
|                           crossAxisAlignment: CrossAxisAlignment.start, |                           crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|                           children: [ |                           children: [ | ||||||
|                             if (nty.readAt == null) |                             if (nty.readAt == null) | ||||||
|                               StyledWidget(Badge( |                               StyledWidget(Badge(label: Text('notificationUnread').tr())).padding(bottom: 4), | ||||||
|                                 label: Text('notificationUnread').tr(), |                             Text(nty.title, style: Theme.of(context).textTheme.titleMedium), | ||||||
|                               )).padding(bottom: 4), |  | ||||||
|                             Text( |  | ||||||
|                               nty.title, |  | ||||||
|                               style: Theme.of(context).textTheme.titleMedium, |  | ||||||
|                             ), |  | ||||||
|                             if (nty.subtitle != null) |                             if (nty.subtitle != null) | ||||||
|                               Text( |                               Text(nty.subtitle!, style: Theme.of(context).textTheme.titleSmall), | ||||||
|                                 nty.subtitle!, |  | ||||||
|                                 style: Theme.of(context).textTheme.titleSmall, |  | ||||||
|                               ), |  | ||||||
|                             if (nty.subtitle != null) const Gap(4), |                             if (nty.subtitle != null) const Gap(4), | ||||||
|                             SelectionArea( |                             SelectionArea(child: MarkdownTextContent(content: nty.body, isAutoWarp: true)), | ||||||
|                               child: MarkdownTextContent( |  | ||||||
|                                 content: nty.body, |  | ||||||
|                                 isAutoWarp: true, |  | ||||||
|                               ), |  | ||||||
|                             ), |  | ||||||
|                             if ([ |                             if ([ | ||||||
|  |                                   'interactive.reply', | ||||||
|                                   'interactive.feedback', |                                   'interactive.feedback', | ||||||
|                                   'interactive.subscription' |                                   'interactive.subscription', | ||||||
|                                 ].contains(nty.topic) && |                                 ].contains(nty.topic) && | ||||||
|                                 nty.metadata['related_post'] != null) |                                 nty.metadata['related_post'] != null) | ||||||
|                               StyledWidget(Container( |                               GestureDetector( | ||||||
|  |                                 child: Container( | ||||||
|                                   decoration: BoxDecoration( |                                   decoration: BoxDecoration( | ||||||
|                                   borderRadius: const BorderRadius.all( |                                     borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||||
|                                       Radius.circular(8)), |                                     border: Border.all(color: Theme.of(context).dividerColor, width: 1), | ||||||
|                                   border: Border.all( |  | ||||||
|                                     color: Theme.of(context).dividerColor, |  | ||||||
|                                     width: 1, |  | ||||||
|                                   ), |  | ||||||
|                                   ), |                                   ), | ||||||
|                                   child: PostItem( |                                   child: PostItem( | ||||||
|                                   data: SnPost.fromJson( |                                     data: SnPost.fromJson(nty.metadata['related_post']!), | ||||||
|                                     nty.metadata['related_post']!, |  | ||||||
|                                   ), |  | ||||||
|                                     showComments: false, |                                     showComments: false, | ||||||
|                                     showReactions: false, |                                     showReactions: false, | ||||||
|                                     showMenu: false, |                                     showMenu: false, | ||||||
|                                   ), |                                   ), | ||||||
|                               )).padding(top: 8), |                                 ), | ||||||
|  |                                 onTap: () { | ||||||
|  |                                   GoRouter.of(context).pushNamed( | ||||||
|  |                                     'postDetail', | ||||||
|  |                                     pathParameters: {'slug': nty.metadata['related_post']!['id'].toString()}, | ||||||
|  |                                   ); | ||||||
|  |                                 }, | ||||||
|  |                               ).padding(top: 8), | ||||||
|                             const Gap(8), |                             const Gap(8), | ||||||
|                             Row( |                             Row( | ||||||
|                               children: [ |                               children: [ | ||||||
|                                 Text( |                                 Text(DateFormat('yy/MM/dd').format(nty.createdAt)).fontSize(12), | ||||||
|                                   DateFormat('yy/MM/dd').format(nty.createdAt), |  | ||||||
|                                 ).fontSize(12), |  | ||||||
|                                 const Gap(4), |                                 const Gap(4), | ||||||
|                                 Text( |                                 Text('·', style: TextStyle(fontSize: 12)), | ||||||
|                                   '·', |  | ||||||
|                                   style: TextStyle(fontSize: 12), |  | ||||||
|                                 ), |  | ||||||
|                                 const Gap(4), |                                 const Gap(4), | ||||||
|                                 Text( |                                 Text(RelativeTime(context).format(nty.createdAt)).fontSize(12), | ||||||
|                                   RelativeTime(context).format(nty.createdAt), |  | ||||||
|                                 ).fontSize(12), |  | ||||||
|                               ], |                               ], | ||||||
|                             ).opacity(0.75), |                             ).opacity(0.75), | ||||||
|                           ], |                           ], | ||||||
| @@ -268,10 +235,8 @@ class _NotificationScreenState extends State<NotificationScreen> { | |||||||
|                       IconButton( |                       IconButton( | ||||||
|                         icon: const Icon(Symbols.check), |                         icon: const Icon(Symbols.check), | ||||||
|                         padding: EdgeInsets.all(0), |                         padding: EdgeInsets.all(0), | ||||||
|                         visualDensity: |                         visualDensity: const VisualDensity(horizontal: -4, vertical: -4), | ||||||
|                             const VisualDensity(horizontal: -4, vertical: -4), |                         onPressed: _isSubmitting ? null : () => _markOneAsRead(nty), | ||||||
|                         onPressed: |  | ||||||
|                             _isSubmitting ? null : () => _markOneAsRead(nty), |  | ||||||
|                       ), |                       ), | ||||||
|                     ], |                     ], | ||||||
|                   ).padding(horizontal: 16); |                   ).padding(horizontal: 16); | ||||||
|   | |||||||
| @@ -6,7 +6,6 @@ import 'package:gap/gap.dart'; | |||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:responsive_framework/responsive_framework.dart'; |  | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
| import 'package:surface/providers/post.dart'; | import 'package:surface/providers/post.dart'; | ||||||
| import 'package:surface/providers/userinfo.dart'; | import 'package:surface/providers/userinfo.dart'; | ||||||
| @@ -17,7 +16,6 @@ import 'package:surface/widgets/navigation/app_background.dart'; | |||||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
| import 'package:surface/widgets/post/post_comment_list.dart'; | import 'package:surface/widgets/post/post_comment_list.dart'; | ||||||
| import 'package:surface/widgets/post/post_item.dart'; | import 'package:surface/widgets/post/post_item.dart'; | ||||||
| import 'package:surface/widgets/post/post_mini_editor.dart'; |  | ||||||
|  |  | ||||||
| class PostDetailScreen extends StatefulWidget { | class PostDetailScreen extends StatefulWidget { | ||||||
|   final String slug; |   final String slug; | ||||||
| @@ -64,7 +62,8 @@ class _PostDetailScreenState extends State<PostDetailScreen> { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     final ua = context.watch<UserProvider>(); |     final ua = context.watch<UserProvider>(); | ||||||
|     final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; |  | ||||||
|  |     final double maxWidth = _data?.type == 'video' ? double.infinity : 640; | ||||||
|  |  | ||||||
|     return AppBackground( |     return AppBackground( | ||||||
|       isRoot: widget.onBack != null, |       isRoot: widget.onBack != null, | ||||||
| @@ -114,7 +113,7 @@ class _PostDetailScreenState extends State<PostDetailScreen> { | |||||||
|               SliverToBoxAdapter( |               SliverToBoxAdapter( | ||||||
|                 child: PostItem( |                 child: PostItem( | ||||||
|                   data: _data!, |                   data: _data!, | ||||||
|                   maxWidth: 640, |                   maxWidth: maxWidth, | ||||||
|                   showComments: false, |                   showComments: false, | ||||||
|                   showFullPost: true, |                   showFullPost: true, | ||||||
|                   onChanged: (data) { |                   onChanged: (data) { | ||||||
| @@ -125,11 +124,11 @@ class _PostDetailScreenState extends State<PostDetailScreen> { | |||||||
|                   }, |                   }, | ||||||
|                 ), |                 ), | ||||||
|               ), |               ), | ||||||
|             const SliverToBoxAdapter(child: Divider(height: 1)), |             if (_data != null && _data!.type != 'video') const SliverToBoxAdapter(child: Divider(height: 1)), | ||||||
|             if (_data != null) |             if (_data != null && _data!.type != 'video') | ||||||
|               SliverToBoxAdapter( |               SliverToBoxAdapter( | ||||||
|                 child: Container( |                 child: Container( | ||||||
|                   constraints: const BoxConstraints(maxWidth: 640), |                   constraints: BoxConstraints(maxWidth: maxWidth), | ||||||
|                   child: Row( |                   child: Row( | ||||||
|                     crossAxisAlignment: CrossAxisAlignment.center, |                     crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|                     children: [ |                     children: [ | ||||||
| @@ -142,32 +141,12 @@ class _PostDetailScreenState extends State<PostDetailScreen> { | |||||||
|                   ).padding(horizontal: 20, vertical: 12).center(), |                   ).padding(horizontal: 20, vertical: 12).center(), | ||||||
|                 ), |                 ), | ||||||
|               ), |               ), | ||||||
|             if (_data != null && ua.isAuthorized) |             if (_data != null && ua.isAuthorized && _data!.type != 'video') | ||||||
|               SliverToBoxAdapter( |               SliverToBoxAdapter( | ||||||
|                 child: Container( |                 child: PostCommentQuickAction( | ||||||
|                   height: 240, |                   parentPost: _data!, | ||||||
|                   constraints: const BoxConstraints(maxWidth: 640), |                   maxWidth: maxWidth, | ||||||
|                   margin: |                   onPosted: () { | ||||||
|                       ResponsiveBreakpoints.of(context).largerThan(MOBILE) ? const EdgeInsets.all(8) : EdgeInsets.zero, |  | ||||||
|                   decoration: BoxDecoration( |  | ||||||
|                     borderRadius: ResponsiveBreakpoints.of(context).largerThan(MOBILE) |  | ||||||
|                         ? const BorderRadius.all(Radius.circular(8)) |  | ||||||
|                         : BorderRadius.zero, |  | ||||||
|                     border: ResponsiveBreakpoints.of(context).largerThan(MOBILE) |  | ||||||
|                         ? Border.all( |  | ||||||
|                             color: Theme.of(context).dividerColor, |  | ||||||
|                             width: 1 / devicePixelRatio, |  | ||||||
|                           ) |  | ||||||
|                         : Border.symmetric( |  | ||||||
|                             horizontal: BorderSide( |  | ||||||
|                               color: Theme.of(context).dividerColor, |  | ||||||
|                               width: 1 / devicePixelRatio, |  | ||||||
|                             ), |  | ||||||
|                           ), |  | ||||||
|                   ), |  | ||||||
|                   child: PostMiniEditor( |  | ||||||
|                     postReplyId: _data!.id, |  | ||||||
|                     onPost: () { |  | ||||||
|                     setState(() { |                     setState(() { | ||||||
|                       _data = _data!.copyWith( |                       _data = _data!.copyWith( | ||||||
|                         metric: _data!.metric.copyWith( |                         metric: _data!.metric.copyWith( | ||||||
| @@ -178,15 +157,14 @@ class _PostDetailScreenState extends State<PostDetailScreen> { | |||||||
|                     _childListKey.currentState!.refresh(); |                     _childListKey.currentState!.refresh(); | ||||||
|                   }, |                   }, | ||||||
|                 ), |                 ), | ||||||
|                 ).center(), |  | ||||||
|               ), |               ), | ||||||
|             if (_data != null) |             if (_data != null && _data!.type != 'video') | ||||||
|               PostCommentSliverList( |               PostCommentSliverList( | ||||||
|                 key: _childListKey, |                 key: _childListKey, | ||||||
|                 parentPostId: _data!.id, |                 parentPost: _data!, | ||||||
|                 maxWidth: 640, |                 maxWidth: maxWidth, | ||||||
|               ), |               ), | ||||||
|             SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)), |             if (_data != null && _data!.type == 'video') SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)), | ||||||
|           ], |           ], | ||||||
|         ), |         ), | ||||||
|       ), |       ), | ||||||
|   | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,7 +1,6 @@ | |||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:gap/gap.dart'; | import 'package:gap/gap.dart'; | ||||||
| import 'package:go_router/go_router.dart'; |  | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
| @@ -134,7 +133,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> { | |||||||
|       body: Stack( |       body: Stack( | ||||||
|         children: [ |         children: [ | ||||||
|           InfiniteList( |           InfiniteList( | ||||||
|             padding: const EdgeInsets.only(top: 100), |             padding: const EdgeInsets.only(top: 100 + 8), | ||||||
|             itemCount: _posts.length, |             itemCount: _posts.length, | ||||||
|             isLoading: _isBusy, |             isLoading: _isBusy, | ||||||
|             hasReachedMax: _postCount != null && _posts.length >= _postCount!, |             hasReachedMax: _postCount != null && _posts.length >= _postCount!, | ||||||
| @@ -142,8 +141,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> { | |||||||
|               _fetchPosts(); |               _fetchPosts(); | ||||||
|             }, |             }, | ||||||
|             itemBuilder: (context, idx) { |             itemBuilder: (context, idx) { | ||||||
|               return GestureDetector( |               return OpenablePostItem( | ||||||
|                 child: PostItem( |  | ||||||
|                 data: _posts[idx], |                 data: _posts[idx], | ||||||
|                 maxWidth: 640, |                 maxWidth: 640, | ||||||
|                 onChanged: (data) { |                 onChanged: (data) { | ||||||
| @@ -152,17 +150,9 @@ class _PostSearchScreenState extends State<PostSearchScreen> { | |||||||
|                 onDeleted: () { |                 onDeleted: () { | ||||||
|                   _refreshPosts(); |                   _refreshPosts(); | ||||||
|                 }, |                 }, | ||||||
|                 ), |  | ||||||
|                 onTap: () { |  | ||||||
|                   GoRouter.of(context).pushNamed( |  | ||||||
|                     'postDetail', |  | ||||||
|                     pathParameters: {'slug': _posts[idx].id.toString()}, |  | ||||||
|                     extra: _posts[idx], |  | ||||||
|               ); |               ); | ||||||
|             }, |             }, | ||||||
|               ); |             separatorBuilder: (_, __) => const Gap(8), | ||||||
|             }, |  | ||||||
|             separatorBuilder: (context, index) => const Divider(height: 1), |  | ||||||
|           ), |           ), | ||||||
|           Positioned( |           Positioned( | ||||||
|             top: 16, |             top: 16, | ||||||
|   | |||||||
| @@ -597,25 +597,16 @@ class _PublisherPostList extends StatelessWidget { | |||||||
|       hasReachedMax: postCount != null && posts.length >= postCount!, |       hasReachedMax: postCount != null && posts.length >= postCount!, | ||||||
|       onFetchData: fetchPosts, |       onFetchData: fetchPosts, | ||||||
|       itemBuilder: (context, idx) { |       itemBuilder: (context, idx) { | ||||||
|         return GestureDetector( |         return OpenablePostItem( | ||||||
|           child: PostItem( |  | ||||||
|           data: posts[idx], |           data: posts[idx], | ||||||
|           maxWidth: 640, |           maxWidth: 640, | ||||||
|           onChanged: (data) { |           onChanged: (data) { | ||||||
|             onChanged(idx, data); |             onChanged(idx, data); | ||||||
|           }, |           }, | ||||||
|           onDeleted: onDeleted, |           onDeleted: onDeleted, | ||||||
|           ), |  | ||||||
|           onTap: () { |  | ||||||
|             GoRouter.of(context).pushNamed( |  | ||||||
|               'postDetail', |  | ||||||
|               pathParameters: {'slug': posts[idx].id.toString()}, |  | ||||||
|               extra: posts[idx], |  | ||||||
|         ); |         ); | ||||||
|       }, |       }, | ||||||
|         ); |       separatorBuilder: (_, __) => const Gap(8), | ||||||
|       }, |  | ||||||
|       separatorBuilder: (context, index) => const Divider(height: 1), |  | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -4,17 +4,16 @@ import 'package:gap/gap.dart'; | |||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:surface/providers/config.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/providers/userinfo.dart'; | import 'package:surface/providers/userinfo.dart'; | ||||||
| import 'package:surface/types/realm.dart'; | import 'package:surface/types/realm.dart'; | ||||||
| import 'package:surface/widgets/account/account_image.dart'; |  | ||||||
| import 'package:surface/widgets/app_bar_leading.dart'; | import 'package:surface/widgets/app_bar_leading.dart'; | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
| import 'package:surface/widgets/loading_indicator.dart'; | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  | import 'package:surface/widgets/realm/realm_item.dart'; | ||||||
| import 'package:surface/widgets/unauthorized_hint.dart'; | import 'package:surface/widgets/unauthorized_hint.dart'; | ||||||
| import 'package:surface/widgets/universal_image.dart'; |  | ||||||
|  |  | ||||||
| class RealmScreen extends StatefulWidget { | class RealmScreen extends StatefulWidget { | ||||||
|   const RealmScreen({super.key}); |   const RealmScreen({super.key}); | ||||||
| @@ -75,12 +74,12 @@ class _RealmScreenState extends State<RealmScreen> { | |||||||
|   @override |   @override | ||||||
|   void initState() { |   void initState() { | ||||||
|     super.initState(); |     super.initState(); | ||||||
|  |     _isCompactView = context.read<ConfigProvider>().realmCompactView; | ||||||
|     _fetchRealms(); |     _fetchRealms(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     final sn = context.read<SnNetworkProvider>(); |  | ||||||
|     final ua = context.read<UserProvider>(); |     final ua = context.read<UserProvider>(); | ||||||
|  |  | ||||||
|     if (!ua.isAuthorized) { |     if (!ua.isAuthorized) { | ||||||
| @@ -100,10 +99,17 @@ class _RealmScreenState extends State<RealmScreen> { | |||||||
|         leading: AutoAppBarLeading(), |         leading: AutoAppBarLeading(), | ||||||
|         title: Text('screenRealm').tr(), |         title: Text('screenRealm').tr(), | ||||||
|         actions: [ |         actions: [ | ||||||
|  |           IconButton( | ||||||
|  |             icon: const Icon(Symbols.globe), | ||||||
|  |             onPressed: () { | ||||||
|  |               GoRouter.of(context).pushNamed('realmDiscovery'); | ||||||
|  |             }, | ||||||
|  |           ), | ||||||
|           IconButton( |           IconButton( | ||||||
|             icon: !_isCompactView ? const Icon(Symbols.view_list) : const Icon(Symbols.view_module), |             icon: !_isCompactView ? const Icon(Symbols.view_list) : const Icon(Symbols.view_module), | ||||||
|             onPressed: () { |             onPressed: () { | ||||||
|               setState(() => _isCompactView = !_isCompactView); |               setState(() => _isCompactView = !_isCompactView); | ||||||
|  |               context.read<ConfigProvider>().realmCompactView = _isCompactView; | ||||||
|             }, |             }, | ||||||
|           ), |           ), | ||||||
|           const Gap(8), |           const Gap(8), | ||||||
| @@ -128,21 +134,12 @@ class _RealmScreenState extends State<RealmScreen> { | |||||||
|                   itemCount: _realms?.length ?? 0, |                   itemCount: _realms?.length ?? 0, | ||||||
|                   itemBuilder: (context, idx) { |                   itemBuilder: (context, idx) { | ||||||
|                     final realm = _realms![idx]; |                     final realm = _realms![idx]; | ||||||
|                     if (_isCompactView) { |  | ||||||
|                       return ListTile( |                     return RealmItemWidget( | ||||||
|                         contentPadding: const EdgeInsets.symmetric(horizontal: 16), |                       showPopularity: false, | ||||||
|                         leading: AccountImage( |                       item: realm, | ||||||
|                           content: realm.avatar, |                       isListView: _isCompactView, | ||||||
|                           fallbackWidget: const Icon(Symbols.group, size: 20), |                       actionListView: [ | ||||||
|                         ), |  | ||||||
|                         title: Text(realm.name), |  | ||||||
|                         subtitle: Text( |  | ||||||
|                           realm.description, |  | ||||||
|                           maxLines: 1, |  | ||||||
|                           overflow: TextOverflow.ellipsis, |  | ||||||
|                         ), |  | ||||||
|                         trailing: PopupMenuButton( |  | ||||||
|                           itemBuilder: (BuildContext context) => [ |  | ||||||
|                         PopupMenuItem( |                         PopupMenuItem( | ||||||
|                           child: Row( |                           child: Row( | ||||||
|                             children: [ |                             children: [ | ||||||
| @@ -175,75 +172,9 @@ class _RealmScreenState extends State<RealmScreen> { | |||||||
|                           }, |                           }, | ||||||
|                         ), |                         ), | ||||||
|                       ], |                       ], | ||||||
|                         ), |                       onUpdate: _fetchRealms, | ||||||
|                         onTap: () { |  | ||||||
|                           GoRouter.of(context).pushNamed( |  | ||||||
|                             'realmDetail', |  | ||||||
|                             pathParameters: {'alias': realm.alias}, |  | ||||||
|                     ); |                     ); | ||||||
|                   }, |                   }, | ||||||
|                       ); |  | ||||||
|                     } |  | ||||||
|  |  | ||||||
|                     return Container( |  | ||||||
|                       constraints: BoxConstraints(maxWidth: 640), |  | ||||||
|                       child: Card( |  | ||||||
|                         margin: const EdgeInsets.all(12), |  | ||||||
|                         child: InkWell( |  | ||||||
|                           borderRadius: const BorderRadius.all(Radius.circular(8)), |  | ||||||
|                           child: Column( |  | ||||||
|                             crossAxisAlignment: CrossAxisAlignment.start, |  | ||||||
|                             children: [ |  | ||||||
|                               AspectRatio( |  | ||||||
|                                 aspectRatio: 16 / 7, |  | ||||||
|                                 child: Stack( |  | ||||||
|                                   clipBehavior: Clip.none, |  | ||||||
|                                   fit: StackFit.expand, |  | ||||||
|                                   children: [ |  | ||||||
|                                     ClipRRect( |  | ||||||
|                                       borderRadius: const BorderRadius.all(Radius.circular(8)), |  | ||||||
|                                       child: Container( |  | ||||||
|                                         color: Theme.of(context).colorScheme.surfaceContainer, |  | ||||||
|                                         child: (realm.banner?.isEmpty ?? true) |  | ||||||
|                                             ? const SizedBox.shrink() |  | ||||||
|                                             : AutoResizeUniversalImage( |  | ||||||
|                                                 sn.getAttachmentUrl(realm.banner!), |  | ||||||
|                                                 fit: BoxFit.cover, |  | ||||||
|                                               ), |  | ||||||
|                                       ), |  | ||||||
|                                     ), |  | ||||||
|                                     Positioned( |  | ||||||
|                                       bottom: -30, |  | ||||||
|                                       left: 18, |  | ||||||
|                                       child: AccountImage( |  | ||||||
|                                         content: realm.avatar, |  | ||||||
|                                         radius: 24, |  | ||||||
|                                         fallbackWidget: const Icon(Symbols.group, size: 24), |  | ||||||
|                                       ), |  | ||||||
|                                     ), |  | ||||||
|                                   ], |  | ||||||
|                                 ), |  | ||||||
|                               ), |  | ||||||
|                               const Gap(20 + 12), |  | ||||||
|                               Column( |  | ||||||
|                                 crossAxisAlignment: CrossAxisAlignment.start, |  | ||||||
|                                 children: [ |  | ||||||
|                                   Text(realm.name).textStyle(Theme.of(context).textTheme.titleMedium!), |  | ||||||
|                                   Text(realm.description).textStyle(Theme.of(context).textTheme.bodySmall!), |  | ||||||
|                                 ], |  | ||||||
|                               ).padding(horizontal: 24, bottom: 14), |  | ||||||
|                             ], |  | ||||||
|                           ), |  | ||||||
|                           onTap: () { |  | ||||||
|                             GoRouter.of(context).pushNamed( |  | ||||||
|                               'realmDetail', |  | ||||||
|                               pathParameters: {'alias': realm.alias}, |  | ||||||
|                             ); |  | ||||||
|                           }, |  | ||||||
|                         ), |  | ||||||
|                       ), |  | ||||||
|                     ).center(); |  | ||||||
|                   }, |  | ||||||
|                 ), |                 ), | ||||||
|               ), |               ), | ||||||
|             ), |             ), | ||||||
|   | |||||||
| @@ -50,6 +50,8 @@ class _RealmManageScreenState extends State<RealmManageScreen> { | |||||||
|       _aliasController.text = out.alias; |       _aliasController.text = out.alias; | ||||||
|       _nameController.text = out.name; |       _nameController.text = out.name; | ||||||
|       _descriptionController.text = out.description; |       _descriptionController.text = out.description; | ||||||
|  |       _isPublic = out.isPublic; | ||||||
|  |       _isCommunity = out.isCommunity; | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       // ignore: use_build_context_synchronously |       // ignore: use_build_context_synchronously | ||||||
|       if (context.mounted) context.showErrorDialog(err); |       if (context.mounted) context.showErrorDialog(err); | ||||||
| @@ -67,6 +69,9 @@ class _RealmManageScreenState extends State<RealmManageScreen> { | |||||||
|  |  | ||||||
|   final _imagePicker = ImagePicker(); |   final _imagePicker = ImagePicker(); | ||||||
|  |  | ||||||
|  |   bool _isPublic = false; | ||||||
|  |   bool _isCommunity = false; | ||||||
|  |  | ||||||
|   Future<void> _updateImage(String place) async { |   Future<void> _updateImage(String place) async { | ||||||
|     final image = await _imagePicker.pickImage(source: ImageSource.gallery); |     final image = await _imagePicker.pickImage(source: ImageSource.gallery); | ||||||
|     if (image == null) return; |     if (image == null) return; | ||||||
| @@ -138,6 +143,8 @@ class _RealmManageScreenState extends State<RealmManageScreen> { | |||||||
|       'description': _descriptionController.text, |       'description': _descriptionController.text, | ||||||
|       'avatar': _avatar, |       'avatar': _avatar, | ||||||
|       'banner': _banner, |       'banner': _banner, | ||||||
|  |       'is_public': _isPublic, | ||||||
|  |       'is_community': _isCommunity, | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
| @@ -293,6 +300,23 @@ class _RealmManageScreenState extends State<RealmManageScreen> { | |||||||
|                       FocusManager.instance.primaryFocus?.unfocus(), |                       FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                 ), |                 ), | ||||||
|                 const Gap(12), |                 const Gap(12), | ||||||
|  |                 CheckboxListTile( | ||||||
|  |                   value: _isPublic, | ||||||
|  |                   title: Text('realmIsPublic'.tr()), | ||||||
|  |                   subtitle: Text('realmIsPublicDescription'.tr()), | ||||||
|  |                   onChanged: (value) { | ||||||
|  |                     setState(() => _isPublic = value ?? false); | ||||||
|  |                   }, | ||||||
|  |                 ), | ||||||
|  |                 CheckboxListTile( | ||||||
|  |                   value: _isCommunity, | ||||||
|  |                   title: Text('realmIsCommunity'.tr()), | ||||||
|  |                   subtitle: Text('realmIsCommunityDescription'.tr()), | ||||||
|  |                   onChanged: (value) { | ||||||
|  |                     setState(() => _isCommunity = value ?? false); | ||||||
|  |                   }, | ||||||
|  |                 ), | ||||||
|  |                 const Gap(12), | ||||||
|                 Row( |                 Row( | ||||||
|                   mainAxisAlignment: MainAxisAlignment.end, |                   mainAxisAlignment: MainAxisAlignment.end, | ||||||
|                   children: [ |                   children: [ | ||||||
|   | |||||||
| @@ -5,14 +5,19 @@ import 'package:go_router/go_router.dart'; | |||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:surface/providers/post.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
| import 'package:surface/providers/user_directory.dart'; | import 'package:surface/providers/user_directory.dart'; | ||||||
| import 'package:surface/providers/userinfo.dart'; | import 'package:surface/providers/userinfo.dart'; | ||||||
|  | import 'package:surface/types/account.dart'; | ||||||
|  | import 'package:surface/types/chat.dart'; | ||||||
| import 'package:surface/types/post.dart'; | import 'package:surface/types/post.dart'; | ||||||
| import 'package:surface/types/realm.dart'; | import 'package:surface/types/realm.dart'; | ||||||
| import 'package:surface/widgets/account/account_image.dart'; | import 'package:surface/widgets/account/account_image.dart'; | ||||||
|  | import 'package:surface/widgets/account/account_select.dart'; | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  | import 'package:surface/widgets/post/post_item.dart'; | ||||||
| import 'package:very_good_infinite_list/very_good_infinite_list.dart'; | import 'package:very_good_infinite_list/very_good_infinite_list.dart'; | ||||||
|  |  | ||||||
| class RealmDetailScreen extends StatefulWidget { | class RealmDetailScreen extends StatefulWidget { | ||||||
| @@ -46,7 +51,8 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> { | |||||||
|   Future<void> _fetchPublishers() async { |   Future<void> _fetchPublishers() async { | ||||||
|     try { |     try { | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final resp = await sn.client.get('/cgi/co/publishers?realm=${widget.alias}'); |       final resp = | ||||||
|  |           await sn.client.get('/cgi/co/publishers?realm=${widget.alias}'); | ||||||
|       _publishers = List<SnPublisher>.from( |       _publishers = List<SnPublisher>.from( | ||||||
|         resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], |         resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], | ||||||
|       ); |       ); | ||||||
| @@ -58,31 +64,68 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   List<SnChannel>? _channels; | ||||||
|  |  | ||||||
|  |   Future<void> _fetchChannels() async { | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       final resp = | ||||||
|  |           await sn.client.get('/cgi/im/channels/${widget.alias}/public'); | ||||||
|  |       _channels = List<SnChannel>.from( | ||||||
|  |         resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(), | ||||||
|  |       ); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (mounted) context.showErrorDialog(err); | ||||||
|  |       rethrow; | ||||||
|  |     } finally { | ||||||
|  |       setState(() {}); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void initState() { |   void initState() { | ||||||
|     super.initState(); |     super.initState(); | ||||||
|     _fetchRealm().then((_) { |     _fetchRealm().then((_) { | ||||||
|       _fetchPublishers(); |       _fetchPublishers(); | ||||||
|  |       _fetchChannels(); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return DefaultTabController( |     return DefaultTabController( | ||||||
|       length: 3, |       length: 4, | ||||||
|       child: AppScaffold( |       child: AppScaffold( | ||||||
|         body: NestedScrollView( |         body: NestedScrollView( | ||||||
|           headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { |           headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { | ||||||
|             return <Widget>[ |             return <Widget>[ | ||||||
|               SliverOverlapAbsorber( |               SliverOverlapAbsorber( | ||||||
|                 handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), |                 handle: | ||||||
|  |                     NestedScrollView.sliverOverlapAbsorberHandleFor(context), | ||||||
|                 sliver: SliverAppBar( |                 sliver: SliverAppBar( | ||||||
|                   title: Text(_realm?.name ?? 'loading'.tr()), |                   title: Text(_realm?.name ?? 'loading'.tr()), | ||||||
|                   bottom: TabBar( |                   bottom: TabBar( | ||||||
|                     tabs: [ |                     tabs: [ | ||||||
|                       Tab(icon: Icon(Symbols.home, color: Theme.of(context).appBarTheme.foregroundColor)), |                       Tab( | ||||||
|                       Tab(icon: Icon(Symbols.group, color: Theme.of(context).appBarTheme.foregroundColor)), |                           icon: Icon(Symbols.home, | ||||||
|                       Tab(icon: Icon(Symbols.settings, color: Theme.of(context).appBarTheme.foregroundColor)), |                               color: Theme.of(context) | ||||||
|  |                                   .appBarTheme | ||||||
|  |                                   .foregroundColor)), | ||||||
|  |                       Tab( | ||||||
|  |                           icon: Icon(Symbols.explore, | ||||||
|  |                               color: Theme.of(context) | ||||||
|  |                                   .appBarTheme | ||||||
|  |                                   .foregroundColor)), | ||||||
|  |                       Tab( | ||||||
|  |                           icon: Icon(Symbols.group, | ||||||
|  |                               color: Theme.of(context) | ||||||
|  |                                   .appBarTheme | ||||||
|  |                                   .foregroundColor)), | ||||||
|  |                       Tab( | ||||||
|  |                           icon: Icon(Symbols.settings, | ||||||
|  |                               color: Theme.of(context) | ||||||
|  |                                   .appBarTheme | ||||||
|  |                                   .foregroundColor)), | ||||||
|                     ], |                     ], | ||||||
|                   ), |                   ), | ||||||
|                 ), |                 ), | ||||||
| @@ -91,7 +134,9 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> { | |||||||
|           }, |           }, | ||||||
|           body: TabBarView( |           body: TabBarView( | ||||||
|             children: [ |             children: [ | ||||||
|               _RealmDetailHomeWidget(realm: _realm, publishers: _publishers), |               _RealmDetailHomeWidget( | ||||||
|  |                   realm: _realm, publishers: _publishers, channels: _channels), | ||||||
|  |               _RealmPostListWidget(realm: _realm), | ||||||
|               _RealmMemberListWidget(realm: _realm), |               _RealmMemberListWidget(realm: _realm), | ||||||
|               _RealmSettingsWidget( |               _RealmSettingsWidget( | ||||||
|                 realm: _realm, |                 realm: _realm, | ||||||
| @@ -110,8 +155,10 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> { | |||||||
| class _RealmDetailHomeWidget extends StatelessWidget { | class _RealmDetailHomeWidget extends StatelessWidget { | ||||||
|   final SnRealm? realm; |   final SnRealm? realm; | ||||||
|   final List<SnPublisher>? publishers; |   final List<SnPublisher>? publishers; | ||||||
|  |   final List<SnChannel>? channels; | ||||||
|  |  | ||||||
|   const _RealmDetailHomeWidget({required this.realm, this.publishers}); |   const _RealmDetailHomeWidget( | ||||||
|  |       {required this.realm, this.publishers, this.channels}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
| @@ -133,10 +180,21 @@ class _RealmDetailHomeWidget extends StatelessWidget { | |||||||
|             ], |             ], | ||||||
|           ).padding(horizontal: 24), |           ).padding(horizontal: 24), | ||||||
|         const Gap(16), |         const Gap(16), | ||||||
|         const Divider(), |         const Divider(height: 1), | ||||||
|         Expanded( |         Expanded( | ||||||
|           child: ListView.builder( |           child: CustomScrollView( | ||||||
|             padding: EdgeInsets.zero, |             slivers: [ | ||||||
|  |               if (publishers?.isNotEmpty ?? false) | ||||||
|  |                 SliverToBoxAdapter( | ||||||
|  |                   child: Container( | ||||||
|  |                     width: double.infinity, | ||||||
|  |                     color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||||
|  |                     child: Text('realmCommunityPublishersHint'.tr(), | ||||||
|  |                             style: Theme.of(context).textTheme.bodyMedium) | ||||||
|  |                         .padding(horizontal: 24, vertical: 8), | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |               SliverList.builder( | ||||||
|                 itemCount: publishers?.length ?? 0, |                 itemCount: publishers?.length ?? 0, | ||||||
|                 itemBuilder: (context, idx) { |                 itemBuilder: (context, idx) { | ||||||
|                   final ele = publishers![idx]; |                   final ele = publishers![idx]; | ||||||
| @@ -158,12 +216,115 @@ class _RealmDetailHomeWidget extends StatelessWidget { | |||||||
|                   ); |                   ); | ||||||
|                 }, |                 }, | ||||||
|               ), |               ), | ||||||
|  |               if (channels?.isNotEmpty ?? false) | ||||||
|  |                 SliverToBoxAdapter( | ||||||
|  |                   child: Container( | ||||||
|  |                     width: double.infinity, | ||||||
|  |                     color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||||
|  |                     child: Text('realmCommunityPublicChannelsHint'.tr(), | ||||||
|  |                             style: Theme.of(context).textTheme.bodyMedium) | ||||||
|  |                         .padding(horizontal: 24, vertical: 8), | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |               SliverList.builder( | ||||||
|  |                 itemCount: channels?.length ?? 0, | ||||||
|  |                 itemBuilder: (context, idx) { | ||||||
|  |                   final ele = channels![idx]; | ||||||
|  |                   return ListTile( | ||||||
|  |                     contentPadding: const EdgeInsets.symmetric(horizontal: 20), | ||||||
|  |                     leading: AccountImage( | ||||||
|  |                       content: null, | ||||||
|  |                       fallbackWidget: const Icon(Symbols.chat, size: 20), | ||||||
|  |                     ), | ||||||
|  |                     title: Text(ele.name), | ||||||
|  |                     subtitle: Text('#${ele.alias}'), | ||||||
|  |                     trailing: const Icon(Symbols.chevron_right), | ||||||
|  |                     onTap: () { | ||||||
|  |                       GoRouter.of(context).pushNamed( | ||||||
|  |                         'chatRoom', | ||||||
|  |                         pathParameters: { | ||||||
|  |                           'scope': realm?.alias ?? 'global', | ||||||
|  |                           'alias': ele.alias, | ||||||
|  |                         }, | ||||||
|  |                       ); | ||||||
|  |                     }, | ||||||
|  |                   ); | ||||||
|  |                 }, | ||||||
|  |               ), | ||||||
|  |             ], | ||||||
|  |           ), | ||||||
|         ), |         ), | ||||||
|       ], |       ], | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | class _RealmPostListWidget extends StatefulWidget { | ||||||
|  |   final SnRealm? realm; | ||||||
|  |  | ||||||
|  |   const _RealmPostListWidget({this.realm}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<_RealmPostListWidget> createState() => _RealmPostListWidgetState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _RealmPostListWidgetState extends State<_RealmPostListWidget> { | ||||||
|  |   bool _isBusy = false; | ||||||
|  |   int? _totalCount; | ||||||
|  |   final List<SnPost> _posts = List.empty(growable: true); | ||||||
|  |  | ||||||
|  |   Future<void> _fetchPosts() async { | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       final pt = context.read<SnPostContentProvider>(); | ||||||
|  |       final out = await pt.listPosts( | ||||||
|  |         take: 10, | ||||||
|  |         offset: _posts.length, | ||||||
|  |         realm: widget.realm?.id.toString(), | ||||||
|  |       ); | ||||||
|  |       _totalCount = out.$2; | ||||||
|  |       _posts.addAll(out.$1); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return MediaQuery.removePadding( | ||||||
|  |       context: context, | ||||||
|  |       removeTop: true, | ||||||
|  |       child: RefreshIndicator( | ||||||
|  |         onRefresh: _fetchPosts, | ||||||
|  |         child: InfiniteList( | ||||||
|  |           itemCount: _posts.length, | ||||||
|  |           isLoading: _isBusy, | ||||||
|  |           hasReachedMax: _totalCount != null && _posts.length >= _totalCount!, | ||||||
|  |           onFetchData: _fetchPosts, | ||||||
|  |           itemBuilder: (context, idx) { | ||||||
|  |             final post = _posts[idx]; | ||||||
|  |             return OpenablePostItem( | ||||||
|  |               data: post, | ||||||
|  |               maxWidth: 640, | ||||||
|  |               onChanged: (data) { | ||||||
|  |                 setState(() => _posts[idx] = data); | ||||||
|  |               }, | ||||||
|  |               onDeleted: () { | ||||||
|  |                 setState(() => _posts.removeAt(idx)); | ||||||
|  |               }, | ||||||
|  |             ); | ||||||
|  |           }, | ||||||
|  |           separatorBuilder: (_, __) => const Gap(8), | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ).padding(top: 8); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
| class _RealmMemberListWidget extends StatefulWidget { | class _RealmMemberListWidget extends StatefulWidget { | ||||||
|   final SnRealm? realm; |   final SnRealm? realm; | ||||||
|  |  | ||||||
| @@ -185,9 +346,11 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> { | |||||||
|     try { |     try { | ||||||
|       final ud = context.read<UserDirectoryProvider>(); |       final ud = context.read<UserDirectoryProvider>(); | ||||||
|       final sn = context.read<SnNetworkProvider>(); |       final sn = context.read<SnNetworkProvider>(); | ||||||
|       final resp = await sn.client.get('/cgi/id/realms/${widget.realm!.alias}/members', queryParameters: { |       final resp = await sn.client.get( | ||||||
|  |           '/cgi/id/realms/${widget.realm!.alias}/members', | ||||||
|  |           queryParameters: { | ||||||
|             'take': 10, |             'take': 10, | ||||||
|         'offset': 0, |             'offset': _members.length, | ||||||
|           }); |           }); | ||||||
|  |  | ||||||
|       final out = List<SnRealmMember>.from( |       final out = List<SnRealmMember>.from( | ||||||
| @@ -229,13 +392,35 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void _showMemberAdd() { |   Future<void> _addMember(SnAccount related) async { | ||||||
|     showModalBottomSheet( |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       await sn.client.post( | ||||||
|  |         '/cgi/id/realms/${widget.realm!.alias}/members', | ||||||
|  |         data: {'related': related.name}, | ||||||
|  |       ); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showSnackbar('realmMemberAdded'.tr()); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void _showMemberAdd() async { | ||||||
|  |     final user = await showModalBottomSheet<SnAccount?>( | ||||||
|       context: context, |       context: context, | ||||||
|       builder: (context) => _NewRealmMemberWidget( |       builder: (context) => AccountSelect( | ||||||
|         realm: widget.realm!, |         title: 'realmMemberAdd'.tr(), | ||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
|  |     if (!mounted) return; | ||||||
|  |     if (user == null) return; | ||||||
|  |     _addMember(user); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
| @@ -272,14 +457,14 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> { | |||||||
|             return ListTile( |             return ListTile( | ||||||
|               contentPadding: const EdgeInsets.only(right: 24, left: 16), |               contentPadding: const EdgeInsets.only(right: 24, left: 16), | ||||||
|               leading: AccountImage( |               leading: AccountImage( | ||||||
|                 content: ud.getAccountFromCache(member.accountId)?.avatar, |                 content: ud.getFromCache(member.accountId)?.avatar, | ||||||
|                 fallbackWidget: const Icon(Symbols.group, size: 24), |                 fallbackWidget: const Icon(Symbols.group, size: 24), | ||||||
|               ), |               ), | ||||||
|               title: Text( |               title: Text( | ||||||
|                 ud.getAccountFromCache(member.accountId)?.nick ?? 'unknown'.tr(), |                 ud.getFromCache(member.accountId)?.nick ?? 'unknown'.tr(), | ||||||
|               ), |               ), | ||||||
|               subtitle: Text( |               subtitle: Text( | ||||||
|                 ud.getAccountFromCache(member.accountId)?.name ?? 'unknown'.tr(), |                 ud.getFromCache(member.accountId)?.name ?? 'unknown'.tr(), | ||||||
|               ), |               ), | ||||||
|               trailing: IconButton( |               trailing: IconButton( | ||||||
|                 icon: const Icon(Symbols.person_remove), |                 icon: const Icon(Symbols.person_remove), | ||||||
| @@ -293,85 +478,6 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> { | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| class _NewRealmMemberWidget extends StatefulWidget { |  | ||||||
|   final SnRealm realm; |  | ||||||
|  |  | ||||||
|   const _NewRealmMemberWidget({required this.realm}); |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   State<_NewRealmMemberWidget> createState() => _NewRealmMemberWidgetState(); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class _NewRealmMemberWidgetState extends State<_NewRealmMemberWidget> { |  | ||||||
|   bool _isBusy = false; |  | ||||||
|  |  | ||||||
|   final TextEditingController _relatedController = TextEditingController(); |  | ||||||
|  |  | ||||||
|   Future<void> _performAction() async { |  | ||||||
|     if (_relatedController.text.isEmpty) return; |  | ||||||
|  |  | ||||||
|     setState(() => _isBusy = true); |  | ||||||
|  |  | ||||||
|     try { |  | ||||||
|       final sn = context.read<SnNetworkProvider>(); |  | ||||||
|       await sn.client.post( |  | ||||||
|         '/cgi/id/realms/${widget.realm.alias}/members', |  | ||||||
|         data: { |  | ||||||
|           'related': _relatedController.text, |  | ||||||
|         }, |  | ||||||
|       ); |  | ||||||
|       if (!mounted) return; |  | ||||||
|       Navigator.pop(context, true); |  | ||||||
|       context.showSnackbar('channelMemberAdded'.tr()); |  | ||||||
|     } catch (err) { |  | ||||||
|       if (!mounted) return; |  | ||||||
|       context.showErrorDialog(err); |  | ||||||
|     } finally { |  | ||||||
|       setState(() => _isBusy = false); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   void dispose() { |  | ||||||
|     super.dispose(); |  | ||||||
|     _relatedController.dispose(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   Widget build(BuildContext context) { |  | ||||||
|     return StyledWidget(Column( |  | ||||||
|       crossAxisAlignment: CrossAxisAlignment.start, |  | ||||||
|       children: [ |  | ||||||
|         Text( |  | ||||||
|           'realmMemberAdd', |  | ||||||
|           style: Theme.of(context).textTheme.titleLarge, |  | ||||||
|         ).tr(), |  | ||||||
|         const Gap(12), |  | ||||||
|         TextField( |  | ||||||
|           controller: _relatedController, |  | ||||||
|           readOnly: _isBusy, |  | ||||||
|           autocorrect: false, |  | ||||||
|           autofocus: true, |  | ||||||
|           textCapitalization: TextCapitalization.none, |  | ||||||
|           decoration: InputDecoration( |  | ||||||
|             labelText: 'fieldMemberRelatedName'.tr(), |  | ||||||
|             suffix: SizedBox( |  | ||||||
|               height: 24, |  | ||||||
|               child: IconButton( |  | ||||||
|                 onPressed: _isBusy ? null : () => _performAction(), |  | ||||||
|                 icon: Icon(Symbols.send), |  | ||||||
|                 visualDensity: const VisualDensity(horizontal: -4, vertical: -4), |  | ||||||
|                 padding: EdgeInsets.zero, |  | ||||||
|               ), |  | ||||||
|             ), |  | ||||||
|           ), |  | ||||||
|           onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |  | ||||||
|         ) |  | ||||||
|       ], |  | ||||||
|     )).padding(all: 24); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class _RealmSettingsWidget extends StatefulWidget { | class _RealmSettingsWidget extends StatefulWidget { | ||||||
|   final SnRealm? realm; |   final SnRealm? realm; | ||||||
|   final Function() onUpdate; |   final Function() onUpdate; | ||||||
| @@ -398,12 +504,31 @@ class _RealmSettingsWidgetState extends State<_RealmSettingsWidget> { | |||||||
|     setState(() => _isBusy = true); |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       await sn.client.delete('/cgi/id/realms/${widget.realm!.alias}'); |       await sn.client.delete('/cgi/id/realms/${widget.realm!.id}'); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       Navigator.pop(context, true); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> _leaveRealm() async { | ||||||
|  |     final confirm = await context.showConfirmDialog( | ||||||
|  |       'realmLeave'.tr(), | ||||||
|  |       'realmLeaveDescription'.tr(), | ||||||
|  |     ); | ||||||
|  |     if (!confirm) return; | ||||||
|  |     if (!mounted) return; | ||||||
|  |  | ||||||
|  |     final sn = context.read<SnNetworkProvider>(); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       await sn.client.delete('/cgi/id/realms/${widget.realm!.alias}/me'); | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       Navigator.pop(context, true); |       Navigator.pop(context, true); | ||||||
|       context.showSnackbar('realmDeleted'.tr(args: [ |  | ||||||
|         '#${widget.realm!.alias}', |  | ||||||
|       ])); |  | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (!mounted) return; |       if (!mounted) return; | ||||||
|       context.showErrorDialog(err); |       context.showErrorDialog(err); | ||||||
| @@ -421,6 +546,15 @@ class _RealmSettingsWidgetState extends State<_RealmSettingsWidget> { | |||||||
|     return Column( |     return Column( | ||||||
|       children: [ |       children: [ | ||||||
|         const Gap(8), |         const Gap(8), | ||||||
|  |         ListTile( | ||||||
|  |           leading: const Icon(Symbols.logout), | ||||||
|  |           trailing: const Icon(Symbols.chevron_right), | ||||||
|  |           title: Text('realmLeave').tr(), | ||||||
|  |           subtitle: Text('realmLeaveDescription').tr(), | ||||||
|  |           contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |           onTap: _isBusy ? null : () => _leaveRealm(), | ||||||
|  |         ), | ||||||
|  |         if (isOwned) | ||||||
|           ListTile( |           ListTile( | ||||||
|             leading: const Icon(Symbols.edit), |             leading: const Icon(Symbols.edit), | ||||||
|             trailing: const Icon(Symbols.chevron_right), |             trailing: const Icon(Symbols.chevron_right), | ||||||
|   | |||||||
							
								
								
									
										255
									
								
								lib/screens/realm/realm_discovery.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										255
									
								
								lib/screens/realm/realm_discovery.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,255 @@ | |||||||
|  | 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/config.dart'; | ||||||
|  | import 'package:surface/providers/sn_network.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/dialog.dart'; | ||||||
|  | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  | import 'package:surface/widgets/realm/realm_item.dart'; | ||||||
|  |  | ||||||
|  | class RealmDiscoveryScreen extends StatefulWidget { | ||||||
|  |   const RealmDiscoveryScreen({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<RealmDiscoveryScreen> createState() => _RealmDiscoveryScreenState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _RealmDiscoveryScreenState extends State<RealmDiscoveryScreen> { | ||||||
|  |   List<SnRealm>? _realms; | ||||||
|  |   bool _isBusy = false; | ||||||
|  |   bool _isCompactView = false; | ||||||
|  |  | ||||||
|  |   Future<void> _fetchRealms() async { | ||||||
|  |     try { | ||||||
|  |       setState(() => _isBusy = true); | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       final resp = await sn.client.get('/cgi/id/realms'); | ||||||
|  |       _realms = List<SnRealm>.from( | ||||||
|  |         resp.data?.map((e) => SnRealm.fromJson(e)) ?? [], | ||||||
|  |       ); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (mounted) context.showErrorDialog(err); | ||||||
|  |       rethrow; | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _isCompactView = context.read<ConfigProvider>().realmCompactView; | ||||||
|  |     _fetchRealms(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AppScaffold( | ||||||
|  |       appBar: AppBar( | ||||||
|  |         title: Text('screenRealmDiscovery').tr(), | ||||||
|  |         actions: [ | ||||||
|  |           IconButton( | ||||||
|  |             icon: _isCompactView ? const Icon(Symbols.view_list) : const Icon(Symbols.view_module), | ||||||
|  |             onPressed: () { | ||||||
|  |               setState(() => _isCompactView = !_isCompactView); | ||||||
|  |               context.read<ConfigProvider>().realmCompactView = _isCompactView; | ||||||
|  |             }, | ||||||
|  |           ), | ||||||
|  |           const Gap(8), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |       body: Column( | ||||||
|  |         children: [ | ||||||
|  |           LoadingIndicator(isActive: _isBusy), | ||||||
|  |           Expanded( | ||||||
|  |             child: RefreshIndicator( | ||||||
|  |               onRefresh: _fetchRealms, | ||||||
|  |               child: ListView.builder( | ||||||
|  |                 padding: EdgeInsets.zero, | ||||||
|  |                 itemCount: _realms?.length ?? 0, | ||||||
|  |                 itemBuilder: (context, idx) { | ||||||
|  |                   final realm = _realms![idx]; | ||||||
|  |                   return RealmItemWidget( | ||||||
|  |                     item: realm, | ||||||
|  |                     isListView: _isCompactView, | ||||||
|  |                     onTap: () { | ||||||
|  |                       showModalBottomSheet( | ||||||
|  |                         context: context, | ||||||
|  |                         builder: (context) => _RealmJoinPopup(realm: realm), | ||||||
|  |                       ); | ||||||
|  |                     }, | ||||||
|  |                   ); | ||||||
|  |                 }, | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _RealmJoinPopup extends StatefulWidget { | ||||||
|  |   final SnRealm realm; | ||||||
|  |  | ||||||
|  |   const _RealmJoinPopup({required this.realm}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<_RealmJoinPopup> createState() => _RealmJoinPopupState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _RealmJoinPopupState extends State<_RealmJoinPopup> { | ||||||
|  |   final List<String> _planJoinChannels = List.empty(growable: true); | ||||||
|  |  | ||||||
|  |   List<SnChannel>? _channels; | ||||||
|  |   bool _isBusy = false; | ||||||
|  |   bool _isJoining = false; | ||||||
|  |  | ||||||
|  |   Future<void> _fetchPublicChannels() async { | ||||||
|  |     try { | ||||||
|  |       setState(() => _isBusy = true); | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       final resp = await sn.client.get('/cgi/im/channels/${widget.realm.alias}/public'); | ||||||
|  |       final out = List<SnChannel>.from( | ||||||
|  |         resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(), | ||||||
|  |       ); | ||||||
|  |       setState(() => _channels = out); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> _joinRealm() async { | ||||||
|  |     try { | ||||||
|  |       setState(() => _isJoining = true); | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       final ua = context.read<UserProvider>(); | ||||||
|  |       await sn.client.post('/cgi/id/realms/${widget.realm.alias}/members', data: { | ||||||
|  |         'related': ua.user?.name, | ||||||
|  |       }); | ||||||
|  |       await _joinSelectedChannels(); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showSnackbar('realmJoined'.tr(args: [widget.realm.name])); | ||||||
|  |       Navigator.pop(context); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isJoining = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> _joinSelectedChannels() async { | ||||||
|  |     if (_planJoinChannels.isEmpty) return; | ||||||
|  |     for (final channel in _planJoinChannels) { | ||||||
|  |       try { | ||||||
|  |         final sn = context.read<SnNetworkProvider>(); | ||||||
|  |         final ua = context.read<UserProvider>(); | ||||||
|  |         await sn.client.post('/cgi/im/channels/${widget.realm.alias}/$channel/members', data: { | ||||||
|  |           'related': ua.user?.name, | ||||||
|  |         }); | ||||||
|  |       } catch (err) { | ||||||
|  |         if (!mounted) return; | ||||||
|  |         context.showErrorDialog(err); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _fetchPublicChannels(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return Column( | ||||||
|  |       crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |       children: [ | ||||||
|  |         Row( | ||||||
|  |           crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |           children: [ | ||||||
|  |             const Icon(Symbols.group_add, size: 24), | ||||||
|  |             const Gap(16), | ||||||
|  |             Text('realmJoin', style: Theme.of(context).textTheme.titleLarge).tr(), | ||||||
|  |           ], | ||||||
|  |         ).padding(horizontal: 20, top: 16, bottom: 12), | ||||||
|  |         Row( | ||||||
|  |           crossAxisAlignment: CrossAxisAlignment.end, | ||||||
|  |           children: [ | ||||||
|  |             Expanded( | ||||||
|  |               child: Column( | ||||||
|  |                 crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                 children: [ | ||||||
|  |                   Text( | ||||||
|  |                     widget.realm.name, | ||||||
|  |                     style: Theme.of(context).textTheme.titleMedium, | ||||||
|  |                   ), | ||||||
|  |                   Text( | ||||||
|  |                     widget.realm.description, | ||||||
|  |                     maxLines: 3, | ||||||
|  |                     overflow: TextOverflow.ellipsis, | ||||||
|  |                     style: Theme.of(context).textTheme.bodyMedium, | ||||||
|  |                   ), | ||||||
|  |                 ], | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |             ElevatedButton( | ||||||
|  |               onPressed: _isJoining ? null : () => _joinRealm(), | ||||||
|  |               child: Text('join'.tr()), | ||||||
|  |             ), | ||||||
|  |           ], | ||||||
|  |         ).padding(horizontal: 24, bottom: 12), | ||||||
|  |         const Divider(height: 1), | ||||||
|  |         LoadingIndicator(isActive: _isBusy), | ||||||
|  |         Container( | ||||||
|  |           width: double.infinity, | ||||||
|  |           color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||||
|  |           child: Text('realmCommunityPublicChannelsHint'.tr(), style: Theme.of(context).textTheme.bodyMedium) | ||||||
|  |               .padding(horizontal: 24, vertical: 8), | ||||||
|  |         ), | ||||||
|  |         Expanded( | ||||||
|  |           child: ListView.builder( | ||||||
|  |             itemCount: _channels?.length ?? 0, | ||||||
|  |             itemBuilder: (context, index) { | ||||||
|  |               final channel = _channels![index]; | ||||||
|  |               return CheckboxListTile( | ||||||
|  |                 value: _planJoinChannels.contains(channel.alias), | ||||||
|  |                 title: Text(channel.name), | ||||||
|  |                 subtitle: Text( | ||||||
|  |                   channel.description, | ||||||
|  |                   maxLines: 1, | ||||||
|  |                   overflow: TextOverflow.ellipsis, | ||||||
|  |                 ), | ||||||
|  |                 contentPadding: const EdgeInsets.symmetric(horizontal: 16), | ||||||
|  |                 secondary: AccountImage( | ||||||
|  |                   content: null, | ||||||
|  |                   fallbackWidget: const Icon(Symbols.chat, size: 20), | ||||||
|  |                 ), | ||||||
|  |                 onChanged: (value) { | ||||||
|  |                   value ??= false; | ||||||
|  |                   if (value) { | ||||||
|  |                     setState(() => _planJoinChannels.add(channel.alias)); | ||||||
|  |                   } else { | ||||||
|  |                     setState(() => _planJoinChannels.remove(channel.alias)); | ||||||
|  |                   } | ||||||
|  |                 }, | ||||||
|  |               ); | ||||||
|  |             }, | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -5,8 +5,11 @@ import 'package:dropdown_button2/dropdown_button2.dart'; | |||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/foundation.dart'; | import 'package:flutter/foundation.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter/services.dart'; | ||||||
|  | import 'package:flutter_cache_manager/flutter_cache_manager.dart'; | ||||||
| import 'package:flutter_colorpicker/flutter_colorpicker.dart'; | import 'package:flutter_colorpicker/flutter_colorpicker.dart'; | ||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
|  | import 'package:google_fonts/google_fonts.dart'; | ||||||
| import 'package:image_picker/image_picker.dart'; | import 'package:image_picker/image_picker.dart'; | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:path_provider/path_provider.dart'; | import 'package:path_provider/path_provider.dart'; | ||||||
| @@ -14,11 +17,15 @@ import 'package:provider/provider.dart'; | |||||||
| import 'package:shared_preferences/shared_preferences.dart'; | import 'package:shared_preferences/shared_preferences.dart'; | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
| import 'package:surface/providers/config.dart'; | import 'package:surface/providers/config.dart'; | ||||||
|  | import 'package:surface/providers/database.dart'; | ||||||
|  | import 'package:surface/providers/notification.dart'; | ||||||
| import 'package:surface/providers/sn_network.dart'; | import 'package:surface/providers/sn_network.dart'; | ||||||
|  | import 'package:surface/providers/sn_sticker.dart'; | ||||||
| import 'package:surface/providers/theme.dart'; | import 'package:surface/providers/theme.dart'; | ||||||
| import 'package:surface/theme.dart'; | import 'package:surface/theme.dart'; | ||||||
| import 'package:surface/widgets/dialog.dart'; | import 'package:surface/widgets/dialog.dart'; | ||||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  | import 'package:surface/widgets/updater.dart'; | ||||||
|  |  | ||||||
| const Map<String, Color> kColorSchemes = { | const Map<String, Color> kColorSchemes = { | ||||||
|   'colorSchemeIndigo': Colors.indigo, |   'colorSchemeIndigo': Colors.indigo, | ||||||
| @@ -42,6 +49,7 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|   late final SharedPreferences _prefs; |   late final SharedPreferences _prefs; | ||||||
|   String _docBasepath = '/'; |   String _docBasepath = '/'; | ||||||
|  |  | ||||||
|  |   final TextEditingController _customFontController = TextEditingController(); | ||||||
|   final TextEditingController _serverUrlController = TextEditingController(); |   final TextEditingController _serverUrlController = TextEditingController(); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
| @@ -56,17 +64,22 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|     final config = context.read<ConfigProvider>(); |     final config = context.read<ConfigProvider>(); | ||||||
|     _prefs = config.prefs; |     _prefs = config.prefs; | ||||||
|     _serverUrlController.text = config.serverUrl; |     _serverUrlController.text = config.serverUrl; | ||||||
|  |     if (_prefs.getString(kAppCustomFonts) != null) { | ||||||
|  |       _customFontController.text = _prefs.getString(kAppCustomFonts) ?? ''; | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void dispose() { |   void dispose() { | ||||||
|     _serverUrlController.dispose(); |     _serverUrlController.dispose(); | ||||||
|  |     _customFontController.dispose(); | ||||||
|     super.dispose(); |     super.dispose(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     final sn = context.read<SnNetworkProvider>(); |     final sn = context.read<SnNetworkProvider>(); | ||||||
|  |     final dt = context.read<DatabaseProvider>(); | ||||||
|  |  | ||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
| @@ -81,7 +94,11 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|             Column( |             Column( | ||||||
|               crossAxisAlignment: CrossAxisAlignment.start, |               crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|               children: [ |               children: [ | ||||||
|                 Text('settingsAppearance').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4), |                 Text('settingsAppearance') | ||||||
|  |                     .bold() | ||||||
|  |                     .fontSize(17) | ||||||
|  |                     .tr() | ||||||
|  |                     .padding(horizontal: 20, bottom: 4), | ||||||
|                 ListTile( |                 ListTile( | ||||||
|                   title: Text('settingsDisplayLanguage').tr(), |                   title: Text('settingsDisplayLanguage').tr(), | ||||||
|                   subtitle: Text('settingsDisplayLanguageDescription').tr(), |                   subtitle: Text('settingsDisplayLanguageDescription').tr(), | ||||||
| @@ -91,15 +108,21 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|                     child: DropdownButton2<Locale?>( |                     child: DropdownButton2<Locale?>( | ||||||
|                       isExpanded: true, |                       isExpanded: true, | ||||||
|                       items: [ |                       items: [ | ||||||
|                         ...EasyLocalization.of(context)!.supportedLocales.mapIndexed((idx, ele) { |                         ...EasyLocalization.of(context)! | ||||||
|  |                             .supportedLocales | ||||||
|  |                             .mapIndexed((idx, ele) { | ||||||
|                           return DropdownMenuItem<Locale?>( |                           return DropdownMenuItem<Locale?>( | ||||||
|                             value: ele, |                             value: ele, | ||||||
|                             child: Text('${ele.languageCode}-${ele.countryCode}').fontSize(14), |                             child: | ||||||
|  |                                 Text('${ele.languageCode}-${ele.countryCode}') | ||||||
|  |                                     .fontSize(14), | ||||||
|                           ); |                           ); | ||||||
|                         }), |                         }), | ||||||
|                         DropdownMenuItem<Locale?>( |                         DropdownMenuItem<Locale?>( | ||||||
|                           value: null, |                           value: null, | ||||||
|                           child: Text('settingsDisplayLanguageSystem').tr().fontSize(14), |                           child: Text('settingsDisplayLanguageSystem') | ||||||
|  |                               .tr() | ||||||
|  |                               .fontSize(14), | ||||||
|                         ), |                         ), | ||||||
|                       ], |                       ], | ||||||
|                       value: EasyLocalization.of(context)!.currentLocale, |                       value: EasyLocalization.of(context)!.currentLocale, | ||||||
| @@ -132,10 +155,12 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|                     leading: const Icon(Symbols.image), |                     leading: const Icon(Symbols.image), | ||||||
|                     trailing: const Icon(Symbols.chevron_right), |                     trailing: const Icon(Symbols.chevron_right), | ||||||
|                     onTap: () async { |                     onTap: () async { | ||||||
|                       final image = await ImagePicker().pickImage(source: ImageSource.gallery); |                       final image = await ImagePicker() | ||||||
|  |                           .pickImage(source: ImageSource.gallery); | ||||||
|                       if (image == null) return; |                       if (image == null) return; | ||||||
|  |  | ||||||
|                       await File(image.path).copy('$_docBasepath/app_background_image'); |                       await File(image.path) | ||||||
|  |                           .copy('$_docBasepath/app_background_image'); | ||||||
|                       _prefs.setBool(kAppBackgroundStoreKey, true); |                       _prefs.setBool(kAppBackgroundStoreKey, true); | ||||||
|  |  | ||||||
|                       setState(() {}); |                       setState(() {}); | ||||||
| @@ -143,7 +168,8 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|                   ), |                   ), | ||||||
|                 if (!kIsWeb) |                 if (!kIsWeb) | ||||||
|                   FutureBuilder<bool>( |                   FutureBuilder<bool>( | ||||||
|                       future: File('$_docBasepath/app_background_image').exists(), |                       future: | ||||||
|  |                           File('$_docBasepath/app_background_image').exists(), | ||||||
|                       builder: (context, snapshot) { |                       builder: (context, snapshot) { | ||||||
|                         if (!snapshot.hasData || !snapshot.data!) { |                         if (!snapshot.hasData || !snapshot.data!) { | ||||||
|                           return const SizedBox.shrink(); |                           return const SizedBox.shrink(); | ||||||
| @@ -151,12 +177,16 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|  |  | ||||||
|                         return ListTile( |                         return ListTile( | ||||||
|                           title: Text('settingsBackgroundImageClear').tr(), |                           title: Text('settingsBackgroundImageClear').tr(), | ||||||
|                           subtitle: Text('settingsBackgroundImageClearDescription').tr(), |                           subtitle: | ||||||
|                           contentPadding: const EdgeInsets.symmetric(horizontal: 24), |                               Text('settingsBackgroundImageClearDescription') | ||||||
|  |                                   .tr(), | ||||||
|  |                           contentPadding: | ||||||
|  |                               const EdgeInsets.symmetric(horizontal: 24), | ||||||
|                           leading: const Icon(Symbols.texture), |                           leading: const Icon(Symbols.texture), | ||||||
|                           trailing: const Icon(Symbols.chevron_right), |                           trailing: const Icon(Symbols.chevron_right), | ||||||
|                           onTap: () { |                           onTap: () { | ||||||
|                             File('$_docBasepath/app_background_image').deleteSync(); |                             File('$_docBasepath/app_background_image') | ||||||
|  |                                 .deleteSync(); | ||||||
|                             _prefs.remove(kAppBackgroundStoreKey); |                             _prefs.remove(kAppBackgroundStoreKey); | ||||||
|                             setState(() {}); |                             setState(() {}); | ||||||
|                           }, |                           }, | ||||||
| @@ -186,11 +216,12 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), |                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|                   trailing: const Icon(Symbols.chevron_right), |                   trailing: const Icon(Symbols.chevron_right), | ||||||
|                   onTap: () async { |                   onTap: () async { | ||||||
|                     Color pickerColor = Color(_prefs.getInt(kAppColorSchemeStoreKey) ?? Colors.indigo.value); |                     Color pickerColor = Color( | ||||||
|  |                         _prefs.getInt(kAppColorSchemeStoreKey) ?? | ||||||
|  |                             Colors.indigo.value); | ||||||
|                     final color = await showDialog<Color?>( |                     final color = await showDialog<Color?>( | ||||||
|                       context: context, |                       context: context, | ||||||
|                       builder: (context) => |                       builder: (context) => AlertDialog( | ||||||
|                           AlertDialog( |  | ||||||
|                         content: SingleChildScrollView( |                         content: SingleChildScrollView( | ||||||
|                           child: ColorPicker( |                           child: ColorPicker( | ||||||
|                             pickerColor: pickerColor, |                             pickerColor: pickerColor, | ||||||
| @@ -248,16 +279,17 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|                       ], |                       ], | ||||||
|                       value: _prefs.getInt(kAppColorSchemeStoreKey) == null |                       value: _prefs.getInt(kAppColorSchemeStoreKey) == null | ||||||
|                           ? 1 |                           ? 1 | ||||||
|                           : kColorSchemes.values |                           : kColorSchemes.values.toList().indexWhere((ele) => | ||||||
|                           .toList() |                               ele.value == | ||||||
|                           .indexWhere((ele) => ele.value == _prefs.getInt(kAppColorSchemeStoreKey)), |                               _prefs.getInt(kAppColorSchemeStoreKey)), | ||||||
|                       onChanged: (int? value) { |                       onChanged: (int? value) { | ||||||
|                         if (value != null && value != -1) { |                         if (value != null && value != -1) { | ||||||
|                           _prefs.setInt(kAppColorSchemeStoreKey, kColorSchemes.values |                           _prefs.setInt(kAppColorSchemeStoreKey, | ||||||
|                               .elementAt(value) |                               kColorSchemes.values.elementAt(value).value); | ||||||
|                               .value); |  | ||||||
|                           final th = context.read<ThemeProvider>(); |                           final th = context.read<ThemeProvider>(); | ||||||
|                           th.reloadTheme(seedColorOverride: kColorSchemes.values.elementAt(value)); |                           th.reloadTheme( | ||||||
|  |                               seedColorOverride: | ||||||
|  |                                   kColorSchemes.values.elementAt(value)); | ||||||
|                           setState(() {}); |                           setState(() {}); | ||||||
|  |  | ||||||
|                           context.showSnackbar('colorSchemeApplied'.tr()); |                           context.showSnackbar('colorSchemeApplied'.tr()); | ||||||
| @@ -293,7 +325,8 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|                 CheckboxListTile( |                 CheckboxListTile( | ||||||
|                   secondary: const Icon(Symbols.left_panel_close), |                   secondary: const Icon(Symbols.left_panel_close), | ||||||
|                   title: Text('settingsDrawerPreferCollapse').tr(), |                   title: Text('settingsDrawerPreferCollapse').tr(), | ||||||
|                   subtitle: Text('settingsDrawerPreferCollapseDescription').tr(), |                   subtitle: | ||||||
|  |                       Text('settingsDrawerPreferCollapseDescription').tr(), | ||||||
|                   contentPadding: const EdgeInsets.only(left: 24, right: 17), |                   contentPadding: const EdgeInsets.only(left: 24, right: 17), | ||||||
|                   value: _prefs.getBool(kAppDrawerPreferCollapse) ?? false, |                   value: _prefs.getBool(kAppDrawerPreferCollapse) ?? false, | ||||||
|                   onChanged: (value) { |                   onChanged: (value) { | ||||||
| @@ -303,12 +336,57 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|                     setState(() {}); |                     setState(() {}); | ||||||
|                   }, |                   }, | ||||||
|                 ), |                 ), | ||||||
|  |                 ListTile( | ||||||
|  |                   leading: const Icon(Symbols.font_download), | ||||||
|  |                   title: Text('settingsCustomFonts').tr(), | ||||||
|  |                   subtitle: Text('settingsCustomFontsDescription').tr(), | ||||||
|  |                   contentPadding: const EdgeInsets.only(left: 24, right: 14), | ||||||
|  |                   trailing: IconButton( | ||||||
|  |                     padding: EdgeInsets.zero, | ||||||
|  |                     constraints: const BoxConstraints(), | ||||||
|  |                     icon: const Icon(Icons.clear), | ||||||
|  |                     onPressed: () { | ||||||
|  |                       _prefs.remove(kAppCustomFonts); | ||||||
|  |                       context.showSnackbar('settingsCustomFontApplied'.tr()); | ||||||
|  |                       final theme = context.read<ThemeProvider>(); | ||||||
|  |                       _customFontController.clear(); | ||||||
|  |                       theme.reloadTheme(); | ||||||
|  |                     }, | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |                 TextField( | ||||||
|  |                   controller: _customFontController, | ||||||
|  |                   decoration: InputDecoration( | ||||||
|  |                     border: const OutlineInputBorder(), | ||||||
|  |                     labelText: 'settingsCustomFontFamily'.tr(), | ||||||
|  |                     helperText: 'settingsCustomFontFamilyHint'.tr(), | ||||||
|  |                     prefixIcon: const Icon(Symbols.format_paint), | ||||||
|  |                     suffixIcon: IconButton( | ||||||
|  |                       icon: const Icon(Symbols.save), | ||||||
|  |                       onPressed: () { | ||||||
|  |                         _prefs.setString( | ||||||
|  |                           kAppCustomFonts, | ||||||
|  |                           _customFontController.text, | ||||||
|  |                         ); | ||||||
|  |                         context.showSnackbar('settingsCustomFontApplied'.tr()); | ||||||
|  |                         final theme = context.read<ThemeProvider>(); | ||||||
|  |                         theme.reloadTheme(); | ||||||
|  |                       }, | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|  |                   onTapOutside: (_) => | ||||||
|  |                       FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |                 ).padding(horizontal: 16, top: 8, bottom: 4), | ||||||
|               ], |               ], | ||||||
|             ), |             ), | ||||||
|             Column( |             Column( | ||||||
|               crossAxisAlignment: CrossAxisAlignment.start, |               crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|               children: [ |               children: [ | ||||||
|                 Text('settingsFeatures').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4), |                 Text('settingsFeatures') | ||||||
|  |                     .bold() | ||||||
|  |                     .fontSize(17) | ||||||
|  |                     .tr() | ||||||
|  |                     .padding(horizontal: 20, bottom: 4), | ||||||
|                 CheckboxListTile( |                 CheckboxListTile( | ||||||
|                   secondary: const Icon(Symbols.vibration), |                   secondary: const Icon(Symbols.vibration), | ||||||
|                   contentPadding: const EdgeInsets.only(left: 24, right: 17), |                   contentPadding: const EdgeInsets.only(left: 24, right: 17), | ||||||
| @@ -350,7 +428,11 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|             Column( |             Column( | ||||||
|               crossAxisAlignment: CrossAxisAlignment.start, |               crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|               children: [ |               children: [ | ||||||
|                 Text('settingsNetwork').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4), |                 Text('settingsNetwork') | ||||||
|  |                     .bold() | ||||||
|  |                     .fontSize(17) | ||||||
|  |                     .tr() | ||||||
|  |                     .padding(horizontal: 20, bottom: 4), | ||||||
|                 TextField( |                 TextField( | ||||||
|                   controller: _serverUrlController, |                   controller: _serverUrlController, | ||||||
|                   decoration: InputDecoration( |                   decoration: InputDecoration( | ||||||
| @@ -371,7 +453,8 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|                       }, |                       }, | ||||||
|                     ), |                     ), | ||||||
|                   ), |                   ), | ||||||
|                   onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |                   onTapOutside: (_) => | ||||||
|  |                       FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                 ).padding(horizontal: 16, top: 8, bottom: 4), |                 ).padding(horizontal: 16, top: 8, bottom: 4), | ||||||
|                 ListTile( |                 ListTile( | ||||||
|                   title: Text('settingsNetworkServerPreset').tr(), |                   title: Text('settingsNetworkServerPreset').tr(), | ||||||
| @@ -383,12 +466,13 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|                       isExpanded: true, |                       isExpanded: true, | ||||||
|                       items: [ |                       items: [ | ||||||
|                         ...kNetworkServerDirectory, |                         ...kNetworkServerDirectory, | ||||||
|                         if (!kNetworkServerDirectory.map((ele) => ele.$2).contains(_serverUrlController.text)) |                         if (!kNetworkServerDirectory | ||||||
|  |                             .map((ele) => ele.$2) | ||||||
|  |                             .contains(_serverUrlController.text)) | ||||||
|                           ('Custom', _serverUrlController.text), |                           ('Custom', _serverUrlController.text), | ||||||
|                       ] |                       ] | ||||||
|                           .map( |                           .map( | ||||||
|                             (item) => |                             (item) => DropdownMenuItem<String>( | ||||||
|                             DropdownMenuItem<String>( |  | ||||||
|                               value: item.$2, |                               value: item.$2, | ||||||
|                               child: Column( |                               child: Column( | ||||||
|                                 mainAxisSize: MainAxisSize.max, |                                 mainAxisSize: MainAxisSize.max, | ||||||
| @@ -396,7 +480,8 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|                                 crossAxisAlignment: CrossAxisAlignment.start, |                                 crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|                                 children: [ |                                 children: [ | ||||||
|                                   Text(item.$1).fontSize(14), |                                   Text(item.$1).fontSize(14), | ||||||
|                                   Text(item.$2, overflow: TextOverflow.ellipsis).fontSize(11) |                                   Text(item.$2, overflow: TextOverflow.ellipsis) | ||||||
|  |                                       .fontSize(11) | ||||||
|                                 ], |                                 ], | ||||||
|                               ), |                               ), | ||||||
|                             ), |                             ), | ||||||
| @@ -442,7 +527,11 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|             Column( |             Column( | ||||||
|               crossAxisAlignment: CrossAxisAlignment.start, |               crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|               children: [ |               children: [ | ||||||
|                 Text('settingsPerformance').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4), |                 Text('settingsPerformance') | ||||||
|  |                     .bold() | ||||||
|  |                     .fontSize(17) | ||||||
|  |                     .tr() | ||||||
|  |                     .padding(horizontal: 20, bottom: 4), | ||||||
|                 ListTile( |                 ListTile( | ||||||
|                   title: Text('settingsImageQuality').tr(), |                   title: Text('settingsImageQuality').tr(), | ||||||
|                   subtitle: Text('settingsImageQualityDescription').tr(), |                   subtitle: Text('settingsImageQualityDescription').tr(), | ||||||
| @@ -450,13 +539,13 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|                   leading: const Icon(Symbols.image), |                   leading: const Icon(Symbols.image), | ||||||
|                   trailing: DropdownButtonHideUnderline( |                   trailing: DropdownButtonHideUnderline( | ||||||
|                     child: DropdownButton2<FilterQuality>( |                     child: DropdownButton2<FilterQuality>( | ||||||
|                       value: kImageQualityLevel.values.elementAtOrNull(_prefs.getInt('app_image_quality') ?? 3) ?? |                       value: kImageQualityLevel.values.elementAtOrNull( | ||||||
|  |                               _prefs.getInt('app_image_quality') ?? 3) ?? | ||||||
|                           FilterQuality.high, |                           FilterQuality.high, | ||||||
|                       isExpanded: true, |                       isExpanded: true, | ||||||
|                       items: kImageQualityLevel.entries |                       items: kImageQualityLevel.entries | ||||||
|                           .map( |                           .map( | ||||||
|                             (item) => |                             (item) => DropdownMenuItem<FilterQuality>( | ||||||
|                             DropdownMenuItem<FilterQuality>( |  | ||||||
|                               value: item.value, |                               value: item.value, | ||||||
|                               child: Text(item.key).tr().fontSize(14), |                               child: Text(item.key).tr().fontSize(14), | ||||||
|                             ), |                             ), | ||||||
| @@ -464,7 +553,8 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|                           .toList(), |                           .toList(), | ||||||
|                       onChanged: (FilterQuality? value) { |                       onChanged: (FilterQuality? value) { | ||||||
|                         if (value == null) return; |                         if (value == null) return; | ||||||
|                         _prefs.setInt('app_image_quality', kImageQualityLevel.values.toList().indexOf(value)); |                         _prefs.setInt('app_image_quality', | ||||||
|  |                             kImageQualityLevel.values.toList().indexOf(value)); | ||||||
|                         setState(() {}); |                         setState(() {}); | ||||||
|                       }, |                       }, | ||||||
|                       buttonStyleData: const ButtonStyleData( |                       buttonStyleData: const ButtonStyleData( | ||||||
| @@ -486,7 +576,136 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|             Column( |             Column( | ||||||
|               crossAxisAlignment: CrossAxisAlignment.start, |               crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|               children: [ |               children: [ | ||||||
|                 Text('settingsMisc').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4), |                 Text('settingsMisc') | ||||||
|  |                     .bold() | ||||||
|  |                     .fontSize(17) | ||||||
|  |                     .tr() | ||||||
|  |                     .padding(horizontal: 20, bottom: 4), | ||||||
|  |                 ListTile( | ||||||
|  |                   leading: const Icon(Symbols.home_storage), | ||||||
|  |                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                   title: Text('cacheSize').tr(), | ||||||
|  |                   subtitle: FutureBuilder( | ||||||
|  |                     future: DefaultCacheManager().store.getCacheSize(), | ||||||
|  |                     builder: (context, snapshot) { | ||||||
|  |                       if (!snapshot.hasData || kIsWeb) { | ||||||
|  |                         return Text('unknown').tr(); | ||||||
|  |                       } | ||||||
|  |                       return Text( | ||||||
|  |                         snapshot.data!.formatBytes(), | ||||||
|  |                         style: GoogleFonts.robotoMono(), | ||||||
|  |                       ); | ||||||
|  |                     }, | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |                 ListTile( | ||||||
|  |                   leading: const Icon(Symbols.cleaning_services), | ||||||
|  |                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                   title: Text('cacheDelete').tr(), | ||||||
|  |                   subtitle: Text('cacheDeleteDescription').tr(), | ||||||
|  |                   trailing: const Icon(Symbols.chevron_right), | ||||||
|  |                   onTap: () async { | ||||||
|  |                     await DefaultCacheManager().emptyCache(); | ||||||
|  |                     if (!context.mounted) return; | ||||||
|  |                     HapticFeedback.heavyImpact(); | ||||||
|  |                     context.showSnackbar('cacheDeleted'.tr()); | ||||||
|  |                     setState(() {}); | ||||||
|  |                   }, | ||||||
|  |                 ), | ||||||
|  |                 ListTile( | ||||||
|  |                   leading: const Icon(Symbols.database), | ||||||
|  |                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                   title: Text('databaseSize').tr(), | ||||||
|  |                   subtitle: FutureBuilder( | ||||||
|  |                     future: dt.getDatabaseSize(), | ||||||
|  |                     builder: (context, snapshot) { | ||||||
|  |                       if (!snapshot.hasData || kIsWeb) { | ||||||
|  |                         return Text('unknown').tr(); | ||||||
|  |                       } | ||||||
|  |                       return Text( | ||||||
|  |                         snapshot.data!.formatBytes(), | ||||||
|  |                         style: GoogleFonts.robotoMono(), | ||||||
|  |                       ); | ||||||
|  |                     }, | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |                 ListTile( | ||||||
|  |                   leading: const Icon(Symbols.database_off), | ||||||
|  |                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                   title: Text('databaseDelete').tr(), | ||||||
|  |                   subtitle: Text('databaseDeleteDescription').tr(), | ||||||
|  |                   trailing: const Icon(Symbols.chevron_right), | ||||||
|  |                   onTap: () async { | ||||||
|  |                     await dt.removeDatabase(); | ||||||
|  |                     if (!context.mounted) return; | ||||||
|  |                     HapticFeedback.heavyImpact(); | ||||||
|  |                     context.showSnackbar('databaseDeleted'.tr()); | ||||||
|  |                     setState(() {}); | ||||||
|  |                   }, | ||||||
|  |                 ), | ||||||
|  |                 ListTile( | ||||||
|  |                   leading: const Icon(Symbols.notifications), | ||||||
|  |                   title: Text('settingsEnablePushNotifications').tr(), | ||||||
|  |                   subtitle: | ||||||
|  |                       Text('settingsEnablePushNotificationsDescription').tr(), | ||||||
|  |                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                   trailing: const Icon(Symbols.chevron_right), | ||||||
|  |                   onTap: () async { | ||||||
|  |                     final nty = context.read<NotificationProvider>(); | ||||||
|  |                     try { | ||||||
|  |                       await nty.registerPushNotifications(); | ||||||
|  |                       if (!context.mounted) return; | ||||||
|  |                       HapticFeedback.heavyImpact(); | ||||||
|  |                       context.showSnackbar( | ||||||
|  |                           'settingsEnabledPushNotifications'.tr()); | ||||||
|  |                     } catch (err) { | ||||||
|  |                       if (!mounted) return; | ||||||
|  |                       context.showErrorDialog(err); | ||||||
|  |                     } | ||||||
|  |                   }, | ||||||
|  |                 ), | ||||||
|  |                 ListTile( | ||||||
|  |                   leading: const Icon(Symbols.refresh), | ||||||
|  |                   title: Text('stickersReload').tr(), | ||||||
|  |                   subtitle: Text('stickersReloadDescription').tr(), | ||||||
|  |                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                   trailing: const Icon(Symbols.chevron_right), | ||||||
|  |                   onTap: () async { | ||||||
|  |                     final stickers = context.read<SnStickerProvider>(); | ||||||
|  |                     try { | ||||||
|  |                       await stickers.listSticker(); | ||||||
|  |                       if (!context.mounted) return; | ||||||
|  |                       HapticFeedback.heavyImpact(); | ||||||
|  |                       context.showSnackbar('stickersReloaded'.tr()); | ||||||
|  |                     } catch (err) { | ||||||
|  |                       if (!context.mounted) return; | ||||||
|  |                       context.showErrorDialog(err); | ||||||
|  |                     } | ||||||
|  |                   }, | ||||||
|  |                 ), | ||||||
|  |                 ListTile( | ||||||
|  |                   title: Text('forceUpdate').tr(), | ||||||
|  |                   subtitle: Text('forceUpdateDescription').tr(), | ||||||
|  |                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                   leading: const Icon(Symbols.update), | ||||||
|  |                   trailing: const Icon(Symbols.chevron_right), | ||||||
|  |                   onTap: () async { | ||||||
|  |                     showModalBottomSheet( | ||||||
|  |                       context: context, | ||||||
|  |                       builder: (context) => VersionUpdatePopup(), | ||||||
|  |                     ); | ||||||
|  |                   }, | ||||||
|  |                 ), | ||||||
|  |                 ListTile( | ||||||
|  |                   title: Text('runtimeLogsOpen').tr(), | ||||||
|  |                   subtitle: Text('runtimeLogsDescription').tr(), | ||||||
|  |                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                   leading: const Icon(Symbols.receipt_long), | ||||||
|  |                   trailing: const Icon(Symbols.chevron_right), | ||||||
|  |                   onTap: () async { | ||||||
|  |                     GoRouter.of(context).pushNamed('debugLogging'); | ||||||
|  |                   }, | ||||||
|  |                 ), | ||||||
|                 ListTile( |                 ListTile( | ||||||
|                   title: Text('settingsMiscAbout').tr(), |                   title: Text('settingsMiscAbout').tr(), | ||||||
|                   subtitle: Text('settingsMiscAboutDescription').tr(), |                   subtitle: Text('settingsMiscAboutDescription').tr(), | ||||||
|   | |||||||
| @@ -51,26 +51,35 @@ class _AppSharingListenerState extends State<AppSharingListener> { | |||||||
|                 child: Column( |                 child: Column( | ||||||
|                   children: [ |                   children: [ | ||||||
|                     ListTile( |                     ListTile( | ||||||
|                       contentPadding: const EdgeInsets.symmetric(horizontal: 24), |                       contentPadding: | ||||||
|                       shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), |                           const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                       shape: RoundedRectangleBorder( | ||||||
|  |                           borderRadius: BorderRadius.circular(8)), | ||||||
|                       leading: Icon(Icons.post_add), |                       leading: Icon(Icons.post_add), | ||||||
|                       trailing: const Icon(Icons.chevron_right), |                       trailing: const Icon(Icons.chevron_right), | ||||||
|                       title: Text('shareIntentPostStory').tr(), |                       title: Text('shareIntentPostStory').tr(), | ||||||
|                       onTap: () { |                       onTap: () { | ||||||
|                         GoRouter.of(context).pushNamed( |                         GoRouter.of(context).pushNamed( | ||||||
|                           'postEditor', |                           'postEditor', | ||||||
|                           pathParameters: { |                           queryParameters: { | ||||||
|                             'mode': 'stories', |                             'mode': 'stories', | ||||||
|                           }, |                           }, | ||||||
|                           extra: PostEditorExtra( |                           extra: PostEditorExtra( | ||||||
|                             text: value |                             text: value | ||||||
|                                 .where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type)) |                                 .where((e) => [ | ||||||
|  |                                       SharedMediaType.text, | ||||||
|  |                                       SharedMediaType.url | ||||||
|  |                                     ].contains(e.type)) | ||||||
|                                 .map((e) => e.path) |                                 .map((e) => e.path) | ||||||
|                                 .join('\n'), |                                 .join('\n'), | ||||||
|                             attachments: value |                             attachments: value | ||||||
|                                 .where((e) => [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image] |                                 .where((e) => [ | ||||||
|                                     .contains(e.type)) |                                       SharedMediaType.video, | ||||||
|                                 .map((e) => PostWriteMedia.fromFile(XFile(e.path))) |                                       SharedMediaType.file, | ||||||
|  |                                       SharedMediaType.image | ||||||
|  |                                     ].contains(e.type)) | ||||||
|  |                                 .map((e) => | ||||||
|  |                                     PostWriteMedia.fromFile(XFile(e.path))) | ||||||
|                                 .toList(), |                                 .toList(), | ||||||
|                           ), |                           ), | ||||||
|                         ); |                         ); | ||||||
| @@ -78,15 +87,18 @@ class _AppSharingListenerState extends State<AppSharingListener> { | |||||||
|                       }, |                       }, | ||||||
|                     ), |                     ), | ||||||
|                     ListTile( |                     ListTile( | ||||||
|                       contentPadding: const EdgeInsets.symmetric(horizontal: 24), |                       contentPadding: | ||||||
|                       shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), |                           const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                       shape: RoundedRectangleBorder( | ||||||
|  |                           borderRadius: BorderRadius.circular(8)), | ||||||
|                       leading: Icon(Icons.chat_outlined), |                       leading: Icon(Icons.chat_outlined), | ||||||
|                       trailing: const Icon(Icons.chevron_right), |                       trailing: const Icon(Icons.chevron_right), | ||||||
|                       title: Text('shareIntentSendChannel').tr(), |                       title: Text('shareIntentSendChannel').tr(), | ||||||
|                       onTap: () { |                       onTap: () { | ||||||
|                         showModalBottomSheet( |                         showModalBottomSheet( | ||||||
|                           context: context, |                           context: context, | ||||||
|                           builder: (context) => _ShareIntentChannelSelect(value: value), |                           builder: (context) => | ||||||
|  |                               _ShareIntentChannelSelect(value: value), | ||||||
|                         ).then((val) { |                         ).then((val) { | ||||||
|                           if (!context.mounted) return; |                           if (!context.mounted) return; | ||||||
|                           if (val == true) Navigator.pop(context); |                           if (val == true) Navigator.pop(context); | ||||||
| @@ -110,7 +122,8 @@ class _AppSharingListenerState extends State<AppSharingListener> { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   void _initialize() async { |   void _initialize() async { | ||||||
|     _shareIntentSubscription = ReceiveSharingIntent.instance.getMediaStream().listen((value) { |     _shareIntentSubscription = | ||||||
|  |         ReceiveSharingIntent.instance.getMediaStream().listen((value) { | ||||||
|       if (value.isEmpty) return; |       if (value.isEmpty) return; | ||||||
|       if (mounted) { |       if (mounted) { | ||||||
|         _gotoPost(value); |         _gotoPost(value); | ||||||
| @@ -157,7 +170,8 @@ class _ShareIntentChannelSelect extends StatefulWidget { | |||||||
|   const _ShareIntentChannelSelect({required this.value}); |   const _ShareIntentChannelSelect({required this.value}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   State<_ShareIntentChannelSelect> createState() => _ShareIntentChannelSelectState(); |   State<_ShareIntentChannelSelect> createState() => | ||||||
|  |       _ShareIntentChannelSelectState(); | ||||||
| } | } | ||||||
|  |  | ||||||
| class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> { | class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> { | ||||||
| @@ -178,8 +192,11 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> { | |||||||
|       final lastMessages = await chan.getLastMessages(channels); |       final lastMessages = await chan.getLastMessages(channels); | ||||||
|       _lastMessages = {for (final val in lastMessages) val.channelId: val}; |       _lastMessages = {for (final val in lastMessages) val.channelId: val}; | ||||||
|       channels.sort((a, b) { |       channels.sort((a, b) { | ||||||
|         if (_lastMessages!.containsKey(a.id) && _lastMessages!.containsKey(b.id)) { |         if (_lastMessages!.containsKey(a.id) && | ||||||
|           return _lastMessages![b.id]!.createdAt.compareTo(_lastMessages![a.id]!.createdAt); |             _lastMessages!.containsKey(b.id)) { | ||||||
|  |           return _lastMessages![b.id]! | ||||||
|  |               .createdAt | ||||||
|  |               .compareTo(_lastMessages![a.id]!.createdAt); | ||||||
|         } |         } | ||||||
|         if (_lastMessages!.containsKey(a.id)) return -1; |         if (_lastMessages!.containsKey(a.id)) return -1; | ||||||
|         if (_lastMessages!.containsKey(b.id)) return 1; |         if (_lastMessages!.containsKey(b.id)) return 1; | ||||||
| @@ -232,7 +249,9 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> { | |||||||
|           children: [ |           children: [ | ||||||
|             const Icon(Symbols.chat, size: 24), |             const Icon(Symbols.chat, size: 24), | ||||||
|             const Gap(16), |             const Gap(16), | ||||||
|             Text('shareIntentSendChannel', style: Theme.of(context).textTheme.titleLarge).tr(), |             Text('shareIntentSendChannel', | ||||||
|  |                     style: Theme.of(context).textTheme.titleLarge) | ||||||
|  |                 .tr(), | ||||||
|           ], |           ], | ||||||
|         ).padding(horizontal: 20, top: 16, bottom: 12), |         ).padding(horizontal: 20, top: 16, bottom: 12), | ||||||
|         LoadingIndicator(isActive: _isBusy), |         LoadingIndicator(isActive: _isBusy), | ||||||
| @@ -249,29 +268,34 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> { | |||||||
|                   final lastMessage = _lastMessages?[channel.id]; |                   final lastMessage = _lastMessages?[channel.id]; | ||||||
|  |  | ||||||
|                   if (channel.type == 1) { |                   if (channel.type == 1) { | ||||||
|                     final otherMember = channel.members?.cast<SnChannelMember?>().firstWhere( |                     final otherMember = | ||||||
|  |                         channel.members?.cast<SnChannelMember?>().firstWhere( | ||||||
|                               (ele) => ele?.accountId != ua.user?.id, |                               (ele) => ele?.accountId != ua.user?.id, | ||||||
|                               orElse: () => null, |                               orElse: () => null, | ||||||
|                             ); |                             ); | ||||||
|  |  | ||||||
|                     return ListTile( |                     return ListTile( | ||||||
|                       title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name), |                       title: Text( | ||||||
|  |                           ud.getFromCache(otherMember?.accountId)?.nick ?? | ||||||
|  |                               channel.name), | ||||||
|                       subtitle: lastMessage != null |                       subtitle: lastMessage != null | ||||||
|                           ? Text( |                           ? Text( | ||||||
|                               '${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}', |                               '${ud.getFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}', | ||||||
|                               maxLines: 1, |                               maxLines: 1, | ||||||
|                               overflow: TextOverflow.ellipsis, |                               overflow: TextOverflow.ellipsis, | ||||||
|                             ) |                             ) | ||||||
|                           : Text( |                           : Text( | ||||||
|                               'channelDirectMessageDescription'.tr(args: [ |                               'channelDirectMessageDescription'.tr(args: [ | ||||||
|                                 '@${ud.getAccountFromCache(otherMember?.accountId)?.name}', |                                 '@${ud.getFromCache(otherMember?.accountId)?.name}', | ||||||
|                               ]), |                               ]), | ||||||
|                               maxLines: 1, |                               maxLines: 1, | ||||||
|                               overflow: TextOverflow.ellipsis, |                               overflow: TextOverflow.ellipsis, | ||||||
|                             ), |                             ), | ||||||
|                       contentPadding: const EdgeInsets.symmetric(horizontal: 16), |                       contentPadding: | ||||||
|  |                           const EdgeInsets.symmetric(horizontal: 16), | ||||||
|                       leading: AccountImage( |                       leading: AccountImage( | ||||||
|                         content: ud.getAccountFromCache(otherMember?.accountId)?.avatar, |                         content: | ||||||
|  |                             ud.getFromCache(otherMember?.accountId)?.avatar, | ||||||
|                       ), |                       ), | ||||||
|                       onTap: () { |                       onTap: () { | ||||||
|                         GoRouter.of(context).pushNamed( |                         GoRouter.of(context).pushNamed( | ||||||
| @@ -291,7 +315,7 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> { | |||||||
|                     title: Text(channel.name), |                     title: Text(channel.name), | ||||||
|                     subtitle: lastMessage != null |                     subtitle: lastMessage != null | ||||||
|                         ? Text( |                         ? Text( | ||||||
|                             '${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}', |                             '${ud.getFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}', | ||||||
|                             maxLines: 1, |                             maxLines: 1, | ||||||
|                             overflow: TextOverflow.ellipsis, |                             overflow: TextOverflow.ellipsis, | ||||||
|                           ) |                           ) | ||||||
| @@ -316,13 +340,20 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> { | |||||||
|                         }, |                         }, | ||||||
|                         extra: ChatRoomScreenExtra( |                         extra: ChatRoomScreenExtra( | ||||||
|                           initialText: widget.value |                           initialText: widget.value | ||||||
|                               .where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type)) |                               .where((e) => [ | ||||||
|  |                                     SharedMediaType.text, | ||||||
|  |                                     SharedMediaType.url | ||||||
|  |                                   ].contains(e.type)) | ||||||
|                               .map((e) => e.path) |                               .map((e) => e.path) | ||||||
|                               .join('\n'), |                               .join('\n'), | ||||||
|                           initialAttachments: widget.value |                           initialAttachments: widget.value | ||||||
|                               .where((e) => |                               .where((e) => [ | ||||||
|                                   [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image].contains(e.type)) |                                     SharedMediaType.video, | ||||||
|                               .map((e) => PostWriteMedia.fromFile(XFile(e.path))) |                                     SharedMediaType.file, | ||||||
|  |                                     SharedMediaType.image | ||||||
|  |                                   ].contains(e.type)) | ||||||
|  |                               .map( | ||||||
|  |                                   (e) => PostWriteMedia.fromFile(XFile(e.path))) | ||||||
|                               .toList(), |                               .toList(), | ||||||
|                         ), |                         ), | ||||||
|                       ) |                       ) | ||||||
|   | |||||||
							
								
								
									
										469
									
								
								lib/screens/stickers.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										469
									
								
								lib/screens/stickers.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,469 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:gap/gap.dart'; | ||||||
|  | import 'package:go_router/go_router.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:surface/providers/sn_network.dart'; | ||||||
|  | import 'package:surface/providers/sn_sticker.dart'; | ||||||
|  | import 'package:surface/providers/userinfo.dart'; | ||||||
|  | import 'package:surface/types/attachment.dart'; | ||||||
|  | import 'package:surface/widgets/app_bar_leading.dart'; | ||||||
|  | import 'package:surface/widgets/attachment/attachment_item.dart'; | ||||||
|  | import 'package:surface/widgets/dialog.dart'; | ||||||
|  | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  | import 'package:very_good_infinite_list/very_good_infinite_list.dart'; | ||||||
|  |  | ||||||
|  | class StickerScreen extends StatefulWidget { | ||||||
|  |   const StickerScreen({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<StickerScreen> createState() => _StickerScreenState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _StickerScreenState extends State<StickerScreen> | ||||||
|  |     with SingleTickerProviderStateMixin { | ||||||
|  |   late final TabController _tabController = | ||||||
|  |       TabController(length: 3, vsync: this); | ||||||
|  |  | ||||||
|  |   bool _isBusy = false; | ||||||
|  |   int? _totalCount; | ||||||
|  |   final List<SnStickerPack> _packs = List.empty(growable: true); | ||||||
|  |  | ||||||
|  |   Future<void> _fetchPacks() async { | ||||||
|  |     try { | ||||||
|  |       setState(() => _isBusy = true); | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       final ua = context.read<UserProvider>(); | ||||||
|  |       final resp = await sn.client.get( | ||||||
|  |         _tabController.index == 1 | ||||||
|  |             ? '/cgi/uc/stickers/packs/own' | ||||||
|  |             : '/cgi/uc/stickers/packs', | ||||||
|  |         queryParameters: { | ||||||
|  |           'take': 10, | ||||||
|  |           'offset': _packs.length, | ||||||
|  |           if (_tabController.index == 2) 'author': ua.user?.id, | ||||||
|  |         }, | ||||||
|  |       ); | ||||||
|  |       if (resp.data is Map<String, dynamic>) { | ||||||
|  |         _totalCount = resp.data['count'] as int?; | ||||||
|  |         final out = List<SnStickerPack>.from( | ||||||
|  |           resp.data['data'].map((ele) => SnStickerPack.fromJson(ele)), | ||||||
|  |         ); | ||||||
|  |         _packs.addAll(out); | ||||||
|  |       } else { | ||||||
|  |         _totalCount = 0; | ||||||
|  |         final out = List<SnStickerPack>.from( | ||||||
|  |           resp.data.map((ele) => SnStickerPack.fromJson(ele)), | ||||||
|  |         ); | ||||||
|  |         _packs.addAll(out); | ||||||
|  |       } | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> _removePack(SnStickerPack pack) async { | ||||||
|  |     try { | ||||||
|  |       setState(() => _isBusy = true); | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       await sn.client.delete('/cgi/uc/stickers/packs/${pack.id}/own'); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showSnackbar('stickersRemoved'.tr()); | ||||||
|  |       _refreshPacks(); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> _deletePack(SnStickerPack pack) async { | ||||||
|  |     final confirm = await context.showConfirmDialog( | ||||||
|  |       'stickersPackDelete'.tr(args: [pack.name]), | ||||||
|  |       'stickersPackDeleteDescription'.tr(), | ||||||
|  |     ); | ||||||
|  |     if (!confirm) return; | ||||||
|  |     if (!mounted) return; | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       setState(() => _isBusy = true); | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       await sn.client.delete('/cgi/uc/stickers/packs/${pack.id}'); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showSnackbar('stickersDeleted'.tr()); | ||||||
|  |       _refreshPacks(); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> _refreshPacks() async { | ||||||
|  |     _packs.clear(); | ||||||
|  |     _totalCount = null; | ||||||
|  |     await _fetchPacks(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _fetchPacks(); | ||||||
|  |     _tabController.addListener(() { | ||||||
|  |       if (_tabController.indexIsChanging) { | ||||||
|  |         _refreshPacks(); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void dispose() { | ||||||
|  |     _tabController.dispose(); | ||||||
|  |     super.dispose(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AppScaffold( | ||||||
|  |       appBar: AppBar( | ||||||
|  |         leading: AutoAppBarLeading(), | ||||||
|  |         title: Text('screenStickers').tr(), | ||||||
|  |         actions: [ | ||||||
|  |           IconButton( | ||||||
|  |             icon: const Icon(Symbols.add_circle), | ||||||
|  |             onPressed: () { | ||||||
|  |               showDialog( | ||||||
|  |                 context: context, | ||||||
|  |                 builder: (context) => _StickerPackCreateDialog(), | ||||||
|  |               ).then((value) { | ||||||
|  |                 if (value == true) _refreshPacks(); | ||||||
|  |               }); | ||||||
|  |             }, | ||||||
|  |           ), | ||||||
|  |           const Gap(8), | ||||||
|  |         ], | ||||||
|  |         bottom: TabBar( | ||||||
|  |           controller: _tabController, | ||||||
|  |           tabs: [ | ||||||
|  |             Tab( | ||||||
|  |               child: Text('stickersDiscovery'.tr()).textColor( | ||||||
|  |                 Theme.of(context).appBarTheme.foregroundColor, | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |             Tab( | ||||||
|  |               child: Text('stickersOwned'.tr()).textColor( | ||||||
|  |                 Theme.of(context).appBarTheme.foregroundColor, | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |             Tab( | ||||||
|  |               child: Text('stickersCreated'.tr()).textColor( | ||||||
|  |                 Theme.of(context).appBarTheme.foregroundColor, | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |       body: MediaQuery.removePadding( | ||||||
|  |         context: context, | ||||||
|  |         removeTop: true, | ||||||
|  |         child: RefreshIndicator( | ||||||
|  |           onRefresh: _refreshPacks, | ||||||
|  |           child: InfiniteList( | ||||||
|  |             itemCount: _packs.length, | ||||||
|  |             onFetchData: _fetchPacks, | ||||||
|  |             hasReachedMax: | ||||||
|  |                 (_totalCount != null && _packs.length >= _totalCount!) || | ||||||
|  |                     _tabController.index == 2, | ||||||
|  |             isLoading: _isBusy, | ||||||
|  |             itemBuilder: (context, idx) { | ||||||
|  |               final pack = _packs[idx]; | ||||||
|  |               return ListTile( | ||||||
|  |                 title: Text(pack.name), | ||||||
|  |                 subtitle: Text( | ||||||
|  |                   pack.description, | ||||||
|  |                   maxLines: 1, | ||||||
|  |                   overflow: TextOverflow.ellipsis, | ||||||
|  |                 ), | ||||||
|  |                 contentPadding: const EdgeInsets.symmetric(horizontal: 16), | ||||||
|  |                 trailing: _tabController.index == 1 | ||||||
|  |                     ? IconButton( | ||||||
|  |                         onPressed: () { | ||||||
|  |                           _removePack(pack); | ||||||
|  |                         }, | ||||||
|  |                         icon: const Icon(Symbols.remove), | ||||||
|  |                       ) | ||||||
|  |                     : _tabController.index == 2 | ||||||
|  |                         ? IconButton( | ||||||
|  |                             onPressed: () { | ||||||
|  |                               _deletePack(pack); | ||||||
|  |                             }, | ||||||
|  |                             icon: const Icon(Symbols.delete), | ||||||
|  |                           ) | ||||||
|  |                         : null, | ||||||
|  |                 onTap: () { | ||||||
|  |                   if (_tabController.index == 0) { | ||||||
|  |                     showModalBottomSheet( | ||||||
|  |                       context: context, | ||||||
|  |                       builder: (context) => _StickerPackAddPopup(pack: pack), | ||||||
|  |                     ).then((value) { | ||||||
|  |                       if (value == true && _tabController.index == 1) { | ||||||
|  |                         _refreshPacks(); | ||||||
|  |                       } | ||||||
|  |                     }); | ||||||
|  |                   } else { | ||||||
|  |                     GoRouter.of(context).pushNamed( | ||||||
|  |                       'stickerPack', | ||||||
|  |                       pathParameters: { | ||||||
|  |                         'id': pack.id.toString(), | ||||||
|  |                       }, | ||||||
|  |                     ); | ||||||
|  |                   } | ||||||
|  |                 }, | ||||||
|  |               ); | ||||||
|  |             }, | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _StickerPackAddPopup extends StatefulWidget { | ||||||
|  |   final SnStickerPack pack; | ||||||
|  |   const _StickerPackAddPopup({required this.pack}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<_StickerPackAddPopup> createState() => _StickerPackAddPopupState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _StickerPackAddPopupState extends State<_StickerPackAddPopup> { | ||||||
|  |   SnStickerPack? _pack; | ||||||
|  |  | ||||||
|  |   bool _isBusy = false; | ||||||
|  |  | ||||||
|  |   Future<void> _fetchPack() async { | ||||||
|  |     try { | ||||||
|  |       setState(() => _isBusy = true); | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       final resp = | ||||||
|  |           await sn.client.get('/cgi/uc/stickers/packs/${widget.pack.id}'); | ||||||
|  |       _pack = SnStickerPack.fromJson(resp.data); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _fetchPack(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   bool _isAdding = false; | ||||||
|  |  | ||||||
|  |   Future<void> _addPack() async { | ||||||
|  |     if (_pack == null) return; | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       setState(() => _isAdding = true); | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       final stickers = context.read<SnStickerProvider>(); | ||||||
|  |       await sn.client.post( | ||||||
|  |         '/cgi/uc/stickers/packs/${widget.pack.id}/own', | ||||||
|  |       ); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showSnackbar('stickersAdded'.tr()); | ||||||
|  |       if (_pack?.stickers != null) { | ||||||
|  |         stickers.putSticker( | ||||||
|  |             _pack!.stickers!.map((ele) => ele.copyWith(pack: _pack!))); | ||||||
|  |       } | ||||||
|  |       Navigator.pop(context, true); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isAdding = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return Column( | ||||||
|  |       crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |       children: [ | ||||||
|  |         Row( | ||||||
|  |           crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |           children: [ | ||||||
|  |             const Icon(Symbols.add, size: 24), | ||||||
|  |             const Gap(16), | ||||||
|  |             Text('stickersAdd', style: Theme.of(context).textTheme.titleLarge) | ||||||
|  |                 .tr(), | ||||||
|  |           ], | ||||||
|  |         ).padding(horizontal: 20, top: 16, bottom: 12), | ||||||
|  |         Row( | ||||||
|  |           crossAxisAlignment: CrossAxisAlignment.end, | ||||||
|  |           children: [ | ||||||
|  |             Expanded( | ||||||
|  |               child: Column( | ||||||
|  |                 crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                 children: [ | ||||||
|  |                   Text(widget.pack.name).bold(), | ||||||
|  |                   Text( | ||||||
|  |                     widget.pack.description, | ||||||
|  |                     maxLines: 2, | ||||||
|  |                     overflow: TextOverflow.ellipsis, | ||||||
|  |                   ), | ||||||
|  |                 ], | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |             ElevatedButton( | ||||||
|  |               onPressed: _isAdding ? null : _addPack, | ||||||
|  |               child: Text('add').tr(), | ||||||
|  |             ), | ||||||
|  |           ], | ||||||
|  |         ).padding(horizontal: 24), | ||||||
|  |         LoadingIndicator(isActive: _isBusy), | ||||||
|  |         if (_pack?.stickers != null) | ||||||
|  |           Expanded( | ||||||
|  |             child: GridView.extent( | ||||||
|  |               padding: EdgeInsets.only(left: 20, right: 20, top: 8), | ||||||
|  |               maxCrossAxisExtent: 48, | ||||||
|  |               mainAxisSpacing: 8, | ||||||
|  |               crossAxisSpacing: 8, | ||||||
|  |               children: _pack!.stickers! | ||||||
|  |                   .map( | ||||||
|  |                     (ele) => ClipRRect( | ||||||
|  |                       borderRadius: BorderRadius.circular(8), | ||||||
|  |                       child: Container( | ||||||
|  |                         color: | ||||||
|  |                             Theme.of(context).colorScheme.surfaceContainerHigh, | ||||||
|  |                         child: AttachmentItem( | ||||||
|  |                           data: ele.attachment, | ||||||
|  |                           heroTag: 'sticker-pack-${ele.attachment.rid}', | ||||||
|  |                           fit: BoxFit.contain, | ||||||
|  |                         ), | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                   ) | ||||||
|  |                   .toList(), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _StickerPackCreateDialog extends StatefulWidget { | ||||||
|  |   const _StickerPackCreateDialog(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<_StickerPackCreateDialog> createState() => | ||||||
|  |       _StickerPackCreateDialogState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _StickerPackCreateDialogState extends State<_StickerPackCreateDialog> { | ||||||
|  |   final TextEditingController _nameController = TextEditingController(); | ||||||
|  |   final TextEditingController _prefixController = TextEditingController(); | ||||||
|  |   final TextEditingController _descriptionController = TextEditingController(); | ||||||
|  |  | ||||||
|  |   bool _isBusy = false; | ||||||
|  |  | ||||||
|  |   Future<void> _createPack() async { | ||||||
|  |     if (_nameController.text.isEmpty || | ||||||
|  |         _prefixController.text.isEmpty || | ||||||
|  |         _descriptionController.text.isEmpty) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       await sn.client.post( | ||||||
|  |         '/cgi/uc/stickers/packs', | ||||||
|  |         data: { | ||||||
|  |           'name': _nameController.text, | ||||||
|  |           'prefix': _prefixController.text, | ||||||
|  |           'description': _descriptionController.text, | ||||||
|  |         }, | ||||||
|  |       ); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       Navigator.pop(context, true); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void dispose() { | ||||||
|  |     _nameController.dispose(); | ||||||
|  |     _prefixController.dispose(); | ||||||
|  |     _descriptionController.dispose(); | ||||||
|  |     super.dispose(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AlertDialog( | ||||||
|  |       title: Text('stickersPackNew').tr(), | ||||||
|  |       content: Column( | ||||||
|  |         mainAxisSize: MainAxisSize.min, | ||||||
|  |         crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |         children: [ | ||||||
|  |           TextField( | ||||||
|  |             controller: _nameController, | ||||||
|  |             decoration: InputDecoration( | ||||||
|  |               border: const UnderlineInputBorder(), | ||||||
|  |               labelText: 'fieldStickerPackName'.tr(), | ||||||
|  |             ), | ||||||
|  |             onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |           ), | ||||||
|  |           const Gap(4), | ||||||
|  |           TextField( | ||||||
|  |             controller: _prefixController, | ||||||
|  |             decoration: InputDecoration( | ||||||
|  |               border: const UnderlineInputBorder(), | ||||||
|  |               labelText: 'fieldStickerPackPrefix'.tr(), | ||||||
|  |             ), | ||||||
|  |             onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |           ), | ||||||
|  |           const Gap(4), | ||||||
|  |           TextField( | ||||||
|  |             controller: _descriptionController, | ||||||
|  |             decoration: InputDecoration( | ||||||
|  |               border: const UnderlineInputBorder(), | ||||||
|  |               labelText: 'fieldStickerPackDescription'.tr(), | ||||||
|  |             ), | ||||||
|  |             onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |       actions: [ | ||||||
|  |         TextButton( | ||||||
|  |           onPressed: _isBusy | ||||||
|  |               ? null | ||||||
|  |               : () { | ||||||
|  |                   Navigator.pop(context); | ||||||
|  |                 }, | ||||||
|  |           child: Text('dialogDismiss').tr(), | ||||||
|  |         ), | ||||||
|  |         TextButton( | ||||||
|  |           onPressed: _isBusy ? null : () => _createPack(), | ||||||
|  |           child: Text('dialogConfirm').tr(), | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										266
									
								
								lib/screens/stickers/pack_detail.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										266
									
								
								lib/screens/stickers/pack_detail.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,266 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:gap/gap.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:surface/providers/sn_network.dart'; | ||||||
|  | import 'package:surface/types/attachment.dart'; | ||||||
|  | import 'package:surface/widgets/attachment/attachment_input.dart'; | ||||||
|  | import 'package:surface/widgets/attachment/attachment_item.dart'; | ||||||
|  | import 'package:surface/widgets/dialog.dart'; | ||||||
|  | import 'package:surface/widgets/loading_indicator.dart'; | ||||||
|  | import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||||
|  |  | ||||||
|  | class StickerPackScreen extends StatefulWidget { | ||||||
|  |   final int id; | ||||||
|  |   const StickerPackScreen({super.key, required this.id}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<StickerPackScreen> createState() => _StickerPackScreenState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _StickerPackScreenState extends State<StickerPackScreen> { | ||||||
|  |   SnStickerPack? _pack; | ||||||
|  |  | ||||||
|  |   Future<void> _fetchPack() async { | ||||||
|  |     try { | ||||||
|  |       setState(() => _isBusy = true); | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       final resp = await sn.client.get('/cgi/uc/stickers/packs/${widget.id}'); | ||||||
|  |       _pack = SnStickerPack.fromJson(resp.data); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   bool _isBusy = false; | ||||||
|  |  | ||||||
|  |   Future<void> _deleteSticker(SnSticker sticker) async { | ||||||
|  |     final confirm = await context.showConfirmDialog( | ||||||
|  |       'stickersDelete'.tr(args: [sticker.name]), | ||||||
|  |       'stickersDeleteDescription'.tr(), | ||||||
|  |     ); | ||||||
|  |     if (!confirm) return; | ||||||
|  |     if (!mounted) return; | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       setState(() => _isBusy = true); | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       await sn.client.delete('/cgi/uc/stickers/${sticker.id}'); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showSnackbar('stickersDeleted'.tr()); | ||||||
|  |       _fetchPack(); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } finally { | ||||||
|  |       setState(() => _isBusy = false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _fetchPack(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AppScaffold( | ||||||
|  |       appBar: AppBar( | ||||||
|  |         title: Text(_pack?.name ?? 'loading'.tr()), | ||||||
|  |       ), | ||||||
|  |       body: Column( | ||||||
|  |         crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |         children: [ | ||||||
|  |           LoadingIndicator(isActive: _isBusy), | ||||||
|  |           if (_pack != null) | ||||||
|  |             Column( | ||||||
|  |               crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |               children: [ | ||||||
|  |                 Text(_pack!.name).bold(), | ||||||
|  |                 Text( | ||||||
|  |                   _pack!.description, | ||||||
|  |                   maxLines: 2, | ||||||
|  |                   overflow: TextOverflow.ellipsis, | ||||||
|  |                 ), | ||||||
|  |               ], | ||||||
|  |             ).padding(horizontal: 24, vertical: 16), | ||||||
|  |           const Divider(height: 1), | ||||||
|  |           ListTile( | ||||||
|  |             leading: const Icon(Symbols.add), | ||||||
|  |             title: Text('stickersNew').tr(), | ||||||
|  |             subtitle: Text('stickersNewDescription').tr(), | ||||||
|  |             contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |             onTap: () { | ||||||
|  |               showDialog( | ||||||
|  |                 context: context, | ||||||
|  |                 builder: (context) => _StickerCreateDialog(pack: _pack!), | ||||||
|  |               ).then((value) { | ||||||
|  |                 if (value) _fetchPack(); | ||||||
|  |               }); | ||||||
|  |             }, | ||||||
|  |           ), | ||||||
|  |           const Divider(height: 1), | ||||||
|  |           if (_pack?.stickers != null) | ||||||
|  |             Expanded( | ||||||
|  |               child: GridView.extent( | ||||||
|  |                 padding: EdgeInsets.only(left: 20, right: 20, top: 16), | ||||||
|  |                 maxCrossAxisExtent: 48, | ||||||
|  |                 mainAxisSpacing: 8, | ||||||
|  |                 crossAxisSpacing: 8, | ||||||
|  |                 children: _pack!.stickers! | ||||||
|  |                     .map( | ||||||
|  |                       (ele) => GestureDetector( | ||||||
|  |                         child: ClipRRect( | ||||||
|  |                           borderRadius: BorderRadius.circular(8), | ||||||
|  |                           child: Container( | ||||||
|  |                             color: Theme.of(context) | ||||||
|  |                                 .colorScheme | ||||||
|  |                                 .surfaceContainerHigh, | ||||||
|  |                             child: AttachmentItem( | ||||||
|  |                               data: ele.attachment, | ||||||
|  |                               heroTag: 'sticker-pack-${ele.attachment.rid}', | ||||||
|  |                               fit: BoxFit.contain, | ||||||
|  |                             ), | ||||||
|  |                           ), | ||||||
|  |                         ), | ||||||
|  |                         onTap: () { | ||||||
|  |                           _deleteSticker(ele); | ||||||
|  |                         }, | ||||||
|  |                       ), | ||||||
|  |                     ) | ||||||
|  |                     .toList(), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _StickerCreateDialog extends StatefulWidget { | ||||||
|  |   final SnStickerPack pack; | ||||||
|  |   const _StickerCreateDialog({required this.pack}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<_StickerCreateDialog> createState() => _StickerCreateDialogState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _StickerCreateDialogState extends State<_StickerCreateDialog> { | ||||||
|  |   final TextEditingController _nameController = TextEditingController(); | ||||||
|  |   final TextEditingController _aliasController = TextEditingController(); | ||||||
|  |   final TextEditingController _attachmentController = TextEditingController(); | ||||||
|  |  | ||||||
|  |   bool _isBusy = false; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void dispose() { | ||||||
|  |     _nameController.dispose(); | ||||||
|  |     _aliasController.dispose(); | ||||||
|  |     _attachmentController.dispose(); | ||||||
|  |     super.dispose(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> _createSticker() async { | ||||||
|  |     if (_nameController.text.isEmpty || | ||||||
|  |         _aliasController.text.isEmpty || | ||||||
|  |         _attachmentController.text.isEmpty) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     setState(() => _isBusy = true); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       final sn = context.read<SnNetworkProvider>(); | ||||||
|  |       await sn.client.post( | ||||||
|  |         '/cgi/uc/stickers', | ||||||
|  |         data: { | ||||||
|  |           'name': _nameController.text, | ||||||
|  |           'alias': _aliasController.text, | ||||||
|  |           'attachment_id': _attachmentController.text, | ||||||
|  |           'pack_id': widget.pack.id, | ||||||
|  |         }, | ||||||
|  |       ); | ||||||
|  |       if (!mounted) return; | ||||||
|  |       Navigator.pop(context, true); | ||||||
|  |     } catch (err) { | ||||||
|  |       if (!mounted) return; | ||||||
|  |       context.showErrorDialog(err); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AlertDialog( | ||||||
|  |       title: Text('stickersNew'.tr()), | ||||||
|  |       content: Column( | ||||||
|  |         mainAxisSize: MainAxisSize.min, | ||||||
|  |         crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |         children: [ | ||||||
|  |           TextField( | ||||||
|  |             controller: _nameController, | ||||||
|  |             decoration: InputDecoration( | ||||||
|  |               border: const UnderlineInputBorder(), | ||||||
|  |               labelText: 'fieldStickerName'.tr(), | ||||||
|  |             ), | ||||||
|  |             onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |           ), | ||||||
|  |           const Gap(4), | ||||||
|  |           TextField( | ||||||
|  |             controller: _aliasController, | ||||||
|  |             decoration: InputDecoration( | ||||||
|  |               border: const UnderlineInputBorder(), | ||||||
|  |               labelText: 'fieldStickerAlias'.tr(), | ||||||
|  |               helperText: 'fieldStickerAliasHint'.tr(), | ||||||
|  |             ), | ||||||
|  |             onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |           ), | ||||||
|  |           const Gap(4), | ||||||
|  |           TextField( | ||||||
|  |             controller: _attachmentController, | ||||||
|  |             decoration: InputDecoration( | ||||||
|  |               border: const UnderlineInputBorder(), | ||||||
|  |               labelText: 'fieldStickerAttachment'.tr(), | ||||||
|  |             ), | ||||||
|  |             readOnly: true, | ||||||
|  |             onTap: () async { | ||||||
|  |               final attachment = await showDialog<SnAttachment?>( | ||||||
|  |                 context: context, | ||||||
|  |                 builder: (context) => AttachmentInputDialog( | ||||||
|  |                   title: 'fieldStickerAttachment'.tr(), | ||||||
|  |                   pool: 'sticker', | ||||||
|  |                   mediaType: SnMediaType.image, | ||||||
|  |                 ), | ||||||
|  |               ); | ||||||
|  |               if (attachment != null) { | ||||||
|  |                 setState(() { | ||||||
|  |                   _attachmentController.text = attachment.rid; | ||||||
|  |                 }); | ||||||
|  |               } | ||||||
|  |             }, | ||||||
|  |             onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |       actions: [ | ||||||
|  |         TextButton( | ||||||
|  |           onPressed: _isBusy | ||||||
|  |               ? null | ||||||
|  |               : () { | ||||||
|  |                   Navigator.pop(context); | ||||||
|  |                 }, | ||||||
|  |           child: Text('dialogDismiss').tr(), | ||||||
|  |         ), | ||||||
|  |         TextButton( | ||||||
|  |           onPressed: _isBusy ? null : () => _createSticker(), | ||||||
|  |           child: Text('dialogConfirm').tr(), | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -11,10 +11,19 @@ class ThemeSet { | |||||||
|   ThemeSet({required this.light, required this.dark}); |   ThemeSet({required this.light, required this.dark}); | ||||||
| } | } | ||||||
|  |  | ||||||
| Future<ThemeSet> createAppThemeSet({Color? seedColorOverride, bool? useMaterial3}) async { | Future<ThemeSet> createAppThemeSet( | ||||||
|  |     {Color? seedColorOverride, bool? useMaterial3, String? customFonts}) async { | ||||||
|   return ThemeSet( |   return ThemeSet( | ||||||
|     light: await createAppTheme(Brightness.light, useMaterial3: useMaterial3), |     light: await createAppTheme( | ||||||
|     dark: await createAppTheme(Brightness.dark, useMaterial3: useMaterial3), |       Brightness.light, | ||||||
|  |       useMaterial3: useMaterial3, | ||||||
|  |       customFonts: customFonts, | ||||||
|  |     ), | ||||||
|  |     dark: await createAppTheme( | ||||||
|  |       Brightness.dark, | ||||||
|  |       useMaterial3: useMaterial3, | ||||||
|  |       customFonts: customFonts, | ||||||
|  |     ), | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -22,24 +31,36 @@ Future<ThemeData> createAppTheme( | |||||||
|   Brightness brightness, { |   Brightness brightness, { | ||||||
|   Color? seedColorOverride, |   Color? seedColorOverride, | ||||||
|   bool? useMaterial3, |   bool? useMaterial3, | ||||||
|  |   String? customFonts, | ||||||
| }) async { | }) async { | ||||||
|   final prefs = await SharedPreferences.getInstance(); |   final prefs = await SharedPreferences.getInstance(); | ||||||
|  |  | ||||||
|   final seedColorString = prefs.getInt(kAppColorSchemeStoreKey); |   final seedColorString = prefs.getInt(kAppColorSchemeStoreKey); | ||||||
|   final seedColor = seedColorString != null ? Color(seedColorString) : Colors.indigo; |   final seedColor = | ||||||
|  |       seedColorString != null ? Color(seedColorString) : Colors.indigo; | ||||||
|  |  | ||||||
|   final colorScheme = ColorScheme.fromSeed( |   final colorScheme = ColorScheme.fromSeed( | ||||||
|     seedColor: seedColorOverride ?? seedColor, |     seedColor: seedColorOverride ?? seedColor, | ||||||
|     brightness: brightness, |     brightness: brightness, | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|   final hasAppBarBlurry = prefs.getBool(kAppbarTransparentStoreKey) ?? false; |   final hasAppBarTransparent = | ||||||
|   final useM3 = useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? true); |       prefs.getBool(kAppbarTransparentStoreKey) ?? false; | ||||||
|  |   final useM3 = | ||||||
|  |       useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? true); | ||||||
|  |  | ||||||
|  |   final inUseFonts = (customFonts ?? prefs.getString(kAppCustomFonts)) | ||||||
|  |           ?.split(',') | ||||||
|  |           .map((ele) => ele.trim()) | ||||||
|  |           .toList() ?? | ||||||
|  |       ['Nunito']; | ||||||
|  |  | ||||||
|   return ThemeData( |   return ThemeData( | ||||||
|     useMaterial3: useM3, |     useMaterial3: useM3, | ||||||
|     colorScheme: colorScheme, |     colorScheme: colorScheme, | ||||||
|     brightness: brightness, |     brightness: brightness, | ||||||
|  |     fontFamily: inUseFonts.firstOrNull, | ||||||
|  |     fontFamilyFallback: inUseFonts.sublist(1), | ||||||
|     iconTheme: IconThemeData( |     iconTheme: IconThemeData( | ||||||
|       fill: 0, |       fill: 0, | ||||||
|       weight: 400, |       weight: 400, | ||||||
| @@ -51,13 +72,15 @@ Future<ThemeData> createAppTheme( | |||||||
|     ), |     ), | ||||||
|     appBarTheme: AppBarTheme( |     appBarTheme: AppBarTheme( | ||||||
|       centerTitle: true, |       centerTitle: true, | ||||||
|       elevation: hasAppBarBlurry ? 0 : null, |       elevation: hasAppBarTransparent ? 0 : null, | ||||||
|       backgroundColor: hasAppBarBlurry ? colorScheme.primary.withOpacity(0.3) : colorScheme.primary, |       backgroundColor: | ||||||
|       foregroundColor: hasAppBarBlurry ? colorScheme.onSurface : colorScheme.onPrimary, |           hasAppBarTransparent ? Colors.transparent : colorScheme.primary, | ||||||
|  |       foregroundColor: | ||||||
|  |           hasAppBarTransparent ? colorScheme.onSurface : colorScheme.onPrimary, | ||||||
|     ), |     ), | ||||||
|     pageTransitionsTheme: PageTransitionsTheme( |     pageTransitionsTheme: PageTransitionsTheme( | ||||||
|       builders: { |       builders: { | ||||||
|         TargetPlatform.android: PredictiveBackPageTransitionsBuilder(), |         TargetPlatform.android: ZoomPageTransitionsBuilder(), | ||||||
|         TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), |         TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), | ||||||
|         TargetPlatform.macOS: ZoomPageTransitionsBuilder(), |         TargetPlatform.macOS: ZoomPageTransitionsBuilder(), | ||||||
|         TargetPlatform.fuchsia: ZoomPageTransitionsBuilder(), |         TargetPlatform.fuchsia: ZoomPageTransitionsBuilder(), | ||||||
| @@ -67,3 +90,20 @@ Future<ThemeData> createAppTheme( | |||||||
|     ), |     ), | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | extension HexColor on Color { | ||||||
|  |   /// String is in the format "aabbcc" or "ffaabbcc" with an optional leading "#". | ||||||
|  |   static Color fromHex(String hexString) { | ||||||
|  |     final buffer = StringBuffer(); | ||||||
|  |     if (hexString.length == 6 || hexString.length == 7) buffer.write('ff'); | ||||||
|  |     buffer.write(hexString.replaceFirst('#', '')); | ||||||
|  |     return Color(int.parse(buffer.toString(), radix: 16)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /// Prefixes a hash sign if [leadingHashSign] is set to `true` (default is `true`). | ||||||
|  |   String toHex({bool leadingHashSign = true}) => '${leadingHashSign ? '#' : ''}' | ||||||
|  |       '${alpha.toRadixString(16).padLeft(2, '0')}' | ||||||
|  |       '${red.toRadixString(16).padLeft(2, '0')}' | ||||||
|  |       '${green.toRadixString(16).padLeft(2, '0')}' | ||||||
|  |       '${blue.toRadixString(16).padLeft(2, '0')}'; | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,15 +1,14 @@ | |||||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | import 'package:freezed_annotation/freezed_annotation.dart'; | ||||||
| import 'package:hive_flutter/hive_flutter.dart'; |  | ||||||
|  |  | ||||||
| part 'account.freezed.dart'; | part 'account.freezed.dart'; | ||||||
| part 'account.g.dart'; | part 'account.g.dart'; | ||||||
|  |  | ||||||
| @freezed | @freezed | ||||||
| class SnAccount with _$SnAccount { | abstract class SnAccount with _$SnAccount { | ||||||
|   const SnAccount._(); |   const SnAccount._(); | ||||||
|  |  | ||||||
|   const factory SnAccount({ |   const factory SnAccount({ | ||||||
|     @HiveField(0) required int id, |     required int id, | ||||||
|     required DateTime createdAt, |     required DateTime createdAt, | ||||||
|     required DateTime updatedAt, |     required DateTime updatedAt, | ||||||
|     required DateTime? deletedAt, |     required DateTime? deletedAt, | ||||||
| @@ -17,10 +16,9 @@ class SnAccount with _$SnAccount { | |||||||
|     required List<SnAccountContact>? contacts, |     required List<SnAccountContact>? contacts, | ||||||
|     @Default("") String avatar, |     @Default("") String avatar, | ||||||
|     @Default("") String banner, |     @Default("") String banner, | ||||||
|     required String description, |  | ||||||
|     required String name, |     required String name, | ||||||
|     required String nick, |     required String nick, | ||||||
|     required Map<String, dynamic> permNodes, |     @Default({}) Map<String, dynamic> permNodes, | ||||||
|     required String language, |     required String language, | ||||||
|     required SnAccountProfile? profile, |     required SnAccountProfile? profile, | ||||||
|     @Default([]) List<SnAccountBadge> badges, |     @Default([]) List<SnAccountBadge> badges, | ||||||
| @@ -36,7 +34,7 @@ class SnAccount with _$SnAccount { | |||||||
| } | } | ||||||
|  |  | ||||||
| @freezed | @freezed | ||||||
| class SnAccountContact with _$SnAccountContact { | abstract class SnAccountContact with _$SnAccountContact { | ||||||
|   const factory SnAccountContact({ |   const factory SnAccountContact({ | ||||||
|     required int accountId, |     required int accountId, | ||||||
|     required String content, |     required String content, | ||||||
| @@ -55,18 +53,24 @@ class SnAccountContact with _$SnAccountContact { | |||||||
| } | } | ||||||
|  |  | ||||||
| @freezed | @freezed | ||||||
| class SnAccountProfile with _$SnAccountProfile { | abstract class SnAccountProfile with _$SnAccountProfile { | ||||||
|   const factory SnAccountProfile({ |   const factory SnAccountProfile({ | ||||||
|     required int id, |     required int id, | ||||||
|     required int accountId, |  | ||||||
|     required DateTime? birthday, |  | ||||||
|     required DateTime createdAt, |     required DateTime createdAt, | ||||||
|  |     required DateTime updatedAt, | ||||||
|     required DateTime? deletedAt, |     required DateTime? deletedAt, | ||||||
|     required int experience, |  | ||||||
|     required String firstName, |     required String firstName, | ||||||
|     required String lastName, |     required String lastName, | ||||||
|  |     required String description, | ||||||
|  |     required String timeZone, | ||||||
|  |     required String location, | ||||||
|  |     required String pronouns, | ||||||
|  |     required String gender, | ||||||
|  |     @Default({}) Map<String, String> links, | ||||||
|  |     required int experience, | ||||||
|     required DateTime? lastSeenAt, |     required DateTime? lastSeenAt, | ||||||
|     required DateTime updatedAt, |     required DateTime? birthday, | ||||||
|  |     required int accountId, | ||||||
|   }) = _SnAccountProfile; |   }) = _SnAccountProfile; | ||||||
|  |  | ||||||
|   factory SnAccountProfile.fromJson(Map<String, Object?> json) => |   factory SnAccountProfile.fromJson(Map<String, Object?> json) => | ||||||
| @@ -74,7 +78,7 @@ class SnAccountProfile with _$SnAccountProfile { | |||||||
| } | } | ||||||
|  |  | ||||||
| @freezed | @freezed | ||||||
| class SnRelationship with _$SnRelationship { | abstract class SnRelationship with _$SnRelationship { | ||||||
|   const factory SnRelationship({ |   const factory SnRelationship({ | ||||||
|     required int id, |     required int id, | ||||||
|     required DateTime createdAt, |     required DateTime createdAt, | ||||||
| @@ -93,7 +97,7 @@ class SnRelationship with _$SnRelationship { | |||||||
| } | } | ||||||
|  |  | ||||||
| @freezed | @freezed | ||||||
| class SnAccountBadge with _$SnAccountBadge { | abstract class SnAccountBadge with _$SnAccountBadge { | ||||||
|   const factory SnAccountBadge({ |   const factory SnAccountBadge({ | ||||||
|     required int id, |     required int id, | ||||||
|     required DateTime createdAt, |     required DateTime createdAt, | ||||||
| @@ -101,6 +105,7 @@ class SnAccountBadge with _$SnAccountBadge { | |||||||
|     required dynamic deletedAt, |     required dynamic deletedAt, | ||||||
|     required String type, |     required String type, | ||||||
|     required int accountId, |     required int accountId, | ||||||
|  |     @Default(false) bool isActive, | ||||||
|     @Default({}) Map<String, dynamic> metadata, |     @Default({}) Map<String, dynamic> metadata, | ||||||
|   }) = _SnAccountBadge; |   }) = _SnAccountBadge; | ||||||
|  |  | ||||||
| @@ -109,7 +114,7 @@ class SnAccountBadge with _$SnAccountBadge { | |||||||
| } | } | ||||||
|  |  | ||||||
| @freezed | @freezed | ||||||
| class SnAccountStatusInfo with _$SnAccountStatusInfo { | abstract class SnAccountStatusInfo with _$SnAccountStatusInfo { | ||||||
|   const factory SnAccountStatusInfo({ |   const factory SnAccountStatusInfo({ | ||||||
|     required bool isDisturbable, |     required bool isDisturbable, | ||||||
|     required bool isOnline, |     required bool isOnline, | ||||||
| @@ -122,7 +127,7 @@ class SnAccountStatusInfo with _$SnAccountStatusInfo { | |||||||
| } | } | ||||||
|  |  | ||||||
| @freezed | @freezed | ||||||
| class SnAbuseReport with _$SnAbuseReport { | abstract class SnAbuseReport with _$SnAbuseReport { | ||||||
|   const factory SnAbuseReport({ |   const factory SnAbuseReport({ | ||||||
|     required int id, |     required int id, | ||||||
|     required DateTime createdAt, |     required DateTime createdAt, | ||||||
|   | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -6,8 +6,7 @@ part of 'account.dart'; | |||||||
| // JsonSerializableGenerator | // JsonSerializableGenerator | ||||||
| // ************************************************************************** | // ************************************************************************** | ||||||
|  |  | ||||||
| _$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) => | _SnAccount _$SnAccountFromJson(Map<String, dynamic> json) => _SnAccount( | ||||||
|     _$SnAccountImpl( |  | ||||||
|       id: (json['id'] as num).toInt(), |       id: (json['id'] as num).toInt(), | ||||||
|       createdAt: DateTime.parse(json['created_at'] as String), |       createdAt: DateTime.parse(json['created_at'] as String), | ||||||
|       updatedAt: DateTime.parse(json['updated_at'] as String), |       updatedAt: DateTime.parse(json['updated_at'] as String), | ||||||
| @@ -22,10 +21,9 @@ _$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) => | |||||||
|           .toList(), |           .toList(), | ||||||
|       avatar: json['avatar'] as String? ?? "", |       avatar: json['avatar'] as String? ?? "", | ||||||
|       banner: json['banner'] as String? ?? "", |       banner: json['banner'] as String? ?? "", | ||||||
|       description: json['description'] as String, |  | ||||||
|       name: json['name'] as String, |       name: json['name'] as String, | ||||||
|       nick: json['nick'] as String, |       nick: json['nick'] as String, | ||||||
|       permNodes: json['perm_nodes'] as Map<String, dynamic>, |       permNodes: json['perm_nodes'] as Map<String, dynamic>? ?? const {}, | ||||||
|       language: json['language'] as String, |       language: json['language'] as String, | ||||||
|       profile: json['profile'] == null |       profile: json['profile'] == null | ||||||
|           ? null |           ? null | ||||||
| @@ -43,7 +41,7 @@ _$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) => | |||||||
|       automatedId: (json['automated_id'] as num?)?.toInt(), |       automatedId: (json['automated_id'] as num?)?.toInt(), | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
| Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) => | Map<String, dynamic> _$SnAccountToJson(_SnAccount instance) => | ||||||
|     <String, dynamic>{ |     <String, dynamic>{ | ||||||
|       'id': instance.id, |       'id': instance.id, | ||||||
|       'created_at': instance.createdAt.toIso8601String(), |       'created_at': instance.createdAt.toIso8601String(), | ||||||
| @@ -53,7 +51,6 @@ Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) => | |||||||
|       'contacts': instance.contacts?.map((e) => e.toJson()).toList(), |       'contacts': instance.contacts?.map((e) => e.toJson()).toList(), | ||||||
|       'avatar': instance.avatar, |       'avatar': instance.avatar, | ||||||
|       'banner': instance.banner, |       'banner': instance.banner, | ||||||
|       'description': instance.description, |  | ||||||
|       'name': instance.name, |       'name': instance.name, | ||||||
|       'nick': instance.nick, |       'nick': instance.nick, | ||||||
|       'perm_nodes': instance.permNodes, |       'perm_nodes': instance.permNodes, | ||||||
| @@ -67,9 +64,8 @@ Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) => | |||||||
|       'automated_id': instance.automatedId, |       'automated_id': instance.automatedId, | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
| _$SnAccountContactImpl _$$SnAccountContactImplFromJson( | _SnAccountContact _$SnAccountContactFromJson(Map<String, dynamic> json) => | ||||||
|         Map<String, dynamic> json) => |     _SnAccountContact( | ||||||
|     _$SnAccountContactImpl( |  | ||||||
|       accountId: (json['account_id'] as num).toInt(), |       accountId: (json['account_id'] as num).toInt(), | ||||||
|       content: json['content'] as String, |       content: json['content'] as String, | ||||||
|       createdAt: DateTime.parse(json['created_at'] as String), |       createdAt: DateTime.parse(json['created_at'] as String), | ||||||
| @@ -86,8 +82,7 @@ _$SnAccountContactImpl _$$SnAccountContactImplFromJson( | |||||||
|           : DateTime.parse(json['verified_at'] as String), |           : DateTime.parse(json['verified_at'] as String), | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
| Map<String, dynamic> _$$SnAccountContactImplToJson( | Map<String, dynamic> _$SnAccountContactToJson(_SnAccountContact instance) => | ||||||
|         _$SnAccountContactImpl instance) => |  | ||||||
|     <String, dynamic>{ |     <String, dynamic>{ | ||||||
|       'account_id': instance.accountId, |       'account_id': instance.accountId, | ||||||
|       'content': instance.content, |       'content': instance.content, | ||||||
| @@ -101,44 +96,57 @@ Map<String, dynamic> _$$SnAccountContactImplToJson( | |||||||
|       'verified_at': instance.verifiedAt?.toIso8601String(), |       'verified_at': instance.verifiedAt?.toIso8601String(), | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
| _$SnAccountProfileImpl _$$SnAccountProfileImplFromJson( | _SnAccountProfile _$SnAccountProfileFromJson(Map<String, dynamic> json) => | ||||||
|         Map<String, dynamic> json) => |     _SnAccountProfile( | ||||||
|     _$SnAccountProfileImpl( |  | ||||||
|       id: (json['id'] as num).toInt(), |       id: (json['id'] as num).toInt(), | ||||||
|       accountId: (json['account_id'] as num).toInt(), |  | ||||||
|       birthday: json['birthday'] == null |  | ||||||
|           ? null |  | ||||||
|           : DateTime.parse(json['birthday'] as String), |  | ||||||
|       createdAt: DateTime.parse(json['created_at'] as String), |       createdAt: DateTime.parse(json['created_at'] as String), | ||||||
|  |       updatedAt: DateTime.parse(json['updated_at'] as String), | ||||||
|       deletedAt: json['deleted_at'] == null |       deletedAt: json['deleted_at'] == null | ||||||
|           ? null |           ? null | ||||||
|           : DateTime.parse(json['deleted_at'] as String), |           : DateTime.parse(json['deleted_at'] as String), | ||||||
|       experience: (json['experience'] as num).toInt(), |  | ||||||
|       firstName: json['first_name'] as String, |       firstName: json['first_name'] as String, | ||||||
|       lastName: json['last_name'] as String, |       lastName: json['last_name'] as String, | ||||||
|  |       description: json['description'] as String, | ||||||
|  |       timeZone: json['time_zone'] as String, | ||||||
|  |       location: json['location'] as String, | ||||||
|  |       pronouns: json['pronouns'] as String, | ||||||
|  |       gender: json['gender'] as String, | ||||||
|  |       links: (json['links'] as Map<String, dynamic>?)?.map( | ||||||
|  |             (k, e) => MapEntry(k, e as String), | ||||||
|  |           ) ?? | ||||||
|  |           const {}, | ||||||
|  |       experience: (json['experience'] as num).toInt(), | ||||||
|       lastSeenAt: json['last_seen_at'] == null |       lastSeenAt: json['last_seen_at'] == null | ||||||
|           ? null |           ? null | ||||||
|           : DateTime.parse(json['last_seen_at'] as String), |           : DateTime.parse(json['last_seen_at'] as String), | ||||||
|       updatedAt: DateTime.parse(json['updated_at'] as String), |       birthday: json['birthday'] == null | ||||||
|  |           ? null | ||||||
|  |           : DateTime.parse(json['birthday'] as String), | ||||||
|  |       accountId: (json['account_id'] as num).toInt(), | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
| Map<String, dynamic> _$$SnAccountProfileImplToJson( | Map<String, dynamic> _$SnAccountProfileToJson(_SnAccountProfile instance) => | ||||||
|         _$SnAccountProfileImpl instance) => |  | ||||||
|     <String, dynamic>{ |     <String, dynamic>{ | ||||||
|       'id': instance.id, |       'id': instance.id, | ||||||
|       'account_id': instance.accountId, |  | ||||||
|       'birthday': instance.birthday?.toIso8601String(), |  | ||||||
|       'created_at': instance.createdAt.toIso8601String(), |       'created_at': instance.createdAt.toIso8601String(), | ||||||
|  |       'updated_at': instance.updatedAt.toIso8601String(), | ||||||
|       'deleted_at': instance.deletedAt?.toIso8601String(), |       'deleted_at': instance.deletedAt?.toIso8601String(), | ||||||
|       'experience': instance.experience, |  | ||||||
|       'first_name': instance.firstName, |       'first_name': instance.firstName, | ||||||
|       'last_name': instance.lastName, |       'last_name': instance.lastName, | ||||||
|  |       'description': instance.description, | ||||||
|  |       'time_zone': instance.timeZone, | ||||||
|  |       'location': instance.location, | ||||||
|  |       'pronouns': instance.pronouns, | ||||||
|  |       'gender': instance.gender, | ||||||
|  |       'links': instance.links, | ||||||
|  |       'experience': instance.experience, | ||||||
|       'last_seen_at': instance.lastSeenAt?.toIso8601String(), |       'last_seen_at': instance.lastSeenAt?.toIso8601String(), | ||||||
|       'updated_at': instance.updatedAt.toIso8601String(), |       'birthday': instance.birthday?.toIso8601String(), | ||||||
|  |       'account_id': instance.accountId, | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
| _$SnRelationshipImpl _$$SnRelationshipImplFromJson(Map<String, dynamic> json) => | _SnRelationship _$SnRelationshipFromJson(Map<String, dynamic> json) => | ||||||
|     _$SnRelationshipImpl( |     _SnRelationship( | ||||||
|       id: (json['id'] as num).toInt(), |       id: (json['id'] as num).toInt(), | ||||||
|       createdAt: DateTime.parse(json['created_at'] as String), |       createdAt: DateTime.parse(json['created_at'] as String), | ||||||
|       updatedAt: DateTime.parse(json['updated_at'] as String), |       updatedAt: DateTime.parse(json['updated_at'] as String), | ||||||
| @@ -157,8 +165,7 @@ _$SnRelationshipImpl _$$SnRelationshipImplFromJson(Map<String, dynamic> json) => | |||||||
|       permNodes: json['perm_nodes'] as Map<String, dynamic>? ?? const {}, |       permNodes: json['perm_nodes'] as Map<String, dynamic>? ?? const {}, | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
| Map<String, dynamic> _$$SnRelationshipImplToJson( | Map<String, dynamic> _$SnRelationshipToJson(_SnRelationship instance) => | ||||||
|         _$SnRelationshipImpl instance) => |  | ||||||
|     <String, dynamic>{ |     <String, dynamic>{ | ||||||
|       'id': instance.id, |       'id': instance.id, | ||||||
|       'created_at': instance.createdAt.toIso8601String(), |       'created_at': instance.createdAt.toIso8601String(), | ||||||
| @@ -172,19 +179,19 @@ Map<String, dynamic> _$$SnRelationshipImplToJson( | |||||||
|       'perm_nodes': instance.permNodes, |       'perm_nodes': instance.permNodes, | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
| _$SnAccountBadgeImpl _$$SnAccountBadgeImplFromJson(Map<String, dynamic> json) => | _SnAccountBadge _$SnAccountBadgeFromJson(Map<String, dynamic> json) => | ||||||
|     _$SnAccountBadgeImpl( |     _SnAccountBadge( | ||||||
|       id: (json['id'] as num).toInt(), |       id: (json['id'] as num).toInt(), | ||||||
|       createdAt: DateTime.parse(json['created_at'] as String), |       createdAt: DateTime.parse(json['created_at'] as String), | ||||||
|       updatedAt: DateTime.parse(json['updated_at'] as String), |       updatedAt: DateTime.parse(json['updated_at'] as String), | ||||||
|       deletedAt: json['deleted_at'], |       deletedAt: json['deleted_at'], | ||||||
|       type: json['type'] as String, |       type: json['type'] as String, | ||||||
|       accountId: (json['account_id'] as num).toInt(), |       accountId: (json['account_id'] as num).toInt(), | ||||||
|  |       isActive: json['is_active'] as bool? ?? false, | ||||||
|       metadata: json['metadata'] as Map<String, dynamic>? ?? const {}, |       metadata: json['metadata'] as Map<String, dynamic>? ?? const {}, | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
| Map<String, dynamic> _$$SnAccountBadgeImplToJson( | Map<String, dynamic> _$SnAccountBadgeToJson(_SnAccountBadge instance) => | ||||||
|         _$SnAccountBadgeImpl instance) => |  | ||||||
|     <String, dynamic>{ |     <String, dynamic>{ | ||||||
|       'id': instance.id, |       'id': instance.id, | ||||||
|       'created_at': instance.createdAt.toIso8601String(), |       'created_at': instance.createdAt.toIso8601String(), | ||||||
| @@ -192,12 +199,12 @@ Map<String, dynamic> _$$SnAccountBadgeImplToJson( | |||||||
|       'deleted_at': instance.deletedAt, |       'deleted_at': instance.deletedAt, | ||||||
|       'type': instance.type, |       'type': instance.type, | ||||||
|       'account_id': instance.accountId, |       'account_id': instance.accountId, | ||||||
|  |       'is_active': instance.isActive, | ||||||
|       'metadata': instance.metadata, |       'metadata': instance.metadata, | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
| _$SnAccountStatusInfoImpl _$$SnAccountStatusInfoImplFromJson( | _SnAccountStatusInfo _$SnAccountStatusInfoFromJson(Map<String, dynamic> json) => | ||||||
|         Map<String, dynamic> json) => |     _SnAccountStatusInfo( | ||||||
|     _$SnAccountStatusInfoImpl( |  | ||||||
|       isDisturbable: json['is_disturbable'] as bool, |       isDisturbable: json['is_disturbable'] as bool, | ||||||
|       isOnline: json['is_online'] as bool, |       isOnline: json['is_online'] as bool, | ||||||
|       lastSeenAt: json['last_seen_at'] == null |       lastSeenAt: json['last_seen_at'] == null | ||||||
| @@ -206,8 +213,8 @@ _$SnAccountStatusInfoImpl _$$SnAccountStatusInfoImplFromJson( | |||||||
|       status: json['status'], |       status: json['status'], | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
| Map<String, dynamic> _$$SnAccountStatusInfoImplToJson( | Map<String, dynamic> _$SnAccountStatusInfoToJson( | ||||||
|         _$SnAccountStatusInfoImpl instance) => |         _SnAccountStatusInfo instance) => | ||||||
|     <String, dynamic>{ |     <String, dynamic>{ | ||||||
|       'is_disturbable': instance.isDisturbable, |       'is_disturbable': instance.isDisturbable, | ||||||
|       'is_online': instance.isOnline, |       'is_online': instance.isOnline, | ||||||
| @@ -215,8 +222,8 @@ Map<String, dynamic> _$$SnAccountStatusInfoImplToJson( | |||||||
|       'status': instance.status, |       'status': instance.status, | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
| _$SnAbuseReportImpl _$$SnAbuseReportImplFromJson(Map<String, dynamic> json) => | _SnAbuseReport _$SnAbuseReportFromJson(Map<String, dynamic> json) => | ||||||
|     _$SnAbuseReportImpl( |     _SnAbuseReport( | ||||||
|       id: (json['id'] as num).toInt(), |       id: (json['id'] as num).toInt(), | ||||||
|       createdAt: DateTime.parse(json['created_at'] as String), |       createdAt: DateTime.parse(json['created_at'] as String), | ||||||
|       updatedAt: DateTime.parse(json['updated_at'] as String), |       updatedAt: DateTime.parse(json['updated_at'] as String), | ||||||
| @@ -229,7 +236,7 @@ _$SnAbuseReportImpl _$$SnAbuseReportImplFromJson(Map<String, dynamic> json) => | |||||||
|       accountId: (json['account_id'] as num).toInt(), |       accountId: (json['account_id'] as num).toInt(), | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
| Map<String, dynamic> _$$SnAbuseReportImplToJson(_$SnAbuseReportImpl instance) => | Map<String, dynamic> _$SnAbuseReportToJson(_SnAbuseReport instance) => | ||||||
|     <String, dynamic>{ |     <String, dynamic>{ | ||||||
|       'id': instance.id, |       'id': instance.id, | ||||||
|       'created_at': instance.createdAt.toIso8601String(), |       'created_at': instance.createdAt.toIso8601String(), | ||||||
|   | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user