Compare commits

...

202 Commits

Author SHA1 Message Date
ceb5c53229 🚀 Launch 2.4.2+85 2025-03-28 01:01:34 +08:00
908f0cb59e 🐛 Fix some issues 2025-03-28 00:54:51 +08:00
7c2b8de931 Desktop chat list
🍱 Update launch sfx
2025-03-28 00:52:19 +08:00
6bb9c21759 Rollback drawer style on mobile
🗑️ Remove drawer prefer collapse & expand
2025-03-28 00:00:39 +08:00
8f2fc55608 User ear healthy 2025-03-27 23:52:37 +08:00
a1c4e5eca0 ♻️ Refactored large screen user experience 2025-03-27 23:18:40 +08:00
595050f89f ♻️ Explore two column 2025-03-27 22:58:06 +08:00
0722c99f21 ♻️ Openable Post Item now push pages 2025-03-27 22:46:36 +08:00
12d03836f9 ♻️ Updated nav & account page two column design 2025-03-27 22:42:44 +08:00
f78d3f4fd5 🔀 Merge pull request #18 from Texas0295/master
Fix workflow
2025-03-27 22:04:02 +08:00
e798a8ba76 fix workflow 2025-03-27 21:24:38 +08:00
c28a664373 Memorable window size 2025-03-27 00:37:45 +08:00
4589722c3b Weird boot sound effects 2025-03-27 00:22:41 +08:00
38e1c51b45 🐛 Fix linux compile issue 2025-03-26 23:32:23 +08:00
610ddec05c Sound effects on notify 2025-03-26 23:16:55 +08:00
d0276f9ac6 🐛 Fix some date issue 2025-03-26 22:51:09 +08:00
c1e89a2ee6 Punishments 2025-03-26 22:43:27 +08:00
ecc79368a1 🐛 Fix attachment border in list 2025-03-26 00:29:00 +08:00
e6d732c86a 💄 Optimize status text 2025-03-26 00:26:37 +08:00
dd055fb077 💄 Optimization and bug fixes 2025-03-26 00:24:07 +08:00
280840c6d8 ⬆️ Upgrade deps 2025-03-25 21:33:58 +08:00
bde62a7b2c Add cache for audio and video (experimental) 2025-03-25 00:33:39 +08:00
5445c570a2 Add deps for google_mobile_ads 2025-03-24 23:12:49 +08:00
b2302f5b3c 🐛 Make initialize for push notification no longer waited 2025-03-24 20:55:55 +08:00
d7359cfd0d 🐛 Fixes and optimization in programs 2025-03-24 00:09:36 +08:00
9cc577adbe Programs, members
🐛 Fix web assets redirecting issue
2025-03-23 22:34:58 +08:00
dd196b7754 Golden points 2025-03-23 18:23:18 +08:00
16c07c2133 🐛 Fix deps 2025-03-23 17:01:21 +08:00
6bcb658d44 🐛 Fix platform specific captcha solution cause build failed. 2025-03-23 16:47:06 +08:00
9311bfc3b5 ⬆️ Upgrade deps & replace to own translation api 2025-03-23 16:26:41 +08:00
8dd6435a30 🐛 Fix some issues on Android and Web 2025-03-23 16:24:53 +08:00
21a1d4a2ad 🐛 Fix unable select answer 2025-03-23 00:01:48 +08:00
603875b1af 🐛 Fix styling issue 2025-03-22 23:07:13 +08:00
4209a13c84 🐛 Fix no nav to use 2025-03-22 22:51:50 +08:00
55b79bfd8f 🐛 Finish bug fixes 2025-03-22 21:50:01 +08:00
6e6c3f42f6 🚀 Launch 2.4.2+84 2025-03-22 20:28:53 +08:00
dc38b46b2c Support captcha 2025-03-22 20:24:05 +08:00
b4990308e9 ♻️ Refactored nav completely 2025-03-22 18:39:01 +08:00
237abe564d ♻️ Refactored drawer nav 2025-03-22 18:14:36 +08:00
71b41d470a Splash screen loading 2025-03-22 16:36:10 +08:00
7052b5b635 Join channel hint
🗃️ Realm local db
2025-03-22 14:12:46 +08:00
f356e08f79 💄 New navigation draft (skip ci) 2025-03-22 12:48:55 +08:00
152872db65 💄 Show nick instead of name in typing indicator 2025-03-22 00:16:59 +08:00
dfe117d04f Auth preference screen 2025-03-22 00:14:55 +08:00
caf63f0cbe Notification preferences 2025-03-21 23:59:42 +08:00
b8f5cc82f9 Add attachments from file 2025-03-20 23:20:24 +08:00
360bc50f21 🐛 Fix linux G_APPLICATION_FLAGS_NONE api deprecated 2025-03-20 22:52:32 +08:00
2de93a0486 🐛 Close #15 with vide coding (not tested) 2025-03-20 22:45:53 +08:00
02227852f8 🚀 Launch 2.4.2+83 for some platforms 2025-03-19 00:48:36 +08:00
ad16de595b 🐛 Fix menubar missing hide 2025-03-19 00:30:58 +08:00
9f8c8923d9 🐛 Bug fixes in posts 2025-03-19 00:29:29 +08:00
060bfa4887 🐛 Fix explore unmixed feed pagination issue 2025-03-19 00:23:57 +08:00
e68ada2d04 💄 Optimize post comments 2025-03-19 00:21:54 +08:00
d6013078bd 🚀 Launch 2.4.2+81 2025-03-17 00:38:15 +08:00
5976d61997 💄 Bunch of optimization 2025-03-17 00:36:20 +08:00
b492db90ca 🚀 Launch Feature Drop 2.4.2+80 2025-03-16 23:27:52 +08:00
c9f69fed2c Complete translation 2025-03-16 23:24:36 +08:00
d2f4e7a969 Message translation 2025-03-16 23:10:59 +08:00
aecd04e0b9 Translate infra & post translation 2025-03-16 23:05:07 +08:00
e5212419ae 🐛 Fix poll percentage background 2025-03-16 22:13:19 +08:00
ec7650a920 💄 Optimize nesting 2025-03-16 22:11:40 +08:00
7b96013406 Better(?) comment nesting 2025-03-16 21:41:38 +08:00
fc5a79b29b Blurry attachment background 2025-03-16 19:34:42 +08:00
4146820be5 🐛 Bug fixes 2025-03-16 19:24:21 +08:00
9ec0f1ff19 💄 Redesigned post item 2025-03-16 18:56:08 +08:00
ac2aec48aa Allow to delete contact methods 2025-03-16 11:55:03 +08:00
58421e5d5e Basic contact methods 2025-03-16 01:39:05 +08:00
172d0d24fb 🐛 Fix unconfirmed indicator display logic 2025-03-15 23:17:03 +08:00
71899dd4f2 Empty sticker picker placeholder 2025-03-15 23:07:46 +08:00
02ffe9866d Unconfirmed account indicator 2025-03-15 22:53:27 +08:00
1b7e668b3f Dispose current session when logout 2025-03-15 21:11:49 +08:00
f03d80ba88 Auth tickets management 2025-03-15 20:27:14 +08:00
14ee6845ed Action events 2025-03-15 19:28:37 +08:00
8fe6c2be46 Upgrade deps and add flutter map 2025-03-15 18:37:00 +08:00
78e765f69d 🐛 Bug fixes 2025-03-15 15:44:56 +08:00
ddd6ff7eee Service status on home
🗑️ Remove news from home
2025-03-15 15:38:50 +08:00
b8f379796f News in feed 2025-03-15 14:53:42 +08:00
3a10e9280c 💄 Optimize news rendering 2025-03-13 23:04:34 +08:00
65fe06de22 ♻️ Optimized fediverse post displaying 2025-03-13 22:26:35 +08:00
e44320e0fe Basic fediverse posts displaying 2025-03-13 00:09:28 +08:00
f2d913ffec 🐛 Fix un-centered text 2025-03-10 21:37:58 +08:00
e88dea8858 MacOS menubar 2025-03-10 21:35:33 +08:00
813679b161 🐛 Bug fixes on post editor 2025-03-10 21:02:18 +08:00
9d4ce6ca8c 🚀 Launch bug hotfix +79 2025-03-09 15:22:25 +08:00
88396647f3 🚀 Launch 2.4.2+78 Feature Drop 2025-03-09 14:04:18 +08:00
335318ae3f Status system 2025-03-09 14:00:35 +08:00
da25fb9c29 🐛 Fix user cache 2025-03-09 13:03:57 +08:00
c1aef89b84 ♻️ Refactor account badge showing 2025-03-09 12:57:53 +08:00
0241c5f804 Check in streak 2025-03-09 12:41:34 +08:00
f6939d7c23 💄 Adjust icon size 2025-03-09 01:31:31 +08:00
d654c162e3 Shuffle post 2025-03-09 00:49:13 +08:00
25550ba197 💄 Changes to the showing of realm post 2025-03-09 00:11:01 +08:00
3defd3a593 💄 Modify explore appbar 2025-03-08 23:51:22 +08:00
d62ed4c375 Adjust explore categorized mode 2025-03-08 22:40:17 +08:00
857f3cc832 Post drafts 2025-03-08 22:32:38 +08:00
e16bc80eea 🐛 Fix attachments 2025-03-08 19:19:06 +08:00
a4f6e8af56 ♻️ New post explore realm design 2025-03-08 18:43:58 +08:00
060a97f5ec ♻️ Refactored explore screen 2025-03-08 18:19:57 +08:00
92f7e92018 🐛 Bug fixes due to post editor changes 2025-03-08 16:04:51 +08:00
5c483bd3b8 ♻️ Move the post editor mode into editor itself 2025-03-08 16:00:10 +08:00
1c510d63fe 🐛 Fix share via image errored 2025-03-06 22:46:02 +08:00
115cb4adc1 💄 Redesigned attachment zoom view 2025-03-06 22:35:06 +08:00
54c098c274 🍱 Update assets
 Optimize loading of web version in some regions
2025-03-05 22:23:42 +08:00
29731728cd 🚀 Launch 2.4.2+76 2025-03-05 00:43:50 +08:00
9e8882c580 Complete profile page 2025-03-05 00:21:25 +08:00
6042e57e7a 🐛 Fix orientation inconsistences 2025-03-05 00:00:11 +08:00
6235e736b9 Sticker cache 2025-03-04 23:56:39 +08:00
e075804782 🐛 Bug fixes on channel member cache 2025-03-04 23:39:55 +08:00
d40a6ca1c4 User channel profile cache 2025-03-04 23:35:28 +08:00
5ac657e526 Attachment local cache 2025-03-04 23:13:43 +08:00
97ddc18b8e 🗃️ Add expired to cache
 Add sticker cache
2025-03-04 22:56:43 +08:00
b835c8edea 💄 Optimize badges list screen 2025-03-04 22:33:56 +08:00
288c0399f9 User cache 2025-03-04 22:30:17 +08:00
1478933cf1 🐛 Fix editing message mock issue 2025-03-04 21:59:18 +08:00
93c6fa6e53 🗃️ Add more cache ability to local database 2025-03-04 21:49:24 +08:00
ce6e9c185a ♻️ Refactor channel list
💄 Stop previewing encrypted message raw message
2025-03-04 21:34:28 +08:00
cdaa8cfe58 🐛 Fix loading indicator not hiding on first time load 2025-03-04 21:20:54 +08:00
76d8cd943d 💄 Optimize de/encrypting animations 2025-03-04 21:17:17 +08:00
d6f3ffc655 Functional key exchange 2025-03-04 21:08:40 +08:00
5a6b841253 Sending encrypted message 2025-03-03 23:56:45 +08:00
cb2de52bee Key pairs 2025-03-03 23:04:02 +08:00
64e2644745 Keypair Infra 2025-03-03 22:25:59 +08:00
56711889ab 🗃️ Local keypair db 2025-03-03 21:31:41 +08:00
4f47cd2c0c 💄 Optimize chat style 2025-03-03 21:13:26 +08:00
2b61c372f5 Allow profile picture (avatar & banner) upload gif 2025-03-03 20:53:42 +08:00
73777fe74e 💄 Optimize attachment view 2025-03-02 22:53:14 +08:00
33a4bd7e71 🐛 Bug fixes 2025-03-02 21:56:45 +08:00
17e6b81f76 Show badge in more places
♻️ Refactor account image
2025-03-02 21:52:41 +08:00
22fde6b400 🍱 Add more badges 2025-03-02 21:19:59 +08:00
6e03a00280 Wearable badge 2025-03-02 21:08:41 +08:00
72e6a6a1f6 Enhanced profile edit 2025-03-02 20:37:36 +08:00
66aef44281 ⬆️ Upgrade freezed 2025-03-02 15:22:24 +08:00
7bb73c80b0 🐛 Fixes on load new messages 2025-03-01 22:52:22 +08:00
d043ef2410 🐛 Fix websocket uri too long cause disconnect 2025-03-01 18:49:45 +08:00
1d0e2f7591 Provide client id to websocket 2025-03-01 18:34:59 +08:00
e9ef28d764 Optimize loading speed of chat
 Support new subscribe channel
2025-03-01 18:32:31 +08:00
289aa17a7a 🐛 Fix video post editor layout issue 2025-02-28 00:11:54 +08:00
93f41bb523 Chat input auto grow 2025-02-28 00:08:12 +08:00
09ec9d4a0c 🐛 Fix displaying quoted message attachment with weird padding 2025-02-28 00:03:47 +08:00
1153fbdeee Cache management 2025-02-27 23:46:47 +08:00
e933058338 💄 Optimize runtime log screen 2025-02-27 23:33:29 +08:00
ae9743c84f ♻️ Refactor logging module 2025-02-27 23:30:08 +08:00
32bf834108 Logging framework 2025-02-27 22:58:31 +08:00
1b41c847a6 Custom fonts 2025-02-27 22:35:12 +08:00
b1af6c2c97 🐛 Optimize and fix profile page loading issue 2025-02-27 22:11:53 +08:00
8e76ff3f84 Optimize user loading api usage 2025-02-27 20:51:47 +08:00
bd26602299 Code highlighting 2025-02-26 23:29:02 +08:00
52ab1d0d10 🐛 Fix chat last message displaying inconsistences 2025-02-26 00:29:35 +08:00
f746e06f65 ⚗️ Experimental user first badge showing on chat 2025-02-26 00:25:42 +08:00
d11069a2be 🐛 Bug fixes on notification page 2025-02-26 00:00:53 +08:00
d6dc487d9e Latex Rendering, closed #9 2025-02-25 23:49:48 +08:00
a07c7cdede 🐛 Fix infinite loading own sticker 2025-02-25 22:56:30 +08:00
acbc125dec 🚀 Launch 2.3.2+75 2025-02-24 23:21:06 +08:00
ad0ee971c1 Desktop mute notification
🐛 Bug fixes on tray icon
2025-02-24 22:46:02 +08:00
52d6bb083e 🐛 Fix macos titlebar not centered 2025-02-24 22:38:08 +08:00
2027eab49b 💄 Optimize displaying of message 2025-02-24 22:35:14 +08:00
566ebde1dd 🐛 Fix windows tray issue 2025-02-24 21:59:41 +08:00
9e039cc532 🐛 Fix editing message 2025-02-24 21:31:12 +08:00
c4b95d7084 🐛 Fix account settings screen error cause by locale 2025-02-24 21:25:12 +08:00
a66129a9ba 🐛 Bug fixes 2025-02-24 21:18:49 +08:00
44e1a8bf67 🚀 Launch 2.3.2+74 2025-02-23 22:45:01 +08:00
efcfd3f57d 🚀 Launch 2.3.2+73 2025-02-23 21:37:33 +08:00
84759715a4 💄 Not showing notification when in the channel 2025-02-23 21:19:34 +08:00
fda09382dd 💄 Hide unread count auto after entering channel 2025-02-23 21:10:32 +08:00
2c5dd0563a 🐛 Fix checking for update db issue 2025-02-23 21:10:18 +08:00
5bdd8e94fa 🐛 Bug hotfix launch 2.3.2+72 2025-02-23 18:48:26 +08:00
2a53031c9a 🚀 Relaunch 2.3.2+71 2025-02-23 15:41:18 +08:00
e8bc7261f3 Updater 2025-02-23 15:41:03 +08:00
997934f680 🚀 Launch 2.3.2+71 2025-02-23 14:51:15 +08:00
26e69d6264 Desktop local notification 2025-02-23 14:49:38 +08:00
153eabcbf2 💄 Enlarge emote when there is only one 2025-02-23 14:40:40 +08:00
6d0145c335 💄 Make attachment in chat aligned with message 2025-02-23 14:35:51 +08:00
81a79f9476 Stickers 2025-02-23 14:23:06 +08:00
537f404fe0 Delete sticker 2025-02-23 14:15:32 +08:00
eb29f76b9a Create new sticker to pack 2025-02-23 14:11:45 +08:00
56816dc060 More debug options in settings 2025-02-23 13:20:41 +08:00
899d5f3e5e Sticker page & add sticker 2025-02-23 13:14:16 +08:00
c8c455bb57 🐛 Make sure the send read event triggered before dispose chat message controller 2025-02-23 12:03:17 +08:00
5468fc0748 Two pane chat screen 2025-02-23 11:36:02 +08:00
78516abf2e Chat unread count 2025-02-23 01:49:07 +08:00
0424f98eb5 🐛 Fix title bar on macOS don't centered 2025-02-23 01:06:24 +08:00
2188b8b2e2 💄 Optimize (idk what i did) 2025-02-23 00:50:37 +08:00
0bf614a75c 🔀 Merge pull request '♻️ Use sqlite to replace hive' (#5) from refactor/sqlite into master
Reviewed-on: HyperNet/Surface#5
2025-02-22 12:49:51 +00:00
9f21f744a4 Remove Hive 2025-02-22 20:47:27 +08:00
b94cda6205 🗑️ Remove Hive related code 2025-02-22 20:46:47 +08:00
3c0e4046a4 ♻️ Refactor to replace Hive with Sqlite 2025-02-22 20:43:24 +08:00
338c22a606 Add sqlite3 dependency 2025-02-22 16:22:33 +08:00
25dd895e0d 💄 Optimize category selector 2025-02-22 14:58:20 +08:00
ea9ef9e82a ♻️ Refactored explore page 2025-02-22 14:52:58 +08:00
edd86eda77 Realm posts 2025-02-22 13:13:38 +08:00
671b857a79 💄 Optimize realm
 Post realm post
2025-02-22 12:25:56 +08:00
408fd0f35e 🐛 Bug fixes 2025-02-22 01:33:57 +08:00
30184d08b1 ♻️ Refactor the way to set thumbnail 2025-02-21 21:50:36 +08:00
95f257c47a Merge pull request #7 from I21b/master 2025-02-21 00:16:41 +08:00
92
41297c6712 📃 Add issue templates
en and zh
2025-02-21 00:49:21 +09:00
a8e0ade0c8 Realm Popularity 2025-02-20 23:44:28 +08:00
3338e699c4 💄 Memorable realm view style 2025-02-20 22:09:05 +08:00
e07da3efa5 Sliding window pricing of attachment billing info displaying 2025-02-20 21:19:23 +08:00
4f7f015250 ⬆️ I forgot what did I did last night 2025-02-20 20:41:41 +08:00
2a4c15d0dc 💄 Optimize About page 2025-02-20 20:41:25 +08:00
70ef894ec5 ♻️ Transferable chat channel 2025-02-18 23:34:59 +08:00
bb9179d5f9 🐛 Fix drawer remain when device rotate 2025-02-18 16:52:15 +08:00
217 changed files with 54678 additions and 14544 deletions

87
.github/ISSUE_TEMPLATE/bug_report.yaml vendored Normal file
View 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

View 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
View 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

View 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

View 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

View File

@ -52,13 +52,16 @@ jobs:
- run: | - run: |
sudo apt-get update -y sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev sudo apt-get install -y ninja-build libgtk-3-dev
sudo apt-get install libmpv-dev mpv sudo apt-get install -y libmpv-dev mpv
sudo apt-get install libayatana-appindicator3-dev sudo apt-get install -y libayatana-appindicator3-dev
sudo apt-get install keybinder-3.0 sudo apt-get install -y keybinder-3.0
sudo apt-get install -y libnotify-dev
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
sudo apt-get install -y gstreamer-1.0
- run: flutter pub get - run: flutter pub get
- run: flutter build linux - run: flutter build linux
- name: Archive production artifacts - name: Archive production artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: build-output-linux name: build-output-linux
path: build/linux/x64/release/bundle path: build/linux/x64/release/bundle

View File

@ -0,0 +1,11 @@
meta {
name: Trigger Fediverse Scan
type: http
seq: 1
}
post {
url: {{endpoint}}/cgi/co/admin/fediverse
body: none
auth: inherit
}

View File

@ -0,0 +1,11 @@
meta {
name: Check Status
type: http
seq: 1
}
get {
url: {{endpoint}}/directory/status
body: none
auth: none
}

View File

@ -0,0 +1,11 @@
meta {
name: List Services
type: http
seq: 2
}
get {
url: {{endpoint}}/directory/services
body: none
auth: none
}

View File

@ -12,9 +12,9 @@ post {
body:json { body:json {
{ {
"alias": "BaLoading", "alias": "Deadge",
"name": "BaLoading", "name": "Dead",
"attachment_id": "2JCI2uh21mKkfk9P", "attachment_id": "pcbFd0u4zgdM39HM",
"pack_id": 3 "pack_id": 4
} }
} }

View File

@ -0,0 +1,18 @@
meta {
name: Deal Abuse Report
type: http
seq: 3
}
put {
url: {{endpoint}}/cgi/id/reports/abuse/6/status
body: json
auth: inherit
}
body:json {
{
"status": "rejected",
"message": "Not a good reason"
}
}

View File

@ -15,12 +15,10 @@ 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": "关于迁移服务器完成的提示",
"subtitle": "一条来自 Solar Network 团队的信息", "subtitle": "一条来自 Solar Network 团队的运营信息",
"content": "今天是农历正月初一,小羊祝您新年快乐 🎉", "content": "我们已经将所有用户数据迁移到新版服务器,刚刚发布新的 DNS因为部分 DNS 缓存的影响。可能更改不会生效,可以使用 nslookup / ping 检查解析地址是否未 8. 开头,您可以主动刷新 DNS。谢谢",
"metadata": { "metadata": {},
"image": "D2EDbcrsTugs3xk5"
},
"priority": 10 "priority": 10
} }
} }

View File

@ -5,7 +5,7 @@ meta {
} }
post { post {
url: {{endpoint}}/cgi/id/dev/notify/122 url: {{endpoint}}/cgi/id/dev/notify/328
body: json body: json
auth: inherit auth: inherit
} }
@ -15,9 +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": "处理该帐号 @solian 的决定", "subject": "处理该发布者 @vedal987 的决定",
"subtitle": "违反用户协议", "subtitle": "一条来自 Solar Network 客户支持的信息",
"content": "您的帐号违反了我们用户协议中关于冒充我们官方的行为,至此做出停权的决定。还请见谅。该决定是最终决定,不接受上诉。", "content": "您的发布者违反了我们用户协议中的「禁止冒充他人」的相关条例,经管理决定,将相关内容隐藏。冒充他人的判定无论作者是否有主观意志,只要造成了误解我们就有责任处理。希望您能理解,本次决定未作出任何帐号相关的连带处罚。",
"priority": 10 "priority": 10
} }
} }

View File

@ -7,5 +7,5 @@ meta {
get { get {
url: {{endpoint}}/cgi/re/well-known/sources url: {{endpoint}}/cgi/re/well-known/sources
body: none body: none
auth: none auth: inherit
} }

View File

@ -12,7 +12,7 @@ post {
body:json { body:json {
{ {
"sources": ["taiwan-ltn"], "sources": ["taiwan-pts"],
"eager": true "eager": true
} }
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/fonts/Nunito-Bold.ttf Executable file

Binary file not shown.

BIN
assets/fonts/Nunito-Italic.ttf Executable file

Binary file not shown.

BIN
assets/fonts/Nunito-Regular.ttf Executable file

Binary file not shown.

BIN
assets/icon/kanban-1st.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

View File

@ -130,7 +130,7 @@
"accountPublishersSubtitle": "Manage your publish identities.", "accountPublishersSubtitle": "Manage your publish identities.",
"accountSettings": "Account Settings", "accountSettings": "Account Settings",
"accountSettingsSubtitle": "Manage your account and make it yours.", "accountSettingsSubtitle": "Manage your account and make it yours.",
"accountProfileEdit": "Edit your profile", "accountProfileEdit": "Edit Profile",
"accountProfileEditSubtitle": "Make your Solarpass account more looks like you.", "accountProfileEditSubtitle": "Make your Solarpass account more looks like you.",
"accountWallet": "Wallet", "accountWallet": "Wallet",
"accountWalletSubtitle": "View your balance and transactions.", "accountWalletSubtitle": "View your balance and transactions.",
@ -153,6 +153,11 @@
"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", "writePostTypeQuestion": "Ask a question",
@ -202,7 +207,13 @@
"one": "{} comment", "one": "{} comment",
"other": "{} comments" "other": "{} comments"
}, },
"postCommentExpand": "Show 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",
@ -327,6 +338,7 @@
"fieldAttachmentRandomId": "Random ID", "fieldAttachmentRandomId": "Random ID",
"fieldAttachmentAlt": "Alternative text", "fieldAttachmentAlt": "Alternative text",
"addAttachmentFromAlbum": "Add from album", "addAttachmentFromAlbum": "Add from album",
"addAttachmentFromFiles": "Add from files",
"addAttachmentFromClipboard": "Paste file", "addAttachmentFromClipboard": "Paste file",
"addAttachmentFromCameraPhoto": "Take photo", "addAttachmentFromCameraPhoto": "Take photo",
"addAttachmentFromCameraVideo": "Take video", "addAttachmentFromCameraVideo": "Take video",
@ -512,8 +524,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",
@ -548,6 +565,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",
@ -583,6 +601,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",
@ -620,11 +639,13 @@
"postQuestionUnansweredWithReward": "Unanswered Question, reward source points {}", "postQuestionUnansweredWithReward": "Unanswered Question, reward source points {}",
"postQuestionAnswered": "Answered Question", "postQuestionAnswered": "Answered Question",
"postQuestionAnswerSelect": "Select as Answer", "postQuestionAnswerSelect": "Select as Answer",
"postQuestionAnswerTitle": "Selected Question",
"postQuestionAnswerSelected": "Answer has been selected, reward has been applied.", "postQuestionAnswerSelected": "Answer has been selected, reward has been applied.",
"postVideoUpload": "Upload Video", "postVideoUpload": "Upload Video",
"realmJoin": "Join Realm", "realmJoin": "Join Realm",
"realmCommunityHint": "This realm is a community realm, you can freely join.", "realmCommunityHint": "This realm is a community realm, you can freely join.",
"realmCommunityPublicChannelsHint": "The public channels in this realm", "realmCommunityPublicChannelsHint": "The public channels in this realm",
"realmCommunityPublishersHint": "The publishers in this realm",
"realmJoined": "Joined realm {}.", "realmJoined": "Joined realm {}.",
"join": "Join", "join": "Join",
"pollEditorNew": "New Poll", "pollEditorNew": "New Poll",
@ -665,5 +686,260 @@
"zero": "No views", "zero": "No views",
"one": "{} view", "one": "{} view",
"other": "{} views" "other": "{} views"
} },
"attachmentBillingUploaded": "Used space",
"attachmentBillingDiscount": "Free space",
"attachmentBillingRatio": "Usage",
"attachmentBillingHint": "Sliding Window Pricing®\nFees will only apply if the size of the file uploaded within 24 hours exceeds the free space.",
"postThumbnail": "Post Thumbnail",
"accountRealms": "Realms",
"postInGlobal": "Global",
"postInGlobalDescription": "Do not link this post with any realm.",
"postChannelGlobal": "Global",
"postChannelFriends": "Friends",
"postChannelFollowing": "Following",
"postChannelRealm": "Realms",
"postFilterReset": "Reset Filter",
"postFilterResetDescription": "Clear filter and show all posts.",
"postFilterWithCategory": "Viewing posts in {}",
"databaseSize": "Database Size",
"databaseDelete": "Delete Database",
"databaseDeleteDescription": "Remove the database on your local disk, the content will be fetched from server again.",
"databaseDeleted": "The local database has been deleted.",
"settingsEnablePushNotifications": "Enable Push Notifications",
"settingsEnablePushNotificationsDescription": "Re-enable and request permission to receive push notifications. Just in case it didn't run automatically.",
"settingsEnabledPushNotifications": "Push notification has been enabled.",
"screenStickers": "Stickers",
"stickersDiscovery": "Discovery",
"stickersOwned": "Owned",
"stickersCreated": "Created",
"stickersAdd": "Add Sticker Pack",
"stickersAdded": "Sticker pack has been added.",
"add": "Add",
"stickersRemoved": "Sticker pack has been removed, you can add it again anytime.",
"stickersReload": "Reload Stickers",
"stickersReloadDescription": "Reload stickers from the server, update the sticker picker.",
"stickersReloaded": "Sticker packs has been reloaded.",
"stickersPackDelete": "Delete Pack {}",
"stickersPackDeleteDescription": "Are you sure you want to delete this sticker pack? This operation is irreversible.",
"stickersPackDeleted": "Sticker pack has been deleted.",
"stickersDelete": "Delete Sticker {}",
"stickersDeleteDescription": "Are you sure you want to delete this sticker? This operation is irreversible.",
"stickersDeleted": "Sticker has been deleted.",
"fieldStickerName": "Sticker Name",
"fieldStickerAlias": "Sticker Alias",
"fieldStickerAliasHint": "The unique sticker placeholder with the pack prefix.",
"fieldStickerPackName": "Name",
"fieldStickerPackDescription": "Description",
"fieldStickerPackPrefix": "Prefix",
"fieldStickerAttachment": "Attachment",
"stickersNew": "New Sticker",
"stickersNewDescription": "Create a new sticker belongs to this pack.",
"stickersPackNew": "New Sticker Pack",
"trayMenuShow": "Show",
"trayMenuMuteNotification": "Do Not Disturb",
"update": "Update",
"forceUpdate": "Force Update",
"forceUpdateDescription": "Force to show the application update popup, even the new version is not available.",
"debugLogging": "Runtime Logs",
"runtimeLogsOpen": "Open Logs",
"runtimeLogsDescription": "Show the runtime logs to help debugging.",
"signinResetPasswordHint": "Please enter the username / email address to help us to find your account and reset your password.",
"cacheSize": "Cache Size",
"cacheDelete": "Clean Cache",
"cacheDeleteDescription": "Remove the cached images and other resources from your disk, the content will be downloaded from server again.",
"cacheDeleted": "All cache has been cleaned up.",
"userNoDescription": "No description.",
"fieldTimeZone": "Time Zone",
"fieldGender": "Gender",
"fieldPronouns": "Pronouns",
"fieldLocation": "Location",
"fieldLinks": "Links",
"fieldLinkName": "Name",
"fieldLinkUrl": "URL",
"screenAccountBadges": "Badges",
"accountBadges": "Badges",
"accountBadgesDescription": "View and manage your badges.",
"badgeActivated": "Activated badge {}.",
"viewDetailedAttachment": "Details",
"screenKeyPairs": "Key Pairs",
"accountKeyPairs": "Key Pairs",
"accountKeyPairsDescription": "Manage the key pairs which used to encrypt messages.",
"enrollNewKeyPair": "Enroll New One",
"enrollNewKeyPairDescription": "Generate a new key pair.",
"keyPairHasPrivateKey": "With private key",
"decrypting": "Decrypting……",
"decryptingKeyNotFound": "Key not found or exchange failed, the other party may not be online",
"messageUnablePreview": "Unable preview",
"messageUnablePreviewEncrypted": "Unable preview encrypted message",
"postViewInGlobalDescription": "Do not view the post in the specific realm.",
"postDraftSaved": "The draft has been saved.",
"postDraftBox": "Draft Box",
"postShuffle": "Read Randomly",
"checkInStreak": {
"zero": "No streak",
"one": "{} day streak",
"other": "{} days streak"
},
"accountChangeStatus": "Change Status",
"accountStatusSilent": "Do not Disturb",
"accountStatusSilentDesc": "The notification will stop popping up",
"accountStatusInvisible": "Invisible",
"accountStatusInvisibleDesc": "Will show as offline, but all features still remain normal",
"accountCustomStatus": "Custom Status",
"accountCustomStatusDescription": "Customize your status.",
"accountClearStatus": "Clear Status",
"accountClearStatusDescription": "Clear your status, and let server decide which status you are for you.",
"fieldAccountStatusLabel": "Status Text",
"fieldAccountStatusClearAt": "Clear At",
"accountStatusNegative": "Negative",
"accountStatusNeutral": "Neutral",
"accountStatusPositive": "Positive",
"mixedFeed": "Mixed Feed",
"mixedFeedDescription": "The Explore screen may not only display the user's posts, but may also contain other content. However, this mode does not apply to classification and filtering.",
"filterFeed": "Exploring Adjust",
"feedUnknownItem": "Unable to display this content, the current version of the client does not support the type of content, please try to update the application afterwards.",
"serviceStatusOperational": "All services operational",
"serviceStatusDowngraded": "Some services downgraded",
"serviceStatusFailed": "All services unavailable",
"serviceStatusFailedDescription": "The server is down or the maintenance is just finished.",
"serviceNameInsights": "Summarize and Insights",
"serviceNameInteractive": "Posts, Reactions and Explore",
"serviceNameReader": "News and Link Previews",
"serviceNameMessaging": "Chat",
"serviceNameMatrix": "Matrix Software and Game Marketplace",
"serviceNamePaperclip": "Attachments, Images and Files",
"serviceNameWallet": "Source Points Wallet",
"serviceNamePassport": "Authorization and Authentication",
"accountActionEvent": "Action Events",
"accountActionEventDescription": "View your action event logs.",
"eventMetadata": "Metadata",
"accountAuthTickets": "Auth Sessions",
"accountAuthTicketsDescription": "View and manage your auth sessions.",
"authTicketCreatedAt": "Issued at {}",
"authTicketExpiredAt": "Expired at {}",
"authTicketLastGrantAt": "Last granted at {}",
"authTicketCurrent": "Current",
"accountUnconfirmedTitle": "Unconfirmed Account",
"accountUnconfirmedSubtitle": "Your account is unconfirmed, which will make most features unavailable and your account will be destroyed in 24 hours. You should receive an email in your inbox with a confirmation link.",
"accountUnconfirmedUnreceived": "Didn't receive the email?",
"accountUnconfirmedResend": "Resend one",
"accountUnconfirmedResendSuccessful": "Email has been resent, you can resend it again in 60 minutes.",
"stickerPickerEmpty": "Sticker list is empty",
"stickerPickerEmptyHint": "To start using stickers, you need to add a sticker pack first.",
"goto": "Go to {}",
"accountContactMethods": "Contact Methods",
"accountContactMethodsDescription": "Manage your contact methods.",
"accountContactMethodsNameEmail": "Email address",
"accountContactMethodsNamePhone": "Phone number",
"accountContactMethodsNameAddress": "Address",
"accountContactMethodsPrimary": "Primary",
"accountContactMethodsVerified": "Verified",
"accountContactMethodsPublic": "Public",
"accountContactMethodsAdd": "Add Contact Method",
"accountContactMethodsEdit": "Edit Contact Method",
"accountContactMethodsAddDescription": "Add a new contact method.",
"fieldContactContent": "Contact method",
"accountContactMethodsPublicHint": "This contact method will be displayed publicly on your profile.",
"accountContactMethodsDelete": "Delete Contact Method",
"accountContactMethodsDeleteDescription": "Are you sure you want to delete contact method {}? This operation is irreversible.",
"postCommentAdd": "Write a comment",
"translate": "Translate",
"translating": "Translating…",
"translated": "Translated",
"settingsAutoTranslate": "Auto Translate",
"settingsAutoTranslateDescription": "Automatically translate text when viewing posts and messages.",
"trayMenuHide": "Hide",
"accountSettingsNotify": "Notify Settings",
"accountSettingsNotifyDescription": "Adjust the types of notifications you receive.",
"accountSettingsSecurity": "Security Settings",
"accountSettingsSecurityDescription": "Adjust your account security settings.",
"save": "Save",
"notificationTopicPostFeedback": "Post Feedback",
"notificationTopicPostReply": "Post Replies",
"notificationTopicPostSubscription": "Post Subscriptions",
"notificationTopicMessaging": "New Messages",
"notificationTopicMessagingCall": "Incoming Calls",
"notificationTopicGeneral": "General",
"authMaximumAuthSteps": "Maximum Authenticate Steps",
"authMaximumAuthStepsDescription": {
"one": "Maximum ask for {} step authenticate",
"other": "Maximum ask for {} steps authenticate"
},
"authAlwaysRisky": "Always Risky",
"authAlwaysRiskyDescription": "Always ask for the highest steps count of authentication when logging in.",
"chatUnjoined": "Unjoined Channel",
"chatUnjoinedDescription": "You haven't joined this channel, so you can't send messages either view messages in it.",
"chatUnjoinedPublicDescription": "Fortunately, this is a public channel, so you can join it as you want.",
"chatJoin": "Join the Channel",
"appInitStarting": "Starting",
"appInitNetwork": "Initializing Network",
"appInitUserdata": "Initializing User Data",
"appInitWebsocket": "Establishing Solar Link",
"appInitNotification": "Initializing Push Notifications",
"appInitKeyPair": "Initializing Key Pairs",
"appInitStickers": "Initializing Stickers",
"appInitUserDirectory": "Initializing User Directory",
"appInitRealm": "Initializing Realms",
"appInitChat": "Initializing Chat",
"appInitDone": "Completed",
"community": "Community",
"realmCommunity": "{}'s Community",
"postTotalCount": {
"one": "Total {} post",
"other": "Total {} posts"
},
"settingsHideBottomNav": "Hide Bottom Navigation",
"settingsHideBottomNavDescription": "Hide the bottom navigation bar, and show the navigation buttons in the drawer.",
"reCaptcha": "reCaptcha",
"friends": "Friends",
"friendsDescription": "Manage your friendships.",
"album": "Album",
"albumDescription": "View albums and manage attachments.",
"stickers": "Stickers",
"stickersDescription": "View sticker packs and manage stickers.",
"navBottomUnauthorizedCaption": "Or create an account",
"walletCurrencyGoldenShort": "GDP",
"walletCurrencyGolden": {
"one": "{} Golden Point",
"other": "{} Golden Points"
},
"walletTransactionTypeNormal": "Source Point",
"walletTransactionTypeGolden": "Golden Point",
"accountProgram": "Programs",
"accountProgramDescription": "Explore the available member programs.",
"accountProgramJoin": "Join Program",
"accountProgramJoinRequirements": "Requirements",
"accountProgramJoinPricing": "Pricing",
"accountProgramJoinPricingHint": "Billed every (30 days) month.",
"accountProgramLeaveHint": "After leaving the program, the source points will not be refunded.",
"accountProgramJoined": "Joined Program.",
"accountProgramAlreadyJoined": "Joined",
"accountProgramLeft": "Left Program.",
"leave": "Leave",
"attachmentFailedToLoadMedia": "Unable to load media file, please try again later. If this error occurs repeatedly, the source file may not exist or the network connection may be abnormal.",
"accountPunishments": "Punishments",
"accountPunishmentsDescription": "View your account's reputation status.",
"punishmentType0": "Strike",
"punishmentType1": "Limited",
"punishmentType2": "Banned",
"punishmentOverall": "Overall Status",
"punishmentStatusNormal": "All abilities normal",
"punishmentStatusWarned": "All abilities normal, but at least one strike is in effect",
"punishmentStatusLimited": "Some abilities limited, at least one limited punishment is in effect",
"punishmentStatusLimitedFully": "All abilities limited, at least one completely limited punishment is in effect",
"punishmentStatusBanned": "All services are terminated, banned",
"punishmentCreatedAt": "Applied since {}",
"punishmentExpiredAt": "Expired at {}",
"punishmentExpiredNever": "Never expired",
"punishmentModerator": "Moderator who made this punishment",
"punishmentMadeBySystem": "Made by auto-mod system",
"settingsAprilFoolFeatures": "April Fool Features",
"settingsAprilFoolFeaturesDescription": "Enable April Fool features during April Fool, this option will only be visible during April Fool.",
"settingsSoundEffects": "Sound Effects",
"settingsSoundEffectsDescription": "Enable the sound effects around the app.",
"settingsResetMemorizedWindowSize": "Reset Window Size",
"settingsResetMemorizedWindowSizeDescription": "Reset the memorized window size, and set it to the default size.",
"chatDirect": "Direct Messages",
"back": "返回"
} }

View File

@ -137,6 +137,11 @@
"publisherRunBy": "由 {} 管理", "publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所属领域", "fieldPublisherBelongToRealm": "所属领域",
"fieldPublisherBelongToRealmUnset": "未设置发布者所属领域", "fieldPublisherBelongToRealmUnset": "未设置发布者所属领域",
"writePost": "撰写",
"postTypeStory": "动态",
"postTypeArticle": "文章",
"postTypeQuestion": "问题",
"postTypeVideo": "视频",
"writePostTypeStory": "发动态", "writePostTypeStory": "发动态",
"writePostTypeArticle": "写文章", "writePostTypeArticle": "写文章",
"writePostTypeQuestion": "提问题", "writePostTypeQuestion": "提问题",
@ -200,7 +205,13 @@
"one": "{} 条评论", "one": "{} 条评论",
"other": "{} 条评论" "other": "{} 条评论"
}, },
"postCommentExpand": "展开评论",
"settingsAppearance": "外观", "settingsAppearance": "外观",
"settingsCustomFonts": "自定义字体",
"settingsCustomFontsDescription": "设置应用程序使用的字体。",
"settingsCustomFontFamily": "应用字体",
"settingsCustomFontFamilyHint": "使用英文逗号分割每一种字体,越前优先级越高",
"settingsCustomFontApplied": "自定义字体已经应用。",
"settingsDisplayLanguage": "显示语言", "settingsDisplayLanguage": "显示语言",
"settingsDisplayLanguageDescription": "设置应用程序使用的语言", "settingsDisplayLanguageDescription": "设置应用程序使用的语言",
"settingsDisplayLanguageSystem": "跟随系统", "settingsDisplayLanguageSystem": "跟随系统",
@ -325,6 +336,7 @@
"fieldAttachmentRandomId": "访问 ID", "fieldAttachmentRandomId": "访问 ID",
"fieldAttachmentAlt": "概述文字", "fieldAttachmentAlt": "概述文字",
"addAttachmentFromAlbum": "从相册中添加附件", "addAttachmentFromAlbum": "从相册中添加附件",
"addAttachmentFromFiles": "从文件中添加附件",
"addAttachmentFromClipboard": "粘贴附件", "addAttachmentFromClipboard": "粘贴附件",
"addAttachmentFromCameraPhoto": "拍摄照片", "addAttachmentFromCameraPhoto": "拍摄照片",
"addAttachmentFromCameraVideo": "拍摄视频", "addAttachmentFromCameraVideo": "拍摄视频",
@ -510,8 +522,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": "离线",
@ -546,6 +563,7 @@
"termAcceptNextWithAgree": "点击 “下一步”,即表示你同意我们的各项条款,包括其之后的更新。", "termAcceptNextWithAgree": "点击 “下一步”,即表示你同意我们的各项条款,包括其之后的更新。",
"unauthorized": "未登陆", "unauthorized": "未登陆",
"unauthorizedDescription": "登陆以探索整个 Solar Network。", "unauthorizedDescription": "登陆以探索整个 Solar Network。",
"projectDetail": "项目详情",
"serviceStatus": "服务状态", "serviceStatus": "服务状态",
"termRelated": "相关条款", "termRelated": "相关条款",
"appDetails": "应用程序详情", "appDetails": "应用程序详情",
@ -581,6 +599,7 @@
"colorSchemeBlack": "黑色", "colorSchemeBlack": "黑色",
"colorSchemeApplied": "主题色已应用,可能需要重启来生效。", "colorSchemeApplied": "主题色已应用,可能需要重启来生效。",
"postFeaturedComment": "精选评论", "postFeaturedComment": "精选评论",
"postCategory": "分类",
"postCategoryTechnology": "技术", "postCategoryTechnology": "技术",
"postCategoryGaming": "游戏", "postCategoryGaming": "游戏",
"postCategoryLife": "生活", "postCategoryLife": "生活",
@ -624,6 +643,7 @@
"realmJoin": "加入领域", "realmJoin": "加入领域",
"realmCommunityHint": "该领域是一个社区领域,你可以自由加入。", "realmCommunityHint": "该领域是一个社区领域,你可以自由加入。",
"realmCommunityPublicChannelsHint": "该领域包含的公共频道", "realmCommunityPublicChannelsHint": "该领域包含的公共频道",
"realmCommunityPublishersHint": "该领域的发布者",
"realmJoined": "已加入领域 {}。", "realmJoined": "已加入领域 {}。",
"join": "加入", "join": "加入",
"pollEditorNew": "新投票", "pollEditorNew": "新投票",
@ -664,5 +684,259 @@
"zero": "{} 次浏览", "zero": "{} 次浏览",
"one": "{} 次浏览", "one": "{} 次浏览",
"other": "{} 次浏览" "other": "{} 次浏览"
} },
"attachmentBillingUploaded": "已占用的字节数",
"attachmentBillingDiscount": "免费的字节数",
"attachmentBillingHint": "滑动窗口计价®\n在24小时内上传的文件大小超出免费空间才会适用扣费。",
"postThumbnail": "帖子缩略图",
"accountRealms": "领域",
"postInGlobal": "全站",
"postInGlobalDescription": "不关联此帖子与任何领域。",
"postChannelGlobal": "全站",
"postChannelFriends": "好友",
"postChannelFollowing": "关注",
"postChannelRealm": "领域",
"postFilterReset": "重置过滤器",
"postFilterResetDescription": "清除过滤器并显示所有帖子。",
"postFilterWithCategory": "查看{}区中的帖子",
"databaseSize": "数据库大小",
"databaseDelete": "删除数据库",
"databaseDeleteDescription": "删除本地数据库,内容将从服务器重新获取。",
"databaseDeleted": "本地数据库已被删除。",
"settingsEnablePushNotifications": "启用推送数据",
"settingsEnablePushNotificationsDescription": "重新启用并请求推送权限,以防自动激活失败。",
"settingsEnabledPushNotifications": "推送通知已经注册。",
"screenStickers": "贴图",
"stickersDiscovery": "发现",
"stickersOwned": "由我拥有",
"stickersCreated": "由我发布",
"stickersAdd": "添加贴图包",
"stickersAdded": "贴图包已添加。",
"add": "添加",
"stickersRemoved": "贴图包已被移除,你可以随时再次添加回来。",
"stickersReload": "重载贴图包",
"stickersReloadDescription": "从服务器重新加载添加过的贴图,更新贴图选择器。",
"stickersReloaded": "贴图包已重载。",
"stickersPackDelete": "删除贴图包 {}",
"stickersPackDeleteDescription": "你确定要删除这个贴图包吗?这个操作不可撤销。",
"stickersPackDeleted": "贴图包已被删除。",
"stickersDelete": "删除贴图 {}",
"stickersDeleteDescription": "你确定要删除这个贴图吗?这个操作不可撤销。",
"stickersDeleted": "贴图已被删除。",
"fieldStickerName": "贴图名称",
"fieldStickerAlias": "贴图别名",
"fieldStickerAliasHint": "和贴图包前缀组合成为本贴图的唯一占位符。",
"fieldStickerPackName": "名称",
"fieldStickerPackDescription": "描述",
"fieldStickerPackPrefix": "贴图包前缀",
"fieldStickerAttachment": "附件",
"stickersNew": "新建贴图",
"stickersNewDescription": "创建一个新的贴图。",
"stickersPackNew": "新建贴图包",
"trayMenuShow": "显示",
"trayMenuMuteNotification": "静音通知",
"update": "更新",
"forceUpdate": "强制更新",
"forceUpdateDescription": "强制更新应用程序,即使有更新的版本可能不可用。",
"runtimeLogs": "运行时日志",
"runtimeLogsOpen": "打开日志文件",
"runtimeLogsDescription": "显示运行时的日志记录。",
"signinResetPasswordHint": "请输入用户名/电子邮箱地址以帮助我们找到您的帐户并重置密码。",
"cacheSize": "缓存资源大小",
"cacheDelete": "清除缓存",
"cacheDeleteDescription": "从磁盘中移除缓存的图片和其他资源,内容将从服务器重新下载。",
"cacheDeleted": "所有缓存已被清除。",
"userNoDescription": "这个人很懒,没有留下什么……",
"fieldTimeZone": "时区",
"fieldGender": "性别",
"fieldPronouns": "人称代词",
"fieldLocation": "位置",
"fieldLinks": "链接",
"fieldLinkName": "名称",
"fieldLinkUrl": "链接",
"screenAccountBadges": "徽章",
"accountBadges": "徽章",
"accountBadgesDescription": "查看并管理你的徽章。",
"badgeActivated": "已佩戴徽章 {}。",
"viewDetailedAttachment": "查看附件详情",
"screenKeyPairs": "密钥对",
"accountKeyPairs": "密钥对",
"accountKeyPairsDescription": "管理用于加密信息的密钥对。",
"enrollNewKeyPair": "新建密钥对",
"enrollNewKeyPairDescription": "生成一对新密钥对。",
"keyPairHasPrivateKey": "有私钥",
"decrypting": "解密中……",
"decryptingKeyNotFound": "未找到密钥对或交换失败,对方可能不在线",
"messageUnablePreview": "无法预览消息",
"messageUnablePreviewEncrypted": "无法预览加密消息",
"postViewInGlobalDescription": "不查看特定领域的帖子。",
"postDraftSaved": "已保存为草稿。",
"postDraftBox": "草稿箱",
"postShuffle": "随便看看",
"checkInStreak": {
"zero": "无连击",
"one": "连续签到 {} 天",
"other": "连续签到 {} 天"
},
"accountChangeStatus": "修改状态",
"accountStatusSilent": "请勿打扰",
"accountStatusSilentDesc": "将会暂停所有通知推送",
"accountStatusInvisible": "隐身",
"accountStatusInvisibleDesc": "将会在他人界面显示离线,但不影响功能使用",
"accountCustomStatus": "自定义状态",
"accountCustomStatusDescription": "客制化你的状态。",
"accountClearStatus": "清除状态",
"accountClearStatusDescription": "清除你的状态,并让服务器决定你的状态。",
"fieldAccountStatusLabel": "状态文字",
"fieldAccountStatusClearAt": "清除时间",
"accountStatusNegative": "负面",
"accountStatusNeutral": "中性",
"accountStatusPositive": "正面",
"mixedFeed": "混合推荐流",
"mixedFeedDescription": "探索页面可能不只会展示用户的帖子,更可能包含其他的内容。但该模式不适用分类和过滤。",
"filterFeed": "探索队列调整",
"feedUnknownItem": "无法显示该内容,当前版本客户端不支持该类型的内容,请尝试更新应用程序后再试。",
"serviceStatusOperational": "所有服务正常",
"serviceStatusDowngraded": "部分服务异常",
"serviceStatusFailed": "服务状态异常",
"serviceStatusFailedDescription": "服务器炸了或者刚刚执行完维护程序。",
"serviceNameInsights": "总结、见解与洞察",
"serviceNameInteractive": "帖子与互动",
"serviceNameReader": "新闻与链接展开",
"serviceNameMessaging": "即使聊天",
"serviceNameMatrix": "矩阵市场",
"serviceNamePaperclip": "附件",
"serviceNameWallet": "源点钱包",
"serviceNamePassport": "身份验证与授权",
"accountActionEvent": "操作日志",
"accountActionEventDescription": "查看你的操作日志。",
"eventMetadata": "元数据",
"accountAuthTickets": "授权会话",
"accountAuthTicketsDescription": "查看和管理你的授权会话。",
"authTicketCreatedAt": "签发于 {}",
"authTicketExpiredAt": "到期于 {}",
"authTicketLastGrantAt": "上次刷新于 {}",
"authTicketCurrent": "当前会话",
"accountUnconfirmedTitle": "尚未未确认账户",
"accountUnconfirmedSubtitle": "您的账户尚未确认,这会导致大部分功能不可用,并且您的帐户将在 24 小时后自毁。您绑定的邮箱的收件箱应该会有一封邮件指引您确认您的帐户。",
"accountUnconfirmedUnreceived": "未收到邮件?",
"accountUnconfirmedResend": "重新发送一封",
"accountUnconfirmedResendSuccessful": "邮件已重新发送,你可以在 60 分钟后再重发一封。",
"stickerPickerEmpty": "贴图列表为空",
"stickerPickerEmptyHint": "想要开始使用贴图,请先添加贴图包。",
"goto": "跳转到 {}",
"accountContactMethods": "联系方式",
"accountContactMethodsDescription": "管理你的联系方式。",
"accountContactMethodsNameEmail": "电子邮箱",
"accountContactMethodsNamePhone": "电话",
"accountContactMethodsNameAddress": "地址",
"accountContactMethodsPrimary": "主要的",
"accountContactMethodsVerified": "已验证",
"accountContactMethodsPublic": "公开的",
"accountContactMethodsAdd": "添加联系方式",
"accountContactMethodsEdit": "编辑联系方式",
"accountContactMethodsAddDescription": "添加新的联系方式。",
"fieldContactContent": "联系方式",
"accountContactMethodsPublicHint": "这个联系方式公开地显示在个人资料中。",
"accountContactMethodsDelete": "删除联系方式",
"accountContactMethodsDeleteDescription": "你确定要删除联系方式 {} 吗?这个操作不可撤销。",
"postCommentAdd": "撰写一条评论",
"translate": "翻译",
"translating": "正在翻译……",
"translated": "已翻译",
"settingsAutoTranslate": "自动翻译",
"settingsAutoTranslateDescription": "在查看帖子、消息时自动翻译文本。",
"trayMenuHide": "隐藏",
"accountSettingsNotify": "通知设置",
"accountSettingsNotifyDescription": "调整你所收到的通知种类。",
"accountSettingsSecurity": "安全设置",
"accountSettingsSecurityDescription": "调整你的帐户安全设置。",
"save": "保存",
"notificationTopicPostFeedback": "帖子数据反馈",
"notificationTopicPostReply": "帖子回复",
"notificationTopicPostSubscription": "帖子订阅",
"notificationTopicMessaging": "消息",
"notificationTopicMessagingCall": "通话",
"notificationTopicGeneral": "杂项",
"authMaximumAuthSteps": "最大验证步骤",
"authMaximumAuthStepsDescription": {
"one": "登入时最多要求 {} 步验证",
"other": "登入时最多要求 {} 步验证"
},
"authAlwaysRisky": "总是风险",
"authAlwaysRiskyDescription": "在登入时始终按最高标准要求验证。",
"chatUnjoined": "未加入频道",
"chatUnjoinedDescription": "你没有加入这个频道,所以你也无法发送消息或者查看这个频道中的消息。",
"chatUnjoinedPublicDescription": "但幸运的是,这是一个公开频道,所以你可以主动加入。",
"chatJoin": "加入频道",
"appInitStarting": "启动中",
"appInitNetwork": "正在初始化网络",
"appInitUserdata": "正在初始化用户数据",
"appInitWebsocket": "正在建立 Solar Link",
"appInitNotification": "正在初始化推送通知",
"appInitKeyPair": "正在初始化密钥对",
"appInitStickers": "正在初始化贴图包",
"appInitUserDirectory": "正在初始化用户目录",
"appInitRealm": "正在初始化领域信息",
"appInitChat": "正在初始化聊天",
"appInitDone": "完成",
"community": "社区",
"realmCommunity": "{}的社区",
"postTotalCount": {
"zero": "没有帖子",
"one": "共 {} 条帖子"
},
"settingsHideBottomNav": "隐藏底部导航栏",
"settingsHideBottomNavDescription": "隐藏底部导航栏,在侧边栏抽屉显示导航按钮。",
"reCaptcha": "人机验证",
"friends": "好友",
"friendsDescription": "管理好友关系。",
"album": "相册",
"albumDescription": "查看相册与管理上传附件。",
"stickers": "贴图",
"stickersDescription": "查看贴图包与管理贴图。",
"navBottomUnauthorizedCaption": "或者注册一个账号",
"walletCurrencyGoldenShort": "金点",
"walletCurrencyGolden": {
"one": "{} 金点",
"other": "{} 金点"
},
"walletTransactionTypeNormal": "源点",
"walletTransactionTypeGolden": "金点",
"accountProgram": "计划",
"accountProgramDescription": "了解可用的成员计划。",
"accountProgramJoin": "加入计划",
"accountProgramJoinRequirements": "要求",
"accountProgramJoinPricing": "价格",
"accountProgramJoinPricingHint": "按月30 天)收费",
"accountProgramLeaveHint": "离开计划后,之前花费的源点不会退款。",
"accountProgramJoined": "已加入计划。",
"accountProgramLeft": "已离开计划。",
"accountProgramAlreadyJoined": "已加入",
"leave": "离开",
"attachmentFailedToLoadMedia": "无法加载媒体文件,请稍后重试。若此错误重复出现,可能源文件不存在或者网络连接异常。",
"accountPunishments": "处分",
"accountPunishmentsDescription": "查看你帐号的信誉状态。",
"punishmentType0": "警告",
"punishmentType1": "停权",
"punishmentType2": "封禁",
"punishmentOverall": "总体状态",
"punishmentStatusNormal": "所有功能正常",
"punishmentStatusWarned": "所有功能正常,但有警告生效",
"punishmentStatusLimited": "部份功能暂时受限,有至少一个停权生效",
"punishmentStatusLimitedFully": "所有功能暂时受限,有至少一个完全停权生效",
"punishmentStatusBanned": "所有服务终止,已被封禁",
"punishmentCreatedAt": "宣布于 {}",
"punishmentExpiredAt": "到期于 {}",
"punishmentExpiredNever": "永久生效",
"punishmentModerator": "责任管理员",
"punishmentMadeBySystem": "由系统自动裁决",
"settingsAprilFoolFeatures": "愚人节特性",
"settingsAprilFoolFeaturesDescription": "在愚人节期间启用愚人节特性,该选项只会在愚人节期间显示。",
"settingsSoundEffects": "声音效果",
"settingsSoundEffectsDescription": "在一些场合下启用声音特效。",
"settingsResetMemorizedWindowSize": "重置窗口大小",
"settingsResetMemorizedWindowSizeDescription": "重置记忆的窗口大小,以重新设置为默认大小。",
"chatDirect": "私信",
"back": "返回"
} }

View File

@ -137,6 +137,11 @@
"publisherRunBy": "由 {} 管理", "publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所屬領域", "fieldPublisherBelongToRealm": "所屬領域",
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域", "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
"writePost": "撰寫",
"postTypeStory": "動態",
"postTypeArticle": "文章",
"postTypeQuestion": "問題",
"postTypeVideo": "視頻",
"writePostTypeStory": "發動態", "writePostTypeStory": "發動態",
"writePostTypeArticle": "寫文章", "writePostTypeArticle": "寫文章",
"writePostTypeQuestion": "提問題", "writePostTypeQuestion": "提問題",
@ -200,7 +205,13 @@
"one": "{} 條評論", "one": "{} 條評論",
"other": "{} 條評論" "other": "{} 條評論"
}, },
"postCommentExpand": "展開評論",
"settingsAppearance": "外觀", "settingsAppearance": "外觀",
"settingsCustomFonts": "自定義字體",
"settingsCustomFontsDescription": "設置應用程序使用的字體。",
"settingsCustomFontFamily": "應用字體",
"settingsCustomFontFamilyHint": "使用英文逗號分割每一種字體,越前優先級越高",
"settingsCustomFontApplied": "自定義字體已經應用。",
"settingsDisplayLanguage": "顯示語言", "settingsDisplayLanguage": "顯示語言",
"settingsDisplayLanguageDescription": "設置應用程序使用的語言", "settingsDisplayLanguageDescription": "設置應用程序使用的語言",
"settingsDisplayLanguageSystem": "跟隨系統", "settingsDisplayLanguageSystem": "跟隨系統",
@ -325,6 +336,7 @@
"fieldAttachmentRandomId": "訪問 ID", "fieldAttachmentRandomId": "訪問 ID",
"fieldAttachmentAlt": "概述文字", "fieldAttachmentAlt": "概述文字",
"addAttachmentFromAlbum": "從相冊中添加附件", "addAttachmentFromAlbum": "從相冊中添加附件",
"addAttachmentFromFiles": "從文件中添加附件",
"addAttachmentFromClipboard": "粘貼附件", "addAttachmentFromClipboard": "粘貼附件",
"addAttachmentFromCameraPhoto": "拍攝照片", "addAttachmentFromCameraPhoto": "拍攝照片",
"addAttachmentFromCameraVideo": "拍攝視頻", "addAttachmentFromCameraVideo": "拍攝視頻",
@ -510,8 +522,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": "離線",
@ -546,6 +563,7 @@
"termAcceptNextWithAgree": "點擊 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。", "termAcceptNextWithAgree": "點擊 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。",
"unauthorized": "未登陸", "unauthorized": "未登陸",
"unauthorizedDescription": "登陸以探索整個 Solar Network。", "unauthorizedDescription": "登陸以探索整個 Solar Network。",
"projectDetail": "項目詳情",
"serviceStatus": "服務狀態", "serviceStatus": "服務狀態",
"termRelated": "相關條款", "termRelated": "相關條款",
"appDetails": "應用程序詳情", "appDetails": "應用程序詳情",
@ -581,6 +599,7 @@
"colorSchemeBlack": "黑色", "colorSchemeBlack": "黑色",
"colorSchemeApplied": "主題色已應用,可能需要重啓來生效。", "colorSchemeApplied": "主題色已應用,可能需要重啓來生效。",
"postFeaturedComment": "精選評論", "postFeaturedComment": "精選評論",
"postCategory": "分類",
"postCategoryTechnology": "技術", "postCategoryTechnology": "技術",
"postCategoryGaming": "遊戲", "postCategoryGaming": "遊戲",
"postCategoryLife": "生活", "postCategoryLife": "生活",
@ -624,6 +643,7 @@
"realmJoin": "加入領域", "realmJoin": "加入領域",
"realmCommunityHint": "該領域是一個社區領域,你可以自由加入。", "realmCommunityHint": "該領域是一個社區領域,你可以自由加入。",
"realmCommunityPublicChannelsHint": "該領域包含的公共頻道", "realmCommunityPublicChannelsHint": "該領域包含的公共頻道",
"realmCommunityPublishersHint": "該領域的發佈者",
"realmJoined": "已加入領域 {}。", "realmJoined": "已加入領域 {}。",
"join": "加入", "join": "加入",
"pollEditorNew": "新投票", "pollEditorNew": "新投票",
@ -664,5 +684,216 @@
"zero": "{} 次瀏覽", "zero": "{} 次瀏覽",
"one": "{} 次瀏覽", "one": "{} 次瀏覽",
"other": "{} 次瀏覽" "other": "{} 次瀏覽"
} },
"attachmentBillingUploaded": "已佔用的字節數",
"attachmentBillingDiscount": "免費的字節數",
"attachmentBillingHint": "滑動窗口計價®\n在24小時內上傳的文件大小超出免費空間才會適用扣費。",
"postThumbnail": "帖子縮略圖",
"accountRealms": "領域",
"postInGlobal": "全站",
"postInGlobalDescription": "不關聯此帖子與任何領域。",
"postChannelGlobal": "全站",
"postChannelFriends": "好友",
"postChannelFollowing": "關注",
"postChannelRealm": "領域",
"postFilterReset": "重置過濾器",
"postFilterResetDescription": "清除過濾器並顯示所有帖子。",
"postFilterWithCategory": "查看{}區中的帖子",
"databaseSize": "數據庫大小",
"databaseDelete": "刪除數據庫",
"databaseDeleteDescription": "刪除本地數據庫,內容將從服務器重新獲取。",
"databaseDeleted": "本地數據庫已被刪除。",
"settingsEnablePushNotifications": "啓用推送數據",
"settingsEnablePushNotificationsDescription": "重新啓用並請求推送權限,以防自動激活失敗。",
"settingsEnabledPushNotifications": "推送通知已經註冊。",
"screenStickers": "貼圖",
"stickersDiscovery": "發現",
"stickersOwned": "由我擁有",
"stickersCreated": "由我發佈",
"stickersAdd": "添加貼圖包",
"stickersAdded": "貼圖包已添加。",
"add": "添加",
"stickersRemoved": "貼圖包已被移除,你可以隨時再次添加回來。",
"stickersReload": "重載貼圖包",
"stickersReloadDescription": "從服務器重新加載添加過的貼圖,更新貼圖選擇器。",
"stickersReloaded": "貼圖包已重載。",
"stickersPackDelete": "刪除貼圖包 {}",
"stickersPackDeleteDescription": "你確定要刪除這個貼圖包嗎?這個操作不可撤銷。",
"stickersPackDeleted": "貼圖包已被刪除。",
"stickersDelete": "刪除貼圖 {}",
"stickersDeleteDescription": "你確定要刪除這個貼圖嗎?這個操作不可撤銷。",
"stickersDeleted": "貼圖已被刪除。",
"fieldStickerName": "貼圖名稱",
"fieldStickerAlias": "貼圖別名",
"fieldStickerAliasHint": "和貼圖包前綴組合成為本貼圖的唯一佔位符。",
"fieldStickerPackName": "名稱",
"fieldStickerPackDescription": "描述",
"fieldStickerPackPrefix": "貼圖包前綴",
"fieldStickerAttachment": "附件",
"stickersNew": "新建貼圖",
"stickersNewDescription": "創建一個新的貼圖。",
"stickersPackNew": "新建貼圖包",
"trayMenuShow": "顯示",
"trayMenuMuteNotification": "靜音通知",
"update": "更新",
"forceUpdate": "強制更新",
"forceUpdateDescription": "強制更新應用程序,即使有更新的版本可能不可用。",
"runtimeLogs": "運行時日誌",
"runtimeLogsOpen": "打開日誌文件",
"runtimeLogsDescription": "顯示運行時的日誌記錄。",
"signinResetPasswordHint": "請輸入用户名/電子郵箱地址以幫助我們找到您的帳户並重置密碼。",
"cacheSize": "緩存資源大小",
"cacheDelete": "清除緩存",
"cacheDeleteDescription": "從磁盤中移除緩存的圖片和其他資源,內容將從服務器重新下載。",
"cacheDeleted": "所有緩存已被清除。",
"userNoDescription": "這個人很懶,沒有留下什麼……",
"fieldTimeZone": "時區",
"fieldGender": "性別",
"fieldPronouns": "人稱代詞",
"fieldLocation": "位置",
"fieldLinks": "鏈接",
"fieldLinkName": "名稱",
"fieldLinkUrl": "鏈接",
"screenAccountBadges": "徽章",
"accountBadges": "徽章",
"accountBadgesDescription": "查看並管理你的徽章。",
"badgeActivated": "已佩戴徽章 {}。",
"viewDetailedAttachment": "查看附件詳情",
"screenKeyPairs": "密鑰對",
"accountKeyPairs": "密鑰對",
"accountKeyPairsDescription": "管理用於加密信息的密鑰對。",
"enrollNewKeyPair": "新建密鑰對",
"enrollNewKeyPairDescription": "生成一對新密鑰對。",
"keyPairHasPrivateKey": "有私鑰",
"decrypting": "解密中……",
"decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線",
"messageUnablePreview": "無法預覽消息",
"messageUnablePreviewEncrypted": "無法預覽加密消息",
"postViewInGlobalDescription": "不查看特定領域的帖子。",
"postDraftSaved": "已保存為草稿。",
"postDraftBox": "草稿箱",
"postShuffle": "隨便看看",
"checkInStreak": {
"zero": "無連擊",
"one": "連續簽到 {} 天",
"other": "連續簽到 {} 天"
},
"accountChangeStatus": "修改狀態",
"accountStatusSilent": "請勿打擾",
"accountStatusSilentDesc": "將會暫停所有通知推送",
"accountStatusInvisible": "隱身",
"accountStatusInvisibleDesc": "將會在他人界面顯示離線,但不影響功能使用",
"accountCustomStatus": "自定義狀態",
"accountCustomStatusDescription": "客製化你的狀態。",
"accountClearStatus": "清除狀態",
"accountClearStatusDescription": "清除你的狀態,並讓服務器決定你的狀態。",
"fieldAccountStatusLabel": "狀態文字",
"fieldAccountStatusClearAt": "清除時間",
"accountStatusNegative": "負面",
"accountStatusNeutral": "中性",
"accountStatusPositive": "正面",
"mixedFeed": "混合推薦流",
"mixedFeedDescription": "探索頁面可能不只會展示用户的帖子,更可能包含其他的內容。但該模式不適用分類和過濾。",
"filterFeed": "探索隊列調整",
"feedUnknownItem": "無法顯示該內容,當前版本客户端不支持該類型的內容,請嘗試更新應用程序後再試。",
"serviceStatusOperational": "所有服務正常",
"serviceStatusDowngraded": "部分服務異常",
"serviceStatusFailed": "服務狀態異常",
"serviceStatusFailedDescription": "服務器炸了或者剛剛執行完維護程序。",
"serviceNameInsights": "總結、見解與洞察",
"serviceNameInteractive": "帖子與互動",
"serviceNameReader": "新聞與鏈接展開",
"serviceNameMessaging": "即使聊天",
"serviceNameMatrix": "矩陣市場",
"serviceNamePaperclip": "附件",
"serviceNameWallet": "源點錢包",
"serviceNamePassport": "身份驗證與授權",
"accountActionEvent": "操作日誌",
"accountActionEventDescription": "查看你的操作日誌。",
"eventMetadata": "元數據",
"accountAuthTickets": "授權會話",
"accountAuthTicketsDescription": "查看和管理你的授權會話。",
"authTicketCreatedAt": "簽發於 {}",
"authTicketExpiredAt": "到期於 {}",
"authTicketLastGrantAt": "上次刷新於 {}",
"authTicketCurrent": "當前會話",
"accountUnconfirmedTitle": "尚未未確認賬户",
"accountUnconfirmedSubtitle": "您的賬户尚未確認,這會導致大部分功能不可用,並且您的帳户將在 24 小時後自毀。您綁定的郵箱的收件箱應該會有一封郵件指引您確認您的帳户。",
"accountUnconfirmedUnreceived": "未收到郵件?",
"accountUnconfirmedResend": "重新發送一封",
"accountUnconfirmedResendSuccessful": "郵件已重新發送,你可以在 60 分鐘後再重發一封。",
"stickerPickerEmpty": "貼圖列表為空",
"stickerPickerEmptyHint": "想要開始使用貼圖,請先添加貼圖包。",
"goto": "跳轉到 {}",
"accountContactMethods": "聯繫方式",
"accountContactMethodsDescription": "管理你的聯繫方式。",
"accountContactMethodsNameEmail": "電子郵箱",
"accountContactMethodsNamePhone": "電話",
"accountContactMethodsNameAddress": "地址",
"accountContactMethodsPrimary": "主要的",
"accountContactMethodsVerified": "已驗證",
"accountContactMethodsPublic": "公開的",
"accountContactMethodsAdd": "添加聯繫方式",
"accountContactMethodsEdit": "編輯聯繫方式",
"accountContactMethodsAddDescription": "添加新的聯繫方式。",
"fieldContactContent": "聯繫方式",
"accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。",
"accountContactMethodsDelete": "刪除聯繫方式",
"accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。",
"postCommentAdd": "撰寫一條評論",
"translate": "翻譯",
"translating": "正在翻譯……",
"translated": "已翻譯",
"settingsAutoTranslate": "自動翻譯",
"settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。",
"trayMenuHide": "隱藏",
"accountSettingsNotify": "通知設置",
"accountSettingsNotifyDescription": "調整你所收到的通知種類。",
"accountSettingsSecurity": "安全設置",
"accountSettingsSecurityDescription": "調整你的帳户安全設置。",
"save": "保存",
"notificationTopicPostFeedback": "帖子數據反饋",
"notificationTopicPostReply": "帖子回覆",
"notificationTopicPostSubscription": "帖子訂閲",
"notificationTopicMessaging": "消息",
"notificationTopicMessagingCall": "通話",
"notificationTopicGeneral": "雜項",
"authMaximumAuthSteps": "最大驗證步驟",
"authMaximumAuthStepsDescription": {
"one": "登入時最多要求 {} 步驗證",
"other": "登入時最多要求 {} 步驗證"
},
"authAlwaysRisky": "總是風險",
"authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。",
"chatUnjoined": "未加入頻道",
"chatUnjoinedDescription": "你沒有加入這個頻道,所以你也無法發送消息或者查看這個頻道中的消息。",
"chatUnjoinedPublicDescription": "但幸運的是,這是一個公開頻道,所以你可以主動加入。",
"chatJoin": "加入頻道",
"appInitStarting": "啓動中",
"appInitNetwork": "正在初始化網絡",
"appInitUserdata": "正在初始化用户數據",
"appInitWebsocket": "正在建立 Solar Link",
"appInitNotification": "正在初始化推送通知",
"appInitKeyPair": "正在初始化密鑰對",
"appInitStickers": "正在初始化貼圖包",
"appInitUserDirectory": "正在初始化用户目錄",
"appInitRealm": "正在初始化領域信息",
"appInitChat": "正在初始化聊天",
"appInitDone": "完成",
"community": "社區",
"realmCommunity": "{}的社區",
"postTotalCount": {
"zero": "沒有帖子",
"one": "共 {} 條帖子"
},
"settingsHideBottomNav": "隱藏底部導航欄",
"settingsHideBottomNavDescription": "隱藏底部導航欄,在側邊欄抽屜顯示導航按鈕。",
"reCaptcha": "人機驗證",
"friends": "好友",
"friendsDescription": "管理好友關係。",
"album": "相冊",
"albumDescription": "查看相冊與管理上傳附件。",
"stickers": "貼圖",
"stickersDescription": "查看貼圖包與管理貼圖。",
"navBottomUnauthorizedCaption": "或者註冊一個賬號"
} }

View File

@ -137,6 +137,11 @@
"publisherRunBy": "由 {} 管理", "publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所屬領域", "fieldPublisherBelongToRealm": "所屬領域",
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域", "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
"writePost": "撰寫",
"postTypeStory": "動態",
"postTypeArticle": "文章",
"postTypeQuestion": "問題",
"postTypeVideo": "視頻",
"writePostTypeStory": "發動態", "writePostTypeStory": "發動態",
"writePostTypeArticle": "寫文章", "writePostTypeArticle": "寫文章",
"writePostTypeQuestion": "提問題", "writePostTypeQuestion": "提問題",
@ -200,7 +205,13 @@
"one": "{} 條評論", "one": "{} 條評論",
"other": "{} 條評論" "other": "{} 條評論"
}, },
"postCommentExpand": "展開評論",
"settingsAppearance": "外觀", "settingsAppearance": "外觀",
"settingsCustomFonts": "自定義字體",
"settingsCustomFontsDescription": "設置應用程序使用的字體。",
"settingsCustomFontFamily": "應用字體",
"settingsCustomFontFamilyHint": "使用英文逗號分割每一種字體,越前優先級越高",
"settingsCustomFontApplied": "自定義字體已經應用。",
"settingsDisplayLanguage": "顯示語言", "settingsDisplayLanguage": "顯示語言",
"settingsDisplayLanguageDescription": "設置應用程序使用的語言", "settingsDisplayLanguageDescription": "設置應用程序使用的語言",
"settingsDisplayLanguageSystem": "跟隨系統", "settingsDisplayLanguageSystem": "跟隨系統",
@ -325,6 +336,7 @@
"fieldAttachmentRandomId": "訪問 ID", "fieldAttachmentRandomId": "訪問 ID",
"fieldAttachmentAlt": "概述文字", "fieldAttachmentAlt": "概述文字",
"addAttachmentFromAlbum": "從相冊中添加附件", "addAttachmentFromAlbum": "從相冊中添加附件",
"addAttachmentFromFiles": "從文件中添加附件",
"addAttachmentFromClipboard": "粘貼附件", "addAttachmentFromClipboard": "粘貼附件",
"addAttachmentFromCameraPhoto": "拍攝照片", "addAttachmentFromCameraPhoto": "拍攝照片",
"addAttachmentFromCameraVideo": "拍攝視頻", "addAttachmentFromCameraVideo": "拍攝視頻",
@ -510,8 +522,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": "離線",
@ -546,6 +563,7 @@
"termAcceptNextWithAgree": "點擊 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。", "termAcceptNextWithAgree": "點擊 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。",
"unauthorized": "未登陸", "unauthorized": "未登陸",
"unauthorizedDescription": "登陸以探索整個 Solar Network。", "unauthorizedDescription": "登陸以探索整個 Solar Network。",
"projectDetail": "項目詳情",
"serviceStatus": "服務狀態", "serviceStatus": "服務狀態",
"termRelated": "相關條款", "termRelated": "相關條款",
"appDetails": "應用程序詳情", "appDetails": "應用程序詳情",
@ -581,6 +599,7 @@
"colorSchemeBlack": "黑色", "colorSchemeBlack": "黑色",
"colorSchemeApplied": "主題色已應用,可能需要重啟來生效。", "colorSchemeApplied": "主題色已應用,可能需要重啟來生效。",
"postFeaturedComment": "精選評論", "postFeaturedComment": "精選評論",
"postCategory": "分類",
"postCategoryTechnology": "技術", "postCategoryTechnology": "技術",
"postCategoryGaming": "遊戲", "postCategoryGaming": "遊戲",
"postCategoryLife": "生活", "postCategoryLife": "生活",
@ -624,6 +643,7 @@
"realmJoin": "加入領域", "realmJoin": "加入領域",
"realmCommunityHint": "該領域是一個社區領域,你可以自由加入。", "realmCommunityHint": "該領域是一個社區領域,你可以自由加入。",
"realmCommunityPublicChannelsHint": "該領域包含的公共頻道", "realmCommunityPublicChannelsHint": "該領域包含的公共頻道",
"realmCommunityPublishersHint": "該領域的發佈者",
"realmJoined": "已加入領域 {}。", "realmJoined": "已加入領域 {}。",
"join": "加入", "join": "加入",
"pollEditorNew": "新投票", "pollEditorNew": "新投票",
@ -664,5 +684,216 @@
"zero": "{} 次瀏覽", "zero": "{} 次瀏覽",
"one": "{} 次瀏覽", "one": "{} 次瀏覽",
"other": "{} 次瀏覽" "other": "{} 次瀏覽"
} },
"attachmentBillingUploaded": "已佔用的字節數",
"attachmentBillingDiscount": "免費的字節數",
"attachmentBillingHint": "滑動窗口計價®\n在24小時內上傳的文件大小超出免費空間才會適用扣費。",
"postThumbnail": "帖子縮略圖",
"accountRealms": "領域",
"postInGlobal": "全站",
"postInGlobalDescription": "不關聯此帖子與任何領域。",
"postChannelGlobal": "全站",
"postChannelFriends": "好友",
"postChannelFollowing": "關注",
"postChannelRealm": "領域",
"postFilterReset": "重置過濾器",
"postFilterResetDescription": "清除過濾器並顯示所有帖子。",
"postFilterWithCategory": "查看{}區中的帖子",
"databaseSize": "數據庫大小",
"databaseDelete": "刪除數據庫",
"databaseDeleteDescription": "刪除本地數據庫,內容將從服務器重新獲取。",
"databaseDeleted": "本地數據庫已被刪除。",
"settingsEnablePushNotifications": "啟用推送數據",
"settingsEnablePushNotificationsDescription": "重新啟用並請求推送權限,以防自動激活失敗。",
"settingsEnabledPushNotifications": "推送通知已經註冊。",
"screenStickers": "貼圖",
"stickersDiscovery": "發現",
"stickersOwned": "由我擁有",
"stickersCreated": "由我發佈",
"stickersAdd": "添加貼圖包",
"stickersAdded": "貼圖包已添加。",
"add": "添加",
"stickersRemoved": "貼圖包已被移除,你可以隨時再次添加回來。",
"stickersReload": "重載貼圖包",
"stickersReloadDescription": "從服務器重新加載添加過的貼圖,更新貼圖選擇器。",
"stickersReloaded": "貼圖包已重載。",
"stickersPackDelete": "刪除貼圖包 {}",
"stickersPackDeleteDescription": "你確定要刪除這個貼圖包嗎?這個操作不可撤銷。",
"stickersPackDeleted": "貼圖包已被刪除。",
"stickersDelete": "刪除貼圖 {}",
"stickersDeleteDescription": "你確定要刪除這個貼圖嗎?這個操作不可撤銷。",
"stickersDeleted": "貼圖已被刪除。",
"fieldStickerName": "貼圖名稱",
"fieldStickerAlias": "貼圖別名",
"fieldStickerAliasHint": "和貼圖包前綴組合成為本貼圖的唯一佔位符。",
"fieldStickerPackName": "名稱",
"fieldStickerPackDescription": "描述",
"fieldStickerPackPrefix": "貼圖包前綴",
"fieldStickerAttachment": "附件",
"stickersNew": "新建貼圖",
"stickersNewDescription": "創建一個新的貼圖。",
"stickersPackNew": "新建貼圖包",
"trayMenuShow": "顯示",
"trayMenuMuteNotification": "靜音通知",
"update": "更新",
"forceUpdate": "強制更新",
"forceUpdateDescription": "強制更新應用程序,即使有更新的版本可能不可用。",
"runtimeLogs": "運行時日誌",
"runtimeLogsOpen": "打開日誌文件",
"runtimeLogsDescription": "顯示運行時的日誌記錄。",
"signinResetPasswordHint": "請輸入用戶名/電子郵箱地址以幫助我們找到您的帳戶並重置密碼。",
"cacheSize": "緩存資源大小",
"cacheDelete": "清除緩存",
"cacheDeleteDescription": "從磁盤中移除緩存的圖片和其他資源,內容將從服務器重新下載。",
"cacheDeleted": "所有緩存已被清除。",
"userNoDescription": "這個人很懶,沒有留下什麼……",
"fieldTimeZone": "時區",
"fieldGender": "性別",
"fieldPronouns": "人稱代詞",
"fieldLocation": "位置",
"fieldLinks": "鏈接",
"fieldLinkName": "名稱",
"fieldLinkUrl": "鏈接",
"screenAccountBadges": "徽章",
"accountBadges": "徽章",
"accountBadgesDescription": "查看並管理你的徽章。",
"badgeActivated": "已佩戴徽章 {}。",
"viewDetailedAttachment": "查看附件詳情",
"screenKeyPairs": "密鑰對",
"accountKeyPairs": "密鑰對",
"accountKeyPairsDescription": "管理用於加密信息的密鑰對。",
"enrollNewKeyPair": "新建密鑰對",
"enrollNewKeyPairDescription": "生成一對新密鑰對。",
"keyPairHasPrivateKey": "有私鑰",
"decrypting": "解密中……",
"decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線",
"messageUnablePreview": "無法預覽消息",
"messageUnablePreviewEncrypted": "無法預覽加密消息",
"postViewInGlobalDescription": "不查看特定領域的帖子。",
"postDraftSaved": "已保存為草稿。",
"postDraftBox": "草稿箱",
"postShuffle": "隨便看看",
"checkInStreak": {
"zero": "無連擊",
"one": "連續簽到 {} 天",
"other": "連續簽到 {} 天"
},
"accountChangeStatus": "修改狀態",
"accountStatusSilent": "請勿打擾",
"accountStatusSilentDesc": "將會暫停所有通知推送",
"accountStatusInvisible": "隱身",
"accountStatusInvisibleDesc": "將會在他人界面顯示離線,但不影響功能使用",
"accountCustomStatus": "自定義狀態",
"accountCustomStatusDescription": "客製化你的狀態。",
"accountClearStatus": "清除狀態",
"accountClearStatusDescription": "清除你的狀態,並讓服務器決定你的狀態。",
"fieldAccountStatusLabel": "狀態文字",
"fieldAccountStatusClearAt": "清除時間",
"accountStatusNegative": "負面",
"accountStatusNeutral": "中性",
"accountStatusPositive": "正面",
"mixedFeed": "混合推薦流",
"mixedFeedDescription": "探索頁面可能不只會展示用戶的帖子,更可能包含其他的內容。但該模式不適用分類和過濾。",
"filterFeed": "探索隊列調整",
"feedUnknownItem": "無法顯示該內容,當前版本客戶端不支持該類型的內容,請嘗試更新應用程序後再試。",
"serviceStatusOperational": "所有服務正常",
"serviceStatusDowngraded": "部分服務異常",
"serviceStatusFailed": "服務狀態異常",
"serviceStatusFailedDescription": "服務器炸了或者剛剛執行完維護程序。",
"serviceNameInsights": "總結、見解與洞察",
"serviceNameInteractive": "帖子與互動",
"serviceNameReader": "新聞與鏈接展開",
"serviceNameMessaging": "即使聊天",
"serviceNameMatrix": "矩陣市場",
"serviceNamePaperclip": "附件",
"serviceNameWallet": "源點錢包",
"serviceNamePassport": "身份驗證與授權",
"accountActionEvent": "操作日誌",
"accountActionEventDescription": "查看你的操作日誌。",
"eventMetadata": "元數據",
"accountAuthTickets": "授權會話",
"accountAuthTicketsDescription": "查看和管理你的授權會話。",
"authTicketCreatedAt": "簽發於 {}",
"authTicketExpiredAt": "到期於 {}",
"authTicketLastGrantAt": "上次刷新於 {}",
"authTicketCurrent": "當前會話",
"accountUnconfirmedTitle": "尚未未確認賬戶",
"accountUnconfirmedSubtitle": "您的賬戶尚未確認,這會導致大部分功能不可用,並且您的帳戶將在 24 小時後自毀。您綁定的郵箱的收件箱應該會有一封郵件指引您確認您的帳戶。",
"accountUnconfirmedUnreceived": "未收到郵件?",
"accountUnconfirmedResend": "重新發送一封",
"accountUnconfirmedResendSuccessful": "郵件已重新發送,你可以在 60 分鐘後再重發一封。",
"stickerPickerEmpty": "貼圖列表為空",
"stickerPickerEmptyHint": "想要開始使用貼圖,請先添加貼圖包。",
"goto": "跳轉到 {}",
"accountContactMethods": "聯繫方式",
"accountContactMethodsDescription": "管理你的聯繫方式。",
"accountContactMethodsNameEmail": "電子郵箱",
"accountContactMethodsNamePhone": "電話",
"accountContactMethodsNameAddress": "地址",
"accountContactMethodsPrimary": "主要的",
"accountContactMethodsVerified": "已驗證",
"accountContactMethodsPublic": "公開的",
"accountContactMethodsAdd": "添加聯繫方式",
"accountContactMethodsEdit": "編輯聯繫方式",
"accountContactMethodsAddDescription": "添加新的聯繫方式。",
"fieldContactContent": "聯繫方式",
"accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。",
"accountContactMethodsDelete": "刪除聯繫方式",
"accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。",
"postCommentAdd": "撰寫一條評論",
"translate": "翻譯",
"translating": "正在翻譯……",
"translated": "已翻譯",
"settingsAutoTranslate": "自動翻譯",
"settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。",
"trayMenuHide": "隱藏",
"accountSettingsNotify": "通知設置",
"accountSettingsNotifyDescription": "調整你所收到的通知種類。",
"accountSettingsSecurity": "安全設置",
"accountSettingsSecurityDescription": "調整你的帳戶安全設置。",
"save": "保存",
"notificationTopicPostFeedback": "帖子數據反饋",
"notificationTopicPostReply": "帖子回覆",
"notificationTopicPostSubscription": "帖子訂閱",
"notificationTopicMessaging": "消息",
"notificationTopicMessagingCall": "通話",
"notificationTopicGeneral": "雜項",
"authMaximumAuthSteps": "最大驗證步驟",
"authMaximumAuthStepsDescription": {
"one": "登入時最多要求 {} 步驗證",
"other": "登入時最多要求 {} 步驗證"
},
"authAlwaysRisky": "總是風險",
"authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。",
"chatUnjoined": "未加入頻道",
"chatUnjoinedDescription": "你沒有加入這個頻道,所以你也無法發送消息或者查看這個頻道中的消息。",
"chatUnjoinedPublicDescription": "但幸運的是,這是一個公開頻道,所以你可以主動加入。",
"chatJoin": "加入頻道",
"appInitStarting": "啟動中",
"appInitNetwork": "正在初始化網絡",
"appInitUserdata": "正在初始化用戶數據",
"appInitWebsocket": "正在建立 Solar Link",
"appInitNotification": "正在初始化推送通知",
"appInitKeyPair": "正在初始化密鑰對",
"appInitStickers": "正在初始化貼圖包",
"appInitUserDirectory": "正在初始化用戶目錄",
"appInitRealm": "正在初始化領域信息",
"appInitChat": "正在初始化聊天",
"appInitDone": "完成",
"community": "社區",
"realmCommunity": "{}的社區",
"postTotalCount": {
"zero": "沒有帖子",
"one": "共 {} 條帖子"
},
"settingsHideBottomNav": "隱藏底部導航欄",
"settingsHideBottomNavDescription": "隱藏底部導航欄,在側邊欄抽屜顯示導航按鈕。",
"reCaptcha": "人機驗證",
"friends": "好友",
"friendsDescription": "管理好友關係。",
"album": "相冊",
"albumDescription": "查看相冊與管理上傳附件。",
"stickers": "貼圖",
"stickersDescription": "查看貼圖包與管理貼圖。",
"navBottomUnauthorizedCaption": "或者註冊一個賬號"
} }

View File

@ -4,4 +4,8 @@ targets:
json_serializable: json_serializable:
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

View 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":[]}}]}

View 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"]}}]}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,7 @@
PODS: PODS:
- Alamofire (5.10.2) - Alamofire (5.10.2)
- audioplayers_darwin (0.0.1):
- Flutter
- connectivity_plus (0.0.1): - connectivity_plus (0.0.1):
- Flutter - Flutter
- croppy (0.0.1): - croppy (0.0.1):
@ -37,63 +39,65 @@ PODS:
- DKPhotoGallery/Resource (0.0.19): - DKPhotoGallery/Resource (0.0.19):
- SDWebImage - SDWebImage
- SwiftyGif - SwiftyGif
- fast_rsa (0.6.0):
- Flutter
- file_picker (0.0.1): - file_picker (0.0.1):
- DKImagePickerController/PhotoGallery - DKImagePickerController/PhotoGallery
- Flutter - Flutter
- file_saver (0.0.1): - file_saver (0.0.1):
- Flutter - Flutter
- Firebase/Analytics (11.7.0): - Firebase/Analytics (11.8.0):
- Firebase/Core - Firebase/Core
- Firebase/Core (11.7.0): - Firebase/Core (11.8.0):
- Firebase/CoreOnly - Firebase/CoreOnly
- FirebaseAnalytics (~> 11.7.0) - FirebaseAnalytics (~> 11.8.0)
- Firebase/CoreOnly (11.7.0): - Firebase/CoreOnly (11.8.0):
- FirebaseCore (~> 11.7.0) - FirebaseCore (~> 11.8.0)
- Firebase/Messaging (11.7.0): - Firebase/Messaging (11.8.0):
- Firebase/CoreOnly - Firebase/CoreOnly
- FirebaseMessaging (~> 11.7.0) - FirebaseMessaging (~> 11.8.0)
- firebase_analytics (11.4.2): - firebase_analytics (11.4.4):
- Firebase/Analytics (= 11.7.0) - Firebase/Analytics (= 11.8.0)
- firebase_core - firebase_core
- Flutter - Flutter
- firebase_core (3.11.0): - firebase_core (3.12.1):
- Firebase/CoreOnly (= 11.7.0) - Firebase/CoreOnly (= 11.8.0)
- Flutter - Flutter
- firebase_messaging (15.2.2): - firebase_messaging (15.2.4):
- Firebase/Messaging (= 11.7.0) - Firebase/Messaging (= 11.8.0)
- firebase_core - firebase_core
- Flutter - Flutter
- FirebaseAnalytics (11.7.0): - FirebaseAnalytics (11.8.0):
- FirebaseAnalytics/AdIdSupport (= 11.7.0) - FirebaseAnalytics/AdIdSupport (= 11.8.0)
- FirebaseCore (~> 11.7.0) - FirebaseCore (~> 11.8.0)
- FirebaseInstallations (~> 11.0) - FirebaseInstallations (~> 11.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0) - GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0) - GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)" - "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- FirebaseAnalytics/AdIdSupport (11.7.0): - FirebaseAnalytics/AdIdSupport (11.8.0):
- FirebaseCore (~> 11.7.0) - FirebaseCore (~> 11.8.0)
- FirebaseInstallations (~> 11.0) - FirebaseInstallations (~> 11.0)
- GoogleAppMeasurement (= 11.7.0) - GoogleAppMeasurement (= 11.8.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0) - GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0) - GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)" - "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- FirebaseCore (11.7.0): - FirebaseCore (11.8.1):
- FirebaseCoreInternal (~> 11.7.0) - FirebaseCoreInternal (~> 11.8.0)
- GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/Logger (~> 8.0) - GoogleUtilities/Logger (~> 8.0)
- FirebaseCoreInternal (11.7.0): - FirebaseCoreInternal (11.8.0):
- "GoogleUtilities/NSData+zlib (~> 8.0)" - "GoogleUtilities/NSData+zlib (~> 8.0)"
- FirebaseInstallations (11.7.0): - FirebaseInstallations (11.8.0):
- FirebaseCore (~> 11.7.0) - FirebaseCore (~> 11.8.0)
- GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0) - GoogleUtilities/UserDefaults (~> 8.0)
- PromisesObjC (~> 2.4) - PromisesObjC (~> 2.4)
- FirebaseMessaging (11.7.0): - FirebaseMessaging (11.8.0):
- FirebaseCore (~> 11.7.0) - FirebaseCore (~> 11.8.0)
- FirebaseInstallations (~> 11.0) - FirebaseInstallations (~> 11.0)
- GoogleDataTransport (~> 10.0) - GoogleDataTransport (~> 10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
@ -113,6 +117,8 @@ PODS:
- OrderedSet (~> 6.0.3) - OrderedSet (~> 6.0.3)
- flutter_native_splash (2.4.3): - flutter_native_splash (2.4.3):
- Flutter - Flutter
- flutter_timezone (0.0.1):
- Flutter
- flutter_udid (0.0.1): - flutter_udid (0.0.1):
- Flutter - Flutter
- SAMKeychain - SAMKeychain
@ -122,21 +128,21 @@ PODS:
- gal (1.0.0): - gal (1.0.0):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- GoogleAppMeasurement (11.7.0): - GoogleAppMeasurement (11.8.0):
- GoogleAppMeasurement/AdIdSupport (= 11.7.0) - GoogleAppMeasurement/AdIdSupport (= 11.8.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0) - GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0) - GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)" - "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- GoogleAppMeasurement/AdIdSupport (11.7.0): - GoogleAppMeasurement/AdIdSupport (11.8.0):
- GoogleAppMeasurement/WithoutAdIdSupport (= 11.7.0) - GoogleAppMeasurement/WithoutAdIdSupport (= 11.8.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0) - GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0) - GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)" - "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- GoogleAppMeasurement/WithoutAdIdSupport (11.7.0): - GoogleAppMeasurement/WithoutAdIdSupport (11.8.0):
- GoogleUtilities/AppDelegateSwizzler (~> 8.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0) - GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0) - GoogleUtilities/Network (~> 8.0)
@ -179,14 +185,12 @@ PODS:
- in_app_review (2.0.0): - in_app_review (2.0.0):
- Flutter - Flutter
- Kingfisher (8.2.0) - Kingfisher (8.2.0)
- livekit_client (2.3.6): - livekit_client (2.4.1):
- Flutter - Flutter
- flutter_webrtc - flutter_webrtc
- WebRTC-SDK (= 125.6422.06) - WebRTC-SDK (= 125.6422.06)
- media_kit_libs_ios_video (1.0.4): - media_kit_libs_ios_video (1.0.4):
- Flutter - Flutter
- media_kit_native_event_loop (1.0.0):
- Flutter
- media_kit_video (0.0.1): - media_kit_video (0.0.1):
- Flutter - Flutter
- nanopb (3.30910.0): - nanopb (3.30910.0):
@ -208,11 +212,9 @@ PODS:
- receive_sharing_intent (1.8.1): - receive_sharing_intent (1.8.1):
- Flutter - Flutter
- SAMKeychain (1.5.3) - SAMKeychain (1.5.3)
- screen_brightness_ios (0.1.0): - SDWebImage (5.20.1):
- Flutter - SDWebImage/Core (= 5.20.1)
- SDWebImage (5.20.0): - SDWebImage/Core (5.20.1)
- SDWebImage/Core (= 5.20.0)
- SDWebImage/Core (5.20.0)
- share_plus (0.0.1): - share_plus (0.0.1):
- Flutter - Flutter
- shared_preferences_foundation (0.0.1): - shared_preferences_foundation (0.0.1):
@ -221,6 +223,28 @@ 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/math (3.49.1):
- sqlite3/common
- sqlite3/perf-threadsafe (3.49.1):
- sqlite3/common
- sqlite3/rtree (3.49.1):
- sqlite3/common
- sqlite3_flutter_libs (0.0.1):
- Flutter
- FlutterMacOS
- sqlite3 (~> 3.49.1)
- sqlite3/dbstatvtab
- sqlite3/fts5
- sqlite3/math
- sqlite3/perf-threadsafe
- sqlite3/rtree
- SwiftyGif (5.4.5) - SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1): - url_launcher_ios (0.0.1):
- Flutter - Flutter
@ -236,9 +260,11 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- Alamofire - Alamofire
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- croppy (from `.symlinks/plugins/croppy/ios`) - croppy (from `.symlinks/plugins/croppy/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- fast_rsa (from `.symlinks/plugins/fast_rsa/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`)
- file_saver (from `.symlinks/plugins/file_saver/ios`) - file_saver (from `.symlinks/plugins/file_saver/ios`)
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
@ -248,6 +274,7 @@ 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`)
@ -257,17 +284,16 @@ DEPENDENCIES:
- Kingfisher (~> 8.0) - Kingfisher (~> 8.0)
- livekit_client (from `.symlinks/plugins/livekit_client/ios`) - livekit_client (from `.symlinks/plugins/livekit_client/ios`)
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
- media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- pasteboard (from `.symlinks/plugins/pasteboard/ios`) - pasteboard (from `.symlinks/plugins/pasteboard/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_compress (from `.symlinks/plugins/video_compress/ios`) - video_compress (from `.symlinks/plugins/video_compress/ios`)
- volume_controller (from `.symlinks/plugins/volume_controller/ios`) - volume_controller (from `.symlinks/plugins/volume_controller/ios`)
@ -294,16 +320,21 @@ SPEC REPOS:
- PromisesObjC - PromisesObjC
- SAMKeychain - SAMKeychain
- SDWebImage - SDWebImage
- sqlite3
- SwiftyGif - SwiftyGif
- WebRTC-SDK - WebRTC-SDK
EXTERNAL SOURCES: EXTERNAL SOURCES:
audioplayers_darwin:
:path: ".symlinks/plugins/audioplayers_darwin/ios"
connectivity_plus: connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios" :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:
@ -322,6 +353,8 @@ 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:
@ -338,8 +371,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/livekit_client/ios" :path: ".symlinks/plugins/livekit_client/ios"
media_kit_libs_ios_video: media_kit_libs_ios_video:
:path: ".symlinks/plugins/media_kit_libs_ios_video/ios" :path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
media_kit_native_event_loop:
:path: ".symlinks/plugins/media_kit_native_event_loop/ios"
media_kit_video: media_kit_video:
:path: ".symlinks/plugins/media_kit_video/ios" :path: ".symlinks/plugins/media_kit_video/ios"
package_info_plus: package_info_plus:
@ -352,14 +383,14 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/permission_handler_apple/ios" :path: ".symlinks/plugins/permission_handler_apple/ios"
receive_sharing_intent: receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios" :path: ".symlinks/plugins/receive_sharing_intent/ios"
screen_brightness_ios:
:path: ".symlinks/plugins/screen_brightness_ios/ios"
share_plus: share_plus:
:path: ".symlinks/plugins/share_plus/ios" :path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation: shared_preferences_foundation:
: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:
@ -373,61 +404,64 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496 Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d audioplayers_darwin: ccf9c770ee768abb07e26d90af093f7bab1c12ab
croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321 connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 fast_rsa: d99f8e1809a4a312fa9216d830186869b2e9eb65
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Firebase: a64bf6a8546e6eab54f1c715cd6151f39d2329f4 file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
firebase_analytics: 7236e6115c1b4e62c2270faa29c052a317e31107 Firebase: d80354ed7f6df5f9aca55e9eb47cc4b634735eaf
firebase_core: aa979ae726f00b3ef4ccf59dfb96170af84efbd4 firebase_analytics: 4e93dbe66872104d28ae9750fec1800e1fd66858
firebase_messaging: 3af84b6a90aeac4d7a67fbf4c43a91e7083bea1f firebase_core: 8d552814f6c01ccde5d88939fced4ec26f2f5510
FirebaseAnalytics: bc9e565af9044ba8d6c6e4157e4edca8e5fdf7ec firebase_messaging: 8b96a4f09841c15a16b96973ef5c3dcfc1a064e4
FirebaseCore: 3227e35f4197a924206fbcdc0349325baf4f5de4 FirebaseAnalytics: 4fd42def128146e24e480e89f310e3d8534ea42b
FirebaseCoreInternal: d6c17dafc8dc33614733a8b52df78fcb4394c881 FirebaseCore: 99fe0c4b44a39f37d99e6404e02009d2db5d718d
FirebaseInstallations: 9347e719c3d52d8d7b9074b2c32407dd027305e9 FirebaseCoreInternal: df24ce5af28864660ecbd13596fc8dd3a8c34629
FirebaseMessaging: 00ece041b71ddb52a2862ffdee73fb6e9824bd0c FirebaseInstallations: 6c963bd2a86aca0481eef4f48f5a4df783ae5917
FirebaseMessaging: 487b634ccdf6f7b7ff180fdcb2a9935490f764e8
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc flutter_app_update: 816fdb2e30e4832a7c45e3f108d391c42ef040a9
flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4 flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99
flutter_native_splash: f71420956eb811e6d310720fee915f1d42852e7a flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544
flutter_webrtc: 90260f83024b1b96d239a575ea4e3708e79344d1 flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5 flutter_webrtc: 57f32415b8744e806f9c2a96ccdb60c6a627ba33
GoogleAppMeasurement: 0471a5b5bff51f3a91b1e76df22c952d04c63967 gal: baecd024ebfd13c441269ca7404792a7152fde89
GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57 home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
in_app_review: a31b5257259646ea78e0e35fc914979b0031d011 in_app_review: 5596fe56fab799e8edb3561c03d053363ab13457
Kingfisher: 323e5c4ec7983aaace12af655a7b51a7f88a599d Kingfisher: 323e5c4ec7983aaace12af655a7b51a7f88a599d
livekit_client: 148b2cf67a09aaf475ba8e5bf1667fe10dc35f81 livekit_client: 08755cabfa4da4ed455642f460cfbb39bc518070
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1 media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0 pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1 receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625 SDWebImage: 33d0f23bddeb5d209ae959153883247be6703713
SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8 share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe video_compress: f2133a07762889d67f0711ac831faa26f956980e
volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9 volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12
wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56 wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49
WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6 workmanager: 01be2de7f184bd15de93a1812936a2b7f42ef07e
PODFILE CHECKSUM: 9b244e02f87527430136c8d21cbdcf1cd586b6bc PODFILE CHECKSUM: 9b244e02f87527430136c8d21cbdcf1cd586b6bc

View File

@ -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">

View File

@ -79,6 +79,8 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UISupportedInterfaceOrientations~ipad</key> <key>UISupportedInterfaceOrientations~ipad</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>

View File

@ -2,11 +2,15 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:drift/drift.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:surface/database/database.dart';
import 'package:surface/logger.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/database.dart';
import 'package:surface/providers/keypair.dart';
import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/user_directory.dart';
@ -16,13 +20,15 @@ import 'package:surface/types/websocket.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class ChatMessageController extends ChangeNotifier { class ChatMessageController extends ChangeNotifier {
static const kChatMessageBoxPrefix = 'nex_chat_messages_';
static const kSingleBatchLoadLimit = 100; static const kSingleBatchLoadLimit = 100;
late final SnNetworkProvider _sn; late final SnNetworkProvider _sn;
late final UserDirectoryProvider _ud; late final UserDirectoryProvider _ud;
late final WebSocketProvider _ws; late final WebSocketProvider _ws;
late final SnAttachmentProvider _attach; late final SnAttachmentProvider _attach;
late final DatabaseProvider _dt;
late final ChatChannelProvider _ct;
late final KeyPairProvider _kp;
StreamSubscription? _wsSubscription; StreamSubscription? _wsSubscription;
@ -31,16 +37,20 @@ class ChatMessageController extends ChangeNotifier {
_ud = context.read<UserDirectoryProvider>(); _ud = context.read<UserDirectoryProvider>();
_ws = context.read<WebSocketProvider>(); _ws = context.read<WebSocketProvider>();
_attach = context.read<SnAttachmentProvider>(); _attach = context.read<SnAttachmentProvider>();
_ct = context.read<ChatChannelProvider>();
_dt = context.read<DatabaseProvider>();
_kp = context.read<KeyPairProvider>();
} }
bool isPending = true; bool isPending = true;
bool isLoading = false; bool isLoading = false;
bool isAggressiveLoading = false;
int? messageTotal; int? messageTotal;
bool get isAllLoaded => messageTotal != null && messages.length >= messageTotal!; bool get isAllLoaded =>
messageTotal != null && messages.length >= messageTotal!;
String? _boxKey;
SnChannel? channel; SnChannel? channel;
SnChannelMember? profile; SnChannelMember? profile;
@ -51,25 +61,14 @@ class ChatMessageController extends ChangeNotifier {
/// Stored as a list of nonce to provide the loading state /// Stored as a list of nonce to provide the loading state
final List<String> unconfirmedMessages = List.empty(growable: true); final List<String> unconfirmedMessages = List.empty(growable: true);
Box<SnChatMessage>? get _box => (_boxKey == null || isPending) ? null : Hive.box<SnChatMessage>(_boxKey!);
final List<SnChannelMember> typingMembers = List.empty(growable: true); final List<SnChannelMember> typingMembers = List.empty(growable: true);
final Map<int, Timer> typingInactiveTimer = {}; final Map<int, Timer> typingInactiveTimer = {};
Future<void> initialize(SnChannel chan) async { Future<void> initialize(SnChannel chan) async {
channel = chan; channel = chan;
// Initialize local data
_boxKey = '$kChatMessageBoxPrefix${chan.id}';
await Hive.openBox<SnChatMessage>(_boxKey!);
// Fetch channel profile // Fetch channel profile
final resp = await _sn.client.get( profile = await _ct.getChannelProfile(channel!);
'/cgi/im/channels/${chan.keyPath}/me',
);
profile = SnChannelMember.fromJson(
resp.data as Map<String, dynamic>,
);
_wsSubscription = _ws.pk.stream.listen((event) { _wsSubscription = _ws.pk.stream.listen((event) {
switch (event.method) { switch (event.method) {
@ -87,7 +86,8 @@ class ChatMessageController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
typingInactiveTimer[member.id]?.cancel(); typingInactiveTimer[member.id]?.cancel();
typingInactiveTimer[member.id] = Timer(const Duration(seconds: 3), () { typingInactiveTimer[member.id] =
Timer(const Duration(seconds: 3), () {
typingMembers.removeWhere((x) => x.id == member.id); typingMembers.removeWhere((x) => x.id == member.id);
typingInactiveTimer.remove(member.id); typingInactiveTimer.remove(member.id);
notifyListeners(); notifyListeners();
@ -129,10 +129,16 @@ class ChatMessageController extends ChangeNotifier {
} }
Future<void> _saveMessageToLocal(Iterable<SnChatMessage> messages) async { Future<void> _saveMessageToLocal(Iterable<SnChatMessage> messages) async {
if (_box == null) return; await _dt.db.snLocalChatMessage.insertAll(
await _box!.putAll({ messages.map(
for (final message in messages) message.id: message, (ele) => SnLocalChatMessageCompanion.insert(
}); id: Value(ele.id),
content: ele,
channelId: channel!.id,
createdAt: Value(ele.createdAt),
),
),
onConflict: DoNothing());
} }
Future<void> _addUnconfirmedMessage(SnChatMessage message) async { Future<void> _addUnconfirmedMessage(SnChatMessage message) async {
@ -181,11 +187,27 @@ class ChatMessageController extends ChangeNotifier {
} else { } else {
messages.insert(0, message); messages.insert(0, message);
} }
notifyListeners();
await _applyMessage(message); await _applyMessage(message);
notifyListeners(); notifyListeners();
if (_box == null) return; if (isCheckedUpdate) {
await _box!.put(message.id, message); await _dt.db.snLocalChatMessage.insertOne(
SnLocalChatMessageCompanion.insert(
id: Value(message.id),
content: message,
channelId: channel!.id,
createdAt: Value(message.createdAt),
),
onConflict: DoUpdate(
(_) => SnLocalChatMessageCompanion.custom(
content: Constant(jsonEncode(message.toJson())),
),
),
);
} else {
incomeStrandedQueue.add(message);
}
} }
Future<void> _applyMessage(SnChatMessage message) async { Future<void> _applyMessage(SnChatMessage message) async {
@ -194,29 +216,56 @@ class ChatMessageController extends ChangeNotifier {
switch (message.type) { switch (message.type) {
case 'messages.edit': case 'messages.edit':
if (message.relatedEventId != null) { if (message.relatedEventId != null) {
final idx = messages.indexWhere((x) => x.id == message.relatedEventId); final idx =
messages.indexWhere((x) => x.id == message.relatedEventId);
if (idx != -1) { if (idx != -1) {
final newBody = message.body; final newBody = Map<String, dynamic>.from(message.body);
newBody.remove('related_event'); newBody.remove('related_event');
messages[idx] = messages[idx].copyWith( messages[idx] = messages[idx].copyWith(
body: newBody, body: newBody,
updatedAt: message.updatedAt, updatedAt: message.updatedAt,
); );
if (_box!.containsKey(message.relatedEventId)) { }
await _box!.put(message.relatedEventId, messages[idx]); if (message.relatedEventId != null) {
} await (_dt.db.snLocalChatMessage.update()
..where((e) => e.id.equals(message.relatedEventId!)))
.write(
SnLocalChatMessageCompanion.custom(
content: Constant(jsonEncode(messages[idx].toJson())),
),
);
} }
} }
case 'messages.delete': case 'messages.delete':
if (message.relatedEventId != null) { if (message.relatedEventId != null) {
messages.removeWhere((x) => x.id == message.relatedEventId); messages.removeWhere((x) => x.id == message.relatedEventId);
if (_box!.containsKey(message.relatedEventId)) { if (message.relatedEventId != null) {
await _box!.delete(message.relatedEventId); await (_dt.db.snLocalChatMessage.delete()
..where((e) => e.id.equals(message.relatedEventId!)))
.go();
} }
} }
} }
} }
Future<Map<String, dynamic>> _encodeMessageBody(
String text,
bool isEncrypted,
) async {
if (!isEncrypted || _kp.activeKp == null) {
return {
'text': text,
'algorithm': 'plain',
};
} else {
return {
'text': await _kp.encryptText(text),
'algorithm': 'rsa',
'keypair_id': _kp.activeKp!.id,
};
}
}
Future<void> sendMessage( Future<void> sendMessage(
String type, String type,
String content, { String content, {
@ -224,36 +273,40 @@ class ChatMessageController extends ChangeNotifier {
int? relatedId, int? relatedId,
List<String>? attachments, List<String>? attachments,
SnChatMessage? editingMessage, SnChatMessage? editingMessage,
bool isEncrypted = false,
}) async { }) async {
if (channel == null) return; if (channel == null) return;
const uuid = Uuid(); const uuid = Uuid();
final nonce = uuid.v4(); final nonce = uuid.v4();
final body = { final body = {
'text': content, ...(await _encodeMessageBody(content, isEncrypted)),
'algorithm': 'plain',
if (quoteId != null) 'quote_event': quoteId, if (quoteId != null) 'quote_event': quoteId,
if (relatedId != null) 'related_event': relatedId, if (relatedId != null) 'related_event': relatedId,
if (attachments != null && attachments.isNotEmpty) 'attachments': attachments, if (attachments != null && attachments.isNotEmpty)
'attachments': attachments,
}; };
// Mock the message locally // Mock the message locally
final createdAt = DateTime.now(); // Do not mock the editing message
final message = SnChatMessage( if (editingMessage == null) {
id: 0, final createdAt = DateTime.now();
createdAt: createdAt, final message = SnChatMessage(
updatedAt: createdAt, id: 0,
deletedAt: null, createdAt: createdAt,
uuid: nonce, updatedAt: createdAt,
body: body, deletedAt: null,
type: type, uuid: nonce,
channel: channel!, body: body,
channelId: channel!.id, type: type,
sender: profile!, channel: channel!,
senderId: profile!.id, channelId: channel!.id,
quoteEventId: quoteId, sender: profile!,
relatedEventId: relatedId, senderId: profile!.id,
); quoteEventId: quoteId,
_addUnconfirmedMessage(message); relatedEventId: relatedId,
);
_addUnconfirmedMessage(message);
}
// Send to server // Send to server
try { try {
@ -287,20 +340,36 @@ class ChatMessageController extends ChangeNotifier {
} }
} }
bool isCheckedUpdate = false;
List<SnChatMessage> incomeStrandedQueue = List.empty(growable: true);
/// Check the local storage is up to date with the server. /// Check the local storage is up to date with the server.
/// If the local storage is not up to date, it will be updated. /// If the local storage is not up to date, it will be updated.
Future<void> checkUpdate() async { Future<void> checkUpdate() async {
if (_box == null) return; isAggressiveLoading = true;
if (_box!.isEmpty) return;
isLoading = true;
notifyListeners(); notifyListeners();
final mostRecentMessage = await (_dt.db.snLocalChatMessage.select()
..where((e) => e.channelId.equals(channel!.id))
..limit(1)
..orderBy([
(e) =>
OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc)
]))
.getSingleOrNull();
if (mostRecentMessage == null) {
// Initial load
await loadMessages(take: 20);
isAggressiveLoading = false;
isCheckedUpdate = true;
return;
}
try { try {
final resp = await _sn.client.get( final resp = await _sn.client.get(
'/cgi/im/channels/${channel!.keyPath}/events/update', '/cgi/im/channels/${channel!.keyPath}/events/update',
queryParameters: { queryParameters: {
'pivot': _box!.values.last.id, 'pivot': mostRecentMessage.content.id,
}, },
); );
if (resp.data['up_to_date'] == true) return; if (resp.data['up_to_date'] == true) return;
@ -309,13 +378,25 @@ class ChatMessageController extends ChangeNotifier {
final countToFetch = math.min(resp.data['count'] as int, 100); final countToFetch = math.min(resp.data['count'] as int, 100);
for (int idx = 0; idx < countToFetch; idx += kSingleBatchLoadLimit) { for (int idx = 0; idx < countToFetch; idx += kSingleBatchLoadLimit) {
await getMessages(kSingleBatchLoadLimit, idx, forceRemote: true); final out = await getMessages(
kSingleBatchLoadLimit,
idx,
forceRemote: true,
);
messages.insertAll(0, out);
notifyListeners();
} }
} catch (err) { } catch (err) {
rethrow; rethrow;
} finally { } finally {
await loadMessages(); await loadMessages();
isLoading = false; isAggressiveLoading = false;
isCheckedUpdate = true;
_saveMessageToLocal(incomeStrandedQueue).then((_) {
incomeStrandedQueue.clear();
});
notifyListeners(); notifyListeners();
} }
} }
@ -324,13 +405,18 @@ class ChatMessageController extends ChangeNotifier {
/// If it was not found in local storage we will look it up in remote /// If it was not found in local storage we will look it up in remote
Future<SnChatMessage?> getMessage(int id) async { Future<SnChatMessage?> getMessage(int id) async {
SnChatMessage? out; SnChatMessage? out;
if (_box != null && _box!.containsKey(id)) { final local = await (_dt.db.snLocalChatMessage.select()
out = _box!.get(id); ..limit(1)
..where((e) => e.id.equals(id)))
.getSingleOrNull();
if (local != null) {
out = local.content;
} }
if (out == null) { if (out == null) {
try { try {
final resp = await _sn.client.get('/cgi/im/channels/${channel!.keyPath}/events/$id'); final resp = await _sn.client
.get('/cgi/im/channels/${channel!.keyPath}/events/$id');
out = SnChatMessage.fromJson(resp.data); out = SnChatMessage.fromJson(resp.data);
_saveMessageToLocal([out]); _saveMessageToLocal([out]);
} catch (_) { } catch (_) {
@ -364,16 +450,21 @@ class ChatMessageController extends ChangeNotifier {
bool forceLocal = false, bool forceLocal = false,
bool forceRemote = false, bool forceRemote = false,
}) async { }) async {
final localTotal = await _dt.db.snLocalChatMessage
.count(where: (e) => e.channelId.equals(channel!.id))
.getSingle();
late List<SnChatMessage> out; late List<SnChatMessage> out;
if (_box != null && (_box!.length >= take + offset || forceLocal) && !forceRemote) { if ((localTotal >= take + offset || forceLocal) && !forceRemote) {
out = _box!.keys final result = await (_dt.db.snLocalChatMessage.select()
.toList() ..where((e) => e.channelId.equals(channel!.id))
.cast<int>() ..orderBy([
.sorted((a, b) => b.compareTo(a)) (e) =>
.skip(offset) OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc)
.take(take) ])
.map((key) => _box!.get(key)!) ..limit(take, offset: offset))
.toList(); .get();
out = result.map((e) => e.content).toList();
} else { } else {
final resp = await _sn.client.get( final resp = await _sn.client.get(
'/cgi/im/channels/${channel!.keyPath}/events', '/cgi/im/channels/${channel!.keyPath}/events',
@ -408,7 +499,8 @@ class ChatMessageController extends ChangeNotifier {
quoteEvent: quoteEvent, quoteEvent: quoteEvent,
attachments: attachments attachments: attachments
.where( .where(
(ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false, (ele) =>
out[i].body['attachments']?.contains(ele?.rid) ?? false,
) )
.toList(), .toList(),
), ),
@ -416,7 +508,10 @@ class ChatMessageController extends ChangeNotifier {
} }
// Preload sender accounts // Preload sender accounts
final accountId = out.where((ele) => ele.sender.accountId >= 0).map((ele) => ele.sender.accountId).toSet(); final accountId = out
.where((ele) => ele.sender.accountId >= 0)
.map((ele) => ele.sender.accountId)
.toSet();
await _ud.listAccount(accountId); await _ud.listAccount(accountId);
return out; return out;
@ -441,10 +536,45 @@ class ChatMessageController extends ChangeNotifier {
} }
} }
Timer? _readEventDebounce;
int? _readEventAnchor;
void readEvent(int id) {
if (_readEventAnchor != null) {
_readEventAnchor = math.max(_readEventAnchor!, id);
} else {
_readEventAnchor = id;
}
if (_readEventDebounce?.isActive ?? false) {
_readEventDebounce?.cancel();
}
_readEventDebounce = Timer(const Duration(milliseconds: 500), () {
_sendReadEvent();
});
}
void _sendReadEvent() {
_ws.conn?.sink.add(jsonEncode(
WebSocketPackage(
method: 'events.read',
endpoint: 'im',
payload: {
'channel_member_id': profile!.id,
'event_id': _readEventAnchor,
},
).toJson(),
));
logging.debug('[Messaging] Send read event request: $_readEventAnchor');
}
@override @override
void dispose() { void dispose() {
_box?.close();
_wsSubscription?.cancel(); _wsSubscription?.cancel();
if (_readEventDebounce?.isActive ?? false) {
_sendReadEvent();
}
_readEventDebounce?.cancel();
super.dispose(); super.dispose();
} }
} }

View File

@ -18,6 +18,7 @@ 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/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';
@ -70,7 +71,8 @@ class PostWriteMedia {
} }
} }
PostWriteMedia.fromBytes(this.raw, this.name, this.type, {this.attachment, this.file}); PostWriteMedia.fromBytes(this.raw, this.name, this.type,
{this.attachment, this.file});
bool get isEmpty => attachment == null && file == null && raw == null; bool get isEmpty => attachment == null && file == null && raw == null;
@ -104,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,
@ -115,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,
@ -158,13 +162,17 @@ class PostWriteController extends ChangeNotifier {
final TextEditingController aliasController = TextEditingController(); final TextEditingController aliasController = TextEditingController();
final TextEditingController rewardController = TextEditingController(); final TextEditingController rewardController = TextEditingController();
ContentInsertionConfiguration get contentInsertionConfiguration => ContentInsertionConfiguration( ContentInsertionConfiguration get contentInsertionConfiguration =>
onContentInserted: (KeyboardInsertedContent content) { ContentInsertionConfiguration(
if (content.hasData) { onContentInserted: (KeyboardInsertedContent content) {
addAttachments([PostWriteMedia.fromBytes(content.data!, 'attachmentInsertedImage'.tr(), SnMediaType.image)]); if (content.hasData) {
} addAttachments([
}, PostWriteMedia.fromBytes(content.data!,
); 'attachmentInsertedImage'.tr(), SnMediaType.image)
]);
}
},
);
bool _temporarySaveActive = false; bool _temporarySaveActive = false;
@ -191,13 +199,16 @@ 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;
bool editingDraft = false;
int visibility = 0; int visibility = 0;
List<int> visibleUsers = List.empty(); List<int> visibleUsers = List.empty();
@ -234,16 +245,25 @@ class PostWriteController extends ChangeNotifier {
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; poll = post.preload?.poll;
if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) { editingDraft = post.isDraft;
if (post.preload?.thumbnail != null &&
(post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
thumbnail = PostWriteMedia(post.preload!.thumbnail); thumbnail = PostWriteMedia(post.preload!.thumbnail);
} }
if (post.preload?.realm != null) {
realm = post.preload!.realm!;
}
editingPost = post; editingPost = post;
} }
@ -266,7 +286,8 @@ class PostWriteController extends ChangeNotifier {
} }
} }
Future<SnAttachment> _uploadAttachment(BuildContext context, PostWriteMedia media, Future<SnAttachment> _uploadAttachment(
BuildContext context, PostWriteMedia media,
{bool isCompressed = false}) async { {bool isCompressed = false}) async {
final attach = context.read<SnAttachmentProvider>(); final attach = context.read<SnAttachmentProvider>();
@ -275,7 +296,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(
@ -291,9 +314,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);
@ -303,8 +328,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;
@ -328,7 +355,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;
} }
@ -364,26 +392,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)
'description': descriptionController.text,
if (rewardController.text.isNotEmpty) 'reward': rewardController.text, if (rewardController.text.isNotEmpty) 'reward': rewardController.text,
if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.toJson(), if (thumbnail != null && thumbnail!.attachment != null)
'attachments': 'thumbnail': thumbnail!.attachment!.toJson(),
attachments.where((e) => e.attachment != null).map((e) => e.attachment!.toJson()).toList(growable: true), '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 (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() {
@ -396,19 +438,26 @@ class PostWriteController extends ChangeNotifier {
titleController.text = data['title'] ?? ''; titleController.text = data['title'] ?? '';
descriptionController.text = data['description'] ?? ''; descriptionController.text = data['description'] ?? '';
rewardController.text = data['reward']?.toString() ?? ''; rewardController.text = data['reward']?.toString() ?? '';
if (data['thumbnail'] != null) thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail'])); if (data['thumbnail'] != null)
attachments thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail']));
.addAll(data['attachments'].map((ele) => PostWriteMedia(SnAttachment.fromJson(ele))).cast<PostWriteMedia>()); 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; poll = data['poll'] != null ? SnPoll.fromJson(data['poll']) : null;
realm = data['realm'] != null ? SnRealm.fromJson(data['realm']) : null;
temporaryRestored = true; temporaryRestored = true;
notifyListeners(); notifyListeners();
}); });
@ -428,7 +477,10 @@ class PostWriteController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> sendPost(BuildContext context) async { Future<void> sendPost(
BuildContext context, {
bool saveAsDraft = false,
}) async {
if (isBusy || publisher == null) return; if (isBusy || publisher == null) return;
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
@ -455,7 +507,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(
@ -464,16 +518,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) {
@ -500,7 +558,7 @@ class PostWriteController extends ChangeNotifier {
// Posting the content // Posting the content
try { try {
final baseProgressVal = progress!; final baseProgressVal = progress!;
await sn.client.request( final resp = await sn.client.request(
[ [
'/cgi/co/$mode', '/cgi/co/$mode',
if (editingPost != null) '${editingPost!.id}', if (editingPost != null) '${editingPost!.id}',
@ -510,35 +568,56 @@ 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 (reward != null) 'reward': reward,
if (videoAttachment != null) 'video': videoAttachment!.rid, if (videoAttachment != null) 'video': videoAttachment!.rid,
if (poll != null) 'poll': poll!.id, if (poll != null) 'poll': poll!.id,
if (realm != null) 'realm': realm!.id,
'is_draft': saveAsDraft,
}, },
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(
method: editingPost != null ? 'PUT' : 'POST', method: editingPost != null ? 'PUT' : 'POST',
), ),
); );
reset(); if (saveAsDraft) {
if (!context.mounted) return;
editingDraft = true;
final out = SnPost.fromJson(resp.data);
final pt = context.read<SnPostContentProvider>();
editingPost = await pt.completePostData(out);
notifyListeners();
} else {
reset();
}
} catch (err) { } catch (err) {
if (!context.mounted) return; if (!context.mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -571,17 +650,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();
} }
@ -633,6 +703,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();
@ -678,7 +753,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
View 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()();
}

View 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
View 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()();
}

View File

@ -0,0 +1,62 @@
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:path_provider/path_provider.dart';
import 'package:surface/database/account.dart';
import 'package:surface/database/attachment.dart';
import 'package:surface/database/chat.dart';
import 'package:surface/database/database.steps.dart';
import 'package:surface/database/keypair.dart';
import 'package:surface/database/realm.dart';
import 'package:surface/database/sticker.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/types/attachment.dart';
import 'package:surface/types/account.dart';
import 'package:surface/types/realm.dart';
part 'database.g.dart';
@DriftDatabase(tables: [
SnLocalChatChannel,
SnLocalChatMessage,
SnLocalChannelMember,
SnLocalKeyPair,
SnLocalAccount,
SnLocalAttachment,
SnLocalSticker,
SnLocalStickerPack,
SnLocalRealm,
])
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? e]) : super(e ?? _openConnection());
@override
int get schemaVersion => 4;
static QueryExecutor _openConnection() {
return driftDatabase(
name: 'solar_network_data',
native: const DriftNativeOptions(
databaseDirectory: getApplicationSupportDirectory,
),
web: DriftWebOptions(
sqlite3Wasm: Uri.parse('sqlite3.wasm'),
driftWorker: Uri.parse('drift_worker.dart.js'),
),
);
}
@override
MigrationStrategy get migration {
return MigrationStrategy(
onUpgrade: stepByStep(from1To2: (m, schema) async {
// Nothing else to do here
}, from2To3: (m, schema) async {
// Nothing else to do here, too
}, from3To4: (m, schema) async {
m.createTable(schema.snLocalRealm);
m.createIndex(schema.idxRealmAccount);
m.createIndex(schema.idxRealmAlias);
}),
);
}
}

4452
lib/database/database.g.dart Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,657 @@
// dart format width=80
import 'package:drift/internal/versioned_schema.dart' as i0;
import 'package:drift/drift.dart' as i1;
import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import
// GENERATED BY drift_dev, DO NOT MODIFY.
final class Schema2 extends i0.VersionedSchema {
Schema2({required super.database}) : super(version: 2);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
snLocalChatChannel,
snLocalChatMessage,
snLocalKeyPair,
];
late final Shape0 snLocalChatChannel = Shape0(
source: i0.VersionedTable(
entityName: 'sn_local_chat_channel',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_1,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape1 snLocalChatMessage = Shape1(
source: i0.VersionedTable(
entityName: 'sn_local_chat_message',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_4,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape2 snLocalKeyPair = Shape2(
source: i0.VersionedTable(
entityName: 'sn_local_key_pair',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(id)',
],
columns: [
_column_5,
_column_6,
_column_7,
_column_8,
_column_9,
],
attachedDatabase: database,
),
alias: null);
}
class Shape0 extends i0.VersionedTable {
Shape0({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get alias =>
columnsByName['alias']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get content =>
columnsByName['content']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<int> _column_0(String aliasedName) =>
i1.GeneratedColumn<int>('id', aliasedName, false,
hasAutoIncrement: true,
type: i1.DriftSqlType.int,
defaultConstraints:
i1.GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
i1.GeneratedColumn<String> _column_1(String aliasedName) =>
i1.GeneratedColumn<String>('alias', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_2(String aliasedName) =>
i1.GeneratedColumn<String>('content', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<DateTime> _column_3(String aliasedName) =>
i1.GeneratedColumn<DateTime>('created_at', aliasedName, false,
type: i1.DriftSqlType.dateTime,
defaultValue: const CustomExpression(
'CAST(strftime(\'%s\', CURRENT_TIMESTAMP) AS INTEGER)'));
class Shape1 extends i0.VersionedTable {
Shape1({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get channelId =>
columnsByName['channel_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get content =>
columnsByName['content']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<int> _column_4(String aliasedName) =>
i1.GeneratedColumn<int>('channel_id', aliasedName, false,
type: i1.DriftSqlType.int);
class Shape2 extends i0.VersionedTable {
Shape2({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get accountId =>
columnsByName['account_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get publicKey =>
columnsByName['public_key']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get privateKey =>
columnsByName['private_key']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isActive =>
columnsByName['is_active']! as i1.GeneratedColumn<bool>;
}
i1.GeneratedColumn<String> _column_5(String aliasedName) =>
i1.GeneratedColumn<String>('id', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<int> _column_6(String aliasedName) =>
i1.GeneratedColumn<int>('account_id', aliasedName, false,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<String> _column_7(String aliasedName) =>
i1.GeneratedColumn<String>('public_key', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_8(String aliasedName) =>
i1.GeneratedColumn<String>('private_key', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<bool> _column_9(String aliasedName) =>
i1.GeneratedColumn<bool>('is_active', aliasedName, false,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("is_active" IN (0, 1))'),
defaultValue: const CustomExpression('0'));
final class Schema3 extends i0.VersionedSchema {
Schema3({required super.database}) : super(version: 3);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
snLocalChatChannel,
snLocalChatMessage,
snLocalChannelMember,
snLocalKeyPair,
snLocalAccount,
snLocalAttachment,
snLocalSticker,
snLocalStickerPack,
idxChannelAlias,
idxChatChannel,
idxAccountName,
idxAttachmentRid,
idxAttachmentAccount,
];
late final Shape0 snLocalChatChannel = Shape0(
source: i0.VersionedTable(
entityName: 'sn_local_chat_channel',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_1,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape3 snLocalChatMessage = Shape3(
source: i0.VersionedTable(
entityName: 'sn_local_chat_message',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_4,
_column_10,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape4 snLocalChannelMember = Shape4(
source: i0.VersionedTable(
entityName: 'sn_local_channel_member',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_4,
_column_6,
_column_2,
_column_3,
_column_11,
],
attachedDatabase: database,
),
alias: null);
late final Shape2 snLocalKeyPair = Shape2(
source: i0.VersionedTable(
entityName: 'sn_local_key_pair',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(id)',
],
columns: [
_column_5,
_column_6,
_column_7,
_column_8,
_column_9,
],
attachedDatabase: database,
),
alias: null);
late final Shape5 snLocalAccount = Shape5(
source: i0.VersionedTable(
entityName: 'sn_local_account',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_12,
_column_2,
_column_3,
_column_11,
],
attachedDatabase: database,
),
alias: null);
late final Shape6 snLocalAttachment = Shape6(
source: i0.VersionedTable(
entityName: 'sn_local_attachment',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_13,
_column_14,
_column_2,
_column_6,
_column_3,
_column_11,
],
attachedDatabase: database,
),
alias: null);
late final Shape7 snLocalSticker = Shape7(
source: i0.VersionedTable(
entityName: 'sn_local_sticker',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_1,
_column_15,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape8 snLocalStickerPack = Shape8(
source: i0.VersionedTable(
entityName: 'sn_local_sticker_pack',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
final i1.Index idxChannelAlias = i1.Index('idx_channel_alias',
'CREATE INDEX idx_channel_alias ON sn_local_chat_channel (alias)');
final i1.Index idxChatChannel = i1.Index('idx_chat_channel',
'CREATE INDEX idx_chat_channel ON sn_local_chat_message (channel_id)');
final i1.Index idxAccountName = i1.Index('idx_account_name',
'CREATE INDEX idx_account_name ON sn_local_account (name)');
final i1.Index idxAttachmentRid = i1.Index('idx_attachment_rid',
'CREATE INDEX idx_attachment_rid ON sn_local_attachment (rid)');
final i1.Index idxAttachmentAccount = i1.Index('idx_attachment_account',
'CREATE INDEX idx_attachment_account ON sn_local_attachment (account_id)');
}
class Shape3 extends i0.VersionedTable {
Shape3({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get channelId =>
columnsByName['channel_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get senderId =>
columnsByName['sender_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get content =>
columnsByName['content']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<int> _column_10(String aliasedName) =>
i1.GeneratedColumn<int>('sender_id', aliasedName, true,
type: i1.DriftSqlType.int);
class Shape4 extends i0.VersionedTable {
Shape4({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get channelId =>
columnsByName['channel_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get accountId =>
columnsByName['account_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get content =>
columnsByName['content']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<DateTime> _column_11(String aliasedName) =>
i1.GeneratedColumn<DateTime>('cache_expired_at', aliasedName, false,
type: i1.DriftSqlType.dateTime);
class Shape5 extends i0.VersionedTable {
Shape5({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get content =>
columnsByName['content']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<String> _column_12(String aliasedName) =>
i1.GeneratedColumn<String>('name', aliasedName, false,
type: i1.DriftSqlType.string);
class Shape6 extends i0.VersionedTable {
Shape6({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get rid =>
columnsByName['rid']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get uuid =>
columnsByName['uuid']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get content =>
columnsByName['content']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get accountId =>
columnsByName['account_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<String> _column_13(String aliasedName) =>
i1.GeneratedColumn<String>('rid', aliasedName, false,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'));
i1.GeneratedColumn<String> _column_14(String aliasedName) =>
i1.GeneratedColumn<String>('uuid', aliasedName, false,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'));
class Shape7 extends i0.VersionedTable {
Shape7({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get alias =>
columnsByName['alias']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get fullAlias =>
columnsByName['full_alias']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get content =>
columnsByName['content']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<String> _column_15(String aliasedName) =>
i1.GeneratedColumn<String>('full_alias', aliasedName, false,
type: i1.DriftSqlType.string);
class Shape8 extends i0.VersionedTable {
Shape8({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get content =>
columnsByName['content']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
}
final class Schema4 extends i0.VersionedSchema {
Schema4({required super.database}) : super(version: 4);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
snLocalChatChannel,
snLocalChatMessage,
snLocalChannelMember,
snLocalKeyPair,
snLocalAccount,
snLocalAttachment,
snLocalSticker,
snLocalStickerPack,
snLocalRealm,
idxChannelAlias,
idxChatChannel,
idxAccountName,
idxAttachmentRid,
idxAttachmentAccount,
idxRealmAlias,
idxRealmAccount,
];
late final Shape0 snLocalChatChannel = Shape0(
source: i0.VersionedTable(
entityName: 'sn_local_chat_channel',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_1,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape3 snLocalChatMessage = Shape3(
source: i0.VersionedTable(
entityName: 'sn_local_chat_message',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_4,
_column_10,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape4 snLocalChannelMember = Shape4(
source: i0.VersionedTable(
entityName: 'sn_local_channel_member',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_4,
_column_6,
_column_2,
_column_3,
_column_11,
],
attachedDatabase: database,
),
alias: null);
late final Shape2 snLocalKeyPair = Shape2(
source: i0.VersionedTable(
entityName: 'sn_local_key_pair',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(id)',
],
columns: [
_column_5,
_column_6,
_column_7,
_column_8,
_column_9,
],
attachedDatabase: database,
),
alias: null);
late final Shape5 snLocalAccount = Shape5(
source: i0.VersionedTable(
entityName: 'sn_local_account',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_12,
_column_2,
_column_3,
_column_11,
],
attachedDatabase: database,
),
alias: null);
late final Shape6 snLocalAttachment = Shape6(
source: i0.VersionedTable(
entityName: 'sn_local_attachment',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_13,
_column_14,
_column_2,
_column_6,
_column_3,
_column_11,
],
attachedDatabase: database,
),
alias: null);
late final Shape7 snLocalSticker = Shape7(
source: i0.VersionedTable(
entityName: 'sn_local_sticker',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_1,
_column_15,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape8 snLocalStickerPack = Shape8(
source: i0.VersionedTable(
entityName: 'sn_local_sticker_pack',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape9 snLocalRealm = Shape9(
source: i0.VersionedTable(
entityName: 'sn_local_realm',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_16,
_column_2,
_column_6,
_column_3,
_column_11,
],
attachedDatabase: database,
),
alias: null);
final i1.Index idxChannelAlias = i1.Index('idx_channel_alias',
'CREATE INDEX idx_channel_alias ON sn_local_chat_channel (alias)');
final i1.Index idxChatChannel = i1.Index('idx_chat_channel',
'CREATE INDEX idx_chat_channel ON sn_local_chat_message (channel_id)');
final i1.Index idxAccountName = i1.Index('idx_account_name',
'CREATE INDEX idx_account_name ON sn_local_account (name)');
final i1.Index idxAttachmentRid = i1.Index('idx_attachment_rid',
'CREATE INDEX idx_attachment_rid ON sn_local_attachment (rid)');
final i1.Index idxAttachmentAccount = i1.Index('idx_attachment_account',
'CREATE INDEX idx_attachment_account ON sn_local_attachment (account_id)');
final i1.Index idxRealmAlias = i1.Index('idx_realm_alias',
'CREATE INDEX idx_realm_alias ON sn_local_realm (alias)');
final i1.Index idxRealmAccount = i1.Index('idx_realm_account',
'CREATE INDEX idx_realm_account ON sn_local_realm (account_id)');
}
class Shape9 extends i0.VersionedTable {
Shape9({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get alias =>
columnsByName['alias']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get content =>
columnsByName['content']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get accountId =>
columnsByName['account_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<String> _column_16(String aliasedName) =>
i1.GeneratedColumn<String>('alias', aliasedName, false,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'));
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
case 1:
final schema = Schema2(database: database);
final migrator = i1.Migrator(database, schema);
await from1To2(migrator, schema);
return 2;
case 2:
final schema = Schema3(database: database);
final migrator = i1.Migrator(database, schema);
await from2To3(migrator, schema);
return 3;
case 3:
final schema = Schema4(database: database);
final migrator = i1.Migrator(database, schema);
await from3To4(migrator, schema);
return 4;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
};
}
i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
}) =>
i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
from2To3: from2To3,
from3To4: from3To4,
));

View 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
View File

@ -0,0 +1,16 @@
import 'package:drift/drift.dart';
class SnLocalKeyPair extends Table {
TextColumn get id => text()();
IntColumn get accountId => integer()();
TextColumn get publicKey => text()();
TextColumn get privateKey => text().nullable()();
BoolColumn get isActive => boolean().withDefault(Constant(false))();
@override
Set<Column<Object>> get primaryKey => {id};
}

45
lib/database/realm.dart Normal file
View File

@ -0,0 +1,45 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:surface/types/realm.dart';
class SnRealmConverter extends TypeConverter<SnRealm, String>
with JsonTypeConverter2<SnRealm, String, Map<String, Object?>> {
const SnRealmConverter();
@override
SnRealm fromSql(String fromDb) {
return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
}
@override
String toSql(SnRealm value) {
return jsonEncode(toJson(value));
}
@override
SnRealm fromJson(Map<String, Object?> json) {
return SnRealm.fromJson(json);
}
@override
Map<String, Object?> toJson(SnRealm value) {
return value.toJson();
}
}
@TableIndex(name: 'idx_realm_alias', columns: {#alias})
@TableIndex(name: 'idx_realm_account', columns: {#accountId})
class SnLocalRealm extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get alias => text().unique()();
TextColumn get content => text().map(const SnRealmConverter())();
IntColumn get accountId => integer()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get cacheExpiredAt => dateTime()();
}

74
lib/database/sticker.dart Normal file
View 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
View File

@ -0,0 +1,10 @@
import 'package:talker/talker.dart';
final logging = Talker(
settings: TalkerSettings(
enabled: true,
useHistory: true,
maxHistoryItems: 1000,
useConsoleLogs: true,
),
);

View File

@ -3,6 +3,7 @@ import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'dart:ui'; import 'dart:ui';
import 'package:audioplayers/audioplayers.dart';
import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:croppy/croppy.dart'; import 'package:croppy/croppy.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
@ -12,18 +13,22 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.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:hotkey_manager/hotkey_manager.dart'; import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
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:styled_widget/styled_widget.dart';
import 'package:surface/firebase_options.dart'; import 'package:surface/firebase_options.dart';
import 'package:surface/logger.dart';
import 'package:surface/providers/channel.dart'; import 'package:surface/providers/channel.dart';
import 'package:surface/providers/chat_call.dart'; import 'package:surface/providers/chat_call.dart';
import 'package:surface/providers/config.dart'; import 'package:surface/providers/config.dart';
import 'package:surface/providers/database.dart';
import 'package:surface/providers/keypair.dart';
import 'package:surface/providers/link_preview.dart'; import 'package:surface/providers/link_preview.dart';
import 'package:surface/providers/navigation.dart'; import 'package:surface/providers/navigation.dart';
import 'package:surface/providers/notification.dart'; import 'package:surface/providers/notification.dart';
@ -31,24 +36,27 @@ 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';
import 'package:surface/providers/translation.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/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:surface/widgets/menu_bar.dart';
import 'package:surface/widgets/version_label.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_android/image_picker_android.dart';
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
import 'package:local_notifier/local_notifier.dart';
@pragma('vm:entry-point') @pragma('vm:entry-point')
void appBackgroundDispatcher() { void appBackgroundDispatcher() {
@ -67,13 +75,40 @@ void appBackgroundDispatcher() {
}); });
} }
// Desktop size tools
Future<Size> _getSavedWindowSize() async {
final prefs = await SharedPreferences.getInstance();
String? sizeString = prefs.getString(kAppWindowSize);
if (sizeString != null) {
List<String> parts = sizeString.split('x');
if (parts.length == 2) {
double? width = double.tryParse(parts[0]);
double? height = double.tryParse(parts[1]);
if (width != null && height != null) {
return Size(width, height);
}
}
}
return const Size(1280, 720); // Default size
}
Future<void> _saveWindowSize() async {
final prefs = await SharedPreferences.getInstance();
final size = appWindow.size;
await prefs.setString(kAppWindowSize, '${size.width}x${size.height}');
}
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
final Size savedSize = await _getSavedWindowSize();
doWhenWindowReady(() { doWhenWindowReady(() {
appWindow.minSize = Size(480, 640); appWindow.minSize = Size(480, 640);
appWindow.size = Size(1280, 720); appWindow.size = savedSize;
appWindow.alignment = Alignment.center; appWindow.alignment = Alignment.center;
appWindow.show(); appWindow.show();
}); });
@ -81,26 +116,17 @@ void main() async {
await EasyLocalization.ensureInitialized(); await EasyLocalization.ensureInitialized();
await Hive.initFlutter(); if (!kIsWeb && !Platform.isLinux) {
Hive.registerAdapter(SnChannelImplAdapter());
Hive.registerAdapter(SnRealmImplAdapter());
Hive.registerAdapter(SnChannelMemberImplAdapter());
Hive.registerAdapter(SnChatMessageImplAdapter());
if (kIsWeb && !Platform.isLinux) {
await Firebase.initializeApp( await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform, options: DefaultFirebaseOptions.currentPlatform);
);
} }
GoRouter.optionURLReflectsImperativeAPIs = true; GoRouter.optionURLReflectsImperativeAPIs = true;
usePathUrlStrategy(); usePathUrlStrategy();
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
Workmanager().initialize( Workmanager()
appBackgroundDispatcher, .initialize(appBackgroundDispatcher, isInDebugMode: kDebugMode);
isInDebugMode: kDebugMode,
);
if (Platform.isAndroid) { if (Platform.isAndroid) {
Workmanager().registerPeriodicTask( Workmanager().registerPeriodicTask(
"widget-update-random-post", "widget-update-random-post",
@ -113,7 +139,8 @@ void main() async {
} }
if (!kIsWeb && Platform.isAndroid) { if (!kIsWeb && Platform.isAndroid) {
final ImagePickerPlatform imagePickerImplementation = ImagePickerPlatform.instance; final ImagePickerPlatform imagePickerImplementation =
ImagePickerPlatform.instance;
if (imagePickerImplementation is ImagePickerAndroid) { if (imagePickerImplementation is ImagePickerAndroid) {
imagePickerImplementation.useAndroidPhotoPicker = true; imagePickerImplementation.useAndroidPhotoPicker = true;
} }
@ -134,13 +161,16 @@ class SolianApp extends StatelessWidget {
Locale('en', 'US'), Locale('en', 'US'),
Locale('zh', 'CN'), Locale('zh', 'CN'),
Locale('zh', 'TW'), Locale('zh', 'TW'),
Locale('zh', 'HK'), Locale('zh', 'HK')
], ],
fallbackLocale: Locale('en', 'US'), fallbackLocale: Locale('en', 'US'),
useFallbackTranslations: true, useFallbackTranslations: true,
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)),
@ -155,15 +185,18 @@ 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)),
ChangeNotifierProvider(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)),
Provider(create: (ctx) => SnTranslator()),
// Additional helper layer // Additional helper layer
Provider(create: (ctx) => SpecialDayProvider(ctx)), Provider(create: (ctx) => SpecialDayProvider(ctx)),
@ -223,19 +256,23 @@ class _AppSplashScreen extends StatefulWidget {
} }
class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
bool _isBusy = false;
String _phaseText = 'appInitStarting';
void _tryRequestRating() async { void _tryRequestRating() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
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 {
@ -250,28 +287,38 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
final localVersionString = '${info.version}+${info.buildNumber}'; final localVersionString = '${info.version}+${info.buildNumber}';
final resp = await Dio( final resp = await Dio(
BaseOptions( BaseOptions(
sendTimeout: const Duration(seconds: 60), sendTimeout: const Duration(seconds: 60),
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?['tag_name'] ?? '0.0.0+0';
final remoteVersionString = (resp.data as List).firstOrNull?['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');
} }
} }
void _setPhaseText(String text) {
_phaseText = 'appInit${text.capitalize()}'.tr();
if (mounted) setState(() {});
}
Future<void> _initialize() async { Future<void> _initialize() async {
try { try {
final cfg = context.read<ConfigProvider>(); final cfg = context.read<ConfigProvider>();
@ -284,22 +331,52 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
// The Network initialization must be done after the HomeWidget initialization // The Network initialization must be done after the HomeWidget initialization
// The Network initialization will save the server url to the HomeWidget // The Network initialization will save the server url to the HomeWidget
// The Network initialization will also save initialize the Config, so it not need to be initialized again // The Network initialization will also save initialize the Config, so it not need to be initialized again
_setPhaseText('network');
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
await sn.initializeUserAgent(); await sn.initializeUserAgent();
await sn.setConfigWithNative(); await sn.setConfigWithNative();
if (!mounted) return; if (!mounted) return;
_setPhaseText('userdata');
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
await ua.initialize(); await ua.initialize();
if (!mounted) return; if (!mounted) return;
_setPhaseText('websocket');
final ws = context.read<WebSocketProvider>(); final ws = context.read<WebSocketProvider>();
await ws.tryConnect(); await ws.tryConnect();
if (!mounted) return; try {
final notify = context.read<NotificationProvider>(); if (!mounted) return;
notify.listen(); _setPhaseText('keyPair');
await notify.registerPushNotifications(); final kp = context.read<KeyPairProvider>();
if (!mounted) return; kp.reloadActive();
final sticker = context.read<SnStickerProvider>(); kp.listen();
await sticker.listStickerEagerly(); } catch (_) {}
if (ua.isAuthorized) {
if (!mounted) return;
_setPhaseText('notification');
final notify = context.read<NotificationProvider>();
notify.listen();
try {
notify.registerPushNotifications();
} catch (_) {}
if (!mounted) return;
_setPhaseText('stickers');
final sticker = context.read<SnStickerProvider>();
await sticker.listSticker();
if (!mounted) return;
_setPhaseText('userDirectory');
final ud = context.read<UserDirectoryProvider>();
await ud.loadAccountCache();
if (!mounted) return;
_setPhaseText('realm');
final rm = context.read<SnRealmProvider>();
await rm.refreshAvailableRealms();
if (!mounted) return;
_setPhaseText('chat');
final ct = context.read<ChatChannelProvider>();
await ct.refreshAvailableChannels();
_setPhaseText('done');
_playIntro();
}
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
await context.showErrorDialog(err); await context.showErrorDialog(err);
@ -312,44 +389,59 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
Future<void> _hotkeyInitialization() async { Future<void> _hotkeyInitialization() async {
if (kIsWeb) return; if (kIsWeb) return;
// The quit key has been removed, and the logic of the quit key is moved to system menu bar activator.
if (Platform.isMacOS) {
HotKey quitHotKey = HotKey(
key: PhysicalKeyboardKey.keyQ,
modifiers: [HotKeyModifier.meta],
scope: HotKeyScope.inapp,
);
await hotKeyManager.register(quitHotKey, keyUpHandler: (_) {
_appLifecycleListener?.dispose();
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
});
}
} }
void _playIntro() async {
final cfg = context.read<ConfigProvider>();
if (!cfg.soundEffects) return;
final player = AudioPlayer(playerId: 'launch-done-player');
await player.play(AssetSource('audio/sfx/launch-done.mp3'), volume: 0.8);
player.onPlayerComplete.listen((_) {
player.dispose();
});
}
final Menu _appTrayMenu = Menu(
items: [
MenuItem(key: 'version_label', label: 'Solian', disabled: true),
MenuItem.separator(),
MenuItem.checkbox(
checked: false,
key: 'mute_notification',
label: 'trayMenuMuteNotification'.tr()),
MenuItem.separator(),
MenuItem(key: 'window_show', label: 'trayMenuShow'.tr()),
MenuItem(key: 'exit', label: 'trayMenuExit'.tr()),
],
);
Future<void> _trayInitialization() async { Future<void> _trayInitialization() async {
if (kIsWeb || Platform.isAndroid || Platform.isIOS) return; if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
final icon = Platform.isWindows ? 'assets/icon/tray-icon.ico' : 'assets/icon/tray-icon.png'; final icon = Platform.isWindows
? 'assets/icon/tray-icon.ico'
: 'assets/icon/tray-icon.png';
final appVersion = await PackageInfo.fromPlatform(); final appVersion = await PackageInfo.fromPlatform();
trayManager.addListener(this); trayManager.addListener(this);
await trayManager.setIcon(icon); await trayManager.setIcon(icon);
Menu menu = Menu( _appTrayMenu.items![0] = MenuItem(
items: [ key: 'version_label',
MenuItem( label: 'Solian ${appVersion.version}+${appVersion.buildNumber}',
key: 'version_label', disabled: true,
label: 'Solian ${appVersion.version}+${appVersion.buildNumber}',
disabled: true,
),
MenuItem.separator(),
MenuItem(
key: 'exit',
label: 'trayMenuExit'.tr(),
),
],
); );
await trayManager.setContextMenu(menu);
await trayManager.setContextMenu(_appTrayMenu);
}
Future<void> _notifyInitialization() async {
if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
await localNotifier.setup(
appName: 'Solian', shortcutPolicy: ShortcutPolicy.requireCreate);
} }
AppLifecycleListener? _appLifecycleListener; AppLifecycleListener? _appLifecycleListener;
@ -358,18 +450,20 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
void initState() { void initState() {
super.initState(); super.initState();
_isBusy = true;
if (!kIsWeb && !(Platform.isIOS || Platform.isAndroid)) { if (!kIsWeb && !(Platform.isIOS || Platform.isAndroid)) {
_appLifecycleListener = AppLifecycleListener( _appLifecycleListener =
onExitRequested: _onExitRequested, AppLifecycleListener(onExitRequested: _onExitRequested);
);
} }
_trayInitialization(); _trayInitialization();
_hotkeyInitialization(); _hotkeyInitialization();
_notifyInitialization();
_initialize().then((_) { _initialize().then((_) {
_postInitialization(); _postInitialization();
_tryRequestRating(); _tryRequestRating();
_checkForUpdate(); _checkForUpdate();
setState(() => _isBusy = false);
}); });
} }
@ -378,6 +472,16 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
return AppExitResponse.cancel; return AppExitResponse.cancel;
} }
void _quitApp() {
_saveWindowSize();
_appLifecycleListener?.dispose();
if (Platform.isWindows) {
appWindow.close();
} else {
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
}
}
@override @override
void onTrayIconMouseDown() { void onTrayIconMouseDown() {
if (Platform.isWindows) { if (Platform.isWindows) {
@ -401,9 +505,18 @@ 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(); _quitApp();
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
break; break;
} }
} }
@ -420,15 +533,73 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cfg = context.read<ConfigProvider>(); final cfg = context.read<ConfigProvider>();
return NotificationListener<SizeChangedLayoutNotification>( return AppSystemMenuBar(
onNotification: (notification) { onQuit: _quitApp,
WidgetsBinding.instance.addPostFrameCallback((_) { child: NotificationListener<SizeChangedLayoutNotification>(
cfg.calcDrawerSize(context); onNotification: (notification) {
}); WidgetsBinding.instance.addPostFrameCallback((_) {
return false; cfg.calcDrawerSize(context);
}, });
child: SizeChangedLayoutNotifier( return false;
child: widget.child, },
child: OrientationBuilder(
builder: (context, orientation) {
final cfg = context.read<ConfigProvider>();
WidgetsBinding.instance.addPostFrameCallback((_) {
cfg.calcDrawerSize(context);
});
Future.delayed(const Duration(milliseconds: 300), () {
if (context.mounted) {
cfg.calcDrawerSize(context);
}
});
return SizeChangedLayoutNotifier(
child: _isBusy
? Material(
key: Key('app-splash-screen-$_isBusy'),
child: Stack(
children: [
Container(
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/icon/kanban-1st.jpg'),
fit: BoxFit.cover,
opacity: 0.1,
),
color: Theme.of(context).colorScheme.surface,
backgroundBlendMode: BlendMode.darken,
),
),
Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 240),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/icon/icon.png',
width: 64,
height: 64,
color:
Theme.of(context).colorScheme.onSurface,
),
Text('Solar Network').bold(),
AppVersionLabel(),
Gap(8),
Text(_phaseText, textAlign: TextAlign.center),
Gap(16),
const LinearProgressIndicator(),
],
),
),
),
],
),
)
: widget.child,
);
},
),
), ),
); );
} }

View File

@ -1,48 +1,75 @@
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 { final List<SnChannel> _availableChannels = List.empty(growable: true);
await Hive.openBox<SnChannel>(kChatChannelBoxName);
}
Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async { List<SnChannel> get availableChannels => _availableChannels;
if (_channelBox == null) return;
await _channelBox!.putAll({ Future<void> refreshAvailableChannels() async {
for (final channel in channels) channel.key: channel, final stream = fetchChannels();
stream.listen((ele) {
_availableChannels.clear();
_availableChannels.addAll(ele);
notifyListeners();
}); });
} }
void addAvailableChannel(SnChannel channel) {
_availableChannels.add(channel);
notifyListeners();
}
Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async {
await Future.wait(
channels.map(
(ele) => _dt.db.snLocalChatChannel.insertOne(
SnLocalChatChannelCompanion.insert(
id: Value(ele.id),
alias: ele.key,
content: ele,
createdAt: Value(ele.createdAt),
),
onConflict: DoUpdate(
(_) => SnLocalChatChannelCompanion.custom(
content: Constant(jsonEncode(ele.toJson())),
),
),
),
),
);
}
Future<List<SnChannel>> _fetchChannelsFromServer({ 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 +81,25 @@ class ChatChannelProvider extends ChangeNotifier {
/// It will use the local storage as much as possible. /// It will use the local storage as much as possible.
/// The alias should include the scope, formatted as `scope:alias`. /// The alias should include the scope, formatted as `scope:alias`.
Future<SnChannel> getChannel(String key) async { Future<SnChannel> getChannel(String key) async {
if (_channelBox != null) { final local = await (_dt.db.snLocalChatChannel.select()
final local = _channelBox!.get(key); ..where((e) => e.alias.equals(key)))
if (local != null) return local; .getSingleOrNull();
if (local != null) {
final out = local.content;
if (out.realmId != null) {
return out.copyWith(realm: await _rels.getRealm(out.realmId!));
} else {
return out;
}
} }
var resp = await _sn.client.get('/cgi/im/channels/$key'); var resp =
await _sn.client.get('/cgi/im/channels/${key.replaceAll(':', '/')}');
var out = SnChannel.fromJson(resp.data); var out = SnChannel.fromJson(resp.data);
// Preload realm of the channel // Preload realm of the channel
if (out.realmId != null) { if (out.realmId != null) {
resp = await _sn.client.get('/cgi/id/realms/${out.realmId}'); out = out.copyWith(realm: await _rels.getRealm(out.realmId!));
out = out.copyWith(realm: SnRealm.fromJson(resp.data));
} }
_saveChannelToLocal([out]); _saveChannelToLocal([out]);
@ -77,66 +111,119 @@ class ChatChannelProvider extends ChangeNotifier {
/// And the second time is when the data was fetched from the server. /// And the second time is when the data was fetched from the server.
/// But there is some exception that will only cause one of them to be emitted. /// But there is some exception that will only cause one of them to be emitted.
/// Like the local storage is broken or the server is down. /// Like the local storage is broken or the server is down.
Stream<List<SnChannel>> fetchChannels() async* { Stream<List<SnChannel>> fetchChannels(
if (_channelBox != null) yield _channelBox!.values.toList(); {bool noRemote = false, bool noLocal = false}) async* {
if (!noLocal) {
var resp = await _sn.client.get('/cgi/id/realms/me/available'); final local = await (_dt.db.snLocalChatChannel.select()
final realms = List<SnRealm>.from( ..orderBy([
resp.data?.map((e) => SnRealm.fromJson(e)) ?? [], (e) =>
); OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc)
final realmMap = { ]))
for (final realm in realms) realm.alias: realm, .get();
}; final out = local.map((e) => e.content).toList();
for (var idx = 0; idx < out.length; idx++) {
final scopeToFetch = {'global', ...realms.map((e) => e.alias)}; final channel = out[idx];
if (channel.realmId != null) {
final List<SnChannel> result = List.empty(growable: true); out[idx] = out[idx].copyWith(
final directMessages = await _fetchChannelsFromServer( realm: await _rels.getRealm(channel.realmId!),
scope: scopeToFetch.first, );
direct: true, }
); }
result.addAll(directMessages); yield out;
final nonBelongsChannels = await _fetchChannelsFromServer(
scope: scopeToFetch.first,
direct: false,
);
result.addAll(nonBelongsChannels);
for (final scope in scopeToFetch.skip(1)) {
final channel = await _fetchChannelsFromServer(
scope: scope,
direct: false,
doNotSave: true,
);
final out = channel.map((ele) => ele.copyWith(realm: realmMap[scope]));
_saveChannelToLocal(out);
result.addAll(out);
} }
if (noRemote) return;
final List<SnChannel> result = List.empty(growable: true);
final channels = await _fetchChannelsFromServer();
for (var idx = 0; idx < channels.length; idx++) {
final channel = channels[idx];
if (channel.realmId != null) {
channels[idx] = channels[idx].copyWith(
realm: await _rels.getRealm(channel.realmId!),
);
}
}
result.addAll(channels);
yield result; yield result;
} }
Future<List<SnChatMessage>> getLastMessages( Future<List<SnChatMessage>> getLastMessages(
Iterable<SnChannel> channels, Iterable<SnChannel> channels,
) async { ) async {
final result = List<SnChatMessage>.empty(growable: true); final result = List<Future<SnLocalChatMessageData?>>.empty(growable: true);
for (final channel in channels) { for (final channel in channels) {
final channelBox = await Hive.openBox<SnChatMessage>( final out = (_dt.db.snLocalChatMessage.select()
'${ChatMessageController.kChatMessageBoxPrefix}${channel.id}', ..where((e) => e.channelId.equals(channel.id))
); ..orderBy([
final lastMessage = (e) =>
channelBox.isNotEmpty ? channelBox.values.reduce((a, b) => a.createdAt.isAfter(b.createdAt) ? a : b) : null; OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc)
if (lastMessage != null) result.add(lastMessage); ])
channelBox.close(); ..limit(1))
.getSingleOrNull();
result.add(out);
} }
await _ud.listAccount(result.map((ele) => ele.sender.accountId).toSet()); final out = (await Future.wait(result))
return result; .where((e) => e != null)
.map((e) => e!.content)
.toList();
await _ud.listAccount(out.map((ele) => ele.sender.accountId).toSet());
return out;
} }
@override Future<void> _saveMemberToLocal(Iterable<SnChannelMember> members) async {
void dispose() { final queries = members.map((ele) {
_channelBox?.close(); return _dt.db.snLocalChannelMember.insertOne(
super.dispose(); SnLocalChannelMemberCompanion.insert(
id: Value(ele.id),
channelId: ele.channelId,
accountId: ele.accountId,
content: ele,
cacheExpiredAt: DateTime.now().add(const Duration(days: 7)),
),
onConflict: DoUpdate(
(_) => SnLocalChannelMemberCompanion.custom(
content: Constant(jsonEncode(ele.toJson())),
cacheExpiredAt:
Constant(DateTime.now().add(const Duration(days: 7))),
),
),
);
});
await Future.wait(queries);
}
Future<void> removeLocalChannel(SnChannel channel) async {
await _dt.db.transaction(() async {
await (_dt.db.snLocalChannelMember.delete()
..where((e) => e.channelId.equals(channel.id)))
.go();
await (_dt.db.snLocalChatChannel.delete()
..where((e) => e.id.equals(channel.id)))
.go();
await (_dt.db.snLocalChatMessage.delete()
..where((e) => e.channelId.equals(channel.id)))
.go();
});
}
Future<void> updateChannelProfile(SnChannelMember member) {
return _saveMemberToLocal([member]);
}
Future<SnChannelMember> getChannelProfile(SnChannel channel) async {
if (_ua.user == null) throw Exception('User not logged in');
final local = await (_dt.db.snLocalChannelMember.select()
..where((e) => e.channelId.equals(channel.id))
..where((e) => e.accountId.equals(_ua.user!.id)))
.getSingleOrNull();
if (local != null) {
return local.content;
}
final resp = await _sn.client.get('/cgi/im/channels/${channel.keyPath}/me');
final out = SnChannelMember.fromJson(resp.data);
_saveMemberToLocal([out]);
return out;
} }
} }

View File

@ -13,10 +13,17 @@ const kNetworkServerStoreKey = 'app_server_url';
const kAppbarTransparentStoreKey = 'app_bar_transparent'; const kAppbarTransparentStoreKey = 'app_bar_transparent';
const kAppBackgroundStoreKey = 'app_has_background'; const kAppBackgroundStoreKey = 'app_has_background';
const kAppColorSchemeStoreKey = 'app_color_scheme'; const kAppColorSchemeStoreKey = 'app_color_scheme';
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 kAppMixedFeed = 'app_mixed_feed';
const kAppAutoTranslate = 'app_auto_translate';
const kAppHideBottomNav = 'app_hide_bottom_nav';
const kAppSoundEffects = 'app_sound_effects';
const kAppAprilFoolFeatures = 'app_april_fool_features';
const kAppWindowSize = 'app_window_size';
const Map<String, FilterQuality> kImageQualityLevel = { const Map<String, FilterQuality> kImageQualityLevel = {
'settingsImageQualityLowest': FilterQuality.none, 'settingsImageQualityLowest': FilterQuality.none,
@ -39,48 +46,97 @@ class ConfigProvider extends ChangeNotifier {
} }
bool drawerIsCollapsed = false; bool drawerIsCollapsed = false;
bool drawerIsExpanded = false;
void calcDrawerSize(BuildContext context, {bool withMediaQuery = false}) { void calcDrawerSize(BuildContext context, {bool withMediaQuery = false}) {
bool newDrawerIsCollapsed = false; bool newDrawerIsCollapsed = false;
bool newDrawerIsExpanded = false;
if (withMediaQuery) { if (withMediaQuery) {
newDrawerIsCollapsed = MediaQuery.of(context).size.width < 600; newDrawerIsCollapsed = MediaQuery.of(context).size.width < 600;
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);
newDrawerIsExpanded = rpb.largerThan(TABLET)
? (prefs.getBool(kAppDrawerPreferCollapse) ?? false)
? false
: true
: false;
} }
if (newDrawerIsExpanded != drawerIsExpanded || newDrawerIsCollapsed != drawerIsCollapsed) { if (newDrawerIsCollapsed != drawerIsCollapsed) {
drawerIsExpanded = newDrawerIsExpanded;
drawerIsCollapsed = newDrawerIsCollapsed; drawerIsCollapsed = newDrawerIsCollapsed;
notifyListeners(); notifyListeners();
} }
} }
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;
}
bool get mixedFeed {
return prefs.getBool(kAppMixedFeed) ?? true;
}
bool get autoTranslate {
return prefs.getBool(kAppAutoTranslate) ?? false;
}
bool get hideBottomNav {
return prefs.getBool(kAppHideBottomNav) ?? false;
}
bool get aprilFoolFeatures {
return prefs.getBool(kAppAprilFoolFeatures) ?? true;
}
bool get soundEffects {
return prefs.getBool(kAppSoundEffects) ?? true;
}
set soundEffects(bool value) {
prefs.setBool(kAppSoundEffects, value);
notifyListeners();
}
set aprilFoolFeatures(bool value) {
prefs.setBool(kAppAprilFoolFeatures, value);
notifyListeners();
}
set hideBottomNav(bool value) {
prefs.setBool(kAppHideBottomNav, value);
notifyListeners();
}
set autoTranslate(bool value) {
prefs.setBool(kAppAutoTranslate, value);
notifyListeners();
}
set mixedFeed(bool value) {
prefs.setBool(kAppMixedFeed, value);
notifyListeners();
}
set realmCompactView(bool value) {
prefs.setBool(kAppRealmCompactView, value);
notifyListeners();
}
set serverUrl(String url) { 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();
} }
} }

View 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
View 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;
}
}

View File

@ -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;
} }
} }

View File

@ -5,6 +5,20 @@ import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
class AppNavListItem {
final String title;
final String subtitle;
final String screen;
final IconData icon;
const AppNavListItem({
required this.title,
required this.subtitle,
required this.screen,
required this.icon,
});
}
class AppNavDestination { class AppNavDestination {
final String label; final String label;
final String screen; final String screen;
@ -24,13 +38,10 @@ class NavigationProvider extends ChangeNotifier {
int? get currentIndex => _currentIndex; int? get currentIndex => _currentIndex;
static const List<String> kShowBottomNavScreen = [ List<String> get showBottomNavScreen => destinations
'home', .where((ele) => ele.isPinned)
'explore', .map((ele) => ele.screen)
'account', .toList();
'album',
'chat',
];
static const List<AppNavDestination> kAllDestination = [ static const List<AppNavDestination> kAllDestination = [
AppNavDestination( AppNavDestination(
@ -48,11 +59,6 @@ class NavigationProvider extends ChangeNotifier {
screen: 'chat', screen: 'chat',
label: 'screenChat', label: 'screenChat',
), ),
AppNavDestination(
icon: Icon(Symbols.account_circle, weight: 400, opticalSize: 20),
screen: 'account',
label: 'screenAccount',
),
AppNavDestination( AppNavDestination(
icon: Icon(Symbols.group, weight: 400, opticalSize: 20), icon: Icon(Symbols.group, weight: 400, opticalSize: 20),
screen: 'realm', screen: 'realm',
@ -64,31 +70,22 @@ class NavigationProvider extends ChangeNotifier {
label: 'screenNews', label: 'screenNews',
), ),
AppNavDestination( AppNavDestination(
icon: Icon(Symbols.photo_library, weight: 400, opticalSize: 20), icon: Icon(Symbols.settings, weight: 400, opticalSize: 20),
screen: 'album', screen: 'settings',
label: 'screenAlbum', label: 'screenSettings',
),
AppNavDestination(
icon: Icon(Symbols.diversity_4, weight: 400, opticalSize: 20),
screen: 'friend',
label: 'screenFriend',
),
AppNavDestination(
icon: Icon(Symbols.notifications, weight: 400, opticalSize: 20),
screen: 'notification',
label: 'screenNotification',
), ),
]; ];
static const List<String> kDefaultPinnedDestination = [ static const List<String> kDefaultPinnedDestination = [
'home', 'home',
'explore', 'explore',
'chat', 'chat',
'account', 'realm',
]; ];
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 +114,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();

View File

@ -1,12 +1,15 @@
import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:audioplayers/audioplayers.dart';
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';
@ -20,6 +23,8 @@ class NotificationProvider extends ChangeNotifier {
late final WebSocketProvider _ws; late final WebSocketProvider _ws;
late final ConfigProvider _cfg; late final ConfigProvider _cfg;
final AudioPlayer _notifySoundPlayer = AudioPlayer(playerId: 'notify-sound');
NotificationProvider(BuildContext context) { NotificationProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>(); _sn = context.read<SnNetworkProvider>();
_ua = context.read<UserProvider>(); _ua = context.read<UserProvider>();
@ -46,11 +51,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,38 +67,81 @@ 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( try {
'/cgi/id/notifications/subscription', await _sn.client.post(
data: { '/cgi/id/notifications/subscription',
'provider': provider, data: {
'device_token': token, 'provider': provider,
'device_id': deviceUuid, 'device_token': token,
}, 'device_id': deviceUuid
); },
);
} catch (err) {
logging.error(
'[Push Notification] Unable to register push notifications: $err');
}
} }
int showingCount = 0; int showingCount = 0;
int showingTrayCount = 0; int showingTrayCount = 0;
List<SnNotification> notifications = List.empty(growable: true); List<SnNotification> notifications = List.empty(growable: true);
int? skippableNotifyChannel;
bool isMuted = false;
void listen() { void listen() {
_ws.pk.stream.listen((event) { _ws.pk.stream.listen((event) {
if (event.method == 'notifications.new') { if (event.method == 'notifications.new') {
final notification = SnNotification.fromJson(event.payload!); final notification = SnNotification.fromJson(event.payload!);
final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true;
if (doHaptic) HapticFeedback.mediumImpact();
// April fool notification sfx
if (_cfg.prefs.getBool(kAppAprilFoolFeatures) ?? true) {
final now = DateTime.now();
if (now.day == 1 && now.month == 4) {
_notifySoundPlayer.play(
AssetSource('audio/notify/metal-pipe.mp3'),
volume: 0.6,
);
}
}
if (notification.topic == 'messaging.message' &&
skippableNotifyChannel != null) {
if (notification.metadata['channel_id'] != null &&
notification.metadata['channel_id'] == skippableNotifyChannel) {
return;
}
}
if (showingCount < 0) showingCount = 0; if (showingCount < 0) showingCount = 0;
showingCount++; showingCount++;
showingTrayCount++; showingTrayCount++;
notifications.add(notification); notifications.add(notification);
Future.delayed(const Duration(seconds: 3), () { Future.delayed(const Duration(seconds: 5), () {
if (showingCount >= 0) showingCount--; if (showingCount >= 0) showingCount--;
notifyListeners(); notifyListeners();
}); });
notifyListeners(); notifyListeners();
updateTray(); updateTray();
final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true;
if (doHaptic) HapticFeedback.mediumImpact(); if (!kIsWeb && !isMuted) {
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
LocalNotification notify = LocalNotification(
title: notification.title,
subtitle: notification.subtitle,
body: notification.body,
);
notify.onClick = () {
appWindow.show();
};
notify.show();
}
}
} }
}); });
} }

View File

@ -2,19 +2,23 @@ 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/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 { Future<SnPoll> _fetchPoll(int id) async {
@ -24,6 +28,7 @@ class SnPostContentProvider {
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) {
@ -37,34 +42,50 @@ class SnPostContentProvider {
repostTo: await _preloadRelatedDataSingle(out[i].repostTo!), repostTo: await _preloadRelatedDataSingle(out[i].repostTo!),
); );
} }
if (out[i].publisher.type == 0) {
uids.add(out[i].publisher.accountId);
}
} }
final attachments = await _attach.getMultiple(rids.toList()); final attachments = await _attach.getMultiple(rids.toList());
for (var i = 0; i < out.length; i++) { for (var i = 0; i < out.length; i++) {
SnPoll? poll; SnPoll? poll;
SnRealm? realm;
if (out[i].pollId != null) { if (out[i].pollId != null) {
poll = await _fetchPoll(out[i].pollId!); 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
attachments: attachments.where((ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false).toList(), .where((ele) => ele?.rid == out[i].body['thumbnail'])
video: attachments.where((ele) => ele?.rid == out[i].body['video']).firstOrNull, .firstOrNull,
attachments: attachments
.where((ele) =>
out[i].body['attachments']?.contains(ele?.rid) ?? false)
.toList(),
video: attachments
.where((ele) => ele?.rid == out[i].body['video'])
.firstOrNull,
poll: poll, poll: poll,
realm: realm,
), ),
); );
} }
await _ud.listAccount( uids.addAll(
attachments.where((ele) => ele != null).map((ele) => ele!.accountId).toSet(), attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
); 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']);
@ -77,23 +98,42 @@ class SnPostContentProvider {
repostTo: await _preloadRelatedDataSingle(out.repostTo!), repostTo: await _preloadRelatedDataSingle(out.repostTo!),
); );
} }
if (out.publisher.type == 0) {
uids.add(out.publisher.accountId);
}
final attachments = await _attach.getMultiple(rids.toList()); final attachments = await _attach.getMultiple(rids.toList());
SnPoll? poll; SnPoll? poll;
SnRealm? realm;
if (out.pollId != null) { if (out.pollId != null) {
poll = await _fetchPoll(out.pollId!); 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
attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(), .where((ele) => ele?.rid == out.body['thumbnail'])
video: attachments.where((ele) => ele?.rid == out.body['video']).firstOrNull, .firstOrNull,
attachments: attachments
.where(
(ele) => out.body['attachments']?.contains(ele?.rid) ?? false)
.toList(),
video: attachments
.where((ele) => ele?.rid == out.body['video'])
.firstOrNull,
poll: poll, poll: poll,
realm: realm,
), ),
); );
uids.addAll(
attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
await _ud.listAccount(uids);
return out; return out;
} }
@ -105,6 +145,36 @@ class SnPostContentProvider {
return out; return out;
} }
Future<List<SnFeedEntry>> getFeed({int take = 20, DateTime? cursor}) async {
final resp =
await _sn.client.get('/cgi/co/recommendations/feed', queryParameters: {
'take': take,
if (cursor != null) 'cursor': cursor.toUtc().millisecondsSinceEpoch,
});
final List<SnFeedEntry> out =
List.from(resp.data.map((ele) => SnFeedEntry.fromJson(ele)));
List<SnPost> posts = List.empty(growable: true);
for (var idx = 0; idx < out.length; idx++) {
final ele = out[idx];
if (ele.type == 'interactive.post') {
posts.add(SnPost.fromJson(ele.data));
}
}
posts = await _preloadRelatedDataInBatch(posts);
var postsIdx = 0;
for (var idx = 0; idx < out.length; idx++) {
final ele = out[idx];
if (ele.type == 'interactive.post') {
out[idx] = ele.copyWith(data: posts[postsIdx].toJson());
postsIdx++;
}
}
return out;
}
Future<(List<SnPost>, int)> listPosts({ Future<(List<SnPost>, int)> listPosts({
int take = 10, int take = 10,
int offset = 0, int offset = 0,
@ -112,15 +182,27 @@ class SnPostContentProvider {
String? author, String? author,
Iterable<String>? categories, Iterable<String>? categories,
Iterable<String>? tags, Iterable<String>? tags,
String? realm,
String? channel,
bool isDraft = false,
bool isShuffle = false,
}) async { }) async {
final resp = await _sn.client.get('/cgi/co/posts', queryParameters: { final resp = await _sn.client.get(
'take': take, isShuffle
'offset': offset, ? '/cgi/co/recommendations/shuffle'
if (type != null) 'type': type, : '/cgi/co/posts${isDraft ? '/drafts' : ''}',
if (author != null) 'author': author, queryParameters: {
if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','), 'take': take,
if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','), 'offset': offset,
}); if (type != null) 'type': type,
if (author != null) 'author': author,
if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
if (categories?.isNotEmpty ?? false)
'categories': categories!.join(','),
if (realm != null) 'realm': realm,
if (channel != null) 'channel': channel,
},
);
final List<SnPost> out = await _preloadRelatedDataInBatch( 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)) ?? []),
); );
@ -133,7 +215,8 @@ class SnPostContentProvider {
int take = 10, int take = 10,
int offset = 0, int offset = 0,
}) async { }) async {
final resp = await _sn.client.get('/cgi/co/posts/$parentId/replies', queryParameters: { final resp = await _sn.client
.get('/cgi/co/posts/$parentId/replies', queryParameters: {
'take': take, 'take': take,
'offset': offset, 'offset': offset,
}); });
@ -172,4 +255,9 @@ class SnPostContentProvider {
); );
return out; return out;
} }
Future<SnPost> completePostData(SnPost post) async {
final out = await _preloadRelatedDataSingle(post);
return out;
}
} }

View File

@ -1,11 +1,14 @@
import 'dart:collection'; import 'dart:collection';
import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:typed_data';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:drift/drift.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:cross_file/cross_file.dart'; import 'package:cross_file/cross_file.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:surface/database/database.dart';
import 'package:surface/providers/database.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/attachment.dart'; import 'package:surface/types/attachment.dart';
@ -13,10 +16,12 @@ const kConcurrentUploadChunks = 5;
class SnAttachmentProvider { class SnAttachmentProvider {
late final SnNetworkProvider _sn; late final SnNetworkProvider _sn;
late final DatabaseProvider _dt;
final Map<String, SnAttachment> _cache = {}; final Map<String, SnAttachment> _cache = {};
SnAttachmentProvider(BuildContext context) { SnAttachmentProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>(); _sn = context.read<SnNetworkProvider>();
_dt = context.read<DatabaseProvider>();
} }
void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) { void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) {
@ -28,20 +33,33 @@ class SnAttachmentProvider {
} }
Future<SnAttachment> getOne(String rid, {noCache = false}) async { Future<SnAttachment> getOne(String rid, {noCache = false}) async {
// In-memory cache
if (!noCache && _cache.containsKey(rid)) { if (!noCache && _cache.containsKey(rid)) {
return _cache[rid]!; return _cache[rid]!;
} }
// On-disk cache
final dbResp = await (_dt.db.snLocalAttachment.select()
..where((e) => e.rid.equals(rid))
..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())))
.getSingleOrNull();
if (dbResp != null) {
_cache[rid] = dbResp.content;
return dbResp.content;
}
// Remote server
final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta'); final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta');
final out = SnAttachment.fromJson(resp.data); final out = SnAttachment.fromJson(resp.data);
if (out.isAnalyzed) { if (out.isAnalyzed) {
_cache[rid] = out; _cache[rid] = out;
} }
_saveToLocal([out]);
return out; return out;
} }
Future<List<SnAttachment?>> getMultiple(List<String> rids, {noCache = false}) async { Future<List<SnAttachment?>> getMultiple(List<String> rids,
{bool noCache = false}) async {
// In-memory cache
final result = List<SnAttachment?>.filled(rids.length, null); final result = List<SnAttachment?>.filled(rids.length, null);
final Map<String, int> randomMapping = {}; final Map<String, int> randomMapping = {};
for (int i = 0; i < rids.length; i++) { for (int i = 0; i < rids.length; i++) {
@ -52,32 +70,55 @@ class SnAttachmentProvider {
result[i] = _cache[rid]!; result[i] = _cache[rid]!;
} }
} }
final pendingFetch = randomMapping.keys; var pendingFetch = randomMapping.keys;
// On-disk cache
if (pendingFetch.isNotEmpty) { if (pendingFetch.isEmpty) return result;
final resp = await _sn.client.get( if (!noCache) {
'/cgi/uc/attachments', final dbResp = await (_dt.db.snLocalAttachment.select()
queryParameters: { ..where((e) => e.rid.isIn(pendingFetch))
'take': pendingFetch.length, ..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())))
'id': pendingFetch.join(','), .get();
}, for (final item in dbResp) {
); if (item.content.isAnalyzed) {
final List<SnAttachment?> out = _cache[item.rid] = item.content;
resp.data['data'].map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e)).cast<SnAttachment?>().toList();
for (final item in out) {
if (item == null) continue;
if (item.isAnalyzed) {
_cache[item.rid] = item;
} }
result[randomMapping[item.rid]!] = item; result[randomMapping[item.rid]!] = item.content;
randomMapping.remove(item.rid);
} }
pendingFetch = randomMapping.keys;
} }
// Remote server
if (pendingFetch.isEmpty) return result;
final resp = await _sn.client.get(
'/cgi/uc/attachments',
queryParameters: {
'take': pendingFetch.length,
'id': pendingFetch.join(','),
},
);
final List<SnAttachment?> out = resp.data['data']
.map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e))
.cast<SnAttachment?>()
.toList();
for (final item in out) {
if (item == null) continue;
if (item.isAnalyzed) {
_cache[item.rid] = item;
}
result[randomMapping[item.rid]!] = item;
}
_saveToLocal(out.where((ele) => ele != null).cast());
return result; return result;
} }
static Map<String, String> mimetypeOverrides = {'mov': 'video/quicktime', 'mp4': 'video/mp4'}; static Map<String, String> mimetypeOverrides = {
'mov': 'video/quicktime',
'mp4': 'video/mp4',
'm4a': 'audio/mp4',
'apng': 'image/apng',
'webp': 'image/webp',
};
Future<SnAttachment> directUploadOne( Future<SnAttachment> directUploadOne(
Uint8List data, Uint8List data,
@ -89,8 +130,11 @@ class SnAttachmentProvider {
bool analyzeNow = false, bool analyzeNow = false,
}) async { }) async {
final filePayload = MultipartFile.fromBytes(data, filename: filename); final filePayload = MultipartFile.fromBytes(data, filename: filename);
final fileAlt = filename.contains('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename; final fileAlt = filename.contains('.')
final fileExt = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); ? filename.substring(0, filename.lastIndexOf('.'))
: filename;
final fileExt =
filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
String? mimetypeOverride; String? mimetypeOverride;
if (mimetype != null) { if (mimetype != null) {
@ -127,8 +171,11 @@ class SnAttachmentProvider {
Map<String, dynamic>? metadata, { Map<String, dynamic>? metadata, {
String? mimetype, String? mimetype,
}) async { }) async {
final fileAlt = filename.contains('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename; final fileAlt = filename.contains('.')
final fileExt = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); ? filename.substring(0, filename.lastIndexOf('.'))
: filename;
final fileExt =
filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
String? mimetypeOverride; String? mimetypeOverride;
if (mimetype == null && mimetypeOverrides.keys.contains(fileExt)) { if (mimetype == null && mimetypeOverrides.keys.contains(fileExt)) {
@ -146,7 +193,10 @@ class SnAttachmentProvider {
if (mimetypeOverride != null) 'mimetype': mimetypeOverride, if (mimetypeOverride != null) 'mimetype': mimetypeOverride,
}); });
return (SnAttachmentFragment.fromJson(resp.data['meta']), resp.data['chunk_size'] as int); return (
SnAttachmentFragment.fromJson(resp.data['meta']),
resp.data['chunk_size'] as int
);
} }
Future<dynamic> _chunkedUploadOnePart( Future<dynamic> _chunkedUploadOnePart(
@ -197,7 +247,10 @@ class SnAttachmentProvider {
(entry.value + 1) * chunkSize, (entry.value + 1) * chunkSize,
await file.length(), await file.length(),
); );
final data = Uint8List.fromList(await file.openRead(beginCursor, endCursor).expand((chunk) => chunk).toList()); final data = Uint8List.fromList(await file
.openRead(beginCursor, endCursor)
.expand((chunk) => chunk)
.toList());
final result = await _chunkedUploadOnePart( final result = await _chunkedUploadOnePart(
data, data,
@ -253,6 +306,31 @@ class SnAttachmentProvider {
'metadata': metadata ?? item.usermeta, 'metadata': metadata ?? item.usermeta,
'is_indexable': isIndexable ?? item.isIndexable, 'is_indexable': isIndexable ?? item.isIndexable,
}); });
return SnAttachment.fromJson(resp.data); final out = SnAttachment.fromJson(resp.data);
_saveToLocal([out]);
return out;
}
Future<void> _saveToLocal(Iterable<SnAttachment> out) async {
for (final ele in out) {
if (!ele.isAnalyzed || ele.destination == 0) continue;
await _dt.db.snLocalAttachment.insertOne(
SnLocalAttachmentCompanion.insert(
id: Value(ele.id),
rid: ele.rid,
uuid: ele.uuid,
content: ele,
accountId: ele.accountId,
cacheExpiredAt: DateTime.now().add(const Duration(hours: 1)),
),
onConflict: DoUpdate(
(_) => SnLocalAttachmentCompanion.custom(
content: Constant(jsonEncode(ele.toJson())),
cacheExpiredAt:
Constant(DateTime.now().add(const Duration(hours: 1))),
),
),
);
}
} }
} }

View File

@ -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,26 @@ 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';
enum ServiceStatus { operational, downgraded, failed }
const Map<String, String> kServicesName = {
'ai': 'Insights',
'co': 'Interactive',
're': 'Reader',
'im': 'Messaging',
'ma': 'Matrix',
'uc': 'Paperclip',
'wa': 'Wallet',
'id': 'Passport',
'pusher': 'Pusher',
};
const kNetworkServerDirectory = [ const kNetworkServerDirectory = [
('Solar Network', 'https://api.sn.solsynth.dev'), ('Solar Network', 'https://api.sn.solsynth.dev'),
@ -36,6 +52,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 +98,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 +119,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 +132,8 @@ class SnNetworkProvider {
}, },
), ),
); );
client.options.baseUrl = prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault; client.options.baseUrl =
prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
return client; return client;
} }
@ -119,7 +149,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 +159,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 +180,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 +220,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 +235,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 +273,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();

View File

@ -0,0 +1,90 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:surface/database/database.dart';
import 'package:surface/providers/database.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/realm.dart';
class SnRealmProvider extends ChangeNotifier {
late final SnNetworkProvider _sn;
late final DatabaseProvider _dt;
SnRealmProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
_dt = context.read<DatabaseProvider>();
}
final Map<String, SnRealm> _cache = {};
List<SnRealm> _availableRealms = List.empty(growable: true);
Future<void> refreshAvailableRealms() async {
_availableRealms = await listAvailableRealms();
}
List<SnRealm> get availableRealms => _availableRealms;
Future<List<SnRealm>> listAvailableRealms() async {
final resp = await _sn.client.get('/cgi/id/realms/me/available');
final out = List<SnRealm>.from(
resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
);
for (final realm in out) {
_cache[realm.alias] = realm;
_cache[realm.id.toString()] = realm;
}
_saveToLocal(out);
return out;
}
void addAvailableRealm(SnRealm realm) {
_availableRealms.add(realm);
notifyListeners();
}
Future<SnRealm> getRealm(dynamic aliasOrId) async {
if (_cache.containsKey(aliasOrId.toString())) {
return _cache[aliasOrId.toString()]!;
}
final localResp = await (_dt.db.snLocalRealm.select()
..where((e) =>
e.id.equals(aliasOrId is int ? aliasOrId : 0) |
e.alias.equals(aliasOrId.toString()))
..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())))
.getSingleOrNull();
if (localResp != null) {
_cache[localResp.content.id.toString()] = localResp.content;
_cache[localResp.content.alias] = localResp.content;
return localResp.content;
}
final resp = await _sn.client.get('/cgi/id/realms/$aliasOrId');
final out = SnRealm.fromJson(resp.data);
_cache[out.alias] = out;
_cache[out.id.toString()] = out;
_saveToLocal([out]);
return out;
}
Future<void> _saveToLocal(Iterable<SnRealm> out) async {
for (final ele in out) {
await _dt.db.snLocalRealm.insertOne(
SnLocalRealmCompanion.insert(
id: Value(ele.id),
alias: ele.alias,
content: ele,
accountId: ele.accountId,
cacheExpiredAt: DateTime.now().add(const Duration(hours: 1)),
),
onConflict: DoUpdate(
(_) => SnLocalRealmCompanion.custom(
content: Constant(jsonEncode(ele.toJson())),
cacheExpiredAt:
Constant(DateTime.now().add(const Duration(hours: 1))),
),
),
);
}
}
}

View File

@ -1,20 +1,27 @@
import 'dart:developer'; import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:surface/database/database.dart';
import 'package:surface/logger.dart';
import 'package:surface/providers/database.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/attachment.dart'; import 'package:surface/types/attachment.dart';
class SnStickerProvider { class SnStickerProvider {
late final SnNetworkProvider _sn; late final SnNetworkProvider _sn;
late final DatabaseProvider _dt;
final Map<String, SnSticker?> _cache = {}; final Map<String, SnSticker?> _cache = {};
final Map<int, List<SnSticker>> stickersByPack = {}; final Map<int, List<SnSticker>> stickersByPack = {};
List<SnSticker> get stickers => _cache.values.where((ele) => ele != null).cast<SnSticker>().toList(); List<SnSticker> get stickers =>
_cache.values.where((ele) => ele != null).cast<SnSticker>().toList();
SnStickerProvider(BuildContext context) { SnStickerProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>(); _sn = context.read<SnNetworkProvider>();
_dt = context.read<DatabaseProvider>();
} }
bool hasNotSticker(String alias) { bool hasNotSticker(String alias) {
@ -23,52 +30,103 @@ class SnStickerProvider {
void _cacheSticker(SnSticker sticker) { void _cacheSticker(SnSticker sticker) {
_cache['${sticker.pack.prefix}:${sticker.alias}'] = sticker; _cache['${sticker.pack.prefix}:${sticker.alias}'] = sticker;
if (stickersByPack[sticker.pack.id] == null) stickersByPack[sticker.pack.id] = List.empty(growable: true); if (stickersByPack[sticker.pack.id] == null) {
if (!stickersByPack[sticker.pack.id]!.contains(sticker)) stickersByPack[sticker.pack.id]!.add(sticker); stickersByPack[sticker.pack.id] = List.empty(growable: true);
}
if (!stickersByPack[sticker.pack.id]!.contains(sticker)) {
stickersByPack[sticker.pack.id]!.add(sticker);
}
}
void putSticker(Iterable<SnSticker> stickers) {
for (final ele in stickers) {
_cacheSticker(ele);
}
_saveStickerToLocal(stickers);
_saveStickerPackToLocal(stickers.map((ele) => ele.pack).toSet());
} }
Future<SnSticker?> lookupSticker(String alias) async { Future<SnSticker?> lookupSticker(String alias) async {
// In-memory cache
if (_cache.containsKey(alias)) { if (_cache.containsKey(alias)) {
return _cache[alias]; return _cache[alias];
} }
// On-disk cache
final localStickers = await (_dt.db.snLocalSticker.select()
..where((e) => e.fullAlias.equals(alias)))
.getSingleOrNull();
if (localStickers != null) {
_cache[alias] = localStickers.content;
return localStickers.content;
}
// Remote server
try { try {
final resp = await _sn.client.get('/cgi/uc/stickers/lookup/$alias'); final resp = await _sn.client.get('/cgi/uc/stickers/lookup/$alias');
final sticker = SnSticker.fromJson(resp.data); final sticker = SnSticker.fromJson(resp.data);
_cacheSticker(sticker); putSticker([sticker]);
return sticker; return sticker;
} catch (err) { } catch (err) {
_cache[alias] = null; _cache[alias] = null;
log('[Sticker] Failed to lookup sticker $alias: $err'); logging.warning('[Sticker] Failed to lookup sticker $alias', err);
} }
return null; return null;
} }
Future<void> listStickerEagerly() async { Future<void> listSticker() async {
var count = await listSticker(); final localPacks = await _dt.db.snLocalStickerPack.select().get();
for (var page = 1; count > 0; count -= 10) { final localStickers = await _dt.db.snLocalSticker.select().get();
await listSticker(page: page); final local = localStickers.map((ele) {
page++; return ele.content.copyWith(
pack: localPacks
.firstWhere((pk) => pk.content.id == ele.content.packId)
.content,
);
});
for (final sticker in local) {
_cacheSticker(sticker);
} }
}
Future<int> listSticker({int page = 0}) async {
try { try {
final resp = await _sn.client.get('/cgi/uc/stickers', queryParameters: { final resp = await _sn.client.get('/cgi/uc/stickers');
'take': 10,
'offset': page * 10,
});
final data = resp.data; final data = resp.data;
final stickers = List.from(data['data']).map((ele) => SnSticker.fromJson(ele)); final stickers = List.from(data).map((ele) => SnSticker.fromJson(ele));
for (final sticker in stickers) { for (final sticker in stickers) {
_cacheSticker(sticker); _cacheSticker(sticker);
} }
return data['count'] as int;
} catch (err) { } catch (err) {
log('[Sticker] Failed to list stickers: $err'); logging.error('[Sticker] Failed to list stickers...', err);
rethrow; rethrow;
} }
} }
Future<void> _saveStickerToLocal(Iterable<SnSticker> stickers) async {
await _dt.db.snLocalSticker.insertAll(
stickers.map(
(ele) => SnLocalStickerCompanion.insert(
id: Value(ele.id),
alias: ele.alias,
fullAlias: '${ele.pack.prefix}${ele.alias}',
content: ele,
createdAt: Value(ele.createdAt),
),
),
onConflict: DoNothing(),
);
}
Future<void> _saveStickerPackToLocal(Iterable<SnStickerPack> packs) async {
final queries = packs
.map(
(ele) => _dt.db.snLocalStickerPack.insertOne(
SnLocalStickerPackCompanion.insert(
id: Value(ele.id),
content: ele,
createdAt: Value(ele.createdAt),
),
onConflict: DoUpdate((_) => SnLocalStickerPackCompanion.custom(
content: Constant(jsonEncode(ele.toJson()))))),
)
.toList();
await Future.wait(queries);
}
} }

View File

@ -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();
}); });

View File

@ -0,0 +1,55 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:surface/logger.dart';
const kTranslateApiBaseUrl = 'https://translate.solsynth.dev';
class SnTranslator {
final Dio client = Dio(
BaseOptions(
baseUrl: kTranslateApiBaseUrl,
connectTimeout: Duration(seconds: 3),
sendTimeout: Duration(seconds: 3),
receiveTimeout: Duration(seconds: 3),
),
);
final Map<String, String> _cache = {};
Future<String> translate(
String text, {
required String to,
String from = 'auto',
bool skipCache = false,
}) async {
if (text.isEmpty) return text;
final cacheKey = md5.convert(utf8.encode('$text$from$to')).toString();
if (!skipCache && _cache.containsKey(cacheKey)) {
return _cache[cacheKey]!;
}
logging.info('[Translator] Translate $text from $from to $to');
final resp = await client.post(
'/translate',
data: {
'q': text,
'source': from,
'target': to,
'format': 'text',
},
);
if (resp.statusCode == 200) {
final out = resp.data['translatedText'];
if (out.isNotEmpty) {
logging.info('[Translator] Translated $text from $from to $to');
_cache[cacheKey] = out;
return out;
}
}
throw Exception('translate failed: $resp');
}
}

View File

@ -1,19 +1,44 @@
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 = {};
DateTime? _cacheExpiredAt;
Future<int> loadAccountCache({int max = 100}) async {
final out = await (_dt.db.snLocalAccount.select()..limit(max)).get();
for (final ele in out) {
_cache[ele.id] = ele.content;
_idCache[ele.name] = ele.id;
}
_cacheExpiredAt = DateTime.now().add(const Duration(hours: 1));
return out.length;
}
Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async { Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async {
// In-memory cache
if (_cacheExpiredAt != null && _cacheExpiredAt!.isBefore(DateTime.now())) {
_cache.clear();
_cacheExpiredAt = DateTime.now().add(const Duration(hours: 1));
} else {
_cacheExpiredAt ??= DateTime.now().add(const Duration(hours: 1));
}
final out = List<SnAccount?>.generate(id.length, (e) => null); final out = List<SnAccount?>.generate(id.length, (e) => null);
final plannedQuery = <int>{}; final plannedQuery = <int>{};
for (var idx = 0; idx < out.length; idx++) { for (var idx = 0; idx < out.length; idx++) {
@ -27,8 +52,30 @@ class UserDirectoryProvider {
plannedQuery.add(item); plannedQuery.add(item);
} }
} }
final resp = await _sn.client.get('/cgi/id/users', queryParameters: {'id': plannedQuery.join(',')}); // On-disk cache
final respDecoded = resp.data.map((e) => SnAccount.fromJson(e)).cast<SnAccount>().toList(); if (plannedQuery.isEmpty) return out;
final dbResp = await (_dt.db.snLocalAccount.select()
..where((e) => e.id.isIn(plannedQuery))
..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now()))
..limit(plannedQuery.length))
.get();
for (var idx = 0; idx < out.length; idx++) {
if (out[idx] != null) continue;
if (dbResp.length <= idx) {
break;
}
out[idx] = dbResp[idx].content;
_cache[dbResp[idx].id] = dbResp[idx].content;
_idCache[dbResp[idx].name] = dbResp[idx].id;
plannedQuery.remove(dbResp[idx].id);
}
// Remote server
_saveToLocal(out.where((ele) => ele != null).cast());
if (plannedQuery.isEmpty) return out;
final resp = await _sn.client
.get('/cgi/id/users', queryParameters: {'id': plannedQuery.join(',')});
final respDecoded =
resp.data.map((e) => SnAccount.fromJson(e)).cast<SnAccount>().toList();
var sideIdx = 0; var sideIdx = 0;
for (var idx = 0; idx < out.length; idx++) { for (var idx = 0; idx < out.length; idx++) {
if (out[idx] != null) continue; if (out[idx] != null) continue;
@ -40,17 +87,29 @@ class UserDirectoryProvider {
_idCache[respDecoded[sideIdx].name] = respDecoded[sideIdx].id; _idCache[respDecoded[sideIdx].name] = respDecoded[sideIdx].id;
sideIdx++; 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(
@ -58,16 +117,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);
}
} }

View File

@ -1,8 +1,9 @@
import 'dart:developer'; import 'dart:convert';
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,13 +31,40 @@ 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}');
} }
}); });
} }
Future<Map<String, dynamic>?> get atkClaims async {
final tk = (await atk);
if (tk == null) return null;
final atkParts = tk.split('.');
if (atkParts.length != 3) {
throw Exception('invalid format of access token');
}
var rawPayload = atkParts[1].replaceAll('-', '+').replaceAll('_', '/');
switch (rawPayload.length % 4) {
case 0:
break;
case 2:
rawPayload += '==';
break;
case 3:
rawPayload += '=';
break;
default:
throw Exception('illegal format of access token payload');
}
final b64 = utf8.fuse(base64Url);
return jsonDecode(b64.decode(rawPayload));
}
Future<SnAccount?> refreshUser() async { Future<SnAccount?> refreshUser() async {
if (!isAuthorized) return null;
final resp = await _sn.client.get('/cgi/id/users/me'); final resp = await _sn.client.get('/cgi/id/users/me');
final out = SnAccount.fromJson(resp.data); final out = SnAccount.fromJson(resp.data);
@ -48,7 +76,13 @@ class UserProvider extends ChangeNotifier {
} }
void logoutUser() async { void logoutUser() async {
_sn.clearTokenPair(); atkClaims.then((value) async {
if (value != null) {
await _sn.client.delete('/cgi/id/users/me/tickets/${value['sed']}');
logging.info('[Auth] Current session has been destroyed.');
}
_sn.clearTokenPair();
});
isAuthorized = false; isAuthorized = false;
user = null; user = null;
notifyListeners(); notifyListeners();

View File

@ -1,12 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_udid/flutter_udid.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:surface/logger.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/websocket.dart'; import 'package:surface/types/websocket.dart';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart';
class WebSocketProvider extends ChangeNotifier { class WebSocketProvider extends ChangeNotifier {
@ -30,7 +33,7 @@ class WebSocketProvider extends ChangeNotifier {
if (isConnected) return; if (isConnected) return;
if (!_ua.isAuthorized) return; if (!_ua.isAuthorized) return;
log('[WebSocket] Connecting to the server...'); logging.debug('[WebSocket] Connecting to the server...');
await connect(); await connect();
} }
@ -39,7 +42,7 @@ class WebSocketProvider extends ChangeNotifier {
Future<void> connect({noRetry = false}) async { Future<void> connect({noRetry = false}) async {
if (_connectCompleter != null) { if (_connectCompleter != null) {
await _connectCompleter!.future; await _connectCompleter!.future;
_connectCompleter = null; return;
} }
if (!_ua.isAuthorized) return; if (!_ua.isAuthorized) return;
@ -52,27 +55,39 @@ class WebSocketProvider extends ChangeNotifier {
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();
conn = WebSocketChannel.connect(uri); conn = kIsWeb
? WebSocketChannel.connect(uri)
: IOWebSocketChannel.connect(
uri,
headers: {'Authorization': 'Bearer $atk'},
);
await conn!.ready; await conn!.ready;
_wsStream = conn!.stream.asBroadcastStream(); _wsStream = conn!.stream.asBroadcastStream();
listen(); listen();
log('[WebSocket] Connected to server!'); logging.info('[WebSocket] Connected to server!');
isConnected = true; isConnected = true;
} catch (err) { } catch (err) {
if (err is WebSocketChannelException) { if (err is WebSocketChannelException) {
log('Failed to connect to websocket: ${(err.inner as dynamic).message}'); logging.error(
'[WebSocket] Failed to connect to websocket...',
err.inner,
);
} else { } else {
log('Failed to connect to websocket: $err'); logging.error('[WebSocket] Failed to connect to websocket...', err);
} }
if (!noRetry) { if (!noRetry) {
log('Retry connecting to websocket in 3 seconds...'); logging.warning(
'[WebSocket] Retry connecting to websocket in 3 seconds...',
);
return Future.delayed( return Future.delayed(
const Duration(seconds: 3), const Duration(seconds: 3),
() => connect(noRetry: true), () => connect(noRetry: true),
@ -100,7 +115,9 @@ class WebSocketProvider extends ChangeNotifier {
_wsStream!.listen( _wsStream!.listen(
(event) { (event) {
final packet = WebSocketPackage.fromJson(jsonDecode(event)); final packet = WebSocketPackage.fromJson(jsonDecode(event));
log('Websocket incoming message: ${packet.method} ${packet.message}'); logging.debug(
'[Websocket] Incoming message: ${packet.method} ${packet.message}',
);
pk.sink.add(packet); pk.sink.add(packet);
}, },
onDone: () { onDone: () {

View File

@ -3,13 +3,22 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; 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/punishments.dart';
import 'package:surface/screens/account/settings.dart';
import 'package:surface/screens/account/action_events.dart';
import 'package:surface/screens/account/badges.dart';
import 'package:surface/screens/account/contact_methods.dart';
import 'package:surface/screens/account/factor_settings.dart'; import 'package:surface/screens/account/factor_settings.dart';
import 'package:surface/screens/account/keypairs.dart';
import 'package:surface/screens/account/prefs/notify.dart';
import 'package:surface/screens/account/prefs/security.dart';
import 'package:surface/screens/account/profile_page.dart'; import 'package:surface/screens/account/profile_page.dart';
import 'package:surface/screens/account/profile_edit.dart'; import 'package:surface/screens/account/profile_edit.dart';
import 'package:surface/screens/account/programs.dart';
import 'package:surface/screens/account/publishers/publisher_edit.dart'; import 'package:surface/screens/account/publishers/publisher_edit.dart';
import 'package:surface/screens/account/publishers/publisher_new.dart'; import 'package:surface/screens/account/publishers/publisher_new.dart';
import 'package:surface/screens/account/publishers/publishers.dart'; import 'package:surface/screens/account/publishers/publishers.dart';
import 'package:surface/screens/account/auth_tickets.dart';
import 'package:surface/screens/album.dart'; import 'package:surface/screens/album.dart';
import 'package:surface/screens/auth/login.dart'; import 'package:surface/screens/auth/login.dart';
import 'package:surface/screens/auth/register.dart'; import 'package:surface/screens/auth/register.dart';
@ -21,26 +30,32 @@ import 'package:surface/screens/chat/room.dart';
import 'package:surface/screens/explore.dart'; import 'package:surface/screens/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';
import 'package:surface/screens/post/post_detail.dart'; import 'package:surface/screens/post/post_detail.dart';
import 'package:surface/screens/post/post_draft.dart';
import 'package:surface/screens/post/post_editor.dart'; import 'package:surface/screens/post/post_editor.dart';
import 'package:surface/screens/post/post_shuffle.dart';
import 'package:surface/screens/post/publisher_page.dart'; import 'package:surface/screens/post/publisher_page.dart';
import 'package:surface/screens/post/post_search.dart'; 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/community.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/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,
@ -57,14 +72,19 @@ final _appRoutes = [
), ),
GoRoute( GoRoute(
path: '/posts', path: '/posts',
name: 'explore', name: 'posts',
builder: (context, state) => const ExploreScreen(), builder: (_, __) => const SizedBox.shrink(),
routes: [ routes: [
GoRoute( GoRoute(
path: '/write/:mode', path: '/draft',
name: 'postDraftBox',
builder: (context, state) => const PostDraftBox(),
),
GoRoute(
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'] ?? '',
), ),
@ -77,111 +97,208 @@ final _appRoutes = [
extraProps: state.extra as PostEditorExtra?, extraProps: state.extra as PostEditorExtra?,
), ),
), ),
GoRoute(
path: '/shuffle',
name: 'postShuffle',
builder: (context, state) => const PostShuffleScreen(),
),
GoRoute( GoRoute(
path: '/search', path: '/search',
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(','),
),
),
],
),
ShellRoute(
builder: (context, state, child) => ResponsiveScaffold(
asideFlex: 2,
contentFlex: 3,
aside: const ExploreScreen(),
child: child,
),
routes: [
GoRoute(
path: '/explore',
name: 'explore',
builder: (context, state) => const ResponsiveScaffoldLanding(
child: ExploreScreen(),
),
),
GoRoute(
path: '/posts/:slug',
name: 'postDetail',
builder: (context, state) => PostDetailScreen(
key: ValueKey(state.pathParameters['slug']!),
slug: state.pathParameters['slug']!,
preload: state.extra as SnPost?,
), ),
), ),
GoRoute( 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(
path: '/:slug',
name: 'postDetail',
builder: (context, state) => PostDetailScreen(
slug: state.pathParameters['slug']!,
preload: state.extra as SnPost?,
),
), ),
], ],
), ),
GoRoute(path: '/account', name: 'account', builder: (context, state) => const AccountScreen(), routes: [ ShellRoute(
GoRoute( builder: (context, state, child) => ResponsiveScaffold(
path: '/wallet', aside: const AccountScreen(),
name: 'accountWallet', child: child,
builder: (context, state) => const WalletScreen(),
), ),
GoRoute(
path: '/settings',
name: 'accountSettings',
builder: (context, state) => AccountSettingsScreen(),
),
GoRoute(
path: '/settings/factors',
name: 'factorSettings',
builder: (context, state) => FactorSettingsScreen(),
),
GoRoute(
path: '/profile/edit',
name: 'accountProfileEdit',
builder: (context, state) => ProfileEditScreen(),
),
GoRoute(
path: '/publishers',
name: 'accountPublishers',
builder: (context, state) => PublisherScreen(),
),
GoRoute(
path: '/publishers/new',
name: 'accountPublisherNew',
builder: (context, state) => AccountPublisherNewScreen(),
),
GoRoute(
path: '/publishers/edit/:name',
name: 'accountPublisherEdit',
builder: (context, state) => AccountPublisherEditScreen(
name: state.pathParameters['name']!,
),
),
GoRoute(
path: '/:name',
name: 'accountProfilePage',
pageBuilder: (context, state) => NoTransitionPage(
child: UserScreen(name: state.pathParameters['name']!),
),
),
]),
GoRoute(
path: '/chat',
name: 'chat',
builder: (context, state) => const ChatScreen(),
routes: [ routes: [
GoRoute( GoRoute(
path: '/:scope/:alias', path: '/account',
name: 'chatRoom', name: 'account',
builder: (context, state) => ChatRoomScreen( builder: (context, state) =>
scope: state.pathParameters['scope']!, const ResponsiveScaffoldLanding(child: AccountScreen()),
alias: state.pathParameters['alias']!, routes: [
extra: state.extra as ChatRoomScreenExtra?, GoRoute(
), path: '/punishments',
name: 'accountPunishments',
builder: (context, state) => const PunishmentsScreen(),
),
GoRoute(
path: '/programs',
name: 'accountProgram',
builder: (context, state) => const AccountProgramScreen(),
),
GoRoute(
path: '/contacts',
name: 'accountContactMethods',
builder: (context, state) => const AccountContactMethod(),
),
GoRoute(
path: '/events',
name: 'accountActionEvents',
builder: (context, state) => const ActionEventScreen(),
),
GoRoute(
path: '/tickets',
name: 'accountAuthTickets',
builder: (context, state) => const AccountAuthTicket(),
),
GoRoute(
path: '/badges',
name: 'accountBadges',
builder: (context, state) => const AccountBadgesScreen(),
),
GoRoute(
path: '/wallet',
name: 'accountWallet',
builder: (context, state) => const WalletScreen(),
),
GoRoute(
path: '/keypairs',
name: 'accountKeyPairs',
builder: (context, state) => const KeyPairScreen(),
),
GoRoute(
path: '/settings',
name: 'accountSettings',
builder: (context, state) => AccountSettingsScreen(),
routes: [
GoRoute(
path: '/notify',
name: 'accountSettingsNotify',
builder: (context, state) => const AccountNotifyPrefsScreen(),
),
GoRoute(
path: '/auth',
name: 'accountSettingsSecurity',
builder: (context, state) => const AccountSecurityPrefsScreen(),
),
],
),
GoRoute(
path: '/settings/factors',
name: 'factorSettings',
builder: (context, state) => FactorSettingsScreen(),
),
GoRoute(
path: '/profile/edit',
name: 'accountProfileEdit',
builder: (context, state) => ProfileEditScreen(),
),
GoRoute(
path: '/publishers',
name: 'accountPublishers',
builder: (context, state) => PublisherScreen(),
),
GoRoute(
path: '/publishers/new',
name: 'accountPublisherNew',
builder: (context, state) => AccountPublisherNewScreen(),
),
GoRoute(
path: '/publishers/edit/:name',
name: 'accountPublisherEdit',
builder: (context, state) => AccountPublisherEditScreen(
name: state.pathParameters['name']!,
),
),
],
), ),
],
),
GoRoute(
path: '/accounts/:name',
name: 'accountProfilePage',
pageBuilder: (context, state) => NoTransitionPage(
child: UserScreen(name: state.pathParameters['name']!),
),
),
ShellRoute(
builder: (context, state, child) =>
ResponsiveScaffold(aside: const ChatScreen(), child: child),
routes: [
GoRoute( GoRoute(
path: '/:scope/:alias/call', path: '/chat',
name: 'chatCallRoom', name: 'chat',
builder: (context, state) => CallRoomScreen( builder: (context, state) => const ResponsiveScaffoldLanding(
scope: state.pathParameters['scope']!, child: ChatScreen(),
alias: state.pathParameters['alias']!,
),
),
GoRoute(
path: '/:scope/:alias/detail',
name: 'channelDetail',
builder: (context, state) => ChannelDetailScreen(
scope: state.pathParameters['scope']!,
alias: state.pathParameters['alias']!,
),
),
GoRoute(
path: '/manage',
name: 'chatManage',
builder: (context, state) => ChatManageScreen(
editingChannelAlias: state.uri.queryParameters['editing'],
), ),
routes: [
GoRoute(
path: '/:scope/:alias',
name: 'chatRoom',
builder: (context, state) => ChatRoomScreen(
key: ValueKey(
'${state.pathParameters['scope']!}:${state.pathParameters['alias']!}',
),
scope: state.pathParameters['scope']!,
alias: state.pathParameters['alias']!,
extra: state.extra as ChatRoomScreenExtra?,
),
),
GoRoute(
path: '/:scope/:alias/call',
name: 'chatCallRoom',
builder: (context, state) => CallRoomScreen(
scope: state.pathParameters['scope']!,
alias: state.pathParameters['alias']!,
),
),
GoRoute(
path: '/:scope/:alias/detail',
name: 'channelDetail',
builder: (context, state) => ChannelDetailScreen(
scope: state.pathParameters['scope']!,
alias: state.pathParameters['alias']!,
),
),
GoRoute(
path: '/manage',
name: 'chatManage',
builder: (context, state) => ChatManageScreen(
editingChannelAlias: state.uri.queryParameters['editing'],
),
),
],
), ),
], ],
), ),
@ -193,6 +310,13 @@ final _appRoutes = [
child: const RealmScreen(), child: const RealmScreen(),
), ),
routes: [ routes: [
GoRoute(
path: '/:alias/community',
name: 'realmCommunity',
builder: (context, state) => RealmCommunityScreen(
alias: state.pathParameters['alias']!,
),
),
GoRoute( GoRoute(
path: '/manage', path: '/manage',
name: 'realmManage', name: 'realmManage',
@ -208,19 +332,44 @@ final _appRoutes = [
GoRoute( GoRoute(
path: '/:alias', path: '/:alias',
name: 'realmDetail', name: 'realmDetail',
builder: (context, state) => RealmDetailScreen(alias: state.pathParameters['alias']!), builder: (context, state) =>
RealmDetailScreen(alias: state.pathParameters['alias']!),
), ),
], ],
), ),
GoRoute(path: '/news', name: 'news', builder: (context, state) => const NewsScreen(), routes: [ GoRoute(
GoRoute( path: '/news',
path: '/:hash', name: 'news',
name: 'newsDetail', builder: (context, state) => const NewsScreen(),
builder: (context, state) => NewsDetailScreen( routes: [
hash: state.pathParameters['hash']!, GoRoute(
path: '/:hash',
name: 'newsDetail',
builder: (context, state) => NewsDetailScreen(
hash: state.pathParameters['hash']!,
),
), ),
), ],
]), ),
GoRoute(
path: '/stickers',
name: 'stickers',
builder: (context, state) => const StickerScreen(),
routes: [
GoRoute(
path: '/packs/:id',
name: 'stickerPack',
builder: (context, state) => StickerPackScreen(
id: int.tryParse(state.pathParameters['id']!)!,
),
),
],
),
GoRoute(
path: '/debug/logging',
name: 'debugLogging',
builder: (context, state) => const DebugLoggingScreen(),
),
GoRoute( GoRoute(
path: '/album', path: '/album',
name: 'album', name: 'album',

View File

@ -4,14 +4,17 @@ 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/navigation.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';
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_status.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';
@ -20,32 +23,103 @@ import 'package:surface/widgets/universal_image.dart';
class AccountScreen extends StatelessWidget { class AccountScreen extends StatelessWidget {
const AccountScreen({super.key}); const AccountScreen({super.key});
static const List<AppNavListItem> kNavList = [
AppNavListItem(
title: "accountPublishers",
subtitle: "accountPublishersSubtitle",
screen: "accountPublishers",
icon: Symbols.face,
),
AppNavListItem(
title: "accountProgram",
subtitle: "accountProgramDescription",
screen: "accountProgram",
icon: Symbols.communities,
),
AppNavListItem(
title: "friends",
subtitle: "friendsDescription",
screen: "friend",
icon: Symbols.person,
),
AppNavListItem(
title: "album",
subtitle: "albumDescription",
screen: "album",
icon: Symbols.photo_library,
),
AppNavListItem(
title: "stickers",
subtitle: "stickersDescription",
screen: "stickers",
icon: Symbols.emoji_emotions,
),
AppNavListItem(
title: "accountWallet",
subtitle: "accountWalletSubtitle",
screen: "accountWallet",
icon: Symbols.wallet,
),
AppNavListItem(
title: "accountBadges",
subtitle: "accountBadgesDescription",
screen: "accountBadges",
icon: Symbols.award_star,
),
AppNavListItem(
title: "accountKeyPairs",
subtitle: "accountKeyPairsDescription",
screen: "accountKeyPairs",
icon: Symbols.key,
),
AppNavListItem(
title: "accountPunishments",
subtitle: "accountPunishmentsDescription",
screen: "accountPunishments",
icon: Symbols.credit_score,
),
AppNavListItem(
title: "accountActionEvent",
subtitle: "accountActionEventDescription",
screen: "accountActionEvents",
icon: Symbols.history,
),
AppNavListItem(
title: "accountAuthTickets",
subtitle: "accountAuthTicketsDescription",
screen: "accountAuthTickets",
icon: Symbols.confirmation_number,
),
AppNavListItem(
title: "accountSettings",
subtitle: "accountSettingsSubtitle",
screen: "accountSettings",
icon: Symbols.manage_accounts,
),
AppNavListItem(
title: "abuseReport",
subtitle: "abuseReportActionDescription",
screen: "abuseReport",
icon: Symbols.flag,
),
];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ua = context.watch<UserProvider>(); final ua = context.watch<UserProvider>();
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
return AppScaffold( return AppScaffold(
noBackground: true,
appBar: AppBar( appBar: AppBar(
leading: AutoAppBarLeading(), leading: AutoAppBarLeading(),
title: Text( title: Text("screenAccount").tr(),
"screenAccount",
style: TextStyle(
color: Colors.white,
shadows: [
Shadow(
offset: Offset(1, 1),
blurRadius: 5.0,
color: Color.fromARGB(255, 0, 0, 0),
),
],
),
).tr(),
flexibleSpace: ua.user != null && ua.user!.banner.isNotEmpty flexibleSpace: ua.user != null && ua.user!.banner.isNotEmpty
? 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,
@ -68,18 +142,11 @@ class AccountScreen extends StatelessWidget {
], ],
) )
: null, : null,
actions: [
IconButton(
icon: const Icon(Symbols.settings, fill: 1),
onPressed: () {
GoRouter.of(context).pushNamed('settings');
},
),
const Gap(8),
],
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
child: ua.isAuthorized ? _AuthorizedAccountScreen() : _UnauthorizedAccountScreen(), child: ua.isAuthorized
? _AuthorizedAccountScreen()
: _UnauthorizedAccountScreen(),
), ),
); );
} }
@ -109,93 +176,86 @@ class _AuthorizedAccountScreen extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
AccountImage(content: ua.user!.avatar, radius: 28), Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
child: AccountImage(
content: ua.user!.avatar,
radius: 28,
),
onTap: () {
GoRouter.of(context)
.pushNamed('accountProfilePage', pathParameters: {
'name': ua.user!.name,
});
},
),
_AccountStatusWidget(account: ua.user!),
],
),
const Gap(8), const Gap(8),
Row( Row(
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!),
], ],
), ),
); );
}).padding(all: 20), }).padding(all: 20),
).padding(horizontal: 8, top: 16, bottom: 4), ).padding(horizontal: 8, top: 16, bottom: 4),
ListTile( for (final item in AccountScreen.kNavList)
title: Text('accountPublishers').tr(), Tooltip(
subtitle: Text('accountPublishersSubtitle').tr(), message: item.subtitle.tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24), child: ListTile(
leading: const Icon(Symbols.face), minTileHeight: 48,
trailing: const Icon(Symbols.chevron_right), title: Text(item.title).tr(),
onTap: () { contentPadding: const EdgeInsets.symmetric(horizontal: 24),
GoRouter.of(context).pushNamed('accountPublishers'); leading: Icon(item.icon),
}, trailing: const Icon(Symbols.chevron_right),
), onTap: () {
ListTile( GoRouter.of(context).pushNamed(item.screen);
title: Text('abuseReport').tr(), },
subtitle: Text('abuseReportActionDescription').tr(), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 24), ),
leading: const Icon(Symbols.flag), Tooltip(
trailing: const Icon(Symbols.chevron_right), message: 'accountLogoutSubtitle'.tr(),
onTap: () { child: ListTile(
GoRouter.of(context).pushNamed('abuseReport'); title: Text('accountLogout').tr(),
}, minTileHeight: 48,
), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
ListTile( leading: const Icon(Symbols.logout),
title: Text('factorSettings').tr(), trailing: const Icon(Symbols.chevron_right),
subtitle: Text('factorSettingsSubtitle').tr(), onTap: () async {
contentPadding: const EdgeInsets.symmetric(horizontal: 24), final confirm = await context.showConfirmDialog(
leading: const Icon(Symbols.lock), 'accountLogoutConfirmTitle'.tr(),
trailing: const Icon(Symbols.chevron_right), 'accountLogoutConfirm'.tr(),
onTap: () { );
GoRouter.of(context).pushNamed('factorSettings');
},
),
ListTile(
title: Text('accountWallet').tr(),
subtitle: Text('accountWalletSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.wallet),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountWallet');
},
),
ListTile(
title: Text('accountSettings').tr(),
subtitle: Text('accountSettingsSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.manage_accounts),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountSettings');
},
),
ListTile(
title: Text('accountLogout').tr(),
subtitle: Text('accountLogoutSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.logout),
trailing: const Icon(Symbols.chevron_right),
onTap: () async {
final confirm = await context.showConfirmDialog(
'accountLogoutConfirmTitle'.tr(),
'accountLogoutConfirm'.tr(),
);
if (!confirm) return; if (!confirm) return;
if (!context.mounted) return; if (!context.mounted) return;
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 +280,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),
@ -236,9 +298,7 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
GoRouter.of(context).pushNamed('authLogin').then((value) { GoRouter.of(context).pushNamed('authLogin').then((value) {
if (value == true && context.mounted) { if (value == true && context.mounted) {
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
context.showSnackbar('loginSuccess'.tr(args: [ ua.refreshUser();
'@${ua.user?.name} (${ua.user?.nick})',
]));
} }
}); });
}, },
@ -257,3 +317,81 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
); );
} }
} }
class _AccountStatusWidget extends StatefulWidget {
final SnAccount account;
const _AccountStatusWidget({required this.account});
@override
State<_AccountStatusWidget> createState() => _AccountStatusWidgetState();
}
class _AccountStatusWidgetState extends State<_AccountStatusWidget> {
SnAccountStatusInfo? _status;
Future<void> _fetchStatus() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp =
await sn.client.get('/cgi/id/users/${widget.account.name}/status');
setState(() {
_status = SnAccountStatusInfo.fromJson(resp.data);
});
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() {});
}
}
@override
void initState() {
super.initState();
_fetchStatus();
}
@override
Widget build(BuildContext context) {
return InkWell(
child: Row(
children: [
Text(
_status != null
? (_status!.status?.label.isNotEmpty ?? false)
? _status!.status!.label
: _status!.isOnline
? 'accountStatusOnline'.tr()
: 'accountStatusOffline'.tr()
: 'loading'.tr(),
),
const Gap(4),
Icon(
(_status?.isDisturbable ?? true)
? Symbols.circle
: Symbols.do_not_disturb_on,
fill: (_status?.isOnline ?? false) ? 1 : 0,
size: 16,
color: (_status?.isOnline ?? false)
? (_status?.isDisturbable ?? true)
? Colors.green
: Colors.red
: Colors.grey,
).padding(all: 4),
],
),
onTap: () {
showModalBottomSheet(
context: context,
builder: (context) => AccountStatusActionPopup(
currentStatus: _status,
),
).then((value) {
if (value == true && mounted) {
_fetchStatus();
}
});
},
);
}
}

View File

@ -0,0 +1,161 @@
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/account.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:timelines_plus/timelines_plus.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class ActionEventScreen extends StatefulWidget {
const ActionEventScreen({super.key});
@override
State<ActionEventScreen> createState() => _ActionEventScreenState();
}
class _ActionEventScreenState extends State<ActionEventScreen> {
bool _isBusy = false;
int? _totalCount;
final List<SnActionEvent> _actionEvents = List.empty(growable: true);
Future<void> _fetchActionEvents() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get(
'/cgi/id/users/me/events',
queryParameters: {
'take': 10,
'offset': _actionEvents.length,
},
);
_totalCount = resp.data['count'];
_actionEvents.addAll(
(resp.data['data'] as List<dynamic>)
.map((e) => SnActionEvent.fromJson(e)),
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchActionEvents();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
noBackground: true,
appBar: AppBar(
leading: const PageBackButton(),
title: Text('accountActionEvent').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
Expanded(
child: RefreshIndicator(
onRefresh: () {
_totalCount = null;
return _fetchActionEvents();
},
child: InfiniteList(
padding: EdgeInsets.only(left: 20, right: 8),
itemCount: _actionEvents.length,
isLoading: _isBusy,
hasReachedMax:
_totalCount != null && _actionEvents.length >= _totalCount!,
onFetchData: _fetchActionEvents,
itemBuilder: (context, idx) {
final event = _actionEvents[idx];
return TimelineTile(
nodeAlign: TimelineNodeAlign.start,
contents: Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.type,
maxLines: 1,
style: GoogleFonts.robotoMono(),
),
if (event.ipAddress.isNotEmpty)
Text(
event.ipAddress,
style: TextStyle(fontSize: 13),
),
if (event.location?.isNotEmpty ?? false)
Text(event.location!),
Row(
children: [
Text(DateFormat()
.format(event.createdAt.toLocal()))
.fontSize(12),
Text(' · ')
.fontSize(12)
.padding(horizontal: 4),
Text(RelativeTime(context)
.format(event.createdAt.toLocal()))
.fontSize(12),
],
).opacity(0.75).padding(top: 4),
],
),
),
if (event.metadata != null)
ExpansionTile(
minTileHeight: 40,
tilePadding: EdgeInsets.symmetric(horizontal: 16),
title: Text('eventMetadata').tr(),
expandedAlignment: Alignment.topLeft,
expandedCrossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
JsonEncoder.withIndent('\t')
.convert(event.metadata),
style: GoogleFonts.robotoMono(),
).padding(vertical: 8, horizontal: 16),
],
).padding(bottom: 6),
],
),
),
node: TimelineNode(
indicator: DotIndicator(),
startConnector: SolidLineConnector(),
endConnector: SolidLineConnector(),
),
);
},
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,187 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/auth.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
const Map<String, IconData> kAuthTicketIcon = {
'ios': Symbols.ios,
'android': Symbols.android,
'macos': Symbols.computer,
'windows nt': Symbols.laptop_windows,
'linux': Symbols.laptop,
};
class AccountAuthTicket extends StatefulWidget {
const AccountAuthTicket({super.key});
@override
State<AccountAuthTicket> createState() => _AccountAuthTicketState();
}
class _AccountAuthTicketState extends State<AccountAuthTicket> {
bool _isBusy = false;
int? _totalCount;
final List<SnAuthTicket> _authTickets = List.empty(growable: true);
Future<void> _fetchAuthTickets() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get(
'/cgi/id/users/me/tickets',
queryParameters: {
'take': 10,
'offset': _authTickets.length,
},
);
_totalCount = resp.data['count'];
_authTickets.addAll(
(resp.data['data'] as List<dynamic>)
.map((e) => SnAuthTicket.fromJson(e)),
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _deleteAuthTicket(SnAuthTicket ticket) async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.delete(
'/cgi/id/users/me/tickets/${ticket.id}',
);
setState(() {
_authTickets.remove(ticket);
});
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
int? _currentTicketId;
@override
void initState() {
super.initState();
_fetchAuthTickets();
final ua = context.read<UserProvider>();
ua.atkClaims.then((value) {
if (value == null) return;
_currentTicketId = int.parse(value['sed']);
});
}
@override
Widget build(BuildContext context) {
return AppScaffold(
noBackground: true,
appBar: AppBar(
leading: const PageBackButton(),
title: Text('accountAuthTickets').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
Expanded(
child: RefreshIndicator(
onRefresh: () {
_totalCount = null;
return _fetchAuthTickets();
},
child: InfiniteList(
padding: EdgeInsets.zero,
onFetchData: _fetchAuthTickets,
isLoading: _isBusy,
hasReachedMax:
_totalCount != null && _authTickets.length >= _totalCount!,
itemCount: _authTickets.length,
itemBuilder: (context, idx) {
final ticket = _authTickets[idx];
final platform = RegExp(r'\(([^;]+);')
.firstMatch(ticket.userAgent)
?.group(1);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
kAuthTicketIcon[platform!.toLowerCase()] ?? Symbols.web,
),
const Gap(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
ticket.ipAddress,
style: TextStyle(fontSize: 15),
),
Text(ticket.userAgent).opacity(0.8),
if (ticket.location?.isNotEmpty ?? false)
const Gap(4),
if (ticket.location?.isNotEmpty ?? false)
Text(ticket.location!).opacity(0.8),
const Gap(4),
Text('authTicketCreatedAt'.tr(args: [
(DateFormat().format(ticket.createdAt.toLocal()))
])).fontSize(12).opacity(0.75),
if (ticket.expiredAt != null)
Text('authTicketExpiredAt'.tr(args: [
(DateFormat()
.format(ticket.expiredAt!.toLocal()))
])).fontSize(12).opacity(0.75),
if (ticket.lastGrantAt != null)
Text('authTicketLastGrantAt'.tr(args: [
(DateFormat()
.format(ticket.lastGrantAt!.toLocal()))
])).fontSize(12).opacity(0.75),
const Gap(4),
if (_currentTicketId == ticket.id)
Text('authTicketCurrent'.tr())
.fontSize(11)
.bold()
.opacity(0.75),
Text('#${ticket.id}').fontSize(11).opacity(0.75),
],
),
),
IconButton(
iconSize: 20,
visualDensity:
VisualDensity(horizontal: -4, vertical: -4),
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
icon: const Icon(Symbols.logout),
onPressed: _currentTicketId == ticket.id
? null
: () {
_deleteAuthTicket(ticket);
},
),
],
).padding(horizontal: 16, vertical: 12);
},
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,141 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/screens/account/profile_page.dart' show kBadgesMeta;
import 'package:surface/theme.dart';
import 'package:surface/types/account.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class AccountBadgesScreen extends StatefulWidget {
const AccountBadgesScreen({super.key});
@override
State<AccountBadgesScreen> createState() => _AccountBadgesScreenState();
}
class _AccountBadgesScreenState extends State<AccountBadgesScreen> {
bool _isBusy = false;
List<SnAccountBadge>? _badges;
Future<void> _fetchBadges() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/badges/me');
if (!mounted) return;
setState(
() => _badges = List<SnAccountBadge>.from(
resp.data?.map((e) => SnAccountBadge.fromJson(e)) ?? [],
),
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
bool _isActivating = false;
Future<void> _activateBadge(SnAccountBadge badge) async {
try {
setState(() => _isActivating = true);
final sn = context.read<SnNetworkProvider>();
await sn.client.post('/cgi/id/badges/${badge.id}/active');
if (!mounted) return;
context.showSnackbar('badgeActivated'
.tr(args: [(kBadgesMeta[badge.type]?.$1 ?? 'unknown').tr()]));
await _fetchBadges();
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isActivating = false);
}
}
@override
void initState() {
super.initState();
_fetchBadges();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
noBackground: true,
appBar: AppBar(
title: Text('screenAccountBadges').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
if (_badges != null)
Expanded(
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: RefreshIndicator(
onRefresh: _fetchBadges,
child: ListView.builder(
itemCount: _badges!.length,
itemBuilder: (context, idx) {
final badge = _badges![idx];
return ListTile(
title: Text(
kBadgesMeta[badge.type]?.$1 ?? 'unknown',
).tr(),
contentPadding: const EdgeInsets.only(
left: 24,
right: 16,
top: 4,
bottom: 4,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (badge.metadata['title'] != null)
Text(badge.metadata['title']).fontSize(14).bold()
else
Text(
'#${badge.id.toString().padLeft(8, '0')}',
style: GoogleFonts.robotoMono(),
).fontSize(14).bold(),
Text(
DateFormat('y/M/d').format(badge.createdAt),
)
],
),
trailing: IconButton(
icon: const Icon(Symbols.check),
onPressed: (badge.isActive || _isActivating)
? null
: () {
_activateBadge(badge);
},
),
leading: Icon(
kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark,
color: badge.metadata['color'] != null
? HexColor.fromHex(badge.metadata['color']!)
: kBadgesMeta[badge.type]?.$3,
fill: 1,
),
);
},
),
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,323 @@
import 'package:collection/collection.dart';
import 'package:dio/dio.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/account.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
const kContactMethodsIcons = [Symbols.email, Symbols.phone, Symbols.map];
const kContactMethodsName = ['Email', 'Phone', 'Address'];
class AccountContactMethod extends StatefulWidget {
const AccountContactMethod({super.key});
@override
State<AccountContactMethod> createState() => _AccountContactMethodState();
}
class _AccountContactMethodState extends State<AccountContactMethod> {
bool _isBusy = false;
List<SnAccountContact> _contactMethods = List.empty(growable: true);
Future<void> _fetchContactMethods() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/me/contacts');
_contactMethods = List.from((resp.data as List<dynamic>)
.map((e) => SnAccountContact.fromJson(e)));
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _deleteContactMethod(SnAccountContact contact) async {
final confirm = await context.showConfirmDialog(
'accountContactMethodsDelete'.tr(),
'accountContactMethodsDeleteDescription'.tr(args: [contact.content]),
);
if (!confirm || !mounted) return;
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.delete('/cgi/id/users/me/contacts/${contact.id}');
if (!mounted) return;
await _fetchContactMethods();
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
}
}
@override
void initState() {
super.initState();
_fetchContactMethods();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
noBackground: true,
appBar: AppBar(
leading: const PageBackButton(),
title: Text('accountContactMethods').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
ListTile(
title: Text('accountContactMethodsAdd').tr(),
subtitle: Text('accountContactMethodsAddDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.add),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
showDialog(
context: context,
builder: (context) => _ContactMethodEditor(),
).then((value) {
if (value) {
_fetchContactMethods();
}
});
},
),
Divider(height: 1),
Expanded(
child: RefreshIndicator(
onRefresh: _fetchContactMethods,
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: _contactMethods.length,
itemBuilder: (context, index) {
final method = _contactMethods[index];
return ListTile(
title: Text(method.content),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'accountContactMethodsName${kContactMethodsName[method.type]}',
).tr().bold(),
if (method.isPrimary ||
method.isPublic ||
method.verifiedAt != null)
Row(
spacing: 4,
children: [
if (method.isPrimary)
Text('accountContactMethodsPrimary').tr(),
if (method.isPublic)
Text('accountContactMethodsPublic').tr(),
if (method.verifiedAt != null)
Text('accountContactMethodsVerified').tr(),
],
),
],
),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(
kContactMethodsIcons[method.type],
),
trailing: PopupMenuButton(
itemBuilder: (_) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.edit),
const Gap(16),
Text('edit').tr(),
],
),
onTap: () {
showDialog(
context: context,
builder: (context) => _ContactMethodEditor(
contact: method,
),
).then((value) {
if (value) {
_fetchContactMethods();
}
});
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.delete),
const Gap(16),
Text('delete'.tr()),
],
),
onTap: () {
_deleteContactMethod(method);
},
),
],
),
);
},
),
),
),
],
),
);
}
}
class _ContactMethodEditor extends StatefulWidget {
final SnAccountContact? contact;
const _ContactMethodEditor({this.contact});
@override
State<_ContactMethodEditor> createState() => _ContactMethodEditorState();
}
class _ContactMethodEditorState extends State<_ContactMethodEditor> {
int _type = 0;
bool _isPublic = false;
final TextEditingController _contentController = TextEditingController();
bool _isBusy = false;
Future<void> _saveContactMethod() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.request(
widget.contact == null
? '/cgi/id/users/me/contacts'
: '/cgi/id/users/me/contacts/${widget.contact!.id}',
data: {
'content': _contentController.text,
'type': _type,
'is_public': _isPublic,
},
options: Options(
method: widget.contact == null ? 'POST' : 'PUT',
),
);
if (!mounted) return;
Navigator.pop(context, true);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
if (widget.contact != null) {
_type = widget.contact!.type;
_isPublic = widget.contact!.isPublic;
_contentController.text = widget.contact!.content;
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: widget.contact == null
? Text('accountContactMethodsAdd').tr()
: Text('accountContactMethodsEdit').tr(),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonHideUnderline(
child: DropdownButton2<int>(
value: _type,
items: kContactMethodsName
.mapIndexed((idx, ele) => DropdownMenuItem<int>(
value: idx,
child: Text('accountContactMethodsName$ele').tr(),
))
.toList(),
buttonStyleData: ButtonStyleData(
height: 48,
width: double.infinity,
padding: const EdgeInsets.only(left: 14, right: 14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Theme.of(context).dividerColor,
),
),
),
menuItemStyleData: const MenuItemStyleData(
height: 48,
padding: EdgeInsets.only(left: 14, right: 14),
),
onChanged: (value) {
setState(() => _type = value ?? 0);
},
),
),
const Gap(8),
TextField(
controller: _contentController,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'fieldContactContent'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(8),
Card(
margin: EdgeInsets.zero,
child: CheckboxListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(8),
),
),
title: Text('accountContactMethodsPublic').tr(),
subtitle: Text('accountContactMethodsPublicHint').tr(),
secondary: const Icon(Symbols.globe),
value: _isPublic,
onChanged: (value) {
setState(() => _isPublic = value ?? false);
},
),
)
],
),
actions: [
TextButton(
onPressed: _isBusy
? null
: () {
Navigator.of(context).pop();
},
child: Text('dialogDismiss').tr(),
),
TextButton(
onPressed: _isBusy
? null
: () {
_saveContactMethod();
},
child: Text('dialogConfirm').tr(),
),
],
);
}
}

View File

@ -16,7 +16,11 @@ final Map<int, (String, String, IconData)> kFactorTypes = {
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password), 0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email), 1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email),
2: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer), 2: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer),
3: ('authFactorInAppNotify', 'authFactorInAppNotifyDescription', Symbols.notifications_active), 3: (
'authFactorInAppNotify',
'authFactorInAppNotifyDescription',
Symbols.notifications_active
),
}; };
class FactorSettingsScreen extends StatefulWidget { class FactorSettingsScreen extends StatefulWidget {
@ -36,7 +40,10 @@ class _FactorSettingsScreenState extends State<FactorSettingsScreen> {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/me/factors'); final resp = await sn.client.get('/cgi/id/users/me/factors');
_factors = List<SnAuthFactor>.from( _factors = List<SnAuthFactor>.from(
resp.data?.map((e) => SnAuthFactor.fromJson(e as Map<String, dynamic>)).toList() ?? [], resp.data
?.map((e) => SnAuthFactor.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
); );
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
@ -55,6 +62,7 @@ class _FactorSettingsScreenState extends State<FactorSettingsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AppScaffold( return AppScaffold(
noBackground: true,
appBar: AppBar( appBar: AppBar(
leading: PageBackButton(), leading: PageBackButton(),
title: Text('screenFactorSettings').tr(), title: Text('screenFactorSettings').tr(),
@ -96,7 +104,8 @@ class _FactorSettingsScreenState extends State<FactorSettingsScreen> {
return ListTile( return ListTile(
title: Text(kFactorTypes[ele.type]!.$1).tr(), title: Text(kFactorTypes[ele.type]!.$1).tr(),
subtitle: Text(kFactorTypes[ele.type]!.$2).tr(), subtitle: Text(kFactorTypes[ele.type]!.$2).tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 12), contentPadding:
const EdgeInsets.only(left: 24, right: 12),
leading: Icon(kFactorTypes[ele.type]!.$3), leading: Icon(kFactorTypes[ele.type]!.$3),
trailing: IconButton( trailing: IconButton(
icon: const Icon(Symbols.close), icon: const Icon(Symbols.close),
@ -105,14 +114,17 @@ class _FactorSettingsScreenState extends State<FactorSettingsScreen> {
context context
.showConfirmDialog( .showConfirmDialog(
'authFactorDelete'.tr(), 'authFactorDelete'.tr(),
'authFactorDeleteDescription'.tr(args: [kFactorTypes[ele.type]!.$1.tr()]), 'authFactorDeleteDescription'.tr(
args: [kFactorTypes[ele.type]!.$1.tr()]),
) )
.then((val) async { .then((val) async {
if (!val) return; if (!val) return;
try { try {
if (!context.mounted) return; if (!context.mounted) return;
final sn = context.read<SnNetworkProvider>(); final sn =
await sn.client.delete('/cgi/id/users/me/factors/${ele.id}'); context.read<SnNetworkProvider>();
await sn.client.delete(
'/cgi/id/users/me/factors/${ele.id}');
_fetchFactors(); _fetchFactors();
} catch (err) { } catch (err) {
if (!context.mounted) return; if (!context.mounted) return;
@ -191,7 +203,9 @@ class _FactorNewDialogState extends State<_FactorNewDialog> {
value: _factorType, value: _factorType,
items: kFactorTypes.entries.map( items: kFactorTypes.entries.map(
(ele) { (ele) {
final contains = widget.currentlyHave.map((ele) => ele.type).contains(ele.key); final contains = widget.currentlyHave
.map((ele) => ele.type)
.contains(ele.key);
return DropdownMenuItem<int>( return DropdownMenuItem<int>(
enabled: !contains, enabled: !contains,
value: ele.key, value: ele.key,

View File

@ -0,0 +1,107 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/keypair.dart';
import 'package:surface/types/keypair.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class KeyPairScreen extends StatefulWidget {
const KeyPairScreen({super.key});
@override
State<KeyPairScreen> createState() => _KeyPairScreenState();
}
class _KeyPairScreenState extends State<KeyPairScreen> {
bool _isBusy = false;
List<SnKeyPair>? _keyPairs;
Future<void> _loadKeyPairs() async {
setState(() => _isBusy = true);
final kps = await context.read<KeyPairProvider>().listKeyPair();
setState(() {
_keyPairs = kps;
_isBusy = false;
});
}
@override
void initState() {
super.initState();
_loadKeyPairs();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
noBackground: true,
appBar: AppBar(
title: Text('screenKeyPairs').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
ListTile(
leading: const Icon(Symbols.add),
title: Text('enrollNewKeyPair').tr(),
subtitle: Text('enrollNewKeyPairDescription').tr(),
onTap: () async {
await context.read<KeyPairProvider>().enrollNew();
_loadKeyPairs();
},
),
const Divider(height: 1),
if (_keyPairs != null)
Expanded(
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: RefreshIndicator(
onRefresh: _loadKeyPairs,
child: ListView.builder(
itemCount: _keyPairs!.length,
itemBuilder: (context, index) {
final kp = _keyPairs![index];
return ListTile(
title: Text(kp.id.toUpperCase()),
subtitle: Row(
spacing: 8,
children: [
if (kp.privateKey != null)
Text(
'keyPairHasPrivateKey'.tr(),
),
if (kp.privateKey != null) Text('·'),
Flexible(
flex: 1,
child: Text(
'UID #${kp.accountId.toString().padLeft(8, '0')}',
style: GoogleFonts.robotoMono(),
),
),
],
),
trailing: IconButton(
icon: const Icon(Symbols.check),
onPressed: kp.isActive == true
? null
: () async {
final k = context.read<KeyPairProvider>();
await k.activeKeyPair(kp.id);
_loadKeyPairs();
},
),
);
},
),
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,123 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
final Map<String, String> kNotifyTopicMap = {
'interactive.reply': 'notificationTopicPostReply'.tr(),
'interactive.feedback': 'notificationTopicPostFeedback'.tr(),
'interactive.subscription': 'notificationTopicPostSubscription'.tr(),
'messaging.message': 'notificationTopicMessaging'.tr(),
'messaging.call': 'notificationTopicMessagingCall'.tr(),
'general': 'notificationTopicGeneral'.tr(),
};
class AccountNotifyPrefsScreen extends StatefulWidget {
const AccountNotifyPrefsScreen({super.key});
@override
State<AccountNotifyPrefsScreen> createState() =>
_AccountNotifyPrefsScreenState();
}
class _AccountNotifyPrefsScreenState extends State<AccountNotifyPrefsScreen> {
bool _isBusy = true;
Map<String, bool> _config = {};
Future<void> _getPreferences() async {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
try {
final resp = await sn.client.get('/cgi/id/preferences/notifications');
_config = resp.data['config']
.map((k, v) => MapEntry(k, v as bool))
.cast<String, bool>();
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _savePreferences() async {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
try {
await sn.client.put(
'/cgi/id/preferences/notifications',
data: {
'config': _config,
},
);
if (!mounted) return;
context.showSnackbar('accountSettingsApplied'.tr());
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_getPreferences();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
noBackground: true,
appBar: AppBar(
leading: const PageBackButton(),
title: Text('accountSettingsNotify').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainer,
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.save),
title: Text('save').tr(),
enabled: !_isBusy,
onTap: () {
_savePreferences();
},
),
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: kNotifyTopicMap.length,
itemBuilder: (context, index) {
final element = kNotifyTopicMap.entries.elementAt(index);
return CheckboxListTile(
title: Text(element.value),
subtitle: Text(
element.key,
style: GoogleFonts.robotoMono(fontSize: 12),
),
value: _config[element.key] ?? true,
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onChanged: (value) {
setState(() {
_config[element.key] = value ?? false;
});
},
);
},
),
),
],
),
);
}
}

View File

@ -0,0 +1,148 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class AccountSecurityPrefsScreen extends StatefulWidget {
const AccountSecurityPrefsScreen({super.key});
@override
State<AccountSecurityPrefsScreen> createState() =>
_AccountSecurityPrefsScreenState();
}
class _AccountSecurityPrefsScreenState
extends State<AccountSecurityPrefsScreen> {
bool _isBusy = true;
Map<String, dynamic> _config = {
'maximum_auth_steps': 2,
'always_risky': false,
};
Future<void> _getPreferences() async {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
try {
final resp = await sn.client.get('/cgi/id/preferences/auth');
_config = resp.data['config']
.map((k, v) => MapEntry(k, v as bool))
.cast<String, bool>();
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _savePreferences() async {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
try {
await sn.client.put(
'/cgi/id/preferences/auth',
data: {
'config': _config,
},
);
if (!mounted) return;
context.showSnackbar('accountSettingsApplied'.tr());
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_getPreferences();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
noBackground: true,
appBar: AppBar(
leading: const PageBackButton(),
title: Text('accountSettingsSecurity').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainer,
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.save),
title: Text('save').tr(),
enabled: !_isBusy,
onTap: () {
_savePreferences();
},
),
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
ListTile(
title: Text('authMaximumAuthSteps').tr(),
subtitle: Text('authMaximumAuthStepsDescription')
.plural(_config['maximum_auth_steps'] ?? 2),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
padding: EdgeInsets.zero,
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
icon: const Icon(Symbols.remove),
onPressed: () {
if (_config['maximum_auth_steps'] > 1) {
setState(() => _config['maximum_auth_steps']--);
}
},
),
IconButton(
padding: EdgeInsets.zero,
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
icon: const Icon(Symbols.add),
onPressed: () {
if (_config['maximum_auth_steps'] < 99) {
setState(() => _config['maximum_auth_steps']++);
}
},
),
],
),
),
CheckboxListTile(
title: Text('authAlwaysRisky').tr(),
subtitle: Text('authAlwaysRiskyDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
value: _config['always_risky'] ?? false,
onChanged: (value) {
setState(() => _config['always_risky'] = value);
},
),
],
),
),
],
),
);
}
}

View File

@ -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,15 +57,21 @@ 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 =
_birthdayController.text = DateFormat(_kDateFormat).format( prof.profile!.links.entries.map((ele) => (ele.key, ele.value)).toList();
prof.profile!.birthday!.toLocal(), _birthday = prof.profile!.birthday?.toLocal();
); if (_birthday != null) {
_birthdayController.text =
DateFormat(_kDateFormat).format(prof.profile!.birthday!.toLocal());
} }
} }
@ -69,9 +81,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
builder: (BuildContext context) => Container( 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:
bottom: MediaQuery.of(context).viewInsets.bottom, EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
),
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: SafeArea( child: SafeArea(
top: false, top: false,
@ -82,7 +93,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
onDateTimeChanged: (DateTime newDate) { onDateTimeChanged: (DateTime newDate) {
setState(() { setState(() {
_birthday = newDate; _birthday = newDate;
_birthdayController.text = DateFormat(_kDateFormat).format(_birthday!); _birthdayController.text =
DateFormat(_kDateFormat).format(_birthday!);
}); });
}, },
), ),
@ -96,32 +108,45 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
if (image == null) return; if (image == null) return;
if (!mounted) return; if (!mounted) return;
final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path)); final skipCrop = image.path.endsWith('.gif');
final aspectRatios =
place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
)
: await showMaterialImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
);
if (result == null) return; Uint8List? rawBytes;
if (!skipCrop) {
final ImageProvider imageProvider =
kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
final aspectRatios = place == 'banner'
? [CropAspectRatio(width: 16, height: 7)]
: [CropAspectRatio(width: 1, height: 1)];
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
)
: await showMaterialImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
);
if (result == null) return;
if (!mounted) return;
setState(() => _isBusy = true);
rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!
.buffer
.asUint8List();
} else {
if (!mounted) return;
setState(() => _isBusy = true);
rawBytes = await image.readAsBytes();
}
if (!mounted) return; if (!mounted) return;
final attach = context.read<SnAttachmentProvider>(); final attach = context.read<SnAttachmentProvider>();
setState(() => _isBusy = true);
final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
try { try {
final attachment = await attach.directUploadOne( final attachment = await attach.directUploadOne(
rawBytes, rawBytes,
@ -133,10 +158,8 @@ 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
'/cgi/id/users/me/$place', .put('/cgi/id/users/me/$place', data: {'attachment': attachment.rid});
data: {'attachment': attachment.rid},
);
if (!mounted) return; if (!mounted) return;
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
@ -166,7 +189,16 @@ 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 +229,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 +244,10 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
return AppScaffold( return AppScaffold(
noBackground: true,
appBar: AppBar( appBar: AppBar(
leading: const PageBackButton(), leading: const PageBackButton(),
title: Text('screenAccountProfileEdit').tr(), title: Text('screenAccountProfileEdit').tr()),
),
body: SingleChildScrollView( body: SingleChildScrollView(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -229,12 +265,13 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
child: AspectRatio( child: AspectRatio(
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: _banner != null
? AutoResizeUniversalImage( ? AutoResizeUniversalImage(
sn.getAttachmentUrl(_banner!), sn.getAttachmentUrl(_banner!),
fit: BoxFit.cover, fit: BoxFit.cover)
)
: const SizedBox.shrink(), : const SizedBox.shrink(),
), ),
), ),
@ -262,6 +299,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 +309,17 @@ 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(), border: const UnderlineInputBorder(),
labelText: 'fieldNickname'.tr(), labelText: 'fieldNickname'.tr()),
), onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(4),
Row( Row(
children: [ children: [
Flexible( Flexible(
@ -291,6 +330,8 @@ 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 +343,189 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
border: const UnderlineInputBorder(), border: const UnderlineInputBorder(),
labelText: 'fieldLastName'.tr(), labelText: 'fieldLastName'.tr(),
), ),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
],
),
Row(
children: [
Flexible(
flex: 1,
child: TextField(
controller: _genderController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldGender'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
const Gap(4),
Flexible(
flex: 1,
child: TextField(
controller: _pronounsController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldPronouns'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
), ),
), ),
], ],
), ),
const Gap(4),
TextField( TextField(
controller: _descriptionController, controller: _descriptionController,
keyboardType: TextInputType.multiline, keyboardType: TextInputType.multiline,
maxLines: null, maxLines: null,
minLines: 3, minLines: 3,
decoration: InputDecoration( decoration: InputDecoration(
border: const UnderlineInputBorder(), border: const UnderlineInputBorder(),
labelText: 'fieldDescription'.tr(), labelText: 'fieldDescription'.tr()),
), onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: TextField(
controller: _timezoneController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldTimeZone'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
const Gap(4),
StyledWidget(
IconButton(
icon: const Icon(Symbols.calendar_month),
visualDensity:
VisualDensity(horizontal: -4, vertical: -4),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () async {
_timezoneController.text =
await FlutterTimezone.getLocalTimezone();
},
),
).padding(top: 6),
const Gap(4),
StyledWidget(
IconButton(
icon: const Icon(Symbols.clear),
visualDensity:
VisualDensity(horizontal: -4, vertical: -4),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
_timezoneController.clear();
},
),
).padding(top: 6),
],
),
TextField(
controller: _locationController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldLocation'.tr()),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(4),
TextField( TextField(
controller: _birthdayController, controller: _birthdayController,
readOnly: true, readOnly: true,
decoration: InputDecoration( decoration: InputDecoration(
border: const UnderlineInputBorder(), border: const UnderlineInputBorder(),
labelText: 'fieldBirthday'.tr(), 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 +539,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
), ),
], ],
).padding(horizontal: padding), ).padding(horizontal: padding),
Gap(MediaQuery.of(context).padding.bottom),
], ],
), ),
), ),

View File

@ -1,3 +1,4 @@
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';
@ -18,10 +19,13 @@ import 'package:surface/types/account.dart';
import 'package:surface/types/check_in.dart'; import 'package:surface/types/check_in.dart';
import 'package:surface/types/post.dart'; 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/account/badge.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 +36,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 +72,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 +94,18 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
} }
} }
Future<List<SnCheckInRecord>> _getCheckInRecords() async { List<SnCheckInRecord>? _records;
Future<void> _getCheckInRecords() async {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14'); final resp =
return List.from( await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14');
resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [], setState(() {
); _records = List.from(
resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [],
);
});
} catch (err) { } catch (err) {
if (mounted) context.showErrorDialog(err); if (mounted) context.showErrorDialog(err);
rethrow; rethrow;
@ -98,7 +133,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 +180,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 +197,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 +227,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 =
math.min((_appBarWidth * kBannerAspectRatio), 360).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 +246,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
_fetchStatus(); _fetchStatus();
_fetchPublishers(); _fetchPublishers();
_getCheckInRecords();
try { try {
final rel = context.read<SnRelationshipProvider>(); final rel = context.read<SnRelationshipProvider>();
@ -260,18 +302,20 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
text: TextSpan(children: [ text: TextSpan(children: [
TextSpan( TextSpan(
text: _account!.nick, text: _account!.nick,
style: Theme.of(context).textTheme.titleLarge!.copyWith( style:
color: Colors.white, Theme.of(context).textTheme.titleLarge!.copyWith(
shadows: labelShadows, color: Colors.white,
), shadows: labelShadows,
),
), ),
const TextSpan(text: '\n'), const TextSpan(text: '\n'),
TextSpan( TextSpan(
text: '@${_account!.name}', text: '@${_account!.name}',
style: Theme.of(context).textTheme.bodySmall!.copyWith( style:
color: Colors.white, Theme.of(context).textTheme.bodySmall!.copyWith(
shadows: labelShadows, color: Colors.white,
), shadows: labelShadows,
),
), ),
]), ]),
), ),
@ -280,14 +324,21 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
? Stack( ? Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
UniversalImage( if (_account!.banner.isNotEmpty)
sn.getAttachmentUrl(_account!.banner), UniversalImage(
fit: BoxFit.cover, sn.getAttachmentUrl(_account!.banner),
height: imageHeight, fit: BoxFit.cover,
width: _appBarWidth, height: imageHeight,
cacheHeight: imageHeight, width: _appBarWidth,
cacheWidth: _appBarWidth, cacheHeight: imageHeight,
), cacheWidth: _appBarWidth,
)
else
Container(
color: Theme.of(context)
.colorScheme
.surfaceContainerHigh,
),
Positioned( Positioned(
top: 0, top: 0,
left: 0, left: 0,
@ -339,7 +390,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,27 +441,41 @@ 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(
children: [ children: [
Icon( Icon(
Symbols.circle, (_status?.isDisturbable ?? true)
fill: 1, ? Symbols.circle
: Symbols.do_not_disturb_on,
fill: (_status?.isOnline ?? false) ? 1 : 0,
size: 16, size: 16,
color: (_status?.isOnline ?? false) ? Colors.green : Colors.grey, color: (_status?.isOnline ?? false)
? (_status?.isDisturbable ?? true)
? Colors.green
: Colors.red
: Colors.grey,
).padding(all: 4), ).padding(all: 4),
const Gap(8), const Gap(8),
Text( Text(
_status != null _status != null
? _status!.isOnline ? (_status!.status?.label.isNotEmpty ?? false)
? 'accountStatusOnline'.tr() ? _status!.status!.label
: 'accountStatusOffline'.tr() : _status!.isOnline
? 'accountStatusOnline'.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
@ -424,30 +490,10 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
), ),
const Gap(8), const Gap(8),
Wrap( Wrap(
spacing: 4,
runSpacing: 4,
children: _account!.badges children: _account!.badges
.map( .map((ele) => AccountBadge(badge: ele))
(ele) => Tooltip(
richMessage: TextSpan(
children: [
TextSpan(text: kBadgesMeta[ele.type]?.$1.tr() ?? 'unknown'.tr()),
if (ele.metadata['title'] != null)
TextSpan(
text: '\n${ele.metadata['title']}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: '\n'),
TextSpan(
text: DateFormat.yMEd().format(ele.createdAt),
),
],
),
child: Icon(
kBadgesMeta[ele.type]?.$2 ?? Symbols.question_mark,
color: kBadgesMeta[ele.type]?.$3,
fill: 1,
),
),
)
.toList(), .toList(),
).padding(horizontal: 8), ).padding(horizontal: 8),
const Gap(8), const Gap(8),
@ -458,7 +504,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 +523,44 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
]), ]),
], ],
), ),
if (_account!.profile!.gender.isNotEmpty ||
_account!.profile!.pronouns.isNotEmpty)
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.wc),
const Gap(8),
Text(
_account!.profile!.gender.isNotEmpty
? _account!.profile!.gender
: 'unknown'.tr(),
),
Text(' · ').padding(horizontal: 4),
Text(
_account!.profile!.pronouns.isNotEmpty
? _account!.profile!.pronouns
: 'unknown'.tr(),
),
],
),
if (_account!.profile!.timeZone.isNotEmpty)
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.schedule),
const Gap(8),
Text(_account!.profile!.timeZone),
],
),
if (_account!.profile!.location.isNotEmpty)
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.location_on),
const Gap(8),
Text(_account!.profile!.location),
],
),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
@ -491,17 +577,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 +604,46 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
], ],
).padding(all: 16), ).padding(all: 16),
), ),
if (_account?.profile?.links.isNotEmpty ?? false)
SliverToBoxAdapter(child: const Divider()),
if (_account?.profile?.links.isNotEmpty ?? false)
SliverToBoxAdapter(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: _account!.profile!.links.entries.map((ele) {
return ListTile(
leading: const Icon(Symbols.link),
title: Text(ele.key),
subtitle: Text(ele.value),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
launchUrlString(ele.value);
},
);
}).toList(),
),
),
SliverToBoxAdapter(child: const Divider()), SliverToBoxAdapter(child: const Divider()),
const SliverGap(12), const SliverGap(12),
SliverToBoxAdapter( SliverToBoxAdapter(
child: FutureBuilder<List<SnCheckInRecord>>( child: Builder(
future: _getCheckInRecords(), builder: (context) {
builder: (context, snapshot) { if (_records == null) return const SizedBox.shrink();
if (!snapshot.hasData) return const SizedBox.shrink(); if (_records!.length <= 1) {
if (snapshot.data!.length <= 1) {
return Text( return Text(
'accountCheckInNoRecords', 'accountCheckInNoRecords',
textAlign: TextAlign.center, textAlign: TextAlign.center,
).tr().fontWeight(FontWeight.bold).center().padding(horizontal: 20, vertical: 8); )
.tr()
.fontWeight(FontWeight.bold)
.center()
.padding(horizontal: 20, vertical: 8);
} }
final records = snapshot.data!;
return SizedBox( return SizedBox(
width: double.infinity, width: double.infinity,
height: 240, height: 240,
child: CheckInRecordChart(records: records), child: CheckInRecordChart(records: _records!),
).padding( ).padding(
right: 24, right: 24,
left: 16, left: 16,
@ -540,45 +655,55 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
const SliverGap(12), const SliverGap(12),
SliverToBoxAdapter(child: const Divider()), SliverToBoxAdapter(child: const Divider()),
const SliverGap(12), const SliverGap(12),
SliverToBoxAdapter( if (_account?.badges.isNotEmpty ?? false)
child: Column( SliverToBoxAdapter(
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Text('accountBadge').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4), children: [
SizedBox( Text('accountBadge')
height: 80, .bold()
width: double.infinity, .fontSize(17)
child: ListView( .tr()
padding: EdgeInsets.symmetric(horizontal: 8), .padding(horizontal: 20, bottom: 4),
scrollDirection: Axis.horizontal, SizedBox(
children: [ height: 80,
for (final badge in _account?.badges ?? []) width: double.infinity,
SizedBox( child: ListView(
width: 280, padding: EdgeInsets.symmetric(horizontal: 8),
child: Card( scrollDirection: Axis.horizontal,
child: ListTile( children: [
leading: Icon( for (final badge in _account?.badges ?? [])
kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark, SizedBox(
color: kBadgesMeta[badge.type]?.$3, width: 280,
fill: 1, child: Card(
child: ListTile(
leading: Icon(
kBadgesMeta[badge.type]?.$2 ??
Symbols.question_mark,
color: badge.metadata['color'] != null
? HexColor.fromHex(
badge.metadata['color']!)
: kBadgesMeta[badge.type]?.$3,
fill: 1,
),
title: Text(
kBadgesMeta[badge.type]?.$1 ?? 'unknown',
).tr(),
subtitle: badge.metadata['title'] != null
? Text(badge.metadata['title'])
: Text(
DateFormat('y/M/d')
.format(badge.createdAt),
),
), ),
title: Text(
kBadgesMeta[badge.type]?.$1 ?? 'unknown',
).tr(),
subtitle: badge.metadata['title'] != null
? Text(badge.metadata['title'])
: Text(
DateFormat('y/M/d').format(badge.createdAt),
),
), ),
), ),
), ],
], ),
), ),
), ],
], ),
), ),
),
const SliverGap(8), const SliverGap(8),
SliverToBoxAdapter(child: const Divider()), SliverToBoxAdapter(child: const Divider()),
SliverList.builder( SliverList.builder(
@ -664,7 +789,8 @@ class CheckInRecordChart extends StatelessWidget {
), ),
) )
.toList(), .toList(),
getTooltipColor: (_) => Theme.of(context).colorScheme.surfaceContainerHigh, getTooltipColor: (_) =>
Theme.of(context).colorScheme.surfaceContainerHigh,
), ),
), ),
titlesData: FlTitlesData( titlesData: FlTitlesData(

View File

@ -0,0 +1,291 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/experience.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/account.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class AccountProgramScreen extends StatefulWidget {
const AccountProgramScreen({super.key});
@override
State<AccountProgramScreen> createState() => _AccountProgramScreenState();
}
class _AccountProgramScreenState extends State<AccountProgramScreen> {
bool _isBusy = false;
final List<SnProgram> _programs = List.empty(growable: true);
final List<SnProgramMember> _programMembers = List.empty(growable: true);
Future<void> _fetchPrograms() async {
_programs.clear();
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/programs');
_programs.addAll(
resp.data.map((ele) => SnProgram.fromJson(ele)).cast<SnProgram>(),
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _fetchProgramMembers() async {
_programMembers.clear();
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/programs/members');
_programMembers.addAll(
resp.data
.map((ele) => SnProgramMember.fromJson(ele))
.cast<SnProgramMember>(),
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchPrograms();
_fetchProgramMembers();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
noBackground: true,
appBar: AppBar(
title: Text('accountProgram').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: _programs.length,
itemBuilder: (context, idx) {
final ele = _programs[idx];
return Card(
child: InkWell(
borderRadius: BorderRadius.all(Radius.circular(8)),
onTap: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => _ProgramJoinPopup(
program: ele,
isJoined:
_programMembers.any((e) => e.programId == ele.id),
),
).then((value) {
if (value == true) {
_fetchProgramMembers();
}
});
},
child: Column(
children: [
if (ele.appearance['banner'] != null)
AspectRatio(
aspectRatio: 16 / 5,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: Theme.of(context)
.colorScheme
.surfaceVariant,
child: Image.network(
ele.appearance['banner'],
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
ele.name,
style: Theme.of(context)
.textTheme
.titleMedium,
).bold(),
Text(
ele.description,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
if (_programMembers
.any((e) => e.programId == ele.id))
Text('accountProgramAlreadyJoined'.tr())
.opacity(0.75),
],
),
),
],
),
),
],
),
),
).padding(horizontal: 8);
},
),
),
],
),
);
}
}
class _ProgramJoinPopup extends StatefulWidget {
final SnProgram program;
final bool isJoined;
const _ProgramJoinPopup({required this.program, required this.isJoined});
@override
State<_ProgramJoinPopup> createState() => _ProgramJoinPopupState();
}
class _ProgramJoinPopupState extends State<_ProgramJoinPopup> {
bool _isBusy = false;
Future<void> _joinProgram() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.post('/cgi/id/programs/${widget.program.id}');
if (!mounted) return;
Navigator.pop(context, true);
context.showSnackbar('accountProgramJoined'.tr());
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _leaveProgram() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.delete('/cgi/id/programs/${widget.program.id}');
if (!mounted) return;
Navigator.pop(context, true);
context.showSnackbar('accountProgramLeft'.tr());
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: MediaQuery.of(context).size.height * 0.75,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.add, size: 24),
const Gap(16),
Text(
'accountProgramJoin',
style: Theme.of(context).textTheme.titleLarge,
).tr(),
],
).padding(horizontal: 20, top: 16, bottom: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.program.appearance['banner'] != null)
AspectRatio(
aspectRatio: 16 / 5,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: Theme.of(context).colorScheme.surfaceVariant,
child: Image.network(
widget.program.appearance['banner'],
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
).padding(bottom: 12),
Text(
widget.program.name,
style: Theme.of(context).textTheme.titleMedium,
).bold(),
MarkdownTextContent(content: widget.program.description),
const Gap(8),
Text(
'accountProgramJoinRequirements',
style: Theme.of(context).textTheme.titleMedium,
).tr().bold(),
Text('≥EXP ${widget.program.expRequirement}'),
Text('≥Lv${getLevelFromExp(widget.program.expRequirement)}'),
const Gap(8),
Text(
'accountProgramJoinPricing',
style: Theme.of(context).textTheme.titleMedium,
).tr().bold(),
Text('walletCurrency${widget.program.price['currency'].toString().capitalize().replaceFirst('Normal', '')}')
.plural(widget.program.price['amount'].toDouble()),
Text('accountProgramJoinPricingHint').tr().opacity(0.75),
const Gap(8),
if (widget.isJoined)
Text('accountProgramLeaveHint')
.tr()
.opacity(0.75)
.padding(bottom: 8),
if (!widget.isJoined)
ElevatedButton(
onPressed: _isBusy ? null : _joinProgram,
child: Text('join').tr(),
)
else
ElevatedButton(
onPressed: _isBusy ? null : _leaveProgram,
child: Text('leave').tr(),
),
],
).padding(horizontal: 24),
Gap(MediaQuery.of(context).padding.bottom),
],
),
),
);
}
}

View File

@ -27,10 +27,12 @@ class AccountPublisherEditScreen extends StatefulWidget {
const AccountPublisherEditScreen({super.key, required this.name}); const AccountPublisherEditScreen({super.key, required this.name});
@override @override
State<AccountPublisherEditScreen> createState() => _AccountPublisherEditScreenState(); State<AccountPublisherEditScreen> createState() =>
_AccountPublisherEditScreenState();
} }
class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> { class _AccountPublisherEditScreenState
extends State<AccountPublisherEditScreen> {
bool _isBusy = false; bool _isBusy = false;
SnPublisher? _publisher; SnPublisher? _publisher;
@ -68,16 +70,19 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
await sn.client.put('/cgi/co/publishers/${widget.name}', data: { await sn.client.put(
'avatar': _avatar, '/cgi/co/publishers/${widget.name}',
'banner': _banner, data: {
'nick': _nickController.text, 'avatar': _avatar,
'name': _nameController.text, 'banner': _banner,
'description': _descriptionController.text, 'nick': _nickController.text,
}); 'name': _nameController.text,
'description': _descriptionController.text,
},
);
if (mounted) Navigator.pop(context, true); if (mounted) Navigator.pop(context, true);
} catch (err) { } catch (err) {
if(mounted) context.showErrorDialog(err); if (mounted) context.showErrorDialog(err);
} finally { } finally {
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
@ -97,7 +102,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
_banner = ua.user!.banner; _banner = ua.user!.banner;
_nickController.text = ua.user!.nick; _nickController.text = ua.user!.nick;
_nameController.text = ua.user!.name; _nameController.text = ua.user!.name;
_descriptionController.text = ua.user!.description; _descriptionController.text = ua.user!.profile!.description;
setState(() {}); setState(() {});
} }
@ -108,32 +113,45 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
if (image == null) return; if (image == null) return;
if (!mounted) return; if (!mounted) return;
final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path)); final skipCrop = image.path.endsWith('.gif');
final aspectRatios =
place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
)
: await showMaterialImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
);
if (result == null) return; Uint8List? rawBytes;
if (!skipCrop) {
final ImageProvider imageProvider =
kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
final aspectRatios = place == 'banner'
? [CropAspectRatio(width: 16, height: 7)]
: [CropAspectRatio(width: 1, height: 1)];
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
)
: await showMaterialImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
);
if (result == null) return;
if (!mounted) return;
setState(() => _isBusy = true);
rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!
.buffer
.asUint8List();
} else {
if (!mounted) return;
setState(() => _isBusy = true);
rawBytes = await image.readAsBytes();
}
if (!mounted) return; if (!mounted) return;
final attach = context.read<SnAttachmentProvider>(); final attach = context.read<SnAttachmentProvider>();
setState(() => _isBusy = true);
final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
try { try {
final attachment = await attach.directUploadOne( final attachment = await attach.directUploadOne(
rawBytes, rawBytes,
@ -178,10 +196,10 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
return AppScaffold( return AppScaffold(
noBackground: true,
appBar: AppBar( appBar: AppBar(
leading: PageBackButton(), leading: PageBackButton(),
title: Text('screenAccountPublisherEdit').tr(), title: Text('screenAccountPublisherEdit').tr()),
),
body: SingleChildScrollView( body: SingleChildScrollView(
child: Column( child: Column(
children: [ children: [
@ -198,12 +216,13 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
child: AspectRatio( child: AspectRatio(
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: _banner != null
? AutoResizeUniversalImage( ? AutoResizeUniversalImage(
sn.getAttachmentUrl(_banner!), sn.getAttachmentUrl(_banner!),
fit: BoxFit.cover, fit: BoxFit.cover)
)
: const SizedBox.shrink(), : const SizedBox.shrink(),
), ),
), ),
@ -237,25 +256,24 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
labelText: 'fieldUsername'.tr(), labelText: 'fieldUsername'.tr(),
helperText: 'fieldUsernameCannotEditHint'.tr(), helperText: 'fieldUsernameCannotEditHint'.tr(),
), ),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
), ),
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),
TextField( TextField(
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),
Row( Row(
@ -275,7 +293,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),
), ),

View File

@ -25,7 +25,8 @@ class _AccountPublisherNewScreenState extends State<AccountPublisherNewScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AppScaffold( return AppScaffold(
noBackground: true,
appBar: AppBar( appBar: AppBar(
leading: const PageBackButton(), leading: const PageBackButton(),
title: Text('screenAccountPublisherNew').tr(), title: Text('screenAccountPublisherNew').tr(),
@ -109,7 +110,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

View File

@ -33,7 +33,8 @@ class _PublisherScreenState extends State<PublisherScreen> {
try { try {
final resp = await sn.client.get('/cgi/co/publishers/me'); final resp = await sn.client.get('/cgi/co/publishers/me');
final List<SnPublisher> out = List<SnPublisher>.from(resp.data?.map((e) => SnPublisher.fromJson(e)) ?? []); final List<SnPublisher> out = List<SnPublisher>.from(
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? []);
if (!mounted) return; if (!mounted) return;
@ -81,6 +82,7 @@ class _PublisherScreenState extends State<PublisherScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AppScaffold( return AppScaffold(
noBackground: true,
appBar: AppBar( appBar: AppBar(
leading: const PageBackButton(), leading: const PageBackButton(),
title: Text('screenAccountPublishers').tr(), title: Text('screenAccountPublishers').tr(),
@ -93,7 +95,9 @@ class _PublisherScreenState extends State<PublisherScreen> {
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.add_circle), leading: const Icon(Symbols.add_circle),
onTap: () { onTap: () {
GoRouter.of(context).pushNamed('accountPublisherNew').then((value) { GoRouter.of(context)
.pushNamed('accountPublisherNew')
.then((value) {
if (value == true) { if (value == true) {
_publishers.clear(); _publishers.clear();
_fetchPublishers(); _fetchPublishers();
@ -119,7 +123,8 @@ class _PublisherScreenState extends State<PublisherScreen> {
return ListTile( return ListTile(
title: Text(publisher.nick), title: Text(publisher.nick),
subtitle: Text('@${publisher.name}'), subtitle: Text('@${publisher.name}'),
contentPadding: const EdgeInsets.symmetric(horizontal: 16), contentPadding:
const EdgeInsets.symmetric(horizontal: 16),
leading: AccountImage(content: publisher.avatar), leading: AccountImage(content: publisher.avatar),
trailing: PopupMenuButton( trailing: PopupMenuButton(
itemBuilder: (BuildContext context) => [ itemBuilder: (BuildContext context) => [

View File

@ -0,0 +1,187 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/account.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
const kPunishmentIcons = [
Symbols.warning,
Symbols.emergency_home,
Symbols.dangerous,
];
class PunishmentsScreen extends StatefulWidget {
const PunishmentsScreen({super.key});
@override
State<PunishmentsScreen> createState() => _PunishmentsScreenState();
}
class _PunishmentsScreenState extends State<PunishmentsScreen> {
bool _isBusy = false;
List<SnPunishment>? _punishments;
Future<void> _fetchPunishments() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/punishments');
if (!mounted) return;
_punishments = List.from(
resp.data.map((ele) => SnPunishment.fromJson(ele)),
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchPunishments();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
noBackground: true,
appBar: AppBar(
title: Text('accountPunishments').tr(),
leading: PageBackButton(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
Card(
margin: EdgeInsets.only(bottom: 8, left: 8, right: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Symbols.visibility, size: 20),
const Gap(6),
Expanded(
child: Text('punishmentOverall').tr().fontSize(16).bold(),
),
],
),
Builder(
builder: (context) {
if (_punishments == null) return Text('loading').tr();
if (_punishments!.any((ele) => ele.type == 2)) {
return Text('punishmentStatusBanned').tr();
}
if (_punishments!.any(
(ele) => ele.type == 1 && ele.permNodes.isEmpty,
)) {
return Text('punishmentStatusLimitedFully').tr();
} else if (_punishments!.any((ele) => ele.type == 1)) {
return Text('punishmentStatusLimited').tr();
}
if (_punishments!.any((ele) => ele.type == 0)) {
return Text('punishmentStatusWarned').tr();
}
return Text('punishmentStatusNormal').tr();
},
),
],
).padding(horizontal: 24, vertical: 16),
),
Expanded(
child: RefreshIndicator(
onRefresh: _fetchPunishments,
child: ListView.separated(
padding: EdgeInsets.zero,
itemCount: _punishments?.length ?? 0,
itemBuilder: (context, index) {
final ele = _punishments![index];
return Card(
margin: EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(kPunishmentIcons[ele.type], size: 20),
const Gap(6),
Expanded(
child: Text('punishmentType${ele.type}')
.tr()
.fontSize(16)
.bold(),
),
],
),
Text(ele.reason),
const Gap(4),
Text(
'punishmentCreatedAt'.tr(args: [
DateFormat().format(
ele.createdAt.toLocal(),
)
]),
).opacity(0.8),
Text(
ele.expiredAt == null
? 'punishmentExpiredNever'.tr()
: 'punishmentExpiredAt'.tr(args: [
DateFormat().format(
ele.expiredAt!.toLocal(),
)
]),
).opacity(0.8),
const Gap(8),
if (ele.moderator != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('punishmentModerator').tr().opacity(0.75),
InkWell(
child: Row(
children: [
AccountImage(
content: ele.moderator!.avatar,
radius: 8,
),
const Gap(4),
Text(ele.moderator?.nick ?? 'unknown'),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'accountProfilePage',
pathParameters: {
'name': ele.moderator!.name,
},
);
},
),
],
)
else
Text('punishmentMadeBySystem').tr().opacity(0.75),
],
).padding(horizontal: 24, vertical: 16),
);
},
separatorBuilder: (_, __) => const Gap(8),
),
),
),
],
),
);
}
}

View File

@ -37,6 +37,7 @@ class AccountSettingsScreen extends StatelessWidget {
final ua = context.watch<UserProvider>(); final ua = context.watch<UserProvider>();
return AppScaffold( return AppScaffold(
noBackground: true,
appBar: AppBar( appBar: AppBar(
leading: PageBackButton(), leading: PageBackButton(),
title: Text('screenAccountSettings').tr(), title: Text('screenAccountSettings').tr(),
@ -54,14 +55,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);
@ -81,6 +88,46 @@ class AccountSettingsScreen extends StatelessWidget {
), ),
), ),
), ),
ListTile(
title: Text('accountContactMethods').tr(),
subtitle: Text('accountContactMethodsDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.contacts),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountContactMethods');
},
),
ListTile(
title: Text('accountSettingsNotify').tr(),
subtitle: Text('accountSettingsNotifyDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.notifications),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountSettingsNotify');
},
),
ListTile(
title: Text('accountSettingsSecurity').tr(),
subtitle: Text('accountSettingsSecurityDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.shield),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountSettingsSecurity');
},
),
ListTile(
title: Text('factorSettings').tr(),
subtitle: Text('factorSettingsSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.lock),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('factorSettings');
},
),
ListTile( ListTile(
title: Text('accountProfileEdit').tr(), title: Text('accountProfileEdit').tr(),
subtitle: Text('accountProfileEditSubtitle').tr(), subtitle: Text('accountProfileEditSubtitle').tr(),

View File

@ -2,12 +2,14 @@ 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';
import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/user_directory.dart';
import 'package:surface/types/attachment.dart'; import 'package:surface/types/attachment.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/attachment/attachment_zoom.dart'; import 'package:surface/widgets/attachment/attachment_zoom.dart';
import 'package:surface/widgets/attachment/attachment_item.dart'; import 'package:surface/widgets/attachment/attachment_item.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
@ -27,9 +29,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 +78,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) {
@ -88,9 +105,53 @@ class _AlbumScreenState extends State<AlbumScreen> {
controller: _scrollController, controller: _scrollController,
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
leading: AutoAppBarLeading(), leading: PageBackButton(),
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,

View File

@ -7,6 +7,7 @@ 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';
import 'package:surface/screens/captcha/captcha.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:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
@ -33,10 +34,20 @@ class _RegisterScreenState extends State<RegisterScreen> {
final username = _usernameController.value.text; final username = _usernameController.value.text;
final nickname = _nicknameController.value.text; final nickname = _nicknameController.value.text;
final password = _passwordController.value.text; final password = _passwordController.value.text;
if (email.isEmpty || username.isEmpty || nickname.isEmpty || password.isEmpty) { if (email.isEmpty ||
username.isEmpty ||
nickname.isEmpty ||
password.isEmpty) {
return; return;
} }
final captchaTk = await Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (context) => CaptchaScreen(),
),
);
if (captchaTk == null) return;
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
await sn.client.post('/cgi/id/users', data: { await sn.client.post('/cgi/id/users', data: {
@ -45,6 +56,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
'email': email, 'email': email,
'password': password, 'password': password,
'language': EasyLocalization.of(context)!.currentLocale.toString(), 'language': EasyLocalization.of(context)!.currentLocale.toString(),
'captcha_token': captchaTk,
}); });
if (!context.mounted) return; if (!context.mounted) return;
@ -91,8 +103,11 @@ class _RegisterScreenState extends State<RegisterScreen> {
children: [ children: [
TextFormField( TextFormField(
validator: (value) { validator: (value) {
if (value == null || value.length < 4 || value.length > 32) { if (value == null ||
return 'fieldUsernameLengthLimit'.tr(args: [4.toString(), 32.toString()]); value.length < 4 ||
value.length > 32) {
return 'fieldUsernameLengthLimit'
.tr(args: [4.toString(), 32.toString()]);
} }
if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value)) { if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value)) {
return 'fieldUsernameAlphanumOnly'.tr(); return 'fieldUsernameAlphanumOnly'.tr();
@ -108,13 +123,17 @@ class _RegisterScreenState extends State<RegisterScreen> {
border: const UnderlineInputBorder(), border: const UnderlineInputBorder(),
labelText: 'fieldUsername'.tr(), labelText: 'fieldUsername'.tr(),
), ),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(12), const Gap(12),
TextFormField( TextFormField(
validator: (value) { validator: (value) {
if (value == null || value.length < 4 || value.length > 32) { if (value == null ||
return 'fieldNicknameLengthLimit'.tr(args: [4.toString(), 32.toString()]); value.length < 4 ||
value.length > 32) {
return 'fieldNicknameLengthLimit'
.tr(args: [4.toString(), 32.toString()]);
} }
return null; return null;
}, },
@ -127,7 +146,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
border: const UnderlineInputBorder(), border: const UnderlineInputBorder(),
labelText: 'fieldNickname'.tr(), labelText: 'fieldNickname'.tr(),
), ),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(12), const Gap(12),
TextFormField( TextFormField(
@ -149,7 +169,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
border: const UnderlineInputBorder(), border: const UnderlineInputBorder(),
labelText: 'fieldEmail'.tr(), labelText: 'fieldEmail'.tr(),
), ),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(12), const Gap(12),
TextFormField( TextFormField(
@ -169,7 +190,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
border: const UnderlineInputBorder(), border: const UnderlineInputBorder(),
labelText: 'fieldPassword'.tr(), labelText: 'fieldPassword'.tr(),
), ),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
), ),
], ],
).padding(horizontal: 7), ).padding(horizontal: 7),
@ -186,9 +208,13 @@ class _RegisterScreenState extends State<RegisterScreen> {
Text( Text(
'termAcceptNextWithAgree'.tr(), 'termAcceptNextWithAgree'.tr(),
textAlign: TextAlign.end, textAlign: TextAlign.end,
style: Theme.of(context).textTheme.bodySmall!.copyWith( style:
color: Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round()), Theme.of(context).textTheme.bodySmall!.copyWith(
), color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha((255 * 0.75).round()),
),
), ),
Material( Material(
color: Colors.transparent, color: Colors.transparent,

View File

@ -0,0 +1,3 @@
import 'package:flutter/foundation.dart' show kIsWeb;
export 'captcha_native.dart' if (kIsWeb) 'captcha_web.dart';

View File

@ -0,0 +1,37 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class CaptchaScreen extends StatefulWidget {
const CaptchaScreen({super.key});
@override
State<CaptchaScreen> createState() => _CaptchaScreenState();
}
class _CaptchaScreenState extends State<CaptchaScreen> {
@override
Widget build(BuildContext context) {
final cfg = context.read<ConfigProvider>();
return AppScaffold(
appBar: AppBar(title: Text("reCaptcha").tr()),
body: InAppWebView(
initialUrlRequest: URLRequest(
url: WebUri('${cfg.serverUrl}/captcha?redirect_uri=solink://captcha'),
),
shouldOverrideUrlLoading: (controller, navigationAction) async {
Uri? url = navigationAction.request.url;
if (url != null && url.queryParameters.containsKey('captcha_tk')) {
Navigator.pop(context, url.queryParameters['captcha_tk']!);
return NavigationActionPolicy.CANCEL;
}
return NavigationActionPolicy.ALLOW;
},
),
);
}
}

View File

@ -0,0 +1,54 @@
import 'dart:html' as html;
import 'dart:ui_web' as ui;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class CaptchaScreen extends StatefulWidget {
const CaptchaScreen({super.key});
@override
State<CaptchaScreen> createState() => _CaptchaScreenState();
}
class _CaptchaScreenState extends State<CaptchaScreen> {
@override
void initState() {
super.initState();
_setupWebListener();
}
void _setupWebListener() {
html.window.onMessage.listen((event) {
if (event.data != null && event.data is String) {
final message = event.data as String;
if (message.startsWith("captcha_tk=")) {
String token = message.replaceFirst("captcha_tk=", "");
Navigator.pop(context, token);
}
}
});
final iframe = html.IFrameElement()
..src = '${context.read<ConfigProvider>().serverUrl}/captcha?redirect_uri=web'
..style.border = 'none'
..width = '100%'
..height = '100%';
html.document.body!.append(iframe);
ui.platformViewRegistry.registerViewFactory(
'captcha-iframe',
(int viewId) => iframe,
);
}
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(title: Text("reCaptcha").tr()),
body: HtmlElementView(viewType: 'captcha-iframe'),
);
}
}

View File

@ -1,13 +1,21 @@
import 'package:animations/animations.dart';
import 'package:collection/collection.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';
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:styled_widget/styled_widget.dart';
import 'package:surface/providers/channel.dart'; import 'package:surface/providers/channel.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/sn_realm.dart';
import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/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';
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';
@ -15,11 +23,9 @@ 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 'package:surface/widgets/unauthorized_hint.dart';
import 'package:surface/widgets/universal_image.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,21 +40,65 @@ class _ChatScreenState extends State<ChatScreen> {
List<SnChannel>? _channels; List<SnChannel>? _channels;
Map<int, SnChatMessage>? _lastMessages; Map<int, SnChatMessage>? _lastMessages;
Map<int, int>? _unreadCounts;
Map<int, int>? _unreadCountsGrouped;
void _refreshChannels() { Future<void> _fetchWhatsNew() async {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/im/whats-new');
if (resp.data == null) return;
final List<dynamic> out = resp.data;
setState(() {
_unreadCounts ??= {};
_unreadCountsGrouped ??= {};
for (var v in out) {
_unreadCounts![v['channel_id']] = v['count'];
final channel =
_channels?.firstWhereOrNull((ele) => ele.id == v['channel_id']);
if (channel != null) {
if (channel.realmId != null) {
_unreadCountsGrouped![channel.realmId!] ??= 0;
_unreadCountsGrouped![channel.realmId!] =
(_unreadCountsGrouped![channel.realmId!]! + v['count']).toInt();
}
if (channel.type == 1) {
_unreadCountsGrouped![0] ??= 0;
_unreadCountsGrouped![0] =
(_unreadCountsGrouped![0]! + v['count']).toInt();
}
}
}
});
}
void _refreshChannels({bool withBoost = false, bool noRemote = false}) {
final ct = context.read<ChatChannelProvider>();
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
if (!ua.isAuthorized) { if (!ua.isAuthorized) {
setState(() => _isBusy = false); setState(() => _isBusy = false);
return; return;
} }
if (!withBoost) {
if (!noRemote) {
ct.refreshAvailableChannels();
}
} else {
setState(() {
_channels = ct.availableChannels;
});
}
final chan = context.read<ChatChannelProvider>(); final chan = context.read<ChatChannelProvider>();
chan.fetchChannels().listen((channels) async { chan.fetchChannels(noRemote: true).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 +107,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);
}) })
@ -80,13 +132,15 @@ class _ChatScreenState extends State<ChatScreen> {
..onDone(() { ..onDone(() {
if (!mounted) return; if (!mounted) return;
setState(() => _isBusy = false); setState(() => _isBusy = false);
_fetchWhatsNew();
}); });
} }
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 +152,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();
@ -112,13 +167,48 @@ class _ChatScreenState extends State<ChatScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_refreshChannels(); _refreshChannels(withBoost: true);
} }
void _onTapChannel(SnChannel channel) {
setState(() => _unreadCounts?[channel.id] = 0);
if (ResponsiveScaffold.getIsExpand(context)) {
GoRouter.of(context).pushReplacementNamed(
'chatRoom',
pathParameters: {
'scope': channel.realm?.alias ?? 'global',
'alias': channel.alias,
},
).then((value) {
if (mounted) {
setState(() => _unreadCounts?[channel.id] = 0);
_refreshChannels(noRemote: true);
}
});
} else {
GoRouter.of(context).pushNamed(
'chatRoom',
pathParameters: {
'scope': channel.realm?.alias ?? 'global',
'alias': channel.alias,
},
).then((value) {
if (mounted) {
setState(() => _unreadCounts?[channel.id] = 0);
_refreshChannels(noRemote: true);
}
});
}
}
SnRealm? _focusedRealm;
bool _isDirect = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ud = context.read<UserDirectoryProvider>();
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
final sn = context.read<SnNetworkProvider>();
final rel = context.read<SnRealmProvider>();
if (!ua.isAuthorized) { if (!ua.isAuthorized) {
return AppScaffold( return AppScaffold(
@ -133,6 +223,7 @@ class _ChatScreenState extends State<ChatScreen> {
} }
return AppScaffold( return AppScaffold(
noBackground: true,
appBar: AppBar( appBar: AppBar(
leading: AutoAppBarLeading(), leading: AutoAppBarLeading(),
title: Text('screenChat').tr(), title: Text('screenChat').tr(),
@ -144,21 +235,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(
@ -195,94 +291,324 @@ class _ChatScreenState extends State<ChatScreen> {
body: Column( body: Column(
children: [ children: [
LoadingIndicator(isActive: _isBusy), LoadingIndicator(isActive: _isBusy),
Expanded( if (_channels != null && ResponsiveScaffold.getIsExpand(context))
child: MediaQuery.removePadding( Expanded(
context: context,
removeTop: true,
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () => Future.sync(() => _refreshChannels()), onRefresh: () => Future.sync(() => _refreshChannels()),
child: ListView.builder( child: Builder(builder: (context) {
itemCount: _channels?.length ?? 0, final scopeList = ListView(
itemBuilder: (context, idx) { key: const Key('realm-list-view'),
final channel = _channels![idx]; padding: EdgeInsets.zero,
final lastMessage = _lastMessages?[channel.id]; children: [
ListTile(
if (channel.type == 1) { minTileHeight: 48,
final otherMember = channel.members?.cast<SnChannelMember?>().firstWhere( leading:
(ele) => ele?.accountId != ua.user?.id, const Icon(Symbols.inbox_text).padding(right: 4),
orElse: () => null, contentPadding: EdgeInsets.only(left: 24, right: 24),
); title: Text('chatDirect').tr(),
trailing: Row(
return ListTile( mainAxisSize: MainAxisSize.min,
title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name), crossAxisAlignment: CrossAxisAlignment.center,
subtitle: lastMessage != null children: [
? Text( if (_unreadCountsGrouped?[0] != null &&
'${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}', (_unreadCountsGrouped?[0] ?? 0) > 0)
maxLines: 1, Badge(
overflow: TextOverflow.ellipsis, label: Text(
) _unreadCountsGrouped![0].toString(),
: 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( setState(() => _isDirect = true);
'chatRoom',
pathParameters: {
'scope': channel.realm?.alias ?? 'global',
'alias': channel.alias,
},
).then((value) {
if (mounted) _refreshChannels();
});
}, },
);
}
return ListTile(
title: Text(channel.name),
subtitle: lastMessage != null
? Text(
'${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: Text(
channel.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
leading: AccountImage(
content: null,
fallbackWidget: const Icon(Symbols.chat, size: 20),
), ),
onTap: () { ...rel.availableRealms.map((ele) {
GoRouter.of(context).pushNamed( return ListTile(
'chatRoom', minTileHeight: 48,
pathParameters: { contentPadding: EdgeInsets.only(left: 20, right: 24),
'scope': channel.realm?.alias ?? 'global', leading: AccountImage(
'alias': channel.alias, content: ele.avatar,
radius: 16,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (_unreadCountsGrouped?[ele.id] != null &&
(_unreadCountsGrouped?[ele.id] ?? 0) > 0)
Badge(
label: Text(
_unreadCountsGrouped![ele.id].toString(),
),
),
],
),
title: Text(ele.name),
onTap: () {
setState(() => _focusedRealm = ele);
}, },
).then((value) { );
if (value == true) _refreshChannels(); }),
}); ],
}, );
);
}, final directChatList = ListView(
key: Key('direct-chat-list-view'),
padding: EdgeInsets.zero,
children: [
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.arrow_left_alt),
contentPadding: EdgeInsets.only(left: 24),
title: Text('back').tr(),
onTap: () {
setState(() => _isDirect = false);
},
),
const Divider(height: 1),
..._channels!.where((ele) => ele.type == 1).map(
(ele) {
return _ChatChannelEntry(
channel: ele,
unreadCount: _unreadCounts?[ele.id],
lastMessage: _lastMessages?[ele.id],
isCompact: true,
onTap: () => _onTapChannel(ele),
);
},
)
],
);
final realmScopedChatList = _focusedRealm == null
? const SizedBox.shrink()
: ListView(
key: ValueKey(_focusedRealm),
padding: EdgeInsets.zero,
children: [
if (_focusedRealm!.banner != null)
AspectRatio(
aspectRatio: 16 / 9,
child: AutoResizeUniversalImage(
sn.getAttachmentUrl(
_focusedRealm!.banner!,
),
fit: BoxFit.cover,
),
),
ListTile(
minTileHeight: 48,
tileColor: Theme.of(context)
.colorScheme
.surfaceContainer,
leading: AccountImage(
content: _focusedRealm!.avatar,
radius: 16,
),
contentPadding: EdgeInsets.only(
left: 20,
right: 16,
),
trailing: IconButton(
icon: const Icon(Symbols.close),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
visualDensity: VisualDensity.compact,
onPressed: () {
setState(() => _focusedRealm = null);
},
),
title: Text(_focusedRealm!.name),
),
...(_channels!
.where(
(ele) => ele.realmId == _focusedRealm?.id)
.map(
(ele) {
return _ChatChannelEntry(
channel: ele,
unreadCount: _unreadCounts?[ele.id],
lastMessage: _lastMessages?[ele.id],
onTap: () => _onTapChannel(ele),
isCompact: true,
);
},
))
],
);
return PageTransitionSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
fillColor: Colors.transparent,
transitionType: SharedAxisTransitionType.horizontal,
child: child,
);
},
child: (_focusedRealm == null && !_isDirect)
? scopeList
: _isDirect
? directChatList
: realmScopedChatList,
);
}),
),
)
else if (_channels != null)
Expanded(
child: RefreshIndicator(
onRefresh: () => Future.sync(() => _refreshChannels()),
child: ListView(
key: const Key('chat-list-view'),
padding: EdgeInsets.zero,
children: [
...(_channels!.map((ele) {
return _ChatChannelEntry(
channel: ele,
unreadCount: _unreadCounts?[ele.id],
lastMessage: _lastMessages?[ele.id],
onTap: () => _onTapChannel(ele),
);
}))
],
), ),
), ),
), ),
),
], ],
), ),
); );
} }
} }
class _ChatChannelEntry extends StatelessWidget {
final SnChannel channel;
final int? unreadCount;
final SnChatMessage? lastMessage;
final Function? onTap;
final bool isCompact;
const _ChatChannelEntry({
required this.channel,
this.unreadCount,
this.lastMessage,
this.onTap,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
final ud = context.read<UserDirectoryProvider>();
final ua = context.read<UserProvider>();
final otherMember = channel.type == 1
? channel.members?.cast<SnChannelMember?>().firstWhere(
(ele) => ele?.accountId != ua.user?.id,
orElse: () => null,
)
: null;
final title = otherMember != null
? ud.getFromCache(otherMember.accountId)?.nick ?? channel.name
: channel.name;
if (isCompact) {
return ListTile(
minTileHeight: 48,
contentPadding:
EdgeInsets.only(left: otherMember != null ? 20 : 24, right: 24),
leading: otherMember != null
? AccountImage(
content: ud.getFromCache(otherMember.accountId)?.avatar,
radius: 16,
)
: const Icon(Symbols.tag),
trailing: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (unreadCount != null && (unreadCount ?? 0) > 0)
Badge(
label: Text(unreadCount.toString()),
),
],
),
title: Text(title),
onTap: () {
onTap?.call();
},
);
}
return ListTile(
title: Row(
children: [
Expanded(child: Text(title)),
const Gap(8),
if (unreadCount != null && unreadCount! > 0)
Badge(
label: Text(unreadCount.toString()),
),
],
),
subtitle: lastMessage != null
? Row(
children: [
Badge(
label: Text(
ud.getFromCache(lastMessage!.sender.accountId)?.nick ??
'unknown'.tr()),
backgroundColor: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
const Gap(6),
Expanded(
child: Text(
lastMessage!.body['algorithm'] == 'plain'
? lastMessage!.body['text'] ??
'messageUnablePreview'.tr()
: 'messageUnablePreviewEncrypted'.tr(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: lastMessage!.body['algorithm'] != 'plain' ||
lastMessage!.body['text'] == null
? TextStyle(fontStyle: FontStyle.italic)
: null,
),
),
const Gap(4),
Text(
DateFormat(
lastMessage!.createdAt.toLocal().day == DateTime.now().day
? 'HH:mm'
: lastMessage!.createdAt.toLocal().year ==
DateTime.now().year
? 'MM/dd'
: 'yy/MM/dd',
).format(lastMessage!.createdAt.toLocal()),
style: GoogleFonts.robotoMono(
fontSize: 12,
),
),
],
)
: Text(
channel.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
leading: AccountImage(
content: otherMember != null
? ud.getFromCache(otherMember.accountId)?.avatar
: channel.realm?.avatar,
fallbackWidget: const Icon(Symbols.tag, size: 20),
),
onTap: () => onTap?.call(),
);
}
}

View File

@ -37,7 +37,8 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
return Stack( return Stack(
children: [ children: [
Container( Container(
color: Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.75), color:
Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.75),
child: call.focusTrack != null child: call.focusTrack != null
? InteractiveParticipantWidget( ? InteractiveParticipantWidget(
isFixedAvatar: false, isFixedAvatar: false,
@ -72,7 +73,8 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
color: Theme.of(context).cardColor, color: Theme.of(context).cardColor,
participant: track, participant: track,
onTap: () { onTap: () {
if (track.participant.sid != call.focusTrack?.participant.sid) { if (track.participant.sid !=
call.focusTrack?.participant.sid) {
call.setFocusTrack(track); call.setFocusTrack(track);
} }
}, },
@ -114,10 +116,14 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
child: InteractiveParticipantWidget( child: InteractiveParticipantWidget(
color: Theme.of(context).colorScheme.surfaceContainerHigh.withOpacity(0.75), color: Theme.of(context)
.colorScheme
.surfaceContainerHigh
.withOpacity(0.75),
participant: track, participant: track,
onTap: () { onTap: () {
if (track.participant.sid != call.focusTrack?.participant.sid) { if (track.participant.sid !=
call.focusTrack?.participant.sid) {
call.setFocusTrack(track); call.setFocusTrack(track);
} }
}, },
@ -149,6 +155,7 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
listenable: call, listenable: call,
builder: (context, _) { builder: (context, _) {
return AppScaffold( return AppScaffold(
noBackground: true,
appBar: AppBar( appBar: AppBar(
title: RichText( title: RichText(
textAlign: TextAlign.center, textAlign: TextAlign.center,
@ -183,7 +190,8 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
Builder(builder: (context) { Builder(builder: (context) {
final call = context.read<ChatCallProvider>(); final call = context.read<ChatCallProvider>();
final connectionQuality = final connectionQuality =
call.room.localParticipant?.connectionQuality ?? livekit.ConnectionQuality.unknown; call.room.localParticipant?.connectionQuality ??
livekit.ConnectionQuality.unknown;
return Expanded( return Expanded(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -205,24 +213,35 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
children: [ children: [
Text( Text(
{ {
livekit.ConnectionState.disconnected: 'callStatusDisconnected'.tr(), livekit.ConnectionState.disconnected:
livekit.ConnectionState.connected: 'callStatusConnected'.tr(), 'callStatusDisconnected'.tr(),
livekit.ConnectionState.connecting: 'callStatusConnecting'.tr(), livekit.ConnectionState.connected:
livekit.ConnectionState.reconnecting: 'callStatusReconnecting'.tr(), 'callStatusConnected'.tr(),
livekit.ConnectionState.connecting:
'callStatusConnecting'.tr(),
livekit.ConnectionState.reconnecting:
'callStatusReconnecting'.tr(),
}[call.room.connectionState]!, }[call.room.connectionState]!,
), ),
const Gap(6), const Gap(6),
if (connectionQuality != livekit.ConnectionQuality.unknown) if (connectionQuality !=
livekit.ConnectionQuality.unknown)
Icon( Icon(
{ {
livekit.ConnectionQuality.excellent: Icons.signal_cellular_alt, livekit.ConnectionQuality.excellent:
livekit.ConnectionQuality.good: Icons.signal_cellular_alt_2_bar, Icons.signal_cellular_alt,
livekit.ConnectionQuality.poor: Icons.signal_cellular_alt_1_bar, livekit.ConnectionQuality.good:
Icons.signal_cellular_alt_2_bar,
livekit.ConnectionQuality.poor:
Icons.signal_cellular_alt_1_bar,
}[connectionQuality], }[connectionQuality],
color: { color: {
livekit.ConnectionQuality.excellent: Colors.green, livekit.ConnectionQuality.excellent:
livekit.ConnectionQuality.good: Colors.orange, Colors.green,
livekit.ConnectionQuality.poor: Colors.red, livekit.ConnectionQuality.good:
Colors.orange,
livekit.ConnectionQuality.poor:
Colors.red,
}[connectionQuality], }[connectionQuality],
size: 16, size: 16,
) )
@ -244,7 +263,9 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
Row( Row(
children: [ children: [
IconButton( IconButton(
icon: _layoutMode == 0 ? const Icon(Icons.view_list) : const Icon(Icons.grid_view), icon: _layoutMode == 0
? const Icon(Icons.view_list)
: const Icon(Icons.grid_view),
onPressed: () { onPressed: () {
_switchLayout(); _switchLayout();
}, },

View File

@ -57,10 +57,10 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
final sn = context.read<SnNetworkProvider>(); final ct = context.read<ChatChannelProvider>();
final resp = await sn.client.get('/cgi/im/channels/${_channel!.keyPath}/members/me'); final resp = await ct.getChannelProfile(_channel!);
_profile = SnChannelMember.fromJson(resp.data); _profile = resp;
_notifyLevel = _profile!.notify; _notifyLevel = resp.notify;
if (!mounted) return; if (!mounted) return;
final ud = context.read<UserDirectoryProvider>(); final ud = context.read<UserDirectoryProvider>();
await ud.getAccount(_profile!.accountId); await ud.getAccount(_profile!.accountId);
@ -102,10 +102,12 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
if (!mounted) return; if (!mounted) return;
try { try {
final ct = context.read<ChatChannelProvider>();
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
await sn.client.delete( await sn.client.delete(
'/cgi/im/channels/${_channel!.realm?.alias ?? 'global'}/${_channel!.id}/members/me', '/cgi/im/channels/${_channel!.realm?.alias ?? 'global'}/${_channel!.alias}/me',
); );
await ct.removeLocalChannel(_channel!);
if (!mounted) return; if (!mounted) return;
Navigator.pop(context, false); Navigator.pop(context, false);
} catch (err) { } catch (err) {
@ -129,12 +131,15 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
setState(() => _isUpdatingNotifyLevel = true); setState(() => _isUpdatingNotifyLevel = true);
try { try {
final ct = context.read<ChatChannelProvider>();
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
await sn.client.put( final resp = await sn.client.put(
'/cgi/im/channels/${_channel!.keyPath}/members/me/notify', '/cgi/im/channels/${_channel!.keyPath}/members/me/notify',
data: {'notify_level': value}, data: {'notify_level': value},
); );
_profile = SnChannelMember.fromJson(resp.data);
_notifyLevel = value; _notifyLevel = value;
await ct.updateChannelProfile(_profile!);
if (!mounted) return; if (!mounted) return;
context.showSnackbar('channelNotifyLevelApplied'.tr()); context.showSnackbar('channelNotifyLevelApplied'.tr());
} catch (err) { } catch (err) {
@ -215,6 +220,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
final isOwned = ua.isAuthorized && _channel?.accountId == ua.user?.id; final isOwned = ua.isAuthorized && _channel?.accountId == ua.user?.id;
return AppScaffold( return AppScaffold(
noBackground: true,
appBar: AppBar( appBar: AppBar(
title: _channel != null ? Text(_channel!.name) : Text('loading').tr(), title: _channel != null ? Text(_channel!.name) : Text('loading').tr(),
), ),
@ -245,7 +251,11 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('channelDetailPersonalRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4), Text('channelDetailPersonalRegion')
.bold()
.fontSize(17)
.tr()
.padding(horizontal: 20, bottom: 4),
ListTile( ListTile(
leading: const Icon(Symbols.notifications), leading: const Icon(Symbols.notifications),
trailing: DropdownButtonHideUnderline( trailing: DropdownButtonHideUnderline(
@ -284,14 +294,14 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
), ),
ListTile( ListTile(
leading: AccountImage( leading: AccountImage(
content: ud.getAccountFromCache(_profile!.accountId)?.avatar, content: ud.getFromCache(_profile!.accountId)?.avatar,
radius: 18, radius: 18,
), ),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
title: Text('channelEditProfile').tr(), title: Text('channelEditProfile').tr(),
subtitle: Text( subtitle: Text(
(_profile?.nick?.isEmpty ?? true) (_profile?.nick?.isEmpty ?? true)
? ud.getAccountFromCache(_profile!.accountId)!.nick ? ud.getFromCache(_profile!.accountId)!.nick
: _profile!.nick!, : _profile!.nick!,
), ),
contentPadding: const EdgeInsets.only(left: 20, right: 20), contentPadding: const EdgeInsets.only(left: 20, right: 20),
@ -303,7 +313,8 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
title: Text('channelActionLeave').tr(), title: Text('channelActionLeave').tr(),
subtitle: Text('channelActionLeaveDescription').tr(), subtitle: Text('channelActionLeaveDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding:
const EdgeInsets.symmetric(horizontal: 24),
onTap: _leaveChannel, onTap: _leaveChannel,
), ),
], ],
@ -311,7 +322,11 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('channelDetailMemberRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4), Text('channelDetailMemberRegion')
.bold()
.fontSize(17)
.tr()
.padding(horizontal: 20, bottom: 4),
ListTile( ListTile(
leading: const Icon(Symbols.group), leading: const Icon(Symbols.group),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
@ -333,7 +348,11 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('channelDetailAdminRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4), Text('channelDetailAdminRegion')
.bold()
.fontSize(17)
.tr()
.padding(horizontal: 20, bottom: 4),
ListTile( ListTile(
leading: const Icon(Symbols.edit), leading: const Icon(Symbols.edit),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
@ -379,10 +398,12 @@ class _ChannelProfileDetailDialog extends StatefulWidget {
}); });
@override @override
State<_ChannelProfileDetailDialog> createState() => _ChannelProfileDetailDialogState(); State<_ChannelProfileDetailDialog> createState() =>
_ChannelProfileDetailDialogState();
} }
class _ChannelProfileDetailDialogState extends State<_ChannelProfileDetailDialog> { class _ChannelProfileDetailDialogState
extends State<_ChannelProfileDetailDialog> {
bool _isBusy = false; bool _isBusy = false;
final TextEditingController _nickController = TextEditingController(); final TextEditingController _nickController = TextEditingController();
@ -391,11 +412,14 @@ class _ChannelProfileDetailDialogState extends State<_ChannelProfileDetailDialog
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
final ct = context.read<ChatChannelProvider>();
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
await sn.client.put( final resp = await sn.client.put(
'/cgi/im/channels/${widget.channel.keyPath}/members/me', '/cgi/im/channels/${widget.channel.keyPath}/members/me',
data: {'nick': _nickController.text}, data: {'nick': _nickController.text},
); );
final out = SnChannelMember.fromJson(resp.data);
await ct.updateChannelProfile(out);
if (!mounted) return; if (!mounted) return;
Navigator.pop(context, true); Navigator.pop(context, true);
} catch (err) { } catch (err) {
@ -457,7 +481,8 @@ class _ChannelMemberListWidget extends StatefulWidget {
const _ChannelMemberListWidget({required this.channel}); const _ChannelMemberListWidget({required this.channel});
@override @override
State<_ChannelMemberListWidget> createState() => _ChannelMemberListWidgetState(); State<_ChannelMemberListWidget> createState() =>
_ChannelMemberListWidgetState();
} }
class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> { class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
@ -472,10 +497,12 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
try { try {
final ud = context.read<UserDirectoryProvider>(); final ud = context.read<UserDirectoryProvider>();
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/im/channels/${widget.channel.keyPath}/members', queryParameters: { final resp = await sn.client.get(
'take': 10, '/cgi/im/channels/${widget.channel.keyPath}/members',
'offset': _members.length, queryParameters: {
}); 'take': 10,
'offset': _members.length,
});
final out = List<SnChannelMember>.from( final out = List<SnChannelMember>.from(
resp.data['data']?.map((e) => SnChannelMember.fromJson(e)) ?? [], resp.data['data']?.map((e) => SnChannelMember.fromJson(e)) ?? [],
); );
@ -533,7 +560,9 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
children: [ children: [
const Icon(Symbols.group, size: 24), const Icon(Symbols.group, size: 24),
const Gap(16), const Gap(16),
Text('channelMemberManage').tr().textStyle(Theme.of(context).textTheme.titleLarge!), Text('channelMemberManage')
.tr()
.textStyle(Theme.of(context).textTheme.titleLarge!),
], ],
).padding(horizontal: 20, top: 16, bottom: 12), ).padding(horizontal: 20, top: 16, bottom: 12),
Expanded( Expanded(
@ -544,7 +573,8 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
}, },
child: InfiniteList( child: InfiniteList(
itemCount: _members.length, itemCount: _members.length,
hasReachedMax: _totalCount != null && _members.length >= _totalCount!, hasReachedMax:
_totalCount != null && _members.length >= _totalCount!,
isLoading: _isBusy, isLoading: _isBusy,
onFetchData: _fetchMembers, onFetchData: _fetchMembers,
itemBuilder: (context, index) { itemBuilder: (context, index) {
@ -552,10 +582,10 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
return ListTile( return ListTile(
contentPadding: const EdgeInsets.only(right: 24, left: 16), contentPadding: const EdgeInsets.only(right: 24, left: 16),
leading: AccountImage( leading: AccountImage(
content: ud.getAccountFromCache(member.accountId)?.avatar, content: ud.getFromCache(member.accountId)?.avatar,
), ),
title: Text( title: Text(
ud.getAccountFromCache(member.accountId)?.name ?? 'unknown'.tr(), ud.getFromCache(member.accountId)?.name ?? 'unknown'.tr(),
), ),
subtitle: Text(member.nick ?? 'unknown'.tr()), subtitle: Text(member.nick ?? 'unknown'.tr()),
trailing: SizedBox( trailing: SizedBox(
@ -565,7 +595,8 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
IconButton( IconButton(
onPressed: _isUpdating ? null : () => _deleteMember(member), onPressed:
_isUpdating ? null : () => _deleteMember(member),
icon: const Icon(Symbols.person_remove), icon: const Icon(Symbols.person_remove),
), ),
], ],

View File

@ -49,7 +49,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
resp.data?.map((e) => SnRealm.fromJson(e)) ?? [], resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
); );
if (_editingChannel != null) { if (_editingChannel != null) {
_belongToRealm = _realms?.firstWhereOrNull((e) => e.id == _editingChannel!.realmId); _belongToRealm =
_realms?.firstWhereOrNull((e) => e.id == _editingChannel!.realmId);
} }
} catch (err) { } catch (err) {
if (mounted) context.showErrorDialog(err); if (mounted) context.showErrorDialog(err);
@ -95,6 +96,11 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
'description': _descriptionController.text, 'description': _descriptionController.text,
'is_public': _isPublic, 'is_public': _isPublic,
'is_community': _isCommunity, '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 {
@ -135,8 +141,11 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AppScaffold( return AppScaffold(
noBackground: true,
appBar: AppBar( appBar: AppBar(
title: widget.editingChannelAlias != null ? Text('screenChatManage').tr() : Text('screenChatNew').tr(), title: widget.editingChannelAlias != null
? Text('screenChatManage').tr()
: Text('screenChatNew').tr(),
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
child: Column( child: Column(
@ -148,7 +157,8 @@ 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'.tr(args: ['#${_editingChannel!.alias}']), 'channelEditingNotice'
.tr(args: ['#${_editingChannel!.alias}']),
), ),
actions: [ actions: [
TextButton( TextButton(
@ -171,7 +181,6 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
items: [ items: [
...(_realms?.map( ...(_realms?.map(
(SnRealm item) => DropdownMenuItem<SnRealm>( (SnRealm item) => DropdownMenuItem<SnRealm>(
enabled: _editingChannel == null || _editingChannel?.realmId == item.id,
value: item, value: item,
child: Row( child: Row(
children: [ children: [
@ -189,12 +198,15 @@ 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).textTheme.bodyMedium!), Text(item.name).textStyle(Theme.of(context)
.textTheme
.bodyMedium!),
Text( Text(
item.description, item.description,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
).textStyle(Theme.of(context).textTheme.bodySmall!), ).textStyle(
Theme.of(context).textTheme.bodySmall!),
], ],
), ),
), ),
@ -204,14 +216,14 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
) ?? ) ??
[]), []),
DropdownMenuItem<SnRealm>( DropdownMenuItem<SnRealm>(
enabled: _editingChannel == null,
value: null, value: null,
child: Row( child: Row(
children: [ children: [
CircleAvatar( CircleAvatar(
radius: 16, radius: 16,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
foregroundColor: Theme.of(context).colorScheme.onSurface, foregroundColor:
Theme.of(context).colorScheme.onSurface,
child: const Icon(Symbols.clear), child: const Icon(Symbols.clear),
), ),
const Gap(12), const Gap(12),
@ -220,7 +232,9 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('fieldChatBelongToRealmUnset').tr().textStyle( Text('fieldChatBelongToRealmUnset')
.tr()
.textStyle(
Theme.of(context).textTheme.bodyMedium!, Theme.of(context).textTheme.bodyMedium!,
), ),
], ],
@ -255,7 +269,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
helperText: 'fieldChatAliasHint'.tr(), helperText: 'fieldChatAliasHint'.tr(),
helperMaxLines: 2, helperMaxLines: 2,
), ),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(4), const Gap(4),
TextField( TextField(
@ -264,7 +279,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
border: const UnderlineInputBorder(), border: const UnderlineInputBorder(),
labelText: 'fieldChatName'.tr(), labelText: 'fieldChatName'.tr(),
), ),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(4), const Gap(4),
TextField( TextField(
@ -275,7 +291,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
border: const UnderlineInputBorder(), border: const UnderlineInputBorder(),
labelText: 'fieldChatDescription'.tr(), labelText: 'fieldChatDescription'.tr(),
), ),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(12), const Gap(12),
CheckboxListTile( CheckboxListTile(

View File

@ -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();
@ -48,16 +52,41 @@ class ChatRoomScreen extends StatefulWidget {
class _ChatRoomScreenState extends State<ChatRoomScreen> { class _ChatRoomScreenState extends State<ChatRoomScreen> {
bool _isBusy = false; bool _isBusy = false;
bool _isCalling = false; bool _isCalling = false;
bool _isJoining = false;
SnChannel? _channel; SnChannel? _channel;
SnChannelMember? _currentMember;
SnChannelMember? _otherMember; SnChannelMember? _otherMember;
SnChatCall? _ongoingCall; SnChatCall? _ongoingCall;
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;
Future<void> _joinChannel() async {
try {
setState(() => _isJoining = true);
final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>();
await sn.client
.post('/cgi/im/channels/${_channel!.keyPath}/members', data: {
'related': ua.user?.name,
});
_initializeChat();
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isJoining = true);
}
}
Future<void> _fetchChannel() async { Future<void> _fetchChannel() async {
setState(() => _isBusy = true); setState(() => _isBusy = true);
@ -66,6 +95,12 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
_channel = await chan.getChannel('${widget.scope}:${widget.alias}'); _channel = await chan.getChannel('${widget.scope}:${widget.alias}');
if (!mounted || _channel == null) return; if (!mounted || _channel == null) return;
final ct = context.read<ChatChannelProvider>();
try {
_currentMember = await ct.getChannelProfile(_channel!);
} catch (_) {}
if (!mounted || _currentMember == null) return;
final ud = context.read<UserDirectoryProvider>(); final ud = context.read<UserDirectoryProvider>();
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
if (_channel!.type == 1) { if (_channel!.type == 1) {
@ -82,6 +117,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);
@ -180,21 +229,21 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
return a.createdAt.difference(b.createdAt).inMinutes <= 3; return a.createdAt.difference(b.createdAt).inMinutes <= 3;
} }
@override Future<void> _initializeChat() async {
void initState() {
super.initState();
_messageController = ChatMessageController(context);
_fetchChannel().then((_) async { _fetchChannel().then((_) async {
if (_currentMember == null) return;
await _messageController.initialize(_channel!); await _messageController.initialize(_channel!);
if (widget.extra != null) { if (widget.extra != null) {
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!);
} }
}); });
} }
@ -204,9 +253,15 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
_fetchOngoingCall(), _fetchOngoingCall(),
]); ]);
}); });
}
final ws = context.read<WebSocketProvider>(); @override
_wsSubscription = ws.pk.stream.listen((event) { void initState() {
super.initState();
_messageController = ChatMessageController(context);
_initializeChat();
_wsSubscription = _ws.pk.stream.listen((event) {
switch (event.method) { switch (event.method) {
case 'calls.new': case 'calls.new':
final payload = SnChatCall.fromJson(event.payload!); final payload = SnChatCall.fromJson(event.payload!);
@ -228,6 +283,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();
} }
@ -237,21 +304,35 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
final ud = context.read<UserDirectoryProvider>(); final ud = context.read<UserDirectoryProvider>();
return AppScaffold( return AppScaffold(
noBackground: true,
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( if (_currentMember != null)
icon: _ongoingCall == null ? const Icon(Symbols.call) : const Icon(Symbols.call_end), IconButton(
onPressed: _isCalling onPressed: () {
? null setState(() => _isEncrypted = !_isEncrypted);
: _ongoingCall == null _inputGlobalKey.currentState?.setEncrypted(_isEncrypted);
? _makeCall },
: _endCall, icon: _isEncrypted
), ? const Icon(Symbols.lock)
: const Icon(Symbols.no_encryption),
),
if (_currentMember != null)
IconButton(
icon: _ongoingCall == null
? const Icon(Symbols.call)
: const Icon(Symbols.call_end),
onPressed: _isCalling
? null
: _ongoingCall == null
? _makeCall
: _endCall,
),
IconButton( IconButton(
icon: const Icon(Symbols.more_vert), icon: const Icon(Symbols.more_vert),
onPressed: () { onPressed: () {
@ -275,7 +356,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 +378,48 @@ 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 (_currentMember == null && !_isBusy)
Expanded(
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Symbols.person_remove, size: 40, fill: 1),
const Gap(8),
Text('chatUnjoined'.tr(), textAlign: TextAlign.center)
.fontSize(16)
.bold(),
Text('chatUnjoinedDescription'.tr(),
textAlign: TextAlign.center)
.fontSize(13),
if (_channel!.isPublic)
Text('chatUnjoinedPublicDescription'.tr(),
textAlign: TextAlign.center)
.fontSize(13)
.padding(top: 8),
if (_channel!.isPublic)
TextButton(
style: ButtonStyle(
visualDensity: VisualDensity.compact,
),
onPressed: _isJoining ? null : _joinChannel,
child: Text('chatJoin').tr(),
),
],
),
),
),
)
else if (_messageController.isPending)
Expanded( 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 +432,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 +454,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);
}, },
@ -351,7 +470,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
}, },
), ),
), ),
if (!_messageController.isPending) if (!_messageController.isPending && _currentMember != null)
Material( Material(
elevation: 2, elevation: 2,
child: Column( child: Column(

View File

@ -5,16 +5,28 @@ 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/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/providers/sn_realm.dart';
import 'package:surface/providers/userinfo.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/feed/feed_news.dart';
import 'package:surface/widgets/feed/feed_unknown.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart'; import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/fediverse_post_item.dart';
import 'package:surface/widgets/post/post_item.dart'; import 'package: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';
const kPostChannels = ['Global', 'Friends', 'Following'];
const kPostChannelIcons = [Symbols.globe, Symbols.group, Symbols.subscriptions];
const Map<String, IconData> kCategoryIcons = { const Map<String, IconData> kCategoryIcons = {
'technology': Symbols.tools_wrench, 'technology': Symbols.tools_wrench,
'gaming': Symbols.gamepad, 'gaming': Symbols.gamepad,
@ -35,66 +47,117 @@ class ExploreScreen extends StatefulWidget {
State<ExploreScreen> createState() => _ExploreScreenState(); State<ExploreScreen> createState() => _ExploreScreenState();
} }
class _ExploreScreenState extends State<ExploreScreen> { class _ExploreScreenState extends State<ExploreScreen>
with TickerProviderStateMixin {
late TabController _tabController = TabController(
length: kPostChannels.length,
vsync: this,
);
final _fabKey = GlobalKey<ExpandableFabState>(); final _fabKey = GlobalKey<ExpandableFabState>();
final _listKey = GlobalKey<_PostListWidgetState>();
bool _isBusy = true; bool _showCategories = false;
final List<SnPost> _posts = List.empty(growable: true);
final List<SnPostCategory> _categories = List.empty(growable: true); final List<SnPostCategory> _categories = List.empty(growable: true);
int? _postCount;
String? _selectedCategory;
Future<void> _fetchCategories() async { Future<void> _fetchCategories() async {
_categories.clear(); _categories.clear();
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/co/categories?take=100'); final resp = await sn.client.get('/cgi/co/categories?take=100');
_categories.addAll(resp.data.map((e) => SnPostCategory.fromJson(e)).cast<SnPostCategory>() ?? []); setState(() {
_categories.addAll(resp.data
.map((e) => SnPostCategory.fromJson(e))
.cast<SnPostCategory>() ??
[]);
});
} catch (err) { } catch (err) {
if (!mounted) return; if (mounted) context.showErrorDialog(err);
context.showErrorDialog(err);
} }
} }
Future<void> _fetchPosts() async { final List<SnRealm> _realms = List.empty(growable: true);
if (_postCount != null && _posts.length >= _postCount!) return;
setState(() => _isBusy = true); Future<void> _fetchRealms() async {
try {
final pt = context.read<SnPostContentProvider>(); final ua = context.read<UserProvider>();
final result = await pt.listPosts( if (!ua.isAuthorized) return;
take: 10, final rels = context.read<SnRealmProvider>();
offset: _posts.length, final out = await rels.listAvailableRealms();
categories: _selectedCategory != null ? [_selectedCategory!] : null, setState(() {
); _realms.addAll(out);
final out = result.$1; });
} catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err);
_postCount = result.$2; rethrow;
_posts.addAll(out); }
if (mounted) setState(() => _isBusy = false);
} }
Future<void> _refreshPosts() { void _toggleShowCategories() {
_postCount = null; _showCategories = !_showCategories;
_posts.clear(); if (_showCategories) {
return _fetchPosts(); _tabController = TabController(length: _categories.length, vsync: this);
_listKey.currentState?.setCategory(_categories[_tabController.index]);
_listKey.currentState?.refreshPosts();
} else {
_tabController = TabController(length: kPostChannels.length, vsync: this);
_listKey.currentState?.setCategory(null);
_listKey.currentState?.refreshPosts();
}
_tabListen();
setState(() {});
}
void _tabListen() {
_tabController.addListener(() {
if (_tabController.indexIsChanging) {
if (_showCategories) {
_listKey.currentState?.setCategory(_categories[_tabController.index]);
_listKey.currentState?.refreshPosts();
return;
}
switch (_tabController.index) {
case 0:
case 3:
_listKey.currentState?.setChannel(null);
break;
case 1:
_listKey.currentState?.setChannel('friends');
break;
case 2:
_listKey.currentState?.setChannel('following');
break;
}
_listKey.currentState?.refreshPosts();
}
});
} }
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_fetchPosts(); _tabListen();
_fetchCategories(); _fetchCategories();
_fetchRealms();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> refreshPosts() async {
await _listKey.currentState?.refreshPosts();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cfg = context.watch<ConfigProvider>();
return AppScaffold( return AppScaffold(
noBackground: true,
floatingActionButtonLocation: ExpandableFab.location, floatingActionButtonLocation: ExpandableFab.location,
floatingActionButton: ExpandableFab( floatingActionButton: ExpandableFab(
key: _fabKey, key: _fabKey,
@ -102,181 +165,485 @@ class _ExploreScreenState extends State<ExploreScreen> {
type: ExpandableFabType.up, type: ExpandableFabType.up,
childrenAnimation: ExpandableFabAnimation.none, childrenAnimation: ExpandableFabAnimation.none,
overlayStyle: ExpandableFabOverlayStyle( overlayStyle: ExpandableFabOverlayStyle(
color: Theme.of(context).colorScheme.surface.withAlpha((255 * 0.5).round()), color: Theme.of(context)
.colorScheme
.surface
.withAlpha((255 * 0.5).round()),
), ),
openButtonBuilder: RotateFloatingActionButtonBuilder( openButtonBuilder: RotateFloatingActionButtonBuilder(
child: const Icon(Symbols.add, size: 28), child: const Icon(Symbols.add, size: 28),
fabSize: ExpandableFabSize.regular, fabSize: ExpandableFabSize.regular,
foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor, foregroundColor:
backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor, Theme.of(context).floatingActionButtonTheme.foregroundColor,
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(
children: [ children: [
Text('writePostTypeStory').tr(), Text('writePost').tr(),
const Gap(20), const Gap(20),
FloatingActionButton( FloatingActionButton(
heroTag: null, heroTag: null,
tooltip: 'writePostTypeStory'.tr(), tooltip: 'writePost'.tr(),
onPressed: () { onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: { GoRouter.of(context).pushNamed('postEditor').then((value) {
'mode': 'stories',
}).then((value) {
if (value == true) { if (value == true) {
_refreshPosts(); refreshPosts();
} }
}); });
_fabKey.currentState!.toggle(); _fabKey.currentState!.toggle();
}, },
child: const Icon(Symbols.post_rounded), child: const Icon(Symbols.edit),
), ),
], ],
), ),
Row( Row(
children: [ children: [
Text('writePostTypeArticle').tr(), Text('postDraftBox').tr(),
const Gap(20), const Gap(20),
FloatingActionButton( FloatingActionButton(
heroTag: null, heroTag: null,
tooltip: 'writePostTypeArticle'.tr(), tooltip: 'postDraftBox'.tr(),
onPressed: () { onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: { GoRouter.of(context).pushNamed('postDraftBox');
'mode': 'articles',
}).then((value) {
if (value == true) {
_refreshPosts();
}
});
_fabKey.currentState!.toggle(); _fabKey.currentState!.toggle();
}, },
child: const Icon(Symbols.news), child: const Icon(Symbols.box_edit),
),
],
),
Row(
children: [
Text('writePostTypeQuestion').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'writePostTypeQuestion'.tr(),
onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
'mode': 'questions',
}).then((value) {
if (value == true) {
_refreshPosts();
}
});
_fabKey.currentState!.toggle();
},
child: const Icon(Symbols.question_answer),
),
],
),
Row(
children: [
Text('writePostTypeVideo').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'writePostTypeVideo'.tr(),
onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
'mode': 'videos',
}).then((value) {
if (value == true) {
_refreshPosts();
}
});
_fabKey.currentState!.toggle();
},
child: const Icon(Symbols.video_call),
), ),
], ],
), ),
], ],
), ),
body: RefreshIndicator( body: NestedScrollView(
displacement: 40 + MediaQuery.of(context).padding.top, headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
onRefresh: () => _refreshPosts(), return [
child: CustomScrollView( SliverOverlapAbsorber(
slivers: [ handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
SliverAppBar( sliver: SliverAppBar(
leading: AutoAppBarLeading(), leading:
title: Text('screenExplore').tr(), ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)
floating: true, ? AutoAppBarLeading()
snap: true, : null,
actions: [ titleSpacing: 0,
IconButton( title: Row(
icon: const Icon(Symbols.search), children: [
onPressed: () { if (ResponsiveBreakpoints.of(context).largerThan(MOBILE))
GoRouter.of(context).pushNamed('postSearch'); const Gap(8),
}, IconButton(
), icon: const Icon(Symbols.shuffle),
const Gap(8), onPressed: () {
], GoRouter.of(context).pushNamed('postShuffle');
bottom: PreferredSize( },
preferredSize: const Size.fromHeight(50),
child: SizedBox(
height: 50,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: _categories.map((ele) {
return StyledWidget(ChoiceChip(
avatar: Icon(kCategoryIcons[ele.alias] ?? Symbols.question_mark),
label: Text(
'postCategory${ele.alias.capitalize()}'.trExists()
? 'postCategory${ele.alias.capitalize()}'.tr()
: ele.name,
),
selected: _selectedCategory == ele.alias,
onSelected: (value) {
_selectedCategory = value ? ele.alias : null;
_refreshPosts();
},
)).padding(horizontal: 4);
}).toList(),
), ),
), if (ResponsiveBreakpoints.of(context).largerThan(MOBILE))
const Gap(48),
Expanded(
child: Center(
child: IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
visualDensity: VisualDensity.compact,
icon: _listKey.currentState?.realm != null
? AccountImage(
content: _listKey.currentState!.realm!.avatar,
radius: 14,
)
: Image.asset(
'assets/icon/icon-dark.png',
width: 32,
height: 32,
color: Theme.of(context)
.appBarTheme
.foregroundColor,
),
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) => _PostListRealmPopup(
realms: _realms,
onUpdate: (realm) {
_listKey.currentState?.setRealm(realm);
_listKey.currentState?.refreshPosts();
Future.delayed(
const Duration(milliseconds: 100), () {
if (mounted) {
setState(() {});
}
});
},
onMixedFeedChanged: (flag) {
_listKey.currentState?.setRealm(null);
_listKey.currentState?.setCategory(null);
if (_showCategories && flag) {
_toggleShowCategories();
}
_listKey.currentState?.refreshPosts();
},
),
);
},
),
),
),
],
), ),
floating: true,
snap: true,
actions: [
IconButton(
icon: const Icon(Symbols.category),
style: _showCategories
? ButtonStyle(
foregroundColor: WidgetStateProperty.all(
Theme.of(context).colorScheme.primary,
),
backgroundColor: MaterialStateProperty.all(
Theme.of(context).colorScheme.secondaryContainer,
),
)
: null,
onPressed: cfg.mixedFeed
? null
: () {
_toggleShowCategories();
},
),
IconButton(
icon: const Icon(Symbols.search),
onPressed: () {
GoRouter.of(context).pushNamed('postSearch');
},
),
const Gap(8),
],
bottom: cfg.mixedFeed
? null
: TabBar(
isScrollable: _showCategories,
controller: _tabController,
tabs: _showCategories
? [
for (final category in _categories)
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
Icon(
kCategoryIcons[category.alias] ??
Symbols.question_mark,
color: Theme.of(context)
.appBarTheme
.foregroundColor!,
),
const Gap(8),
Flexible(
child: Text(
'postCategory${category.alias.capitalize()}'
.trExists()
? 'postCategory${category.alias.capitalize()}'
.tr()
: category.name,
maxLines: 1,
).textColor(
Theme.of(context)
.appBarTheme
.foregroundColor!,
),
),
],
),
),
]
: [
for (final channel in kPostChannels)
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
Icon(
kPostChannelIcons[
kPostChannels.indexOf(channel)],
size: 20,
color: Theme.of(context)
.appBarTheme
.foregroundColor,
),
const Gap(8),
Flexible(
child: Text(
'postChannel$channel',
maxLines: 1,
).tr().textColor(
Theme.of(context)
.appBarTheme
.foregroundColor,
),
),
],
),
),
],
),
), ),
), ),
const SliverGap(12), ];
SliverInfiniteList( },
itemCount: _posts.length, body: _PostListWidget(
isLoading: _isBusy, key: _listKey,
centerLoading: true,
hasReachedMax: _postCount != null && _posts.length >= _postCount!,
onFetchData: _fetchPosts,
itemBuilder: (context, idx) {
return OpenablePostItem(
data: _posts[idx],
maxWidth: 640,
onChanged: (data) {
setState(() => _posts[idx] = data);
},
onDeleted: () {
_refreshPosts();
},
);
},
separatorBuilder: (_, __) => const Gap(8),
),
],
), ),
), ),
); );
} }
} }
class _PostListWidget extends StatefulWidget {
const _PostListWidget({super.key});
@override
State<_PostListWidget> createState() => _PostListWidgetState();
}
class _PostListWidgetState extends State<_PostListWidget> {
bool _isBusy = false;
SnRealm? get realm => _selectedRealm;
final List<SnFeedEntry> _feed = List.empty(growable: true);
SnRealm? _selectedRealm;
String? _selectedChannel;
SnPostCategory? _selectedCategory;
bool _hasLoadedAll = false;
// Called when using regular feed
Future<void> _fetchPosts() async {
if (_hasLoadedAll) return;
setState(() => _isBusy = true);
final pt = context.read<SnPostContentProvider>();
final result = await pt.listPosts(
take: 10,
offset: _feed.length,
categories: _selectedCategory != null ? [_selectedCategory!.alias] : null,
channel: _selectedChannel,
realm: _selectedRealm?.alias,
);
final out = result.$1;
if (!mounted) return;
final postCount = result.$2;
_feed.addAll(
out.map((ele) => SnFeedEntry(
type: 'interactive.post',
data: ele.toJson(),
createdAt: ele.createdAt)),
);
_hasLoadedAll = _feed.length >= postCount;
if (mounted) setState(() => _isBusy = false);
}
// Called when mixed feed is enabled
Future<void> _fetchFeed() async {
if (_hasLoadedAll) return;
setState(() => _isBusy = true);
final pt = context.read<SnPostContentProvider>();
final result = await pt.getFeed(
cursor: _feed
.where((ele) => !['reader.news'].contains(ele.type))
.lastOrNull
?.createdAt,
);
if (!mounted) return;
_feed.addAll(result);
_hasLoadedAll = result.isEmpty;
if (mounted) setState(() => _isBusy = false);
}
void setChannel(String? channel) {
_selectedChannel = channel;
setState(() {});
}
void setRealm(SnRealm? realm) {
_selectedRealm = realm;
setState(() {});
}
void setCategory(SnPostCategory? category) {
_selectedCategory = category;
setState(() {});
}
Future<void> refreshPosts() {
_hasLoadedAll = false;
_feed.clear();
final cfg = context.read<ConfigProvider>();
if (cfg.mixedFeed) {
return _fetchFeed();
} else {
return _fetchPosts();
}
}
@override
void initState() {
super.initState();
final cfg = context.read<ConfigProvider>();
if (cfg.mixedFeed) {
_fetchFeed();
} else {
_fetchPosts();
}
}
@override
Widget build(BuildContext context) {
final cfg = context.watch<ConfigProvider>();
return MediaQuery.removePadding(
context: context,
removeTop: true,
child: RefreshIndicator(
displacement: 40 + MediaQuery.of(context).padding.top,
onRefresh: () => refreshPosts(),
child: InfiniteList(
padding: EdgeInsets.only(top: 8),
itemCount: _feed.length,
isLoading: _isBusy,
centerLoading: true,
hasReachedMax: _hasLoadedAll,
onFetchData: cfg.mixedFeed ? _fetchFeed : _fetchPosts,
itemBuilder: (context, idx) {
final ele = _feed[idx];
switch (ele.type) {
case 'interactive.post':
return OpenablePostItem(
useReplace: true,
data: SnPost.fromJson(ele.data),
maxWidth: 640,
onChanged: (data) {
setState(() {
_feed[idx] = _feed[idx].copyWith(data: data.toJson());
});
},
onDeleted: () {
refreshPosts();
},
);
case 'fediverse.post':
return FediversePostWidget(
data: SnFediversePost.fromJson(ele.data),
maxWidth: 640,
);
case 'reader.news':
return Center(
child: Container(
constraints: BoxConstraints(maxWidth: 640),
child: NewsFeedEntry(data: ele),
),
);
default:
return Container(
constraints: BoxConstraints(maxWidth: 640),
child: FeedUnknownEntry(data: ele),
);
}
},
separatorBuilder: (_, __) => const Divider().padding(vertical: 2),
),
),
);
}
}
class _PostListRealmPopup extends StatelessWidget {
final List<SnRealm>? realms;
final Function(SnRealm?) onUpdate;
final Function(bool) onMixedFeedChanged;
const _PostListRealmPopup({
required this.realms,
required this.onUpdate,
required this.onMixedFeedChanged,
});
@override
Widget build(BuildContext context) {
final cfg = context.watch<ConfigProvider>();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.tune, size: 24),
const Gap(16),
Text('filterFeed', style: Theme.of(context).textTheme.titleLarge)
.tr(),
],
).padding(horizontal: 20, top: 16, bottom: 12),
SwitchListTile(
secondary: const Icon(Symbols.merge_type),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text('mixedFeed').tr(),
subtitle: Text('mixedFeedDescription').tr(),
value: cfg.mixedFeed,
onChanged: (value) {
cfg.mixedFeed = value;
onMixedFeedChanged.call(value);
},
),
if (!cfg.mixedFeed)
ListTile(
leading: const Icon(Symbols.close),
title: Text('postInGlobal').tr(),
subtitle: Text('postViewInGlobalDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
onUpdate.call(null);
Navigator.pop(context);
},
),
if (!cfg.mixedFeed) const Divider(height: 1),
if (!cfg.mixedFeed)
Expanded(
child: ListView.builder(
itemCount: realms?.length ?? 0,
itemBuilder: (context, idx) {
final realm = realms![idx];
return ListTile(
title: Text(realm.name),
subtitle: Text('@${realm.alias}'),
leading: AccountImage(content: realm.avatar, radius: 18),
onTap: () {
onUpdate.call(realm);
Navigator.pop(context);
},
);
},
),
),
],
);
}
}

View File

@ -10,7 +10,6 @@ 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/account/account_select.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';
@ -47,8 +46,7 @@ class _FriendScreenState extends State<FriendScreen> {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/me/relations?status=1'); final resp = await sn.client.get('/cgi/id/users/me/relations?status=1');
_relations = List<SnRelationship>.from( _relations = List<SnRelationship>.from(
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [], resp.data?.map((e) => SnRelationship.fromJson(e)) ?? []);
);
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -67,8 +65,7 @@ class _FriendScreenState extends State<FriendScreen> {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/me/relations?status=0,3'); final resp = await sn.client.get('/cgi/id/users/me/relations?status=0,3');
_requests = List<SnRelationship>.from( _requests = List<SnRelationship>.from(
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [], resp.data?.map((e) => SnRelationship.fromJson(e)) ?? []);
);
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -87,8 +84,7 @@ class _FriendScreenState extends State<FriendScreen> {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/me/relations?status=2'); final resp = await sn.client.get('/cgi/id/users/me/relations?status=2');
_blocks = List<SnRelationship>.from( _blocks = List<SnRelationship>.from(
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [], resp.data?.map((e) => SnRelationship.fromJson(e)) ?? []);
);
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -105,10 +101,7 @@ class _FriendScreenState extends State<FriendScreen> {
try { try {
final rel = context.read<SnRelationshipProvider>(); final rel = context.read<SnRelationshipProvider>();
await rel.updateRelationship( await rel.updateRelationship(
relation.relatedId, relation.relatedId, dstStatus, relation.permNodes);
dstStatus,
relation.permNodes,
);
if (!mounted) return; if (!mounted) return;
_fetchRelations(); _fetchRelations();
} catch (err) { } catch (err) {
@ -122,9 +115,8 @@ class _FriendScreenState extends State<FriendScreen> {
Future<void> _deleteRelation(SnRelationship relation) async { Future<void> _deleteRelation(SnRelationship relation) async {
final confirm = await context.showConfirmDialog( final confirm = await context.showConfirmDialog(
'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]), 'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
'friendDeleteDescription'.tr(args: [ 'friendDeleteDescription'
relation.related?.nick ?? 'unknown'.tr(), .tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
]),
); );
if (!confirm) return; if (!confirm) return;
if (!mounted) return; if (!mounted) return;
@ -146,9 +138,11 @@ class _FriendScreenState extends State<FriendScreen> {
void _showRequests() { void _showRequests() {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => _FriendshipListWidget(relations: _requests), builder: (context) => _FriendshipListWidget(relations: _requests))
).then((value) { .then((
value,
) {
if (value != null) { if (value != null) {
_fetchRequests(); _fetchRequests();
_fetchRelations(); _fetchRelations();
@ -158,9 +152,10 @@ class _FriendScreenState extends State<FriendScreen> {
void _showBlocks() { void _showBlocks() {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => _FriendshipListWidget(relations: _blocks), builder: (context) => _FriendshipListWidget(relations: _blocks)).then((
).then((value) { value,
) {
if (value != null) { if (value != null) {
_fetchBlocks(); _fetchBlocks();
_fetchRelations(); _fetchRelations();
@ -173,9 +168,8 @@ class _FriendScreenState extends State<FriendScreen> {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
await sn.client.post('/cgi/id/users/me/relations', data: { await sn.client
'related': user.name, .post('/cgi/id/users/me/relations', data: {'related': user.name});
});
if (!mounted) return; if (!mounted) return;
context.showSnackbar('friendRequestSent'.tr()); context.showSnackbar('friendRequestSent'.tr());
} catch (err) { } catch (err) {
@ -201,18 +195,16 @@ class _FriendScreenState extends State<FriendScreen> {
if (!ua.isAuthorized) { if (!ua.isAuthorized) {
return AppScaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
leading: AutoAppBarLeading(), leading: PageBackButton(),
title: Text('screenFriend').tr(), title: Text('screenFriend').tr(),
), ),
body: Center( body: Center(child: UnauthorizedHint()),
child: UnauthorizedHint(),
),
); );
} }
return AppScaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
leading: AutoAppBarLeading(), leading: PageBackButton(),
title: Text('screenFriend').tr(), title: Text('screenFriend').tr(),
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
@ -220,9 +212,7 @@ class _FriendScreenState extends State<FriendScreen> {
onPressed: () async { onPressed: () async {
final user = await showModalBottomSheet<SnAccount?>( final user = await showModalBottomSheet<SnAccount?>(
context: context, context: context,
builder: (context) => AccountSelect( builder: (context) => AccountSelect(title: 'friendNew'.tr()),
title: 'friendNew'.tr(),
),
); );
if (!mounted) return; if (!mounted) return;
if (user == null) return; if (user == null) return;
@ -235,9 +225,8 @@ class _FriendScreenState extends State<FriendScreen> {
if (_requests.isNotEmpty) if (_requests.isNotEmpty)
ListTile( ListTile(
title: Text('friendRequests').tr(), title: Text('friendRequests').tr(),
subtitle: Text( subtitle:
'friendRequestsDescription', Text('friendRequestsDescription').plural(_requests.length),
).plural(_requests.length),
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.group_add), leading: const Icon(Symbols.group_add),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
@ -246,31 +235,30 @@ class _FriendScreenState extends State<FriendScreen> {
if (_blocks.isNotEmpty) if (_blocks.isNotEmpty)
ListTile( ListTile(
title: Text('friendBlocklist').tr(), title: Text('friendBlocklist').tr(),
subtitle: Text( subtitle:
'friendBlocklistDescription', Text('friendBlocklistDescription').plural(_blocks.length),
).plural(_blocks.length),
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.block), leading: const Icon(Symbols.block),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
onTap: _showBlocks, onTap: _showBlocks,
), ),
if (_requests.isNotEmpty || _blocks.isNotEmpty) const Divider(height: 1), if (_requests.isNotEmpty || _blocks.isNotEmpty)
const Divider(height: 1),
Expanded( Expanded(
child: MediaQuery.removePadding( child: MediaQuery.removePadding(
context: context, context: context,
removeTop: true, removeTop: true,
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () => Future.wait([ onRefresh: () =>
_fetchRelations(), Future.wait([_fetchRelations(), _fetchRequests()]),
_fetchRequests(),
]),
child: ListView.builder( child: ListView.builder(
itemCount: _relations.length, itemCount: _relations.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final relation = _relations[index]; final relation = _relations[index];
final other = relation.related; final other = relation.related;
return ListTile( return ListTile(
contentPadding: const EdgeInsets.only(right: 24, left: 16), contentPadding:
const EdgeInsets.only(right: 24, left: 16),
leading: AccountImage(content: other?.avatar), leading: AccountImage(content: other?.avatar),
title: Text(other?.nick ?? 'unknown'), title: Text(other?.nick ?? 'unknown'),
subtitle: Text(other?.nick ?? 'unknown'), subtitle: Text(other?.nick ?? 'unknown'),
@ -286,12 +274,16 @@ class _FriendScreenState extends State<FriendScreen> {
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
InkWell( InkWell(
onTap: _isUpdating ? null : () => _changeRelation(relation, 2), onTap: _isUpdating
? null
: () => _changeRelation(relation, 2),
child: Text('friendBlock').tr(), child: Text('friendBlock').tr(),
), ),
const Gap(8), const Gap(8),
InkWell( InkWell(
onTap: _isUpdating ? null : () => _deleteRelation(relation), onTap: _isUpdating
? null
: () => _deleteRelation(relation),
child: Text('friendDeleteAction').tr(), child: Text('friendDeleteAction').tr(),
), ),
], ],
@ -361,10 +353,7 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
try { try {
final rel = context.read<SnRelationshipProvider>(); final rel = context.read<SnRelationshipProvider>();
await rel.updateRelationship( await rel.updateRelationship(
relation.relatedId, relation.relatedId, dstStatus, relation.permNodes);
dstStatus,
relation.permNodes,
);
if (!mounted) return; if (!mounted) return;
Navigator.pop(context, true); Navigator.pop(context, true);
} catch (err) { } catch (err) {
@ -378,9 +367,8 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
Future<void> _deleteRelation(SnRelationship relation) async { Future<void> _deleteRelation(SnRelationship relation) async {
final confirm = await context.showConfirmDialog( final confirm = await context.showConfirmDialog(
'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]), 'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
'friendDeleteDescription'.tr(args: [ 'friendDeleteDescription'
relation.related?.nick ?? 'unknown'.tr(), .tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
]),
); );
if (!confirm) return; if (!confirm) return;
if (!mounted) return; if (!mounted) return;
@ -420,7 +408,9 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text(kFriendStatus[relation.status] ?? 'unknown').tr().opacity(0.75), Text(kFriendStatus[relation.status] ?? 'unknown')
.tr()
.opacity(0.75),
if (relation.status == 0) if (relation.status == 0)
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
@ -441,7 +431,8 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
InkWell( InkWell(
onTap: _isBusy ? null : () => _changeRelation(relation, 1), onTap:
_isBusy ? null : () => _changeRelation(relation, 1),
child: Text('friendUnblock').tr(), child: Text('friendUnblock').tr(),
), ),
const Gap(8), const Gap(8),

View File

@ -1,15 +1,11 @@
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';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:html/parser.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';
@ -22,13 +18,16 @@ import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/special_day.dart'; import 'package:surface/providers/special_day.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/widget.dart'; import 'package:surface/providers/widget.dart';
import 'package:surface/screens/captcha/captcha.dart';
import 'package:surface/types/check_in.dart'; import 'package:surface/types/check_in.dart';
import 'package:surface/types/news.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.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';
import 'package:surface/widgets/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/updater.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:url_launcher/url_launcher_string.dart';
class HomeScreenDashEntry { class HomeScreenDashEntry {
final String name; final String name;
@ -68,7 +67,7 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
HomeScreenDashEntry( HomeScreenDashEntry(
name: 'dashEntryTodayNews', name: 'dashEntryTodayNews',
child: _HomeDashTodayNews(), child: _HomeDashServiceStatus(),
cols: MediaQuery.of(context).size.width >= 640 ? 3 : 2, cols: MediaQuery.of(context).size.width >= 640 ? 3 : 2,
), ),
]; ];
@ -83,14 +82,25 @@ 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,
),
),
_HomeDashUnconfirmedWidget().padding(horizontal: 8),
_HomeDashSpecialDayWidget().padding(horizontal: 8), _HomeDashSpecialDayWidget().padding(horizontal: 8),
StaggeredGrid.extent( StaggeredGrid.extent(
maxCrossAxisExtent: 280, maxCrossAxisExtent: 280,
@ -115,6 +125,64 @@ class _HomeScreenState extends State<HomeScreen> {
} }
} }
class _HomeDashUnconfirmedWidget extends StatelessWidget {
const _HomeDashUnconfirmedWidget();
Future<void> _resendConfirmationEmail(BuildContext context) async {
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.patch('/cgi/id/users/me/confirm');
if (!context.mounted) return;
context.showSnackbar('accountUnconfirmedResendSuccessful'.tr());
} catch (err) {
if (!context.mounted) return;
context.showErrorDialog(err);
}
}
@override
Widget build(BuildContext context) {
final ua = context.watch<UserProvider>();
if (ua.user == null || ua.user?.confirmedAt != null) {
return SizedBox.shrink();
}
return Card(
margin: EdgeInsets.zero,
child: ListTile(
leading: const Icon(Symbols.shield),
title: Text('accountUnconfirmedTitle').tr(),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('accountUnconfirmedSubtitle').tr(),
const Gap(4),
Row(
children: [
Text('accountUnconfirmedUnreceived').tr(),
const Gap(4),
InkWell(
child: Text(
'accountUnconfirmedResend',
style: TextStyle(
decoration: TextDecoration.underline,
color: Theme.of(context).colorScheme.onSurface,
),
).tr(),
onTap: () {
_resendConfirmationEmail(context);
},
),
],
),
],
),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
),
).padding(bottom: 8);
}
}
class _HomeDashUpdateWidget extends StatelessWidget { class _HomeDashUpdateWidget extends StatelessWidget {
final EdgeInsets? padding; final EdgeInsets? padding;
@ -123,7 +191,6 @@ class _HomeDashUpdateWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final config = context.watch<ConfigProvider>(); final config = context.watch<ConfigProvider>();
return ListenableBuilder( return ListenableBuilder(
listenable: config, listenable: config,
builder: (context, _) { builder: (context, _) {
@ -136,21 +203,15 @@ class _HomeDashUpdateWidget extends StatelessWidget {
leading: Icon(Symbols.update), leading: Icon(Symbols.update),
title: Text('updateAvailable').tr(), title: Text('updateAvailable').tr(),
subtitle: Text(config.updatableVersion!), subtitle: Text(config.updatableVersion!),
trailing: (kIsWeb || Platform.isWindows || Platform.isLinux) trailing: IconButton(
? null icon: const Icon(Symbols.arrow_right_alt),
: IconButton( onPressed: () {
icon: const Icon(Symbols.arrow_right_alt), showModalBottomSheet(
onPressed: () { context: context,
final model = UpdateModel( builder: (context) => VersionUpdatePopup(),
'https://files.solsynth.dev/d/production01/solian/app-arm64-v8a-release.apk', );
'solian-app-release-${config.updatableVersion!}.apk', },
'ic_launcher', ),
'https://apps.apple.com/us/app/solian/id6499032345',
);
AzhonAppUpdate.update(model);
context.showSnackbar('updateOngoing'.tr());
},
),
), ),
), ),
); );
@ -166,7 +227,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> {
@ -208,7 +270,9 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
margin: EdgeInsets.zero, 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: [
@ -240,21 +304,31 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
} }
} }
class _HomeDashTodayNews extends StatefulWidget { class _HomeDashServiceStatus extends StatefulWidget {
const _HomeDashTodayNews(); const _HomeDashServiceStatus();
@override @override
State<_HomeDashTodayNews> createState() => _HomeDashTodayNewsState(); State<_HomeDashServiceStatus> createState() => _HomeDashServiceStatusState();
} }
class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> { class _HomeDashServiceStatusState extends State<_HomeDashServiceStatus> {
SnNewsArticle? _article; Map<String, dynamic>? _statuses;
ServiceStatus? _serviceStatus;
Future<void> _fetchArticle() async { Future<void> _fetchStatuses() async {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/re/news/today'); final resp = await sn.client.get('/directory/status');
_article = SnNewsArticle.fromJson(resp.data['data']); _statuses = resp.data;
if (_statuses!.values.contains(false)) {
if (_statuses!.values.contains(true)) {
_serviceStatus = ServiceStatus.downgraded;
} else {
_serviceStatus = ServiceStatus.failed;
}
} else {
_serviceStatus = ServiceStatus.operational;
}
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -267,7 +341,7 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
@override @override
initState() { initState() {
super.initState(); super.initState();
_fetchArticle(); _fetchStatuses();
} }
@override @override
@ -279,62 +353,136 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
children: [ children: [
Row( Row(
children: [ children: [
const Icon(Symbols.newspaper), const Icon(Symbols.flare),
const Gap(8), const Gap(8),
Text( Expanded(
'newsToday', child: Text(
style: Theme.of(context).textTheme.titleLarge, 'serviceStatus',
).tr() style: Theme.of(context).textTheme.titleLarge,
], ).tr(),
).padding(horizontal: 18, top: 12, bottom: 8), ),
if (_article != null) IconButton(
Expanded( icon: const Icon(Symbols.launch, size: 20),
child: InkWell( visualDensity: VisualDensity(horizontal: -4, vertical: -4),
borderRadius: BorderRadius.all(Radius.circular(8)), constraints: const BoxConstraints(),
child: Column( padding: EdgeInsets.zero,
crossAxisAlignment: CrossAxisAlignment.start, onPressed: () {
spacing: 4, launchUrlString('https://status.solsynth.dev');
children: [
Text(
_article!.title,
style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 18),
maxLines: MediaQuery.of(context).size.width >= 640 ? 2 : 1,
overflow: TextOverflow.ellipsis,
),
Text(
parse(_article!.description).children.map((e) => e.text.trim()).join(),
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
Builder(builder: (context) {
final date = _article!.publishedAt ?? _article!.createdAt;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 2,
children: [
Text(DateFormat().format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
Text(' · ').textStyle(Theme.of(context).textTheme.bodySmall!).bold(),
Text(RelativeTime(context).format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
],
).opacity(0.75);
}),
],
).padding(horizontal: 16),
onTap: () {
GoRouter.of(context).pushNamed(
'newsDetail',
pathParameters: {'hash': _article!.hash},
);
}, },
), ),
) ],
else ).padding(horizontal: 18, top: 12, bottom: 8),
Container(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 6),
width: double.infinity,
color: _serviceStatus == null
? Theme.of(context).colorScheme.surfaceContainerHigh
: switch (_serviceStatus) {
ServiceStatus.operational => Colors.green[300],
ServiceStatus.failed => Colors.red[300],
_ => Colors.orange[300],
},
child: _serviceStatus == null
? Row(
children: [
const Icon(
Symbols.more_horiz,
size: 20,
),
const Gap(10),
Text('loading').tr(),
],
)
: switch (_serviceStatus) {
ServiceStatus.operational => Row(
children: [
Icon(
Symbols.check,
size: 20,
color: Colors.green[900],
),
const Gap(10),
Text('serviceStatusOperational')
.tr()
.textColor(Colors.green[900]),
],
),
ServiceStatus.failed => Tooltip(
message: 'serviceStatusFailedDescription'.tr(),
child: Row(
children: [
Icon(
Symbols.dangerous,
size: 20,
color: Colors.red[900],
),
const Gap(10),
Text('serviceStatusFailed')
.tr()
.textColor(Colors.red[900]),
],
),
),
_ => Row(
children: [
Icon(
Symbols.error,
size: 20,
color: Colors.orange[900],
),
const Gap(10),
Text('serviceStatusDowngraded')
.tr()
.textColor(Colors.orange[900]),
],
),
},
),
if (_statuses != null)
Expanded( Expanded(
child: Center( child: SingleChildScrollView(
child: CircularProgressIndicator(), padding: EdgeInsets.only(top: 6),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final entry in _statuses!.entries)
Tooltip(
message: kServicesName[entry.key] != null
? 'serviceName${kServicesName[entry.key]}'.tr()
: 'unknown'.tr(),
child: Chip(
visualDensity:
VisualDensity(horizontal: -4, vertical: -4),
avatar: entry.value
? const Icon(
Symbols.circle,
color: Colors.green,
fill: 1,
size: 16,
)
: AnimateWidgetExtensions(const Icon(
Symbols.error,
color: Colors.red,
fill: 1,
size: 16,
))
.animate(onPlay: (e) => e.repeat())
.fadeIn(
duration: 500.ms, curve: Curves.easeOut)
.then()
.fadeOut(
duration: 500.ms,
delay: 1000.ms,
curve: Curves.easeIn,
),
label: Text(kServicesName[entry.key] ?? entry.key),
),
),
],
).padding(horizontal: 12),
), ),
) ),
], ],
), ),
); );
@ -370,11 +518,20 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
} }
Future<void> _doCheckIn() async { Future<void> _doCheckIn() async {
final captchaTk = await Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (context) => CaptchaScreen(),
),
);
if (captchaTk == null) return;
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final home = context.read<HomeWidgetProvider>(); final home = context.read<HomeWidgetProvider>();
final resp = await sn.client.post('/cgi/id/check-in'); final resp = await sn.client.post('/cgi/id/check-in', data: {
'captcha_token': captchaTk,
});
_todayRecord = SnCheckInRecord.fromJson(resp.data); _todayRecord = SnCheckInRecord.fromJson(resp.data);
await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson()); await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson());
} catch (err) { } catch (err) {
@ -386,15 +543,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',
@ -429,7 +591,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)
@ -445,7 +610,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(),
], ],
), ),
@ -519,11 +687,26 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
'+${_todayRecord!.resultExperience} EXP', '+${_todayRecord!.resultExperience} EXP',
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
if (_todayRecord!.resultCoin >= 0) if (_todayRecord!.resultCoin > 0)
Text( Text(
'+${_todayRecord!.resultCoin} ${'walletCurrencyShort'.tr()}', '+${_todayRecord!.resultCoin} ${'walletCurrencyShort'.tr()}',
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
) ),
if (_todayRecord!.currentStreak > 0)
Row(
children: [
const Icon(
Symbols.local_fire_department,
size: 14,
).padding(bottom: 2),
const Gap(4),
Text(
'checkInStreak'
.plural(_todayRecord!.currentStreak),
style: Theme.of(context).textTheme.bodySmall,
),
],
).padding(top: 4),
], ],
), ),
), ),
@ -571,10 +754,12 @@ class _HomeDashNotificationWidget extends StatefulWidget {
const _HomeDashNotificationWidget(); 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 {
@ -612,7 +797,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,
), ),
], ],
@ -628,7 +815,7 @@ class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget
child: IconButton( child: IconButton(
icon: const Icon(Symbols.arrow_right_alt), icon: const Icon(Symbols.arrow_right_alt),
onPressed: () { onPressed: () {
GoRouter.of(context).goNamed('notification'); GoRouter.of(context).pushNamed('notification');
}, },
), ),
), ),
@ -643,10 +830,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;
@ -710,13 +899,17 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati
).tr(), ).tr(),
], ],
), ),
Text('${_currentPage + 1}/${_posts?.length ?? 0}', style: GoogleFonts.robotoMono()) Text(
'${_currentPage + 1}/${_posts?.length ?? 0}',
style: GoogleFonts.robotoMono(),
)
], ],
).padding(horizontal: 18, top: 12, bottom: 8), ).padding(horizontal: 18, top: 12, bottom: 8),
Expanded( Expanded(
child: PageView.builder( child: PageView.builder(
controller: _pageController, controller: _pageController,
scrollBehavior: ScrollConfiguration.of(context).copyWith(dragDevices: { scrollBehavior:
ScrollConfiguration.of(context).copyWith(dragDevices: {
PointerDeviceKind.mouse, PointerDeviceKind.mouse,
PointerDeviceKind.touch, PointerDeviceKind.touch,
}), }),
@ -727,9 +920,11 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati
child: PostItem( child: PostItem(
data: _posts![index], data: _posts![index],
showMenu: false, showMenu: false,
showFullPost: true,
).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
View 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(),
),
);
},
);
},
),
);
}
}

View File

@ -1,18 +1,17 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:html2md/html2md.dart' as html2md;
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: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';
import 'package:surface/types/news.dart'; import 'package:surface/types/news.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart'; import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:surface/widgets/universal_image.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
class NewsDetailScreen extends StatefulWidget { class NewsDetailScreen extends StatefulWidget {
@ -26,14 +25,12 @@ class NewsDetailScreen extends StatefulWidget {
class _NewsDetailScreenState extends State<NewsDetailScreen> { class _NewsDetailScreenState extends State<NewsDetailScreen> {
SnNewsArticle? _article; SnNewsArticle? _article;
dom.Document? _articleFragment;
Future<void> _fetchArticle() async { Future<void> _fetchArticle() async {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/re/news/${widget.hash}'); final resp = await sn.client.get('/cgi/re/news/${widget.hash}');
_article = SnNewsArticle.fromJson(resp.data); _article = SnNewsArticle.fromJson(resp.data);
_articleFragment = parse(_article!.content);
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err).then((_) { context.showErrorDialog(err).then((_) {
@ -45,104 +42,6 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
} }
} }
List<Widget> _parseHtmlToWidgets(Iterable<dom.Element>? elements) {
if (elements == null) return [];
final List<Widget> widgets = [];
for (final node in elements) {
switch (node.localName) {
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
widgets.add(Text(node.text.trim(), style: Theme.of(context).textTheme.titleMedium));
break;
case 'p':
if (node.text.trim().isEmpty) continue;
widgets.add(
Text.rich(
TextSpan(
text: node.text.trim(),
children: [
for (final child in node.children)
switch (child.localName) {
'a' => TextSpan(
text: child.text.trim(),
style: const TextStyle(decoration: TextDecoration.underline),
recognizer: TapGestureRecognizer()
..onTap = () {
launchUrlString(child.attributes['href']!);
},
),
_ => TextSpan(text: child.text.trim()),
},
],
),
style: Theme.of(context).textTheme.bodyLarge,
),
);
break;
case 'a':
// drop single link
break;
case 'div':
// ignore div text, normally it is not meaningful
widgets.addAll(_parseHtmlToWidgets(node.children));
break;
case 'hr':
widgets.add(const Divider());
break;
case 'img':
var src = node.attributes['src'];
if (src == null) break;
final width = double.tryParse(node.attributes['width'] ?? 'null');
final height = double.tryParse(node.attributes['height'] ?? 'null');
final ratio = width != null && height != null ? width / height : 1.0;
if (src.startsWith('//')) {
src = 'https:$src';
} else if (!src.startsWith('http')) {
final baseUri = Uri.parse(_article!.url);
final baseUrl = '${baseUri.scheme}://${baseUri.host}';
src = '$baseUrl/$src';
}
widgets.add(
AspectRatio(
aspectRatio: ratio,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
),
height: height ?? double.infinity,
child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(8)),
child: Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: AutoResizeUniversalImage(
src,
fit: width != null && height != null ? BoxFit.cover : BoxFit.contain,
),
),
),
),
),
);
break;
default:
widgets.addAll(_parseHtmlToWidgets(node.children));
break;
}
}
return widgets;
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -163,7 +62,9 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
MaterialBanner( MaterialBanner(
dividerColor: Colors.transparent, dividerColor: Colors.transparent,
leading: const Icon(Icons.info), leading: const Icon(Icons.info),
content: Text(_isReadingFromReader ? 'newsReadingFromReader'.tr() : 'newsReadingFromOriginal'.tr()), content: Text(_isReadingFromReader
? 'newsReadingFromReader'.tr()
: 'newsReadingFromOriginal'.tr()),
actions: [ actions: [
TextButton( TextButton(
child: Text('newsReadingProviderSwap').tr(), child: Text('newsReadingProviderSwap').tr(),
@ -173,7 +74,7 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
), ),
], ],
), ),
if (_articleFragment != null && _isReadingFromReader) if (_article != null && _isReadingFromReader)
Expanded( Expanded(
child: Container( child: Container(
constraints: BoxConstraints(maxWidth: 640), constraints: BoxConstraints(maxWidth: 640),
@ -182,28 +83,43 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8, spacing: 8,
children: [ children: [
Text(_article!.title, style: Theme.of(context).textTheme.titleLarge), Text(_article!.title,
style: Theme.of(context).textTheme.titleLarge),
Builder(builder: (context) { Builder(builder: (context) {
final htmlDescription = parse(_article!.description); final htmlDescription = parse(_article!.description);
return Text( return Text(
htmlDescription.children.map((ele) => ele.text.trim()).join(), htmlDescription.children
.map((ele) => ele.text.trim())
.join(),
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
); );
}), }),
Builder(builder: (context) { Builder(builder: (context) {
final date = _article!.publishedAt ?? _article!.createdAt; final date =
_article!.publishedAt ?? _article!.createdAt;
return Row( return Row(
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);
}), }),
Text('newsDisclaimer').tr().textStyle(Theme.of(context).textTheme.bodySmall!).opacity(0.75), Text('newsDisclaimer')
.tr()
.textStyle(Theme.of(context).textTheme.bodySmall!)
.opacity(0.75),
const Divider(), const Divider(),
..._parseHtmlToWidgets(_articleFragment!.children), MarkdownTextContent(
textScaler: TextScaler.linear(1.2),
content: html2md.convert(_article!.content),
),
const Divider(), const Divider(),
InkWell( InkWell(
child: Row( child: Row(
@ -211,7 +127,8 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
children: [ children: [
Text( Text(
'Reference from original website', 'Reference from original website',
style: TextStyle(decoration: TextDecoration.underline), style: TextStyle(
decoration: TextDecoration.underline),
), ),
const Gap(4), const Gap(4),
Icon(Icons.launch, size: 16), Icon(Icons.launch, size: 16),

View File

@ -29,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,
}; };
@ -57,11 +58,15 @@ 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;
@ -97,8 +102,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
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);
@ -123,8 +127,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
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);
@ -146,12 +149,10 @@ class _NotificationScreenState extends State<NotificationScreen> {
if (!ua.isAuthorized) { if (!ua.isAuthorized) {
return AppScaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
leading: AutoAppBarLeading(), leading: PageBackButton(),
title: Text('screenNotification').tr(), title: Text('screenNotification').tr(),
), ),
body: Center( body: Center(child: UnauthorizedHint()),
child: UnauthorizedHint(),
),
); );
} }
@ -161,9 +162,8 @@ class _NotificationScreenState extends State<NotificationScreen> {
title: Text('screenNotification').tr(), title: Text('screenNotification').tr(),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Symbols.checklist), icon: const Icon(Symbols.checklist),
onPressed: _isSubmitting ? null : _markAllAsRead, onPressed: _isSubmitting ? null : _markAllAsRead),
),
const Gap(8), const Gap(8),
], ],
), ),
@ -178,15 +178,16 @@ class _NotificationScreenState extends State<NotificationScreen> {
}, },
child: InfiniteList( child: InfiniteList(
padding: EdgeInsets.only( padding: EdgeInsets.only(
top: 16, top: 16,
bottom: math.max(MediaQuery.of(context).padding.bottom, 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 && _notifications.length >= _totalCount!, hasReachedMax: _totalCount != null &&
_notifications.length >= _totalCount!,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
final nty = _notifications[idx]; final nty = _notifications[idx];
return Row( return Row(
@ -200,50 +201,48 @@ class _NotificationScreenState extends State<NotificationScreen> {
children: [ children: [
if (nty.readAt == null) if (nty.readAt == null)
StyledWidget(Badge( StyledWidget(Badge(
label: Text('notificationUnread').tr(), label: Text('notificationUnread').tr()))
)).padding(bottom: 4), .padding(bottom: 4),
Text( Text(nty.title,
nty.title, style: Theme.of(context).textTheme.titleMedium),
style: Theme.of(context).textTheme.titleMedium,
),
if (nty.subtitle != null) if (nty.subtitle != null)
Text( Text(nty.subtitle!,
nty.subtitle!, style:
style: Theme.of(context).textTheme.titleSmall, Theme.of(context).textTheme.titleSmall),
),
if (nty.subtitle != null) const Gap(4), if (nty.subtitle != null) const Gap(4),
SelectionArea( SelectionArea(
child: MarkdownTextContent( child: MarkdownTextContent(
content: nty.body, content: nty.body, isAutoWarp: true)),
isAutoWarp: true, if ([
), 'interactive.reply',
), 'interactive.feedback',
if (['interactive.reply', 'interactive.feedback', 'interactive.subscription'] 'interactive.subscription',
.contains(nty.topic) && ].contains(nty.topic) &&
nty.metadata['related_post'] != null) nty.metadata['related_post'] != null)
GestureDetector( GestureDetector(
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(
Radius.circular(8)),
border: Border.all( border: Border.all(
color: Theme.of(context).dividerColor, color: Theme.of(context).dividerColor,
width: 1, 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(vertical: 4),
), ),
onTap: () { onTap: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
'postDetail', 'postDetail',
pathParameters: { pathParameters: {
'slug': nty.metadata['related_post']!['id'].toString(), 'slug': nty
.metadata['related_post']!['id']
.toString()
}, },
); );
}, },
@ -251,18 +250,15 @@ class _NotificationScreenState extends State<NotificationScreen> {
const Gap(8), const Gap(8),
Row( Row(
children: [ children: [
Text( Text(DateFormat('yy/MM/dd')
DateFormat('yy/MM/dd').format(nty.createdAt), .format(nty.createdAt))
).fontSize(12), .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)
RelativeTime(context).format(nty.createdAt), .format(nty.createdAt))
).fontSize(12), .fontSize(12),
], ],
).opacity(0.75), ).opacity(0.75),
], ],
@ -272,8 +268,10 @@ 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: const VisualDensity(horizontal: -4, vertical: -4), visualDensity:
onPressed: _isSubmitting ? null : () => _markOneAsRead(nty), const VisualDensity(horizontal: -4, vertical: -4),
onPressed:
_isSubmitting ? null : () => _markOneAsRead(nty),
), ),
], ],
).padding(horizontal: 16); ).padding(horizontal: 16);

View File

@ -12,7 +12,6 @@ import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.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/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';
@ -22,7 +21,8 @@ class PostDetailScreen extends StatefulWidget {
final SnPost? preload; final SnPost? preload;
final Function? onBack; final Function? onBack;
const PostDetailScreen({super.key, required this.slug, this.preload, this.onBack}); const PostDetailScreen(
{super.key, required this.slug, this.preload, this.onBack});
@override @override
State<PostDetailScreen> createState() => _PostDetailScreenState(); State<PostDetailScreen> createState() => _PostDetailScreenState();
@ -65,108 +65,111 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
final double maxWidth = _data?.type == 'video' ? double.infinity : 640; final double maxWidth = _data?.type == 'video' ? double.infinity : 640;
return AppBackground( return AppScaffold(
isRoot: widget.onBack != null, noBackground: true,
child: AppScaffold( appBar: AppBar(
appBar: AppBar( leading: BackButton(
leading: BackButton( onPressed: () {
onPressed: () { if (widget.onBack != null) {
if (widget.onBack != null) { widget.onBack!.call();
widget.onBack!.call(); }
} if (GoRouter.of(context).canPop()) {
if (GoRouter.of(context).canPop()) { GoRouter.of(context).pop(context);
GoRouter.of(context).pop(context); return;
return; }
} GoRouter.of(context).replaceNamed('explore');
GoRouter.of(context).replaceNamed('explore'); },
},
),
title: _data?.body['title'] != null
? RichText(
textAlign: TextAlign.center,
text: TextSpan(children: [
TextSpan(
text: _data?.body['title'] ?? 'postNoun'.tr(),
style: Theme.of(context).textTheme.titleLarge!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
const TextSpan(text: '\n'),
TextSpan(
text: 'postDetail'.tr(),
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
]),
maxLines: 2,
overflow: TextOverflow.ellipsis,
)
: Text('postDetail').tr(),
), ),
body: CustomScrollView( title: _data?.body['title'] != null
slivers: [ ? RichText(
SliverToBoxAdapter( textAlign: TextAlign.center,
child: LoadingIndicator(isActive: _isBusy), text: TextSpan(children: [
), TextSpan(
if (_data != null) text: _data?.body['title'] ?? 'postNoun'.tr(),
SliverToBoxAdapter( style: Theme.of(context).textTheme.titleLarge!.copyWith(
child: PostItem( color: Theme.of(context).appBarTheme.foregroundColor!,
data: _data!,
maxWidth: maxWidth,
showComments: false,
showFullPost: true,
onChanged: (data) {
setState(() => _data = data);
},
onDeleted: () {
Navigator.pop(context);
},
),
),
if (_data != null && _data!.type != 'video') const SliverToBoxAdapter(child: Divider(height: 1)),
if (_data != null && _data!.type != 'video')
SliverToBoxAdapter(
child: Container(
constraints: BoxConstraints(maxWidth: maxWidth),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.comment, size: 24),
const Gap(16),
Text('postCommentsDetailed')
.plural(_data!.metric.replyCount)
.textStyle(Theme.of(context).textTheme.titleLarge!),
],
).padding(horizontal: 20, vertical: 12).center(),
),
),
if (_data != null && ua.isAuthorized && _data!.type != 'video')
SliverToBoxAdapter(
child: PostCommentQuickAction(
parentPost: _data!,
maxWidth: maxWidth,
onPosted: () {
setState(() {
_data = _data!.copyWith(
metric: _data!.metric.copyWith(
replyCount: _data!.metric.replyCount + 1,
), ),
); ),
}); const TextSpan(text: '\n'),
_childListKey.currentState!.refresh(); TextSpan(
}, text: 'postDetail'.tr(),
), style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
]),
maxLines: 2,
overflow: TextOverflow.ellipsis,
)
: Text('postDetail').tr(),
),
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: LoadingIndicator(isActive: _isBusy),
),
if (_data != null)
SliverToBoxAdapter(
child: PostItem(
data: _data!,
maxWidth: maxWidth,
showComments: false,
showFullPost: true,
onChanged: (data) {
setState(() => _data = data);
},
onDeleted: () {
Navigator.pop(context);
},
), ),
if (_data != null && _data!.type != 'video') ),
PostCommentSliverList( if (_data != null)
key: _childListKey, SliverToBoxAdapter(
child: Divider(height: 1).padding(top: 8),
),
if (_data != null)
SliverToBoxAdapter(
child: Container(
constraints: BoxConstraints(maxWidth: maxWidth),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.comment, size: 24),
const Gap(16),
Text('postCommentsDetailed')
.plural(_data!.metric.replyCount)
.textStyle(Theme.of(context).textTheme.titleLarge!),
],
).padding(horizontal: 20, vertical: 12).center(),
),
),
if (_data != null && ua.isAuthorized)
SliverToBoxAdapter(
child: PostCommentQuickAction(
parentPost: _data!, parentPost: _data!,
maxWidth: maxWidth, maxWidth: maxWidth,
onPosted: () {
setState(() {
_data = _data!.copyWith(
metric: _data!.metric.copyWith(
replyCount: _data!.metric.replyCount + 1,
),
);
});
_childListKey.currentState!.refresh();
},
), ),
if (_data != null && _data!.type == 'video') SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)), ),
], if (_data != null) SliverGap(8),
), if (_data != null)
PostCommentSliverList(
key: _childListKey,
parentPost: _data!,
maxWidth: maxWidth,
),
if (_data != null)
SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)),
],
), ),
); );
} }

Some files were not shown because too many files have changed in this diff Show More