Compare commits

...

130 Commits

Author SHA1 Message Date
e2ecb573a2 🚀 Launch 2.3.2+70 2025-02-18 00:52:07 +08:00
8cb5dff498 🌐 Update Traditional Chinese localization files 2025-02-18 00:43:57 +08:00
a5629975ed Content insert support 2025-02-18 00:43:12 +08:00
972b304969 Flag posts 2025-02-18 00:38:32 +08:00
e8ded55055 🐛 Fix some listing non offset bugs
 Optimize user listing speed
2025-02-18 00:07:48 +08:00
04875eb164 Support notifications with multiple attachments 2025-02-18 00:07:20 +08:00
54a59aa470 💄 Recommendation post indicator 2025-02-17 18:53:46 +08:00
365f330629 🐛 Realm related bug fixes 2025-02-16 19:50:34 +08:00
a7829d15b2 🐛 Remove android predictive back 2025-02-16 13:29:41 +08:00
a3868a4281 📝 Update README.md 2025-02-16 01:09:45 +08:00
1d1d61d60c Merge pull request #1 from I21b/master 2025-02-15 23:40:05 +08:00
03c2491587 🔨 Add debian build scripts 2025-02-15 23:04:47 +08:00
2c1adc988c 🐛 Fix desktop window title 2025-02-15 22:25:23 +08:00
c0fbee55e4 🐛 Fix linux running issue 2025-02-15 21:22:22 +08:00
6e544c0b6c 🚀 Launch 2.3.2+69 2025-02-15 19:58:11 +08:00
7d56c5ef31 🐛 Fix reply / forward post type will follow the target post 2025-02-15 19:45:33 +08:00
c2df1af16d Reply & repost indicator 2025-02-15 19:43:41 +08:00
a8143c6453 🐛 Fix publisher list did not update after created 2025-02-15 19:23:02 +08:00
04065061e0 Leave realm 2025-02-15 19:20:34 +08:00
226eb452e5 Community and public chat, realm 2025-02-15 19:16:54 +08:00
a6715b0872 Chat return new line 2025-02-15 19:08:40 +08:00
43e3404dbb Delete publisher 2025-02-15 18:43:06 +08:00
c91cf7c813 🐛 Fix send empty message 2025-02-15 18:12:35 +08:00
92
9cd1cad695 Merge branch 'Solsynth:master' into master 2025-02-15 15:00:07 +09:00
92
dde280833b idk what to say 2025-02-15 14:59:45 +09:00
42ac12b53e 🔨 Fix linux build script 2025-02-15 13:48:25 +08:00
63567bf708 🔨 Fix linux build missing deps 2025-02-15 13:43:21 +08:00
5d3cadefef 🔨 Add linux build pipeline 2025-02-15 13:39:00 +08:00
251fbb2503 🐛 Try to fix github action build error 2025-02-15 13:34:23 +08:00
0b31d32217 💄 Fix some designs issue
🐛 Fix web some pages error
2025-02-15 13:06:25 +08:00
5ddd4fed2e 🐛 Fix missing new publisher button 2025-02-15 01:17:09 +08:00
48b6d5f6c1 🐛 Fix poll 2025-02-15 00:16:06 +08:00
b83b0b5efb 🚀 Launch 2.3.2+67 2025-02-13 22:54:30 +08:00
cb24bd953d Poll participate 2025-02-13 22:35:53 +08:00
4937dee182 Poll editor 2025-02-12 23:56:45 +08:00
d612097bb1 🐛 Fix publisher edit has no header 2025-02-12 19:48:49 +08:00
058d668b6b 💄 Optimize post video displaying 2025-02-12 19:13:08 +08:00
8b19462c3a 🐛 Fix post video bug 2025-02-12 16:56:36 +08:00
0a381ef09b 🚀 Launch 2.3.2+66 2025-02-11 21:59:01 +08:00
9b84e912b2 🐛 Fix post item width issue 2025-02-11 21:35:53 +08:00
b3254e0f2f Realm discovery 2025-02-11 21:31:53 +08:00
f0a3bbe023 🐛 Bug fixes 2025-02-10 18:00:15 +08:00
df81c84438 🐛 Bug fixes 2025-02-10 17:54:31 +08:00
8b12395fca 💄 Add more actions to video post editor 2025-02-10 11:51:42 +08:00
cb2b71d194 🚀 Launch 2.3.2+65 2025-02-10 00:52:09 +08:00
7ed508e2bb Video post 2025-02-10 00:44:52 +08:00
dad869967e 🚀 Launch 2.3.2+64 2025-02-08 15:01:41 +08:00
2d5b3b554e ♻️ Apply new OpenablePostItem to almost everywhere 2025-02-08 13:58:35 +08:00
74882116e3 🐛 Bug fixes on AI Insight 2025-02-08 13:41:39 +08:00
a97c3bce3a Select & Featured Answer 2025-02-08 13:27:53 +08:00
1aa70827dc Create questions & display questions 2025-02-08 01:35:27 +08:00
fe028860e9 💄 Optimize post editors 2025-02-07 22:35:04 +08:00
a2d2ce4d38 🐛 Trying to fix stream already listen 2025-02-07 21:33:39 +08:00
167c11b9eb ♻️ Optimize post editor architecture 2025-02-07 20:19:48 +08:00
8cb3933fcc 🐛 Bug fixes 2025-02-07 18:11:28 +08:00
3818328afe Cmd/Ctrl-V to paste image 2025-02-06 15:04:04 +08:00
11627e2455 💄 Clear tray number when click from it 2025-02-06 14:48:41 +08:00
3f82c06ff8 🐛 Fix use Cmd+Q quitting app 2025-02-06 14:08:57 +08:00
2350f59131 💄 Transparent app bar with real white 2025-02-06 13:22:34 +08:00
9fe7c9530a ♻️ Replace duplicate widgets with account select 2025-02-06 13:17:17 +08:00
52f1826e91 🚀 Launch 2.2.2+62 2025-02-04 22:56:45 +08:00
28a4c86dbf Optimize post editor 2025-02-04 22:04:50 +08:00
85e48ce03b ♻️ Refactor tray with manager 2025-02-04 16:11:25 +08:00
efef61a8ea Tray icon basis 2025-02-04 15:43:20 +08:00
10ead95af9 Emotes picker 2025-02-04 02:33:19 +08:00
838ee4d55d Click to zoom in sticker 2025-02-03 22:56:49 +08:00
13e42429a9 📝 Update API docs 2025-02-03 21:34:15 +08:00
c6ce3fe2b7 🐛 Patch websocket connection issue 2025-02-03 21:34:05 +08:00
ae9a7eb0fd 🚀 Launch 2.2.2+61 2025-02-02 13:35:43 +08:00
5d6fb2442f Able to config preferred language 2025-02-01 19:35:50 +08:00
5a85985534 Featured comment 2025-01-31 23:16:14 +08:00
c80499db03 Share to chat channel 2025-01-31 22:52:21 +08:00
b8dcdb2315 💄 Move the connection indicator 2025-01-31 21:50:18 +08:00
b7b921f1f4 📱 Fix new notify indicator on large screen 2025-01-31 20:26:20 +08:00
319d5c7d7f ♻️ Refactor notification indicator 2025-01-31 20:12:46 +08:00
4b5b001739 🐛 Fix open from widget cause multiple activity 2025-01-31 00:39:10 +08:00
db8871a455 🚀 Launch 2.2.2+60 2025-01-31 00:22:06 +08:00
38dcaa6066 AI Post Insight 2025-01-30 14:58:06 +08:00
03275b46ca 🚀 Launch 2.2.2+59 2025-01-29 21:54:00 +08:00
cf3b482fef 🐛 Bug fixes 2025-01-29 20:42:41 +08:00
aa4c04d4ef 🌐 Complete translations 2025-01-29 20:32:56 +08:00
73b82f65e4 Basic wallet page 2025-01-29 15:18:35 +08:00
9471fe40fe In-app language switcher 2025-01-28 23:09:07 +08:00
0d1e18735e 💄 Give a link to open wiki when error occurred. 2025-01-28 22:57:44 +08:00
8bb62b5992 🚀 Launch 2.2.2+58 2025-01-28 21:15:11 +08:00
1e8a6dea5b 💄 Optimize attachment list 2025-01-28 20:39:34 +08:00
5c2804cc4d 💄 Optimize news design 2025-01-28 20:21:51 +08:00
0dbb8f132a Factor settings with TOTP, In app notify authenticate method 2025-01-28 19:55:35 +08:00
3395f3dbd0 Create auth factor 2025-01-28 00:52:44 +08:00
d258ba776e ♻️ Splitting up account page and settings 2025-01-27 20:14:02 +08:00
0dcfcaad56 🚀 Launch 2.2.2+57 2025-01-26 15:04:22 +08:00
687e720956 💄 Optimize news 2025-01-26 14:50:52 +08:00
180876949e Home screen today news 2025-01-26 14:38:01 +08:00
9718965809 🐛 Bug fixes 2025-01-26 14:17:08 +08:00
5377161fb0 News reader 2025-01-26 12:38:43 +08:00
963e538ae5 News Reader Basis 2025-01-26 02:12:03 +08:00
a355e3bf90 🚀 Launch 2.2.2+56 2025-01-25 14:52:40 +08:00
cb4a2598c8 ♻️ Refactored router 2025-01-25 14:35:04 +08:00
950612dc07 🐛 Fix error when went to publisher page from account page 2025-01-25 14:27:09 +08:00
cbd1eaf1af 🐛 Fix some link preview compatibility issue 2025-01-25 01:40:54 +08:00
ac41cbd99f Toggleable preview link 2025-01-25 01:36:11 +08:00
9f9c90abc4 🐛 Fix call room meaningless safe area 2025-01-23 19:22:55 +08:00
87029e3538 🚀 Launch 2.2.2+55 2025-01-21 21:04:33 +08:00
127d9adc09 💄 Finishing up refactor background image related changes 2025-01-21 20:50:07 +08:00
c82dc7ad85 ♻️ Refactored background image (skip ci) 2025-01-21 20:35:04 +08:00
36bcff7a7c Add haptic feedback on post reaction 2025-01-21 15:07:44 +08:00
38201b547a 💄 Snackbar use floating mode while using m3 2025-01-21 15:06:27 +08:00
ed0334fcda 🐛 Bug fixes 2025-01-21 15:04:55 +08:00
fbb486b90b 💄 Optimized attachment list 2025-01-21 14:57:04 +08:00
9b34f385d5 🐛 Fix app bar buttons clicking event got absorb by indicators 2025-01-21 14:47:13 +08:00
bb7b731602 Rollback to old style attachment list 2025-01-21 12:12:21 +08:00
19076f8136 🚀 Launch 2.2.2+54 2025-01-20 17:35:13 +08:00
dc77a936ce ⬆️ Upgrade deps 2025-01-20 16:53:38 +08:00
7f58710c6f Notification indicator 2025-01-20 16:52:53 +08:00
068ddcdcdc 💄 Optimized connection indicator 2025-01-20 14:40:26 +08:00
f4e9252ca0 💄 Optimized post list 2025-01-20 14:21:41 +08:00
3b1e918117 🐛 Fix side nav cause render error 2025-01-20 01:43:11 +08:00
ed7981fdaf 🧪 Post max width 2025-01-19 17:20:24 +08:00
9698ca53e4 Swipe up to view attachment details 2025-01-19 11:44:14 +08:00
ddc1dc7daf 💄 Optimize attachment zoom page 2025-01-19 01:00:00 +08:00
1625a957f8 👔 Use material design 3 by default 2025-01-19 00:39:47 +08:00
2dc50d627e 🧱 Fix roadsign config 2025-01-16 21:51:28 +08:00
2ffde9a3dd 🚀 Launch 2.2.2+53 2025-01-15 16:00:59 +08:00
5967a91ae1 Chat user popover 2025-01-15 15:52:52 +08:00
32c1effcb5 💄 Post editor content max width 2025-01-15 15:19:46 +08:00
9d0e19c56f 🐛 Fix chat messages 2025-01-15 15:14:13 +08:00
acf4e634fe 🐛 Fix websocket will put message in wrong channel 2025-01-14 23:32:02 +08:00
25942c2338 💄 Optimize chat max width 2025-01-14 23:30:35 +08:00
a4f81f6ba1 🐛 Post auto warp 2025-01-14 23:24:11 +08:00
c1b9090e51 💄 Optimized attachment list 2025-01-14 23:17:34 +08:00
131 changed files with 11073 additions and 2896 deletions

View File

@ -38,4 +38,27 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: build-output-windows name: build-output-windows
path: build/windows/x64/runner/Release path: build/windows/x64/runner/Release
build-linux:
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- run: |
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev
sudo apt-get install libmpv-dev mpv
sudo apt-get install libayatana-appindicator3-dev
sudo apt-get install keybinder-3.0
- run: flutter pub get
- run: flutter build linux
- name: Archive production artifacts
uses: actions/upload-artifact@v4
with:
name: build-output-linux
path: build/linux/x64/release/bundle

View File

@ -1,12 +1,12 @@
{ {
"sync": { "sync": {
"region": "solian-next", "region": "solian",
"configPath": "roadsign.toml" "configPath": "roadsign.toml"
}, },
"deployments": [ "deployments": [
{ {
"region": "solian-next", "region": "solian",
"site": "solian-next-web", "site": "solian-web",
"path": "build/web" "path": "build/web"
} }
] ]

27
README.md Normal file
View File

@ -0,0 +1,27 @@
# Solar Network
![](https://solsynth.dev/_next/static/media/alpha.e779a584.webp)
Hello there! Welcome to the main repository of the HyperNet (also known as the Solar Network). The code here is mainly about the frontend app (also known as Solian). But you can still post issues here to get help and request new features!
## Sub Projects
HyperNet, the Solar Network is a microservices project in which the backends are stored in separate repositories. Here is a simple index for it.
- The Core, Gateway: [Nexus](https://github.com/Solsynth/HyperNet.Nexus)
- The Auth Service: [Passport](https://github.com/Solsynth/HyperNet.Passport)
- The Posting Service: [Interactive](https://github.com/Solsynth/HyperNet.Interactive)
- The Messaging Service: [Messaging](https://github.com/Solsynth/HyperNet.Messaging)
- The Wallet Service: [Wallet](https://github.com/Solsynth/HyperNet.Wallet)
- The Crawler: [Reader](https://github.com/Solsynth/HyperNet.Reader)
- Some others may not be listed, you can search in the organization with `HyperNet.` the prefix of all HyperNet projects.
## Tech Stack
For those people who want to know the tech stack of this project, the frontend was built by Flutter, which provides the cross-platform ability.
The backend was built in Go and PostgreSQL with our very own microservice framework included in the nexus.
-----
The readme will be updated in the future, to be determined. For now, you can check out the link of this repository to learn more on our official website.

View File

@ -18,10 +18,14 @@
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true"> android:requestLegacyExternalStorage="true">
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask" android:launchMode="singleInstance"
android:taskAffinity="" android:taskAffinity=""
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"

View File

@ -54,7 +54,7 @@ class CheckInWidget : GlanceAppWidget() {
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.registerTypeAdapter(Instant::class.java, InstantAdapter()) .registerTypeAdapter(Instant::class.java, InstantAdapter())
.create() .create()
val resultTierSymbols = listOf("大凶", "", "中平", "", "大吉") val resultTierSymbols = listOf("Bad", "Poor", "Medium", "Good", "Great")
val prefs = currentState.preferences val prefs = currentState.preferences
val checkInRaw: String? = prefs.getString("pas_check_in_record", null) val checkInRaw: String? = prefs.getString("pas_check_in_record", null)
@ -120,7 +120,7 @@ class CheckInWidget : GlanceAppWidget() {
} }
Text( Text(
text = "You haven't checked in today", text = "You haven't divined today",
style = TextStyle(fontSize = 15.sp, color = GlanceTheme.colors.onSurface) style = TextStyle(fontSize = 15.sp, color = GlanceTheme.colors.onSurface)
) )
} }

View File

@ -12,9 +12,9 @@ post {
body:json { body:json {
{ {
"alias": "AteChip", "alias": "BaLoading",
"name": "Cat ate chips", "name": "BaLoading",
"attachment_id": "d0b692cc64054463", "attachment_id": "2JCI2uh21mKkfk9P",
"pack_id": 2 "pack_id": 3
} }
} }

View File

@ -0,0 +1,11 @@
meta {
name: Get Sticker Packs
type: http
seq: 3
}
get {
url: {{endpoint}}/cgi/uc/stickers/packs
body: none
auth: none
}

View File

@ -0,0 +1,15 @@
meta {
name: Get Stickers
type: http
seq: 4
}
get {
url: {{endpoint}}/cgi/uc/stickers?take=10
body: none
auth: none
}
params:query {
take: 10
}

View File

@ -15,11 +15,11 @@ 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": "Merry Christmas!", "subject": "新年快乐!",
"subtitle": "一条来自 Solar Network 团队的信息", "subtitle": "一条来自 Solar Network 团队的信息",
"content": "今天是 12 月 25 日 (UTC+8),小羊祝您圣诞快乐 🎄", "content": "今天是农历正月初一,小羊祝您新年快乐 🎉",
"metadata": { "metadata": {
"image": "6EqsYQwmFRCkbmhR" "image": "D2EDbcrsTugs3xk5"
}, },
"priority": 10 "priority": 10
} }

View File

@ -0,0 +1,23 @@
meta {
name: Developer Notify One User
type: http
seq: 2
}
post {
url: {{endpoint}}/cgi/id/dev/notify/122
body: json
auth: inherit
}
body:json {
{
"client_id": "{{third_client_id}}",
"client_secret":"{{third_client_tk}}",
"type": "general",
"subject": "处理该帐号 @solian 的决定",
"subtitle": "违反用户协议",
"content": "您的帐号违反了我们用户协议中关于冒充我们官方的行为,至此做出停权的决定。还请见谅。该决定是最终决定,不接受上诉。",
"priority": 10
}
}

View File

@ -0,0 +1,11 @@
meta {
name: List News Sources
type: http
seq: 3
}
get {
url: {{endpoint}}/cgi/re/well-known/sources
body: none
auth: none
}

17
api/Reader/List News.bru Normal file
View File

@ -0,0 +1,17 @@
meta {
name: List News
type: http
seq: 2
}
get {
url: {{endpoint}}/cgi/re/news?take=10&offset=0&source=shadiao
body: none
auth: none
}
params:query {
take: 10
offset: 0
source: shadiao
}

View File

@ -0,0 +1,18 @@
meta {
name: Trigger Scan News
type: http
seq: 1
}
post {
url: {{endpoint}}/cgi/re/admin/scan
body: json
auth: inherit
}
body:json {
{
"sources": ["taiwan-ltn"],
"eager": true
}
}

View File

@ -0,0 +1,20 @@
meta {
name: Create Order
type: http
seq: 1
}
post {
url: {{endpoint}}/cgi/wa/orders
body: json
auth: none
}
body:json {
{
"client_id": "highland-mc",
"client_secret": "(3^DLAvo3v",
"remark": "我是秦始皇,现在被困香港,现在 SN 转我 500 源点,帮助我回到咸阳,到时候封你为太监一职。",
"amount": 500
}
}

View File

@ -0,0 +1,21 @@
meta {
name: Create Transaction
type: http
seq: 3
}
post {
url: {{endpoint}}/cgi/wa/transactions
body: json
auth: none
}
body:json {
{
"client_id": "alphabot",
"client_secret": "_uR0sVnHTh",
"remark": "新年红包",
"amount": 150,
"payee_id": 18
}
}

20
api/Wallet/Get Order.bru Normal file
View File

@ -0,0 +1,20 @@
meta {
name: Get Order
type: http
seq: 2
}
get {
url: {{endpoint}}/cgi/wa/orders/4
body: none
auth: none
}
body:json {
{
"client_id": "highland-mc",
"client_secret": "(3^DLAvo3v",
"remark": "我是秦始皇,现在被困香港,现在 SN 转我 500 源点,帮助我回到咸阳,到时候封你为太监一职。",
"amount": 500
}
}

View File

@ -0,0 +1,20 @@
meta {
name: Get Transaction
type: http
seq: 4
}
get {
url: {{endpoint}}/cgi/wa/transactions/67
body: none
auth: inherit
}
body:json {
{
"client_id": "highland-mc",
"client_secret": "(3^DLAvo3v",
"remark": "我是秦始皇,现在被困香港,现在 SN 转我 500 源点,帮助我回到咸阳,到时候封你为太监一职。",
"amount": 500
}
}

View File

@ -0,0 +1,11 @@
meta {
name: Run Database Maintenance
type: http
seq: 1
}
post {
url: {{endpoint}}/wt/maintenance/database
body: none
auth: inherit
}

BIN
assets/icon/tray-icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
assets/icon/tray-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

View File

@ -17,12 +17,17 @@
"screenAccountProfileEdit": "Edit Profile", "screenAccountProfileEdit": "Edit Profile",
"screenAbuseReport": "Abuse Reports", "screenAbuseReport": "Abuse Reports",
"screenSettings": "Settings", "screenSettings": "Settings",
"screenAccountSettings": "Account Settings",
"screenFactorSettings": "Auth Factors",
"screenAccountWallet": "Wallet",
"screenNews": "News",
"screenAlbum": "Album", "screenAlbum": "Album",
"screenChat": "Chat", "screenChat": "Chat",
"screenChatManage": "Edit Channel", "screenChatManage": "Edit Channel",
"screenChatNew": "New Channel", "screenChatNew": "New Channel",
"screenRealm": "Realm", "screenRealm": "Realm",
"screenRealmManage": "Edit Realm", "screenRealmManage": "Edit Realm",
"screenRealmDiscovery": "Realm Discovery",
"screenRealmNew": "New Realm", "screenRealmNew": "New Realm",
"screenNotification": "Notification", "screenNotification": "Notification",
"screenPostSearch": "Search Posts", "screenPostSearch": "Search Posts",
@ -103,8 +108,18 @@
}, },
"loginEnterPassword": "Enter the code", "loginEnterPassword": "Enter the code",
"loginSuccess": "Logged in as {}", "loginSuccess": "Logged in as {}",
"authFactorDelete": "Delete Auth Factor",
"authFactorDeleteDescription": "Are you sure you want delete auth factor {}?",
"authFactorPassword": "Password", "authFactorPassword": "Password",
"authFactorPasswordDescription": "The password you set when you registered.",
"authFactorEmail": "Email verification code", "authFactorEmail": "Email verification code",
"authFactorEmailDescription": "An one-time code sent to the email address you set when you registered.",
"authFactorTOTP": "Time-based OTP",
"authFactorTOTPDescription": "A one-time code generated by a TOTP authenticator such as Google Authenticator or Authy.",
"authFactorInAppNotify": "In-app notification",
"authFactorInAppNotifyDescription": "A one-time code sent via in-app notification.",
"authFactorAdd": "Add a factor",
"authFactorAddSubtitle": "Provide another way to login your account.",
"accountIntroTitle": "Hello there!", "accountIntroTitle": "Hello there!",
"accountIntroSubtitle": "Pick an option below to get started.", "accountIntroSubtitle": "Pick an option below to get started.",
"accountLogout": "Logout", "accountLogout": "Logout",
@ -113,8 +128,14 @@
"accountLogoutConfirm": "You will need to re-enter your account password, even if you have already done so. This is required to login again.", "accountLogoutConfirm": "You will need to re-enter your account password, even if you have already done so. This is required to login again.",
"accountPublishers": "Your publishers", "accountPublishers": "Your publishers",
"accountPublishersSubtitle": "Manage your publish identities.", "accountPublishersSubtitle": "Manage your publish identities.",
"accountSettings": "Account Settings",
"accountSettingsSubtitle": "Manage your account and make it yours.",
"accountProfileEdit": "Edit your profile", "accountProfileEdit": "Edit your profile",
"accountProfileEditSubtitle": "Make your Solarpass account more looks like you.", "accountProfileEditSubtitle": "Make your Solarpass account more looks like you.",
"accountWallet": "Wallet",
"accountWalletSubtitle": "View your balance and transactions.",
"factorSettings": "Auth Factors",
"factorSettingsSubtitle": "Manage your authentication factors.",
"accountProfileEditApplied": "Profile modification applied.", "accountProfileEditApplied": "Profile modification applied.",
"publishersNew": "New Publisher", "publishersNew": "New Publisher",
"publisherNewSubtitle": "Create a new publisher identity.", "publisherNewSubtitle": "Create a new publisher identity.",
@ -134,9 +155,12 @@
"fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm", "fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm",
"writePostTypeStory": "Post a story", "writePostTypeStory": "Post a story",
"writePostTypeArticle": "Write an article", "writePostTypeArticle": "Write an article",
"writePostTypeQuestion": "Ask a question",
"writePostTypeVideo": "Post a video",
"fieldPostPublisher": "Post publisher", "fieldPostPublisher": "Post publisher",
"fieldPostContent": "What happened?!", "fieldPostContent": "What happened?!",
"fieldPostTitle": "Title", "fieldPostTitle": "Title",
"fieldPostQuestionReward": "Answer Rewards (Source Points)",
"fieldPostDescription": "Description", "fieldPostDescription": "Description",
"fieldPostTags": "Tags", "fieldPostTags": "Tags",
"fieldPostCategories": "Categories", "fieldPostCategories": "Categories",
@ -146,9 +170,9 @@
"postPosted": "Post has been posted.", "postPosted": "Post has been posted.",
"postPublishedAt": "Published At", "postPublishedAt": "Published At",
"postPublishedUntil": "Published Until", "postPublishedUntil": "Published Until",
"postEditingNotice": "You're about to editing a post that posted {}.", "postEditingNotice": "You're about to editing a post that posted by {}.",
"postReplyingNotice": "You're about to reply to a post that posted {}.", "postReplyingNotice": "You're about to reply to a post that posted by {}.",
"postRepostingNotice": "You're about to repost a post that posted {}.", "postRepostingNotice": "You're about to repost a post that posted by {}.",
"postReact": "React", "postReact": "React",
"postReactions": "Reactions of Post", "postReactions": "Reactions of Post",
"postReactionUpvote": { "postReactionUpvote": {
@ -179,6 +203,9 @@
"other": "{} comments" "other": "{} comments"
}, },
"settingsAppearance": "Appearance", "settingsAppearance": "Appearance",
"settingsDisplayLanguage": "Display Language",
"settingsDisplayLanguageDescription": "Set the application language.",
"settingsDisplayLanguageSystem": "Follow System",
"settingsAppBarTransparent": "Transparent App Bar", "settingsAppBarTransparent": "Transparent App Bar",
"settingsAppBarTransparentDescription": "Enable transparent effect for the app bar.", "settingsAppBarTransparentDescription": "Enable transparent effect for the app bar.",
"settingsDrawerPreferCollapse": "Prefer Drawer Collapse", "settingsDrawerPreferCollapse": "Prefer Drawer Collapse",
@ -193,6 +220,13 @@
"settingsColorSchemeDescription": "Set the application primary color.", "settingsColorSchemeDescription": "Set the application primary color.",
"settingsColorSeed": "Color Seed", "settingsColorSeed": "Color Seed",
"settingsColorSeedDescription": "Select one of the present color schemes.", "settingsColorSeedDescription": "Select one of the present color schemes.",
"settingsFeatures": "Features",
"settingsNotifyWithHaptic": "Haptic when Notified",
"settingsNotifyWithHapticDescription": "Vibrate lightly when a new notification appears in the foreground.",
"settingsExpandPostLink": "Expand Post Link",
"settingsExpandPostLinkDescription": "Expand the post link in the post list.",
"settingsExpandChatLink": "Expand Chat Link",
"settingsExpandChatLinkDescription": "Expand the chat link in the chat list.",
"settingsNetwork": "Network", "settingsNetwork": "Network",
"settingsNetworkServer": "HyperNet Server", "settingsNetworkServer": "HyperNet Server",
"settingsNetworkServerDescription": "Set the HyperNet server address, choose ours or build your own.", "settingsNetworkServerDescription": "Set the HyperNet server address, choose ours or build your own.",
@ -211,12 +245,15 @@
"settingsMisc": "Misc", "settingsMisc": "Misc",
"settingsMiscAbout": "About", "settingsMiscAbout": "About",
"settingsMiscAboutDescription": "View the version information of Solian.", "settingsMiscAboutDescription": "View the version information of Solian.",
"settingsAccountLanguage": "Account Language",
"settingsAccountLanguageDescription": "Set the language for email, notification, and other account-related content.",
"sensitiveContent": "Sensitive Content", "sensitiveContent": "Sensitive Content",
"sensitiveContentCollapsed": "Sensitive content has been collapsed.", "sensitiveContentCollapsed": "Sensitive content has been collapsed.",
"sensitiveContentDescription": "This content has been marked as sensitive, and may not be suitable for all viewers.", "sensitiveContentDescription": "This content has been marked as sensitive, and may not be suitable for all viewers.",
"sensitiveContentReveal": "Reveal", "sensitiveContentReveal": "Reveal",
"serverConnecting": "Connecting to server...", "serverConnecting": "Connecting...",
"serverDisconnected": "Lost connection from server", "serverDisconnected": "Connection Lost",
"serverConnected": "Connected",
"fieldChatAlias": "Channel Alias", "fieldChatAlias": "Channel Alias",
"fieldChatAliasHint": "The unique channel alias within the site, used to represent the channel in URL, leave blank to auto generate. Should be URL-Safe.", "fieldChatAliasHint": "The unique channel alias within the site, used to represent the channel in URL, leave blank to auto generate. Should be URL-Safe.",
"fieldChatName": "Name", "fieldChatName": "Name",
@ -294,7 +331,9 @@
"addAttachmentFromCameraPhoto": "Take photo", "addAttachmentFromCameraPhoto": "Take photo",
"addAttachmentFromCameraVideo": "Take video", "addAttachmentFromCameraVideo": "Take video",
"addAttachmentFromRandomId": "Link via RID", "addAttachmentFromRandomId": "Link via RID",
"attachmentDetailInfo": "Attachment details",
"attachmentPastedImage": "Pasted Image", "attachmentPastedImage": "Pasted Image",
"attachmentInsertedImage": "Inserted Image",
"attachmentInsertLink": "Insert Link", "attachmentInsertLink": "Insert Link",
"attachmentSetAsPostThumbnail": "Set as post thumbnail", "attachmentSetAsPostThumbnail": "Set as post thumbnail",
"attachmentUnsetAsPostThumbnail": "Unset as post thumbnail", "attachmentUnsetAsPostThumbnail": "Unset as post thumbnail",
@ -381,7 +420,7 @@
"callMessageEnded": "Call lasted {}", "callMessageEnded": "Call lasted {}",
"callMessageStarted": "Call started", "callMessageStarted": "Call started",
"dailyCheckIn": "Check In", "dailyCheckIn": "Check In",
"dailyCheckInNone": "You haven't checked in today", "dailyCheckInNone": "You haven't divined today",
"dailyCheckAction": "Check in right now!", "dailyCheckAction": "Check in right now!",
"dailyCheckDetail": "Can't understand the symbol? Master, help me understand it!", "dailyCheckDetail": "Can't understand the symbol? Master, help me understand it!",
"dailyCheckDetailTitle": "{}'s fortune details", "dailyCheckDetailTitle": "{}'s fortune details",
@ -522,11 +561,15 @@
"postImageShareAds": "Explore posts on the Solar Network", "postImageShareAds": "Explore posts on the Solar Network",
"postShare": "Share", "postShare": "Share",
"postShareImage": "Share via Image", "postShareImage": "Share via Image",
"postGetInsight": "Get Insight",
"postGetInsightTitle": "AI Insight",
"postGetInsightDescription": "AI may make mistakes, check important information.",
"appInitializing": "Initializing", "appInitializing": "Initializing",
"poweredBy": "Powered by {}", "poweredBy": "Powered by {}",
"shareIntent": "Share", "shareIntent": "Share",
"shareIntentDescription": "What do you want to do with the content you are sharing?", "shareIntentDescription": "What do you want to do with the content you are sharing?",
"shareIntentPostStory": "Post a Story", "shareIntentPostStory": "Post a Story",
"shareIntentSendChannel": "Share to Channel",
"updateAvailable": "Update Available", "updateAvailable": "Update Available",
"updateOngoing": "Updating, please wait...", "updateOngoing": "Updating, please wait...",
"custom": "Custom", "custom": "Custom",
@ -539,6 +582,7 @@
"colorSchemeWhite": "White", "colorSchemeWhite": "White",
"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",
"postCategoryTechnology": "Technology", "postCategoryTechnology": "Technology",
"postCategoryGaming": "Gaming", "postCategoryGaming": "Gaming",
"postCategoryLife": "Life", "postCategoryLife": "Life",
@ -549,5 +593,77 @@
"postCategoryKnowledge": "Knowledge", "postCategoryKnowledge": "Knowledge",
"postCategoryLiterature": "Literature", "postCategoryLiterature": "Literature",
"postCategoryFunny": "Funny", "postCategoryFunny": "Funny",
"postCategoryUncategorized": "Uncategorized" "postCategoryUncategorized": "Uncategorized",
"newsAllSources": "All News",
"newsReadingProviderSwap": "Swap",
"newsReadingFromReader": "You're reading from HyperNet.Reader",
"newsReadingFromOriginal": "You're reading the original article",
"newsDisclaimer": "This article is fetched from the Internet, we do not guarantee its authenticity, please judge for yourself. All content in this article belongs to the original author.",
"newsToday": "Today's News",
"totpPostSetup": "One More Thing",
"totpPostSetupDescription": "Scan the QR Code below with Google Authenticator, Microsoft Authenticator, 1Password, Authy, Bitwarden or any of kind of authenticator app which supports TOTP.",
"totpNeverShare": "Never share this QR Code",
"needHelp": "Need Help?",
"needHelpLaunch": "Check out our Goatpedia!",
"walletCreate": "Create a Wallet",
"walletCreateSubtitle": "Create a wallet to start using Source Points",
"walletCreatePassword": "Set a payment password for your new wallet below",
"walletCurrencyShort": "SRC",
"walletCurrency": {
"one": "{} Source Point",
"other": "{} Source Points"
},
"aiThinkingProcess": "AI Thinking Process",
"accountSettingsApplied": "Account settings have been applied.",
"trayMenuExit": "Exit",
"postQuestionUnanswered": "Unanswered Question",
"postQuestionUnansweredWithReward": "Unanswered Question, reward source points {}",
"postQuestionAnswered": "Answered Question",
"postQuestionAnswerSelect": "Select as Answer",
"postQuestionAnswerSelected": "Answer has been selected, reward has been applied.",
"postVideoUpload": "Upload Video",
"realmJoin": "Join Realm",
"realmCommunityHint": "This realm is a community realm, you can freely join.",
"realmCommunityPublicChannelsHint": "The public channels in this realm",
"realmJoined": "Joined realm {}.",
"join": "Join",
"pollEditorNew": "New Poll",
"pollEditorEdit": "Edit Poll",
"pollEditorDelete": "Delete Poll",
"pollEditorDeleteDescription": "Are you sure you want to delete this poll? This operation is irreversible.",
"pollEditorUnlink": "Unlink Poll",
"pollOptionAdd": "Add Option",
"pollOptionName": "Option Name",
"pollLinkExisting": "Link existing poll",
"pollAnswered": "Answered the poll.",
"pollVotes": {
"one": "{} vote",
"other": "{} votes"
},
"publisherDelete": "Delete Publisher {}",
"publisherDeleteDescription": "Are you sure you want to delete this publisher? This operation is irreversible.",
"channelIsPublic": "Public Channel",
"channelIsPublicDescription": "The channel is public, anyone can join.",
"channelIsCommunity": "Community Channel",
"channelIsCommunityDescription": "Currently, community channel has nothing special yet.",
"realmIsPublic": "Public Realm",
"realmIsPublicDescription": "The realm is public, anyone can join.",
"realmIsCommunity": "Community Realm",
"realmIsCommunityDescription": "Community realm will be displayed on the discover page.",
"realmLeave": "Leave Realm",
"realmLeaveDescription": "Leave the current realm and delete the realm's identity.",
"checkInResultTier1": "Worst",
"checkInResultTier2": "Worse",
"checkInResultTier3": "Normal",
"checkInResultTier4": "Better",
"checkInResultTier5": "Best",
"flagPostAction": "Flag the Post",
"flagPost": "Flag objectionable content",
"flagPostDescription": "If flagged users takes 50% or more of the views, the post will be collapsed. You cannot revoke the action.",
"flaggedPost": "Post has been flagged.",
"postViews": {
"zero": "No views",
"one": "{} view",
"other": "{} views"
}
} }

View File

@ -15,12 +15,17 @@
"screenAccountProfileEdit": "编辑资料", "screenAccountProfileEdit": "编辑资料",
"screenAbuseReport": "滥用检举", "screenAbuseReport": "滥用检举",
"screenSettings": "设置", "screenSettings": "设置",
"screenAccountSettings": "账号设置",
"screenFactorSettings": "验证因子",
"screenAccountWallet": "钱包",
"screenNews": "新闻",
"screenAlbum": "相册", "screenAlbum": "相册",
"screenChat": "聊天", "screenChat": "聊天",
"screenChatManage": "编辑聊天频道", "screenChatManage": "编辑聊天频道",
"screenChatNew": "新建聊天频道", "screenChatNew": "新建聊天频道",
"screenRealm": "领域", "screenRealm": "领域",
"screenRealmManage": "编辑领域", "screenRealmManage": "编辑领域",
"screenRealmDiscovery": "发现领域",
"screenRealmNew": "新建领域", "screenRealmNew": "新建领域",
"screenNotification": "通知", "screenNotification": "通知",
"screenPostSearch": "搜索帖子", "screenPostSearch": "搜索帖子",
@ -87,8 +92,18 @@
}, },
"loginEnterPassword": "验证代码", "loginEnterPassword": "验证代码",
"loginSuccess": "登录为 {}", "loginSuccess": "登录为 {}",
"authFactorDelete": "删除验证因子",
"authFactorDeleteDescription": "你确定要删除 {} 验证因子吗?",
"authFactorPassword": "密码", "authFactorPassword": "密码",
"authFactorPasswordDescription": "注册时选择设置的密码。",
"authFactorEmail": "电邮一次性验证码", "authFactorEmail": "电邮一次性验证码",
"authFactorEmailDescription": "由我们生成并发送到绑定的的电子邮箱的一次性验证码。",
"authFactorTOTP": "时序验证码",
"authFactorTOTPDescription": "使用 Google Authenticator 或 Authy 等验证器生成的一次性验证码。",
"authFactorInAppNotify": "应用内通知验证码",
"authFactorInAppNotifyDescription": "通过站内通知推送的一次性验证码。",
"authFactorAdd": "添加新验证因子",
"authFactorAddSubtitle": "给你的帐户登陆时提供另一个方案。",
"accountIntroTitle": "喜欢您来!", "accountIntroTitle": "喜欢您来!",
"accountIntroSubtitle": "登陆以探索更广大的世界。", "accountIntroSubtitle": "登陆以探索更广大的世界。",
"accountLogout": "退出登录", "accountLogout": "退出登录",
@ -97,8 +112,14 @@
"accountLogoutConfirm": "您需要重新输入账号密码,甚至可能需要多步验证来再次登陆。", "accountLogoutConfirm": "您需要重新输入账号密码,甚至可能需要多步验证来再次登陆。",
"accountPublishers": "你的发布者", "accountPublishers": "你的发布者",
"accountPublishersSubtitle": "管理你的公共形象。", "accountPublishersSubtitle": "管理你的公共形象。",
"accountSettings": "帐号设置",
"accountSettingsSubtitle": "管理你的帐号并让它更好的服务你。",
"accountProfileEdit": "编辑资料", "accountProfileEdit": "编辑资料",
"accountProfileEditSubtitle": "使你的 Solarpass 账户更像你。", "accountProfileEditSubtitle": "使你的 Solarpass 账户更像你。",
"accountWallet": "钱包",
"accountWalletSubtitle": "查看你的余额和交易记录。",
"factorSettings": "验证因子",
"factorSettingsSubtitle": "管理你的登陆验证方式。",
"accountProfileEditApplied": "个人资料修改已被应用。", "accountProfileEditApplied": "个人资料修改已被应用。",
"publishersNew": "新发布者", "publishersNew": "新发布者",
"publisherNewSubtitle": "创建一个新的公共身份。", "publisherNewSubtitle": "创建一个新的公共身份。",
@ -118,9 +139,12 @@
"fieldPublisherBelongToRealmUnset": "未设置发布者所属领域", "fieldPublisherBelongToRealmUnset": "未设置发布者所属领域",
"writePostTypeStory": "发动态", "writePostTypeStory": "发动态",
"writePostTypeArticle": "写文章", "writePostTypeArticle": "写文章",
"writePostTypeQuestion": "提问题",
"writePostTypeVideo": "发视频",
"fieldPostPublisher": "帖子发布者", "fieldPostPublisher": "帖子发布者",
"fieldPostContent": "发生什么事了?!", "fieldPostContent": "发生什么事了?!",
"fieldPostTitle": "标题", "fieldPostTitle": "标题",
"fieldPostQuestionReward": "回答奖励源点",
"fieldPostDescription": "描述", "fieldPostDescription": "描述",
"fieldPostTags": "标签", "fieldPostTags": "标签",
"fieldPostCategories": "分类", "fieldPostCategories": "分类",
@ -177,6 +201,9 @@
"other": "{} 条评论" "other": "{} 条评论"
}, },
"settingsAppearance": "外观", "settingsAppearance": "外观",
"settingsDisplayLanguage": "显示语言",
"settingsDisplayLanguageDescription": "设置应用程序使用的语言",
"settingsDisplayLanguageSystem": "跟随系统",
"settingsBackgroundImage": "背景图片", "settingsBackgroundImage": "背景图片",
"settingsBackgroundImageDescription": "设置应用全局生效的的背景图片。", "settingsBackgroundImageDescription": "设置应用全局生效的的背景图片。",
"settingsBackgroundImageClear": "清除现存背景图", "settingsBackgroundImageClear": "清除现存背景图",
@ -191,6 +218,13 @@
"settingsColorSchemeDescription": "设置应用主题色。", "settingsColorSchemeDescription": "设置应用主题色。",
"settingsColorSeed": "预设色彩主题", "settingsColorSeed": "预设色彩主题",
"settingsColorSeedDescription": "选择一个预设色彩主题。", "settingsColorSeedDescription": "选择一个预设色彩主题。",
"settingsFeatures": "功能",
"settingsNotifyWithHaptic": "新通知时振动",
"settingsNotifyWithHapticDescription": "在应用在前台时收到新通知出现时出发轻量的振动。",
"settingsExpandPostLink": "展开帖子链接",
"settingsExpandPostLinkDescription": "在帖子列表中展开显示帖子中的链接。",
"settingsExpandChatLink": "展开聊天链接",
"settingsExpandChatLinkDescription": "在聊天信息中展开显示内容中的链接。",
"settingsNetwork": "网络", "settingsNetwork": "网络",
"settingsNetworkServer": "HyperNet 服务器", "settingsNetworkServer": "HyperNet 服务器",
"settingsNetworkServerDescription": "设置 HyperNet 服务器地址,选择我们提供的,或者自己搭建。", "settingsNetworkServerDescription": "设置 HyperNet 服务器地址,选择我们提供的,或者自己搭建。",
@ -209,12 +243,15 @@
"settingsMisc": "杂项", "settingsMisc": "杂项",
"settingsMiscAbout": "关于", "settingsMiscAbout": "关于",
"settingsMiscAboutDescription": "查看 Solian 的版本信息。", "settingsMiscAboutDescription": "查看 Solian 的版本信息。",
"settingsAccountLanguage": "帐号偏好语言",
"settingsAccountLanguageDescription": "设置邮件、通知和其他帐号相关内容的语言。",
"sensitiveContent": "敏感内容", "sensitiveContent": "敏感内容",
"sensitiveContentCollapsed": "敏感内容已折叠。", "sensitiveContentCollapsed": "敏感内容已折叠。",
"sensitiveContentDescription": "此内容已被标记,可能不适合所有人查看。", "sensitiveContentDescription": "此内容已被标记,可能不适合所有人查看。",
"sensitiveContentReveal": "显示内容", "sensitiveContentReveal": "显示内容",
"serverConnecting": "正在连接服务器…", "serverConnecting": "正在连接…",
"serverDisconnected": "已与服务器断开连接", "serverDisconnected": "已断开连接",
"serverConnected": "已连接",
"fieldChatAlias": "频道别名", "fieldChatAlias": "频道别名",
"fieldChatAliasHint": "全站范围内唯一的频道别名,用于在 URL 中表示该频道,留空则自动生成。应遵循 URL-Safe 的原则。", "fieldChatAliasHint": "全站范围内唯一的频道别名,用于在 URL 中表示该频道,留空则自动生成。应遵循 URL-Safe 的原则。",
"fieldChatName": "名称", "fieldChatName": "名称",
@ -292,7 +329,9 @@
"addAttachmentFromCameraPhoto": "拍摄照片", "addAttachmentFromCameraPhoto": "拍摄照片",
"addAttachmentFromCameraVideo": "拍摄视频", "addAttachmentFromCameraVideo": "拍摄视频",
"addAttachmentFromRandomId": "通过访问 ID 链接", "addAttachmentFromRandomId": "通过访问 ID 链接",
"attachmentDetailInfo": "附件详细信息",
"attachmentPastedImage": "粘贴的图片", "attachmentPastedImage": "粘贴的图片",
"attachmentInsertedImage": "插入的图片",
"attachmentInsertLink": "插入连接", "attachmentInsertLink": "插入连接",
"attachmentSetAsPostThumbnail": "设置为帖子缩略图", "attachmentSetAsPostThumbnail": "设置为帖子缩略图",
"attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图", "attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图",
@ -520,11 +559,15 @@
"postImageShareAds": "来 Solar Network 探索更多有趣帖子", "postImageShareAds": "来 Solar Network 探索更多有趣帖子",
"postShare": "分享", "postShare": "分享",
"postShareImage": "分享帖图", "postShareImage": "分享帖图",
"postGetInsight": "获取见解",
"postGetInsightTitle": "AI 见解",
"postGetInsightDescription": "AI 可能会出错,检查信息真实性。",
"appInitializing": "正在初始化", "appInitializing": "正在初始化",
"poweredBy": "由 {} 提供支持", "poweredBy": "由 {} 提供支持",
"shareIntent": "分享", "shareIntent": "分享",
"shareIntentDescription": "您想对您分享的内容做些什么?", "shareIntentDescription": "您想对您分享的内容做些什么?",
"shareIntentPostStory": "发布动态", "shareIntentPostStory": "发布动态",
"shareIntentSendChannel": "分享到聊天频道",
"updateAvailable": "检测到更新可用", "updateAvailable": "检测到更新可用",
"updateOngoing": "正在更新,请稍后……", "updateOngoing": "正在更新,请稍后……",
"custom": "自定义", "custom": "自定义",
@ -537,6 +580,7 @@
"colorSchemeWhite": "白色", "colorSchemeWhite": "白色",
"colorSchemeBlack": "黑色", "colorSchemeBlack": "黑色",
"colorSchemeApplied": "主题色已应用,可能需要重启来生效。", "colorSchemeApplied": "主题色已应用,可能需要重启来生效。",
"postFeaturedComment": "精选评论",
"postCategoryTechnology": "技术", "postCategoryTechnology": "技术",
"postCategoryGaming": "游戏", "postCategoryGaming": "游戏",
"postCategoryLife": "生活", "postCategoryLife": "生活",
@ -547,5 +591,78 @@
"postCategoryKnowledge": "知识", "postCategoryKnowledge": "知识",
"postCategoryLiterature": "文学", "postCategoryLiterature": "文学",
"postCategoryFunny": "搞笑", "postCategoryFunny": "搞笑",
"postCategoryUncategorized": "未分类" "postCategoryUncategorized": "未分类",
"newsAllSources": "所有新闻",
"newsReadingProviderSwap": "切换",
"newsReadingFromReader": "你正在从 HyperNet.Reader 阅读文章",
"newsReadingFromOriginal": "你正在阅读原始文章",
"newsDisclaimer": "本文由 HyperNet.Reader 从互联网上获取,我们不担保其内容的真实性,请自行判断。本文章的所有内容版权归原作者所有。",
"newsToday": "快讯",
"totpPostSetup": "还有一件事",
"totpPostSetupDescription": "使用 Google Authenticator, Microsoft Authenticator, 1Password, Authy, Bitwarden 或其他支持 TOTP 的验证器扫描本 QR Code 来添加。",
"totpNeverShare": "永远不要分享这个 QR Code",
"needHelp": "需要帮助?",
"needHelpLaunch": "查看我们的山羊维基!",
"walletCreate": "创建钱包",
"walletCreateSubtitle": "创建于一个钱包来开始使用源点。",
"walletCreatePassword": "在下方设置你的付款密码",
"walletCurrencyShort": "源点",
"walletCurrency": {
"one": "{} 源点",
"other": "{} 源点"
},
"aiThinkingProcess": "AI 思考过程",
"accountSettingsApplied": "帐号设置已应用。",
"trayMenuExit": "退出",
"postQuestionUnanswered": "未解答的问题",
"postQuestionUnansweredWithReward": "未解答的问题,悬赏源点 {}",
"postQuestionAnswered": "已解答的问题",
"postQuestionAnswerTitle": "精选解答",
"postQuestionAnswerSelect": "选择解答",
"postQuestionAnswerSelected": "解答已选择,奖励已发放。",
"postVideoUpload": "上传视频",
"realmJoin": "加入领域",
"realmCommunityHint": "该领域是一个社区领域,你可以自由加入。",
"realmCommunityPublicChannelsHint": "该领域包含的公共频道",
"realmJoined": "已加入领域 {}。",
"join": "加入",
"pollEditorNew": "新投票",
"pollEditorEdit": "编辑投票",
"pollEditorDelete": "删除投票",
"pollEditorDeleteDescription": "你确定要删除这个投票吗?该操作不可撤销。",
"pollEditorUnlink": "解除链接",
"pollOptionAdd": "添加选项",
"pollOptionName": "选项名称",
"pollLinkExisting": "链接现有投票",
"pollAnswered": "答案已经反馈。",
"pollVotes": {
"one": "{} 票",
"other": "{} 票"
},
"publisherDelete": "删除发布者 {}",
"publisherDeleteDescription": "你确定要删除这个发布者吗?该操作不可撤销。",
"channelIsPublic": "公开频道",
"channelIsPublicDescription": "该频道是公开的,任何人都可以加入。",
"channelIsCommunity": "社区频道",
"channelIsCommunityDescription": "目前来说,社区频道还没有什么特别之处。",
"realmIsPublic": "公开领域",
"realmIsPublicDescription": "该领域是公开的,任何人都可以加入。",
"realmIsCommunity": "社区领域",
"realmIsCommunityDescription": "社区领域会显示在发现页面上。",
"realmLeave": "离开领域",
"realmLeaveDescription": "离开当前领域,并且删除领域中的身份。",
"checkInResultTier1": "大凶",
"checkInResultTier2": "凶",
"checkInResultTier3": "中平",
"checkInResultTier4": "吉",
"checkInResultTier5": "大吉",
"flagPostAction": "吹哨",
"flagPost": "吹哨不良内容",
"flagPostDescription": "吹哨不良内容,如果吹哨用户占浏览量的 50% 或以上,则帖子会被折叠。吹哨后不可撤销。",
"flaggedPost": "哨子已经吹响。",
"postViews": {
"zero": "{} 次浏览",
"one": "{} 次浏览",
"other": "{} 次浏览"
}
} }

View File

@ -15,12 +15,17 @@
"screenAccountProfileEdit": "編輯資料", "screenAccountProfileEdit": "編輯資料",
"screenAbuseReport": "濫用檢舉", "screenAbuseReport": "濫用檢舉",
"screenSettings": "設置", "screenSettings": "設置",
"screenAccountSettings": "賬號設置",
"screenFactorSettings": "驗證因子",
"screenAccountWallet": "錢包",
"screenNews": "新聞",
"screenAlbum": "相冊", "screenAlbum": "相冊",
"screenChat": "聊天", "screenChat": "聊天",
"screenChatManage": "編輯聊天頻道", "screenChatManage": "編輯聊天頻道",
"screenChatNew": "新建聊天頻道", "screenChatNew": "新建聊天頻道",
"screenRealm": "領域", "screenRealm": "領域",
"screenRealmManage": "編輯領域", "screenRealmManage": "編輯領域",
"screenRealmDiscovery": "發現領域",
"screenRealmNew": "新建領域", "screenRealmNew": "新建領域",
"screenNotification": "通知", "screenNotification": "通知",
"screenPostSearch": "搜索帖子", "screenPostSearch": "搜索帖子",
@ -87,8 +92,18 @@
}, },
"loginEnterPassword": "驗證代碼", "loginEnterPassword": "驗證代碼",
"loginSuccess": "登錄為 {}", "loginSuccess": "登錄為 {}",
"authFactorDelete": "刪除驗證因子",
"authFactorDeleteDescription": "你確定要刪除 {} 驗證因子嗎?",
"authFactorPassword": "密碼", "authFactorPassword": "密碼",
"authFactorPasswordDescription": "註冊時選擇設置的密碼。",
"authFactorEmail": "電郵一次性驗證碼", "authFactorEmail": "電郵一次性驗證碼",
"authFactorEmailDescription": "由我們生成併發送到綁定的的電子郵箱的一次性驗證碼。",
"authFactorTOTP": "時序驗證碼",
"authFactorTOTPDescription": "使用 Google Authenticator 或 Authy 等驗證器生成的一次性驗證碼。",
"authFactorInAppNotify": "應用內通知驗證碼",
"authFactorInAppNotifyDescription": "通過站內通知推送的一次性驗證碼。",
"authFactorAdd": "添加新驗證因子",
"authFactorAddSubtitle": "給你的帳户登陸時提供另一個方案。",
"accountIntroTitle": "喜歡您來!", "accountIntroTitle": "喜歡您來!",
"accountIntroSubtitle": "登陸以探索更廣大的世界。", "accountIntroSubtitle": "登陸以探索更廣大的世界。",
"accountLogout": "退出登錄", "accountLogout": "退出登錄",
@ -97,8 +112,14 @@
"accountLogoutConfirm": "您需要重新輸入賬號密碼,甚至可能需要多步驗證來再次登陸。", "accountLogoutConfirm": "您需要重新輸入賬號密碼,甚至可能需要多步驗證來再次登陸。",
"accountPublishers": "你的發佈者", "accountPublishers": "你的發佈者",
"accountPublishersSubtitle": "管理你的公共形象。", "accountPublishersSubtitle": "管理你的公共形象。",
"accountSettings": "帳號設置",
"accountSettingsSubtitle": "管理你的帳號並讓它更好的服務你。",
"accountProfileEdit": "編輯資料", "accountProfileEdit": "編輯資料",
"accountProfileEditSubtitle": "使你的 Solarpass 賬户更像你。", "accountProfileEditSubtitle": "使你的 Solarpass 賬户更像你。",
"accountWallet": "錢包",
"accountWalletSubtitle": "查看你的餘額和交易記錄。",
"factorSettings": "驗證因子",
"factorSettingsSubtitle": "管理你的登陸驗證方式。",
"accountProfileEditApplied": "個人資料修改已被應用。", "accountProfileEditApplied": "個人資料修改已被應用。",
"publishersNew": "新發布者", "publishersNew": "新發布者",
"publisherNewSubtitle": "創建一個新的公共身份。", "publisherNewSubtitle": "創建一個新的公共身份。",
@ -118,9 +139,12 @@
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域", "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
"writePostTypeStory": "發動態", "writePostTypeStory": "發動態",
"writePostTypeArticle": "寫文章", "writePostTypeArticle": "寫文章",
"writePostTypeQuestion": "提問題",
"writePostTypeVideo": "發視頻",
"fieldPostPublisher": "帖子發佈者", "fieldPostPublisher": "帖子發佈者",
"fieldPostContent": "發生什麼事了?!", "fieldPostContent": "發生什麼事了?!",
"fieldPostTitle": "標題", "fieldPostTitle": "標題",
"fieldPostQuestionReward": "回答獎勵源點",
"fieldPostDescription": "描述", "fieldPostDescription": "描述",
"fieldPostTags": "標籤", "fieldPostTags": "標籤",
"fieldPostCategories": "分類", "fieldPostCategories": "分類",
@ -177,6 +201,9 @@
"other": "{} 條評論" "other": "{} 條評論"
}, },
"settingsAppearance": "外觀", "settingsAppearance": "外觀",
"settingsDisplayLanguage": "顯示語言",
"settingsDisplayLanguageDescription": "設置應用程序使用的語言",
"settingsDisplayLanguageSystem": "跟隨系統",
"settingsBackgroundImage": "背景圖片", "settingsBackgroundImage": "背景圖片",
"settingsBackgroundImageDescription": "設置應用全局生效的的背景圖片。", "settingsBackgroundImageDescription": "設置應用全局生效的的背景圖片。",
"settingsBackgroundImageClear": "清除現存背景圖", "settingsBackgroundImageClear": "清除現存背景圖",
@ -191,6 +218,13 @@
"settingsColorSchemeDescription": "設置應用主題色。", "settingsColorSchemeDescription": "設置應用主題色。",
"settingsColorSeed": "預設色彩主題", "settingsColorSeed": "預設色彩主題",
"settingsColorSeedDescription": "選擇一個預設色彩主題。", "settingsColorSeedDescription": "選擇一個預設色彩主題。",
"settingsFeatures": "功能",
"settingsNotifyWithHaptic": "新通知時振動",
"settingsNotifyWithHapticDescription": "在應用在前台時收到新通知出現時出發輕量的振動。",
"settingsExpandPostLink": "展開帖子鏈接",
"settingsExpandPostLinkDescription": "在帖子列表中展開顯示帖子中的鏈接。",
"settingsExpandChatLink": "展開聊天鏈接",
"settingsExpandChatLinkDescription": "在聊天信息中展開顯示內容中的鏈接。",
"settingsNetwork": "網絡", "settingsNetwork": "網絡",
"settingsNetworkServer": "HyperNet 服務器", "settingsNetworkServer": "HyperNet 服務器",
"settingsNetworkServerDescription": "設置 HyperNet 服務器地址,選擇我們提供的,或者自己搭建。", "settingsNetworkServerDescription": "設置 HyperNet 服務器地址,選擇我們提供的,或者自己搭建。",
@ -209,12 +243,15 @@
"settingsMisc": "雜項", "settingsMisc": "雜項",
"settingsMiscAbout": "關於", "settingsMiscAbout": "關於",
"settingsMiscAboutDescription": "查看 Solian 的版本信息。", "settingsMiscAboutDescription": "查看 Solian 的版本信息。",
"settingsAccountLanguage": "帳號偏好語言",
"settingsAccountLanguageDescription": "設置郵件、通知和其他帳號相關內容的語言。",
"sensitiveContent": "敏感內容", "sensitiveContent": "敏感內容",
"sensitiveContentCollapsed": "敏感內容已摺疊。", "sensitiveContentCollapsed": "敏感內容已摺疊。",
"sensitiveContentDescription": "此內容已被標記,可能不適合所有人查看。", "sensitiveContentDescription": "此內容已被標記,可能不適合所有人查看。",
"sensitiveContentReveal": "顯示內容", "sensitiveContentReveal": "顯示內容",
"serverConnecting": "正在連接服務器…", "serverConnecting": "正在連接…",
"serverDisconnected": "已與服務器斷開連接", "serverDisconnected": "已斷開連接",
"serverConnected": "已連接",
"fieldChatAlias": "頻道別名", "fieldChatAlias": "頻道別名",
"fieldChatAliasHint": "全站範圍內唯一的頻道別名,用於在 URL 中表示該頻道,留空則自動生成。應遵循 URL-Safe 的原則。", "fieldChatAliasHint": "全站範圍內唯一的頻道別名,用於在 URL 中表示該頻道,留空則自動生成。應遵循 URL-Safe 的原則。",
"fieldChatName": "名稱", "fieldChatName": "名稱",
@ -292,7 +329,9 @@
"addAttachmentFromCameraPhoto": "拍攝照片", "addAttachmentFromCameraPhoto": "拍攝照片",
"addAttachmentFromCameraVideo": "拍攝視頻", "addAttachmentFromCameraVideo": "拍攝視頻",
"addAttachmentFromRandomId": "通過訪問 ID 鏈接", "addAttachmentFromRandomId": "通過訪問 ID 鏈接",
"attachmentDetailInfo": "附件詳細信息",
"attachmentPastedImage": "粘貼的圖片", "attachmentPastedImage": "粘貼的圖片",
"attachmentInsertedImage": "插入的圖片",
"attachmentInsertLink": "插入連接", "attachmentInsertLink": "插入連接",
"attachmentSetAsPostThumbnail": "設置為帖子縮略圖", "attachmentSetAsPostThumbnail": "設置為帖子縮略圖",
"attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖", "attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖",
@ -520,11 +559,15 @@
"postImageShareAds": "來 Solar Network 探索更多有趣帖子", "postImageShareAds": "來 Solar Network 探索更多有趣帖子",
"postShare": "分享", "postShare": "分享",
"postShareImage": "分享帖圖", "postShareImage": "分享帖圖",
"postGetInsight": "獲取見解",
"postGetInsightTitle": "AI 見解",
"postGetInsightDescription": "AI 可能會出錯,檢查信息真實性。",
"appInitializing": "正在初始化", "appInitializing": "正在初始化",
"poweredBy": "由 {} 提供支持", "poweredBy": "由 {} 提供支持",
"shareIntent": "分享", "shareIntent": "分享",
"shareIntentDescription": "您想對您分享的內容做些什麼?", "shareIntentDescription": "您想對您分享的內容做些什麼?",
"shareIntentPostStory": "發佈動態", "shareIntentPostStory": "發佈動態",
"shareIntentSendChannel": "分享到聊天頻道",
"updateAvailable": "檢測到更新可用", "updateAvailable": "檢測到更新可用",
"updateOngoing": "正在更新,請稍後……", "updateOngoing": "正在更新,請稍後……",
"custom": "自定義", "custom": "自定義",
@ -537,6 +580,7 @@
"colorSchemeWhite": "白色", "colorSchemeWhite": "白色",
"colorSchemeBlack": "黑色", "colorSchemeBlack": "黑色",
"colorSchemeApplied": "主題色已應用,可能需要重啓來生效。", "colorSchemeApplied": "主題色已應用,可能需要重啓來生效。",
"postFeaturedComment": "精選評論",
"postCategoryTechnology": "技術", "postCategoryTechnology": "技術",
"postCategoryGaming": "遊戲", "postCategoryGaming": "遊戲",
"postCategoryLife": "生活", "postCategoryLife": "生活",
@ -547,5 +591,78 @@
"postCategoryKnowledge": "知識", "postCategoryKnowledge": "知識",
"postCategoryLiterature": "文學", "postCategoryLiterature": "文學",
"postCategoryFunny": "搞笑", "postCategoryFunny": "搞笑",
"postCategoryUncategorized": "未分類" "postCategoryUncategorized": "未分類",
"newsAllSources": "所有新聞",
"newsReadingProviderSwap": "切換",
"newsReadingFromReader": "你正在從 HyperNet.Reader 閲讀文章",
"newsReadingFromOriginal": "你正在閲讀原始文章",
"newsDisclaimer": "本文由 HyperNet.Reader 從互聯網上獲取,我們不擔保其內容的真實性,請自行判斷。本文章的所有內容版權歸原作者所有。",
"newsToday": "快訊",
"totpPostSetup": "還有一件事",
"totpPostSetupDescription": "使用 Google Authenticator, Microsoft Authenticator, 1Password, Authy, Bitwarden 或其他支持 TOTP 的驗證器掃描本 QR Code 來添加。",
"totpNeverShare": "永遠不要分享這個 QR Code",
"needHelp": "需要幫助?",
"needHelpLaunch": "查看我們的山羊維基!",
"walletCreate": "創建錢包",
"walletCreateSubtitle": "創建於一個錢包來開始使用源點。",
"walletCreatePassword": "在下方設置你的付款密碼",
"walletCurrencyShort": "源點",
"walletCurrency": {
"one": "{} 源點",
"other": "{} 源點"
},
"aiThinkingProcess": "AI 思考過程",
"accountSettingsApplied": "帳號設置已應用。",
"trayMenuExit": "退出",
"postQuestionUnanswered": "未解答的問題",
"postQuestionUnansweredWithReward": "未解答的問題,懸賞源點 {}",
"postQuestionAnswered": "已解答的問題",
"postQuestionAnswerTitle": "精選解答",
"postQuestionAnswerSelect": "選擇解答",
"postQuestionAnswerSelected": "解答已選擇,獎勵已發放。",
"postVideoUpload": "上傳視頻",
"realmJoin": "加入領域",
"realmCommunityHint": "該領域是一個社區領域,你可以自由加入。",
"realmCommunityPublicChannelsHint": "該領域包含的公共頻道",
"realmJoined": "已加入領域 {}。",
"join": "加入",
"pollEditorNew": "新投票",
"pollEditorEdit": "編輯投票",
"pollEditorDelete": "刪除投票",
"pollEditorDeleteDescription": "你確定要刪除這個投票嗎?該操作不可撤銷。",
"pollEditorUnlink": "解除鏈接",
"pollOptionAdd": "添加選項",
"pollOptionName": "選項名稱",
"pollLinkExisting": "鏈接現有投票",
"pollAnswered": "答案已經反饋。",
"pollVotes": {
"one": "{} 票",
"other": "{} 票"
},
"publisherDelete": "刪除發佈者 {}",
"publisherDeleteDescription": "你確定要刪除這個發佈者嗎?該操作不可撤銷。",
"channelIsPublic": "公開頻道",
"channelIsPublicDescription": "該頻道是公開的,任何人都可以加入。",
"channelIsCommunity": "社區頻道",
"channelIsCommunityDescription": "目前來説,社區頻道還沒有什麼特別之處。",
"realmIsPublic": "公開領域",
"realmIsPublicDescription": "該領域是公開的,任何人都可以加入。",
"realmIsCommunity": "社區領域",
"realmIsCommunityDescription": "社區領域會顯示在發現頁面上。",
"realmLeave": "離開領域",
"realmLeaveDescription": "離開當前領域,並且刪除領域中的身份。",
"checkInResultTier1": "大凶",
"checkInResultTier2": "兇",
"checkInResultTier3": "中平",
"checkInResultTier4": "吉",
"checkInResultTier5": "大吉",
"flagPostAction": "吹哨",
"flagPost": "吹哨不良內容",
"flagPostDescription": "吹哨不良內容,如果吹哨用户佔瀏覽量的 50% 或以上,則帖子會被摺疊。吹哨後不可撤銷。",
"flaggedPost": "哨子已經吹響。",
"postViews": {
"zero": "{} 次瀏覽",
"one": "{} 次瀏覽",
"other": "{} 次瀏覽"
}
} }

View File

@ -15,12 +15,17 @@
"screenAccountProfileEdit": "編輯資料", "screenAccountProfileEdit": "編輯資料",
"screenAbuseReport": "濫用檢舉", "screenAbuseReport": "濫用檢舉",
"screenSettings": "設置", "screenSettings": "設置",
"screenAccountSettings": "賬號設置",
"screenFactorSettings": "驗證因子",
"screenAccountWallet": "錢包",
"screenNews": "新聞",
"screenAlbum": "相冊", "screenAlbum": "相冊",
"screenChat": "聊天", "screenChat": "聊天",
"screenChatManage": "編輯聊天頻道", "screenChatManage": "編輯聊天頻道",
"screenChatNew": "新建聊天頻道", "screenChatNew": "新建聊天頻道",
"screenRealm": "領域", "screenRealm": "領域",
"screenRealmManage": "編輯領域", "screenRealmManage": "編輯領域",
"screenRealmDiscovery": "發現領域",
"screenRealmNew": "新建領域", "screenRealmNew": "新建領域",
"screenNotification": "通知", "screenNotification": "通知",
"screenPostSearch": "搜索帖子", "screenPostSearch": "搜索帖子",
@ -87,8 +92,18 @@
}, },
"loginEnterPassword": "驗證代碼", "loginEnterPassword": "驗證代碼",
"loginSuccess": "登錄為 {}", "loginSuccess": "登錄為 {}",
"authFactorDelete": "刪除驗證因子",
"authFactorDeleteDescription": "你確定要刪除 {} 驗證因子嗎?",
"authFactorPassword": "密碼", "authFactorPassword": "密碼",
"authFactorPasswordDescription": "註冊時選擇設置的密碼。",
"authFactorEmail": "電郵一次性驗證碼", "authFactorEmail": "電郵一次性驗證碼",
"authFactorEmailDescription": "由我們生成併發送到綁定的的電子郵箱的一次性驗證碼。",
"authFactorTOTP": "時序驗證碼",
"authFactorTOTPDescription": "使用 Google Authenticator 或 Authy 等驗證器生成的一次性驗證碼。",
"authFactorInAppNotify": "應用內通知驗證碼",
"authFactorInAppNotifyDescription": "通過站內通知推送的一次性驗證碼。",
"authFactorAdd": "添加新驗證因子",
"authFactorAddSubtitle": "給你的帳戶登陸時提供另一個方案。",
"accountIntroTitle": "喜歡您來!", "accountIntroTitle": "喜歡您來!",
"accountIntroSubtitle": "登陸以探索更廣大的世界。", "accountIntroSubtitle": "登陸以探索更廣大的世界。",
"accountLogout": "退出登錄", "accountLogout": "退出登錄",
@ -97,8 +112,14 @@
"accountLogoutConfirm": "您需要重新輸入賬號密碼,甚至可能需要多步驗證來再次登陸。", "accountLogoutConfirm": "您需要重新輸入賬號密碼,甚至可能需要多步驗證來再次登陸。",
"accountPublishers": "你的發佈者", "accountPublishers": "你的發佈者",
"accountPublishersSubtitle": "管理你的公共形象。", "accountPublishersSubtitle": "管理你的公共形象。",
"accountSettings": "帳號設置",
"accountSettingsSubtitle": "管理你的帳號並讓它更好的服務你。",
"accountProfileEdit": "編輯資料", "accountProfileEdit": "編輯資料",
"accountProfileEditSubtitle": "使你的 Solarpass 賬戶更像你。", "accountProfileEditSubtitle": "使你的 Solarpass 賬戶更像你。",
"accountWallet": "錢包",
"accountWalletSubtitle": "查看你的餘額和交易記錄。",
"factorSettings": "驗證因子",
"factorSettingsSubtitle": "管理你的登陸驗證方式。",
"accountProfileEditApplied": "個人資料修改已被應用。", "accountProfileEditApplied": "個人資料修改已被應用。",
"publishersNew": "新發布者", "publishersNew": "新發布者",
"publisherNewSubtitle": "創建一個新的公共身份。", "publisherNewSubtitle": "創建一個新的公共身份。",
@ -118,9 +139,12 @@
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域", "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
"writePostTypeStory": "發動態", "writePostTypeStory": "發動態",
"writePostTypeArticle": "寫文章", "writePostTypeArticle": "寫文章",
"writePostTypeQuestion": "提問題",
"writePostTypeVideo": "發視頻",
"fieldPostPublisher": "帖子發佈者", "fieldPostPublisher": "帖子發佈者",
"fieldPostContent": "發生什麼事了?!", "fieldPostContent": "發生什麼事了?!",
"fieldPostTitle": "標題", "fieldPostTitle": "標題",
"fieldPostQuestionReward": "回答獎勵源點",
"fieldPostDescription": "描述", "fieldPostDescription": "描述",
"fieldPostTags": "標籤", "fieldPostTags": "標籤",
"fieldPostCategories": "分類", "fieldPostCategories": "分類",
@ -177,6 +201,9 @@
"other": "{} 條評論" "other": "{} 條評論"
}, },
"settingsAppearance": "外觀", "settingsAppearance": "外觀",
"settingsDisplayLanguage": "顯示語言",
"settingsDisplayLanguageDescription": "設置應用程序使用的語言",
"settingsDisplayLanguageSystem": "跟隨系統",
"settingsBackgroundImage": "背景圖片", "settingsBackgroundImage": "背景圖片",
"settingsBackgroundImageDescription": "設置應用全局生效的的背景圖片。", "settingsBackgroundImageDescription": "設置應用全局生效的的背景圖片。",
"settingsBackgroundImageClear": "清除現存背景圖", "settingsBackgroundImageClear": "清除現存背景圖",
@ -191,6 +218,13 @@
"settingsColorSchemeDescription": "設置應用主題色。", "settingsColorSchemeDescription": "設置應用主題色。",
"settingsColorSeed": "預設色彩主題", "settingsColorSeed": "預設色彩主題",
"settingsColorSeedDescription": "選擇一個預設色彩主題。", "settingsColorSeedDescription": "選擇一個預設色彩主題。",
"settingsFeatures": "功能",
"settingsNotifyWithHaptic": "新通知時振動",
"settingsNotifyWithHapticDescription": "在應用在前臺時收到新通知出現時出發輕量的振動。",
"settingsExpandPostLink": "展開帖子鏈接",
"settingsExpandPostLinkDescription": "在帖子列表中展開顯示帖子中的鏈接。",
"settingsExpandChatLink": "展開聊天鏈接",
"settingsExpandChatLinkDescription": "在聊天信息中展開顯示內容中的鏈接。",
"settingsNetwork": "網絡", "settingsNetwork": "網絡",
"settingsNetworkServer": "HyperNet 服務器", "settingsNetworkServer": "HyperNet 服務器",
"settingsNetworkServerDescription": "設置 HyperNet 服務器地址,選擇我們提供的,或者自己搭建。", "settingsNetworkServerDescription": "設置 HyperNet 服務器地址,選擇我們提供的,或者自己搭建。",
@ -209,12 +243,15 @@
"settingsMisc": "雜項", "settingsMisc": "雜項",
"settingsMiscAbout": "關於", "settingsMiscAbout": "關於",
"settingsMiscAboutDescription": "查看 Solian 的版本信息。", "settingsMiscAboutDescription": "查看 Solian 的版本信息。",
"settingsAccountLanguage": "帳號偏好語言",
"settingsAccountLanguageDescription": "設置郵件、通知和其他帳號相關內容的語言。",
"sensitiveContent": "敏感內容", "sensitiveContent": "敏感內容",
"sensitiveContentCollapsed": "敏感內容已摺疊。", "sensitiveContentCollapsed": "敏感內容已摺疊。",
"sensitiveContentDescription": "此內容已被標記,可能不適合所有人查看。", "sensitiveContentDescription": "此內容已被標記,可能不適合所有人查看。",
"sensitiveContentReveal": "顯示內容", "sensitiveContentReveal": "顯示內容",
"serverConnecting": "正在連接服務器…", "serverConnecting": "正在連接…",
"serverDisconnected": "已與服務器斷開連接", "serverDisconnected": "已斷開連接",
"serverConnected": "已連接",
"fieldChatAlias": "頻道別名", "fieldChatAlias": "頻道別名",
"fieldChatAliasHint": "全站範圍內唯一的頻道別名,用於在 URL 中表示該頻道,留空則自動生成。應遵循 URL-Safe 的原則。", "fieldChatAliasHint": "全站範圍內唯一的頻道別名,用於在 URL 中表示該頻道,留空則自動生成。應遵循 URL-Safe 的原則。",
"fieldChatName": "名稱", "fieldChatName": "名稱",
@ -292,7 +329,9 @@
"addAttachmentFromCameraPhoto": "拍攝照片", "addAttachmentFromCameraPhoto": "拍攝照片",
"addAttachmentFromCameraVideo": "拍攝視頻", "addAttachmentFromCameraVideo": "拍攝視頻",
"addAttachmentFromRandomId": "通過訪問 ID 鏈接", "addAttachmentFromRandomId": "通過訪問 ID 鏈接",
"attachmentDetailInfo": "附件詳細信息",
"attachmentPastedImage": "粘貼的圖片", "attachmentPastedImage": "粘貼的圖片",
"attachmentInsertedImage": "插入的圖片",
"attachmentInsertLink": "插入連接", "attachmentInsertLink": "插入連接",
"attachmentSetAsPostThumbnail": "設置為帖子縮略圖", "attachmentSetAsPostThumbnail": "設置為帖子縮略圖",
"attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖", "attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖",
@ -520,11 +559,15 @@
"postImageShareAds": "來 Solar Network 探索更多有趣帖子", "postImageShareAds": "來 Solar Network 探索更多有趣帖子",
"postShare": "分享", "postShare": "分享",
"postShareImage": "分享帖圖", "postShareImage": "分享帖圖",
"postGetInsight": "獲取見解",
"postGetInsightTitle": "AI 見解",
"postGetInsightDescription": "AI 可能會出錯,檢查信息真實性。",
"appInitializing": "正在初始化", "appInitializing": "正在初始化",
"poweredBy": "由 {} 提供支持", "poweredBy": "由 {} 提供支持",
"shareIntent": "分享", "shareIntent": "分享",
"shareIntentDescription": "您想對您分享的內容做些什麼?", "shareIntentDescription": "您想對您分享的內容做些什麼?",
"shareIntentPostStory": "發佈動態", "shareIntentPostStory": "發佈動態",
"shareIntentSendChannel": "分享到聊天頻道",
"updateAvailable": "檢測到更新可用", "updateAvailable": "檢測到更新可用",
"updateOngoing": "正在更新,請稍後……", "updateOngoing": "正在更新,請稍後……",
"custom": "自定義", "custom": "自定義",
@ -537,6 +580,7 @@
"colorSchemeWhite": "白色", "colorSchemeWhite": "白色",
"colorSchemeBlack": "黑色", "colorSchemeBlack": "黑色",
"colorSchemeApplied": "主題色已應用,可能需要重啟來生效。", "colorSchemeApplied": "主題色已應用,可能需要重啟來生效。",
"postFeaturedComment": "精選評論",
"postCategoryTechnology": "技術", "postCategoryTechnology": "技術",
"postCategoryGaming": "遊戲", "postCategoryGaming": "遊戲",
"postCategoryLife": "生活", "postCategoryLife": "生活",
@ -547,5 +591,78 @@
"postCategoryKnowledge": "知識", "postCategoryKnowledge": "知識",
"postCategoryLiterature": "文學", "postCategoryLiterature": "文學",
"postCategoryFunny": "搞笑", "postCategoryFunny": "搞笑",
"postCategoryUncategorized": "未分類" "postCategoryUncategorized": "未分類",
"newsAllSources": "所有新聞",
"newsReadingProviderSwap": "切換",
"newsReadingFromReader": "你正在從 HyperNet.Reader 閱讀文章",
"newsReadingFromOriginal": "你正在閱讀原始文章",
"newsDisclaimer": "本文由 HyperNet.Reader 從互聯網上獲取,我們不擔保其內容的真實性,請自行判斷。本文章的所有內容版權歸原作者所有。",
"newsToday": "快訊",
"totpPostSetup": "還有一件事",
"totpPostSetupDescription": "使用 Google Authenticator, Microsoft Authenticator, 1Password, Authy, Bitwarden 或其他支持 TOTP 的驗證器掃描本 QR Code 來添加。",
"totpNeverShare": "永遠不要分享這個 QR Code",
"needHelp": "需要幫助?",
"needHelpLaunch": "查看我們的山羊維基!",
"walletCreate": "創建錢包",
"walletCreateSubtitle": "創建於一個錢包來開始使用源點。",
"walletCreatePassword": "在下方設置你的付款密碼",
"walletCurrencyShort": "源點",
"walletCurrency": {
"one": "{} 源點",
"other": "{} 源點"
},
"aiThinkingProcess": "AI 思考過程",
"accountSettingsApplied": "帳號設置已應用。",
"trayMenuExit": "退出",
"postQuestionUnanswered": "未解答的問題",
"postQuestionUnansweredWithReward": "未解答的問題,懸賞源點 {}",
"postQuestionAnswered": "已解答的問題",
"postQuestionAnswerTitle": "精選解答",
"postQuestionAnswerSelect": "選擇解答",
"postQuestionAnswerSelected": "解答已選擇,獎勵已發放。",
"postVideoUpload": "上傳視頻",
"realmJoin": "加入領域",
"realmCommunityHint": "該領域是一個社區領域,你可以自由加入。",
"realmCommunityPublicChannelsHint": "該領域包含的公共頻道",
"realmJoined": "已加入領域 {}。",
"join": "加入",
"pollEditorNew": "新投票",
"pollEditorEdit": "編輯投票",
"pollEditorDelete": "刪除投票",
"pollEditorDeleteDescription": "你確定要刪除這個投票嗎?該操作不可撤銷。",
"pollEditorUnlink": "解除鏈接",
"pollOptionAdd": "添加選項",
"pollOptionName": "選項名稱",
"pollLinkExisting": "鏈接現有投票",
"pollAnswered": "答案已經反饋。",
"pollVotes": {
"one": "{} 票",
"other": "{} 票"
},
"publisherDelete": "刪除發佈者 {}",
"publisherDeleteDescription": "你確定要刪除這個發佈者嗎?該操作不可撤銷。",
"channelIsPublic": "公開頻道",
"channelIsPublicDescription": "該頻道是公開的,任何人都可以加入。",
"channelIsCommunity": "社區頻道",
"channelIsCommunityDescription": "目前來說,社區頻道還沒有什麼特別之處。",
"realmIsPublic": "公開領域",
"realmIsPublicDescription": "該領域是公開的,任何人都可以加入。",
"realmIsCommunity": "社區領域",
"realmIsCommunityDescription": "社區領域會顯示在發現頁面上。",
"realmLeave": "離開領域",
"realmLeaveDescription": "離開當前領域,並且刪除領域中的身份。",
"checkInResultTier1": "大凶",
"checkInResultTier2": "兇",
"checkInResultTier3": "中平",
"checkInResultTier4": "吉",
"checkInResultTier5": "大吉",
"flagPostAction": "吹哨",
"flagPost": "吹哨不良內容",
"flagPostDescription": "吹哨不良內容,如果吹哨用戶佔瀏覽量的 50% 或以上,則帖子會被摺疊。吹哨後不可撤銷。",
"flaggedPost": "哨子已經吹響。",
"postViews": {
"zero": "{} 次瀏覽",
"one": "{} 次瀏覽",
"other": "{} 次瀏覽"
}
} }

14
debian/debian.yml vendored Normal file
View File

@ -0,0 +1,14 @@
flutter_app:
command: surface
arch: x64
parent: /usr/local/lib
nonInteractive: false
control:
Package: solian
Version: 2.3.2
Architecture: amd64
Priority: optional
Depends: mpv keybinder-3.0
Maintainer: Solsynth LLC
Description: The Solar Network Desktop Application

9
debian/gui/surface.desktop vendored Normal file
View File

@ -0,0 +1,9 @@
[Desktop Entry]
Version=2.3.2
Name=Solian
GenericName=Solian
Comment=The Solar Network Desktop Application
Terminal=false
Type=Application
Categories=Social Networking
Keywords=social;social network;chat;solar network

23
debian/gui/surface.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 232 KiB

View File

@ -2,7 +2,6 @@ PODS:
- Alamofire (5.10.2) - Alamofire (5.10.2)
- connectivity_plus (0.0.1): - connectivity_plus (0.0.1):
- Flutter - Flutter
- FlutterMacOS
- croppy (0.0.1): - croppy (0.0.1):
- Flutter - Flutter
- device_info_plus (0.0.1): - device_info_plus (0.0.1):
@ -43,58 +42,58 @@ PODS:
- Flutter - Flutter
- file_saver (0.0.1): - file_saver (0.0.1):
- Flutter - Flutter
- Firebase/Analytics (11.4.0): - Firebase/Analytics (11.7.0):
- Firebase/Core - Firebase/Core
- Firebase/Core (11.4.0): - Firebase/Core (11.7.0):
- Firebase/CoreOnly - Firebase/CoreOnly
- FirebaseAnalytics (~> 11.4.0) - FirebaseAnalytics (~> 11.7.0)
- Firebase/CoreOnly (11.4.0): - Firebase/CoreOnly (11.7.0):
- FirebaseCore (= 11.4.0) - FirebaseCore (~> 11.7.0)
- Firebase/Messaging (11.4.0): - Firebase/Messaging (11.7.0):
- Firebase/CoreOnly - Firebase/CoreOnly
- FirebaseMessaging (~> 11.4.0) - FirebaseMessaging (~> 11.7.0)
- firebase_analytics (11.3.6): - firebase_analytics (11.4.2):
- Firebase/Analytics (= 11.4.0) - Firebase/Analytics (= 11.7.0)
- firebase_core - firebase_core
- Flutter - Flutter
- firebase_core (3.9.0): - firebase_core (3.11.0):
- Firebase/CoreOnly (= 11.4.0) - Firebase/CoreOnly (= 11.7.0)
- Flutter - Flutter
- firebase_messaging (15.1.6): - firebase_messaging (15.2.2):
- Firebase/Messaging (= 11.4.0) - Firebase/Messaging (= 11.7.0)
- firebase_core - firebase_core
- Flutter - Flutter
- FirebaseAnalytics (11.4.0): - FirebaseAnalytics (11.7.0):
- FirebaseAnalytics/AdIdSupport (= 11.4.0) - FirebaseAnalytics/AdIdSupport (= 11.7.0)
- FirebaseCore (~> 11.0) - FirebaseCore (~> 11.7.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.4.0): - FirebaseAnalytics/AdIdSupport (11.7.0):
- FirebaseCore (~> 11.0) - FirebaseCore (~> 11.7.0)
- FirebaseInstallations (~> 11.0) - FirebaseInstallations (~> 11.0)
- GoogleAppMeasurement (= 11.4.0) - GoogleAppMeasurement (= 11.7.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.4.0): - FirebaseCore (11.7.0):
- FirebaseCoreInternal (~> 11.0) - FirebaseCoreInternal (~> 11.7.0)
- GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/Logger (~> 8.0) - GoogleUtilities/Logger (~> 8.0)
- FirebaseCoreInternal (11.6.0): - FirebaseCoreInternal (11.7.0):
- "GoogleUtilities/NSData+zlib (~> 8.0)" - "GoogleUtilities/NSData+zlib (~> 8.0)"
- FirebaseInstallations (11.4.0): - FirebaseInstallations (11.7.0):
- FirebaseCore (~> 11.0) - FirebaseCore (~> 11.7.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.4.0): - FirebaseMessaging (11.7.0):
- FirebaseCore (~> 11.0) - FirebaseCore (~> 11.7.0)
- FirebaseInstallations (~> 11.0) - FirebaseInstallations (~> 11.0)
- GoogleDataTransport (~> 10.0) - GoogleDataTransport (~> 10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
@ -105,32 +104,39 @@ PODS:
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_app_update (0.0.1): - flutter_app_update (0.0.1):
- Flutter - Flutter
- flutter_inappwebview_ios (0.0.1):
- Flutter
- flutter_inappwebview_ios/Core (= 0.0.1)
- OrderedSet (~> 6.0.3)
- flutter_inappwebview_ios/Core (0.0.1):
- Flutter
- OrderedSet (~> 6.0.3)
- flutter_native_splash (2.4.3): - flutter_native_splash (2.4.3):
- Flutter - Flutter
- flutter_udid (0.0.1): - flutter_udid (0.0.1):
- Flutter - Flutter
- SAMKeychain - SAMKeychain
- flutter_webrtc (0.12.2): - flutter_webrtc (0.12.6):
- Flutter - Flutter
- WebRTC-SDK (= 125.6422.06) - WebRTC-SDK (= 125.6422.06)
- gal (1.0.0): - gal (1.0.0):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- GoogleAppMeasurement (11.4.0): - GoogleAppMeasurement (11.7.0):
- GoogleAppMeasurement/AdIdSupport (= 11.4.0) - GoogleAppMeasurement/AdIdSupport (= 11.7.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.4.0): - GoogleAppMeasurement/AdIdSupport (11.7.0):
- GoogleAppMeasurement/WithoutAdIdSupport (= 11.4.0) - GoogleAppMeasurement/WithoutAdIdSupport (= 11.7.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.4.0): - GoogleAppMeasurement/WithoutAdIdSupport (11.7.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)
@ -172,8 +178,8 @@ PODS:
- Flutter - Flutter
- in_app_review (2.0.0): - in_app_review (2.0.0):
- Flutter - Flutter
- Kingfisher (8.1.3) - Kingfisher (8.2.0)
- livekit_client (2.3.4): - livekit_client (2.3.6):
- Flutter - Flutter
- flutter_webrtc - flutter_webrtc
- WebRTC-SDK (= 125.6422.06) - WebRTC-SDK (= 125.6422.06)
@ -188,6 +194,7 @@ PODS:
- nanopb/encode (= 3.30910.0) - nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0) - nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0) - nanopb/encode (3.30910.0)
- OrderedSet (6.0.3)
- package_info_plus (0.4.5): - package_info_plus (0.4.5):
- Flutter - Flutter
- pasteboard (0.0.1): - pasteboard (0.0.1):
@ -229,7 +236,7 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- Alamofire - Alamofire
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- croppy (from `.symlinks/plugins/croppy/ios`) - croppy (from `.symlinks/plugins/croppy/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`)
@ -239,6 +246,7 @@ DEPENDENCIES:
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- 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_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/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`)
@ -282,6 +290,7 @@ SPEC REPOS:
- GoogleUtilities - GoogleUtilities
- Kingfisher - Kingfisher
- nanopb - nanopb
- OrderedSet
- PromisesObjC - PromisesObjC
- SAMKeychain - SAMKeychain
- SDWebImage - SDWebImage
@ -290,7 +299,7 @@ SPEC REPOS:
EXTERNAL SOURCES: EXTERNAL SOURCES:
connectivity_plus: connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/darwin" :path: ".symlinks/plugins/connectivity_plus/ios"
croppy: croppy:
:path: ".symlinks/plugins/croppy/ios" :path: ".symlinks/plugins/croppy/ios"
device_info_plus: device_info_plus:
@ -309,6 +318,8 @@ EXTERNAL SOURCES:
:path: Flutter :path: Flutter
flutter_app_update: flutter_app_update:
:path: ".symlinks/plugins/flutter_app_update/ios" :path: ".symlinks/plugins/flutter_app_update/ios"
flutter_inappwebview_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_udid: flutter_udid:
@ -362,40 +373,42 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496 Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
connectivity_plus: 18382e7311ba19efcaee94442b23b32507b20695 connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321 croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808 file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
Firebase: cf1b19f21410b029b6786a54e9764a0cacad3c99 Firebase: a64bf6a8546e6eab54f1c715cd6151f39d2329f4
firebase_analytics: 2815af29d49c1a994652abd37a5b001a88bc7b75 firebase_analytics: 7236e6115c1b4e62c2270faa29c052a317e31107
firebase_core: b62a5080210edad3f2934314a8b2c6f5124e8e10 firebase_core: aa979ae726f00b3ef4ccf59dfb96170af84efbd4
firebase_messaging: 98619a0572d82cfb3668e78859ba9f1110e268c9 firebase_messaging: 3af84b6a90aeac4d7a67fbf4c43a91e7083bea1f
FirebaseAnalytics: 3feef9ae8733c567866342a1000691baaa7cad49 FirebaseAnalytics: bc9e565af9044ba8d6c6e4157e4edca8e5fdf7ec
FirebaseCore: e0510f1523bc0eb21653cac00792e1e2bd6f1771 FirebaseCore: 3227e35f4197a924206fbcdc0349325baf4f5de4
FirebaseCoreInternal: d98ab91e2d80a56d7b246856a8885443b302c0c2 FirebaseCoreInternal: d6c17dafc8dc33614733a8b52df78fcb4394c881
FirebaseInstallations: 6ef4a1c7eb2a61ee1f74727d7f6ce2e72acf1414 FirebaseInstallations: 9347e719c3d52d8d7b9074b2c32407dd027305e9
FirebaseMessaging: f8a160d99c2c2e5babbbcc90c4a3e15db036aee2 FirebaseMessaging: 00ece041b71ddb52a2862ffdee73fb6e9824bd0c
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc
flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4
flutter_native_splash: f71420956eb811e6d310720fee915f1d42852e7a flutter_native_splash: f71420956eb811e6d310720fee915f1d42852e7a
flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab
flutter_webrtc: 1a53bd24f97bcfeff512f13699e721897f261563 flutter_webrtc: 90260f83024b1b96d239a575ea4e3708e79344d1
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5 gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
GoogleAppMeasurement: 987769c4ca6b968f2479fbcc9fe3ce34af454b8e GoogleAppMeasurement: 0471a5b5bff51f3a91b1e76df22c952d04c63967
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57 home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
in_app_review: a31b5257259646ea78e0e35fc914979b0031d011 in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
Kingfisher: f2af9028b16baf9dc6c07c570072bc41cbf009ef Kingfisher: 323e5c4ec7983aaace12af655a7b51a7f88a599d
livekit_client: 4eaa7a2968fc7e7c57888f43d90394547cc8d9e9 livekit_client: 148b2cf67a09aaf475ba8e5bf1667fe10dc35f81
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1 media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0 pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46

View File

@ -123,48 +123,59 @@ class NotificationService: UNNotificationServiceExtension {
} }
if let imageIdentifier = metadata["image"] as? String { if let imageIdentifier = metadata["image"] as? String {
attachMedia(to: content, withIdentifier: imageIdentifier, fileType: UTType.jpeg, doScaleDown: true) attachMedia(to: content, withIdentifier: [imageIdentifier], fileType: UTType.jpeg, doScaleDown: true)
} else if let avatarIdentifier = metadata["avatar"] as? String { } else if let avatarIdentifier = metadata["avatar"] as? String {
attachMedia(to: content, withIdentifier: avatarIdentifier, fileType: UTType.jpeg, doScaleDown: true) attachMedia(to: content, withIdentifier: [avatarIdentifier], fileType: UTType.jpeg, doScaleDown: true)
} else if let imagesIdentifier = metadata["images"] as? Array<String> {
attachMedia(to: content, withIdentifier: imagesIdentifier, fileType: UTType.jpeg, doScaleDown: true)
} else { } else {
contentHandler?(content) contentHandler?(content)
} }
} }
private func attachMedia(to content: UNMutableNotificationContent, withIdentifier identifier: String, fileType type: UTType?, doScaleDown scaleDown: Bool = false) { private func attachMedia(to content: UNMutableNotificationContent, withIdentifier identifier: Array<String>, fileType type: UTType?, doScaleDown scaleDown: Bool = false) {
let attachmentUrl = getAttachmentUrl(for: identifier) let attachmentUrls = identifier.compactMap { element in
return getAttachmentUrl(for: element)
guard let remoteUrl = URL(string: attachmentUrl) else { }
print("Invalid URL for attachment: \(attachmentUrl)")
guard !attachmentUrls.isEmpty else {
print("Invalid URLs for attachments: \(attachmentUrls)")
return return
} }
let targetSize = 800 let targetSize = 800
let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit) let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit)
KingfisherManager.shared.retrieveImage(with: remoteUrl, options: scaleDown ? [ for attachmentUrl in attachmentUrls {
.processor(scaleProcessor) guard let remoteUrl = URL(string: attachmentUrl) else {
] : nil) { [weak self] result in print("Invalid URL for attachment: \(attachmentUrl)")
guard let self = self else { return } continue // Skip this URL and move to the next one
}
switch result {
case .success(let retrievalResult): KingfisherManager.shared.retrieveImage(with: remoteUrl, options: scaleDown ? [
// The image is either retrieved from cache or downloaded .processor(scaleProcessor)
let tempDirectory = FileManager.default.temporaryDirectory ] : nil) { [weak self] result in
let cachedFileUrl = tempDirectory.appendingPathComponent(identifier) guard let self = self else { return }
do { switch result {
// Write the image data to a temporary file for UNNotificationAttachment case .success(let retrievalResult):
try retrievalResult.image.pngData()?.write(to: cachedFileUrl) // The image is either retrieved from cache or downloaded
self.attachLocalMedia(to: content, fileType: type?.identifier, from: cachedFileUrl, withIdentifier: identifier) let tempDirectory = FileManager.default.temporaryDirectory
} catch { let cachedFileUrl = tempDirectory.appendingPathComponent(UUID().uuidString) // Unique identifier for each file
print("Failed to write media to temporary file: \(error.localizedDescription)")
do {
// Write the image data to a temporary file for UNNotificationAttachment
try retrievalResult.image.pngData()?.write(to: cachedFileUrl)
self.attachLocalMedia(to: content, fileType: type?.identifier, from: cachedFileUrl, withIdentifier: attachmentUrl)
} catch {
print("Failed to write media to temporary file: \(error.localizedDescription)")
self.contentHandler?(content)
}
case .failure(let error):
print("Failed to retrieve image: \(error.localizedDescription)")
self.contentHandler?(content) self.contentHandler?(content)
} }
case .failure(let error):
print("Failed to retrieve image: \(error.localizedDescription)")
self.contentHandler?(content)
} }
} }
} }

View File

@ -15,14 +15,14 @@ struct CheckInProvider: TimelineProvider {
func getSnapshot(in context: Context, completion: @escaping (CheckInEntry) -> ()) { func getSnapshot(in context: Context, completion: @escaping (CheckInEntry) -> ()) {
let prefs = UserDefaults(suiteName: "group.solsynth.solian") let prefs = UserDefaults(suiteName: "group.solsynth.solian")
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'" dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"
let jsonDecoder = JSONDecoder() let jsonDecoder = JSONDecoder()
jsonDecoder.dateDecodingStrategy = .formatted(dateFormatter) jsonDecoder.dateDecodingStrategy = .formatted(dateFormatter)
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
let checkInRaw = prefs?.string(forKey: "pas_check_in_record") let checkInRaw = prefs?.string(forKey: "pas_check_in_record")
var checkIn: SolarCheckInRecord? var checkIn: SolarCheckInRecord?
if let checkInRaw = checkInRaw { if let checkInRaw = checkInRaw {
@ -31,7 +31,7 @@ struct CheckInProvider: TimelineProvider {
checkIn = nil checkIn = nil
} }
} }
let entry = CheckInEntry( let entry = CheckInEntry(
date: Date(), date: Date(),
checkIn: checkIn checkIn: checkIn
@ -54,11 +54,11 @@ struct CheckInEntry: TimelineEntry {
struct CheckInWidgetEntryView : View { struct CheckInWidgetEntryView : View {
var entry: CheckInProvider.Entry var entry: CheckInProvider.Entry
private let resultTierSymbols: [String] = ["大凶", "", "中平", "", "大吉"] private let resultTierSymbols: [String] = ["Bad", "Poor", "Medium", "Good", "Great"]
func checkIn() -> Void {} func checkIn() -> Void {}
func seeDetail() -> Void {} func seeDetail() -> Void {}
var body: some View { var body: some View {
@ -68,9 +68,9 @@ struct CheckInWidgetEntryView : View {
Text(resultTierSymbols[checkIn.resultTier]).font(.system(size: 27, weight: .bold)) Text(resultTierSymbols[checkIn.resultTier]).font(.system(size: 27, weight: .bold))
Text("+\(checkIn.resultExperience) EXP").font(.system(size: 15, design: .monospaced)) Text("+\(checkIn.resultExperience) EXP").font(.system(size: 15, design: .monospaced))
}.padding(.horizontal, 4) }.padding(.horizontal, 4)
Spacer() Spacer()
HStack { HStack {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text( Text(
@ -82,7 +82,7 @@ struct CheckInWidgetEntryView : View {
format: .dateTime.day().month() format: .dateTime.day().month()
).font(.system(size: 13)) ).font(.system(size: 13))
}.padding(.leading, 4) }.padding(.leading, 4)
Button("See Detail", systemImage: "arrow.right", action: seeDetail) Button("See Detail", systemImage: "arrow.right", action: seeDetail)
.labelStyle(.iconOnly) .labelStyle(.iconOnly)
.buttonBorderShape(.circle) .buttonBorderShape(.circle)
@ -91,11 +91,11 @@ struct CheckInWidgetEntryView : View {
} else { } else {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text("Check In").font(.system(size: 19, weight: .bold)) Text("Check In").font(.system(size: 19, weight: .bold))
Text("You haven't check in today").font(.system(size: 15)) Text("You haven't divined today").font(.system(size: 15))
}.padding(.horizontal, 4) }.padding(.horizontal, 4)
Spacer() Spacer()
HStack(alignment: .bottom) { HStack(alignment: .bottom) {
Button("Check In", systemImage: "checkmark", action: checkIn).labelStyle(.iconOnly).buttonBorderShape(.circle).frame(maxWidth: .infinity, alignment: .trailing) Button("Check In", systemImage: "checkmark", action: checkIn).labelStyle(.iconOnly).buttonBorderShape(.circle).frame(maxWidth: .infinity, alignment: .trailing)
} }

View File

@ -71,9 +71,10 @@ class ChatMessageController extends ChangeNotifier {
resp.data as Map<String, dynamic>, resp.data as Map<String, dynamic>,
); );
_wsSubscription = _ws.stream.stream.listen((event) { _wsSubscription = _ws.pk.stream.listen((event) {
switch (event.method) { switch (event.method) {
case 'events.new': case 'events.new':
if (event.payload?['channel_id'] != channel?.id) break;
final payload = SnChatMessage.fromJson(event.payload!); final payload = SnChatMessage.fromJson(event.payload!);
_addMessage(payload); _addMessage(payload);
break; break;

View File

@ -16,6 +16,7 @@ import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/attachment.dart'; import 'package:surface/types/attachment.dart';
import 'package:surface/types/poll.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
@ -144,6 +145,8 @@ class PostWriteController extends ChangeNotifier {
static const Map<String, String> kTitleMap = { static const Map<String, String> kTitleMap = {
'stories': 'writePostTypeStory', 'stories': 'writePostTypeStory',
'articles': 'writePostTypeArticle', 'articles': 'writePostTypeArticle',
'questions': 'writePostTypeQuestion',
'videos': 'writePostTypeVideo',
}; };
static const kAttachmentProgressWeight = 0.9; static const kAttachmentProgressWeight = 0.9;
@ -153,6 +156,15 @@ class PostWriteController extends ChangeNotifier {
final TextEditingController titleController = TextEditingController(); final TextEditingController titleController = TextEditingController();
final TextEditingController descriptionController = TextEditingController(); final TextEditingController descriptionController = TextEditingController();
final TextEditingController aliasController = TextEditingController(); final TextEditingController aliasController = TextEditingController();
final TextEditingController rewardController = TextEditingController();
ContentInsertionConfiguration get contentInsertionConfiguration => ContentInsertionConfiguration(
onContentInserted: (KeyboardInsertedContent content) {
if (content.hasData) {
addAttachments([PostWriteMedia.fromBytes(content.data!, 'attachmentInsertedImage'.tr(), SnMediaType.image)]);
}
},
);
bool _temporarySaveActive = false; bool _temporarySaveActive = false;
@ -168,6 +180,7 @@ class PostWriteController extends ChangeNotifier {
}); });
contentController.addListener(() { contentController.addListener(() {
_temporaryPlanSave(); _temporaryPlanSave();
notifyListeners();
}); });
if (doLoadFromTemporary) _temporaryLoad(); if (doLoadFromTemporary) _temporaryLoad();
} }
@ -194,6 +207,8 @@ class PostWriteController extends ChangeNotifier {
PostWriteMedia? thumbnail; PostWriteMedia? thumbnail;
List<PostWriteMedia> attachments = List.empty(growable: true); List<PostWriteMedia> attachments = List.empty(growable: true);
DateTime? publishedAt, publishedUntil; DateTime? publishedAt, publishedUntil;
SnAttachment? videoAttachment;
SnPoll? poll;
Future<void> fetchRelatedPost( Future<void> fetchRelatedPost(
BuildContext context, { BuildContext context, {
@ -214,6 +229,8 @@ class PostWriteController extends ChangeNotifier {
descriptionController.text = post.body['description'] ?? ''; descriptionController.text = post.body['description'] ?? '';
contentController.text = post.body['content'] ?? ''; contentController.text = post.body['content'] ?? '';
aliasController.text = post.alias ?? ''; aliasController.text = post.alias ?? '';
rewardController.text = post.body['reward']?.toString() ?? '';
videoAttachment = post.preload?.video;
publishedAt = post.publishedAt; publishedAt = post.publishedAt;
publishedUntil = post.publishedUntil; publishedUntil = post.publishedUntil;
visibleUsers = List.from(post.visibleUsersList ?? [], growable: true); visibleUsers = List.from(post.visibleUsersList ?? [], growable: true);
@ -222,6 +239,7 @@ class PostWriteController extends ChangeNotifier {
tags = List.from(post.tags.map((ele) => ele.alias), growable: true); tags = List.from(post.tags.map((ele) => ele.alias), growable: true);
categories = List.from(post.categories.map((ele) => ele.alias), growable: true); categories = List.from(post.categories.map((ele) => ele.alias), growable: true);
attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []); attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
poll = post.preload?.poll;
if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) { if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
thumbnail = PostWriteMedia(post.preload!.thumbnail); thumbnail = PostWriteMedia(post.preload!.thumbnail);
@ -347,6 +365,7 @@ class PostWriteController extends ChangeNotifier {
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 (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.toJson(), if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.toJson(),
'attachments': 'attachments':
attachments.where((e) => e.attachment != null).map((e) => e.attachment!.toJson()).toList(growable: true), attachments.where((e) => e.attachment != null).map((e) => e.attachment!.toJson()).toList(growable: true),
@ -359,6 +378,7 @@ class PostWriteController extends ChangeNotifier {
if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(), if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
if (replyingPost != null) 'reply_to': replyingPost!.toJson(), if (replyingPost != null) 'reply_to': replyingPost!.toJson(),
if (repostingPost != null) 'repost_to': repostingPost!.toJson(), if (repostingPost != null) 'repost_to': repostingPost!.toJson(),
if (poll != null) 'poll': poll!.toJson(),
}), }),
); );
}); });
@ -375,6 +395,7 @@ class PostWriteController extends ChangeNotifier {
aliasController.text = data['alias'] ?? ''; aliasController.text = data['alias'] ?? '';
titleController.text = data['title'] ?? ''; titleController.text = data['title'] ?? '';
descriptionController.text = data['description'] ?? ''; descriptionController.text = data['description'] ?? '';
rewardController.text = data['reward']?.toString() ?? '';
if (data['thumbnail'] != null) thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail'])); if (data['thumbnail'] != null) thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail']));
attachments attachments
.addAll(data['attachments'].map((ele) => PostWriteMedia(SnAttachment.fromJson(ele))).cast<PostWriteMedia>()); .addAll(data['attachments'].map((ele) => PostWriteMedia(SnAttachment.fromJson(ele))).cast<PostWriteMedia>());
@ -387,6 +408,7 @@ class PostWriteController extends ChangeNotifier {
if (data['published_until'] != null) publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal(); if (data['published_until'] != null) publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal();
replyingPost = data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null; replyingPost = data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null;
repostingPost = data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null; repostingPost = data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null;
poll = data['poll'] != null ? SnPoll.fromJson(data['poll']) : null;
temporaryRestored = true; temporaryRestored = true;
notifyListeners(); notifyListeners();
}); });
@ -473,6 +495,8 @@ class PostWriteController extends ChangeNotifier {
progress = kAttachmentProgressWeight; progress = kAttachmentProgressWeight;
notifyListeners(); notifyListeners();
final reward = double.tryParse(rewardController.text);
// Posting the content // Posting the content
try { try {
final baseProgressVal = progress!; final baseProgressVal = progress!;
@ -498,6 +522,9 @@ class PostWriteController extends ChangeNotifier {
if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(), if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
if (replyingPost != null) 'reply_to': replyingPost!.id, if (replyingPost != null) 'reply_to': replyingPost!.id,
if (repostingPost != null) 'repost_to': repostingPost!.id, if (repostingPost != null) 'repost_to': repostingPost!.id,
if (reward != null) 'reward': reward,
if (videoAttachment != null) 'video': videoAttachment!.rid,
if (poll != null) 'poll': poll!.id,
}, },
onSendProgress: (count, total) { onSendProgress: (count, total) {
progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2); progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
@ -624,6 +651,16 @@ class PostWriteController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void setVideoAttachment(SnAttachment? value) {
videoAttachment = value;
notifyListeners();
}
void setPoll(SnPoll? value) {
poll = value;
notifyListeners();
}
void reset() { void reset() {
publishedAt = null; publishedAt = null;
publishedUntil = null; publishedUntil = null;

View File

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'dart:ui';
import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:croppy/croppy.dart'; import 'package:croppy/croppy.dart';
@ -10,8 +11,10 @@ import 'package:easy_localization_loader/easy_localization_loader.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package: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:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart'; import 'package:relative_time/relative_time.dart';
@ -40,9 +43,12 @@ import 'package:surface/types/chat.dart';
import 'package:surface/types/realm.dart'; import 'package:surface/types/realm.dart';
import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy; import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:tray_manager/tray_manager.dart';
import 'package:version/version.dart'; import 'package:version/version.dart';
import 'package:workmanager/workmanager.dart'; import 'package:workmanager/workmanager.dart';
import 'package:in_app_review/in_app_review.dart'; import 'package:in_app_review/in_app_review.dart';
import 'package:image_picker_android/image_picker_android.dart';
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
@pragma('vm:entry-point') @pragma('vm:entry-point')
void appBackgroundDispatcher() { void appBackgroundDispatcher() {
@ -63,20 +69,6 @@ void appBackgroundDispatcher() {
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await EasyLocalization.ensureInitialized();
await Hive.initFlutter();
Hive.registerAdapter(SnChannelImplAdapter());
Hive.registerAdapter(SnRealmImplAdapter());
Hive.registerAdapter(SnChannelMemberImplAdapter());
Hive.registerAdapter(SnChatMessageImplAdapter());
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
GoRouter.optionURLReflectsImperativeAPIs = true;
usePathUrlStrategy();
if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
doWhenWindowReady(() { doWhenWindowReady(() {
@ -87,6 +79,23 @@ void main() async {
}); });
} }
await EasyLocalization.ensureInitialized();
await Hive.initFlutter();
Hive.registerAdapter(SnChannelImplAdapter());
Hive.registerAdapter(SnRealmImplAdapter());
Hive.registerAdapter(SnChannelMemberImplAdapter());
Hive.registerAdapter(SnChatMessageImplAdapter());
if (kIsWeb && !Platform.isLinux) {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
}
GoRouter.optionURLReflectsImperativeAPIs = true;
usePathUrlStrategy();
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
Workmanager().initialize( Workmanager().initialize(
appBackgroundDispatcher, appBackgroundDispatcher,
@ -103,6 +112,13 @@ void main() async {
} }
} }
if (!kIsWeb && Platform.isAndroid) {
final ImagePickerPlatform imagePickerImplementation = ImagePickerPlatform.instance;
if (imagePickerImplementation is ImagePickerAndroid) {
imagePickerImplementation.useAndroidPhotoPicker = true;
}
}
runApp(const SolianApp()); runApp(const SolianApp());
} }
@ -156,8 +172,8 @@ class SolianApp extends StatelessWidget {
), ),
), ),
breakpoints: [ breakpoints: [
const Breakpoint(start: 0, end: 450, name: MOBILE), const Breakpoint(start: 0, end: 600, name: MOBILE),
const Breakpoint(start: 451, end: 800, name: TABLET), const Breakpoint(start: 601, end: 800, name: TABLET),
const Breakpoint(start: 801, end: 1920, name: DESKTOP), const Breakpoint(start: 801, end: 1920, name: DESKTOP),
], ],
); );
@ -206,7 +222,7 @@ class _AppSplashScreen extends StatefulWidget {
State<_AppSplashScreen> createState() => _AppSplashScreenState(); State<_AppSplashScreen> createState() => _AppSplashScreenState();
} }
class _AppSplashScreenState extends State<_AppSplashScreen> { class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
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')) {
@ -279,7 +295,11 @@ class _AppSplashScreenState extends State<_AppSplashScreen> {
await ws.tryConnect(); await ws.tryConnect();
if (!mounted) return; if (!mounted) return;
final notify = context.read<NotificationProvider>(); final notify = context.read<NotificationProvider>();
notify.listen();
await notify.registerPushNotifications(); await notify.registerPushNotifications();
if (!mounted) return;
final sticker = context.read<SnStickerProvider>();
await sticker.listStickerEagerly();
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
await context.showErrorDialog(err); await context.showErrorDialog(err);
@ -290,9 +310,62 @@ class _AppSplashScreenState extends State<_AppSplashScreen> {
await widgetUpdateRandomPost(); await widgetUpdateRandomPost();
} }
Future<void> _hotkeyInitialization() async {
if (kIsWeb) return;
if (Platform.isMacOS) {
HotKey quitHotKey = HotKey(
key: PhysicalKeyboardKey.keyQ,
modifiers: [HotKeyModifier.meta],
scope: HotKeyScope.inapp,
);
await hotKeyManager.register(quitHotKey, keyUpHandler: (_) {
_appLifecycleListener?.dispose();
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
});
}
}
Future<void> _trayInitialization() async {
if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
final icon = Platform.isWindows ? 'assets/icon/tray-icon.ico' : 'assets/icon/tray-icon.png';
final appVersion = await PackageInfo.fromPlatform();
trayManager.addListener(this);
await trayManager.setIcon(icon);
Menu menu = Menu(
items: [
MenuItem(
key: 'version_label',
label: 'Solian ${appVersion.version}+${appVersion.buildNumber}',
disabled: true,
),
MenuItem.separator(),
MenuItem(
key: 'exit',
label: 'trayMenuExit'.tr(),
),
],
);
await trayManager.setContextMenu(menu);
}
AppLifecycleListener? _appLifecycleListener;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (!kIsWeb && !(Platform.isIOS || Platform.isAndroid)) {
_appLifecycleListener = AppLifecycleListener(
onExitRequested: _onExitRequested,
);
}
_trayInitialization();
_hotkeyInitialization();
_initialize().then((_) { _initialize().then((_) {
_postInitialization(); _postInitialization();
_tryRequestRating(); _tryRequestRating();
@ -300,6 +373,50 @@ class _AppSplashScreenState extends State<_AppSplashScreen> {
}); });
} }
Future<AppExitResponse> _onExitRequested() async {
appWindow.hide();
return AppExitResponse.cancel;
}
@override
void onTrayIconMouseDown() {
if (Platform.isWindows) {
context.read<NotificationProvider>().clearTray();
appWindow.show();
} else {
trayManager.popUpContextMenu();
}
}
@override
void onTrayIconRightMouseDown() {
if (Platform.isWindows) {
trayManager.popUpContextMenu();
} else {
context.read<NotificationProvider>().clearTray();
appWindow.show();
}
}
@override
void onTrayMenuItemClick(MenuItem menuItem) {
switch (menuItem.key) {
case 'exit':
_appLifecycleListener?.dispose();
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
break;
}
}
@override
void dispose() {
if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) {
trayManager.removeListener(this);
hotKeyManager.unregisterAll();
}
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cfg = context.read<ConfigProvider>(); final cfg = context.read<ConfigProvider>();

View File

@ -14,6 +14,9 @@ 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 kAppDrawerPreferCollapse = 'app_drawer_prefer_collapse';
const kAppNotifyWithHaptic = 'app_notify_with_haptic';
const kAppExpandPostLink = 'app_expand_post_link';
const kAppExpandChatLink = 'app_expand_chat_link';
const Map<String, FilterQuality> kImageQualityLevel = { const Map<String, FilterQuality> kImageQualityLevel = {
'settingsImageQualityLowest': FilterQuality.none, 'settingsImageQualityLowest': FilterQuality.none,
@ -38,14 +41,22 @@ class ConfigProvider extends ChangeNotifier {
bool drawerIsCollapsed = false; bool drawerIsCollapsed = false;
bool drawerIsExpanded = false; bool drawerIsExpanded = false;
void calcDrawerSize(BuildContext context) { void calcDrawerSize(BuildContext context, {bool withMediaQuery = false}) {
final rpb = ResponsiveBreakpoints.of(context); bool newDrawerIsCollapsed = false;
final newDrawerIsCollapsed = rpb.smallerOrEqualTo(MOBILE); bool newDrawerIsExpanded = false;
final newDrawerIsExpanded = rpb.largerThan(TABLET) if (withMediaQuery) {
? (prefs.getBool(kAppDrawerPreferCollapse) ?? false) newDrawerIsCollapsed = MediaQuery.of(context).size.width < 600;
? false newDrawerIsExpanded = MediaQuery.of(context).size.width >= 601;
: true } else {
: false; final rpb = ResponsiveBreakpoints.of(context);
newDrawerIsCollapsed = rpb.smallerOrEqualTo(MOBILE);
newDrawerIsExpanded = rpb.largerThan(TABLET)
? (prefs.getBool(kAppDrawerPreferCollapse) ?? false)
? false
: true
: false;
}
if (newDrawerIsExpanded != drawerIsExpanded || newDrawerIsCollapsed != drawerIsCollapsed) { if (newDrawerIsExpanded != drawerIsExpanded || newDrawerIsCollapsed != drawerIsCollapsed) {
drawerIsExpanded = newDrawerIsExpanded; drawerIsExpanded = newDrawerIsExpanded;
drawerIsCollapsed = newDrawerIsCollapsed; drawerIsCollapsed = newDrawerIsCollapsed;

View File

@ -58,6 +58,11 @@ class NavigationProvider extends ChangeNotifier {
screen: 'realm', screen: 'realm',
label: 'screenRealm', label: 'screenRealm',
), ),
AppNavDestination(
icon: Icon(Symbols.newspaper, weight: 400, opticalSize: 20),
screen: 'news',
label: 'screenNews',
),
AppNavDestination( AppNavDestination(
icon: Icon(Symbols.photo_library, weight: 400, opticalSize: 20), icon: Icon(Symbols.photo_library, weight: 400, opticalSize: 20),
screen: 'album', screen: 'album',
@ -83,8 +88,7 @@ class NavigationProvider extends ChangeNotifier {
List<AppNavDestination> destinations = []; List<AppNavDestination> destinations = [];
int get pinnedDestinationCount => int get pinnedDestinationCount => destinations.where((ele) => ele.isPinned).length;
destinations.where((ele) => ele.isPinned).length;
NavigationProvider() { NavigationProvider() {
buildDestinations(kDefaultPinnedDestination); buildDestinations(kDefaultPinnedDestination);
@ -113,17 +117,13 @@ class NavigationProvider extends ChangeNotifier {
} }
bool isIndexInRange(int min, int max) { bool isIndexInRange(int min, int max) {
return _currentIndex != null && return _currentIndex != null && _currentIndex! >= min && _currentIndex! < max;
_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) => ele.screen == state.routerDelegate.currentConfiguration.last.route.name,
ele.screen ==
state.routerDelegate.currentConfiguration.last.route.name,
); );
_currentIndex = idx == -1 ? null : idx; _currentIndex = idx == -1 ? null : idx;
notifyListeners(); notifyListeners();

View File

@ -4,18 +4,27 @@ import 'dart:io';
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_udid/flutter_udid.dart'; import 'package:flutter_udid/flutter_udid.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/websocket.dart';
import 'package:surface/types/notification.dart';
import 'package:tray_manager/tray_manager.dart';
class NotificationProvider extends ChangeNotifier { class NotificationProvider extends ChangeNotifier {
late final SnNetworkProvider _sn; late final SnNetworkProvider _sn;
late final UserProvider _ua; late final UserProvider _ua;
late final WebSocketProvider _ws;
late final ConfigProvider _cfg;
NotificationProvider(BuildContext context) { NotificationProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>(); _sn = context.read<SnNetworkProvider>();
_ua = context.read<UserProvider>(); _ua = context.read<UserProvider>();
_ws = context.read<WebSocketProvider>();
_cfg = context.read<ConfigProvider>();
} }
Future<void> registerPushNotifications() async { Future<void> registerPushNotifications() async {
@ -62,4 +71,49 @@ class NotificationProvider extends ChangeNotifier {
}, },
); );
} }
int showingCount = 0;
int showingTrayCount = 0;
List<SnNotification> notifications = List.empty(growable: true);
void listen() {
_ws.pk.stream.listen((event) {
if (event.method == 'notifications.new') {
final notification = SnNotification.fromJson(event.payload!);
if (showingCount < 0) showingCount = 0;
showingCount++;
showingTrayCount++;
notifications.add(notification);
Future.delayed(const Duration(seconds: 3), () {
if (showingCount >= 0) showingCount--;
notifyListeners();
});
notifyListeners();
updateTray();
final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true;
if (doHaptic) HapticFeedback.mediumImpact();
}
});
}
void clearTray() {
showingTrayCount = 0;
updateTray();
}
void updateTray() {
if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
if (showingTrayCount == 0) {
trayManager.setTitle('');
} else {
trayManager.setTitle(' $showingTrayCount');
}
}
void clear() {
showingCount = 0;
notifications.clear();
updateTray();
notifyListeners();
}
} }

View File

@ -3,6 +3,7 @@ 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/user_directory.dart'; import 'package:surface/providers/user_directory.dart';
import 'package:surface/types/poll.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
class SnPostContentProvider { class SnPostContentProvider {
@ -16,6 +17,11 @@ class SnPostContentProvider {
_attach = context.read<SnAttachmentProvider>(); _attach = context.read<SnAttachmentProvider>();
} }
Future<SnPoll> _fetchPoll(int id) async {
final resp = await _sn.client.get('/cgi/co/polls/$id');
return SnPoll.fromJson(resp.data);
}
Future<List<SnPost>> _preloadRelatedDataInBatch(List<SnPost> out) async { Future<List<SnPost>> _preloadRelatedDataInBatch(List<SnPost> out) async {
Set<String> rids = {}; Set<String> rids = {};
for (var i = 0; i < out.length; i++) { for (var i = 0; i < out.length; i++) {
@ -23,6 +29,9 @@ class SnPostContentProvider {
if (out[i].body['thumbnail'] != null) { if (out[i].body['thumbnail'] != null) {
rids.add(out[i].body['thumbnail']); rids.add(out[i].body['thumbnail']);
} }
if (out[i].body['video'] != null) {
rids.add(out[i].body['video']);
}
if (out[i].repostTo != null) { if (out[i].repostTo != null) {
out[i] = out[i].copyWith( out[i] = out[i].copyWith(
repostTo: await _preloadRelatedDataSingle(out[i].repostTo!), repostTo: await _preloadRelatedDataSingle(out[i].repostTo!),
@ -32,10 +41,17 @@ class SnPostContentProvider {
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;
if (out[i].pollId != null) {
poll = await _fetchPoll(out[i].pollId!);
}
out[i] = out[i].copyWith( out[i] = out[i].copyWith(
preload: SnPostPreload( preload: SnPostPreload(
thumbnail: attachments.where((ele) => ele?.rid == out[i].body['thumbnail']).firstOrNull, thumbnail: attachments.where((ele) => ele?.rid == out[i].body['thumbnail']).firstOrNull,
attachments: attachments.where((ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false).toList(), attachments: attachments.where((ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false).toList(),
video: attachments.where((ele) => ele?.rid == out[i].body['video']).firstOrNull,
poll: poll,
), ),
); );
} }
@ -53,6 +69,9 @@ class SnPostContentProvider {
if (out.body['thumbnail'] != null) { if (out.body['thumbnail'] != null) {
rids.add(out.body['thumbnail']); rids.add(out.body['thumbnail']);
} }
if (out.body['video'] != null) {
rids.add(out.body['video']);
}
if (out.repostTo != null) { if (out.repostTo != null) {
out = out.copyWith( out = out.copyWith(
repostTo: await _preloadRelatedDataSingle(out.repostTo!), repostTo: await _preloadRelatedDataSingle(out.repostTo!),
@ -60,10 +79,18 @@ class SnPostContentProvider {
} }
final attachments = await _attach.getMultiple(rids.toList()); final attachments = await _attach.getMultiple(rids.toList());
SnPoll? poll;
if (out.pollId != null) {
poll = await _fetchPoll(out.pollId!);
}
out = out.copyWith( out = out.copyWith(
preload: SnPostPreload( preload: SnPostPreload(
thumbnail: attachments.where((ele) => ele?.rid == out.body['thumbnail']).firstOrNull, thumbnail: attachments.where((ele) => ele?.rid == out.body['thumbnail']).firstOrNull,
attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(), attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(),
video: attachments.where((ele) => ele?.rid == out.body['video']).firstOrNull,
poll: poll,
), ),
); );

View File

@ -9,6 +9,10 @@ class SnStickerProvider {
late final SnNetworkProvider _sn; late final SnNetworkProvider _sn;
final Map<String, SnSticker?> _cache = {}; final Map<String, SnSticker?> _cache = {};
final Map<int, List<SnSticker>> stickersByPack = {};
List<SnSticker> get stickers => _cache.values.where((ele) => ele != null).cast<SnSticker>().toList();
SnStickerProvider(BuildContext context) { SnStickerProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>(); _sn = context.read<SnNetworkProvider>();
} }
@ -17,6 +21,12 @@ class SnStickerProvider {
return _cache.containsKey(alias) && _cache[alias] == null; return _cache.containsKey(alias) && _cache[alias] == null;
} }
void _cacheSticker(SnSticker sticker) {
_cache['${sticker.pack.prefix}:${sticker.alias}'] = sticker;
if (stickersByPack[sticker.pack.id] == null) stickersByPack[sticker.pack.id] = List.empty(growable: true);
if (!stickersByPack[sticker.pack.id]!.contains(sticker)) stickersByPack[sticker.pack.id]!.add(sticker);
}
Future<SnSticker?> lookupSticker(String alias) async { Future<SnSticker?> lookupSticker(String alias) async {
if (_cache.containsKey(alias)) { if (_cache.containsKey(alias)) {
return _cache[alias]; return _cache[alias];
@ -25,7 +35,7 @@ class SnStickerProvider {
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);
_cache[alias] = sticker; _cacheSticker(sticker);
return sticker; return sticker;
} catch (err) { } catch (err) {
@ -35,4 +45,30 @@ class SnStickerProvider {
return null; return null;
} }
Future<void> listStickerEagerly() async {
var count = await listSticker();
for (var page = 1; count > 0; count -= 10) {
await listSticker(page: page);
page++;
}
}
Future<int> listSticker({int page = 0}) async {
try {
final resp = await _sn.client.get('/cgi/uc/stickers', queryParameters: {
'take': 10,
'offset': page * 10,
});
final data = resp.data;
final stickers = List.from(data['data']).map((ele) => SnSticker.fromJson(ele));
for (final sticker in stickers) {
_cacheSticker(sticker);
}
return data['count'] as int;
} catch (err) {
log('[Sticker] Failed to list stickers: $err');
rethrow;
}
}
} }

View File

@ -14,9 +14,32 @@ class UserDirectoryProvider {
final Map<int, SnAccount> _cache = {}; final Map<int, SnAccount> _cache = {};
Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async { Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async {
final out = await Future.wait( final out = List<SnAccount?>.generate(id.length, (e) => null);
id.map((e) => getAccount(e)), final plannedQuery = <int>{};
); for (var idx = 0; idx < out.length; idx++) {
var item = id.elementAt(idx);
if (item is String && _idCache.containsKey(item)) {
item = _idCache[item];
}
if (_cache.containsKey(item)) {
out[idx] = _cache[item];
} else {
plannedQuery.add(item);
}
}
final resp = await _sn.client.get('/cgi/id/users', queryParameters: {'id': plannedQuery.join(',')});
final respDecoded = resp.data.map((e) => SnAccount.fromJson(e)).cast<SnAccount>().toList();
var sideIdx = 0;
for (var idx = 0; idx < out.length; idx++) {
if (out[idx] != null) continue;
if (respDecoded.length <= sideIdx) {
break;
}
out[idx] = respDecoded[sideIdx];
_cache[respDecoded[sideIdx].id] = out[idx]!;
_idCache[respDecoded[sideIdx].name] = respDecoded[sideIdx].id;
sideIdx++;
}
return out; return out;
} }

View File

@ -53,4 +53,11 @@ class UserProvider extends ChangeNotifier {
user = null; user = null;
notifyListeners(); notifyListeners();
} }
void setLanguage(String? value) {
if (value == null) return;
if (user == null) return;
user = user!.copyWith(language: value);
notifyListeners();
}
} }

View File

@ -18,7 +18,8 @@ class WebSocketProvider extends ChangeNotifier {
late final SnNetworkProvider _sn; late final SnNetworkProvider _sn;
late final UserProvider _ua; late final UserProvider _ua;
StreamController<WebSocketPackage> stream = StreamController.broadcast(); StreamController<WebSocketPackage> pk = StreamController.broadcast();
Stream<dynamic>? _wsStream;
WebSocketProvider(BuildContext context) { WebSocketProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>(); _sn = context.read<SnNetworkProvider>();
@ -33,23 +34,33 @@ class WebSocketProvider extends ChangeNotifier {
await connect(); await connect();
} }
Completer<void>? _connectCompleter;
Future<void> connect({noRetry = false}) async { Future<void> connect({noRetry = false}) async {
if (_connectCompleter != null) {
await _connectCompleter!.future;
_connectCompleter = null;
}
if (!_ua.isAuthorized) return; if (!_ua.isAuthorized) return;
if (isConnected || conn != null) { if (isConnected || conn != null) {
disconnect(); disconnect();
} }
final atk = await _sn.getFreshAtk();
final uri = Uri.parse(
'${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk',
);
isBusy = true;
notifyListeners();
try { try {
_connectCompleter = Completer<void>();
final atk = await _sn.getFreshAtk();
final uri = Uri.parse(
'${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk',
);
isBusy = true;
notifyListeners();
conn = WebSocketChannel.connect(uri); conn = WebSocketChannel.connect(uri);
await conn!.ready; await conn!.ready;
_wsStream = conn!.stream.asBroadcastStream();
listen(); listen();
log('[WebSocket] Connected to server!'); log('[WebSocket] Connected to server!');
isConnected = true; isConnected = true;
@ -70,6 +81,8 @@ class WebSocketProvider extends ChangeNotifier {
} finally { } finally {
isBusy = false; isBusy = false;
notifyListeners(); notifyListeners();
_connectCompleter!.complete();
_connectCompleter = null;
} }
} }
@ -83,11 +96,12 @@ class WebSocketProvider extends ChangeNotifier {
} }
void listen() { void listen() {
conn?.stream.listen( if (_wsStream == null) return;
_wsStream!.listen(
(event) { (event) {
final packet = WebSocketPackage.fromJson(jsonDecode(event)); final packet = WebSocketPackage.fromJson(jsonDecode(event));
log('Websocket incoming message: ${packet.method} ${packet.message}'); log('Websocket incoming message: ${packet.method} ${packet.message}');
stream.sink.add(packet); pk.sink.add(packet);
}, },
onDone: () { onDone: () {
isConnected = false; isConnected = false;

View File

@ -47,6 +47,7 @@ class HomeWidgetProvider {
} }
Future<void> widgetUpdateRandomPost() async { Future<void> widgetUpdateRandomPost() async {
if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) return;
final snc = await SnNetworkProvider.createOffContextClient(); final snc = await SnNetworkProvider.createOffContextClient();
final resp = await snc.get('/cgi/co/recommendations/shuffle?take=1'); final resp = await snc.get('/cgi/co/recommendations/shuffle?take=1');
final post = SnPost.fromJson(resp.data['data'][0]); final post = SnPost.fromJson(resp.data['data'][0]);

View File

@ -3,7 +3,9 @@ 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/pfp.dart'; import 'package:surface/screens/account/account_settings.dart';
import 'package:surface/screens/account/factor_settings.dart';
import 'package:surface/screens/account/profile_page.dart';
import 'package:surface/screens/account/profile_edit.dart'; import 'package:surface/screens/account/profile_edit.dart';
import 'package:surface/screens/account/publishers/publisher_edit.dart'; import 'package:surface/screens/account/publishers/publisher_edit.dart';
import 'package:surface/screens/account/publishers/publisher_new.dart'; import 'package:surface/screens/account/publishers/publisher_new.dart';
@ -19,6 +21,8 @@ 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/news/news_detail.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_editor.dart'; import 'package:surface/screens/post/post_editor.dart';
@ -27,292 +31,235 @@ import 'package:surface/screens/post/post_search.dart';
import 'package:surface/screens/realm.dart'; import 'package:surface/screens/realm.dart';
import 'package:surface/screens/realm/manage.dart'; import 'package:surface/screens/realm/manage.dart';
import 'package:surface/screens/realm/realm_detail.dart'; import 'package:surface/screens/realm/realm_detail.dart';
import 'package:surface/screens/realm/realm_discovery.dart';
import 'package:surface/screens/settings.dart'; import 'package:surface/screens/settings.dart';
import 'package:surface/screens/sharing.dart'; import 'package:surface/screens/sharing.dart';
import 'package:surface/screens/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_background.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart'; import 'package:surface/widgets/navigation/app_scaffold.dart';
Widget _fadeThroughTransition(
BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
return FadeThroughTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
fillColor: Colors.transparent,
child: child,
);
}
final _appRoutes = [ final _appRoutes = [
ShellRoute( GoRoute(
builder: (context, state, child) => AppPageScaffold( path: '/',
body: child, name: 'home',
showAppBar: false, builder: (context, state) => const HomeScreen(),
), ),
GoRoute(
path: '/posts',
name: 'explore',
builder: (context, state) => const ExploreScreen(),
routes: [ routes: [
GoRoute( GoRoute(
path: '/', path: '/write/:mode',
name: 'home', name: 'postEditor',
pageBuilder: (context, state) => NoTransitionPage( builder: (context, state) => PostEditorScreen(
child: const HomeScreen(), mode: state.pathParameters['mode']!,
postEditId: int.tryParse(
state.uri.queryParameters['editing'] ?? '',
),
postReplyId: int.tryParse(
state.uri.queryParameters['replying'] ?? '',
),
postRepostId: int.tryParse(
state.uri.queryParameters['reposting'] ?? '',
),
extraProps: state.extra as PostEditorExtra?,
), ),
), ),
GoRoute( GoRoute(
path: '/posts', path: '/search',
name: 'explore', name: 'postSearch',
pageBuilder: (context, state) => NoTransitionPage( builder: (context, state) => PostSearchScreen(
child: const ExploreScreen(), initialTags: state.uri.queryParameters['tags']?.split(','),
), initialCategories: state.uri.queryParameters['categories']?.split(','),
routes: [
GoRoute(
path: '/write/:mode',
name: 'postEditor',
builder: (context, state) => AppBackground(
child: PostEditorScreen(
mode: state.pathParameters['mode']!,
postEditId: int.tryParse(
state.uri.queryParameters['editing'] ?? '',
),
postReplyId: int.tryParse(
state.uri.queryParameters['replying'] ?? '',
),
postRepostId: int.tryParse(
state.uri.queryParameters['reposting'] ?? '',
),
extraProps: state.extra as PostEditorExtraProps?,
),
),
),
GoRoute(
path: '/search',
name: 'postSearch',
builder: (context, state) => AppBackground(
child: PostSearchScreen(
initialTags: state.uri.queryParameters['tags']?.split(','),
initialCategories: state.uri.queryParameters['categories']?.split(','),
),
),
),
GoRoute(
path: '/publishers/:name',
name: 'postPublisher',
builder: (context, state) => AppBackground(
child: PostPublisherScreen(name: state.pathParameters['name']!),
),
),
GoRoute(
path: '/:slug',
name: 'postDetail',
builder: (context, state) => AppBackground(
child: PostDetailScreen(
slug: state.pathParameters['slug']!,
preload: state.extra as SnPost?,
),
),
),
],
),
GoRoute(
path: '/account',
name: 'account',
pageBuilder: (context, state) => NoTransitionPage(
child: const AccountScreen(),
),
routes: [],
),
GoRoute(
path: '/chat',
name: 'chat',
pageBuilder: (context, state) => NoTransitionPage(
child: const ChatScreen(),
),
routes: [
GoRoute(
path: '/:scope/:alias',
name: 'chatRoom',
builder: (context, state) => AppBackground(
child: ChatRoomScreen(
scope: state.pathParameters['scope']!,
alias: state.pathParameters['alias']!,
),
),
),
GoRoute(
path: '/:scope/:alias/call',
name: 'chatCallRoom',
builder: (context, state) => AppBackground(
child: CallRoomScreen(
scope: state.pathParameters['scope']!,
alias: state.pathParameters['alias']!,
),
),
),
GoRoute(
path: '/:scope/:alias/detail',
name: 'channelDetail',
builder: (context, state) => AppBackground(
child: ChannelDetailScreen(
scope: state.pathParameters['scope']!,
alias: state.pathParameters['alias']!,
),
),
),
GoRoute(
path: '/manage',
name: 'chatManage',
pageBuilder: (context, state) => CustomTransitionPage(
child: ChatManageScreen(
editingChannelAlias: state.uri.queryParameters['editing'],
),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeThroughTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
fillColor: Colors.transparent,
child: AppBackground(
child: child,
),
);
},
),
),
GoRoute(
path: '/:alias',
name: 'realmDetail',
builder: (context, state) => AppBackground(
child: RealmDetailScreen(alias: state.pathParameters['alias']!),
),
),
],
),
GoRoute(
path: '/realm',
name: 'realm',
pageBuilder: (context, state) => NoTransitionPage(
child: const RealmScreen(),
),
routes: [
GoRoute(
path: '/manage',
name: 'realmManage',
pageBuilder: (context, state) => CustomTransitionPage(
child: RealmManageScreen(
editingRealmAlias: state.uri.queryParameters['editing'],
),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeThroughTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
fillColor: Colors.transparent,
child: AppBackground(
child: child,
),
);
},
),
),
],
),
GoRoute(
path: '/album',
name: 'album',
pageBuilder: (context, state) => NoTransitionPage(
child: const AlbumScreen(),
), ),
), ),
GoRoute( GoRoute(
path: '/friend', path: '/publishers/:name',
name: 'friend', name: 'postPublisher',
pageBuilder: (context, state) => NoTransitionPage( builder: (context, state) => PostPublisherScreen(name: state.pathParameters['name']!),
child: const FriendScreen(),
),
), ),
GoRoute( GoRoute(
path: '/notification', path: '/:slug',
name: 'notification', name: 'postDetail',
pageBuilder: (context, state) => NoTransitionPage( builder: (context, state) => PostDetailScreen(
child: const NotificationScreen(), slug: state.pathParameters['slug']!,
preload: state.extra as SnPost?,
), ),
), ),
], ],
), ),
ShellRoute( GoRoute(path: '/account', name: 'account', builder: (context, state) => const AccountScreen(), routes: [
builder: (context, state, child) => AppPageScaffold(body: child), GoRoute(
path: '/wallet',
name: 'accountWallet',
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: '/auth/login', path: '/:scope/:alias',
name: 'authLogin', name: 'chatRoom',
builder: (context, state) => const AppBackground( builder: (context, state) => ChatRoomScreen(
child: LoginScreen(), scope: state.pathParameters['scope']!,
alias: state.pathParameters['alias']!,
extra: state.extra as ChatRoomScreenExtra?,
), ),
), ),
GoRoute( GoRoute(
path: '/auth/register', path: '/:scope/:alias/call',
name: 'authRegister', name: 'chatCallRoom',
builder: (context, state) => const AppBackground( builder: (context, state) => CallRoomScreen(
child: RegisterScreen(), scope: state.pathParameters['scope']!,
alias: state.pathParameters['alias']!,
), ),
), ),
GoRoute( GoRoute(
path: '/reports', path: '/:scope/:alias/detail',
name: 'abuseReport', name: 'channelDetail',
builder: (context, state) => const AppBackground( builder: (context, state) => ChannelDetailScreen(
child: AbuseReportScreen(), scope: state.pathParameters['scope']!,
alias: state.pathParameters['alias']!,
), ),
), ),
GoRoute( GoRoute(
path: '/account/profile/edit', path: '/manage',
name: 'accountProfileEdit', name: 'chatManage',
builder: (context, state) => const AppBackground( builder: (context, state) => ChatManageScreen(
child: ProfileEditScreen(), editingChannelAlias: state.uri.queryParameters['editing'],
),
),
GoRoute(
path: '/account/publishers',
name: 'accountPublishers',
builder: (context, state) => const AppBackground(
child: PublisherScreen(),
),
),
GoRoute(
path: '/account/publishers/new',
name: 'accountPublisherNew',
builder: (context, state) => const AppBackground(
child: AccountPublisherNewScreen(),
),
),
GoRoute(
path: '/account/publishers/edit/:name',
name: 'accountPublisherEdit',
builder: (context, state) => AppBackground(
child: AccountPublisherEditScreen(
name: state.pathParameters['name']!,
),
), ),
), ),
], ],
), ),
GoRoute( GoRoute(
path: '/account/:name', path: '/realm',
name: 'accountProfilePage', name: 'realm',
pageBuilder: (context, state) => NoTransitionPage( pageBuilder: (context, state) => CustomTransitionPage(
child: UserScreen(name: state.pathParameters['name']!), transitionsBuilder: _fadeThroughTransition,
child: const RealmScreen(),
), ),
),
ShellRoute(
builder: (context, state, child) => AppPageScaffold(body: child),
routes: [ routes: [
GoRoute( GoRoute(
path: '/settings', path: '/manage',
name: 'settings', name: 'realmManage',
builder: (context, state) => const AppBackground( builder: (context, state) => RealmManageScreen(
child: SettingsScreen(), editingRealmAlias: state.uri.queryParameters['editing'],
), ),
), ),
GoRoute(
path: '/discovery',
name: 'realmDiscovery',
builder: (context, state) => const RealmDiscoveryScreen(),
),
GoRoute(
path: '/:alias',
name: 'realmDetail',
builder: (context, state) => RealmDetailScreen(alias: state.pathParameters['alias']!),
),
], ],
), ),
ShellRoute( GoRoute(path: '/news', name: 'news', builder: (context, state) => const NewsScreen(), routes: [
builder: (context, state, child) => AppPageScaffold(body: child), GoRoute(
routes: [ path: '/:hash',
GoRoute( name: 'newsDetail',
path: '/about', builder: (context, state) => NewsDetailScreen(
name: 'about', hash: state.pathParameters['hash']!,
builder: (context, state) => const AppBackground(
child: AboutScreen(),
),
), ),
], ),
]),
GoRoute(
path: '/album',
name: 'album',
builder: (context, state) => const AlbumScreen(),
),
GoRoute(
path: '/friend',
name: 'friend',
builder: (context, state) => const FriendScreen(),
),
GoRoute(
path: '/notification',
name: 'notification',
builder: (context, state) => const NotificationScreen(),
),
GoRoute(
path: '/auth/login',
name: 'authLogin',
builder: (context, state) => LoginScreen(),
),
GoRoute(
path: '/auth/register',
name: 'authRegister',
builder: (context, state) => RegisterScreen(),
),
GoRoute(
path: '/reports',
name: 'abuseReport',
builder: (context, state) => AbuseReportScreen(),
),
GoRoute(
path: '/settings',
name: 'settings',
builder: (context, state) => SettingsScreen(),
),
GoRoute(
path: '/about',
name: 'about',
builder: (context, state) => AboutScreen(),
), ),
]; ];

View File

@ -6,6 +6,7 @@ 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/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import '../types/account.dart'; import '../types/account.dart';
@ -56,7 +57,11 @@ class _AbuseReportScreenState extends State<AbuseReportScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return AppScaffold(
appBar: AppBar(
leading: const PageBackButton(),
title: Text('screenAbuseReport').tr(),
),
body: Column( body: Column(
children: [ children: [
ListTile( ListTile(
@ -69,10 +74,14 @@ class _AbuseReportScreenState extends State<AbuseReportScreen> {
), ),
const Divider(height: 1), const Divider(height: 1),
if (_isBusy) if (_isBusy)
const CircularProgressIndicator().padding(all: 24).center() Padding(
padding: const EdgeInsets.all(24),
child: const CircularProgressIndicator(),
).center()
else else
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
padding: EdgeInsets.only(top: 8),
itemCount: _reports.length, itemCount: _reports.length,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
return ListTile( return ListTile(

View File

@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
@ -12,6 +14,8 @@ import 'package:surface/providers/websocket.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/app_bar_leading.dart'; import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/universal_image.dart';
class AccountScreen extends StatelessWidget { class AccountScreen extends StatelessWidget {
const AccountScreen({super.key}); const AccountScreen({super.key});
@ -19,11 +23,51 @@ class AccountScreen extends StatelessWidget {
@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>();
return Scaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
leading: AutoAppBarLeading(), leading: AutoAppBarLeading(),
title: Text("screenAccount").tr(), title: Text(
"screenAccount",
style: TextStyle(
color: Colors.white,
shadows: [
Shadow(
offset: Offset(1, 1),
blurRadius: 5.0,
color: Color.fromARGB(255, 0, 0, 0),
),
],
),
).tr(),
flexibleSpace: ua.user != null && ua.user!.banner.isNotEmpty
? Stack(
fit: StackFit.expand,
children: [
AutoResizeUniversalImage(sn.getAttachmentUrl(ua.user!.banner), fit: BoxFit.cover),
Positioned(
top: 0,
left: 0,
right: 0,
height: 56 + MediaQuery.of(context).padding.top,
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 10,
sigmaY: 10,
),
child: Container(
color: Colors.black.withOpacity(
clampDouble(10 * 0.1, 0, 0.5),
),
),
),
),
),
],
)
: null,
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Symbols.settings, fill: 1), icon: const Icon(Symbols.settings, fill: 1),
@ -82,16 +126,6 @@ class _AuthorizedAccountScreen extends StatelessWidget {
); );
}).padding(all: 20), }).padding(all: 20),
).padding(horizontal: 8, top: 16, bottom: 4), ).padding(horizontal: 8, top: 16, bottom: 4),
ListTile(
title: Text('accountProfileEdit').tr(),
subtitle: Text('accountProfileEditSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.contact_page),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountProfileEdit');
},
),
ListTile( ListTile(
title: Text('accountPublishers').tr(), title: Text('accountPublishers').tr(),
subtitle: Text('accountPublishersSubtitle').tr(), subtitle: Text('accountPublishersSubtitle').tr(),
@ -112,6 +146,36 @@ class _AuthorizedAccountScreen extends StatelessWidget {
GoRouter.of(context).pushNamed('abuseReport'); GoRouter.of(context).pushNamed('abuseReport');
}, },
), ),
ListTile(
title: Text('factorSettings').tr(),
subtitle: Text('factorSettingsSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.lock),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('factorSettings');
},
),
ListTile(
title: Text('accountWallet').tr(),
subtitle: Text('accountWalletSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.wallet),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountWallet');
},
),
ListTile(
title: Text('accountSettings').tr(),
subtitle: Text('accountSettingsSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.manage_accounts),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountSettings');
},
),
ListTile( ListTile(
title: Text('accountLogout').tr(), title: Text('accountLogout').tr(),
subtitle: Text('accountLogoutSubtitle').tr(), subtitle: Text('accountLogoutSubtitle').tr(),
@ -133,33 +197,6 @@ class _AuthorizedAccountScreen extends StatelessWidget {
await Hive.initFlutter(); await Hive.initFlutter();
}, },
), ),
ListTile(
title: Text('accountDeletion'.tr()),
subtitle: Text('accountDeletionActionDescription'.tr()),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.person_cancel),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
context
.showConfirmDialog(
'accountDeletion'.tr(),
'accountDeletionDescription'.tr(),
)
.then((value) {
if (!value || !context.mounted) return;
final sn = context.read<SnNetworkProvider>();
sn.client.post('/cgi/id/users/me/deletion').then((value) {
if (context.mounted) {
context.showSnackbar('accountDeletionSubmitted'.tr());
}
}).catchError((err) {
if (context.mounted) {
context.showErrorDialog(err);
}
});
});
},
),
], ],
); );
} }

View File

@ -0,0 +1,126 @@
import 'package:collection/collection.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:intl/locale.dart';
class AccountSettingsScreen extends StatelessWidget {
const AccountSettingsScreen({super.key});
Future<void> _setAccountLanguage(BuildContext context, Locale? value) async {
if (value == null) return;
try {
final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>();
await sn.client.put('/cgi/id/users/me/language', data: {
'language': value.toString(),
});
if (!context.mounted) return;
context.showSnackbar('accountSettingsApplied'.tr());
await ua.refreshUser();
} catch (err) {
if (!context.mounted) return;
context.showErrorDialog(err);
}
}
@override
Widget build(BuildContext context) {
final ua = context.watch<UserProvider>();
return AppScaffold(
appBar: AppBar(
leading: PageBackButton(),
title: Text('screenAccountSettings').tr(),
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
title: Text('settingsAccountLanguage').tr(),
subtitle: Text('settingsAccountLanguageDescription').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.translate),
trailing: DropdownButtonHideUnderline(
child: DropdownButton2<Locale?>(
isExpanded: true,
items: [
...EasyLocalization.of(context)!.supportedLocales.mapIndexed((idx, ele) {
return DropdownMenuItem<Locale?>(
value: Locale.parse(ele.toString()),
child: Text('${ele.languageCode}-${ele.countryCode}').fontSize(14),
);
}),
],
value: ua.user?.language != null ? Locale.parse(ua.user!.language) : Locale.parse('en-US'),
onChanged: (Locale? value) {
if (value == null) return;
_setAccountLanguage(context, value);
ua.setLanguage(value.toString());
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 5,
),
height: 40,
width: 160,
),
menuItemStyleData: const MenuItemStyleData(
height: 40,
),
),
),
),
ListTile(
title: Text('accountProfileEdit').tr(),
subtitle: Text('accountProfileEditSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.contact_page),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountProfileEdit');
},
),
ListTile(
title: Text('accountDeletion'.tr()),
subtitle: Text('accountDeletionActionDescription'.tr()),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.person_cancel),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
context
.showConfirmDialog(
'accountDeletion'.tr(),
'accountDeletionDescription'.tr(),
)
.then((value) {
if (!value || !context.mounted) return;
final sn = context.read<SnNetworkProvider>();
sn.client.post('/cgi/id/users/me/deletion').then((value) {
if (context.mounted) {
context.showSnackbar('accountDeletionSubmitted'.tr());
}
}).catchError((err) {
if (context.mounted) {
context.showErrorDialog(err);
}
});
});
},
),
],
),
),
);
}
}

View File

@ -0,0 +1,294 @@
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:qr_flutter/qr_flutter.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.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';
final Map<int, (String, String, IconData)> kFactorTypes = {
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email),
2: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer),
3: ('authFactorInAppNotify', 'authFactorInAppNotifyDescription', Symbols.notifications_active),
};
class FactorSettingsScreen extends StatefulWidget {
const FactorSettingsScreen({super.key});
@override
State<FactorSettingsScreen> createState() => _FactorSettingsScreenState();
}
class _FactorSettingsScreenState extends State<FactorSettingsScreen> {
bool _isBusy = false;
List<SnAuthFactor>? _factors;
Future<void> _fetchFactors() async {
try {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/me/factors');
_factors = List<SnAuthFactor>.from(
resp.data?.map((e) => SnAuthFactor.fromJson(e as Map<String, dynamic>)).toList() ?? [],
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchFactors();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
leading: PageBackButton(),
title: Text('screenFactorSettings').tr(),
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LoadingIndicator(
isActive: _isBusy,
),
ListTile(
title: Text('authFactorAdd').tr(),
subtitle: Text('authFactorAddSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.add),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
showDialog(
context: context,
builder: (context) => _FactorNewDialog(
currentlyHave: _factors!,
),
).then((val) {
if (val == true) _fetchFactors();
});
},
),
const Divider(height: 1),
Expanded(
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: RefreshIndicator(
onRefresh: _fetchFactors,
child: ListView.builder(
itemCount: _factors?.length ?? 0,
itemBuilder: (context, idx) {
final ele = _factors![idx];
return ListTile(
title: Text(kFactorTypes[ele.type]!.$1).tr(),
subtitle: Text(kFactorTypes[ele.type]!.$2).tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 12),
leading: Icon(kFactorTypes[ele.type]!.$3),
trailing: IconButton(
icon: const Icon(Symbols.close),
onPressed: ele.type > 0
? () {
context
.showConfirmDialog(
'authFactorDelete'.tr(),
'authFactorDeleteDescription'.tr(args: [kFactorTypes[ele.type]!.$1.tr()]),
)
.then((val) async {
if (!val) return;
try {
if (!context.mounted) return;
final sn = context.read<SnNetworkProvider>();
await sn.client.delete('/cgi/id/users/me/factors/${ele.id}');
_fetchFactors();
} catch (err) {
if (!context.mounted) return;
context.showErrorDialog(err);
}
});
}
: null,
),
);
},
),
),
),
),
],
),
);
}
}
class _FactorNewDialog extends StatefulWidget {
final List<SnAuthFactor> currentlyHave;
const _FactorNewDialog({required this.currentlyHave});
@override
State<_FactorNewDialog> createState() => _FactorNewDialogState();
}
class _FactorNewDialogState extends State<_FactorNewDialog> {
int? _factorType;
bool _isBusy = false;
Future<void> _submit() async {
try {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.post('/cgi/id/users/me/factors', data: {
'type': _factorType,
});
final factor = SnAuthFactor.fromJson(resp.data);
if (!mounted) return;
if (factor.type == 2) {
await showModalBottomSheet(
context: context,
builder: (context) => _FactorTotpFactorDialog(factor: factor),
);
}
if (!mounted) return;
Navigator.of(context).pop(true);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('authFactorAdd').tr(),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonHideUnderline(
child: DropdownButton2<int>(
hint: Text(
'Select Item',
style: TextStyle(
fontSize: 14,
),
overflow: TextOverflow.ellipsis,
),
value: _factorType,
items: kFactorTypes.entries.map(
(ele) {
final contains = widget.currentlyHave.map((ele) => ele.type).contains(ele.key);
return DropdownMenuItem<int>(
enabled: !contains,
value: ele.key,
child: Text(
ele.value.$1.tr(),
style: const TextStyle(
fontSize: 14,
),
).opacity(contains ? 0.75 : 1),
);
},
).toList(),
onChanged: (val) => setState(() {
_factorType = val;
}),
buttonStyleData: ButtonStyleData(
height: 50,
padding: const EdgeInsets.only(left: 14, right: 14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: Theme.of(context).dividerColor,
),
),
),
),
),
],
),
actions: [
TextButton(
onPressed: _isBusy ? null : () => Navigator.of(context).pop(),
child: Text('dialogCancel').tr(),
),
TextButton(
onPressed: _isBusy ? null : () => _submit(),
child: Text('dialogConfirm').tr(),
),
],
);
}
}
class _FactorTotpFactorDialog extends StatelessWidget {
final SnAuthFactor factor;
const _FactorTotpFactorDialog({required this.factor});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Center(
child: Text(
'totpPostSetup',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge,
).tr().width(280),
),
const Gap(4),
Center(
child: Text(
'totpPostSetupDescription',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
).tr().width(280),
),
const Gap(16),
QrImageView(
padding: EdgeInsets.zero,
data: factor.config!['url'],
errorCorrectionLevel: QrErrorCorrectLevel.H,
version: QrVersions.auto,
size: 160,
gapless: true,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.circle,
color: Theme.of(context).colorScheme.onSurface,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: Theme.of(context).colorScheme.onSurface,
),
),
const Gap(16),
Center(
child: Text(
'totpNeverShare',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
).tr().bold().width(280),
),
],
),
);
}
}

View File

@ -18,6 +18,7 @@ import 'package:surface/providers/userinfo.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart'; import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
class ProfileEditScreen extends StatefulWidget { class ProfileEditScreen extends StatefulWidget {
@ -81,8 +82,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
onDateTimeChanged: (DateTime newDate) { onDateTimeChanged: (DateTime newDate) {
setState(() { setState(() {
_birthday = newDate; _birthday = newDate;
_birthdayController.text = _birthdayController.text = DateFormat(_kDateFormat).format(_birthday!);
DateFormat(_kDateFormat).format(_birthday!);
}); });
}, },
), ),
@ -96,11 +96,9 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
if (image == null) return; if (image == null) return;
if (!mounted) return; if (!mounted) return;
final ImageProvider imageProvider = final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path)); final aspectRatios =
final aspectRatios = place == 'banner' place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
? [CropAspectRatio(width: 16, height: 7)]
: [CropAspectRatio(width: 1, height: 1)];
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper( ? await showCupertinoImageCropper(
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
@ -122,10 +120,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final rawBytes = final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
(await result.uiImage.toByteData(format: ImageByteFormat.png))!
.buffer
.asUint8List();
try { try {
final attachment = await attach.directUploadOne( final attachment = await attach.directUploadOne(
@ -212,136 +207,141 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
return SingleChildScrollView( return AppScaffold(
child: Column( appBar: AppBar(
crossAxisAlignment: CrossAxisAlignment.start, leading: const PageBackButton(),
children: [ title: Text('screenAccountProfileEdit').tr(),
LoadingIndicator(isActive: _isBusy), ),
const Gap(24), body: SingleChildScrollView(
Stack( child: Column(
clipBehavior: Clip.none, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Material( LoadingIndicator(isActive: _isBusy),
elevation: 0, const Gap(24),
child: InkWell( Stack(
child: ClipRRect( clipBehavior: Clip.none,
borderRadius: const BorderRadius.all(Radius.circular(8)), children: [
child: AspectRatio( Material(
aspectRatio: 16 / 9, elevation: 0,
child: Container( child: InkWell(
color: child: ClipRRect(
Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: const BorderRadius.all(Radius.circular(8)),
child: _banner != null child: AspectRatio(
? AutoResizeUniversalImage( aspectRatio: 16 / 9,
sn.getAttachmentUrl(_banner!), child: Container(
fit: BoxFit.cover, color: Theme.of(context).colorScheme.surfaceContainerHigh,
) child: _banner != null
: const SizedBox.shrink(), ? AutoResizeUniversalImage(
sn.getAttachmentUrl(_banner!),
fit: BoxFit.cover,
)
: const SizedBox.shrink(),
),
), ),
), ),
),
onTap: () {
_updateImage('banner');
},
),
),
Positioned(
bottom: -28,
left: 16,
child: Material(
elevation: 2,
borderRadius: const BorderRadius.all(Radius.circular(40)),
child: InkWell(
child: AccountImage(content: _avatar, radius: 40),
onTap: () { onTap: () {
_updateImage('avatar'); _updateImage('banner');
}, },
), ),
), ),
), Positioned(
], bottom: -28,
).padding(horizontal: padding), left: 16,
const Gap(8 + 28), child: Material(
Column( elevation: 2,
children: [ borderRadius: const BorderRadius.all(Radius.circular(40)),
TextField( child: InkWell(
readOnly: true, child: AccountImage(content: _avatar, radius: 40),
controller: _usernameController, onTap: () {
decoration: InputDecoration( _updateImage('avatar');
border: const UnderlineInputBorder(), },
labelText: 'fieldUsername'.tr(),
helperText: 'fieldUsernameCannotEditHint'.tr(),
),
),
const Gap(4),
TextField(
controller: _nicknameController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldNickname'.tr(),
),
),
const Gap(4),
Row(
children: [
Flexible(
flex: 1,
child: TextField(
controller: _firstNameController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldFirstName'.tr(),
),
), ),
), ),
const Gap(8), ),
Flexible( ],
flex: 1, ).padding(horizontal: padding),
child: TextField( const Gap(8 + 28),
controller: _lastNameController, Column(
decoration: InputDecoration( children: [
border: const UnderlineInputBorder(), TextField(
labelText: 'fieldLastName'.tr(), readOnly: true,
controller: _usernameController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldUsername'.tr(),
helperText: 'fieldUsernameCannotEditHint'.tr(),
),
),
const Gap(4),
TextField(
controller: _nicknameController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldNickname'.tr(),
),
),
const Gap(4),
Row(
children: [
Flexible(
flex: 1,
child: TextField(
controller: _firstNameController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldFirstName'.tr(),
),
), ),
), ),
const Gap(8),
Flexible(
flex: 1,
child: TextField(
controller: _lastNameController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldLastName'.tr(),
),
),
),
],
),
const Gap(4),
TextField(
controller: _descriptionController,
keyboardType: TextInputType.multiline,
maxLines: null,
minLines: 3,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldDescription'.tr(),
), ),
],
),
const Gap(4),
TextField(
controller: _descriptionController,
keyboardType: TextInputType.multiline,
maxLines: null,
minLines: 3,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldDescription'.tr(),
), ),
), const Gap(4),
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(), ],
), ).padding(horizontal: padding + 8),
], const Gap(12),
).padding(horizontal: padding + 8), Row(
const Gap(12), mainAxisAlignment: MainAxisAlignment.end,
Row( children: [
mainAxisAlignment: MainAxisAlignment.end, ElevatedButton.icon(
children: [ onPressed: _isBusy ? null : _updateUserInfo,
ElevatedButton.icon( icon: const Icon(Symbols.save),
onPressed: _isBusy ? null : _updateUserInfo, label: Text('apply').tr(),
icon: const Icon(Symbols.save), ),
label: Text('apply').tr(), ],
), ).padding(horizontal: padding),
], ],
).padding(horizontal: padding), ),
],
), ),
); );
} }

View File

@ -241,6 +241,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
return Scaffold( return Scaffold(
backgroundColor: Colors.transparent,
body: CustomScrollView( body: CustomScrollView(
controller: _scrollController, controller: _scrollController,
slivers: [ slivers: [
@ -594,7 +595,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
subtitle: Text('@${ele.name}'), subtitle: Text('@${ele.name}'),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
onTap: () { onTap: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).goNamed(
'postPublisher', 'postPublisher',
pathParameters: {'name': ele.name}, pathParameters: {'name': ele.name},
); );

View File

@ -18,6 +18,7 @@ import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart'; import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
class AccountPublisherEditScreen extends StatefulWidget { class AccountPublisherEditScreen extends StatefulWidget {
@ -176,7 +177,11 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
return Scaffold( return AppScaffold(
appBar: AppBar(
leading: PageBackButton(),
title: Text('screenAccountPublisherEdit').tr(),
),
body: SingleChildScrollView( body: SingleChildScrollView(
child: Column( child: Column(
children: [ children: [

View File

@ -10,6 +10,7 @@ import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/realm.dart'; import 'package:surface/types/realm.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class AccountPublisherNewScreen extends StatefulWidget { class AccountPublisherNewScreen extends StatefulWidget {
const AccountPublisherNewScreen({super.key}); const AccountPublisherNewScreen({super.key});
@ -24,7 +25,11 @@ class _AccountPublisherNewScreenState extends State<AccountPublisherNewScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return AppScaffold(
appBar: AppBar(
leading: const PageBackButton(),
title: Text('screenAccountPublisherNew').tr(),
),
body: SingleChildScrollView( body: SingleChildScrollView(
child: Column( child: Column(
children: [ children: [

View File

@ -10,6 +10,7 @@ import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart'; import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class PublisherScreen extends StatefulWidget { class PublisherScreen extends StatefulWidget {
const PublisherScreen({super.key}); const PublisherScreen({super.key});
@ -32,8 +33,7 @@ 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( final List<SnPublisher> out = List<SnPublisher>.from(resp.data?.map((e) => SnPublisher.fromJson(e)) ?? []);
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? []);
if (!mounted) return; if (!mounted) return;
@ -45,6 +45,33 @@ class _PublisherScreenState extends State<PublisherScreen> {
} }
} }
Future<void> _deletePublisher(SnPublisher publisher) async {
final confirm = await context.showConfirmDialog(
'publisherDelete'.tr(args: ['#${publisher.name}']),
'publisherDeleteDescription'.tr(),
);
if (!confirm) return;
if (!mounted) return;
setState(() => _isBusy = true);
try {
await context
.read<SnNetworkProvider>()
.client
.delete('/cgi/co/publishers/${publisher.name}');
if (!mounted) return;
context.showSnackbar('publisherDeleted'.tr(args: ['#${publisher.name}']));
_publishers.remove(publisher);
_fetchPublishers();
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -53,7 +80,11 @@ class _PublisherScreenState extends State<PublisherScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return AppScaffold(
appBar: AppBar(
leading: const PageBackButton(),
title: Text('screenAccountPublishers').tr(),
),
body: Column( body: Column(
children: [ children: [
ListTile( ListTile(
@ -62,9 +93,7 @@ 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) GoRouter.of(context).pushNamed('accountPublisherNew').then((value) {
.pushNamed('accountPublisherNew')
.then((value) {
if (value == true) { if (value == true) {
_publishers.clear(); _publishers.clear();
_fetchPublishers(); _fetchPublishers();
@ -75,48 +104,64 @@ class _PublisherScreenState extends State<PublisherScreen> {
const Divider(height: 1), const Divider(height: 1),
LoadingIndicator(isActive: _isBusy), LoadingIndicator(isActive: _isBusy),
Expanded( Expanded(
child: RefreshIndicator( child: MediaQuery.removePadding(
onRefresh: () { context: context,
_publishers.clear(); removeTop: true,
return _fetchPublishers(); child: RefreshIndicator(
}, onRefresh: () {
child: ListView.builder( _publishers.clear();
itemCount: _publishers.length, return _fetchPublishers();
itemBuilder: (context, idx) {
final publisher = _publishers[idx];
return ListTile(
title: Text(publisher.nick),
subtitle: Text('@${publisher.name}'),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
leading: AccountImage(content: publisher.avatar),
trailing: PopupMenuButton(
itemBuilder: (BuildContext context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.edit),
const Gap(16),
Text('edit').tr(),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'accountPublisherEdit',
pathParameters: {
'name': publisher.name,
},
).then((value) {
if (value == true) {
_publishers.clear();
_fetchPublishers();
}
});
},
),
],
),
);
}, },
child: ListView.builder(
itemCount: _publishers.length,
itemBuilder: (context, idx) {
final publisher = _publishers[idx];
return ListTile(
title: Text(publisher.nick),
subtitle: Text('@${publisher.name}'),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
leading: AccountImage(content: publisher.avatar),
trailing: PopupMenuButton(
itemBuilder: (BuildContext context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.edit),
const Gap(16),
Text('edit').tr(),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'accountPublisherEdit',
pathParameters: {
'name': publisher.name,
},
).then((value) {
if (value == true) {
_publishers.clear();
_fetchPublishers();
}
});
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.delete),
const Gap(16),
Text('delete').tr(),
],
),
onTap: () {
_deletePublisher(publisher);
},
),
],
),
);
},
),
), ),
), ),
), ),

View File

@ -11,6 +11,7 @@ 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';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class AlbumScreen extends StatefulWidget { class AlbumScreen extends StatefulWidget {
@ -82,7 +83,7 @@ class _AlbumScreenState extends State<AlbumScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return AppScaffold(
body: CustomScrollView( body: CustomScrollView(
controller: _scrollController, controller: _scrollController,
slivers: [ slivers: [
@ -122,8 +123,10 @@ class _AlbumScreenState extends State<AlbumScreen> {
), ),
if (_isBusy) if (_isBusy)
SliverToBoxAdapter( SliverToBoxAdapter(
child: child: Padding(
const CircularProgressIndicator().padding(all: 24).center(), padding: const EdgeInsets.all(24),
child: const CircularProgressIndicator(),
).center(),
), ),
], ],
), ),

View File

@ -7,17 +7,14 @@ 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/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/screens/account/factor_settings.dart';
import 'package:surface/types/auth.dart'; import 'package:surface/types/auth.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.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';
import '../../providers/websocket.dart'; import '../../providers/websocket.dart';
final Map<int, (String label, IconData icon, bool isOtp)> _factorLabelMap = {
0: ('authFactorPassword'.tr(), Symbols.password, false),
1: ('authFactorEmail'.tr(), Symbols.email, true),
};
class LoginScreen extends StatefulWidget { class LoginScreen extends StatefulWidget {
const LoginScreen({super.key}); const LoginScreen({super.key});
@ -35,67 +32,73 @@ class _LoginScreenState extends State<LoginScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Theme( return AppScaffold(
data: Theme.of(context).copyWith(canvasColor: Colors.transparent), appBar: AppBar(
child: SingleChildScrollView( leading: const PageBackButton(),
child: PageTransitionSwitcher( title: Text('screenAuthLogin').tr(),
transitionBuilder: ( ),
Widget child, body: Theme(
Animation<double> primaryAnimation, data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
Animation<double> secondaryAnimation, child: SingleChildScrollView(
) { child: PageTransitionSwitcher(
return SharedAxisTransition( transitionBuilder: (
animation: primaryAnimation, Widget child,
secondaryAnimation: secondaryAnimation, Animation<double> primaryAnimation,
transitionType: SharedAxisTransitionType.horizontal, Animation<double> secondaryAnimation,
child: Container( ) {
constraints: BoxConstraints(maxWidth: 380), return SharedAxisTransition(
child: child, animation: primaryAnimation,
), secondaryAnimation: secondaryAnimation,
); transitionType: SharedAxisTransitionType.horizontal,
}, child: Container(
child: switch (_period % 3) { constraints: BoxConstraints(maxWidth: 380),
1 => _LoginPickerScreen( child: child,
key: const ValueKey(1), ),
ticket: _currentTicket, );
factors: _factors, },
onTicket: (p0) => setState(() { child: switch (_period % 3) {
_currentTicket = p0; 1 => _LoginPickerScreen(
}), key: const ValueKey(1),
onPickFactor: (p0) => setState(() { ticket: _currentTicket,
_factorPicked = p0; factors: _factors,
}), onTicket: (p0) => setState(() {
onNext: () => setState(() { _currentTicket = p0;
_period++; }),
}), onPickFactor: (p0) => setState(() {
), _factorPicked = p0;
2 => _LoginCheckScreen( }),
key: const ValueKey(2), onNext: () => setState(() {
ticket: _currentTicket, _period++;
factor: _factorPicked, }),
onTicket: (p0) => setState(() { ),
_currentTicket = p0; 2 => _LoginCheckScreen(
}), key: const ValueKey(2),
onNext: () => setState(() { ticket: _currentTicket,
_period = 1; factor: _factorPicked,
}), onTicket: (p0) => setState(() {
), _currentTicket = p0;
_ => _LoginLookupScreen( }),
key: const ValueKey(0), onNext: () => setState(() {
ticket: _currentTicket, _period = 1;
onTicket: (p0) => setState(() { }),
_currentTicket = p0; ),
}), _ => _LoginLookupScreen(
onFactor: (p0) => setState(() { key: const ValueKey(0),
_factors = p0; ticket: _currentTicket,
}), onTicket: (p0) => setState(() {
onNext: () => setState(() { _currentTicket = p0;
_period++; }),
}), onFactor: (p0) => setState(() {
), _factors = p0;
}, }),
).padding(all: 24), onNext: () => setState(() {
).center(), _period++;
}),
),
},
).padding(all: 24),
).center(),
),
); );
} }
} }
@ -205,7 +208,9 @@ class _LoginCheckScreenState extends State<_LoginCheckScreen> {
controller: _passwordController, controller: _passwordController,
obscureText: true, obscureText: true,
autofillHints: [ autofillHints: [
(_factorLabelMap[widget.factor!.type]?.$3 ?? true) ? AutofillHints.password : AutofillHints.oneTimeCode widget.factor!.type == 0
? AutofillHints.password
: AutofillHints.oneTimeCode
], ],
decoration: InputDecoration( decoration: InputDecoration(
isDense: true, isDense: true,
@ -260,7 +265,8 @@ class _LoginPickerScreenState extends State<_LoginPickerScreen> {
bool _isBusy = false; bool _isBusy = false;
int? _factorPicked; int? _factorPicked;
Color get _unFocusColor => Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round()); Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round());
void _performGetFactorCode() async { void _performGetFactorCode() async {
if (_factorPicked == null) return; if (_factorPicked == null) return;
@ -321,11 +327,11 @@ class _LoginPickerScreenState extends State<_LoginPickerScreen> {
), ),
), ),
secondary: Icon( secondary: Icon(
_factorLabelMap[x.type]?.$2 ?? Symbols.question_mark, kFactorTypes[x.type]?.$3 ?? Symbols.question_mark,
), ),
title: Text( title: Text(
_factorLabelMap[x.type]?.$1 ?? 'unknown'.tr(), kFactorTypes[x.type]?.$1 ?? 'unknown',
), ).tr(),
enabled: !widget.ticket!.factorTrail.contains(x.id), enabled: !widget.ticket!.factorTrail.contains(x.id),
value: _factorPicked == x.id, value: _factorPicked == x.id,
onChanged: (value) { onChanged: (value) {
@ -401,11 +407,14 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final lookupResp = await sn.client.get('/cgi/id/users/lookup?probe=$username'); final lookupResp =
await sn.client.get('/cgi/id/users/lookup?probe=$username');
await sn.client.post('/cgi/id/users/me/password-reset', data: { await sn.client.post('/cgi/id/users/me/password-reset', data: {
'user_id': lookupResp.data['id'], 'user_id': lookupResp.data['id'],
}); });
if (mounted) context.showModalDialog('done'.tr(), 'signinResetPasswordSent'.tr()); if (mounted) {
context.showModalDialog('done'.tr(), 'signinResetPasswordSent'.tr());
}
} catch (err) { } catch (err) {
if (mounted) context.showErrorDialog(err); if (mounted) context.showErrorDialog(err);
} finally { } finally {
@ -430,7 +439,8 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
widget.onTicket(result.ticket); widget.onTicket(result.ticket);
// Pull factors // Pull factors
final factorResp = await sn.client.get('/cgi/id/auth/factors', queryParameters: { final factorResp =
await sn.client.get('/cgi/id/auth/factors', queryParameters: {
'ticketId': result.ticket!.id.toString(), 'ticketId': result.ticket!.id.toString(),
}); });
widget.onFactor( widget.onFactor(
@ -441,7 +451,7 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
widget.onNext(); widget.onNext();
} catch (err) { } catch (err) {
if(mounted) context.showErrorDialog(err); if (mounted) context.showErrorDialog(err);
return; return;
} finally { } finally {
setState(() => _isBusy = false); setState(() => _isBusy = false);
@ -524,7 +534,10 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
'termAcceptNextWithAgree'.tr(), 'termAcceptNextWithAgree'.tr(),
textAlign: TextAlign.end, textAlign: TextAlign.end,
style: Theme.of(context).textTheme.bodySmall!.copyWith( style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round()), color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha((255 * 0.75).round()),
), ),
), ),
Material( Material(

View File

@ -8,6 +8,7 @@ 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/widgets/dialog.dart'; import 'package:surface/widgets/dialog.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';
class RegisterScreen extends StatefulWidget { class RegisterScreen extends StatefulWidget {
@ -43,6 +44,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
'nick': nickname, 'nick': nickname,
'email': email, 'email': email,
'password': password, 'password': password,
'language': EasyLocalization.of(context)!.currentLocale.toString(),
}); });
if (!context.mounted) return; if (!context.mounted) return;
@ -54,175 +56,178 @@ class _RegisterScreenState extends State<RegisterScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return StyledWidget(Container( return AppScaffold(
constraints: const BoxConstraints(maxWidth: 380), appBar: AppBar(
child: SingleChildScrollView( leading: const PageBackButton(),
child: Column( title: Text('screenAuthRegister').tr(),
crossAxisAlignment: CrossAxisAlignment.start, ),
children: [ body: StyledWidget(Container(
Align( constraints: const BoxConstraints(maxWidth: 380),
alignment: Alignment.centerLeft, child: SingleChildScrollView(
child: CircleAvatar( child: Column(
radius: 26, crossAxisAlignment: CrossAxisAlignment.start,
child: const Icon( children: [
Symbols.person_add, Align(
size: 28, alignment: Alignment.centerLeft,
), child: CircleAvatar(
).padding(bottom: 8), radius: 26,
), child: const Icon(
Text( Symbols.person_add,
'screenAuthRegister', size: 28,
style: const TextStyle( ),
fontSize: 28, ).padding(bottom: 8),
fontWeight: FontWeight.w900,
), ),
).tr().padding(left: 4, bottom: 16), Text(
Form( 'screenAuthRegister',
key: _formKey, style: const TextStyle(
autovalidateMode: AutovalidateMode.onUserInteraction, fontSize: 28,
child: Column( fontWeight: FontWeight.w900,
children: [ ),
TextFormField( ).tr().padding(left: 4, bottom: 16),
validator: (value) { Form(
if (value == null || value.length < 4 || value.length > 32) { key: _formKey,
return 'fieldUsernameLengthLimit'.tr(args: [4.toString(), 32.toString()]); autovalidateMode: AutovalidateMode.onUserInteraction,
} child: Column(
if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value)) { children: [
return 'fieldUsernameAlphanumOnly'.tr(); TextFormField(
} validator: (value) {
return null; if (value == null || value.length < 4 || value.length > 32) {
}, return 'fieldUsernameLengthLimit'.tr(args: [4.toString(), 32.toString()]);
autocorrect: false, }
enableSuggestions: false, if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value)) {
controller: _usernameController, return 'fieldUsernameAlphanumOnly'.tr();
autofillHints: const [AutofillHints.username], }
decoration: InputDecoration( return null;
isDense: true, },
border: const UnderlineInputBorder(), autocorrect: false,
labelText: 'fieldUsername'.tr(), enableSuggestions: false,
), controller: _usernameController,
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), autofillHints: const [AutofillHints.username],
), decoration: InputDecoration(
const Gap(12), isDense: true,
TextFormField( border: const UnderlineInputBorder(),
validator: (value) { labelText: 'fieldUsername'.tr(),
if (value == null || value.length < 4 || value.length > 32) {
return 'fieldNicknameLengthLimit'.tr(args: [4.toString(), 32.toString()]);
}
return null;
},
autocorrect: false,
enableSuggestions: false,
controller: _nicknameController,
autofillHints: const [AutofillHints.nickname],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'fieldNickname'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextFormField(
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
if (!EmailValidator.validate(value)) {
return 'fieldEmailAddressMustBeValid'.tr();
}
return null;
},
autocorrect: false,
enableSuggestions: false,
controller: _emailController,
autofillHints: const [AutofillHints.email],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'fieldEmail'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextFormField(
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
return null;
},
obscureText: true,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.password],
controller: _passwordController,
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'fieldPassword'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
],
).padding(horizontal: 7),
),
const Gap(16),
Align(
alignment: Alignment.centerRight,
child: StyledWidget(
Container(
constraints: const BoxConstraints(maxWidth: 290),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'termAcceptNextWithAgree'.tr(),
textAlign: TextAlign.end,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha((255 * 0.75).round()),
),
), ),
Material( onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
color: Colors.transparent, ),
child: InkWell( const Gap(12),
child: Row( TextFormField(
mainAxisSize: MainAxisSize.min, validator: (value) {
children: [ if (value == null || value.length < 4 || value.length > 32) {
Text('termAcceptLink'.tr()), return 'fieldNicknameLengthLimit'.tr(args: [4.toString(), 32.toString()]);
const Gap(4), }
const Icon(Symbols.launch, size: 14), return null;
], },
autocorrect: false,
enableSuggestions: false,
controller: _nicknameController,
autofillHints: const [AutofillHints.nickname],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'fieldNickname'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextFormField(
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
if (!EmailValidator.validate(value)) {
return 'fieldEmailAddressMustBeValid'.tr();
}
return null;
},
autocorrect: false,
enableSuggestions: false,
controller: _emailController,
autofillHints: const [AutofillHints.email],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'fieldEmail'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextFormField(
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
return null;
},
obscureText: true,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.password],
controller: _passwordController,
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'fieldPassword'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
],
).padding(horizontal: 7),
),
const Gap(16),
Align(
alignment: Alignment.centerRight,
child: StyledWidget(
Container(
constraints: const BoxConstraints(maxWidth: 290),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'termAcceptNextWithAgree'.tr(),
textAlign: TextAlign.end,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round()),
),
),
Material(
color: Colors.transparent,
child: InkWell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('termAcceptLink'.tr()),
const Gap(4),
const Icon(Symbols.launch, size: 14),
],
),
onTap: () {
launchUrlString('https://solsynth.dev/terms');
},
), ),
onTap: () {
launchUrlString('https://solsynth.dev/terms');
},
), ),
), ],
),
),
).padding(horizontal: 16),
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => _performAction(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next').tr(),
const Icon(Symbols.chevron_right),
], ],
), ),
), ),
).padding(horizontal: 16),
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => _performAction(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next').tr(),
const Icon(Symbols.chevron_right),
],
),
), ),
), ],
], ),
), ),
), )).padding(all: 24).center(),
)).padding(all: 24).center(); );
} }
} }

View File

@ -13,6 +13,7 @@ import 'package:surface/widgets/account/account_select.dart';
import 'package:surface/widgets/app_bar_leading.dart'; import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart'; import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/unauthorized_hint.dart'; import 'package:surface/widgets/unauthorized_hint.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
@ -120,7 +121,7 @@ class _ChatScreenState extends State<ChatScreen> {
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
if (!ua.isAuthorized) { if (!ua.isAuthorized) {
return Scaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
leading: AutoAppBarLeading(), leading: AutoAppBarLeading(),
title: Text('screenChat').tr(), title: Text('screenChat').tr(),
@ -131,7 +132,7 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
return Scaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
leading: AutoAppBarLeading(), leading: AutoAppBarLeading(),
title: Text('screenChat').tr(), title: Text('screenChat').tr(),
@ -195,22 +196,58 @@ class _ChatScreenState extends State<ChatScreen> {
children: [ children: [
LoadingIndicator(isActive: _isBusy), LoadingIndicator(isActive: _isBusy),
Expanded( Expanded(
child: RefreshIndicator( child: MediaQuery.removePadding(
onRefresh: () => Future.sync(() => _refreshChannels()), context: context,
child: ListView.builder( removeTop: true,
itemCount: _channels?.length ?? 0, child: RefreshIndicator(
itemBuilder: (context, idx) { onRefresh: () => Future.sync(() => _refreshChannels()),
final channel = _channels![idx]; child: ListView.builder(
final lastMessage = _lastMessages?[channel.id]; itemCount: _channels?.length ?? 0,
itemBuilder: (context, idx) {
final channel = _channels![idx];
final lastMessage = _lastMessages?[channel.id];
if (channel.type == 1) { if (channel.type == 1) {
final otherMember = channel.members?.cast<SnChannelMember?>().firstWhere( final otherMember = channel.members?.cast<SnChannelMember?>().firstWhere(
(ele) => ele?.accountId != ua.user?.id, (ele) => ele?.accountId != ua.user?.id,
orElse: () => null, orElse: () => null,
); );
return ListTile(
title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name),
subtitle: lastMessage != null
? Text(
'${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: Text(
'channelDirectMessageDescription'.tr(args: [
'@${ud.getAccountFromCache(otherMember?.accountId)?.name}',
]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
leading: AccountImage(
content: ud.getAccountFromCache(otherMember?.accountId)?.avatar,
),
onTap: () {
GoRouter.of(context).pushNamed(
'chatRoom',
pathParameters: {
'scope': channel.realm?.alias ?? 'global',
'alias': channel.alias,
},
).then((value) {
if (mounted) _refreshChannels();
});
},
);
}
return ListTile( return ListTile(
title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name), title: Text(channel.name),
subtitle: lastMessage != null subtitle: lastMessage != null
? Text( ? Text(
'${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}', '${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
@ -218,15 +255,14 @@ class _ChatScreenState extends State<ChatScreen> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
) )
: Text( : Text(
'channelDirectMessageDescription'.tr(args: [ channel.description,
'@${ud.getAccountFromCache(otherMember?.accountId)?.name}',
]),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 16), contentPadding: const EdgeInsets.symmetric(horizontal: 16),
leading: AccountImage( leading: AccountImage(
content: ud.getAccountFromCache(otherMember?.accountId)?.avatar, content: null,
fallbackWidget: const Icon(Symbols.chat, size: 20),
), ),
onTap: () { onTap: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
@ -236,43 +272,12 @@ class _ChatScreenState extends State<ChatScreen> {
'alias': channel.alias, 'alias': channel.alias,
}, },
).then((value) { ).then((value) {
if (mounted) _refreshChannels(); if (value == true) _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: () {
GoRouter.of(context).pushNamed(
'chatRoom',
pathParameters: {
'scope': channel.realm?.alias ?? 'global',
'alias': channel.alias,
},
).then((value) {
if (value == true) _refreshChannels();
});
},
);
},
), ),
), ),
), ),

View File

@ -9,10 +9,12 @@ import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/chat_call.dart'; import 'package:surface/providers/chat_call.dart';
import 'package:surface/widgets/chat/call/call_controls.dart'; import 'package:surface/widgets/chat/call/call_controls.dart';
import 'package:surface/widgets/chat/call/call_participant.dart'; import 'package:surface/widgets/chat/call/call_participant.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class CallRoomScreen extends StatefulWidget { class CallRoomScreen extends StatefulWidget {
final String scope; final String scope;
final String alias; final String alias;
const CallRoomScreen({super.key, required this.scope, required this.alias}); const CallRoomScreen({super.key, required this.scope, required this.alias});
@override @override
@ -35,8 +37,7 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
return Stack( return Stack(
children: [ children: [
Container( Container(
color: color: Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.75),
Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.75),
child: call.focusTrack != null child: call.focusTrack != null
? InteractiveParticipantWidget( ? InteractiveParticipantWidget(
isFixedAvatar: false, isFixedAvatar: false,
@ -71,8 +72,7 @@ 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 != if (track.participant.sid != call.focusTrack?.participant.sid) {
call.focusTrack?.participant.sid) {
call.setFocusTrack(track); call.setFocusTrack(track);
} }
}, },
@ -114,14 +114,10 @@ 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) color: Theme.of(context).colorScheme.surfaceContainerHigh.withOpacity(0.75),
.colorScheme
.surfaceContainerHigh
.withOpacity(0.75),
participant: track, participant: track,
onTap: () { onTap: () {
if (track.participant.sid != if (track.participant.sid != call.focusTrack?.participant.sid) {
call.focusTrack?.participant.sid) {
call.setFocusTrack(track); call.setFocusTrack(track);
} }
}, },
@ -152,157 +148,138 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
return ListenableBuilder( return ListenableBuilder(
listenable: call, listenable: call,
builder: (context, _) { builder: (context, _) {
return Scaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
title: RichText( title: RichText(
textAlign: TextAlign.center, textAlign: TextAlign.center,
text: TextSpan(children: [ text: TextSpan(children: [
TextSpan( TextSpan(
text: 'call'.tr(), text: 'call'.tr(),
style: Theme.of(context) style: Theme.of(context).textTheme.titleLarge!.copyWith(
.textTheme color: Theme.of(context).appBarTheme.foregroundColor,
.titleLarge! ),
.copyWith(color: Colors.white),
), ),
const TextSpan(text: '\n'), const TextSpan(text: '\n'),
TextSpan( TextSpan(
text: call.lastDuration.toString(), text: call.lastDuration.toString(),
style: Theme.of(context) style: Theme.of(context).textTheme.bodySmall!.copyWith(
.textTheme color: Theme.of(context).appBarTheme.foregroundColor,
.bodySmall! ),
.copyWith(color: Colors.white),
), ),
]), ]),
), ),
), ),
body: SafeArea( body: GestureDetector(
child: GestureDetector( behavior: HitTestBehavior.translucent,
behavior: HitTestBehavior.translucent, child: Column(
child: Column( children: [
children: [ SizedBox(
width: MediaQuery.of(context).size.width,
height: 64,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Builder(builder: (context) {
final call = context.read<ChatCallProvider>();
final connectionQuality =
call.room.localParticipant?.connectionQuality ?? livekit.ConnectionQuality.unknown;
return Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
call.channel?.name ?? 'unknown'.tr(),
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
const Gap(6),
Text(call.lastDuration.toString())
],
),
Row(
children: [
Text(
{
livekit.ConnectionState.disconnected: 'callStatusDisconnected'.tr(),
livekit.ConnectionState.connected: 'callStatusConnected'.tr(),
livekit.ConnectionState.connecting: 'callStatusConnecting'.tr(),
livekit.ConnectionState.reconnecting: 'callStatusReconnecting'.tr(),
}[call.room.connectionState]!,
),
const Gap(6),
if (connectionQuality != livekit.ConnectionQuality.unknown)
Icon(
{
livekit.ConnectionQuality.excellent: Icons.signal_cellular_alt,
livekit.ConnectionQuality.good: Icons.signal_cellular_alt_2_bar,
livekit.ConnectionQuality.poor: Icons.signal_cellular_alt_1_bar,
}[connectionQuality],
color: {
livekit.ConnectionQuality.excellent: Colors.green,
livekit.ConnectionQuality.good: Colors.orange,
livekit.ConnectionQuality.poor: Colors.red,
}[connectionQuality],
size: 16,
)
else
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
).padding(all: 3),
],
),
],
),
);
}),
Row(
children: [
IconButton(
icon: _layoutMode == 0 ? const Icon(Icons.view_list) : const Icon(Icons.grid_view),
onPressed: () {
_switchLayout();
},
),
],
),
],
).padding(left: 20, right: 16),
),
Expanded(
child: Material(
color: Theme.of(context).colorScheme.surfaceContainerLow,
child: Builder(
builder: (context) {
switch (_layoutMode) {
case 1:
return _buildGridLayout();
default:
return _buildListLayout();
}
},
),
),
),
if (call.room.localParticipant != null)
SizedBox( SizedBox(
width: MediaQuery.of(context).size.width, width: MediaQuery.of(context).size.width,
height: 64, child: ControlsWidget(
child: Row( call.room,
mainAxisAlignment: MainAxisAlignment.spaceBetween, call.room.localParticipant!,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Builder(builder: (context) {
final call = context.read<ChatCallProvider>();
final connectionQuality =
call.room.localParticipant?.connectionQuality ??
livekit.ConnectionQuality.unknown;
return Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
call.channel?.name ?? 'unknown'.tr(),
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
const Gap(6),
Text(call.lastDuration.toString())
],
),
Row(
children: [
Text(
{
livekit.ConnectionState.disconnected:
'callStatusDisconnected'.tr(),
livekit.ConnectionState.connected:
'callStatusConnected'.tr(),
livekit.ConnectionState.connecting:
'callStatusConnecting'.tr(),
livekit.ConnectionState.reconnecting:
'callStatusReconnecting'.tr(),
}[call.room.connectionState]!,
),
const Gap(6),
if (connectionQuality !=
livekit.ConnectionQuality.unknown)
Icon(
{
livekit.ConnectionQuality.excellent:
Icons.signal_cellular_alt,
livekit.ConnectionQuality.good:
Icons.signal_cellular_alt_2_bar,
livekit.ConnectionQuality.poor:
Icons.signal_cellular_alt_1_bar,
}[connectionQuality],
color: {
livekit.ConnectionQuality.excellent:
Colors.green,
livekit.ConnectionQuality.good:
Colors.orange,
livekit.ConnectionQuality.poor:
Colors.red,
}[connectionQuality],
size: 16,
)
else
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
).padding(all: 3),
],
),
],
),
);
}),
Row(
children: [
IconButton(
icon: _layoutMode == 0
? const Icon(Icons.view_list)
: const Icon(Icons.grid_view),
onPressed: () {
_switchLayout();
},
),
],
),
],
).padding(left: 20, right: 16),
),
Expanded(
child: Material(
color:
Theme.of(context).colorScheme.surfaceContainerLow,
child: Builder(
builder: (context) {
switch (_layoutMode) {
case 1:
return _buildGridLayout();
default:
return _buildListLayout();
}
},
),
), ),
), ),
if (call.room.localParticipant != null) ],
SizedBox(
width: MediaQuery.of(context).size.width,
child: ControlsWidget(
call.room,
call.room.localParticipant!,
),
),
],
),
onTap: () {},
), ),
onTap: () {},
), ),
); );
}); });

View File

@ -10,15 +10,19 @@ import 'package:surface/providers/channel.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/account.dart';
import 'package:surface/types/chat.dart'; import 'package:surface/types/chat.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/account/account_select.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart'; import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class ChannelDetailScreen extends StatefulWidget { class ChannelDetailScreen extends StatefulWidget {
final String scope; final String scope;
final String alias; final String alias;
const ChannelDetailScreen({ const ChannelDetailScreen({
super.key, super.key,
required this.scope, required this.scope,
@ -54,8 +58,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client final resp = await sn.client.get('/cgi/im/channels/${_channel!.keyPath}/members/me');
.get('/cgi/im/channels/${_channel!.keyPath}/members/me');
_profile = SnChannelMember.fromJson(resp.data); _profile = SnChannelMember.fromJson(resp.data);
_notifyLevel = _profile!.notify; _notifyLevel = _profile!.notify;
if (!mounted) return; if (!mounted) return;
@ -142,6 +145,25 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
} }
} }
Future<void> _addMember(SnAccount related) async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.post(
'/cgi/im/channels/${_channel!.keyPath}/members',
data: {'related': related.name},
);
if (!mounted) return;
context.showSnackbar('channelMemberAdded'.tr());
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
void _showChannelProfileDetail() { void _showChannelProfileDetail() {
showDialog( showDialog(
context: context, context: context,
@ -165,13 +187,16 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
); );
} }
void _showMemberAdd() { void _showMemberAdd() async {
showModalBottomSheet( final user = await showModalBottomSheet<SnAccount?>(
context: context, context: context,
builder: (context) => _NewChannelMemberWidget( builder: (context) => AccountSelect(
channel: _channel!, title: 'channelMemberAdd'.tr(),
), ),
); );
if (!mounted) return;
if (user == null) return;
_addMember(user);
} }
@override @override
@ -189,7 +214,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 Scaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
title: _channel != null ? Text(_channel!.name) : Text('loading').tr(), title: _channel != null ? Text(_channel!.name) : Text('loading').tr(),
), ),
@ -220,11 +245,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('channelDetailPersonalRegion') Text('channelDetailPersonalRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
.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(
@ -263,8 +284,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
), ),
ListTile( ListTile(
leading: AccountImage( leading: AccountImage(
content: content: ud.getAccountFromCache(_profile!.accountId)?.avatar,
ud.getAccountFromCache(_profile!.accountId)?.avatar,
radius: 18, radius: 18,
), ),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
@ -283,8 +303,7 @@ 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: contentPadding: const EdgeInsets.symmetric(horizontal: 24),
const EdgeInsets.symmetric(horizontal: 24),
onTap: _leaveChannel, onTap: _leaveChannel,
), ),
], ],
@ -292,11 +311,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('channelDetailMemberRegion') Text('channelDetailMemberRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
.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),
@ -318,11 +333,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('channelDetailAdminRegion') Text('channelDetailAdminRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
.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),
@ -361,18 +372,17 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
class _ChannelProfileDetailDialog extends StatefulWidget { class _ChannelProfileDetailDialog extends StatefulWidget {
final SnChannel channel; final SnChannel channel;
final SnChannelMember current; final SnChannelMember current;
const _ChannelProfileDetailDialog({ const _ChannelProfileDetailDialog({
required this.channel, required this.channel,
required this.current, required this.current,
}); });
@override @override
State<_ChannelProfileDetailDialog> createState() => State<_ChannelProfileDetailDialog> createState() => _ChannelProfileDetailDialogState();
_ChannelProfileDetailDialogState();
} }
class _ChannelProfileDetailDialogState class _ChannelProfileDetailDialogState extends State<_ChannelProfileDetailDialog> {
extends State<_ChannelProfileDetailDialog> {
bool _isBusy = false; bool _isBusy = false;
final TextEditingController _nickController = TextEditingController(); final TextEditingController _nickController = TextEditingController();
@ -443,11 +453,11 @@ class _ChannelProfileDetailDialogState
class _ChannelMemberListWidget extends StatefulWidget { class _ChannelMemberListWidget extends StatefulWidget {
final SnChannel channel; final SnChannel channel;
const _ChannelMemberListWidget({required this.channel}); const _ChannelMemberListWidget({required this.channel});
@override @override
State<_ChannelMemberListWidget> createState() => State<_ChannelMemberListWidget> createState() => _ChannelMemberListWidgetState();
_ChannelMemberListWidgetState();
} }
class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> { class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
@ -462,12 +472,10 @@ 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( final resp = await sn.client.get('/cgi/im/channels/${widget.channel.keyPath}/members', queryParameters: {
'/cgi/im/channels/${widget.channel.keyPath}/members', 'take': 10,
queryParameters: { 'offset': _members.length,
'take': 10, });
'offset': 0,
});
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)) ?? [],
); );
@ -525,9 +533,7 @@ 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') Text('channelMemberManage').tr().textStyle(Theme.of(context).textTheme.titleLarge!),
.tr()
.textStyle(Theme.of(context).textTheme.titleLarge!),
], ],
).padding(horizontal: 20, top: 16, bottom: 12), ).padding(horizontal: 20, top: 16, bottom: 12),
Expanded( Expanded(
@ -538,8 +544,7 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
}, },
child: InfiniteList( child: InfiniteList(
itemCount: _members.length, itemCount: _members.length,
hasReachedMax: hasReachedMax: _totalCount != null && _members.length >= _totalCount!,
_totalCount != null && _members.length >= _totalCount!,
isLoading: _isBusy, isLoading: _isBusy,
onFetchData: _fetchMembers, onFetchData: _fetchMembers,
itemBuilder: (context, index) { itemBuilder: (context, index) {
@ -550,8 +555,7 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
content: ud.getAccountFromCache(member.accountId)?.avatar, content: ud.getAccountFromCache(member.accountId)?.avatar,
), ),
title: Text( title: Text(
ud.getAccountFromCache(member.accountId)?.name ?? ud.getAccountFromCache(member.accountId)?.name ?? 'unknown'.tr(),
'unknown'.tr(),
), ),
subtitle: Text(member.nick ?? 'unknown'.tr()), subtitle: Text(member.nick ?? 'unknown'.tr()),
trailing: SizedBox( trailing: SizedBox(
@ -561,8 +565,7 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
IconButton( IconButton(
onPressed: onPressed: _isUpdating ? null : () => _deleteMember(member),
_isUpdating ? null : () => _deleteMember(member),
icon: const Icon(Symbols.person_remove), icon: const Icon(Symbols.person_remove),
), ),
], ],
@ -577,83 +580,3 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
); );
} }
} }
class _NewChannelMemberWidget extends StatefulWidget {
final SnChannel channel;
const _NewChannelMemberWidget({required this.channel});
@override
State<_NewChannelMemberWidget> createState() =>
_NewChannelMemberWidgetState();
}
class _NewChannelMemberWidgetState extends State<_NewChannelMemberWidget> {
bool _isBusy = false;
final TextEditingController _relatedController = TextEditingController();
Future<void> _performAction() async {
if (_relatedController.text.isEmpty) return;
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.post(
'/cgi/im/channels/${widget.channel.keyPath}/members',
data: {
'related': _relatedController.text,
},
);
if (!mounted) return;
Navigator.pop(context, true);
context.showSnackbar('channelMemberAdded'.tr());
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void dispose() {
super.dispose();
_relatedController.dispose();
}
@override
Widget build(BuildContext context) {
return StyledWidget(Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'channelMemberAdd',
style: Theme.of(context).textTheme.titleLarge,
).tr(),
const Gap(12),
TextField(
controller: _relatedController,
readOnly: _isBusy,
autocorrect: false,
autofocus: true,
textCapitalization: TextCapitalization.none,
decoration: InputDecoration(
labelText: 'fieldMemberRelatedName'.tr(),
suffix: SizedBox(
height: 24,
child: IconButton(
onPressed: _isBusy ? null : () => _performAction(),
icon: Icon(Symbols.send),
visualDensity:
const VisualDensity(horizontal: -4, vertical: -4),
padding: EdgeInsets.zero,
),
),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
)
],
)).padding(all: 24);
}
}

View File

@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@ -12,10 +13,12 @@ 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/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:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class ChatManageScreen extends StatefulWidget { class ChatManageScreen extends StatefulWidget {
final String? editingChannelAlias; final String? editingChannelAlias;
const ChatManageScreen({super.key, this.editingChannelAlias}); const ChatManageScreen({super.key, this.editingChannelAlias});
@override @override
@ -32,6 +35,11 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
List<SnRealm>? _realms; List<SnRealm>? _realms;
SnRealm? _belongToRealm; SnRealm? _belongToRealm;
SnChannel? _editingChannel;
bool _isPublic = false;
bool _isCommunity = false;
Future<void> _fetchRealms() async { Future<void> _fetchRealms() async {
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
@ -40,6 +48,9 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
_realms = List<SnRealm>.from( _realms = List<SnRealm>.from(
resp.data?.map((e) => SnRealm.fromJson(e)) ?? [], resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
); );
if (_editingChannel != null) {
_belongToRealm = _realms?.firstWhereOrNull((e) => e.id == _editingChannel!.realmId);
}
} catch (err) { } catch (err) {
if (mounted) context.showErrorDialog(err); if (mounted) context.showErrorDialog(err);
} finally { } finally {
@ -47,8 +58,6 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
} }
} }
SnChannel? _editingChannel;
Future<void> _fetchChannel() async { Future<void> _fetchChannel() async {
setState(() => _isBusy = true); setState(() => _isBusy = true);
@ -61,6 +70,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
_aliasController.text = _editingChannel!.alias; _aliasController.text = _editingChannel!.alias;
_nameController.text = _editingChannel!.name; _nameController.text = _editingChannel!.name;
_descriptionController.text = _editingChannel!.description; _descriptionController.text = _editingChannel!.description;
_isPublic = _editingChannel!.isPublic;
_isCommunity = _editingChannel!.isCommunity;
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -82,6 +93,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
: uuid.v4().replaceAll('-', '').substring(0, 12), : uuid.v4().replaceAll('-', '').substring(0, 12),
'name': _nameController.text, 'name': _nameController.text,
'description': _descriptionController.text, 'description': _descriptionController.text,
'is_public': _isPublic,
'is_community': _isCommunity,
}; };
try { try {
@ -121,11 +134,9 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
title: widget.editingChannelAlias != null title: widget.editingChannelAlias != null ? Text('screenChatManage').tr() : Text('screenChatNew').tr(),
? Text('screenChatManage').tr()
: Text('screenChatNew').tr(),
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
child: Column( child: Column(
@ -137,8 +148,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
leadingPadding: const EdgeInsets.only(left: 10, right: 20), leadingPadding: const EdgeInsets.only(left: 10, right: 20),
dividerColor: Colors.transparent, dividerColor: Colors.transparent,
content: Text( content: Text(
'channelEditingNotice' 'channelEditingNotice'.tr(args: ['#${_editingChannel!.alias}']),
.tr(args: ['#${_editingChannel!.alias}']),
), ),
actions: [ actions: [
TextButton( TextButton(
@ -161,6 +171,7 @@ 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: [
@ -178,15 +189,12 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(item.name).textStyle(Theme.of(context) Text(item.name).textStyle(Theme.of(context).textTheme.bodyMedium!),
.textTheme
.bodyMedium!),
Text( Text(
item.description, item.description,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
).textStyle( ).textStyle(Theme.of(context).textTheme.bodySmall!),
Theme.of(context).textTheme.bodySmall!),
], ],
), ),
), ),
@ -196,14 +204,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: foregroundColor: Theme.of(context).colorScheme.onSurface,
Theme.of(context).colorScheme.onSurface,
child: const Icon(Symbols.clear), child: const Icon(Symbols.clear),
), ),
const Gap(12), const Gap(12),
@ -212,9 +220,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('fieldChatBelongToRealmUnset') Text('fieldChatBelongToRealmUnset').tr().textStyle(
.tr()
.textStyle(
Theme.of(context).textTheme.bodyMedium!, Theme.of(context).textTheme.bodyMedium!,
), ),
], ],
@ -230,10 +236,10 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
}, },
buttonStyleData: const ButtonStyleData( buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.only(right: 16), padding: EdgeInsets.only(right: 16),
height: 60, height: 48,
), ),
menuItemStyleData: const MenuItemStyleData( menuItemStyleData: const MenuItemStyleData(
height: 60, height: 48,
), ),
), ),
), ),
@ -249,8 +255,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
helperText: 'fieldChatAliasHint'.tr(), helperText: 'fieldChatAliasHint'.tr(),
helperMaxLines: 2, helperMaxLines: 2,
), ),
onTapOutside: (_) => onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(4), const Gap(4),
TextField( TextField(
@ -259,8 +264,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
border: const UnderlineInputBorder(), border: const UnderlineInputBorder(),
labelText: 'fieldChatName'.tr(), labelText: 'fieldChatName'.tr(),
), ),
onTapOutside: (_) => onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(4), const Gap(4),
TextField( TextField(
@ -271,8 +275,24 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
border: const UnderlineInputBorder(), border: const UnderlineInputBorder(),
labelText: 'fieldChatDescription'.tr(), labelText: 'fieldChatDescription'.tr(),
), ),
onTapOutside: (_) => onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
FocusManager.instance.primaryFocus?.unfocus(), ),
const Gap(12),
CheckboxListTile(
value: _isPublic,
title: Text('channelIsPublic'.tr()),
subtitle: Text('channelIsPublicDescription'.tr()),
onChanged: (value) {
setState(() => _isPublic = value ?? false);
},
),
CheckboxListTile(
value: _isCommunity,
title: Text('channelIsCommunity'.tr()),
subtitle: Text('channelIsCommunityDescription'.tr()),
onChanged: (value) {
setState(() => _isCommunity = value ?? false);
},
), ),
const Gap(12), const Gap(12),
Row( Row(

View File

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@ -9,9 +10,12 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/chat_message_controller.dart'; import 'package:surface/controllers/chat_message_controller.dart';
import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/channel.dart'; import 'package:surface/providers/channel.dart';
import 'package:surface/providers/chat_call.dart'; import 'package:surface/providers/chat_call.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/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/widgets/chat/call/call_prejoin.dart'; import 'package:surface/widgets/chat/call/call_prejoin.dart';
@ -20,16 +24,22 @@ import 'package:surface/widgets/chat/chat_message_input.dart';
import 'package:surface/widgets/chat/chat_typing_indicator.dart'; import 'package:surface/widgets/chat/chat_typing_indicator.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:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
import '../../providers/user_directory.dart'; class ChatRoomScreenExtra {
import '../../providers/userinfo.dart'; final String? initialText;
final List<PostWriteMedia>? initialAttachments;
ChatRoomScreenExtra({this.initialText, this.initialAttachments});
}
class ChatRoomScreen extends StatefulWidget { class ChatRoomScreen extends StatefulWidget {
final String scope; final String scope;
final String alias; final String alias;
final ChatRoomScreenExtra? extra;
const ChatRoomScreen({super.key, required this.scope, required this.alias}); const ChatRoomScreen({super.key, required this.scope, required this.alias, this.extra});
@override @override
State<ChatRoomScreen> createState() => _ChatRoomScreenState(); State<ChatRoomScreen> createState() => _ChatRoomScreenState();
@ -176,12 +186,27 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
_messageController = ChatMessageController(context); _messageController = ChatMessageController(context);
_fetchChannel().then((_) async { _fetchChannel().then((_) async {
await _messageController.initialize(_channel!); await _messageController.initialize(_channel!);
await _messageController.checkUpdate();
await _fetchOngoingCall(); if (widget.extra != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
log('[ChatInput] Setting initial text and attachments...');
if (widget.extra!.initialText != null) {
_inputGlobalKey.currentState?.setInitialText(widget.extra!.initialText!);
}
if (widget.extra!.initialAttachments != null) {
_inputGlobalKey.currentState?.setInitialAttachments(widget.extra!.initialAttachments!);
}
});
}
await Future.wait([
_messageController.checkUpdate(),
_fetchOngoingCall(),
]);
}); });
final ws = context.read<WebSocketProvider>(); final ws = context.read<WebSocketProvider>();
_wsSubscription = ws.stream.stream.listen((event) { _wsSubscription = ws.pk.stream.listen((event) {
switch (event.method) { switch (event.method) {
case 'calls.new': case 'calls.new':
final payload = SnChatCall.fromJson(event.payload!); final payload = SnChatCall.fromJson(event.payload!);
@ -211,7 +236,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
final call = context.watch<ChatCallProvider>(); final call = context.watch<ChatCallProvider>();
final ud = context.read<UserDirectoryProvider>(); final ud = context.read<UserDirectoryProvider>();
return Scaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(
_channel?.type == 1 _channel?.type == 1

View File

@ -11,6 +11,7 @@ import 'package:surface/providers/sn_network.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/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';
@ -93,7 +94,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return AppScaffold(
floatingActionButtonLocation: ExpandableFab.location, floatingActionButtonLocation: ExpandableFab.location,
floatingActionButton: ExpandableFab( floatingActionButton: ExpandableFab(
key: _fabKey, key: _fabKey,
@ -160,6 +161,48 @@ class _ExploreScreenState extends State<ExploreScreen> {
), ),
], ],
), ),
Row(
children: [
Text('writePostTypeQuestion').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'writePostTypeQuestion'.tr(),
onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
'mode': 'questions',
}).then((value) {
if (value == true) {
_refreshPosts();
}
});
_fabKey.currentState!.toggle();
},
child: const Icon(Symbols.question_answer),
),
],
),
Row(
children: [
Text('writePostTypeVideo').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'writePostTypeVideo'.tr(),
onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
'mode': 'videos',
}).then((value) {
if (value == true) {
_refreshPosts();
}
});
_fabKey.currentState!.toggle();
},
child: const Icon(Symbols.video_call),
),
],
),
], ],
), ),
body: RefreshIndicator( body: RefreshIndicator(
@ -210,6 +253,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
), ),
), ),
), ),
const SliverGap(12),
SliverInfiniteList( SliverInfiniteList(
itemCount: _posts.length, itemCount: _posts.length,
isLoading: _isBusy, isLoading: _isBusy,
@ -217,27 +261,18 @@ class _ExploreScreenState extends State<ExploreScreen> {
hasReachedMax: _postCount != null && _posts.length >= _postCount!, hasReachedMax: _postCount != null && _posts.length >= _postCount!,
onFetchData: _fetchPosts, onFetchData: _fetchPosts,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
return GestureDetector( return OpenablePostItem(
child: PostItem( data: _posts[idx],
data: _posts[idx], maxWidth: 640,
maxWidth: 640, onChanged: (data) {
onChanged: (data) { setState(() => _posts[idx] = data);
setState(() => _posts[idx] = data); },
}, onDeleted: () {
onDeleted: () { _refreshPosts();
_refreshPosts();
},
),
onTap: () {
GoRouter.of(context).pushNamed(
'postDetail',
pathParameters: {'slug': _posts[idx].id.toString()},
extra: _posts[idx],
);
}, },
); );
}, },
separatorBuilder: (context, index) => const Divider(height: 1), separatorBuilder: (_, __) => const Gap(8),
), ),
], ],
), ),

View File

@ -6,14 +6,15 @@ import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/relationship.dart'; import 'package:surface/providers/relationship.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/account.dart'; import 'package:surface/types/account.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/account/account_select.dart';
import 'package:surface/widgets/app_bar_leading.dart'; import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart'; import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import '../providers/userinfo.dart'; import 'package:surface/widgets/unauthorized_hint.dart';
import '../widgets/unauthorized_hint.dart';
const kFriendStatus = { const kFriendStatus = {
0: 'friendStatusPending', 0: 'friendStatusPending',
@ -167,6 +168,24 @@ class _FriendScreenState extends State<FriendScreen> {
}); });
} }
Future<void> _sendRequest(SnAccount user) async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.post('/cgi/id/users/me/relations', data: {
'related': user.name,
});
if (!mounted) return;
context.showSnackbar('friendRequestSent'.tr());
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -180,7 +199,7 @@ class _FriendScreenState extends State<FriendScreen> {
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
if (!ua.isAuthorized) { if (!ua.isAuthorized) {
return Scaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
leading: AutoAppBarLeading(), leading: AutoAppBarLeading(),
title: Text('screenFriend').tr(), title: Text('screenFriend').tr(),
@ -191,18 +210,23 @@ class _FriendScreenState extends State<FriendScreen> {
); );
} }
return Scaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
leading: AutoAppBarLeading(), leading: AutoAppBarLeading(),
title: Text('screenFriend').tr(), title: Text('screenFriend').tr(),
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
child: const Icon(Symbols.add), child: const Icon(Symbols.add),
onPressed: () { onPressed: () async {
showModalBottomSheet( final user = await showModalBottomSheet<SnAccount?>(
context: context, context: context,
builder: (context) => _NewFriendWidget(), builder: (context) => AccountSelect(
title: 'friendNew'.tr(),
),
); );
if (!mounted) return;
if (user == null) return;
_sendRequest(user);
}, },
), ),
body: Column( body: Column(
@ -230,55 +254,54 @@ class _FriendScreenState extends State<FriendScreen> {
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
onTap: _showBlocks, onTap: _showBlocks,
), ),
if (_requests.isNotEmpty || _blocks.isNotEmpty) if (_requests.isNotEmpty || _blocks.isNotEmpty) const Divider(height: 1),
const Divider(height: 1),
Expanded( Expanded(
child: RefreshIndicator( child: MediaQuery.removePadding(
onRefresh: () => Future.wait([ context: context,
_fetchRelations(), removeTop: true,
_fetchRequests(), child: RefreshIndicator(
]), onRefresh: () => Future.wait([
child: ListView.builder( _fetchRelations(),
itemCount: _relations.length, _fetchRequests(),
itemBuilder: (context, index) { ]),
final relation = _relations[index]; child: ListView.builder(
final other = relation.related; itemCount: _relations.length,
return ListTile( itemBuilder: (context, index) {
contentPadding: const EdgeInsets.only(right: 24, left: 16), final relation = _relations[index];
leading: AccountImage(content: other?.avatar), final other = relation.related;
title: Text(other?.nick ?? 'unknown'), return ListTile(
subtitle: Text(other?.nick ?? 'unknown'), contentPadding: const EdgeInsets.only(right: 24, left: 16),
trailing: SizedBox( leading: AccountImage(content: other?.avatar),
height: 48, title: Text(other?.nick ?? 'unknown'),
width: 120, subtitle: Text(other?.nick ?? 'unknown'),
child: Column( trailing: SizedBox(
mainAxisSize: MainAxisSize.min, height: 48,
mainAxisAlignment: MainAxisAlignment.center, width: 120,
crossAxisAlignment: CrossAxisAlignment.end, child: Column(
children: [ mainAxisSize: MainAxisSize.min,
Row( mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
InkWell( Row(
onTap: _isUpdating mainAxisAlignment: MainAxisAlignment.end,
? null children: [
: () => _changeRelation(relation, 2), InkWell(
child: Text('friendBlock').tr(), onTap: _isUpdating ? null : () => _changeRelation(relation, 2),
), child: Text('friendBlock').tr(),
const Gap(8), ),
InkWell( const Gap(8),
onTap: _isUpdating InkWell(
? null onTap: _isUpdating ? null : () => _deleteRelation(relation),
: () => _deleteRelation(relation), child: Text('friendDeleteAction').tr(),
child: Text('friendDeleteAction').tr(), ),
), ],
], ),
), ],
], ),
), ),
), );
); },
}, ),
), ),
), ),
), ),
@ -288,83 +311,9 @@ class _FriendScreenState extends State<FriendScreen> {
} }
} }
class _NewFriendWidget extends StatefulWidget {
const _NewFriendWidget();
@override
State<_NewFriendWidget> createState() => _NewFriendWidgetState();
}
class _NewFriendWidgetState extends State<_NewFriendWidget> {
bool _isBusy = false;
final TextEditingController _relatedController = TextEditingController();
Future<void> _sendRequest() async {
if (_relatedController.text.isEmpty) return;
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.post('/cgi/id/users/me/relations', data: {
'related': _relatedController.text,
});
if (!mounted) return;
Navigator.pop(context, true);
context.showSnackbar('friendRequestSent'.tr());
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void dispose() {
super.dispose();
_relatedController.dispose();
}
@override
Widget build(BuildContext context) {
return StyledWidget(Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'friendNew',
style: Theme.of(context).textTheme.titleLarge,
).tr(),
const Gap(12),
TextField(
controller: _relatedController,
readOnly: _isBusy,
autocorrect: false,
autofocus: true,
textCapitalization: TextCapitalization.none,
decoration: InputDecoration(
labelText: 'fieldFriendRelatedName'.tr(),
suffix: SizedBox(
height: 24,
child: IconButton(
onPressed: _isBusy ? null : () => _sendRequest(),
icon: Icon(Symbols.send),
visualDensity:
const VisualDensity(horizontal: -4, vertical: -4),
padding: EdgeInsets.zero,
),
),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
)
],
)).padding(all: 24);
}
}
class _FriendshipListWidget extends StatefulWidget { class _FriendshipListWidget extends StatefulWidget {
final List<SnRelationship> relations; final List<SnRelationship> relations;
const _FriendshipListWidget({required this.relations}); const _FriendshipListWidget({required this.relations});
@override @override
@ -471,9 +420,7 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text(kFriendStatus[relation.status] ?? 'unknown') Text(kFriendStatus[relation.status] ?? 'unknown').tr().opacity(0.75),
.tr()
.opacity(0.75),
if (relation.status == 0) if (relation.status == 0)
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
@ -494,8 +441,7 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
InkWell( InkWell(
onTap: onTap: _isBusy ? null : () => _changeRelation(relation, 1),
_isBusy ? null : () => _changeRelation(relation, 1),
child: Text('friendUnblock').tr(), child: Text('friendUnblock').tr(),
), ),
const Gap(8), const Gap(8),

View File

@ -9,6 +9,7 @@ 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,9 +23,11 @@ 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/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/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
class HomeScreenDashEntry { class HomeScreenDashEntry {
@ -48,12 +51,12 @@ class HomeScreen extends StatefulWidget {
} }
class _HomeScreenState extends State<HomeScreen> { class _HomeScreenState extends State<HomeScreen> {
static const List<HomeScreenDashEntry> kCards = [ late final List<HomeScreenDashEntry> kCards = [
HomeScreenDashEntry( HomeScreenDashEntry(
name: 'dashEntryRecommendation', name: 'dashEntryRecommendation',
cols: 2,
rows: 2,
child: _HomeDashRecommendationPostWidget(), child: _HomeDashRecommendationPostWidget(),
rows: 2,
cols: 2,
), ),
HomeScreenDashEntry( HomeScreenDashEntry(
name: 'dashEntryCheckIn', name: 'dashEntryCheckIn',
@ -63,11 +66,16 @@ class _HomeScreenState extends State<HomeScreen> {
name: 'dashEntryNotification', name: 'dashEntryNotification',
child: _HomeDashNotificationWidget(), child: _HomeDashNotificationWidget(),
), ),
HomeScreenDashEntry(
name: 'dashEntryTodayNews',
child: _HomeDashTodayNews(),
cols: MediaQuery.of(context).size.width >= 640 ? 3 : 2,
),
]; ];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
leading: AutoAppBarLeading(), leading: AutoAppBarLeading(),
title: Text("screenHome").tr(), title: Text("screenHome").tr(),
@ -123,6 +131,7 @@ class _HomeDashUpdateWidget extends StatelessWidget {
return Container( return Container(
padding: padding, padding: padding,
child: Card( child: Card(
margin: EdgeInsets.zero,
child: ListTile( child: ListTile(
leading: Icon(Symbols.update), leading: Icon(Symbols.update),
title: Text('updateAvailable').tr(), title: Text('updateAvailable').tr(),
@ -172,6 +181,7 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
return Column( return Column(
children: days.map((ele) { children: days.map((ele) {
return Card( return Card(
margin: EdgeInsets.zero,
child: ListTile( child: ListTile(
leading: Text(kSpecialDaysSymbol[ele] ?? '🎉').fontSize(24), leading: Text(kSpecialDaysSymbol[ele] ?? '🎉').fontSize(24),
title: Text('celebrate$ele').tr(args: [ua.user?.nick ?? 'user']), title: Text('celebrate$ele').tr(args: [ua.user?.nick ?? 'user']),
@ -195,6 +205,7 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
final progress = dayz.getSpecialDayProgress(lastOne.$2, date); final progress = dayz.getSpecialDayProgress(lastOne.$2, date);
final diff = nextOne.$2.difference(DateTime.now()); final diff = nextOne.$2.difference(DateTime.now());
return Card( return Card(
margin: EdgeInsets.zero,
child: ListTile( child: ListTile(
leading: Text(kSpecialDaysSymbol[name] ?? '🎉').fontSize(24), leading: Text(kSpecialDaysSymbol[name] ?? '🎉').fontSize(24),
title: Text('pending$name').tr(args: [RelativeTime(context).format(date).replaceFirst('in', '').trim()]), title: Text('pending$name').tr(args: [RelativeTime(context).format(date).replaceFirst('in', '').trim()]),
@ -229,6 +240,107 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
} }
} }
class _HomeDashTodayNews extends StatefulWidget {
const _HomeDashTodayNews();
@override
State<_HomeDashTodayNews> createState() => _HomeDashTodayNewsState();
}
class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
SnNewsArticle? _article;
Future<void> _fetchArticle() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/re/news/today');
_article = SnNewsArticle.fromJson(resp.data['data']);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
rethrow;
} finally {
setState(() {});
}
}
@override
initState() {
super.initState();
_fetchArticle();
}
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Symbols.newspaper),
const Gap(8),
Text(
'newsToday',
style: Theme.of(context).textTheme.titleLarge,
).tr()
],
).padding(horizontal: 18, top: 12, bottom: 8),
if (_article != null)
Expanded(
child: InkWell(
borderRadius: BorderRadius.all(Radius.circular(8)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
Text(
_article!.title,
style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 18),
maxLines: MediaQuery.of(context).size.width >= 640 ? 2 : 1,
overflow: TextOverflow.ellipsis,
),
Text(
parse(_article!.description).children.map((e) => e.text.trim()).join(),
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
Builder(builder: (context) {
final date = _article!.publishedAt ?? _article!.createdAt;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 2,
children: [
Text(DateFormat().format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
Text(' · ').textStyle(Theme.of(context).textTheme.bodySmall!).bold(),
Text(RelativeTime(context).format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
],
).opacity(0.75);
}),
],
).padding(horizontal: 16),
onTap: () {
GoRouter.of(context).pushNamed(
'newsDetail',
pathParameters: {'hash': _article!.hash},
);
},
),
)
else
Expanded(
child: Center(
child: CircularProgressIndicator(),
),
)
],
),
);
}
}
class _HomeDashCheckInWidget extends StatefulWidget { class _HomeDashCheckInWidget extends StatefulWidget {
const _HomeDashCheckInWidget(); const _HomeDashCheckInWidget();
@ -361,6 +473,7 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( return Card(
margin: EdgeInsets.zero,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -387,6 +500,8 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
Text( Text(
'dailyCheckInNone', 'dailyCheckInNone',
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
maxLines: 2,
overflow: TextOverflow.ellipsis,
).tr(), ).tr(),
], ],
) )
@ -404,6 +519,11 @@ 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)
Text(
'+${_todayRecord!.resultCoin} ${'walletCurrencyShort'.tr()}',
style: Theme.of(context).textTheme.bodyLarge,
)
], ],
), ),
), ),
@ -479,6 +599,7 @@ class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( return Card(
margin: EdgeInsets.zero,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -542,36 +663,59 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati
} }
} }
int _currentPage = 0;
final PageController _pageController = PageController();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_fetchRecommendationPosts(); _fetchRecommendationPosts();
_pageController.addListener(() {
setState(() {
_currentPage = _pageController.page?.round() ?? 0;
});
});
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_isBusy) { if (_isBusy) {
return Card( return Card(
margin: EdgeInsets.zero,
child: CircularProgressIndicator().center(), child: CircularProgressIndicator().center(),
); );
} }
return Card( return Card(
margin: EdgeInsets.zero,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Icon(Symbols.star), Row(
const Gap(8), children: [
Text( const Icon(Symbols.star),
'postRecommendation', const Gap(8),
style: Theme.of(context).textTheme.titleLarge, Text(
).tr() 'postRecommendation',
style: Theme.of(context).textTheme.titleLarge,
).tr(),
],
),
Text('${_currentPage + 1}/${_posts?.length ?? 0}', style: GoogleFonts.robotoMono())
], ],
).padding(horizontal: 18, top: 12, bottom: 8), ).padding(horizontal: 18, top: 12, bottom: 8),
Expanded( Expanded(
child: PageView.builder( child: PageView.builder(
controller: _pageController,
scrollBehavior: ScrollConfiguration.of(context).copyWith(dragDevices: { scrollBehavior: ScrollConfiguration.of(context).copyWith(dragDevices: {
PointerDeviceKind.mouse, PointerDeviceKind.mouse,
PointerDeviceKind.touch, PointerDeviceKind.touch,

View File

@ -0,0 +1,241 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart';
import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/news.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:surface/widgets/universal_image.dart';
import 'package:url_launcher/url_launcher_string.dart';
class NewsDetailScreen extends StatefulWidget {
final String hash;
const NewsDetailScreen({super.key, required this.hash});
@override
State<NewsDetailScreen> createState() => _NewsDetailScreenState();
}
class _NewsDetailScreenState extends State<NewsDetailScreen> {
SnNewsArticle? _article;
dom.Document? _articleFragment;
Future<void> _fetchArticle() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/re/news/${widget.hash}');
_article = SnNewsArticle.fromJson(resp.data);
_articleFragment = parse(_article!.content);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err).then((_) {
if (!mounted) return;
Navigator.pop(context);
});
} finally {
setState(() {});
}
}
List<Widget> _parseHtmlToWidgets(Iterable<dom.Element>? elements) {
if (elements == null) return [];
final List<Widget> widgets = [];
for (final node in elements) {
switch (node.localName) {
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
widgets.add(Text(node.text.trim(), style: Theme.of(context).textTheme.titleMedium));
break;
case 'p':
if (node.text.trim().isEmpty) continue;
widgets.add(
Text.rich(
TextSpan(
text: node.text.trim(),
children: [
for (final child in node.children)
switch (child.localName) {
'a' => TextSpan(
text: child.text.trim(),
style: const TextStyle(decoration: TextDecoration.underline),
recognizer: TapGestureRecognizer()
..onTap = () {
launchUrlString(child.attributes['href']!);
},
),
_ => TextSpan(text: child.text.trim()),
},
],
),
style: Theme.of(context).textTheme.bodyLarge,
),
);
break;
case 'a':
// drop single link
break;
case 'div':
// ignore div text, normally it is not meaningful
widgets.addAll(_parseHtmlToWidgets(node.children));
break;
case 'hr':
widgets.add(const Divider());
break;
case 'img':
var src = node.attributes['src'];
if (src == null) break;
final width = double.tryParse(node.attributes['width'] ?? 'null');
final height = double.tryParse(node.attributes['height'] ?? 'null');
final ratio = width != null && height != null ? width / height : 1.0;
if (src.startsWith('//')) {
src = 'https:$src';
} else if (!src.startsWith('http')) {
final baseUri = Uri.parse(_article!.url);
final baseUrl = '${baseUri.scheme}://${baseUri.host}';
src = '$baseUrl/$src';
}
widgets.add(
AspectRatio(
aspectRatio: ratio,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
),
height: height ?? double.infinity,
child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(8)),
child: Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: AutoResizeUniversalImage(
src,
fit: width != null && height != null ? BoxFit.cover : BoxFit.contain,
),
),
),
),
),
);
break;
default:
widgets.addAll(_parseHtmlToWidgets(node.children));
break;
}
}
return widgets;
}
@override
void initState() {
super.initState();
_fetchArticle();
}
bool _isReadingFromReader = true;
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
leading: const PageBackButton(),
title: Text(_article?.title ?? 'loading'.tr()),
),
body: Column(
children: [
MaterialBanner(
dividerColor: Colors.transparent,
leading: const Icon(Icons.info),
content: Text(_isReadingFromReader ? 'newsReadingFromReader'.tr() : 'newsReadingFromOriginal'.tr()),
actions: [
TextButton(
child: Text('newsReadingProviderSwap').tr(),
onPressed: () {
setState(() => _isReadingFromReader = !_isReadingFromReader);
},
),
],
),
if (_articleFragment != null && _isReadingFromReader)
Expanded(
child: Container(
constraints: BoxConstraints(maxWidth: 640),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Text(_article!.title, style: Theme.of(context).textTheme.titleLarge),
Builder(builder: (context) {
final htmlDescription = parse(_article!.description);
return Text(
htmlDescription.children.map((ele) => ele.text.trim()).join(),
style: Theme.of(context).textTheme.bodyMedium,
);
}),
Builder(builder: (context) {
final date = _article!.publishedAt ?? _article!.createdAt;
return Row(
spacing: 2,
children: [
Text(DateFormat().format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
Text(' · ').textStyle(Theme.of(context).textTheme.bodySmall!).bold(),
Text(RelativeTime(context).format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
],
).opacity(0.75);
}),
Text('newsDisclaimer').tr().textStyle(Theme.of(context).textTheme.bodySmall!).opacity(0.75),
const Divider(),
..._parseHtmlToWidgets(_articleFragment!.children),
const Divider(),
InkWell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Reference from original website',
style: TextStyle(decoration: TextDecoration.underline),
),
const Gap(4),
Icon(Icons.launch, size: 16),
],
).opacity(0.85),
onTap: () {
launchUrlString(_article!.url);
},
),
Gap(MediaQuery.of(context).padding.bottom),
],
).padding(horizontal: 12, vertical: 16),
),
).center(),
)
else if (_article != null)
Expanded(
child: InAppWebView(
key: GlobalKey(),
initialUrlRequest: URLRequest(url: WebUri(_article!.url)),
),
),
],
),
);
}
}

View File

@ -0,0 +1,239 @@
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:html/parser.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/news.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/universal_image.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class NewsScreen extends StatefulWidget {
const NewsScreen({super.key});
@override
State<NewsScreen> createState() => _NewsScreenState();
}
class _NewsScreenState extends State<NewsScreen> {
List<SnNewsSource>? _sources;
@override
initState() {
super.initState();
_fetchSources();
}
Future<void> _fetchSources() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/re/well-known/sources');
_sources = List<SnNewsSource>.from(
resp.data?.map((e) => SnNewsSource.fromJson(e)) ?? [],
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() {});
}
}
@override
Widget build(BuildContext context) {
if (_sources == null) {
return AppScaffold(
appBar: AppBar(
leading: AutoAppBarLeading(),
title: Text('screenNews').tr(),
),
body: Center(
child: CircularProgressIndicator(),
),
);
}
return DefaultTabController(
length: _sources!.length + 1,
child: AppScaffold(
body: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar(
leading: AutoAppBarLeading(),
title: Text('screenNews').tr(),
floating: true,
snap: true,
bottom: TabBar(
isScrollable: true,
tabs: [
Tab(child: Text('newsAllSources'.tr()).textColor(Theme.of(context).appBarTheme.foregroundColor)),
for (final source in _sources!)
Tab(
child: Text(source.label).textColor(Theme.of(context).appBarTheme.foregroundColor),
),
],
),
),
),
];
},
body: TabBarView(
children: [
_NewsArticleListWidget(allSources: _sources!),
for (final source in _sources!)
_NewsArticleListWidget(
source: source.id,
allSources: _sources!,
),
],
),
),
),
);
}
}
class _NewsArticleListWidget extends StatefulWidget {
final String? source;
final List<SnNewsSource> allSources;
const _NewsArticleListWidget({this.source, required this.allSources});
@override
State<_NewsArticleListWidget> createState() => _NewsArticleListWidgetState();
}
class _NewsArticleListWidgetState extends State<_NewsArticleListWidget> {
bool _isBusy = false;
int? _totalCount;
final List<SnNewsArticle> _articles = List.empty(growable: true);
Future<void> _fetchArticles() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/re/news', queryParameters: {
'take': 10,
'offset': _articles.length,
if (widget.source != null) 'source': widget.source,
});
_totalCount = resp.data['count'];
_articles.addAll(List<SnNewsArticle>.from(
resp.data['data']?.map((e) => SnNewsArticle.fromJson(e)) ?? [],
));
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchArticles();
}
@override
Widget build(BuildContext context) {
return MediaQuery.removePadding(
context: context,
removeTop: true,
child: Center(
child: Container(
constraints: BoxConstraints(maxWidth: 640),
child: RefreshIndicator(
onRefresh: _fetchArticles,
child: InfiniteList(
isLoading: _isBusy,
itemCount: _articles.length,
hasReachedMax: _totalCount != null && _articles.length >= _totalCount!,
onFetchData: () {
_fetchArticles();
},
itemBuilder: (context, index) {
final article = _articles[index];
final baseUri = Uri.parse(article.url);
final baseUrl = '${baseUri.scheme}://${baseUri.host}';
final htmlDescription = parse(article.description);
final date = article.publishedAt ?? article.createdAt;
return Card(
child: InkWell(
radius: 8,
onTap: () {
GoRouter.of(context).pushNamed(
'newsDetail',
pathParameters: {'hash': article.hash},
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (article.thumbnail.isNotEmpty && !article.thumbnail.endsWith('.svg'))
ClipRRect(
borderRadius: BorderRadius.only(
topRight: Radius.circular(8),
topLeft: Radius.circular(8),
),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: AutoResizeUniversalImage(
article.thumbnail.startsWith('http')
? article.thumbnail
: '$baseUrl/${article.thumbnail}',
),
),
),
),
const Gap(16),
Text(article.title).textStyle(Theme.of(context).textTheme.titleLarge!).padding(horizontal: 16),
const Gap(8),
Text(htmlDescription.children.map((ele) => ele.text.trim()).join())
.textStyle(Theme.of(context).textTheme.bodyMedium!)
.padding(horizontal: 16),
const Gap(8),
Row(
spacing: 2,
children: [
Text(widget.allSources.where((x) => x.id == article.source).first.label)
.textStyle(Theme.of(context).textTheme.bodySmall!),
],
).opacity(0.75).padding(horizontal: 16),
Row(
spacing: 2,
children: [
Text(DateFormat().format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
Text(' · ').textStyle(Theme.of(context).textTheme.bodySmall!).bold(),
Text(RelativeTime(context).format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
],
).opacity(0.75).padding(horizontal: 16),
const Gap(16),
],
),
),
);
},
),
),
),
),
);
}
}

View File

@ -3,10 +3,12 @@ import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart'; import 'package:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/notification.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/notification.dart'; import 'package:surface/types/notification.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
@ -14,12 +16,23 @@ 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/markdown_content.dart'; import 'package:surface/widgets/markdown_content.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
import '../providers/userinfo.dart'; import '../providers/userinfo.dart';
import '../widgets/unauthorized_hint.dart'; import '../widgets/unauthorized_hint.dart';
const Map<String, IconData> kNotificationTopicIcons = {
'general': Symbols.notifications,
'passport.security.alert': Symbols.gpp_maybe,
'passport.security.otp': Symbols.password,
'interactive.subscription': Symbols.subscriptions,
'interactive.feedback': Symbols.add_reaction,
'messaging.callStart': Symbols.call_received,
'wallet.transaction.new': Symbols.receipt,
};
class NotificationScreen extends StatefulWidget { class NotificationScreen extends StatefulWidget {
const NotificationScreen({super.key}); const NotificationScreen({super.key});
@ -35,13 +48,6 @@ class _NotificationScreenState extends State<NotificationScreen> {
final List<SnNotification> _notifications = List.empty(growable: true); final List<SnNotification> _notifications = List.empty(growable: true);
int? _totalCount; int? _totalCount;
static const Map<String, IconData> kNotificationTopicIcons = {
'passport.security.alert': Symbols.gpp_maybe,
'interactive.subscription': Symbols.subscriptions,
'interactive.feedback': Symbols.add_reaction,
'messaging.callStart': Symbols.call_received,
};
Future<void> _fetchNotifications() async { Future<void> _fetchNotifications() async {
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
if (!ua.isAuthorized) return; if (!ua.isAuthorized) return;
@ -50,14 +56,13 @@ class _NotificationScreenState extends State<NotificationScreen> {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final nty = context.read<NotificationProvider>();
final resp = await sn.client.get('/cgi/id/notifications?take=10'); final resp = await sn.client.get('/cgi/id/notifications?take=10');
_totalCount = resp.data['count']; _totalCount = resp.data['count'];
_notifications.addAll( _notifications.addAll(
resp.data['data'] resp.data['data']?.map((e) => SnNotification.fromJson(e)).cast<SnNotification>() ?? [],
?.map((e) => SnNotification.fromJson(e))
.cast<SnNotification>() ??
[],
); );
nty.updateTray();
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -84,9 +89,11 @@ class _NotificationScreenState extends State<NotificationScreen> {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final nty = context.read<NotificationProvider>();
final resp = await sn.client.put('/cgi/id/notifications/read/all'); final resp = await sn.client.put('/cgi/id/notifications/read/all');
_notifications.clear(); _notifications.clear();
_fetchNotifications(); _fetchNotifications();
nty.clear();
if (!mounted) return; if (!mounted) return;
context.showSnackbar( context.showSnackbar(
@ -137,7 +144,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
if (!ua.isAuthorized) { if (!ua.isAuthorized) {
return Scaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
leading: AutoAppBarLeading(), leading: AutoAppBarLeading(),
title: Text('screenNotification').tr(), title: Text('screenNotification').tr(),
@ -148,7 +155,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
); );
} }
return Scaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
leading: AutoAppBarLeading(), leading: AutoAppBarLeading(),
title: Text('screenNotification').tr(), title: Text('screenNotification').tr(),
@ -179,8 +186,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
_fetchNotifications(); _fetchNotifications();
}, },
isLoading: _isBusy, isLoading: _isBusy,
hasReachedMax: _totalCount != null && hasReachedMax: _totalCount != null && _notifications.length >= _totalCount!,
_notifications.length >= _totalCount!,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
final nty = _notifications[idx]; final nty = _notifications[idx];
return Row( return Row(
@ -212,29 +218,36 @@ class _NotificationScreenState extends State<NotificationScreen> {
isAutoWarp: true, isAutoWarp: true,
), ),
), ),
if ([ if (['interactive.reply', 'interactive.feedback', 'interactive.subscription']
'interactive.feedback', .contains(nty.topic) &&
'interactive.subscription'
].contains(nty.topic) &&
nty.metadata['related_post'] != null) nty.metadata['related_post'] != null)
StyledWidget(Container( GestureDetector(
decoration: BoxDecoration( child: Container(
borderRadius: const BorderRadius.all( decoration: BoxDecoration(
Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all( border: Border.all(
color: Theme.of(context).dividerColor, color: Theme.of(context).dividerColor,
width: 1, width: 1,
),
),
child: PostItem(
data: SnPost.fromJson(
nty.metadata['related_post']!,
),
showComments: false,
showReactions: false,
showMenu: false,
), ),
), ),
child: PostItem( onTap: () {
data: SnPost.fromJson( GoRouter.of(context).pushNamed(
nty.metadata['related_post']!, 'postDetail',
), pathParameters: {
showComments: false, 'slug': nty.metadata['related_post']!['id'].toString(),
showReactions: false, },
showMenu: false, );
), },
)).padding(top: 8), ).padding(top: 8),
const Gap(8), const Gap(8),
Row( Row(
children: [ children: [
@ -259,10 +272,8 @@ class _NotificationScreenState extends State<NotificationScreen> {
IconButton( IconButton(
icon: const Icon(Symbols.check), icon: const Icon(Symbols.check),
padding: EdgeInsets.all(0), padding: EdgeInsets.all(0),
visualDensity: visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
const VisualDensity(horizontal: -4, vertical: -4), onPressed: _isSubmitting ? null : () => _markOneAsRead(nty),
onPressed:
_isSubmitting ? null : () => _markOneAsRead(nty),
), ),
], ],
).padding(horizontal: 16); ).padding(horizontal: 16);

View File

@ -6,26 +6,23 @@ import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/post.dart'; import 'package:surface/providers/post.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
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/post/post_comment_list.dart'; import 'package:surface/widgets/post/post_comment_list.dart';
import 'package:surface/widgets/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/post/post_mini_editor.dart';
class PostDetailScreen extends StatefulWidget { class PostDetailScreen extends StatefulWidget {
final String slug; final String slug;
final SnPost? preload; final SnPost? preload;
final Function? onBack;
const PostDetailScreen({ const PostDetailScreen({super.key, required this.slug, this.preload, this.onBack});
super.key,
required this.slug,
this.preload,
});
@override @override
State<PostDetailScreen> createState() => _PostDetailScreenState(); State<PostDetailScreen> createState() => _PostDetailScreenState();
@ -65,105 +62,91 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ua = context.watch<UserProvider>(); final ua = context.watch<UserProvider>();
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return Scaffold( final double maxWidth = _data?.type == 'video' ? double.infinity : 640;
appBar: AppBar(
leading: BackButton( return AppBackground(
onPressed: () { isRoot: widget.onBack != null,
if (GoRouter.of(context).canPop()) { child: AppScaffold(
GoRouter.of(context).pop(context); appBar: AppBar(
return; leading: BackButton(
} onPressed: () {
GoRouter.of(context).replaceNamed('explore'); if (widget.onBack != null) {
}, widget.onBack!.call();
), }
title: _data?.body['title'] != null if (GoRouter.of(context).canPop()) {
? RichText( GoRouter.of(context).pop(context);
textAlign: TextAlign.center, return;
text: TextSpan(children: [ }
TextSpan( GoRouter.of(context).replaceNamed('explore');
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(
slivers: [
SliverToBoxAdapter(
child: LoadingIndicator(isActive: _isBusy),
), ),
if (_data != null) title: _data?.body['title'] != null
SliverToBoxAdapter( ? RichText(
child: PostItem( textAlign: TextAlign.center,
data: _data!, text: TextSpan(children: [
maxWidth: 640, TextSpan(
showComments: false, text: _data?.body['title'] ?? 'postNoun'.tr(),
showFullPost: true, style: Theme.of(context).textTheme.titleLarge!.copyWith(
onChanged: (data) { color: Theme.of(context).appBarTheme.foregroundColor!,
setState(() => _data = data);
},
onDeleted: () {
Navigator.pop(context);
},
),
),
const SliverToBoxAdapter(child: Divider(height: 1)),
if (_data != null)
SliverToBoxAdapter(
child: Container(
constraints: const BoxConstraints(maxWidth: 640),
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: Container(
height: 240,
constraints: const BoxConstraints(maxWidth: 640),
margin:
ResponsiveBreakpoints.of(context).largerThan(MOBILE) ? const EdgeInsets.all(8) : EdgeInsets.zero,
decoration: BoxDecoration(
borderRadius: ResponsiveBreakpoints.of(context).largerThan(MOBILE)
? const BorderRadius.all(Radius.circular(8))
: BorderRadius.zero,
border: ResponsiveBreakpoints.of(context).largerThan(MOBILE)
? Border.all(
color: Theme.of(context).dividerColor,
width: 1 / devicePixelRatio,
)
: Border.symmetric(
horizontal: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / devicePixelRatio,
), ),
), ),
const TextSpan(text: '\n'),
TextSpan(
text: 'postDetail'.tr(),
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
]),
maxLines: 2,
overflow: TextOverflow.ellipsis,
)
: Text('postDetail').tr(),
),
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: LoadingIndicator(isActive: _isBusy),
),
if (_data != null)
SliverToBoxAdapter(
child: PostItem(
data: _data!,
maxWidth: maxWidth,
showComments: false,
showFullPost: true,
onChanged: (data) {
setState(() => _data = data);
},
onDeleted: () {
Navigator.pop(context);
},
), ),
child: PostMiniEditor( ),
postReplyId: _data!.id, if (_data != null && _data!.type != 'video') const SliverToBoxAdapter(child: Divider(height: 1)),
onPost: () { 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(() { setState(() {
_data = _data!.copyWith( _data = _data!.copyWith(
metric: _data!.metric.copyWith( metric: _data!.metric.copyWith(
@ -174,16 +157,16 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
_childListKey.currentState!.refresh(); _childListKey.currentState!.refresh();
}, },
), ),
).center(), ),
), if (_data != null && _data!.type != 'video')
if (_data != null) PostCommentSliverList(
PostCommentSliverList( key: _childListKey,
key: _childListKey, parentPost: _data!,
parentPostId: _data!.id, maxWidth: maxWidth,
maxWidth: 640, ),
), if (_data != null && _data!.type == 'video') SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)),
SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)), ],
], ),
), ),
); );
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,13 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/post.dart'; import 'package:surface/providers/post.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/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/post/post_tags_field.dart'; import 'package:surface/widgets/post/post_tags_field.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
@ -119,7 +119,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
), ),
]; ];
return Scaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
title: Text('screenPostSearch').tr(), title: Text('screenPostSearch').tr(),
actions: [ actions: [
@ -133,7 +133,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
body: Stack( body: Stack(
children: [ children: [
InfiniteList( InfiniteList(
padding: const EdgeInsets.only(top: 100), padding: const EdgeInsets.only(top: 100 + 8),
itemCount: _posts.length, itemCount: _posts.length,
isLoading: _isBusy, isLoading: _isBusy,
hasReachedMax: _postCount != null && _posts.length >= _postCount!, hasReachedMax: _postCount != null && _posts.length >= _postCount!,
@ -141,27 +141,18 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
_fetchPosts(); _fetchPosts();
}, },
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
return GestureDetector( return OpenablePostItem(
child: PostItem( data: _posts[idx],
data: _posts[idx], maxWidth: 640,
maxWidth: 640, onChanged: (data) {
onChanged: (data) { setState(() => _posts[idx] = data);
setState(() => _posts[idx] = data); },
}, onDeleted: () {
onDeleted: () { _refreshPosts();
_refreshPosts();
},
),
onTap: () {
GoRouter.of(context).pushNamed(
'postDetail',
pathParameters: {'slug': _posts[idx].id.toString()},
extra: _posts[idx],
);
}, },
); );
}, },
separatorBuilder: (context, index) => const Divider(height: 1), separatorBuilder: (_, __) => const Gap(8),
), ),
Positioned( Positioned(
top: 16, top: 16,

View File

@ -17,6 +17,7 @@ import 'package:surface/types/post.dart';
import 'package:surface/types/realm.dart'; import 'package:surface/types/realm.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
@ -274,7 +275,7 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
return Scaffold( return AppScaffold(
body: NestedScrollView( body: NestedScrollView(
controller: _scrollController, controller: _scrollController,
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
@ -286,8 +287,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
Theme( Theme(
data: Theme.of(context).copyWith( data: Theme.of(context).copyWith(
appBarTheme: Theme.of(context).appBarTheme.copyWith( appBarTheme: Theme.of(context).appBarTheme.copyWith(
foregroundColor: Colors.white, foregroundColor: Colors.white,
), ),
), ),
child: SliverAppBar( child: SliverAppBar(
expandedHeight: _appBarHeight, expandedHeight: _appBarHeight,
@ -596,25 +597,16 @@ class _PublisherPostList extends StatelessWidget {
hasReachedMax: postCount != null && posts.length >= postCount!, hasReachedMax: postCount != null && posts.length >= postCount!,
onFetchData: fetchPosts, onFetchData: fetchPosts,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
return GestureDetector( return OpenablePostItem(
child: PostItem( data: posts[idx],
data: posts[idx], maxWidth: 640,
maxWidth: 640, onChanged: (data) {
onChanged: (data) { onChanged(idx, data);
onChanged(idx, data);
},
onDeleted: onDeleted,
),
onTap: () {
GoRouter.of(context).pushNamed(
'postDetail',
pathParameters: {'slug': posts[idx].id.toString()},
extra: posts[idx],
);
}, },
onDeleted: onDeleted,
); );
}, },
separatorBuilder: (context, index) => const Divider(height: 1), separatorBuilder: (_, __) => const Gap(8),
); );
} }
} }

View File

@ -12,6 +12,7 @@ import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/app_bar_leading.dart'; import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart'; import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/unauthorized_hint.dart'; import 'package:surface/widgets/unauthorized_hint.dart';
import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
@ -83,7 +84,7 @@ class _RealmScreenState extends State<RealmScreen> {
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
if (!ua.isAuthorized) { if (!ua.isAuthorized) {
return Scaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
leading: AutoAppBarLeading(), leading: AutoAppBarLeading(),
title: Text('screenRealm').tr(), title: Text('screenRealm').tr(),
@ -94,11 +95,17 @@ class _RealmScreenState extends State<RealmScreen> {
); );
} }
return Scaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
leading: AutoAppBarLeading(), leading: AutoAppBarLeading(),
title: Text('screenRealm').tr(), title: Text('screenRealm').tr(),
actions: [ actions: [
IconButton(
icon: const Icon(Symbols.globe),
onPressed: () {
GoRouter.of(context).pushNamed('realmDiscovery');
},
),
IconButton( IconButton(
icon: !_isCompactView ? const Icon(Symbols.view_list) : const Icon(Symbols.view_module), icon: !_isCompactView ? const Icon(Symbols.view_list) : const Icon(Symbols.view_module),
onPressed: () { onPressed: () {
@ -118,125 +125,140 @@ class _RealmScreenState extends State<RealmScreen> {
children: [ children: [
LoadingIndicator(isActive: _isBusy), LoadingIndicator(isActive: _isBusy),
Expanded( Expanded(
child: RefreshIndicator( child: MediaQuery.removePadding(
onRefresh: _fetchRealms, context: context,
child: ListView.builder( removeTop: true,
itemCount: _realms?.length ?? 0, child: RefreshIndicator(
itemBuilder: (context, idx) { onRefresh: _fetchRealms,
final realm = _realms![idx]; child: ListView.builder(
if (_isCompactView) { itemCount: _realms?.length ?? 0,
return ListTile( itemBuilder: (context, idx) {
contentPadding: const EdgeInsets.symmetric(horizontal: 16), final realm = _realms![idx];
leading: AccountImage( if (_isCompactView) {
content: realm.avatar, return ListTile(
fallbackWidget: const Icon(Symbols.group, size: 20), contentPadding: const EdgeInsets.symmetric(horizontal: 16),
), leading: AccountImage(
title: Text(realm.name), content: realm.avatar,
subtitle: Text( fallbackWidget: const Icon(Symbols.group, size: 20),
realm.description, ),
maxLines: 1, title: Text(realm.name),
overflow: TextOverflow.ellipsis, subtitle: Text(
), realm.description,
trailing: PopupMenuButton( maxLines: 1,
itemBuilder: (BuildContext context) => [ overflow: TextOverflow.ellipsis,
PopupMenuItem( ),
child: Row( trailing: PopupMenuButton(
children: [ itemBuilder: (BuildContext context) => [
const Icon(Symbols.edit), PopupMenuItem(
const Gap(16), child: Row(
Text('edit').tr(),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'realmManage',
queryParameters: {'editing': realm.alias},
).then((value) {
if (value != null) {
_fetchRealms();
}
});
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.delete),
const Gap(16),
Text('delete').tr(),
],
),
onTap: () {
_deleteRealm(realm);
},
),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'realmDetail',
pathParameters: {'alias': realm.alias},
);
},
);
}
return Container(
constraints: BoxConstraints(maxWidth: 640),
child: Card(
margin: const EdgeInsets.all(12),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 7,
child: Stack(
clipBehavior: Clip.none,
fit: StackFit.expand,
children: [ children: [
Container( const Icon(Symbols.edit),
color: Theme.of(context).colorScheme.surfaceContainer, const Gap(16),
child: (realm.banner?.isEmpty ?? true) Text('edit').tr(),
? const SizedBox.shrink()
: AutoResizeUniversalImage(
sn.getAttachmentUrl(realm.banner!),
fit: BoxFit.cover,
),
),
Positioned(
bottom: -30,
left: 18,
child: AccountImage(
content: realm.avatar,
radius: 24,
fallbackWidget: const Icon(Symbols.group, size: 24),
),
),
], ],
), ),
onTap: () {
GoRouter.of(context).pushNamed(
'realmManage',
queryParameters: {'editing': realm.alias},
).then((value) {
if (value != null) {
_fetchRealms();
}
});
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.delete),
const Gap(16),
Text('delete').tr(),
],
),
onTap: () {
_deleteRealm(realm);
},
), ),
const Gap(20 + 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(realm.name).textStyle(Theme.of(context).textTheme.titleMedium!),
Text(realm.description).textStyle(Theme.of(context).textTheme.bodySmall!),
],
).padding(horizontal: 24, bottom: 14),
], ],
), ),
onTap: () { onTap: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
'realmDetail', 'realmDetail',
pathParameters: {'alias': realm.alias}, pathParameters: {'alias': realm.alias},
); ).then((value) {
if (value == true) {
_fetchRealms();
}
});
}, },
);
}
return Container(
constraints: BoxConstraints(maxWidth: 640),
child: Card(
margin: const EdgeInsets.all(12),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 7,
child: Stack(
clipBehavior: Clip.none,
fit: StackFit.expand,
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: (realm.banner?.isEmpty ?? true)
? const SizedBox.shrink()
: AutoResizeUniversalImage(
sn.getAttachmentUrl(realm.banner!),
fit: BoxFit.cover,
),
),
),
Positioned(
bottom: -30,
left: 18,
child: AccountImage(
content: realm.avatar,
radius: 24,
fallbackWidget: const Icon(Symbols.group, size: 24),
),
),
],
),
),
const Gap(20 + 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(realm.name).textStyle(Theme.of(context).textTheme.titleMedium!),
Text(realm.description).textStyle(Theme.of(context).textTheme.bodySmall!),
],
).padding(horizontal: 24, bottom: 14),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'realmDetail',
pathParameters: {'alias': realm.alias},
).then((value) {
if (value == true) {
_fetchRealms();
}
});
},
),
), ),
), ).center();
).center(); },
}, ),
), ),
), ),
), ),

View File

@ -18,6 +18,7 @@ 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/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/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
@ -49,6 +50,8 @@ class _RealmManageScreenState extends State<RealmManageScreen> {
_aliasController.text = out.alias; _aliasController.text = out.alias;
_nameController.text = out.name; _nameController.text = out.name;
_descriptionController.text = out.description; _descriptionController.text = out.description;
_isPublic = out.isPublic;
_isCommunity = out.isCommunity;
} catch (err) { } catch (err) {
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
if (context.mounted) context.showErrorDialog(err); if (context.mounted) context.showErrorDialog(err);
@ -66,6 +69,9 @@ class _RealmManageScreenState extends State<RealmManageScreen> {
final _imagePicker = ImagePicker(); final _imagePicker = ImagePicker();
bool _isPublic = false;
bool _isCommunity = false;
Future<void> _updateImage(String place) async { Future<void> _updateImage(String place) async {
final image = await _imagePicker.pickImage(source: ImageSource.gallery); final image = await _imagePicker.pickImage(source: ImageSource.gallery);
if (image == null) return; if (image == null) return;
@ -137,6 +143,8 @@ class _RealmManageScreenState extends State<RealmManageScreen> {
'description': _descriptionController.text, 'description': _descriptionController.text,
'avatar': _avatar, 'avatar': _avatar,
'banner': _banner, 'banner': _banner,
'is_public': _isPublic,
'is_community': _isCommunity,
}; };
try { try {
@ -179,7 +187,7 @@ class _RealmManageScreenState extends State<RealmManageScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
return Scaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
title: widget.editingRealmAlias != null title: widget.editingRealmAlias != null
? Text('screenRealmManage').tr() ? Text('screenRealmManage').tr()
@ -292,6 +300,23 @@ class _RealmManageScreenState extends State<RealmManageScreen> {
FocusManager.instance.primaryFocus?.unfocus(), FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(12), const Gap(12),
CheckboxListTile(
value: _isPublic,
title: Text('realmIsPublic'.tr()),
subtitle: Text('realmIsPublicDescription'.tr()),
onChanged: (value) {
setState(() => _isPublic = value ?? false);
},
),
CheckboxListTile(
value: _isCommunity,
title: Text('realmIsCommunity'.tr()),
subtitle: Text('realmIsCommunityDescription'.tr()),
onChanged: (value) {
setState(() => _isCommunity = value ?? false);
},
),
const Gap(12),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [

View File

@ -8,13 +8,15 @@ 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/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/account.dart';
import 'package:surface/types/post.dart';
import 'package:surface/types/realm.dart'; import 'package:surface/types/realm.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/account/account_select.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
import '../../types/post.dart';
class RealmDetailScreen extends StatefulWidget { class RealmDetailScreen extends StatefulWidget {
final String alias; final String alias;
@ -70,19 +72,11 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DefaultTabController( return DefaultTabController(
length: 3, length: 3,
child: Scaffold( child: AppScaffold(
body: NestedScrollView( body: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
// These are the slivers that show up in the "outer" scroll view.
return <Widget>[ return <Widget>[
SliverOverlapAbsorber( SliverOverlapAbsorber(
// This widget takes the overlapping behavior of the SliverAppBar,
// and redirects it to the SliverOverlapInjector below. If it is
// missing, then it is possible for the nested "inner" scroll view
// below to end up under the SliverAppBar even when the inner
// scroll view thinks it has not been scrolled.
// This is not necessary if the "headerSliverBuilder" only builds
// widgets that do not overlap the next sliver.
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar( sliver: SliverAppBar(
title: Text(_realm?.name ?? 'loading'.tr()), title: Text(_realm?.name ?? 'loading'.tr()),
@ -195,7 +189,7 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/realms/${widget.realm!.alias}/members', queryParameters: { final resp = await sn.client.get('/cgi/id/realms/${widget.realm!.alias}/members', queryParameters: {
'take': 10, 'take': 10,
'offset': 0, 'offset': _members.length,
}); });
final out = List<SnRealmMember>.from( final out = List<SnRealmMember>.from(
@ -237,13 +231,35 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
} }
} }
void _showMemberAdd() { Future<void> _addMember(SnAccount related) async {
showModalBottomSheet( setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.post(
'/cgi/id/realms/${widget.realm!.alias}/members',
data: {'related': related.name},
);
if (!mounted) return;
context.showSnackbar('realmMemberAdded'.tr());
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
void _showMemberAdd() async {
final user = await showModalBottomSheet<SnAccount?>(
context: context, context: context,
builder: (context) => _NewRealmMemberWidget( builder: (context) => AccountSelect(
realm: widget.realm!, title: 'realmMemberAdd'.tr(),
), ),
); );
if (!mounted) return;
if (user == null) return;
_addMember(user);
} }
@override @override
@ -301,85 +317,6 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
} }
} }
class _NewRealmMemberWidget extends StatefulWidget {
final SnRealm realm;
const _NewRealmMemberWidget({required this.realm});
@override
State<_NewRealmMemberWidget> createState() => _NewRealmMemberWidgetState();
}
class _NewRealmMemberWidgetState extends State<_NewRealmMemberWidget> {
bool _isBusy = false;
final TextEditingController _relatedController = TextEditingController();
Future<void> _performAction() async {
if (_relatedController.text.isEmpty) return;
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.post(
'/cgi/id/realms/${widget.realm.alias}/members',
data: {
'related': _relatedController.text,
},
);
if (!mounted) return;
Navigator.pop(context, true);
context.showSnackbar('channelMemberAdded'.tr());
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void dispose() {
super.dispose();
_relatedController.dispose();
}
@override
Widget build(BuildContext context) {
return StyledWidget(Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'realmMemberAdd',
style: Theme.of(context).textTheme.titleLarge,
).tr(),
const Gap(12),
TextField(
controller: _relatedController,
readOnly: _isBusy,
autocorrect: false,
autofocus: true,
textCapitalization: TextCapitalization.none,
decoration: InputDecoration(
labelText: 'fieldMemberRelatedName'.tr(),
suffix: SizedBox(
height: 24,
child: IconButton(
onPressed: _isBusy ? null : () => _performAction(),
icon: Icon(Symbols.send),
visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
padding: EdgeInsets.zero,
),
),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
)
],
)).padding(all: 24);
}
}
class _RealmSettingsWidget extends StatefulWidget { class _RealmSettingsWidget extends StatefulWidget {
final SnRealm? realm; final SnRealm? realm;
final Function() onUpdate; final Function() onUpdate;
@ -406,12 +343,31 @@ class _RealmSettingsWidgetState extends State<_RealmSettingsWidget> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
await sn.client.delete('/cgi/id/realms/${widget.realm!.alias}'); await sn.client.delete('/cgi/id/realms/${widget.realm!.id}');
if (!mounted) return;
Navigator.pop(context, true);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _leaveRealm() async {
final confirm = await context.showConfirmDialog(
'realmLeave'.tr(),
'realmLeaveDescription'.tr(),
);
if (!confirm) return;
if (!mounted) return;
final sn = context.read<SnNetworkProvider>();
try {
await sn.client.delete('/cgi/id/realms/${widget.realm!.alias}/members/me');
if (!mounted) return; if (!mounted) return;
Navigator.pop(context, true); Navigator.pop(context, true);
context.showSnackbar('realmDeleted'.tr(args: [
'#${widget.realm!.alias}',
]));
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -428,24 +384,33 @@ class _RealmSettingsWidgetState extends State<_RealmSettingsWidget> {
return Column( return Column(
children: [ children: [
const Gap(16), const Gap(8),
ListTile( ListTile(
leading: const Icon(Symbols.edit), leading: const Icon(Symbols.logout),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
title: Text('realmEdit').tr(), title: Text('realmLeave').tr(),
subtitle: Text('realmEditDescription').tr(), subtitle: Text('realmLeaveDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () { onTap: _isBusy ? null : () => _leaveRealm(),
GoRouter.of(context).pushNamed(
'realmManage',
queryParameters: {'editing': widget.realm!.alias},
).then((value) {
if (value != null) {
widget.onUpdate();
}
});
},
), ),
if (isOwned)
ListTile(
leading: const Icon(Symbols.edit),
trailing: const Icon(Symbols.chevron_right),
title: Text('realmEdit').tr(),
subtitle: Text('realmEditDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
GoRouter.of(context).pushNamed(
'realmManage',
queryParameters: {'editing': widget.realm!.alias},
).then((value) {
if (value != null) {
widget.onUpdate();
}
});
},
),
if (isOwned) if (isOwned)
ListTile( ListTile(
leading: const Icon(Symbols.delete), leading: const Icon(Symbols.delete),

View File

@ -0,0 +1,290 @@
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/chat.dart';
import 'package:surface/types/realm.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/universal_image.dart';
class RealmDiscoveryScreen extends StatefulWidget {
const RealmDiscoveryScreen({super.key});
@override
State<RealmDiscoveryScreen> createState() => _RealmDiscoveryScreenState();
}
class _RealmDiscoveryScreenState extends State<RealmDiscoveryScreen> {
List<SnRealm>? _realms;
bool _isBusy = false;
Future<void> _fetchRealms() async {
try {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/realms');
_realms = List<SnRealm>.from(
resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
);
} catch (err) {
if (mounted) context.showErrorDialog(err);
rethrow;
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchRealms();
}
@override
Widget build(BuildContext context) {
final sn = context.read<SnNetworkProvider>();
return AppScaffold(
appBar: AppBar(
title: Text('screenRealmDiscovery').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
Expanded(
child: RefreshIndicator(
onRefresh: _fetchRealms,
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: _realms?.length ?? 0,
itemBuilder: (context, idx) {
final realm = _realms![idx];
return Container(
constraints: BoxConstraints(maxWidth: 640),
child: Card(
margin: const EdgeInsets.all(12),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 7,
child: Stack(
clipBehavior: Clip.none,
fit: StackFit.expand,
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: (realm.banner?.isEmpty ?? true)
? const SizedBox.shrink()
: AutoResizeUniversalImage(
sn.getAttachmentUrl(realm.banner!),
fit: BoxFit.cover,
),
),
),
Positioned(
bottom: -30,
left: 18,
child: AccountImage(
content: realm.avatar,
radius: 24,
fallbackWidget: const Icon(Symbols.group, size: 24),
),
),
],
),
),
const Gap(20 + 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(realm.name).textStyle(Theme.of(context).textTheme.titleMedium!),
Text(realm.description).textStyle(Theme.of(context).textTheme.bodySmall!),
],
).padding(horizontal: 24, bottom: 14),
],
),
onTap: () {
showModalBottomSheet(
context: context,
builder: (context) => _RealmJoinPopup(realm: realm),
);
},
),
),
).center();
},
),
),
),
],
),
);
}
}
class _RealmJoinPopup extends StatefulWidget {
final SnRealm realm;
const _RealmJoinPopup({required this.realm});
@override
State<_RealmJoinPopup> createState() => _RealmJoinPopupState();
}
class _RealmJoinPopupState extends State<_RealmJoinPopup> {
final List<String> _planJoinChannels = List.empty(growable: true);
List<SnChannel>? _channels;
bool _isBusy = false;
bool _isJoining = false;
Future<void> _fetchPublicChannels() async {
try {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/im/channels/${widget.realm.alias}/public');
final out = List<SnChannel>.from(
resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(),
);
setState(() => _channels = out);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _joinRealm() async {
try {
setState(() => _isJoining = true);
final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>();
await sn.client.post('/cgi/id/realms/${widget.realm.alias}/members', data: {
'related': ua.user?.name,
});
await _joinSelectedChannels();
if (!mounted) return;
context.showSnackbar('realmJoined'.tr(args: [widget.realm.name]));
Navigator.pop(context);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isJoining = false);
}
}
Future<void> _joinSelectedChannels() async {
if (_planJoinChannels.isEmpty) return;
for (final channel in _planJoinChannels) {
try {
final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>();
await sn.client.post('/cgi/im/channels/${widget.realm.alias}/$channel/members', data: {
'related': ua.user?.name,
});
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
}
}
}
@override
void initState() {
super.initState();
_fetchPublicChannels();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.group_add, size: 24),
const Gap(16),
Text('realmJoin', style: Theme.of(context).textTheme.titleLarge).tr(),
],
).padding(horizontal: 20, top: 16, bottom: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.realm.name,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
widget.realm.description,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
ElevatedButton(
onPressed: _isJoining ? null : () => _joinRealm(),
child: Text('join'.tr()),
),
],
).padding(horizontal: 24, bottom: 12),
const Divider(height: 1),
LoadingIndicator(isActive: _isBusy),
Container(
width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Text('realmCommunityPublicChannelsHint'.tr(), style: Theme.of(context).textTheme.bodyMedium)
.padding(horizontal: 24, vertical: 8),
),
Expanded(
child: ListView.builder(
itemCount: _channels?.length ?? 0,
itemBuilder: (context, index) {
final channel = _channels![index];
return CheckboxListTile(
value: _planJoinChannels.contains(channel.alias),
title: Text(channel.name),
subtitle: Text(
channel.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
secondary: AccountImage(
content: null,
fallbackWidget: const Icon(Symbols.chat, size: 20),
),
onChanged: (value) {
value ??= false;
if (value) {
setState(() => _planJoinChannels.add(channel.alias));
} else {
setState(() => _planJoinChannels.remove(channel.alias));
}
},
);
},
),
),
],
);
}
}

View File

@ -18,6 +18,7 @@ import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/theme.dart'; import 'package:surface/providers/theme.dart';
import 'package:surface/theme.dart'; import 'package:surface/theme.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
const Map<String, Color> kColorSchemes = { const Map<String, Color> kColorSchemes = {
'colorSchemeIndigo': Colors.indigo, 'colorSchemeIndigo': Colors.indigo,
@ -67,7 +68,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
return Scaffold( return AppScaffold(
appBar: AppBar(
leading: const PageBackButton(),
title: Text('screenSettings').tr(),
),
body: SingleChildScrollView( body: SingleChildScrollView(
child: Column( child: Column(
spacing: 16, spacing: 16,
@ -77,6 +82,48 @@ class _SettingsScreenState extends State<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('settingsAppearance').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4), Text('settingsAppearance').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
ListTile(
title: Text('settingsDisplayLanguage').tr(),
subtitle: Text('settingsDisplayLanguageDescription').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.translate),
trailing: DropdownButtonHideUnderline(
child: DropdownButton2<Locale?>(
isExpanded: true,
items: [
...EasyLocalization.of(context)!.supportedLocales.mapIndexed((idx, ele) {
return DropdownMenuItem<Locale?>(
value: ele,
child: Text('${ele.languageCode}-${ele.countryCode}').fontSize(14),
);
}),
DropdownMenuItem<Locale?>(
value: null,
child: Text('settingsDisplayLanguageSystem').tr().fontSize(14),
),
],
value: EasyLocalization.of(context)!.currentLocale,
onChanged: (Locale? value) {
if (value != null) {
EasyLocalization.of(context)!.setLocale(value);
} else {
EasyLocalization.of(context)!.resetLocale();
}
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 5,
),
height: 40,
width: 160,
),
menuItemStyleData: const MenuItemStyleData(
height: 40,
),
),
),
),
if (!kIsWeb) if (!kIsWeb)
ListTile( ListTile(
title: Text('settingsBackgroundImage').tr(), title: Text('settingsBackgroundImage').tr(),
@ -120,7 +167,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
subtitle: Text('settingsThemeMaterial3Description').tr(), subtitle: Text('settingsThemeMaterial3Description').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17), contentPadding: const EdgeInsets.only(left: 24, right: 17),
secondary: const Icon(Symbols.new_releases), secondary: const Icon(Symbols.new_releases),
value: _prefs.getBool(kMaterialYouToggleStoreKey) ?? false, value: _prefs.getBool(kMaterialYouToggleStoreKey) ?? true,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_prefs.setBool( _prefs.setBool(
@ -142,30 +189,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
Color pickerColor = Color(_prefs.getInt(kAppColorSchemeStoreKey) ?? Colors.indigo.value); Color pickerColor = Color(_prefs.getInt(kAppColorSchemeStoreKey) ?? Colors.indigo.value);
final color = await showDialog<Color?>( final color = await showDialog<Color?>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) =>
content: SingleChildScrollView( AlertDialog(
child: ColorPicker( content: SingleChildScrollView(
pickerColor: pickerColor, child: ColorPicker(
onColorChanged: (color) => pickerColor = color, pickerColor: pickerColor,
enableAlpha: false, onColorChanged: (color) => pickerColor = color,
hexInputBar: true, enableAlpha: false,
hexInputBar: true,
),
),
actions: <Widget>[
TextButton(
child: const Text('dialogDismiss').tr(),
onPressed: () {
Navigator.of(context).pop();
},
),
TextButton(
child: const Text('dialogConfirm').tr(),
onPressed: () {
Navigator.of(context).pop(pickerColor);
},
),
],
), ),
),
actions: <Widget>[
TextButton(
child: const Text('dialogDismiss').tr(),
onPressed: () {
Navigator.of(context).pop();
},
),
TextButton(
child: const Text('dialogConfirm').tr(),
onPressed: () {
Navigator.of(context).pop(pickerColor);
},
),
],
),
); );
if (color == null || !context.mounted) return; if (color == null || !context.mounted) return;
@ -201,11 +249,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
value: _prefs.getInt(kAppColorSchemeStoreKey) == null value: _prefs.getInt(kAppColorSchemeStoreKey) == null
? 1 ? 1
: kColorSchemes.values : kColorSchemes.values
.toList() .toList()
.indexWhere((ele) => ele.value == _prefs.getInt(kAppColorSchemeStoreKey)), .indexWhere((ele) => ele.value == _prefs.getInt(kAppColorSchemeStoreKey)),
onChanged: (int? value) { onChanged: (int? value) {
if (value != null && value != -1) { if (value != null && value != -1) {
_prefs.setInt(kAppColorSchemeStoreKey, kColorSchemes.values.elementAt(value).value); _prefs.setInt(kAppColorSchemeStoreKey, kColorSchemes.values
.elementAt(value)
.value);
final th = context.read<ThemeProvider>(); final th = context.read<ThemeProvider>();
th.reloadTheme(seedColorOverride: kColorSchemes.values.elementAt(value)); th.reloadTheme(seedColorOverride: kColorSchemes.values.elementAt(value));
setState(() {}); setState(() {});
@ -255,6 +305,48 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
], ],
), ),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('settingsFeatures').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
CheckboxListTile(
secondary: const Icon(Symbols.vibration),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
title: Text('settingsNotifyWithHaptic').tr(),
subtitle: Text('settingsNotifyWithHapticDescription').tr(),
value: _prefs.getBool(kAppNotifyWithHaptic) ?? true,
onChanged: (value) {
setState(() {
_prefs.setBool(kAppNotifyWithHaptic, value ?? false);
});
},
),
CheckboxListTile(
secondary: const Icon(Symbols.link),
title: Text('settingsExpandPostLink').tr(),
subtitle: Text('settingsExpandPostLinkDescription').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
value: _prefs.getBool(kAppExpandPostLink) ?? true,
onChanged: (value) {
setState(() {
_prefs.setBool(kAppExpandPostLink, value ?? false);
});
},
),
CheckboxListTile(
secondary: const Icon(Symbols.chat),
title: Text('settingsExpandChatLink').tr(),
subtitle: Text('settingsExpandChatLinkDescription').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
value: _prefs.getBool(kAppExpandChatLink) ?? true,
onChanged: (value) {
setState(() {
_prefs.setBool(kAppExpandChatLink, value ?? false);
});
},
),
],
),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -295,7 +387,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
('Custom', _serverUrlController.text), ('Custom', _serverUrlController.text),
] ]
.map( .map(
(item) => DropdownMenuItem<String>( (item) =>
DropdownMenuItem<String>(
value: item.$2, value: item.$2,
child: Column( child: Column(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
@ -307,7 +400,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
], ],
), ),
), ),
) )
.toList(), .toList(),
value: _serverUrlController.text, value: _serverUrlController.text,
onChanged: (String? value) { onChanged: (String? value) {
@ -362,11 +455,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
isExpanded: true, isExpanded: true,
items: kImageQualityLevel.entries items: kImageQualityLevel.entries
.map( .map(
(item) => DropdownMenuItem<FilterQuality>( (item) =>
DropdownMenuItem<FilterQuality>(
value: item.value, value: item.value,
child: Text(item.key).tr().fontSize(14), child: Text(item.key).tr().fontSize(14),
), ),
) )
.toList(), .toList(),
onChanged: (FilterQuality? value) { onChanged: (FilterQuality? value) {
if (value == null) return; if (value == null) return;

View File

@ -8,9 +8,20 @@ import 'package:flutter/foundation.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:material_symbols_icons/material_symbols_icons.dart';
import 'package:provider/provider.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/post_write_controller.dart'; import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/screens/chat/room.dart';
import 'package:surface/screens/post/post_editor.dart'; import 'package:surface/screens/post/post_editor.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
class AppSharingListener extends StatefulWidget { class AppSharingListener extends StatefulWidget {
final Widget child; final Widget child;
@ -51,20 +62,39 @@ class _AppSharingListenerState extends State<AppSharingListener> {
pathParameters: { pathParameters: {
'mode': 'stories', 'mode': 'stories',
}, },
extra: PostEditorExtraProps( extra: PostEditorExtra(
text: value text: value
.where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type)) .where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type))
.map((e) => e.path).join('\n'), .map((e) => e.path)
.join('\n'),
attachments: value attachments: value
.where((e) => [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image].contains(e.type)) .where((e) => [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image]
.map((e) => PostWriteMedia.fromFile(XFile(e.path))).toList(), .contains(e.type))
.map((e) => PostWriteMedia.fromFile(XFile(e.path)))
.toList(),
), ),
); );
Navigator.pop(context); Navigator.pop(context);
}, },
), ),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
leading: Icon(Icons.chat_outlined),
trailing: const Icon(Icons.chevron_right),
title: Text('shareIntentSendChannel').tr(),
onTap: () {
showModalBottomSheet(
context: context,
builder: (context) => _ShareIntentChannelSelect(value: value),
).then((val) {
if (!context.mounted) return;
if (val == true) Navigator.pop(context);
});
},
),
], ],
), ).width(280),
) )
], ],
), ),
@ -103,7 +133,7 @@ class _AppSharingListenerState extends State<AppSharingListener> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if(!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
_initialize(); _initialize();
_initialHandle(); _initialHandle();
} }
@ -120,3 +150,193 @@ class _AppSharingListenerState extends State<AppSharingListener> {
return widget.child; return widget.child;
} }
} }
class _ShareIntentChannelSelect extends StatefulWidget {
final Iterable<SharedMediaFile> value;
const _ShareIntentChannelSelect({required this.value});
@override
State<_ShareIntentChannelSelect> createState() => _ShareIntentChannelSelectState();
}
class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
bool _isBusy = true;
List<SnChannel>? _channels;
Map<int, SnChatMessage>? _lastMessages;
void _refreshChannels() {
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) {
setState(() => _isBusy = false);
return;
}
final chan = context.read<ChatChannelProvider>();
chan.fetchChannels().listen((channels) async {
final lastMessages = await chan.getLastMessages(channels);
_lastMessages = {for (final val in lastMessages) val.channelId: val};
channels.sort((a, b) {
if (_lastMessages!.containsKey(a.id) && _lastMessages!.containsKey(b.id)) {
return _lastMessages![b.id]!.createdAt.compareTo(_lastMessages![a.id]!.createdAt);
}
if (_lastMessages!.containsKey(a.id)) return -1;
if (_lastMessages!.containsKey(b.id)) return 1;
return 0;
});
if (!mounted) return;
final ud = context.read<UserDirectoryProvider>();
for (final channel in channels) {
if (channel.type == 1) {
await ud.listAccount(
channel.members
?.cast<SnChannelMember?>()
.map((ele) => ele?.accountId)
.where((ele) => ele != null)
.toSet() ??
{},
);
}
}
if (mounted) setState(() => _channels = channels);
})
..onError((err) {
if (!mounted) return;
context.showErrorDialog(err);
setState(() => _isBusy = false);
})
..onDone(() {
if (!mounted) return;
setState(() => _isBusy = false);
});
}
@override
void initState() {
super.initState();
_refreshChannels();
}
@override
Widget build(BuildContext context) {
final ua = context.read<UserProvider>();
final ud = context.read<UserDirectoryProvider>();
return Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.chat, size: 24),
const Gap(16),
Text('shareIntentSendChannel', style: Theme.of(context).textTheme.titleLarge).tr(),
],
).padding(horizontal: 20, top: 16, bottom: 12),
LoadingIndicator(isActive: _isBusy),
Expanded(
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: RefreshIndicator(
onRefresh: () => Future.sync(() => _refreshChannels()),
child: ListView.builder(
itemCount: _channels?.length ?? 0,
itemBuilder: (context, idx) {
final channel = _channels![idx];
final lastMessage = _lastMessages?[channel.id];
if (channel.type == 1) {
final otherMember = channel.members?.cast<SnChannelMember?>().firstWhere(
(ele) => ele?.accountId != ua.user?.id,
orElse: () => null,
);
return ListTile(
title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name),
subtitle: lastMessage != null
? Text(
'${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: Text(
'channelDirectMessageDescription'.tr(args: [
'@${ud.getAccountFromCache(otherMember?.accountId)?.name}',
]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
leading: AccountImage(
content: ud.getAccountFromCache(otherMember?.accountId)?.avatar,
),
onTap: () {
GoRouter.of(context).pushNamed(
'chatRoom',
pathParameters: {
'scope': channel.realm?.alias ?? 'global',
'alias': channel.alias,
},
).then((value) {
if (mounted) _refreshChannels();
});
},
);
}
return ListTile(
title: Text(channel.name),
subtitle: lastMessage != null
? Text(
'${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: Text(
channel.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
leading: AccountImage(
content: null,
fallbackWidget: const Icon(Symbols.chat, size: 20),
),
onTap: () {
Navigator.pop(context, true);
GoRouter.of(context)
.pushNamed(
'chatRoom',
pathParameters: {
'scope': channel.realm?.alias ?? 'global',
'alias': channel.alias,
},
extra: ChatRoomScreenExtra(
initialText: widget.value
.where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type))
.map((e) => e.path)
.join('\n'),
initialAttachments: widget.value
.where((e) =>
[SharedMediaType.video, SharedMediaType.file, SharedMediaType.image].contains(e.type))
.map((e) => PostWriteMedia.fromFile(XFile(e.path)))
.toList(),
),
)
.then((value) {
if (value == true) _refreshChannels();
});
},
);
},
),
),
),
),
],
);
}
}

279
lib/screens/wallet.dart Normal file
View File

@ -0,0 +1,279 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.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/types/wallet.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class WalletScreen extends StatefulWidget {
const WalletScreen({super.key});
@override
State<WalletScreen> createState() => _WalletScreenState();
}
class _WalletScreenState extends State<WalletScreen> {
bool _isBusy = false;
SnWallet? _wallet;
Future<void> _fetchWallet() async {
try {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/wa/wallets/me');
_wallet = SnWallet.fromJson(resp.data);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchWallet();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
leading: PageBackButton(),
title: Text('screenAccountWallet').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
if (_wallet == null)
Expanded(
child: _CreateWalletWidget(
onCreate: () {
_fetchWallet();
},
),
)
else
Card(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 28,
child: Icon(Symbols.wallet, size: 28),
),
const Gap(12),
SizedBox(width: double.infinity),
Text(
NumberFormat.compactCurrency(
locale: EasyLocalization.of(context)!.currentLocale.toString(),
symbol: '${'walletCurrencyShort'.tr()} ',
decimalDigits: 2,
).format(double.parse(_wallet!.balance)),
style: Theme.of(context).textTheme.titleLarge,
),
Text('walletCurrency'.plural(double.parse(_wallet!.balance))),
],
).padding(horizontal: 20, vertical: 24),
).padding(horizontal: 8, top: 16, bottom: 4),
if (_wallet != null) Expanded(child: _WalletTransactionList(myself: _wallet!)),
],
),
);
}
}
class _WalletTransactionList extends StatefulWidget {
final SnWallet myself;
const _WalletTransactionList({required this.myself});
@override
State<_WalletTransactionList> createState() => _WalletTransactionListState();
}
class _WalletTransactionListState extends State<_WalletTransactionList> {
bool _isBusy = false;
int? _totalCount;
final List<SnTransaction> _transactions = List.empty(growable: true);
Future<void> _fetchTransactions() async {
try {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/wa/transactions/me', queryParameters: {
'take': 10,
'offset': _transactions.length,
});
_totalCount = resp.data['count'];
_transactions.addAll(
resp.data['data']?.map((e) => SnTransaction.fromJson(e)).cast<SnTransaction>() ?? [],
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchTransactions();
}
@override
Widget build(BuildContext context) {
return MediaQuery.removePadding(
context: context,
removeTop: true,
child: RefreshIndicator(
onRefresh: _fetchTransactions,
child: InfiniteList(
itemCount: _transactions.length,
isLoading: _isBusy,
hasReachedMax: _totalCount != null && _transactions.length >= _totalCount!,
onFetchData: () {
_fetchTransactions();
},
itemBuilder: (context, idx) {
final ele = _transactions[idx];
final isIncoming = ele.payeeId == widget.myself.id;
return ListTile(
leading: isIncoming ? const Icon(Symbols.call_received) : const Icon(Symbols.call_made),
title: Text(
'${isIncoming ? '+' : '-'}${ele.amount} ${'walletCurrencyShort'.tr()}',
style: TextStyle(color: isIncoming ? Colors.green : Colors.red),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(ele.remark),
const Gap(2),
Text(
DateFormat(
null,
EasyLocalization.of(context)!.currentLocale.toString(),
).format(ele.createdAt),
style: Theme.of(context).textTheme.labelSmall,
),
],
),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
);
},
),
),
);
}
}
class _CreateWalletWidget extends StatefulWidget {
final Function()? onCreate;
const _CreateWalletWidget({required this.onCreate});
@override
State<_CreateWalletWidget> createState() => _CreateWalletWidgetState();
}
class _CreateWalletWidgetState extends State<_CreateWalletWidget> {
bool _isBusy = false;
Future<void> _createWallet() async {
final TextEditingController passwordController = TextEditingController();
final password = await showDialog<String?>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('walletCreate').tr(),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('walletCreatePassword').tr(),
const Gap(8),
TextField(
autofocus: true,
obscureText: true,
controller: passwordController,
decoration: InputDecoration(
labelText: 'fieldPassword'.tr(),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text('cancel').tr(),
),
TextButton(
onPressed: () {
Navigator.of(ctx).pop(passwordController.text);
},
child: Text('next').tr(),
),
],
),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
passwordController.dispose();
});
if (password == null || password.isEmpty) return;
if (!mounted) return;
try {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
await sn.client.post('/cgi/wa/wallets/me', data: {
'password': password,
});
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
Widget build(BuildContext context) {
return Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 380),
child: Card(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 28,
child: Icon(Symbols.add, size: 28),
),
const Gap(12),
Text('walletCreate', style: Theme.of(context).textTheme.titleLarge).tr(),
Text('walletCreateSubtitle', style: Theme.of(context).textTheme.bodyMedium).tr(),
const Gap(8),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: _isBusy ? null : () => _createWallet(),
child: Text('next').tr(),
),
),
],
).padding(horizontal: 20, vertical: 24),
),
),
);
}
}

View File

@ -20,7 +20,7 @@ Future<ThemeSet> createAppThemeSet({Color? seedColorOverride, bool? useMaterial3
Future<ThemeData> createAppTheme( Future<ThemeData> createAppTheme(
Brightness brightness, { Brightness brightness, {
Color? seedColorOverride, Color? seedColorOverride,
bool? useMaterial3, bool? useMaterial3,
}) async { }) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@ -33,10 +33,11 @@ Future<ThemeData> createAppTheme(
brightness: brightness, brightness: brightness,
); );
final hasAppBarBlurry = prefs.getBool(kAppbarTransparentStoreKey) ?? false; final hasAppBarTransparent = prefs.getBool(kAppbarTransparentStoreKey) ?? false;
final useM3 = useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? true);
return ThemeData( return ThemeData(
useMaterial3: useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? false), useMaterial3: useM3,
colorScheme: colorScheme, colorScheme: colorScheme,
brightness: brightness, brightness: brightness,
iconTheme: IconThemeData( iconTheme: IconThemeData(
@ -45,12 +46,24 @@ Future<ThemeData> createAppTheme(
opticalSize: 20, opticalSize: 20,
color: colorScheme.onSurface, color: colorScheme.onSurface,
), ),
snackBarTheme: SnackBarThemeData(
behavior: useM3 ? SnackBarBehavior.floating : SnackBarBehavior.fixed,
),
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
centerTitle: true, centerTitle: true,
elevation: hasAppBarBlurry ? 0 : null, elevation: hasAppBarTransparent ? 0 : null,
backgroundColor: hasAppBarBlurry ? colorScheme.primary.withOpacity(0.3) : colorScheme.primary, backgroundColor: hasAppBarTransparent ? Colors.transparent : colorScheme.primary,
foregroundColor: hasAppBarBlurry ? colorScheme.onSurface : colorScheme.onPrimary, foregroundColor: hasAppBarTransparent ? colorScheme.onSurface : colorScheme.onPrimary,
),
pageTransitionsTheme: PageTransitionsTheme(
builders: {
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.macOS: ZoomPageTransitionsBuilder(),
TargetPlatform.fuchsia: ZoomPageTransitionsBuilder(),
TargetPlatform.linux: ZoomPageTransitionsBuilder(),
TargetPlatform.windows: ZoomPageTransitionsBuilder(),
},
), ),
scaffoldBackgroundColor: Colors.transparent,
); );
} }

View File

@ -15,12 +15,13 @@ class SnAccount with _$SnAccount {
required DateTime? deletedAt, required DateTime? deletedAt,
required DateTime? confirmedAt, required DateTime? confirmedAt,
required List<SnAccountContact>? contacts, required List<SnAccountContact>? contacts,
required String avatar, @Default("") String avatar,
required String banner, @Default("") String banner,
required String description, required String description,
required String name, required String name,
required String nick, required String nick,
required Map<String, dynamic> permNodes, @Default({}) Map<String, dynamic> permNodes,
required String language,
required SnAccountProfile? profile, required SnAccountProfile? profile,
@Default([]) List<SnAccountBadge> badges, @Default([]) List<SnAccountBadge> badges,
required DateTime? suspendedAt, required DateTime? suspendedAt,

View File

@ -33,6 +33,7 @@ mixin _$SnAccount {
String get name => throw _privateConstructorUsedError; String get name => throw _privateConstructorUsedError;
String get nick => throw _privateConstructorUsedError; String get nick => throw _privateConstructorUsedError;
Map<String, dynamic> get permNodes => throw _privateConstructorUsedError; Map<String, dynamic> get permNodes => throw _privateConstructorUsedError;
String get language => throw _privateConstructorUsedError;
SnAccountProfile? get profile => throw _privateConstructorUsedError; SnAccountProfile? get profile => throw _privateConstructorUsedError;
List<SnAccountBadge> get badges => throw _privateConstructorUsedError; List<SnAccountBadge> get badges => throw _privateConstructorUsedError;
DateTime? get suspendedAt => throw _privateConstructorUsedError; DateTime? get suspendedAt => throw _privateConstructorUsedError;
@ -69,6 +70,7 @@ abstract class $SnAccountCopyWith<$Res> {
String name, String name,
String nick, String nick,
Map<String, dynamic> permNodes, Map<String, dynamic> permNodes,
String language,
SnAccountProfile? profile, SnAccountProfile? profile,
List<SnAccountBadge> badges, List<SnAccountBadge> badges,
DateTime? suspendedAt, DateTime? suspendedAt,
@ -107,6 +109,7 @@ class _$SnAccountCopyWithImpl<$Res, $Val extends SnAccount>
Object? name = null, Object? name = null,
Object? nick = null, Object? nick = null,
Object? permNodes = null, Object? permNodes = null,
Object? language = null,
Object? profile = freezed, Object? profile = freezed,
Object? badges = null, Object? badges = null,
Object? suspendedAt = freezed, Object? suspendedAt = freezed,
@ -164,6 +167,10 @@ class _$SnAccountCopyWithImpl<$Res, $Val extends SnAccount>
? _value.permNodes ? _value.permNodes
: permNodes // ignore: cast_nullable_to_non_nullable : permNodes // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>, as Map<String, dynamic>,
language: null == language
? _value.language
: language // ignore: cast_nullable_to_non_nullable
as String,
profile: freezed == profile profile: freezed == profile
? _value.profile ? _value.profile
: profile // ignore: cast_nullable_to_non_nullable : profile // ignore: cast_nullable_to_non_nullable
@ -231,6 +238,7 @@ abstract class _$$SnAccountImplCopyWith<$Res>
String name, String name,
String nick, String nick,
Map<String, dynamic> permNodes, Map<String, dynamic> permNodes,
String language,
SnAccountProfile? profile, SnAccountProfile? profile,
List<SnAccountBadge> badges, List<SnAccountBadge> badges,
DateTime? suspendedAt, DateTime? suspendedAt,
@ -268,6 +276,7 @@ class __$$SnAccountImplCopyWithImpl<$Res>
Object? name = null, Object? name = null,
Object? nick = null, Object? nick = null,
Object? permNodes = null, Object? permNodes = null,
Object? language = null,
Object? profile = freezed, Object? profile = freezed,
Object? badges = null, Object? badges = null,
Object? suspendedAt = freezed, Object? suspendedAt = freezed,
@ -325,6 +334,10 @@ class __$$SnAccountImplCopyWithImpl<$Res>
? _value._permNodes ? _value._permNodes
: permNodes // ignore: cast_nullable_to_non_nullable : permNodes // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>, as Map<String, dynamic>,
language: null == language
? _value.language
: language // ignore: cast_nullable_to_non_nullable
as String,
profile: freezed == profile profile: freezed == profile
? _value.profile ? _value.profile
: profile // ignore: cast_nullable_to_non_nullable : profile // ignore: cast_nullable_to_non_nullable
@ -367,12 +380,13 @@ class _$SnAccountImpl extends _SnAccount {
required this.deletedAt, required this.deletedAt,
required this.confirmedAt, required this.confirmedAt,
required final List<SnAccountContact>? contacts, required final List<SnAccountContact>? contacts,
required this.avatar, this.avatar = "",
required this.banner, this.banner = "",
required this.description, required this.description,
required this.name, required this.name,
required this.nick, required this.nick,
required final Map<String, dynamic> permNodes, final Map<String, dynamic> permNodes = const {},
required this.language,
required this.profile, required this.profile,
final List<SnAccountBadge> badges = const [], final List<SnAccountBadge> badges = const [],
required this.suspendedAt, required this.suspendedAt,
@ -410,8 +424,10 @@ class _$SnAccountImpl extends _SnAccount {
} }
@override @override
@JsonKey()
final String avatar; final String avatar;
@override @override
@JsonKey()
final String banner; final String banner;
@override @override
final String description; final String description;
@ -421,12 +437,15 @@ class _$SnAccountImpl extends _SnAccount {
final String nick; final String nick;
final Map<String, dynamic> _permNodes; final Map<String, dynamic> _permNodes;
@override @override
@JsonKey()
Map<String, dynamic> get permNodes { Map<String, dynamic> get permNodes {
if (_permNodes is EqualUnmodifiableMapView) return _permNodes; if (_permNodes is EqualUnmodifiableMapView) return _permNodes;
// ignore: implicit_dynamic_type // ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_permNodes); return EqualUnmodifiableMapView(_permNodes);
} }
@override
final String language;
@override @override
final SnAccountProfile? profile; final SnAccountProfile? profile;
final List<SnAccountBadge> _badges; final List<SnAccountBadge> _badges;
@ -451,7 +470,7 @@ class _$SnAccountImpl extends _SnAccount {
@override @override
String toString() { String toString() {
return 'SnAccount(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, confirmedAt: $confirmedAt, contacts: $contacts, avatar: $avatar, banner: $banner, description: $description, name: $name, nick: $nick, permNodes: $permNodes, profile: $profile, badges: $badges, suspendedAt: $suspendedAt, affiliatedId: $affiliatedId, affiliatedTo: $affiliatedTo, automatedBy: $automatedBy, automatedId: $automatedId)'; return 'SnAccount(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, confirmedAt: $confirmedAt, contacts: $contacts, avatar: $avatar, banner: $banner, description: $description, name: $name, nick: $nick, permNodes: $permNodes, language: $language, profile: $profile, badges: $badges, suspendedAt: $suspendedAt, affiliatedId: $affiliatedId, affiliatedTo: $affiliatedTo, automatedBy: $automatedBy, automatedId: $automatedId)';
} }
@override @override
@ -477,6 +496,8 @@ class _$SnAccountImpl extends _SnAccount {
(identical(other.nick, nick) || other.nick == nick) && (identical(other.nick, nick) || other.nick == nick) &&
const DeepCollectionEquality() const DeepCollectionEquality()
.equals(other._permNodes, _permNodes) && .equals(other._permNodes, _permNodes) &&
(identical(other.language, language) ||
other.language == language) &&
(identical(other.profile, profile) || other.profile == profile) && (identical(other.profile, profile) || other.profile == profile) &&
const DeepCollectionEquality().equals(other._badges, _badges) && const DeepCollectionEquality().equals(other._badges, _badges) &&
(identical(other.suspendedAt, suspendedAt) || (identical(other.suspendedAt, suspendedAt) ||
@ -507,6 +528,7 @@ class _$SnAccountImpl extends _SnAccount {
name, name,
nick, nick,
const DeepCollectionEquality().hash(_permNodes), const DeepCollectionEquality().hash(_permNodes),
language,
profile, profile,
const DeepCollectionEquality().hash(_badges), const DeepCollectionEquality().hash(_badges),
suspendedAt, suspendedAt,
@ -540,12 +562,13 @@ abstract class _SnAccount extends SnAccount {
required final DateTime? deletedAt, required final DateTime? deletedAt,
required final DateTime? confirmedAt, required final DateTime? confirmedAt,
required final List<SnAccountContact>? contacts, required final List<SnAccountContact>? contacts,
required final String avatar, final String avatar,
required final String banner, final String banner,
required final String description, required final String description,
required final String name, required final String name,
required final String nick, required final String nick,
required final Map<String, dynamic> permNodes, final Map<String, dynamic> permNodes,
required final String language,
required final SnAccountProfile? profile, required final SnAccountProfile? profile,
final List<SnAccountBadge> badges, final List<SnAccountBadge> badges,
required final DateTime? suspendedAt, required final DateTime? suspendedAt,
@ -584,6 +607,8 @@ abstract class _SnAccount extends SnAccount {
@override @override
Map<String, dynamic> get permNodes; Map<String, dynamic> get permNodes;
@override @override
String get language;
@override
SnAccountProfile? get profile; SnAccountProfile? get profile;
@override @override
List<SnAccountBadge> get badges; List<SnAccountBadge> get badges;

View File

@ -20,12 +20,13 @@ _$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
contacts: (json['contacts'] as List<dynamic>?) contacts: (json['contacts'] as List<dynamic>?)
?.map((e) => SnAccountContact.fromJson(e as Map<String, dynamic>)) ?.map((e) => SnAccountContact.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
avatar: json['avatar'] as String, avatar: json['avatar'] as String? ?? "",
banner: json['banner'] as String, banner: json['banner'] as String? ?? "",
description: json['description'] as String, description: json['description'] as String,
name: json['name'] as String, name: json['name'] as String,
nick: json['nick'] as String, nick: json['nick'] as String,
permNodes: json['perm_nodes'] as Map<String, dynamic>, permNodes: json['perm_nodes'] as Map<String, dynamic>? ?? const {},
language: json['language'] as String,
profile: json['profile'] == null profile: json['profile'] == null
? null ? null
: SnAccountProfile.fromJson(json['profile'] as Map<String, dynamic>), : SnAccountProfile.fromJson(json['profile'] as Map<String, dynamic>),
@ -56,6 +57,7 @@ Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) =>
'name': instance.name, 'name': instance.name,
'nick': instance.nick, 'nick': instance.nick,
'perm_nodes': instance.permNodes, 'perm_nodes': instance.permNodes,
'language': instance.language,
'profile': instance.profile?.toJson(), 'profile': instance.profile?.toJson(),
'badges': instance.badges.map((e) => e.toJson()).toList(), 'badges': instance.badges.map((e) => e.toJson()).toList(),
'suspended_at': instance.suspendedAt?.toIso8601String(), 'suspended_at': instance.suspendedAt?.toIso8601String(),

View File

@ -1,9 +1,17 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
part 'check_in.freezed.dart'; part 'check_in.freezed.dart';
part 'check_in.g.dart'; part 'check_in.g.dart';
const List<String> kCheckInResultTierSymbols = ['大凶', '', '中平', '', '大吉']; final List<String> kCheckInResultTierSymbols = [
'checkInResultTier1',
'checkInResultTier2',
'checkInResultTier3',
'checkInResultTier4',
'checkInResultTier5'
].map((e) => e.tr()).toList();
@freezed @freezed
class SnCheckInRecord with _$SnCheckInRecord { class SnCheckInRecord with _$SnCheckInRecord {
@ -16,12 +24,12 @@ class SnCheckInRecord with _$SnCheckInRecord {
required DateTime? deletedAt, required DateTime? deletedAt,
required int resultTier, required int resultTier,
required int resultExperience, required int resultExperience,
required double resultCoin,
required List<int> resultModifiers, required List<int> resultModifiers,
required int accountId, required int accountId,
}) = _SnCheckInRecord; }) = _SnCheckInRecord;
factory SnCheckInRecord.fromJson(Map<String, dynamic> json) => factory SnCheckInRecord.fromJson(Map<String, dynamic> json) => _$SnCheckInRecordFromJson(json);
_$SnCheckInRecordFromJson(json);
String get symbol => kCheckInResultTierSymbols[resultTier]; String get symbol => kCheckInResultTierSymbols[resultTier];
} }

View File

@ -26,6 +26,7 @@ mixin _$SnCheckInRecord {
DateTime? get deletedAt => throw _privateConstructorUsedError; DateTime? get deletedAt => throw _privateConstructorUsedError;
int get resultTier => throw _privateConstructorUsedError; int get resultTier => throw _privateConstructorUsedError;
int get resultExperience => throw _privateConstructorUsedError; int get resultExperience => throw _privateConstructorUsedError;
double get resultCoin => throw _privateConstructorUsedError;
List<int> get resultModifiers => throw _privateConstructorUsedError; List<int> get resultModifiers => throw _privateConstructorUsedError;
int get accountId => throw _privateConstructorUsedError; int get accountId => throw _privateConstructorUsedError;
@ -52,6 +53,7 @@ abstract class $SnCheckInRecordCopyWith<$Res> {
DateTime? deletedAt, DateTime? deletedAt,
int resultTier, int resultTier,
int resultExperience, int resultExperience,
double resultCoin,
List<int> resultModifiers, List<int> resultModifiers,
int accountId}); int accountId});
} }
@ -77,6 +79,7 @@ class _$SnCheckInRecordCopyWithImpl<$Res, $Val extends SnCheckInRecord>
Object? deletedAt = freezed, Object? deletedAt = freezed,
Object? resultTier = null, Object? resultTier = null,
Object? resultExperience = null, Object? resultExperience = null,
Object? resultCoin = null,
Object? resultModifiers = null, Object? resultModifiers = null,
Object? accountId = null, Object? accountId = null,
}) { }) {
@ -105,6 +108,10 @@ class _$SnCheckInRecordCopyWithImpl<$Res, $Val extends SnCheckInRecord>
? _value.resultExperience ? _value.resultExperience
: resultExperience // ignore: cast_nullable_to_non_nullable : resultExperience // ignore: cast_nullable_to_non_nullable
as int, as int,
resultCoin: null == resultCoin
? _value.resultCoin
: resultCoin // ignore: cast_nullable_to_non_nullable
as double,
resultModifiers: null == resultModifiers resultModifiers: null == resultModifiers
? _value.resultModifiers ? _value.resultModifiers
: resultModifiers // ignore: cast_nullable_to_non_nullable : resultModifiers // ignore: cast_nullable_to_non_nullable
@ -132,6 +139,7 @@ abstract class _$$SnCheckInRecordImplCopyWith<$Res>
DateTime? deletedAt, DateTime? deletedAt,
int resultTier, int resultTier,
int resultExperience, int resultExperience,
double resultCoin,
List<int> resultModifiers, List<int> resultModifiers,
int accountId}); int accountId});
} }
@ -155,6 +163,7 @@ class __$$SnCheckInRecordImplCopyWithImpl<$Res>
Object? deletedAt = freezed, Object? deletedAt = freezed,
Object? resultTier = null, Object? resultTier = null,
Object? resultExperience = null, Object? resultExperience = null,
Object? resultCoin = null,
Object? resultModifiers = null, Object? resultModifiers = null,
Object? accountId = null, Object? accountId = null,
}) { }) {
@ -183,6 +192,10 @@ class __$$SnCheckInRecordImplCopyWithImpl<$Res>
? _value.resultExperience ? _value.resultExperience
: resultExperience // ignore: cast_nullable_to_non_nullable : resultExperience // ignore: cast_nullable_to_non_nullable
as int, as int,
resultCoin: null == resultCoin
? _value.resultCoin
: resultCoin // ignore: cast_nullable_to_non_nullable
as double,
resultModifiers: null == resultModifiers resultModifiers: null == resultModifiers
? _value._resultModifiers ? _value._resultModifiers
: resultModifiers // ignore: cast_nullable_to_non_nullable : resultModifiers // ignore: cast_nullable_to_non_nullable
@ -205,6 +218,7 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
required this.deletedAt, required this.deletedAt,
required this.resultTier, required this.resultTier,
required this.resultExperience, required this.resultExperience,
required this.resultCoin,
required final List<int> resultModifiers, required final List<int> resultModifiers,
required this.accountId}) required this.accountId})
: _resultModifiers = resultModifiers, : _resultModifiers = resultModifiers,
@ -225,6 +239,8 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
final int resultTier; final int resultTier;
@override @override
final int resultExperience; final int resultExperience;
@override
final double resultCoin;
final List<int> _resultModifiers; final List<int> _resultModifiers;
@override @override
List<int> get resultModifiers { List<int> get resultModifiers {
@ -238,7 +254,7 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
@override @override
String toString() { String toString() {
return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultModifiers: $resultModifiers, accountId: $accountId)'; return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultCoin: $resultCoin, resultModifiers: $resultModifiers, accountId: $accountId)';
} }
@override @override
@ -257,6 +273,8 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
other.resultTier == resultTier) && other.resultTier == resultTier) &&
(identical(other.resultExperience, resultExperience) || (identical(other.resultExperience, resultExperience) ||
other.resultExperience == resultExperience) && other.resultExperience == resultExperience) &&
(identical(other.resultCoin, resultCoin) ||
other.resultCoin == resultCoin) &&
const DeepCollectionEquality() const DeepCollectionEquality()
.equals(other._resultModifiers, _resultModifiers) && .equals(other._resultModifiers, _resultModifiers) &&
(identical(other.accountId, accountId) || (identical(other.accountId, accountId) ||
@ -273,6 +291,7 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
deletedAt, deletedAt,
resultTier, resultTier,
resultExperience, resultExperience,
resultCoin,
const DeepCollectionEquality().hash(_resultModifiers), const DeepCollectionEquality().hash(_resultModifiers),
accountId); accountId);
@ -301,6 +320,7 @@ abstract class _SnCheckInRecord extends SnCheckInRecord {
required final DateTime? deletedAt, required final DateTime? deletedAt,
required final int resultTier, required final int resultTier,
required final int resultExperience, required final int resultExperience,
required final double resultCoin,
required final List<int> resultModifiers, required final List<int> resultModifiers,
required final int accountId}) = _$SnCheckInRecordImpl; required final int accountId}) = _$SnCheckInRecordImpl;
const _SnCheckInRecord._() : super._(); const _SnCheckInRecord._() : super._();
@ -321,6 +341,8 @@ abstract class _SnCheckInRecord extends SnCheckInRecord {
@override @override
int get resultExperience; int get resultExperience;
@override @override
double get resultCoin;
@override
List<int> get resultModifiers; List<int> get resultModifiers;
@override @override
int get accountId; int get accountId;

View File

@ -17,6 +17,7 @@ _$SnCheckInRecordImpl _$$SnCheckInRecordImplFromJson(
: DateTime.parse(json['deleted_at'] as String), : DateTime.parse(json['deleted_at'] as String),
resultTier: (json['result_tier'] as num).toInt(), resultTier: (json['result_tier'] as num).toInt(),
resultExperience: (json['result_experience'] as num).toInt(), resultExperience: (json['result_experience'] as num).toInt(),
resultCoin: (json['result_coin'] as num).toDouble(),
resultModifiers: (json['result_modifiers'] as List<dynamic>) resultModifiers: (json['result_modifiers'] as List<dynamic>)
.map((e) => (e as num).toInt()) .map((e) => (e as num).toInt())
.toList(), .toList(),
@ -32,6 +33,7 @@ Map<String, dynamic> _$$SnCheckInRecordImplToJson(
'deleted_at': instance.deletedAt?.toIso8601String(), 'deleted_at': instance.deletedAt?.toIso8601String(),
'result_tier': instance.resultTier, 'result_tier': instance.resultTier,
'result_experience': instance.resultExperience, 'result_experience': instance.resultExperience,
'result_coin': instance.resultCoin,
'result_modifiers': instance.resultModifiers, 'result_modifiers': instance.resultModifiers,
'account_id': instance.accountId, 'account_id': instance.accountId,
}; };

38
lib/types/news.dart Normal file
View File

@ -0,0 +1,38 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'news.freezed.dart';
part 'news.g.dart';
@freezed
class SnNewsSource with _$SnNewsSource {
const factory SnNewsSource({
required String id,
required String label,
required String type,
required String source,
required int depth,
required bool enabled,
}) = _SnNewsSource;
factory SnNewsSource.fromJson(Map<String, dynamic> json) => _$SnNewsSourceFromJson(json);
}
@freezed
class SnNewsArticle with _$SnNewsArticle {
const factory SnNewsArticle({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required dynamic deletedAt,
required String thumbnail,
required String title,
required String description,
required String content,
required String url,
required String hash,
required String source,
required DateTime? publishedAt,
}) = _SnNewsArticle;
factory SnNewsArticle.fromJson(Map<String, dynamic> json) => _$SnNewsArticleFromJson(json);
}

660
lib/types/news.freezed.dart Normal file
View File

@ -0,0 +1,660 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'news.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
SnNewsSource _$SnNewsSourceFromJson(Map<String, dynamic> json) {
return _SnNewsSource.fromJson(json);
}
/// @nodoc
mixin _$SnNewsSource {
String get id => throw _privateConstructorUsedError;
String get label => throw _privateConstructorUsedError;
String get type => throw _privateConstructorUsedError;
String get source => throw _privateConstructorUsedError;
int get depth => throw _privateConstructorUsedError;
bool get enabled => throw _privateConstructorUsedError;
/// Serializes this SnNewsSource to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnNewsSource
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnNewsSourceCopyWith<SnNewsSource> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnNewsSourceCopyWith<$Res> {
factory $SnNewsSourceCopyWith(
SnNewsSource value, $Res Function(SnNewsSource) then) =
_$SnNewsSourceCopyWithImpl<$Res, SnNewsSource>;
@useResult
$Res call(
{String id,
String label,
String type,
String source,
int depth,
bool enabled});
}
/// @nodoc
class _$SnNewsSourceCopyWithImpl<$Res, $Val extends SnNewsSource>
implements $SnNewsSourceCopyWith<$Res> {
_$SnNewsSourceCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnNewsSource
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? label = null,
Object? type = null,
Object? source = null,
Object? depth = null,
Object? enabled = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
label: null == label
? _value.label
: label // ignore: cast_nullable_to_non_nullable
as String,
type: null == type
? _value.type
: type // ignore: cast_nullable_to_non_nullable
as String,
source: null == source
? _value.source
: source // ignore: cast_nullable_to_non_nullable
as String,
depth: null == depth
? _value.depth
: depth // ignore: cast_nullable_to_non_nullable
as int,
enabled: null == enabled
? _value.enabled
: enabled // ignore: cast_nullable_to_non_nullable
as bool,
) as $Val);
}
}
/// @nodoc
abstract class _$$SnNewsSourceImplCopyWith<$Res>
implements $SnNewsSourceCopyWith<$Res> {
factory _$$SnNewsSourceImplCopyWith(
_$SnNewsSourceImpl value, $Res Function(_$SnNewsSourceImpl) then) =
__$$SnNewsSourceImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{String id,
String label,
String type,
String source,
int depth,
bool enabled});
}
/// @nodoc
class __$$SnNewsSourceImplCopyWithImpl<$Res>
extends _$SnNewsSourceCopyWithImpl<$Res, _$SnNewsSourceImpl>
implements _$$SnNewsSourceImplCopyWith<$Res> {
__$$SnNewsSourceImplCopyWithImpl(
_$SnNewsSourceImpl _value, $Res Function(_$SnNewsSourceImpl) _then)
: super(_value, _then);
/// Create a copy of SnNewsSource
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? label = null,
Object? type = null,
Object? source = null,
Object? depth = null,
Object? enabled = null,
}) {
return _then(_$SnNewsSourceImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
label: null == label
? _value.label
: label // ignore: cast_nullable_to_non_nullable
as String,
type: null == type
? _value.type
: type // ignore: cast_nullable_to_non_nullable
as String,
source: null == source
? _value.source
: source // ignore: cast_nullable_to_non_nullable
as String,
depth: null == depth
? _value.depth
: depth // ignore: cast_nullable_to_non_nullable
as int,
enabled: null == enabled
? _value.enabled
: enabled // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnNewsSourceImpl implements _SnNewsSource {
const _$SnNewsSourceImpl(
{required this.id,
required this.label,
required this.type,
required this.source,
required this.depth,
required this.enabled});
factory _$SnNewsSourceImpl.fromJson(Map<String, dynamic> json) =>
_$$SnNewsSourceImplFromJson(json);
@override
final String id;
@override
final String label;
@override
final String type;
@override
final String source;
@override
final int depth;
@override
final bool enabled;
@override
String toString() {
return 'SnNewsSource(id: $id, label: $label, type: $type, source: $source, depth: $depth, enabled: $enabled)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnNewsSourceImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.label, label) || other.label == label) &&
(identical(other.type, type) || other.type == type) &&
(identical(other.source, source) || other.source == source) &&
(identical(other.depth, depth) || other.depth == depth) &&
(identical(other.enabled, enabled) || other.enabled == enabled));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode =>
Object.hash(runtimeType, id, label, type, source, depth, enabled);
/// Create a copy of SnNewsSource
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnNewsSourceImplCopyWith<_$SnNewsSourceImpl> get copyWith =>
__$$SnNewsSourceImplCopyWithImpl<_$SnNewsSourceImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnNewsSourceImplToJson(
this,
);
}
}
abstract class _SnNewsSource implements SnNewsSource {
const factory _SnNewsSource(
{required final String id,
required final String label,
required final String type,
required final String source,
required final int depth,
required final bool enabled}) = _$SnNewsSourceImpl;
factory _SnNewsSource.fromJson(Map<String, dynamic> json) =
_$SnNewsSourceImpl.fromJson;
@override
String get id;
@override
String get label;
@override
String get type;
@override
String get source;
@override
int get depth;
@override
bool get enabled;
/// Create a copy of SnNewsSource
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnNewsSourceImplCopyWith<_$SnNewsSourceImpl> get copyWith =>
throw _privateConstructorUsedError;
}
SnNewsArticle _$SnNewsArticleFromJson(Map<String, dynamic> json) {
return _SnNewsArticle.fromJson(json);
}
/// @nodoc
mixin _$SnNewsArticle {
int get id => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
dynamic get deletedAt => throw _privateConstructorUsedError;
String get thumbnail => throw _privateConstructorUsedError;
String get title => throw _privateConstructorUsedError;
String get description => throw _privateConstructorUsedError;
String get content => throw _privateConstructorUsedError;
String get url => throw _privateConstructorUsedError;
String get hash => throw _privateConstructorUsedError;
String get source => throw _privateConstructorUsedError;
DateTime? get publishedAt => throw _privateConstructorUsedError;
/// Serializes this SnNewsArticle to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnNewsArticle
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnNewsArticleCopyWith<SnNewsArticle> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnNewsArticleCopyWith<$Res> {
factory $SnNewsArticleCopyWith(
SnNewsArticle value, $Res Function(SnNewsArticle) then) =
_$SnNewsArticleCopyWithImpl<$Res, SnNewsArticle>;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
dynamic deletedAt,
String thumbnail,
String title,
String description,
String content,
String url,
String hash,
String source,
DateTime? publishedAt});
}
/// @nodoc
class _$SnNewsArticleCopyWithImpl<$Res, $Val extends SnNewsArticle>
implements $SnNewsArticleCopyWith<$Res> {
_$SnNewsArticleCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnNewsArticle
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? thumbnail = null,
Object? title = null,
Object? description = null,
Object? content = null,
Object? url = null,
Object? hash = null,
Object? source = null,
Object? publishedAt = freezed,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as dynamic,
thumbnail: null == thumbnail
? _value.thumbnail
: thumbnail // ignore: cast_nullable_to_non_nullable
as String,
title: null == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String,
description: null == description
? _value.description
: description // ignore: cast_nullable_to_non_nullable
as String,
content: null == content
? _value.content
: content // ignore: cast_nullable_to_non_nullable
as String,
url: null == url
? _value.url
: url // ignore: cast_nullable_to_non_nullable
as String,
hash: null == hash
? _value.hash
: hash // ignore: cast_nullable_to_non_nullable
as String,
source: null == source
? _value.source
: source // ignore: cast_nullable_to_non_nullable
as String,
publishedAt: freezed == publishedAt
? _value.publishedAt
: publishedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
) as $Val);
}
}
/// @nodoc
abstract class _$$SnNewsArticleImplCopyWith<$Res>
implements $SnNewsArticleCopyWith<$Res> {
factory _$$SnNewsArticleImplCopyWith(
_$SnNewsArticleImpl value, $Res Function(_$SnNewsArticleImpl) then) =
__$$SnNewsArticleImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
dynamic deletedAt,
String thumbnail,
String title,
String description,
String content,
String url,
String hash,
String source,
DateTime? publishedAt});
}
/// @nodoc
class __$$SnNewsArticleImplCopyWithImpl<$Res>
extends _$SnNewsArticleCopyWithImpl<$Res, _$SnNewsArticleImpl>
implements _$$SnNewsArticleImplCopyWith<$Res> {
__$$SnNewsArticleImplCopyWithImpl(
_$SnNewsArticleImpl _value, $Res Function(_$SnNewsArticleImpl) _then)
: super(_value, _then);
/// Create a copy of SnNewsArticle
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? thumbnail = null,
Object? title = null,
Object? description = null,
Object? content = null,
Object? url = null,
Object? hash = null,
Object? source = null,
Object? publishedAt = freezed,
}) {
return _then(_$SnNewsArticleImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as dynamic,
thumbnail: null == thumbnail
? _value.thumbnail
: thumbnail // ignore: cast_nullable_to_non_nullable
as String,
title: null == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String,
description: null == description
? _value.description
: description // ignore: cast_nullable_to_non_nullable
as String,
content: null == content
? _value.content
: content // ignore: cast_nullable_to_non_nullable
as String,
url: null == url
? _value.url
: url // ignore: cast_nullable_to_non_nullable
as String,
hash: null == hash
? _value.hash
: hash // ignore: cast_nullable_to_non_nullable
as String,
source: null == source
? _value.source
: source // ignore: cast_nullable_to_non_nullable
as String,
publishedAt: freezed == publishedAt
? _value.publishedAt
: publishedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnNewsArticleImpl implements _SnNewsArticle {
const _$SnNewsArticleImpl(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.thumbnail,
required this.title,
required this.description,
required this.content,
required this.url,
required this.hash,
required this.source,
required this.publishedAt});
factory _$SnNewsArticleImpl.fromJson(Map<String, dynamic> json) =>
_$$SnNewsArticleImplFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final dynamic deletedAt;
@override
final String thumbnail;
@override
final String title;
@override
final String description;
@override
final String content;
@override
final String url;
@override
final String hash;
@override
final String source;
@override
final DateTime? publishedAt;
@override
String toString() {
return 'SnNewsArticle(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, thumbnail: $thumbnail, title: $title, description: $description, content: $content, url: $url, hash: $hash, source: $source, publishedAt: $publishedAt)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnNewsArticleImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
const DeepCollectionEquality().equals(other.deletedAt, deletedAt) &&
(identical(other.thumbnail, thumbnail) ||
other.thumbnail == thumbnail) &&
(identical(other.title, title) || other.title == title) &&
(identical(other.description, description) ||
other.description == description) &&
(identical(other.content, content) || other.content == content) &&
(identical(other.url, url) || other.url == url) &&
(identical(other.hash, hash) || other.hash == hash) &&
(identical(other.source, source) || other.source == source) &&
(identical(other.publishedAt, publishedAt) ||
other.publishedAt == publishedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
const DeepCollectionEquality().hash(deletedAt),
thumbnail,
title,
description,
content,
url,
hash,
source,
publishedAt);
/// Create a copy of SnNewsArticle
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnNewsArticleImplCopyWith<_$SnNewsArticleImpl> get copyWith =>
__$$SnNewsArticleImplCopyWithImpl<_$SnNewsArticleImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnNewsArticleImplToJson(
this,
);
}
}
abstract class _SnNewsArticle implements SnNewsArticle {
const factory _SnNewsArticle(
{required final int id,
required final DateTime createdAt,
required final DateTime updatedAt,
required final dynamic deletedAt,
required final String thumbnail,
required final String title,
required final String description,
required final String content,
required final String url,
required final String hash,
required final String source,
required final DateTime? publishedAt}) = _$SnNewsArticleImpl;
factory _SnNewsArticle.fromJson(Map<String, dynamic> json) =
_$SnNewsArticleImpl.fromJson;
@override
int get id;
@override
DateTime get createdAt;
@override
DateTime get updatedAt;
@override
dynamic get deletedAt;
@override
String get thumbnail;
@override
String get title;
@override
String get description;
@override
String get content;
@override
String get url;
@override
String get hash;
@override
String get source;
@override
DateTime? get publishedAt;
/// Create a copy of SnNewsArticle
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnNewsArticleImplCopyWith<_$SnNewsArticleImpl> get copyWith =>
throw _privateConstructorUsedError;
}

61
lib/types/news.g.dart Normal file
View File

@ -0,0 +1,61 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'news.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SnNewsSourceImpl _$$SnNewsSourceImplFromJson(Map<String, dynamic> json) =>
_$SnNewsSourceImpl(
id: json['id'] as String,
label: json['label'] as String,
type: json['type'] as String,
source: json['source'] as String,
depth: (json['depth'] as num).toInt(),
enabled: json['enabled'] as bool,
);
Map<String, dynamic> _$$SnNewsSourceImplToJson(_$SnNewsSourceImpl instance) =>
<String, dynamic>{
'id': instance.id,
'label': instance.label,
'type': instance.type,
'source': instance.source,
'depth': instance.depth,
'enabled': instance.enabled,
};
_$SnNewsArticleImpl _$$SnNewsArticleImplFromJson(Map<String, dynamic> json) =>
_$SnNewsArticleImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'],
thumbnail: json['thumbnail'] as String,
title: json['title'] as String,
description: json['description'] as String,
content: json['content'] as String,
url: json['url'] as String,
hash: json['hash'] as String,
source: json['source'] as String,
publishedAt: json['published_at'] == null
? null
: DateTime.parse(json['published_at'] as String),
);
Map<String, dynamic> _$$SnNewsArticleImplToJson(_$SnNewsArticleImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt,
'thumbnail': instance.thumbnail,
'title': instance.title,
'description': instance.description,
'content': instance.content,
'url': instance.url,
'hash': instance.hash,
'source': instance.source,
'published_at': instance.publishedAt?.toIso8601String(),
};

45
lib/types/poll.dart Normal file
View File

@ -0,0 +1,45 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'poll.freezed.dart';
part 'poll.g.dart';
@freezed
class SnPoll with _$SnPoll {
const factory SnPoll({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required dynamic deletedAt,
required dynamic expiredAt,
required List<SnPollOption> options,
required int accountId,
required SnPollMetric metric,
}) = _SnPoll;
factory SnPoll.fromJson(Map<String, Object?> json) => _$SnPollFromJson(json);
}
@freezed
class SnPollMetric with _$SnPollMetric {
const factory SnPollMetric({
required int totalAnswer,
@Default({}) Map<String, int> byOptions,
@Default({}) Map<String, double> byOptionsPercentage,
}) = _SnPollMetric;
factory SnPollMetric.fromJson(Map<String, Object?> json) =>
_$SnPollMetricFromJson(json);
}
@freezed
class SnPollOption with _$SnPollOption {
const factory SnPollOption({
required String id,
required String icon,
required String name,
required String description,
}) = _SnPollOption;
factory SnPollOption.fromJson(Map<String, Object?> json) =>
_$SnPollOptionFromJson(json);
}

761
lib/types/poll.freezed.dart Normal file
View File

@ -0,0 +1,761 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'poll.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
SnPoll _$SnPollFromJson(Map<String, dynamic> json) {
return _SnPoll.fromJson(json);
}
/// @nodoc
mixin _$SnPoll {
int get id => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
dynamic get deletedAt => throw _privateConstructorUsedError;
dynamic get expiredAt => throw _privateConstructorUsedError;
List<SnPollOption> get options => throw _privateConstructorUsedError;
int get accountId => throw _privateConstructorUsedError;
SnPollMetric get metric => throw _privateConstructorUsedError;
/// Serializes this SnPoll to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnPoll
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnPollCopyWith<SnPoll> get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnPollCopyWith<$Res> {
factory $SnPollCopyWith(SnPoll value, $Res Function(SnPoll) then) =
_$SnPollCopyWithImpl<$Res, SnPoll>;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
dynamic deletedAt,
dynamic expiredAt,
List<SnPollOption> options,
int accountId,
SnPollMetric metric});
$SnPollMetricCopyWith<$Res> get metric;
}
/// @nodoc
class _$SnPollCopyWithImpl<$Res, $Val extends SnPoll>
implements $SnPollCopyWith<$Res> {
_$SnPollCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnPoll
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? expiredAt = freezed,
Object? options = null,
Object? accountId = null,
Object? metric = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as dynamic,
expiredAt: freezed == expiredAt
? _value.expiredAt
: expiredAt // ignore: cast_nullable_to_non_nullable
as dynamic,
options: null == options
? _value.options
: options // ignore: cast_nullable_to_non_nullable
as List<SnPollOption>,
accountId: null == accountId
? _value.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
metric: null == metric
? _value.metric
: metric // ignore: cast_nullable_to_non_nullable
as SnPollMetric,
) as $Val);
}
/// Create a copy of SnPoll
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnPollMetricCopyWith<$Res> get metric {
return $SnPollMetricCopyWith<$Res>(_value.metric, (value) {
return _then(_value.copyWith(metric: value) as $Val);
});
}
}
/// @nodoc
abstract class _$$SnPollImplCopyWith<$Res> implements $SnPollCopyWith<$Res> {
factory _$$SnPollImplCopyWith(
_$SnPollImpl value, $Res Function(_$SnPollImpl) then) =
__$$SnPollImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
dynamic deletedAt,
dynamic expiredAt,
List<SnPollOption> options,
int accountId,
SnPollMetric metric});
@override
$SnPollMetricCopyWith<$Res> get metric;
}
/// @nodoc
class __$$SnPollImplCopyWithImpl<$Res>
extends _$SnPollCopyWithImpl<$Res, _$SnPollImpl>
implements _$$SnPollImplCopyWith<$Res> {
__$$SnPollImplCopyWithImpl(
_$SnPollImpl _value, $Res Function(_$SnPollImpl) _then)
: super(_value, _then);
/// Create a copy of SnPoll
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? expiredAt = freezed,
Object? options = null,
Object? accountId = null,
Object? metric = null,
}) {
return _then(_$SnPollImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as dynamic,
expiredAt: freezed == expiredAt
? _value.expiredAt
: expiredAt // ignore: cast_nullable_to_non_nullable
as dynamic,
options: null == options
? _value._options
: options // ignore: cast_nullable_to_non_nullable
as List<SnPollOption>,
accountId: null == accountId
? _value.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
metric: null == metric
? _value.metric
: metric // ignore: cast_nullable_to_non_nullable
as SnPollMetric,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnPollImpl implements _SnPoll {
const _$SnPollImpl(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.expiredAt,
required final List<SnPollOption> options,
required this.accountId,
required this.metric})
: _options = options;
factory _$SnPollImpl.fromJson(Map<String, dynamic> json) =>
_$$SnPollImplFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final dynamic deletedAt;
@override
final dynamic expiredAt;
final List<SnPollOption> _options;
@override
List<SnPollOption> get options {
if (_options is EqualUnmodifiableListView) return _options;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_options);
}
@override
final int accountId;
@override
final SnPollMetric metric;
@override
String toString() {
return 'SnPoll(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, expiredAt: $expiredAt, options: $options, accountId: $accountId, metric: $metric)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnPollImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
const DeepCollectionEquality().equals(other.deletedAt, deletedAt) &&
const DeepCollectionEquality().equals(other.expiredAt, expiredAt) &&
const DeepCollectionEquality().equals(other._options, _options) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId) &&
(identical(other.metric, metric) || other.metric == metric));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
const DeepCollectionEquality().hash(deletedAt),
const DeepCollectionEquality().hash(expiredAt),
const DeepCollectionEquality().hash(_options),
accountId,
metric);
/// Create a copy of SnPoll
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnPollImplCopyWith<_$SnPollImpl> get copyWith =>
__$$SnPollImplCopyWithImpl<_$SnPollImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnPollImplToJson(
this,
);
}
}
abstract class _SnPoll implements SnPoll {
const factory _SnPoll(
{required final int id,
required final DateTime createdAt,
required final DateTime updatedAt,
required final dynamic deletedAt,
required final dynamic expiredAt,
required final List<SnPollOption> options,
required final int accountId,
required final SnPollMetric metric}) = _$SnPollImpl;
factory _SnPoll.fromJson(Map<String, dynamic> json) = _$SnPollImpl.fromJson;
@override
int get id;
@override
DateTime get createdAt;
@override
DateTime get updatedAt;
@override
dynamic get deletedAt;
@override
dynamic get expiredAt;
@override
List<SnPollOption> get options;
@override
int get accountId;
@override
SnPollMetric get metric;
/// Create a copy of SnPoll
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnPollImplCopyWith<_$SnPollImpl> get copyWith =>
throw _privateConstructorUsedError;
}
SnPollMetric _$SnPollMetricFromJson(Map<String, dynamic> json) {
return _SnPollMetric.fromJson(json);
}
/// @nodoc
mixin _$SnPollMetric {
int get totalAnswer => throw _privateConstructorUsedError;
Map<String, int> get byOptions => throw _privateConstructorUsedError;
Map<String, double> get byOptionsPercentage =>
throw _privateConstructorUsedError;
/// Serializes this SnPollMetric to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnPollMetric
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnPollMetricCopyWith<SnPollMetric> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnPollMetricCopyWith<$Res> {
factory $SnPollMetricCopyWith(
SnPollMetric value, $Res Function(SnPollMetric) then) =
_$SnPollMetricCopyWithImpl<$Res, SnPollMetric>;
@useResult
$Res call(
{int totalAnswer,
Map<String, int> byOptions,
Map<String, double> byOptionsPercentage});
}
/// @nodoc
class _$SnPollMetricCopyWithImpl<$Res, $Val extends SnPollMetric>
implements $SnPollMetricCopyWith<$Res> {
_$SnPollMetricCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnPollMetric
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? totalAnswer = null,
Object? byOptions = null,
Object? byOptionsPercentage = null,
}) {
return _then(_value.copyWith(
totalAnswer: null == totalAnswer
? _value.totalAnswer
: totalAnswer // ignore: cast_nullable_to_non_nullable
as int,
byOptions: null == byOptions
? _value.byOptions
: byOptions // ignore: cast_nullable_to_non_nullable
as Map<String, int>,
byOptionsPercentage: null == byOptionsPercentage
? _value.byOptionsPercentage
: byOptionsPercentage // ignore: cast_nullable_to_non_nullable
as Map<String, double>,
) as $Val);
}
}
/// @nodoc
abstract class _$$SnPollMetricImplCopyWith<$Res>
implements $SnPollMetricCopyWith<$Res> {
factory _$$SnPollMetricImplCopyWith(
_$SnPollMetricImpl value, $Res Function(_$SnPollMetricImpl) then) =
__$$SnPollMetricImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int totalAnswer,
Map<String, int> byOptions,
Map<String, double> byOptionsPercentage});
}
/// @nodoc
class __$$SnPollMetricImplCopyWithImpl<$Res>
extends _$SnPollMetricCopyWithImpl<$Res, _$SnPollMetricImpl>
implements _$$SnPollMetricImplCopyWith<$Res> {
__$$SnPollMetricImplCopyWithImpl(
_$SnPollMetricImpl _value, $Res Function(_$SnPollMetricImpl) _then)
: super(_value, _then);
/// Create a copy of SnPollMetric
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? totalAnswer = null,
Object? byOptions = null,
Object? byOptionsPercentage = null,
}) {
return _then(_$SnPollMetricImpl(
totalAnswer: null == totalAnswer
? _value.totalAnswer
: totalAnswer // ignore: cast_nullable_to_non_nullable
as int,
byOptions: null == byOptions
? _value._byOptions
: byOptions // ignore: cast_nullable_to_non_nullable
as Map<String, int>,
byOptionsPercentage: null == byOptionsPercentage
? _value._byOptionsPercentage
: byOptionsPercentage // ignore: cast_nullable_to_non_nullable
as Map<String, double>,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnPollMetricImpl implements _SnPollMetric {
const _$SnPollMetricImpl(
{required this.totalAnswer,
final Map<String, int> byOptions = const {},
final Map<String, double> byOptionsPercentage = const {}})
: _byOptions = byOptions,
_byOptionsPercentage = byOptionsPercentage;
factory _$SnPollMetricImpl.fromJson(Map<String, dynamic> json) =>
_$$SnPollMetricImplFromJson(json);
@override
final int totalAnswer;
final Map<String, int> _byOptions;
@override
@JsonKey()
Map<String, int> get byOptions {
if (_byOptions is EqualUnmodifiableMapView) return _byOptions;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_byOptions);
}
final Map<String, double> _byOptionsPercentage;
@override
@JsonKey()
Map<String, double> get byOptionsPercentage {
if (_byOptionsPercentage is EqualUnmodifiableMapView)
return _byOptionsPercentage;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_byOptionsPercentage);
}
@override
String toString() {
return 'SnPollMetric(totalAnswer: $totalAnswer, byOptions: $byOptions, byOptionsPercentage: $byOptionsPercentage)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnPollMetricImpl &&
(identical(other.totalAnswer, totalAnswer) ||
other.totalAnswer == totalAnswer) &&
const DeepCollectionEquality()
.equals(other._byOptions, _byOptions) &&
const DeepCollectionEquality()
.equals(other._byOptionsPercentage, _byOptionsPercentage));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
totalAnswer,
const DeepCollectionEquality().hash(_byOptions),
const DeepCollectionEquality().hash(_byOptionsPercentage));
/// Create a copy of SnPollMetric
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnPollMetricImplCopyWith<_$SnPollMetricImpl> get copyWith =>
__$$SnPollMetricImplCopyWithImpl<_$SnPollMetricImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnPollMetricImplToJson(
this,
);
}
}
abstract class _SnPollMetric implements SnPollMetric {
const factory _SnPollMetric(
{required final int totalAnswer,
final Map<String, int> byOptions,
final Map<String, double> byOptionsPercentage}) = _$SnPollMetricImpl;
factory _SnPollMetric.fromJson(Map<String, dynamic> json) =
_$SnPollMetricImpl.fromJson;
@override
int get totalAnswer;
@override
Map<String, int> get byOptions;
@override
Map<String, double> get byOptionsPercentage;
/// Create a copy of SnPollMetric
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnPollMetricImplCopyWith<_$SnPollMetricImpl> get copyWith =>
throw _privateConstructorUsedError;
}
SnPollOption _$SnPollOptionFromJson(Map<String, dynamic> json) {
return _SnPollOption.fromJson(json);
}
/// @nodoc
mixin _$SnPollOption {
String get id => throw _privateConstructorUsedError;
String get icon => throw _privateConstructorUsedError;
String get name => throw _privateConstructorUsedError;
String get description => throw _privateConstructorUsedError;
/// Serializes this SnPollOption to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnPollOption
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnPollOptionCopyWith<SnPollOption> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnPollOptionCopyWith<$Res> {
factory $SnPollOptionCopyWith(
SnPollOption value, $Res Function(SnPollOption) then) =
_$SnPollOptionCopyWithImpl<$Res, SnPollOption>;
@useResult
$Res call({String id, String icon, String name, String description});
}
/// @nodoc
class _$SnPollOptionCopyWithImpl<$Res, $Val extends SnPollOption>
implements $SnPollOptionCopyWith<$Res> {
_$SnPollOptionCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnPollOption
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? icon = null,
Object? name = null,
Object? description = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
icon: null == icon
? _value.icon
: icon // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
description: null == description
? _value.description
: description // ignore: cast_nullable_to_non_nullable
as String,
) as $Val);
}
}
/// @nodoc
abstract class _$$SnPollOptionImplCopyWith<$Res>
implements $SnPollOptionCopyWith<$Res> {
factory _$$SnPollOptionImplCopyWith(
_$SnPollOptionImpl value, $Res Function(_$SnPollOptionImpl) then) =
__$$SnPollOptionImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({String id, String icon, String name, String description});
}
/// @nodoc
class __$$SnPollOptionImplCopyWithImpl<$Res>
extends _$SnPollOptionCopyWithImpl<$Res, _$SnPollOptionImpl>
implements _$$SnPollOptionImplCopyWith<$Res> {
__$$SnPollOptionImplCopyWithImpl(
_$SnPollOptionImpl _value, $Res Function(_$SnPollOptionImpl) _then)
: super(_value, _then);
/// Create a copy of SnPollOption
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? icon = null,
Object? name = null,
Object? description = null,
}) {
return _then(_$SnPollOptionImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
icon: null == icon
? _value.icon
: icon // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
description: null == description
? _value.description
: description // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnPollOptionImpl implements _SnPollOption {
const _$SnPollOptionImpl(
{required this.id,
required this.icon,
required this.name,
required this.description});
factory _$SnPollOptionImpl.fromJson(Map<String, dynamic> json) =>
_$$SnPollOptionImplFromJson(json);
@override
final String id;
@override
final String icon;
@override
final String name;
@override
final String description;
@override
String toString() {
return 'SnPollOption(id: $id, icon: $icon, name: $name, description: $description)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnPollOptionImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.icon, icon) || other.icon == icon) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.description, description) ||
other.description == description));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, id, icon, name, description);
/// Create a copy of SnPollOption
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnPollOptionImplCopyWith<_$SnPollOptionImpl> get copyWith =>
__$$SnPollOptionImplCopyWithImpl<_$SnPollOptionImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnPollOptionImplToJson(
this,
);
}
}
abstract class _SnPollOption implements SnPollOption {
const factory _SnPollOption(
{required final String id,
required final String icon,
required final String name,
required final String description}) = _$SnPollOptionImpl;
factory _SnPollOption.fromJson(Map<String, dynamic> json) =
_$SnPollOptionImpl.fromJson;
@override
String get id;
@override
String get icon;
@override
String get name;
@override
String get description;
/// Create a copy of SnPollOption
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnPollOptionImplCopyWith<_$SnPollOptionImpl> get copyWith =>
throw _privateConstructorUsedError;
}

69
lib/types/poll.g.dart Normal file
View File

@ -0,0 +1,69 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'poll.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SnPollImpl _$$SnPollImplFromJson(Map<String, dynamic> json) => _$SnPollImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'],
expiredAt: json['expired_at'],
options: (json['options'] as List<dynamic>)
.map((e) => SnPollOption.fromJson(e as Map<String, dynamic>))
.toList(),
accountId: (json['account_id'] as num).toInt(),
metric: SnPollMetric.fromJson(json['metric'] as Map<String, dynamic>),
);
Map<String, dynamic> _$$SnPollImplToJson(_$SnPollImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt,
'expired_at': instance.expiredAt,
'options': instance.options.map((e) => e.toJson()).toList(),
'account_id': instance.accountId,
'metric': instance.metric.toJson(),
};
_$SnPollMetricImpl _$$SnPollMetricImplFromJson(Map<String, dynamic> json) =>
_$SnPollMetricImpl(
totalAnswer: (json['total_answer'] as num).toInt(),
byOptions: (json['by_options'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toInt()),
) ??
const {},
byOptionsPercentage:
(json['by_options_percentage'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toDouble()),
) ??
const {},
);
Map<String, dynamic> _$$SnPollMetricImplToJson(_$SnPollMetricImpl instance) =>
<String, dynamic>{
'total_answer': instance.totalAnswer,
'by_options': instance.byOptions,
'by_options_percentage': instance.byOptionsPercentage,
};
_$SnPollOptionImpl _$$SnPollOptionImplFromJson(Map<String, dynamic> json) =>
_$SnPollOptionImpl(
id: json['id'] as String,
icon: json['icon'] as String,
name: json['name'] as String,
description: json['description'] as String,
);
Map<String, dynamic> _$$SnPollOptionImplToJson(_$SnPollOptionImpl instance) =>
<String, dynamic>{
'id': instance.id,
'icon': instance.icon,
'name': instance.name,
'description': instance.description,
};

View File

@ -1,5 +1,6 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:surface/types/attachment.dart'; import 'package:surface/types/attachment.dart';
import 'package:surface/types/poll.dart';
part 'post.freezed.dart'; part 'post.freezed.dart';
part 'post.g.dart'; part 'post.g.dart';
@ -36,7 +37,10 @@ class SnPost with _$SnPost {
required DateTime? publishedUntil, required DateTime? publishedUntil,
required int totalUpvote, required int totalUpvote,
required int totalDownvote, required int totalDownvote,
@Default(0) int totalViews,
@Default(0) int totalAggregatedViews,
required int publisherId, required int publisherId,
required int? pollId,
required SnPublisher publisher, required SnPublisher publisher,
required SnMetric metric, required SnMetric metric,
SnPostPreload? preload, SnPostPreload? preload,
@ -89,6 +93,8 @@ class SnPostPreload with _$SnPostPreload {
const factory SnPostPreload({ const factory SnPostPreload({
required SnAttachment? thumbnail, required SnAttachment? thumbnail,
required List<SnAttachment?>? attachments, required List<SnAttachment?>? attachments,
required SnAttachment? video,
required SnPoll? poll,
}) = _SnPostPreload; }) = _SnPostPreload;
factory SnPostPreload.fromJson(Map<String, Object?> json) => factory SnPostPreload.fromJson(Map<String, Object?> json) =>

View File

@ -47,7 +47,10 @@ mixin _$SnPost {
DateTime? get publishedUntil => throw _privateConstructorUsedError; DateTime? get publishedUntil => throw _privateConstructorUsedError;
int get totalUpvote => throw _privateConstructorUsedError; int get totalUpvote => throw _privateConstructorUsedError;
int get totalDownvote => throw _privateConstructorUsedError; int get totalDownvote => throw _privateConstructorUsedError;
int get totalViews => throw _privateConstructorUsedError;
int get totalAggregatedViews => throw _privateConstructorUsedError;
int get publisherId => throw _privateConstructorUsedError; int get publisherId => throw _privateConstructorUsedError;
int? get pollId => throw _privateConstructorUsedError;
SnPublisher get publisher => throw _privateConstructorUsedError; SnPublisher get publisher => throw _privateConstructorUsedError;
SnMetric get metric => throw _privateConstructorUsedError; SnMetric get metric => throw _privateConstructorUsedError;
SnPostPreload? get preload => throw _privateConstructorUsedError; SnPostPreload? get preload => throw _privateConstructorUsedError;
@ -94,7 +97,10 @@ abstract class $SnPostCopyWith<$Res> {
DateTime? publishedUntil, DateTime? publishedUntil,
int totalUpvote, int totalUpvote,
int totalDownvote, int totalDownvote,
int totalViews,
int totalAggregatedViews,
int publisherId, int publisherId,
int? pollId,
SnPublisher publisher, SnPublisher publisher,
SnMetric metric, SnMetric metric,
SnPostPreload? preload}); SnPostPreload? preload});
@ -148,7 +154,10 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost>
Object? publishedUntil = freezed, Object? publishedUntil = freezed,
Object? totalUpvote = null, Object? totalUpvote = null,
Object? totalDownvote = null, Object? totalDownvote = null,
Object? totalViews = null,
Object? totalAggregatedViews = null,
Object? publisherId = null, Object? publisherId = null,
Object? pollId = freezed,
Object? publisher = null, Object? publisher = null,
Object? metric = null, Object? metric = null,
Object? preload = freezed, Object? preload = freezed,
@ -262,10 +271,22 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost>
? _value.totalDownvote ? _value.totalDownvote
: totalDownvote // ignore: cast_nullable_to_non_nullable : totalDownvote // ignore: cast_nullable_to_non_nullable
as int, as int,
totalViews: null == totalViews
? _value.totalViews
: totalViews // ignore: cast_nullable_to_non_nullable
as int,
totalAggregatedViews: null == totalAggregatedViews
? _value.totalAggregatedViews
: totalAggregatedViews // ignore: cast_nullable_to_non_nullable
as int,
publisherId: null == publisherId publisherId: null == publisherId
? _value.publisherId ? _value.publisherId
: publisherId // ignore: cast_nullable_to_non_nullable : publisherId // ignore: cast_nullable_to_non_nullable
as int, as int,
pollId: freezed == pollId
? _value.pollId
: pollId // ignore: cast_nullable_to_non_nullable
as int?,
publisher: null == publisher publisher: null == publisher
? _value.publisher ? _value.publisher
: publisher // ignore: cast_nullable_to_non_nullable : publisher // ignore: cast_nullable_to_non_nullable
@ -379,7 +400,10 @@ abstract class _$$SnPostImplCopyWith<$Res> implements $SnPostCopyWith<$Res> {
DateTime? publishedUntil, DateTime? publishedUntil,
int totalUpvote, int totalUpvote,
int totalDownvote, int totalDownvote,
int totalViews,
int totalAggregatedViews,
int publisherId, int publisherId,
int? pollId,
SnPublisher publisher, SnPublisher publisher,
SnMetric metric, SnMetric metric,
SnPostPreload? preload}); SnPostPreload? preload});
@ -436,7 +460,10 @@ class __$$SnPostImplCopyWithImpl<$Res>
Object? publishedUntil = freezed, Object? publishedUntil = freezed,
Object? totalUpvote = null, Object? totalUpvote = null,
Object? totalDownvote = null, Object? totalDownvote = null,
Object? totalViews = null,
Object? totalAggregatedViews = null,
Object? publisherId = null, Object? publisherId = null,
Object? pollId = freezed,
Object? publisher = null, Object? publisher = null,
Object? metric = null, Object? metric = null,
Object? preload = freezed, Object? preload = freezed,
@ -550,10 +577,22 @@ class __$$SnPostImplCopyWithImpl<$Res>
? _value.totalDownvote ? _value.totalDownvote
: totalDownvote // ignore: cast_nullable_to_non_nullable : totalDownvote // ignore: cast_nullable_to_non_nullable
as int, as int,
totalViews: null == totalViews
? _value.totalViews
: totalViews // ignore: cast_nullable_to_non_nullable
as int,
totalAggregatedViews: null == totalAggregatedViews
? _value.totalAggregatedViews
: totalAggregatedViews // ignore: cast_nullable_to_non_nullable
as int,
publisherId: null == publisherId publisherId: null == publisherId
? _value.publisherId ? _value.publisherId
: publisherId // ignore: cast_nullable_to_non_nullable : publisherId // ignore: cast_nullable_to_non_nullable
as int, as int,
pollId: freezed == pollId
? _value.pollId
: pollId // ignore: cast_nullable_to_non_nullable
as int?,
publisher: null == publisher publisher: null == publisher
? _value.publisher ? _value.publisher
: publisher // ignore: cast_nullable_to_non_nullable : publisher // ignore: cast_nullable_to_non_nullable
@ -601,7 +640,10 @@ class _$SnPostImpl extends _SnPost {
required this.publishedUntil, required this.publishedUntil,
required this.totalUpvote, required this.totalUpvote,
required this.totalDownvote, required this.totalDownvote,
this.totalViews = 0,
this.totalAggregatedViews = 0,
required this.publisherId, required this.publisherId,
required this.pollId,
required this.publisher, required this.publisher,
required this.metric, required this.metric,
this.preload}) this.preload})
@ -717,8 +759,16 @@ class _$SnPostImpl extends _SnPost {
@override @override
final int totalDownvote; final int totalDownvote;
@override @override
@JsonKey()
final int totalViews;
@override
@JsonKey()
final int totalAggregatedViews;
@override
final int publisherId; final int publisherId;
@override @override
final int? pollId;
@override
final SnPublisher publisher; final SnPublisher publisher;
@override @override
final SnMetric metric; final SnMetric metric;
@ -727,7 +777,7 @@ class _$SnPostImpl extends _SnPost {
@override @override
String toString() { String toString() {
return 'SnPost(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, body: $body, language: $language, alias: $alias, aliasPrefix: $aliasPrefix, tags: $tags, categories: $categories, replies: $replies, replyId: $replyId, repostId: $repostId, replyTo: $replyTo, repostTo: $repostTo, visibleUsersList: $visibleUsersList, invisibleUsersList: $invisibleUsersList, visibility: $visibility, editedAt: $editedAt, pinnedAt: $pinnedAt, lockedAt: $lockedAt, isDraft: $isDraft, publishedAt: $publishedAt, publishedUntil: $publishedUntil, totalUpvote: $totalUpvote, totalDownvote: $totalDownvote, publisherId: $publisherId, publisher: $publisher, metric: $metric, preload: $preload)'; return 'SnPost(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, body: $body, language: $language, alias: $alias, aliasPrefix: $aliasPrefix, tags: $tags, categories: $categories, replies: $replies, replyId: $replyId, repostId: $repostId, replyTo: $replyTo, repostTo: $repostTo, visibleUsersList: $visibleUsersList, invisibleUsersList: $invisibleUsersList, visibility: $visibility, editedAt: $editedAt, pinnedAt: $pinnedAt, lockedAt: $lockedAt, isDraft: $isDraft, publishedAt: $publishedAt, publishedUntil: $publishedUntil, totalUpvote: $totalUpvote, totalDownvote: $totalDownvote, totalViews: $totalViews, totalAggregatedViews: $totalAggregatedViews, publisherId: $publisherId, pollId: $pollId, publisher: $publisher, metric: $metric, preload: $preload)';
} }
@override @override
@ -780,8 +830,13 @@ class _$SnPostImpl extends _SnPost {
other.totalUpvote == totalUpvote) && other.totalUpvote == totalUpvote) &&
(identical(other.totalDownvote, totalDownvote) || (identical(other.totalDownvote, totalDownvote) ||
other.totalDownvote == totalDownvote) && other.totalDownvote == totalDownvote) &&
(identical(other.totalViews, totalViews) ||
other.totalViews == totalViews) &&
(identical(other.totalAggregatedViews, totalAggregatedViews) ||
other.totalAggregatedViews == totalAggregatedViews) &&
(identical(other.publisherId, publisherId) || (identical(other.publisherId, publisherId) ||
other.publisherId == publisherId) && other.publisherId == publisherId) &&
(identical(other.pollId, pollId) || other.pollId == pollId) &&
(identical(other.publisher, publisher) || (identical(other.publisher, publisher) ||
other.publisher == publisher) && other.publisher == publisher) &&
(identical(other.metric, metric) || other.metric == metric) && (identical(other.metric, metric) || other.metric == metric) &&
@ -819,7 +874,10 @@ class _$SnPostImpl extends _SnPost {
publishedUntil, publishedUntil,
totalUpvote, totalUpvote,
totalDownvote, totalDownvote,
totalViews,
totalAggregatedViews,
publisherId, publisherId,
pollId,
publisher, publisher,
metric, metric,
preload preload
@ -870,7 +928,10 @@ abstract class _SnPost extends SnPost {
required final DateTime? publishedUntil, required final DateTime? publishedUntil,
required final int totalUpvote, required final int totalUpvote,
required final int totalDownvote, required final int totalDownvote,
final int totalViews,
final int totalAggregatedViews,
required final int publisherId, required final int publisherId,
required final int? pollId,
required final SnPublisher publisher, required final SnPublisher publisher,
required final SnMetric metric, required final SnMetric metric,
final SnPostPreload? preload}) = _$SnPostImpl; final SnPostPreload? preload}) = _$SnPostImpl;
@ -933,8 +994,14 @@ abstract class _SnPost extends SnPost {
@override @override
int get totalDownvote; int get totalDownvote;
@override @override
int get totalViews;
@override
int get totalAggregatedViews;
@override
int get publisherId; int get publisherId;
@override @override
int? get pollId;
@override
SnPublisher get publisher; SnPublisher get publisher;
@override @override
SnMetric get metric; SnMetric get metric;
@ -1567,6 +1634,8 @@ SnPostPreload _$SnPostPreloadFromJson(Map<String, dynamic> json) {
mixin _$SnPostPreload { mixin _$SnPostPreload {
SnAttachment? get thumbnail => throw _privateConstructorUsedError; SnAttachment? get thumbnail => throw _privateConstructorUsedError;
List<SnAttachment?>? get attachments => throw _privateConstructorUsedError; List<SnAttachment?>? get attachments => throw _privateConstructorUsedError;
SnAttachment? get video => throw _privateConstructorUsedError;
SnPoll? get poll => throw _privateConstructorUsedError;
/// Serializes this SnPostPreload to a JSON map. /// Serializes this SnPostPreload to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError; Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@ -1584,9 +1653,15 @@ abstract class $SnPostPreloadCopyWith<$Res> {
SnPostPreload value, $Res Function(SnPostPreload) then) = SnPostPreload value, $Res Function(SnPostPreload) then) =
_$SnPostPreloadCopyWithImpl<$Res, SnPostPreload>; _$SnPostPreloadCopyWithImpl<$Res, SnPostPreload>;
@useResult @useResult
$Res call({SnAttachment? thumbnail, List<SnAttachment?>? attachments}); $Res call(
{SnAttachment? thumbnail,
List<SnAttachment?>? attachments,
SnAttachment? video,
SnPoll? poll});
$SnAttachmentCopyWith<$Res>? get thumbnail; $SnAttachmentCopyWith<$Res>? get thumbnail;
$SnAttachmentCopyWith<$Res>? get video;
$SnPollCopyWith<$Res>? get poll;
} }
/// @nodoc /// @nodoc
@ -1606,6 +1681,8 @@ class _$SnPostPreloadCopyWithImpl<$Res, $Val extends SnPostPreload>
$Res call({ $Res call({
Object? thumbnail = freezed, Object? thumbnail = freezed,
Object? attachments = freezed, Object? attachments = freezed,
Object? video = freezed,
Object? poll = freezed,
}) { }) {
return _then(_value.copyWith( return _then(_value.copyWith(
thumbnail: freezed == thumbnail thumbnail: freezed == thumbnail
@ -1616,6 +1693,14 @@ class _$SnPostPreloadCopyWithImpl<$Res, $Val extends SnPostPreload>
? _value.attachments ? _value.attachments
: attachments // ignore: cast_nullable_to_non_nullable : attachments // ignore: cast_nullable_to_non_nullable
as List<SnAttachment?>?, as List<SnAttachment?>?,
video: freezed == video
? _value.video
: video // ignore: cast_nullable_to_non_nullable
as SnAttachment?,
poll: freezed == poll
? _value.poll
: poll // ignore: cast_nullable_to_non_nullable
as SnPoll?,
) as $Val); ) as $Val);
} }
@ -1632,6 +1717,34 @@ class _$SnPostPreloadCopyWithImpl<$Res, $Val extends SnPostPreload>
return _then(_value.copyWith(thumbnail: value) as $Val); return _then(_value.copyWith(thumbnail: value) as $Val);
}); });
} }
/// Create a copy of SnPostPreload
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAttachmentCopyWith<$Res>? get video {
if (_value.video == null) {
return null;
}
return $SnAttachmentCopyWith<$Res>(_value.video!, (value) {
return _then(_value.copyWith(video: value) as $Val);
});
}
/// Create a copy of SnPostPreload
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnPollCopyWith<$Res>? get poll {
if (_value.poll == null) {
return null;
}
return $SnPollCopyWith<$Res>(_value.poll!, (value) {
return _then(_value.copyWith(poll: value) as $Val);
});
}
} }
/// @nodoc /// @nodoc
@ -1642,10 +1755,18 @@ abstract class _$$SnPostPreloadImplCopyWith<$Res>
__$$SnPostPreloadImplCopyWithImpl<$Res>; __$$SnPostPreloadImplCopyWithImpl<$Res>;
@override @override
@useResult @useResult
$Res call({SnAttachment? thumbnail, List<SnAttachment?>? attachments}); $Res call(
{SnAttachment? thumbnail,
List<SnAttachment?>? attachments,
SnAttachment? video,
SnPoll? poll});
@override @override
$SnAttachmentCopyWith<$Res>? get thumbnail; $SnAttachmentCopyWith<$Res>? get thumbnail;
@override
$SnAttachmentCopyWith<$Res>? get video;
@override
$SnPollCopyWith<$Res>? get poll;
} }
/// @nodoc /// @nodoc
@ -1663,6 +1784,8 @@ class __$$SnPostPreloadImplCopyWithImpl<$Res>
$Res call({ $Res call({
Object? thumbnail = freezed, Object? thumbnail = freezed,
Object? attachments = freezed, Object? attachments = freezed,
Object? video = freezed,
Object? poll = freezed,
}) { }) {
return _then(_$SnPostPreloadImpl( return _then(_$SnPostPreloadImpl(
thumbnail: freezed == thumbnail thumbnail: freezed == thumbnail
@ -1673,6 +1796,14 @@ class __$$SnPostPreloadImplCopyWithImpl<$Res>
? _value._attachments ? _value._attachments
: attachments // ignore: cast_nullable_to_non_nullable : attachments // ignore: cast_nullable_to_non_nullable
as List<SnAttachment?>?, as List<SnAttachment?>?,
video: freezed == video
? _value.video
: video // ignore: cast_nullable_to_non_nullable
as SnAttachment?,
poll: freezed == poll
? _value.poll
: poll // ignore: cast_nullable_to_non_nullable
as SnPoll?,
)); ));
} }
} }
@ -1682,7 +1813,9 @@ class __$$SnPostPreloadImplCopyWithImpl<$Res>
class _$SnPostPreloadImpl implements _SnPostPreload { class _$SnPostPreloadImpl implements _SnPostPreload {
const _$SnPostPreloadImpl( const _$SnPostPreloadImpl(
{required this.thumbnail, {required this.thumbnail,
required final List<SnAttachment?>? attachments}) required final List<SnAttachment?>? attachments,
required this.video,
required this.poll})
: _attachments = attachments; : _attachments = attachments;
factory _$SnPostPreloadImpl.fromJson(Map<String, dynamic> json) => factory _$SnPostPreloadImpl.fromJson(Map<String, dynamic> json) =>
@ -1700,9 +1833,14 @@ class _$SnPostPreloadImpl implements _SnPostPreload {
return EqualUnmodifiableListView(value); return EqualUnmodifiableListView(value);
} }
@override
final SnAttachment? video;
@override
final SnPoll? poll;
@override @override
String toString() { String toString() {
return 'SnPostPreload(thumbnail: $thumbnail, attachments: $attachments)'; return 'SnPostPreload(thumbnail: $thumbnail, attachments: $attachments, video: $video, poll: $poll)';
} }
@override @override
@ -1713,13 +1851,15 @@ class _$SnPostPreloadImpl implements _SnPostPreload {
(identical(other.thumbnail, thumbnail) || (identical(other.thumbnail, thumbnail) ||
other.thumbnail == thumbnail) && other.thumbnail == thumbnail) &&
const DeepCollectionEquality() const DeepCollectionEquality()
.equals(other._attachments, _attachments)); .equals(other._attachments, _attachments) &&
(identical(other.video, video) || other.video == video) &&
(identical(other.poll, poll) || other.poll == poll));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType, thumbnail, int get hashCode => Object.hash(runtimeType, thumbnail,
const DeepCollectionEquality().hash(_attachments)); const DeepCollectionEquality().hash(_attachments), video, poll);
/// Create a copy of SnPostPreload /// Create a copy of SnPostPreload
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@ -1740,7 +1880,9 @@ class _$SnPostPreloadImpl implements _SnPostPreload {
abstract class _SnPostPreload implements SnPostPreload { abstract class _SnPostPreload implements SnPostPreload {
const factory _SnPostPreload( const factory _SnPostPreload(
{required final SnAttachment? thumbnail, {required final SnAttachment? thumbnail,
required final List<SnAttachment?>? attachments}) = _$SnPostPreloadImpl; required final List<SnAttachment?>? attachments,
required final SnAttachment? video,
required final SnPoll? poll}) = _$SnPostPreloadImpl;
factory _SnPostPreload.fromJson(Map<String, dynamic> json) = factory _SnPostPreload.fromJson(Map<String, dynamic> json) =
_$SnPostPreloadImpl.fromJson; _$SnPostPreloadImpl.fromJson;
@ -1749,6 +1891,10 @@ abstract class _SnPostPreload implements SnPostPreload {
SnAttachment? get thumbnail; SnAttachment? get thumbnail;
@override @override
List<SnAttachment?>? get attachments; List<SnAttachment?>? get attachments;
@override
SnAttachment? get video;
@override
SnPoll? get poll;
/// Create a copy of SnPostPreload /// Create a copy of SnPostPreload
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.

View File

@ -62,7 +62,11 @@ _$SnPostImpl _$$SnPostImplFromJson(Map<String, dynamic> json) => _$SnPostImpl(
: DateTime.parse(json['published_until'] as String), : DateTime.parse(json['published_until'] as String),
totalUpvote: (json['total_upvote'] as num).toInt(), totalUpvote: (json['total_upvote'] as num).toInt(),
totalDownvote: (json['total_downvote'] as num).toInt(), totalDownvote: (json['total_downvote'] as num).toInt(),
totalViews: (json['total_views'] as num?)?.toInt() ?? 0,
totalAggregatedViews:
(json['total_aggregated_views'] as num?)?.toInt() ?? 0,
publisherId: (json['publisher_id'] as num).toInt(), publisherId: (json['publisher_id'] as num).toInt(),
pollId: (json['poll_id'] as num?)?.toInt(),
publisher: publisher:
SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>), SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>),
metric: SnMetric.fromJson(json['metric'] as Map<String, dynamic>), metric: SnMetric.fromJson(json['metric'] as Map<String, dynamic>),
@ -100,7 +104,10 @@ Map<String, dynamic> _$$SnPostImplToJson(_$SnPostImpl instance) =>
'published_until': instance.publishedUntil?.toIso8601String(), 'published_until': instance.publishedUntil?.toIso8601String(),
'total_upvote': instance.totalUpvote, 'total_upvote': instance.totalUpvote,
'total_downvote': instance.totalDownvote, 'total_downvote': instance.totalDownvote,
'total_views': instance.totalViews,
'total_aggregated_views': instance.totalAggregatedViews,
'publisher_id': instance.publisherId, 'publisher_id': instance.publisherId,
'poll_id': instance.pollId,
'publisher': instance.publisher.toJson(), 'publisher': instance.publisher.toJson(),
'metric': instance.metric.toJson(), 'metric': instance.metric.toJson(),
'preload': instance.preload?.toJson(), 'preload': instance.preload?.toJson(),
@ -165,12 +172,20 @@ _$SnPostPreloadImpl _$$SnPostPreloadImplFromJson(Map<String, dynamic> json) =>
? null ? null
: SnAttachment.fromJson(e as Map<String, dynamic>)) : SnAttachment.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
video: json['video'] == null
? null
: SnAttachment.fromJson(json['video'] as Map<String, dynamic>),
poll: json['poll'] == null
? null
: SnPoll.fromJson(json['poll'] as Map<String, dynamic>),
); );
Map<String, dynamic> _$$SnPostPreloadImplToJson(_$SnPostPreloadImpl instance) => Map<String, dynamic> _$$SnPostPreloadImplToJson(_$SnPostPreloadImpl instance) =>
<String, dynamic>{ <String, dynamic>{
'thumbnail': instance.thumbnail?.toJson(), 'thumbnail': instance.thumbnail?.toJson(),
'attachments': instance.attachments?.map((e) => e?.toJson()).toList(), 'attachments': instance.attachments?.map((e) => e?.toJson()).toList(),
'video': instance.video?.toJson(),
'poll': instance.poll?.toJson(),
}; };
_$SnBodyImpl _$$SnBodyImplFromJson(Map<String, dynamic> json) => _$SnBodyImpl( _$SnBodyImpl _$$SnBodyImplFromJson(Map<String, dynamic> json) => _$SnBodyImpl(

37
lib/types/wallet.dart Normal file
View File

@ -0,0 +1,37 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'wallet.freezed.dart';
part 'wallet.g.dart';
@freezed
class SnWallet with _$SnWallet {
const factory SnWallet({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required String balance,
required String password,
required int accountId,
}) = _SnWallet;
factory SnWallet.fromJson(Map<String, dynamic> json) => _$SnWalletFromJson(json);
}
@freezed
class SnTransaction with _$SnTransaction {
const factory SnTransaction({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required String remark,
required String amount,
required SnWallet? payer,
required SnWallet? payee,
required int? payerId,
required int? payeeId,
}) = _SnTransaction;
factory SnTransaction.fromJson(Map<String, dynamic> json) => _$SnTransactionFromJson(json);
}

View File

@ -0,0 +1,666 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'wallet.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
SnWallet _$SnWalletFromJson(Map<String, dynamic> json) {
return _SnWallet.fromJson(json);
}
/// @nodoc
mixin _$SnWallet {
int get id => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
DateTime? get deletedAt => throw _privateConstructorUsedError;
String get balance => throw _privateConstructorUsedError;
String get password => throw _privateConstructorUsedError;
int get accountId => throw _privateConstructorUsedError;
/// Serializes this SnWallet to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnWallet
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnWalletCopyWith<SnWallet> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnWalletCopyWith<$Res> {
factory $SnWalletCopyWith(SnWallet value, $Res Function(SnWallet) then) =
_$SnWalletCopyWithImpl<$Res, SnWallet>;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String balance,
String password,
int accountId});
}
/// @nodoc
class _$SnWalletCopyWithImpl<$Res, $Val extends SnWallet>
implements $SnWalletCopyWith<$Res> {
_$SnWalletCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnWallet
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? balance = null,
Object? password = null,
Object? accountId = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
balance: null == balance
? _value.balance
: balance // ignore: cast_nullable_to_non_nullable
as String,
password: null == password
? _value.password
: password // ignore: cast_nullable_to_non_nullable
as String,
accountId: null == accountId
? _value.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
) as $Val);
}
}
/// @nodoc
abstract class _$$SnWalletImplCopyWith<$Res>
implements $SnWalletCopyWith<$Res> {
factory _$$SnWalletImplCopyWith(
_$SnWalletImpl value, $Res Function(_$SnWalletImpl) then) =
__$$SnWalletImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String balance,
String password,
int accountId});
}
/// @nodoc
class __$$SnWalletImplCopyWithImpl<$Res>
extends _$SnWalletCopyWithImpl<$Res, _$SnWalletImpl>
implements _$$SnWalletImplCopyWith<$Res> {
__$$SnWalletImplCopyWithImpl(
_$SnWalletImpl _value, $Res Function(_$SnWalletImpl) _then)
: super(_value, _then);
/// Create a copy of SnWallet
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? balance = null,
Object? password = null,
Object? accountId = null,
}) {
return _then(_$SnWalletImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
balance: null == balance
? _value.balance
: balance // ignore: cast_nullable_to_non_nullable
as String,
password: null == password
? _value.password
: password // ignore: cast_nullable_to_non_nullable
as String,
accountId: null == accountId
? _value.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnWalletImpl implements _SnWallet {
const _$SnWalletImpl(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.balance,
required this.password,
required this.accountId});
factory _$SnWalletImpl.fromJson(Map<String, dynamic> json) =>
_$$SnWalletImplFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final DateTime? deletedAt;
@override
final String balance;
@override
final String password;
@override
final int accountId;
@override
String toString() {
return 'SnWallet(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, balance: $balance, password: $password, accountId: $accountId)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnWalletImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
(identical(other.deletedAt, deletedAt) ||
other.deletedAt == deletedAt) &&
(identical(other.balance, balance) || other.balance == balance) &&
(identical(other.password, password) ||
other.password == password) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt,
deletedAt, balance, password, accountId);
/// Create a copy of SnWallet
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnWalletImplCopyWith<_$SnWalletImpl> get copyWith =>
__$$SnWalletImplCopyWithImpl<_$SnWalletImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnWalletImplToJson(
this,
);
}
}
abstract class _SnWallet implements SnWallet {
const factory _SnWallet(
{required final int id,
required final DateTime createdAt,
required final DateTime updatedAt,
required final DateTime? deletedAt,
required final String balance,
required final String password,
required final int accountId}) = _$SnWalletImpl;
factory _SnWallet.fromJson(Map<String, dynamic> json) =
_$SnWalletImpl.fromJson;
@override
int get id;
@override
DateTime get createdAt;
@override
DateTime get updatedAt;
@override
DateTime? get deletedAt;
@override
String get balance;
@override
String get password;
@override
int get accountId;
/// Create a copy of SnWallet
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnWalletImplCopyWith<_$SnWalletImpl> get copyWith =>
throw _privateConstructorUsedError;
}
SnTransaction _$SnTransactionFromJson(Map<String, dynamic> json) {
return _SnTransaction.fromJson(json);
}
/// @nodoc
mixin _$SnTransaction {
int get id => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
DateTime? get deletedAt => throw _privateConstructorUsedError;
String get remark => throw _privateConstructorUsedError;
String get amount => throw _privateConstructorUsedError;
SnWallet? get payer => throw _privateConstructorUsedError;
SnWallet? get payee => throw _privateConstructorUsedError;
int? get payerId => throw _privateConstructorUsedError;
int? get payeeId => throw _privateConstructorUsedError;
/// Serializes this SnTransaction to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnTransaction
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnTransactionCopyWith<SnTransaction> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnTransactionCopyWith<$Res> {
factory $SnTransactionCopyWith(
SnTransaction value, $Res Function(SnTransaction) then) =
_$SnTransactionCopyWithImpl<$Res, SnTransaction>;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String remark,
String amount,
SnWallet? payer,
SnWallet? payee,
int? payerId,
int? payeeId});
$SnWalletCopyWith<$Res>? get payer;
$SnWalletCopyWith<$Res>? get payee;
}
/// @nodoc
class _$SnTransactionCopyWithImpl<$Res, $Val extends SnTransaction>
implements $SnTransactionCopyWith<$Res> {
_$SnTransactionCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnTransaction
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? remark = null,
Object? amount = null,
Object? payer = freezed,
Object? payee = freezed,
Object? payerId = freezed,
Object? payeeId = freezed,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
remark: null == remark
? _value.remark
: remark // ignore: cast_nullable_to_non_nullable
as String,
amount: null == amount
? _value.amount
: amount // ignore: cast_nullable_to_non_nullable
as String,
payer: freezed == payer
? _value.payer
: payer // ignore: cast_nullable_to_non_nullable
as SnWallet?,
payee: freezed == payee
? _value.payee
: payee // ignore: cast_nullable_to_non_nullable
as SnWallet?,
payerId: freezed == payerId
? _value.payerId
: payerId // ignore: cast_nullable_to_non_nullable
as int?,
payeeId: freezed == payeeId
? _value.payeeId
: payeeId // ignore: cast_nullable_to_non_nullable
as int?,
) as $Val);
}
/// Create a copy of SnTransaction
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnWalletCopyWith<$Res>? get payer {
if (_value.payer == null) {
return null;
}
return $SnWalletCopyWith<$Res>(_value.payer!, (value) {
return _then(_value.copyWith(payer: value) as $Val);
});
}
/// Create a copy of SnTransaction
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnWalletCopyWith<$Res>? get payee {
if (_value.payee == null) {
return null;
}
return $SnWalletCopyWith<$Res>(_value.payee!, (value) {
return _then(_value.copyWith(payee: value) as $Val);
});
}
}
/// @nodoc
abstract class _$$SnTransactionImplCopyWith<$Res>
implements $SnTransactionCopyWith<$Res> {
factory _$$SnTransactionImplCopyWith(
_$SnTransactionImpl value, $Res Function(_$SnTransactionImpl) then) =
__$$SnTransactionImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String remark,
String amount,
SnWallet? payer,
SnWallet? payee,
int? payerId,
int? payeeId});
@override
$SnWalletCopyWith<$Res>? get payer;
@override
$SnWalletCopyWith<$Res>? get payee;
}
/// @nodoc
class __$$SnTransactionImplCopyWithImpl<$Res>
extends _$SnTransactionCopyWithImpl<$Res, _$SnTransactionImpl>
implements _$$SnTransactionImplCopyWith<$Res> {
__$$SnTransactionImplCopyWithImpl(
_$SnTransactionImpl _value, $Res Function(_$SnTransactionImpl) _then)
: super(_value, _then);
/// Create a copy of SnTransaction
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? remark = null,
Object? amount = null,
Object? payer = freezed,
Object? payee = freezed,
Object? payerId = freezed,
Object? payeeId = freezed,
}) {
return _then(_$SnTransactionImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
remark: null == remark
? _value.remark
: remark // ignore: cast_nullable_to_non_nullable
as String,
amount: null == amount
? _value.amount
: amount // ignore: cast_nullable_to_non_nullable
as String,
payer: freezed == payer
? _value.payer
: payer // ignore: cast_nullable_to_non_nullable
as SnWallet?,
payee: freezed == payee
? _value.payee
: payee // ignore: cast_nullable_to_non_nullable
as SnWallet?,
payerId: freezed == payerId
? _value.payerId
: payerId // ignore: cast_nullable_to_non_nullable
as int?,
payeeId: freezed == payeeId
? _value.payeeId
: payeeId // ignore: cast_nullable_to_non_nullable
as int?,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnTransactionImpl implements _SnTransaction {
const _$SnTransactionImpl(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.remark,
required this.amount,
required this.payer,
required this.payee,
required this.payerId,
required this.payeeId});
factory _$SnTransactionImpl.fromJson(Map<String, dynamic> json) =>
_$$SnTransactionImplFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final DateTime? deletedAt;
@override
final String remark;
@override
final String amount;
@override
final SnWallet? payer;
@override
final SnWallet? payee;
@override
final int? payerId;
@override
final int? payeeId;
@override
String toString() {
return 'SnTransaction(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, remark: $remark, amount: $amount, payer: $payer, payee: $payee, payerId: $payerId, payeeId: $payeeId)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnTransactionImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
(identical(other.deletedAt, deletedAt) ||
other.deletedAt == deletedAt) &&
(identical(other.remark, remark) || other.remark == remark) &&
(identical(other.amount, amount) || other.amount == amount) &&
(identical(other.payer, payer) || other.payer == payer) &&
(identical(other.payee, payee) || other.payee == payee) &&
(identical(other.payerId, payerId) || other.payerId == payerId) &&
(identical(other.payeeId, payeeId) || other.payeeId == payeeId));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt,
deletedAt, remark, amount, payer, payee, payerId, payeeId);
/// Create a copy of SnTransaction
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnTransactionImplCopyWith<_$SnTransactionImpl> get copyWith =>
__$$SnTransactionImplCopyWithImpl<_$SnTransactionImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnTransactionImplToJson(
this,
);
}
}
abstract class _SnTransaction implements SnTransaction {
const factory _SnTransaction(
{required final int id,
required final DateTime createdAt,
required final DateTime updatedAt,
required final DateTime? deletedAt,
required final String remark,
required final String amount,
required final SnWallet? payer,
required final SnWallet? payee,
required final int? payerId,
required final int? payeeId}) = _$SnTransactionImpl;
factory _SnTransaction.fromJson(Map<String, dynamic> json) =
_$SnTransactionImpl.fromJson;
@override
int get id;
@override
DateTime get createdAt;
@override
DateTime get updatedAt;
@override
DateTime? get deletedAt;
@override
String get remark;
@override
String get amount;
@override
SnWallet? get payer;
@override
SnWallet? get payee;
@override
int? get payerId;
@override
int? get payeeId;
/// Create a copy of SnTransaction
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnTransactionImplCopyWith<_$SnTransactionImpl> get copyWith =>
throw _privateConstructorUsedError;
}

65
lib/types/wallet.g.dart Normal file
View File

@ -0,0 +1,65 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'wallet.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SnWalletImpl _$$SnWalletImplFromJson(Map<String, dynamic> json) =>
_$SnWalletImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
balance: json['balance'] as String,
password: json['password'] as String,
accountId: (json['account_id'] as num).toInt(),
);
Map<String, dynamic> _$$SnWalletImplToJson(_$SnWalletImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'balance': instance.balance,
'password': instance.password,
'account_id': instance.accountId,
};
_$SnTransactionImpl _$$SnTransactionImplFromJson(Map<String, dynamic> json) =>
_$SnTransactionImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
remark: json['remark'] as String,
amount: json['amount'] as String,
payer: json['payer'] == null
? null
: SnWallet.fromJson(json['payer'] as Map<String, dynamic>),
payee: json['payee'] == null
? null
: SnWallet.fromJson(json['payee'] as Map<String, dynamic>),
payerId: (json['payer_id'] as num?)?.toInt(),
payeeId: (json['payee_id'] as num?)?.toInt(),
);
Map<String, dynamic> _$$SnTransactionImplToJson(_$SnTransactionImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'remark': instance.remark,
'amount': instance.amount,
'payer': instance.payer?.toJson(),
'payee': instance.payee?.toJson(),
'payer_id': instance.payerId,
'payee_id': instance.payeeId,
};

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.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';
class AboutScreen extends StatelessWidget { class AboutScreen extends StatelessWidget {
@ -12,97 +13,103 @@ class AboutScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
const denseButtonStyle = ButtonStyle(visualDensity: VisualDensity(vertical: -4)); const denseButtonStyle = ButtonStyle(visualDensity: VisualDensity(vertical: -4));
return SizedBox( return AppScaffold(
width: double.infinity, appBar: AppBar(
child: Column( leading: const PageBackButton(),
mainAxisAlignment: MainAxisAlignment.center, title: Text('screenAbout').tr(),
children: [ ),
ClipRRect( body: SizedBox(
borderRadius: const BorderRadius.all(Radius.circular(16)), width: double.infinity,
child: Image.asset('assets/icon/icon-light-radius.png', width: 120, height: 120), child: Column(
), mainAxisAlignment: MainAxisAlignment.center,
const Gap(8), children: [
Text( ClipRRect(
'Solian', borderRadius: const BorderRadius.all(Radius.circular(16)),
style: Theme.of(context).textTheme.titleLarge!.copyWith(fontSize: 36), child: Image.asset('assets/icon/icon-light-radius.png', width: 120, height: 120),
), ),
const Text( const Gap(8),
'The Solar Network', Text(
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), 'Solian',
), style: Theme.of(context).textTheme.titleLarge!.copyWith(fontSize: 36),
const Gap(8), ),
FutureBuilder( const Text(
future: PackageInfo.fromPlatform(), 'The Solar Network',
builder: (context, snapshot) { style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
if (!snapshot.hasData) { ),
return const SizedBox.shrink(); const Gap(8),
} FutureBuilder(
future: PackageInfo.fromPlatform(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox.shrink();
}
return Text( return Text(
'v${snapshot.data!.version} · ${snapshot.data!.buildNumber}', 'v${snapshot.data!.version} · ${snapshot.data!.buildNumber}',
style: const TextStyle(fontFamily: 'monospace'), style: const TextStyle(fontFamily: 'monospace'),
); );
}, },
), ),
Text('Copyright © ${DateTime.now().year} Solsynth LLC'), Text('Copyright © ${DateTime.now().year} Solsynth LLC'),
const Gap(16), const Gap(16),
Container( Container(
constraints: const BoxConstraints(maxWidth: 280), constraints: const BoxConstraints(maxWidth: 280),
child: Wrap( child: Wrap(
spacing: 4, spacing: 4,
runSpacing: 4, runSpacing: 4,
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
children: [ children: [
TextButton( TextButton(
style: denseButtonStyle, style: denseButtonStyle,
child: Text('appDetails').tr(), child: Text('appDetails').tr(),
onPressed: () async { onPressed: () async {
final info = await PackageInfo.fromPlatform(); final info = await PackageInfo.fromPlatform();
if (!context.mounted) return; if (!context.mounted) return;
showAboutDialog( showAboutDialog(
context: context, context: context,
applicationName: 'Solian', applicationName: 'Solian',
applicationVersion: '${info.version}+${info.buildNumber}', applicationVersion: '${info.version}+${info.buildNumber}',
applicationLegalese: applicationLegalese:
'The Solar Network App is an intuitive and open-source social network and computing platform. Experience the freedom of a user-friendly design that empowers you to create and connect with communities on your own terms. Embrace the future of social networking with a platform that prioritizes your independence and privacy.', 'The Solar Network App is an intuitive and open-source social network and computing platform. Experience the freedom of a user-friendly design that empowers you to create and connect with communities on your own terms. Embrace the future of social networking with a platform that prioritizes your independence and privacy.',
applicationIcon: ClipRRect( applicationIcon: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16)), borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Image.asset( child: Image.asset(
'assets/icon/icon-light-radius.png', 'assets/icon/icon-light-radius.png',
width: 60, width: 60,
height: 60, height: 60,
),
), ),
), );
); },
}, ),
), TextButton(
TextButton( style: denseButtonStyle,
style: denseButtonStyle, child: Text('termRelated').tr(),
child: Text('termRelated').tr(), onPressed: () {
onPressed: () { launchUrlString('https://solsynth.dev/terms');
launchUrlString('https://solsynth.dev/terms'); },
}, ),
), TextButton(
TextButton( style: denseButtonStyle,
style: denseButtonStyle, child: Text('serviceStatus').tr(),
child: Text('serviceStatus').tr(), onPressed: () {
onPressed: () { launchUrlString('https://status.solsynth.dev');
launchUrlString('https://status.solsynth.dev'); },
}, ),
), ],
], ),
).center(),
const Gap(16),
const Text(
'Open-sourced under AGPLv3',
style: TextStyle(
fontWeight: FontWeight.w300,
fontSize: 12,
),
), ),
).center(), ],
const Gap(16), ),
const Text(
'Open-sourced under AGPLv3',
style: TextStyle(
fontWeight: FontWeight.w300,
fontSize: 12,
),
),
],
), ),
); );
} }

View File

@ -0,0 +1,164 @@
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:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/experience.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/screens/account/profile_page.dart';
import 'package:surface/types/account.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/universal_image.dart';
class AccountPopoverCard extends StatelessWidget {
final SnAccount data;
const AccountPopoverCard({super.key, required this.data});
@override
Widget build(BuildContext context) {
final sn = context.read<SnNetworkProvider>();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (data.banner.isNotEmpty)
Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: AspectRatio(
aspectRatio: 16 / 7,
child: AutoResizeUniversalImage(
sn.getAttachmentUrl(data.banner),
fit: BoxFit.cover,
),
),
),
// Top padding
Gap(16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AccountImage(
content: data.avatar,
radius: 20,
),
Gap(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(data.nick).bold(),
Text('@${data.name}').fontSize(13).opacity(0.75),
],
),
),
IconButton(
onPressed: () {
Navigator.pop(context);
GoRouter.of(context).pushNamed(
'accountProfilePage',
pathParameters: {'name': data.name},
);
},
icon: const Icon(Symbols.chevron_right),
padding: EdgeInsets.zero,
visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
),
const Gap(8)
],
).padding(horizontal: 16),
const Gap(16),
Wrap(
children: data.badges
.map(
(ele) => Tooltip(
richMessage: TextSpan(
children: [
TextSpan(text: kBadgesMeta[ele.type]?.$1.tr() ?? 'unknown'.tr()),
if (ele.metadata['title'] != null)
TextSpan(
text: '\n${ele.metadata['title']}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: '\n'),
TextSpan(
text: DateFormat.yMEd().format(ele.createdAt),
),
],
),
child: Icon(
kBadgesMeta[ele.type]?.$2 ?? Symbols.question_mark,
color: kBadgesMeta[ele.type]?.$3,
fill: 1,
),
),
)
.toList(),
).padding(horizontal: 24),
const Gap(8),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.star),
const Gap(8),
Text('Lv${getLevelFromExp(data.profile?.experience ?? 0)}'),
const Gap(8),
Text(calcLevelUpProgressLevel(data.profile?.experience ?? 0)).fontSize(11).opacity(0.5),
const Gap(8),
Container(
width: double.infinity,
constraints: const BoxConstraints(maxWidth: 160),
child: LinearProgressIndicator(
value: calcLevelUpProgress(data.profile?.experience ?? 0),
borderRadius: BorderRadius.circular(8),
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
).alignment(Alignment.centerLeft),
),
],
).padding(horizontal: 24),
FutureBuilder(
future: sn.client.get('/cgi/id/users/${data.name}/status'),
builder: (context, snapshot) {
final SnAccountStatusInfo? status =
snapshot.hasData ? SnAccountStatusInfo.fromJson(snapshot.data!.data) : null;
return Row(
children: [
Icon(
Symbols.circle,
fill: 1,
size: 16,
color: (status?.isOnline ?? false) ? Colors.green : Colors.grey,
).padding(all: 4),
const Gap(8),
Text(
status != null
? status.isOnline
? 'accountStatusOnline'.tr()
: 'accountStatusOffline'.tr()
: 'loading'.tr(),
),
if (status != null && !status.isOnline && status.lastSeenAt != null)
Text(
'accountStatusLastSeen'.tr(args: [
status.lastSeenAt != null
? RelativeTime(context).format(
status.lastSeenAt!.toLocal(),
)
: 'unknown',
]),
).padding(left: 6).opacity(0.75),
],
).padding(horizontal: 24);
},
),
// Bottom padding
const Gap(16),
],
);
}
}

View File

@ -1,9 +1,12 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package: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/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';
@ -47,10 +50,19 @@ class _AccountSelectState extends State<AccountSelect> {
Future<void> _getFriends() async { Future<void> _getFriends() async {
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');
if (!mounted) return;
final ua = context.read<UserProvider>();
setState(() { setState(() {
_relativeUsers.addAll( _relativeUsers.addAll(
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [], resp.data?.map((e) {
final rel = SnRelationship.fromJson(e);
if (rel.relatedId == ua.user?.id) {
return rel.account!;
} else {
return rel.related!;
}
}).cast<SnAccount>(),
); );
}); });
} }
@ -96,10 +108,14 @@ class _AccountSelectState extends State<AccountSelect> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
widget.title, crossAxisAlignment: CrossAxisAlignment.center,
style: Theme.of(context).textTheme.headlineSmall, children: [
).padding(left: 24, right: 24, top: 16, bottom: 16), const Icon(Symbols.group, size: 24),
const Gap(16),
Text(widget.title, style: Theme.of(context).textTheme.titleLarge),
],
).padding(horizontal: 20, top: 16, bottom: 12),
Container( Container(
color: Theme.of(context).colorScheme.secondaryContainer, color: Theme.of(context).colorScheme.secondaryContainer,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
@ -117,13 +133,9 @@ class _AccountSelectState extends State<AccountSelect> {
), ),
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
itemCount: _pendingUsers.isEmpty itemCount: _pendingUsers.isEmpty ? _relativeUsers.length : _pendingUsers.length,
? _relativeUsers.length
: _pendingUsers.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
var user = _pendingUsers.isEmpty var user = _pendingUsers.isEmpty ? _relativeUsers[index] : _pendingUsers[index];
? _relativeUsers[index]
: _pendingUsers[index];
return ListTile( return ListTile(
title: Text(user.nick), title: Text(user.nick),
subtitle: Text(user.name), subtitle: Text(user.name),
@ -142,8 +154,7 @@ class _AccountSelectState extends State<AccountSelect> {
} }
setState(() { setState(() {
final idx = _selectedUsers final idx = _selectedUsers.indexWhere((x) => x.id == user.id);
.indexWhere((x) => x.id == user.id);
if (idx != -1) { if (idx != -1) {
_selectedUsers.removeAt(idx); _selectedUsers.removeAt(idx);
} else { } else {

View File

@ -6,12 +6,22 @@ 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_attachment.dart'; import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/types/attachment.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
class AttachmentInputDialog extends StatefulWidget { class AttachmentInputDialog extends StatefulWidget {
final String? title; final String? title;
final bool? analyzeNow; final bool? analyzeNow;
const AttachmentInputDialog({super.key, required this.title, this.analyzeNow = false}); final SnMediaType? mediaType;
final String pool;
const AttachmentInputDialog({
super.key,
required this.title,
required this.pool,
this.analyzeNow = false,
this.mediaType = SnMediaType.image,
});
@override @override
State<AttachmentInputDialog> createState() => _AttachmentInputDialogState(); State<AttachmentInputDialog> createState() => _AttachmentInputDialogState();
@ -20,13 +30,18 @@ final bool? analyzeNow;
class _AttachmentInputDialogState extends State<AttachmentInputDialog> { class _AttachmentInputDialogState extends State<AttachmentInputDialog> {
final _randomIdController = TextEditingController(); final _randomIdController = TextEditingController();
XFile? _thumbnailFile; XFile? _file;
double? _progress;
void _pickImage() async { void _pickMedia() async {
final picker = ImagePicker(); final picker = ImagePicker();
final result = await picker.pickImage(source: ImageSource.gallery); final result = switch (widget.mediaType) {
SnMediaType.image => await picker.pickImage(source: ImageSource.gallery),
SnMediaType.video => await picker.pickVideo(source: ImageSource.gallery),
_ => await picker.pickMedia(),
};
if (result == null) return; if (result == null) return;
setState(() => _thumbnailFile = result); setState(() => _file = result);
} }
bool _isBusy = false; bool _isBusy = false;
@ -46,15 +61,20 @@ class _AttachmentInputDialogState extends State<AttachmentInputDialog> {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
} }
} else if (_thumbnailFile != null) { } else if (_file != null) {
try { try {
final attachment = await attach.directUploadOne( final place = await attach.chunkedUploadInitialize(await _file!.length(), _file!.name, widget.pool, null);
(await _thumbnailFile!.readAsBytes()).buffer.asUint8List(),
_thumbnailFile!.path, final attachment = await attach.chunkedUploadParts(
'interactive', _file!,
null, place.$1,
place.$2,
analyzeNow: widget.analyzeNow ?? false, analyzeNow: widget.analyzeNow ?? false,
onProgress: (value) {
setState(() => _progress = value);
},
); );
if (!mounted) return; if (!mounted) return;
Navigator.pop(context, attachment); Navigator.pop(context, attachment);
} catch (err) { } catch (err) {
@ -67,7 +87,7 @@ class _AttachmentInputDialogState extends State<AttachmentInputDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog(
title: Text(widget.title ?? 'attachmentInputDialog').tr(), title: Text(widget.title ?? 'attachmentInputDialog'.tr()),
content: Column( content: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -86,24 +106,35 @@ class _AttachmentInputDialogState extends State<AttachmentInputDialog> {
const Gap(24), const Gap(24),
Text('attachmentInputNew').tr().fontSize(14), Text('attachmentInputNew').tr().fontSize(14),
Card( Card(
child: ListTile( child: Column(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), children: [
leading: const Icon(Symbols.add_photo_alternate), ListTile(
trailing: const Icon(Symbols.chevron_right), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
title: Text('addAttachmentFromAlbum').tr(), leading: const Icon(Symbols.add_photo_alternate),
subtitle: _thumbnailFile == null ? Text('unset').tr() : Text('waitingForUpload').tr(), trailing: const Icon(Symbols.chevron_right),
onTap: () { title: Text('addAttachmentFromAlbum').tr(),
_pickImage(); subtitle: _file == null ? Text('unset').tr() : Text('waitingForUpload').tr(),
}, onTap: () {
_pickMedia();
},
),
],
), ),
), ),
if (_isBusy)
LinearProgressIndicator(
value: _progress,
borderRadius: BorderRadius.all(Radius.circular(8)),
).padding(top: 16),
], ],
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: _isBusy ? null : () { onPressed: _isBusy
Navigator.pop(context); ? null
}, : () {
Navigator.pop(context);
},
child: Text('dialogDismiss').tr(), child: Text('dialogDismiss').tr(),
), ),
TextButton( TextButton(

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