Compare commits

...

127 Commits

Author SHA1 Message Date
04b9427cdf Recommendation posts 2024-12-10 00:18:39 +08:00
eab939928f Notification card
🐛 Fix post item truncate hint from overflowing
2024-12-09 23:45:10 +08:00
d3148ab89d 💄 Increase the appbar opacity when has background image 2024-12-09 22:57:20 +08:00
f3b7b02e77 🐛 Clean up local stuff when logout 2024-12-09 21:47:27 +08:00
687db37daf 🐛 Fix first time login did not connect to ws
🗑️ Remove next version notice
2024-12-09 21:42:19 +08:00
415446e3bb Add iOS notification services
🐛 Fix didn't request notification permission
2024-12-08 21:24:06 +08:00
0afb6b9c5b 🐛 Fix window decoration on macOS 2024-12-08 19:59:04 +08:00
9f4185dff6 🚀 Launch 2.0.0+17 2024-12-08 19:53:45 +08:00
772a33896d Realm responsive 2024-12-08 19:53:45 +08:00
afc49a7a2a 📱 Chat responsive 2024-12-08 19:53:45 +08:00
3c621187a7 About page 2024-12-08 19:53:45 +08:00
3f0a7a2227 📱 Fix post item responsive 2024-12-08 19:53:45 +08:00
f1dbea190b 💄 Better desktop platform window decoration 2024-12-08 19:11:37 +08:00
893b820e24 🐛 Fix windows complie issue 2024-12-08 18:54:58 +08:00
830da43193 📱 Optimize post detail on large screen 2024-12-08 16:21:56 +08:00
c43cca1aae Share post 2024-12-08 15:30:55 +08:00
49d1d607ce Auth check 2024-12-08 15:25:59 +08:00
67feaacf5a 📝 Add term accept hint 2024-12-08 15:16:53 +08:00
45f61533ee Create account field validation 2024-12-08 15:10:35 +08:00
add904cc41 View all abuse reports 2024-12-08 14:44:55 +08:00
e6a9185d11 Search advance filter with tags 2024-12-08 14:19:18 +08:00
669107a99f Direct messages 2024-12-08 13:45:51 +08:00
4805e68fcd 🐛 Fix deleting message issue 2024-12-08 12:25:43 +08:00
a693bfdc94 Add attachment from camera 2024-12-08 11:37:03 +08:00
be9b3f76d2 🐛 Fix album page user cache issue 2024-12-08 11:30:20 +08:00
ed4fcf9944 🐛 Fix user page conflict with publisher related pages 2024-12-08 11:22:16 +08:00
a688e33e33 🚀 Launch 2.0.0+16 2024-12-08 01:22:29 +08:00
62d4806b95 👔 Remove the next preview server because it become the stable one 2024-12-08 01:15:17 +08:00
ed02ba02a8 Account deletion 2024-12-08 01:12:45 +08:00
efddaf50f2 Abuse report 2024-12-08 01:01:20 +08:00
d4aaf61091 Report post 2024-12-08 00:53:22 +08:00
fa346b528e 🐛 Bug fixes on profile page 2024-12-08 00:40:44 +08:00
4a9ccc7c7a Save attachment to album 2024-12-08 00:25:53 +08:00
76cf08830b 🐛 Fix unable to repost 2024-12-07 23:40:26 +08:00
2cbb7fb29e 🐛 Optimized desktop titlebar styling 2024-12-07 22:34:02 +08:00
c55db308a1 🐛 Bug fixes for previous changes 2024-12-07 22:27:07 +08:00
2a837227d5 💄 New layout for article for optimized reading experience
🐛 Bug fixes on pending post media list
2024-12-07 21:33:01 +08:00
b583780cfc Article thumbnail 2024-12-07 17:43:44 +08:00
599dd4827b 💄 Show the most typical reaction 2024-12-06 00:28:51 +08:00
45f489dcb6 Post visibility hint 2024-12-06 00:21:48 +08:00
f16053c475 🍱 Adjust android icon 2024-12-06 00:01:26 +08:00
c603b3fcb0 Full-support of post visibility 2024-12-05 23:37:56 +08:00
d0a4eeb2b2 ♻️ New auto impl leading 2024-12-05 22:22:38 +08:00
5dd2e83389 Better desktop platform window customization 2024-12-05 00:43:57 +08:00
aa44a40e59 🍱 Update android icons 2024-12-05 00:19:48 +08:00
cae4756747 📱 Add a drawer menu button to fix cannot open drawer on android 2024-12-05 00:02:32 +08:00
5fc03e48a1 📝 Add some todo reminder in post visibility's code 2024-12-04 23:56:00 +08:00
06f2c9ecc2 Basic post visibility 2024-12-04 23:54:47 +08:00
ac06d35c10 🚀 Launch 2.0.0+15 2024-12-04 00:27:56 +08:00
c5a40702b9 Ordering and show the last message in channel 2024-12-04 00:17:11 +08:00
468b7f2c2e User profile 2024-12-03 23:38:43 +08:00
273c66f5d5 Basic account page 2024-12-03 00:02:30 +08:00
6d5b690450 📱 Login and register screen form maxWidth 2024-12-02 22:48:11 +08:00
a70092c6f4 📱 Fix attachment list responsive issue 2024-12-02 22:42:31 +08:00
7a617a4f8c 📱 Fix some layout issues on device which has no safe area 2024-12-02 22:01:02 +08:00
441df4090f 🐛 Bug hotfix on attachment zoom (launch 2.0.0+14) 2024-12-02 00:40:27 +08:00
e8384338f8 🚀 Launch 2.0.0+13 2024-12-02 00:15:36 +08:00
b0790ea145 ♻️ Better attachment list & zoom view 2024-12-01 23:56:56 +08:00
9588fc0475 ♻️ Better categorized fetching in publisher page 2024-12-01 23:20:05 +08:00
177ff513ee Subscription 2024-12-01 23:07:48 +08:00
cf1c4403c1 💄 Better publisher screen layout 2024-12-01 22:51:04 +08:00
23c5a1a23e Basic publisher page 2024-12-01 22:30:32 +08:00
32739821ba Publisher popover 2024-12-01 20:08:04 +08:00
000caf4dd2 Publisher personal & organization management 2024-12-01 14:33:47 +08:00
fc025c6bd3 Realm management 2024-12-01 12:44:02 +08:00
db9f4504db Realm detail, and member management 2024-12-01 12:34:27 +08:00
bb23a12be3 💄 Drawer won't slide to open if page can go back
💄 Fix album loading indicator
2024-12-01 11:05:54 +08:00
a865c4d34b Channel member management 2024-12-01 02:03:03 +08:00
0c2df45337 Friend management 2024-11-30 22:39:49 +08:00
a2a42f66a2 Editable channel notify level 2024-11-30 00:04:20 +08:00
51c7b03ff8 Editable channel profile 2024-11-29 23:48:39 +08:00
ddfbcc5e58 💄 Specialize details for 大吉 and 大凶 2024-11-29 23:30:40 +08:00
997562d174 🚀 Launch 2.0.0+11
 Album
2024-11-29 00:26:07 +08:00
df6f2af756 Leave channel 2024-11-29 00:01:41 +08:00
041be961c4 Delete account 2024-11-28 23:51:13 +08:00
36013a3a57 Editable channel 2024-11-28 23:35:25 +08:00
dc1ce94145 Delete post 2024-11-28 22:04:38 +08:00
2261528580 🐛 Bug fixes on daily check in 2024-11-28 13:15:15 +08:00
23301764ee 🚀 Launch 2.0.0+10
📱 Responsive home page
2024-11-28 00:29:53 +08:00
aa9724102b Birthday celebration 2024-11-28 00:04:45 +08:00
9395e081f0 Detailed daily check in fortune info 2024-11-27 23:37:40 +08:00
bd1d6b7be9 Basic daily sign in 2024-11-27 23:03:18 +08:00
dabb44635e 🍱 Use roboto as primary font 2024-11-27 21:18:02 +08:00
420588860a Post tags 2024-11-27 00:06:11 +08:00
312d68286e 🚀 Launch 2.0.0+9
⬆️ Upgrade deps
2024-11-26 22:06:05 +08:00
bedffbfad7 🐛 Fix notification screen error 2024-11-26 21:56:01 +08:00
6a3cd0a60d 🐛 Bug fixes on wrong push notification provider 2024-11-26 21:48:04 +08:00
356d3d4d3e :refactor: Central post fetching logic 2024-11-26 00:00:09 +08:00
41e2b08bcc Better attachment list 2024-11-25 22:41:15 +08:00
731ab97209 Post headline, and read est 2024-11-25 00:51:34 +08:00
a59de65130 💄 Optimization of UX in messages 2024-11-25 00:05:49 +08:00
9b6544df46 🚀 Launch 2.0.0+8
🐛 Bug fixes on background color
2024-11-24 20:54:01 +08:00
7221af75eb Call 2024-11-24 20:23:06 +08:00
66f41179ba Add livekit 2024-11-24 10:54:55 +08:00
ed32a31819 Add webrtc deps 2024-11-24 00:22:08 +08:00
33be7182d8 ♻️ Update platform specific code & resources 2024-11-23 22:04:21 +08:00
3cd08da3b6 ♻️ Replace storage token engine to prevent some platform specific issue 2024-11-23 19:54:38 +08:00
dfd80021b9 Search post 2024-11-23 19:06:09 +08:00
d64a24454d Set up sentry replay 2024-11-23 18:10:50 +08:00
0ed8c2373d Better reaction panel 2024-11-23 18:04:30 +08:00
b8a1e5b5c0 💫 Optimize transition of pages 2024-11-23 17:32:48 +08:00
5d6a52494e 🚀 Launch 2.0.0+7
 Add sentry
2024-11-23 17:13:28 +08:00
85a1dd3053 Notification screen 2024-11-23 16:55:23 +08:00
63499df99f 🐛 Bug fixes on notification push token register 2024-11-23 12:52:13 +08:00
e70041fefa 🚀 Launch 2.0.0+6 2024-11-22 00:46:55 +08:00
1af90cd9e7 Paste to add attachment 2024-11-22 00:28:29 +08:00
b52811d66e Ability to play video and audio
 Add media kit
2024-11-21 23:28:02 +08:00
7e63611416 Push token push (to server) 2024-11-21 22:55:00 +08:00
d41e358c6a ♻️ Optimized large screen display post effect
 Push notification
2024-11-21 22:10:12 +08:00
9fd30a1994 Add firebase 2024-11-21 00:18:11 +08:00
471d3deec5 ♻️ Optimize chat message display 2024-11-20 22:35:30 +08:00
c7f059b6d7 🐛 Fix bug render chat message on cannot find user 2024-11-20 00:13:36 +08:00
6af695d74e 🐛 Bug fixes on loading more messages 2024-11-19 22:17:17 +08:00
fd272ead37 👽 Update follow server side IM changes 2024-11-18 23:59:08 +08:00
6c5377d9fa 💥 Use quoteEventId column instead of quote_event in message body 2024-11-18 23:04:36 +08:00
ce414d92a2 Chat context menu (w.i.p) 2024-11-18 22:52:22 +08:00
5032cccf38 Chat quote and reply 2024-11-18 22:33:03 +08:00
9f7a3082cb Message with attachment 2024-11-18 21:38:15 +08:00
359cd94532 ♻️ Refactored attachment cache 2024-11-18 00:55:39 +08:00
432705c570 💄 Mergeable chat messages 2024-11-17 22:42:09 +08:00
2065350698 Chat message sending and receiving 2024-11-17 21:30:02 +08:00
285bb42b09 Basic message sending and listing 2024-11-17 01:16:54 +08:00
e9fbd0c65f Chat listing 2024-11-16 21:15:55 +08:00
835203706d Channel creation & alter 2024-11-16 16:55:31 +08:00
0e208cc320 Realm manage (CRUD) 2024-11-16 13:54:36 +08:00
ee2cb0c989 💫 Optimize nav transition 2024-11-15 23:08:29 +08:00
37c61a0406 Optimize nav transition performance 2024-11-15 22:46:12 +08:00
174 changed files with 23099 additions and 1653 deletions

View File

@ -1,5 +1,9 @@
plugins {
id "com.android.application"
// START: FlutterFire Configuration
id 'com.google.gms.google-services'
id 'com.google.firebase.crashlytics'
// END: FlutterFire Configuration
id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
@ -8,15 +12,15 @@ plugins {
android {
namespace = "dev.solsynth.solian"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
ndkVersion = "27.0.12077973"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
jvmTarget = JavaVersion.VERSION_17
}
defaultConfig {

View File

@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "961776991058",
"project_id": "solian-0x001",
"storage_bucket": "solian-0x001.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:961776991058:android:a8d3f7995b0b8e86f4188b",
"android_client_info": {
"package_name": "dev.solsynth.solian"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyDvFNudXYs29uDtcCv6pFR8h5tXBs90FYk"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@ -1,8 +1,22 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="surface"
android:label="Solian"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true">
<activity
android:name=".MainActivity"
android:exported="true"
@ -17,12 +31,12 @@
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
@ -38,8 +52,8 @@
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -1,3 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 872 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1017 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip

View File

@ -18,7 +18,11 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.1.0" apply false
id "com.android.application" version '8.7.2' apply false
// START: FlutterFire Configuration
id "com.google.gms.google-services" version "4.3.15" apply false
id "com.google.firebase.crashlytics" version "2.8.1" apply false
// END: FlutterFire Configuration
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@ -2,6 +2,7 @@
"nextVersionAlert": "Heavy Development Alert",
"nextVersionNotice": "You are using Solian 2.0 Preview, which is the first version of Solian 2.0. The current stable branch (sn.solsynth.dev) is 1.4. This version is still under heavy development, some features may not be stable, and not all features are supported. You can roll back to 1.4.X version via TestFlight, or continue to experience the new version (sn-next.solsynth.dev).",
"screen": "Screen",
"screenAbout": "About",
"screenHome": "Home",
"screenExplore": "Explore",
"screenAccount": "Account",
@ -14,9 +15,18 @@
"screenAccountPublisherNew": "New Publisher",
"screenAccountPublisherEdit": "Edit Publisher",
"screenAccountProfileEdit": "Edit Profile",
"screenAbuseReport": "Abuse Reports",
"screenSettings": "Settings",
"screenAlbum": "Album",
"screenChat": "Chat",
"screenChatManage": "Edit Channel",
"screenChatNew": "New Channel",
"screenRealm": "Realm",
"screenRealmManage": "Edit Realm",
"screenRealmNew": "New Realm",
"screenNotification": "Notification",
"screenPostSearch": "Search Posts",
"screenFriend": "Friends",
"dialogOkay": "Okay",
"dialogCancel": "Cancel",
"dialogConfirm": "Confirm",
@ -28,10 +38,12 @@
"errorRequestNotFound": "The resource that you looking for is not found.",
"errorRequestConnection": "Network connection error, please check your network or the service status.",
"errorRequestUnknown": "Unknown request error, maybe you want to take screenshot and report it to us.",
"unknown": "Unknown",
"prev": "Previous",
"next": "Next",
"edit": "Edit",
"apply": "Apply",
"cancel": "Cancel",
"create": "Create",
"preview": "Preview",
"loading": "Loading...",
@ -41,18 +53,44 @@
"compress": "Compress",
"report": "Report",
"repost": "Repost",
"replyPost": "Reply",
"reply": "Reply",
"unset": "Unset",
"untitled": "Untitled",
"postDetail": "Post detail",
"postNoun": "Post",
"postReadMore": "Read more",
"postReadEstimate": "Est read time {}",
"postTotalLength": {
"zero": "No character",
"one": "{} character",
"other": "{} characters"
},
"postVisibility": "Visibility",
"postVisibilityDescription": "Post visibility determines who can see this post.",
"postVisibilityAll": "Everyone",
"postVisibilityFriends": "Friends",
"postVisibilitySelected": "Selected User",
"postVisibilityFiltered": "Unselected User",
"postVisibilityNone": "Only Me",
"postVisibleUsers": "Visible Users",
"postInvisibleUsers": "Invisible Users",
"postSelectedUsers": {
"zero": "No user",
"one": "{} user",
"other": "{} users"
},
"fieldUsername": "Username",
"fieldNickname": "Nickname",
"fieldEmail": "Email address",
"fieldPassword": "Password",
"fieldDescription": "Description",
"fieldUsernameAlphanumOnly": "Username can only contain alphanumeric characters.",
"fieldUsernameLengthLimit": "Username must be between {} and {} characters.",
"fieldUsernameCannotEditHint": "Username cannot be edited after created",
"fieldUsernameLookupHint": "You can use username, phone number or email to login",
"fieldNicknameLengthLimit": "Nickname must be between {} and {} characters.",
"fieldEmailAddressMustBeValid": "Email address must be an email address.",
"fieldFirstName": "First name",
"fieldLastName": "Last name",
"fieldBirthday": "Birthday",
@ -81,12 +119,25 @@
"publishersNew": "New Publisher",
"publisherNewSubtitle": "Create a new publisher identity.",
"publisherSyncWithAccount": "Sync with account",
"publisherTotalUpvote": "Upvote",
"publisherTotalDownvote": "Downvote",
"publisherSocialPoint": "Social Point",
"publisherJoinedAt": "Joined at {}",
"publisherSocialPointTotal": {
"zero": "No social point",
"one": "{} social point",
"other": "{} social points"
},
"publisherRunBy": "Run by {}",
"fieldPublisherBelongToRealm": "Belongs to",
"fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm",
"writePostTypeStory": "Post a story",
"writePostTypeArticle": "Write an article",
"fieldPostPublisher": "Post publisher",
"fieldPostContent": "What happened?!",
"fieldPostTitle": "Title",
"fieldPostDescription": "Description",
"fieldPostTags": "Tags",
"postPublish": "Publish",
"postPosted": "Post has been posted.",
"postPublishedAt": "Published At",
@ -96,10 +147,20 @@
"postRepostingNotice": "You're about to repost a post that posted {}.",
"postReact": "React",
"postReactions": "Reactions of Post",
"postReactionPoints": {
"zero": "{} pt",
"one": "{} pt",
"other": "{} pts"
"postReactionUpvote": {
"zero": "0 upvote",
"one": "{} upvote",
"other": "{} upvotes"
},
"postReactionDownvote": {
"zero": "0 downvote",
"one": "{} downvote",
"other": "{} downvotes"
},
"postReactionSocialPoint": {
"zero": "0 point",
"one": "{} point",
"other": "{} points"
},
"postReactCompleted": "Reaction has been added.",
"postReactUncompleted": "Reaction has been removed.",
@ -128,10 +189,247 @@
"settingsNetworkServerPreset": "Present HyperNet Server",
"settingsNetworkServerPresetDescription": "You can choose one of our preset HyperNet server addresses from the list on the right.",
"settingsNetworkServerSaved": "Server address saved.",
"settingsMisc": "Misc",
"settingsMiscAbout": "About",
"settingsMiscAboutDescription": "View the version information of Solian.",
"sensitiveContent": "Sensitive Content",
"sensitiveContentCollapsed": "Sensitive content has been collapsed.",
"sensitiveContentDescription": "This content has been marked as sensitive, and may not be suitable for all viewers.",
"sensitiveContentReveal": "Reveal",
"serverConnecting": "Connecting to server...",
"serverDisconnected": "Lost connection from server"
"serverDisconnected": "Lost connection from server",
"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.",
"fieldChatName": "Name",
"fieldChatDescription": "Description",
"fieldChatBelongToRealm": "Belongs to",
"fieldChatBelongToRealmUnset": "Unset Channel Belongs to Realm",
"channelEditingNotice": "You are editing channel {}",
"channelDeleted": "Chat channel {} has been deleted.",
"channelDelete": "Delete channel {}",
"channelDeleteDescription": "Are you sure you want to delete this channel? This operation is irreversible, all messages in this channel will be permanently deleted.",
"channelDetailPersonalRegion": "Personal",
"channelDetailMemberRegion": "Members",
"channelMemberManage": "Manage Member",
"channelMemberManageDescription": "Manage the existing members of this channel.",
"channelMemberAdd": "Add Member",
"channelMemberAddDescription": "Add new member to this channel.",
"channelMemberAdded": "Channel member has been added.",
"fieldMemberRelatedName": "Member name / account ID",
"channelDetailAdminRegion": "Administration",
"channelEditProfile": "Edit Channel Profile",
"channelEdit": "Edit Channel",
"channelEditDescription": "Change the basic information of the channel, metadata, etc.",
"channelProfileEdit": "Edit Channel Profile",
"channelActionDelete": "Delete Channel",
"channelActionDeleteDescription": "Delete the entire channel, and also delete messages in the channel.",
"channelLeave": "Leave Channel {}",
"channelLeaveDescription": "Leave this channel, but the messages in the channel will not be removed.",
"channelActionLeave": "Leave Channel",
"channelActionLeaveDescription": "Delete your profile in this channel.",
"channelNotifyLevel": "Notify Level",
"channelNotifyLevelDescription": "Decide to receive how much notifications from this channel.",
"channelNotifyLevelAll": "All",
"channelNotifyLevelMentioned": "Only Mentioned",
"channelNotifyLevelNone": "Muted",
"channelNotifyLevelApplie": "Channel notify level has been applied.",
"fieldChannelProfileNick": "In-Channel Display Name",
"fieldChannelProfileNickHint": "The nickname to display in the channel, leave blank to use the account display name.",
"fieldRealmAlias": "Realm Alias",
"fieldRealmAliasHint": "The unique realm alias within the site, used to represent the realm in URL, leave blank to auto generate. Should be URL-Safe.",
"fieldRealmName": "Name",
"fieldRealmDescription": "Description",
"realmEditingNotice": "You are editing realm {}",
"realmDeleted": "Realm {} has been deleted.",
"realmDelete": "Delete realm {}",
"realmDeleteDescription": "Are you sure you want to delete this realm? This operation is irreversible, all resources (posts, chat channels, publishers, etc) belonging to this realm will be permanently deleted. Be careful and think twice!",
"realmActionDelete": "Delete Realm",
"realmActionDeleteDescription": "Delete the realm and all its resources.",
"realmEdit": "Edit Realm",
"realmEditDescription": "Edit the basic information of the realm, metadata, etc.",
"realmMemberAdd": "Add Member",
"realmMemberAddDescription": "Add new member to this realm.",
"realmMemberAdded": "Realm member has been added.",
"fieldChatMessage": "Message in {}",
"fieldChatMessageDirect": "Message with {}",
"eventResourceTag": "Event {}",
"messageDelete": "Delete message {}",
"messageDeleteDescription": "Are you sure you want to delete this message? This operation is irreversible. You will leave a record of the deleted message.",
"messageDeleted": "Message {} has been deleted",
"messageEdited": "Message {} has been edited",
"messageEditedHint": "Edited",
"messageUnsupported": "Unsupported message {}",
"messageFileHint": {
"zero": "No attachments",
"one": "{} attachment",
"other": "{} attachments"
},
"addAttachmentFromAlbum": "Add from album",
"addAttachmentFromClipboard": "Paste file",
"addAttachmentFromCameraPhoto": "Take photo",
"addAttachmentFromCameraVideo": "Take video",
"attachmentPastedImage": "Pasted Image",
"attachmentInsertLink": "Insert Link",
"attachmentSetAsPostThumbnail": "Set as post thumbnail",
"attachmentUnsetAsPostThumbnail": "Unset as post thumbnail",
"attachmentSetThumbnail": "Set thumbnail",
"attachmentUpload": "Upload",
"notification": "Notification",
"notificationUnreadCount": {
"zero": "All notifications read",
"one": "{} unread notification",
"other": "{} unread notifications"
},
"notificationUnread": "Unread",
"notificationRead": "Read",
"notificationMarkAllRead": "Mark all notifications as read",
"notificationMarkAllReadDescription": "Are you sure you want to mark all notifications as read? This operation is irreversible.",
"notificationMarkAllReadPrompt": {
"zero": "Marked 0 notification as read.",
"one": "Marked {} notification as read.",
"other": "Marked {} notifications as read."
},
"notificationMarkOneReadPrompt": "Marked notification {} as read.",
"search": "Search",
"postSearchResult": {
"zero": "No results",
"one": "{} result",
"other": "{} results"
},
"postSearchTook": "Took {}",
"postDelete": "Delete post {}",
"postDeleteDescription": "Are you sure you want to delete this post? This operation is irreversible.",
"postDeleted": "Post {} has been deleted.",
"call": "Call",
"callOngoingNotice": "A call is ongoing",
"callJoin": "Join",
"callResume": "Resume",
"callMicrophone": "Microphone",
"callCamera": "Camera",
"callMicrophoneDisabled": "Microphone is disabled",
"callMicrophoneSelect": "Select a microphone",
"callCameraDisabled": "Camera is disabled",
"callCameraSelect": "Select a camera",
"callDisconnected": "Call has been disconnected",
"callEnded": "Call has been ended",
"callStatusConnected": "Connected",
"callStatusDisconnected": "Disconnected",
"callStatusConnecting": "Connecting",
"callStatusReconnecting": "Reconnecting",
"callDisconnect": "Disconnect",
"callDisconnectDescription": "Are you sure you want to disconnect from the call?",
"callMicrophoneOff": "Turn off microphone",
"callMicrophoneOn": "Turn on microphone",
"callCameraOff": "Turn off camera",
"callCameraOn": "Turn on camera",
"callVideoFlip": "Mirror video",
"callSpeakerphoneToggle": "Toggle speakerphone",
"callScreenOff": "Turn off screen share",
"callScreenOn": "Turn on screen share",
"callMessageEnded": "Call lasted {}",
"callMessageStarted": "Call started",
"dailyCheckIn": "Check In",
"dailyCheckInNone": "You haven't checked in today",
"dailyCheckAction": "Check in right now!",
"dailyCheckDetail": "Can't understand the symbol? Master, help me understand it!",
"dailyCheckDetailTitle": "{}'s fortune details",
"dailyCheckPositiveHint": "Good for {}",
"dailyCheckNegativeHint": "Bad for {}",
"dailyCheckEverythingIsPositive": "Everything going to be awesome!",
"dailyCheckEverythingIsNegative": "Everything may be wrong...",
"dailyCheckPositiveHint1": "Making friends",
"dailyCheckPositiveHint1Description": "Friendship lasts forever",
"dailyCheckPositiveHint2": "Drinking",
"dailyCheckPositiveHint2Description": "Drinking under the moonlight with an imaginary companion",
"dailyCheckPositiveHint3": "Traveling",
"dailyCheckPositiveHint3Description": "A journey of a thousand miles begins with a single step",
"dailyCheckPositiveHint4": "Exercising",
"dailyCheckPositiveHint4Description": "Life lies in movement",
"dailyCheckPositiveHint5": "Learning",
"dailyCheckPositiveHint5Description": "Knowledge knows no bounds; progress every day",
"dailyCheckPositiveHint6": "Planting",
"dailyCheckPositiveHint6Description": "Sow hope, reap the future",
"dailyCheckNegativeHint1": "Eating",
"dailyCheckNegativeHint1Description": "Biting your tongue while eating",
"dailyCheckNegativeHint2": "Taking exams",
"dailyCheckNegativeHint2Description": "The exam covered what you didn't review",
"dailyCheckNegativeHint3": "Catching a bus",
"dailyCheckNegativeHint3Description": "Just missed the bus",
"dailyCheckNegativeHint4": "Shopping",
"dailyCheckNegativeHint4Description": "Bought clothes that don't fit",
"dailyCheckNegativeHint5": "Gaming",
"dailyCheckNegativeHint5Description": "Lost connection at a crucial moment",
"dailyCheckNegativeHint6": "Going out",
"dailyCheckNegativeHint6Description": "Forgot your umbrella and got caught in the rain",
"happyBirthday": "Happy birthday, {}!",
"friendNew": "Add Friend",
"friendRequests": "Friend Requests",
"friendRequestsDescription": {
"zero": "You have no friend request",
"one": "You have {} friend request",
"other": "You have {} friend requests"
},
"friendBlocklist": "Blocklist",
"friendBlocklistDescription": {
"zero": "You blocked no one",
"one": "You blocked {} user",
"other": "You blocked {} users"
},
"friendStatusPending": "Pending",
"friendStatusWaiting": "Waiting",
"friendStatusActive": "Friend",
"friendStatusBlocked": "Blocked",
"friendRequestSent": "Friend request has been sent.",
"fieldFriendRelatedName": "Friend name / account ID",
"friendBlock": "Block",
"friendUnblock": "Unblock",
"friendDeleteAction": "Delete",
"friendDelete": "Delete relation with {}",
"friendDeleteDescription": "Are you sure you want to delete the relation with {}? This operation is irreversible.",
"friendRequestAccept": "Accept",
"friendRequestDecline": "Decline",
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe",
"attachmentUploadBy": "Upload by",
"attachmentShotOn": "Shot on {}",
"accountJoinedAt": "Joined at {}",
"accountBirthday": "Born on {}",
"accountBadge": "Badge",
"badgeCompanyStaff": "Solsynth LLC Staff",
"badgeSiteMigration": "Solar Network Native",
"accountStatus": "Status",
"accountStatusOnline": "Online",
"accountStatusOffline": "Offline",
"accountStatusLastSeen": "Last seen at {}",
"postArticle": "Article on the Solar Network",
"articleWrittenAt": "Written at {}",
"articleEditedAt": "Edited at {}",
"attachmentSaved": "Saved to album",
"openInAlbum": "Open in album",
"postAbuseReport": "Report Post",
"postAbuseReportDescription": "Report posts that violate our user agreement and community guidelines to help us improve the content on Solar Network. Please describe how this post violates the relevant rules. Do not include any sensitive information. We will process your report within 24 hours.",
"abuseReport": "Abuse Report",
"abuseReportDescription": "Report any resources that violate our user agreement and community guidelines to help us improve the content on Solar Network. Please describe the location of the resource (provide resource ID as best as possible) and how this violates the relevant rules. Do not include any sensitive information. We will process your report within 24 hours.",
"abuseReportAction": "Submit Abuse Report",
"abuseReportActionDescription": "Report abuse usage behavior.",
"abuseReportResource": "Resource Location / ID",
"abuseReportReason": "Reason",
"abuseReportSubmitted": "Report submitted, thank you for your contribution.",
"submit": "Submit",
"accountDeletion": "Delete Account",
"accountDeletionDescription": "Are you sure you want to delete this account? This operation is irreversible, all resources (posts, chat channels, publishers, etc) belonging to this account will be permanently deleted. Be careful and think twice!",
"accountDeletionActionDescription": "Delete your Solarpass account.",
"accountDeletionSubmitted": "Account deletion request has been sent, you can check your inbox and follow the instructions in the email to complete the deletion operation.",
"channelNewChannel": "New Channel",
"channelNewDirectMessage": "New Direct Message",
"channelDirectMessageDescription": "Direct Message with {}",
"fieldCannotBeEmpty": "This field cannot be empty.",
"termAcceptLink": "View terms",
"termAcceptNextWithAgree": "By clicking the \"Next\", it means you agree to our terms and its updates.",
"unauthorized": "Unauthorized",
"unauthorizedDescription": "Login to explore the entire Solar Network.",
"serviceStatus": "Service Status",
"termRelated": "Related Terms",
"appDetails": "App Details",
"postRecommendation": "Highlight Posts"
}

View File

@ -1,7 +1,6 @@
{
"nextVersionAlert": "高强度开发提示",
"nextVersionNotice": "您正在使用的是 Solian 2.0 的抢先体验版本目前稳定分支sn.solsynth.dev版本为 1.4。该版本还在持续的开发中,部分功能可能不稳定,也并非所有功能都支持了。您可以通过 TestFlight 回滚到 1.4.X 或者继续体验新版本sn-next.solsynth.dev。",
"screen": "页面",
"screenAbout": "关于",
"screenHome": "首页",
"screenExplore": "探索",
"screenAccount": "您",
@ -14,9 +13,18 @@
"screenAccountPublisherNew": "新建发布者",
"screenAccountPublisherEdit": "编辑发布者",
"screenAccountProfileEdit": "编辑资料",
"screenAbuseReport": "滥用检举",
"screenSettings": "设置",
"screenAlbum": "相册",
"screenChat": "聊天",
"screenChatManage": "编辑聊天频道",
"screenChatNew": "新建聊天频道",
"screenRealm": "领域",
"screenRealmManage": "编辑领域",
"screenRealmNew": "新建领域",
"screenNotification": "通知",
"screenPostSearch": "搜索帖子",
"screenFriend": "好友",
"dialogOkay": "好的",
"dialogCancel": "取消",
"dialogConfirm": "确认",
@ -27,12 +35,14 @@
"errorRequestForbidden": "被禁止的请求,您没有足够的权限去做那件事。",
"errorRequestNotFound": "您正查找的资源无法被找到。",
"errorRequestConnection": "网络连接错误,请检查您的网络状态或者检查我们的服务状态。",
"errorRequestUnknown": "位置请求错误,您可能想将此对话框截图并发送给我们。",
"errorRequestUnknown": "未知请求错误,您可能想将此对话框截图并发送给我们。",
"unknown": "未知",
"loading": "加载中…",
"prev": "上一步",
"next": "下一步",
"edit": "编辑",
"apply": "应用",
"cancel": "取消",
"create": "创建",
"preview": "预览",
"delete": "删除",
@ -41,17 +51,29 @@
"compress": "压缩",
"report": "检举",
"repost": "转帖",
"reply": "回贴",
"replyPost": "回贴",
"reply": "回复",
"unset": "未设置",
"untitled": "无题",
"postDetail": "帖子详情",
"postNoun": "帖子",
"postReadMore": "阅读更多",
"postReadEstimate": "预计花费 {} 阅读",
"postTotalLength": {
"zero": "没有内容",
"one": "总计 {} 字",
"other": "总计 {} 字"
},
"fieldUsername": "用户名",
"fieldNickname": "显示名",
"fieldEmail": "电子邮箱地址",
"fieldPassword": "密码",
"fieldUsernameAlphanumOnly": "用户名只能包含英文大小写字母和数字。",
"fieldUsernameLengthLimit": "用户名必须在 {} 和 {} 之间。",
"fieldUsernameCannotEditHint": "用户名在创建后无法修改",
"fieldUsernameLookupHint": "支持用户名、电话号码或邮箱地址",
"fieldNicknameLengthLimit": "昵称必须在 {} 和 {} 之间。",
"fieldEmailAddressMustBeValid": "电子邮箱地址必须是一个电子邮箱地址。",
"fieldFirstName": "名",
"fieldLastName": "姓",
"fieldBirthday": "生日",
@ -81,25 +103,62 @@
"publishersNew": "新发布者",
"publisherNewSubtitle": "创建一个新的公共身份。",
"publisherSyncWithAccount": "同步账户信息",
"publisherTotalUpvote": "总顶数",
"publisherTotalDownvote": "总踩数",
"publisherSocialPoint": "社会信用点",
"publisherJoinedAt": "加入于 {}",
"publisherSocialPointTotal": {
"zero": "无社会信用点",
"one": "{} 点社会信用点",
"other": "{} 点社会信用点"
},
"publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所属领域",
"fieldPublisherBelongToRealmUnset": "未设置发布者所属领域",
"writePostTypeStory": "发动态",
"writePostTypeArticle": "写文章",
"fieldPostPublisher": "帖子发布者",
"fieldPostContent": "发生什么事了?!",
"fieldPostTitle": "标题",
"fieldPostDescription": "描述",
"fieldPostTags": "标签",
"postPublish": "发布",
"postPublishedAt": "发布于",
"postPublishedUntil": "取消发布于",
"postVisibility": "可见性",
"postVisibilityDescription": "帖子可见性决定了谁能查看该篇帖子。",
"postVisibilityAll": "所有人可见",
"postVisibilityFriends": "仅限好友可见",
"postVisibilitySelected": "选定的用户可见",
"postVisibilityFiltered": "选定用户不可见",
"postVisibilityNone": "仅自己可见",
"postVisibleUsers": "可见的用户",
"postInvisibleUsers": "不可见的用户",
"postSelectedUsers": {
"zero": "未选择用户",
"one": "选择了 {} 个用户",
"other": "选择了 {} 个用户"
},
"postEditingNotice": "你正在修改由 {} 发布的帖子。",
"postReplyingNotice": "你正在回复由 {} 发布的帖子。",
"postRepostingNotice": "你正在转发由 {} 发布的帖子。",
"postReact": "反应",
"postPosted": "帖子已经发表。",
"postReactions": "帖子的反应",
"postReactionPoints": {
"zero": "{} 点",
"one": "{} ",
"other": "{} "
"postReactionUpvote": {
"zero": "0 个顶",
"one": "{} 个顶",
"other": "{} 个顶"
},
"postReactionDownvote": {
"zero": "0 个踩",
"one": "{} 个踩",
"other": "{} 个踩"
},
"postReactionSocialPoint": {
"zero": "无社会信用点变更",
"one": "{} 点社会信用点变更",
"other": "{} 点社会信用点变更"
},
"postReactCompleted": "反应已被添加。",
"postReactUncompleted": "反应已被移除。",
@ -128,10 +187,247 @@
"settingsNetworkServerPreset": "预设的 HyperNet 服务器",
"settingsNetworkServerPresetDescription": "你可以在旁边的列表中选择我们提供的预设 HyperNet 服务器地址。",
"settingsNetworkServerSaved": "服务器地址已保存。",
"settingsMisc": "杂项",
"settingsMiscAbout": "关于",
"settingsMiscAboutDescription": "查看 Solian 的版本信息。",
"sensitiveContent": "敏感内容",
"sensitiveContentCollapsed": "敏感内容已折叠。",
"sensitiveContentDescription": "此内容已被标记,可能不适合所有人查看。",
"sensitiveContentReveal": "显示内容",
"serverConnecting": "正在连接服务器…",
"serverDisconnected": "已与服务器断开连接"
"serverDisconnected": "已与服务器断开连接",
"fieldChatAlias": "频道别名",
"fieldChatAliasHint": "全站范围内唯一的频道别名,用于在 URL 中表示该频道,留空则自动生成。应遵循 URL-Safe 的原则。",
"fieldChatName": "名称",
"fieldChatDescription": "描述",
"fieldChatBelongToRealm": "所属领域",
"fieldChatBelongToRealmUnset": "未设置频道所属领域",
"channelEditingNotice": "您正在编辑频道 {}",
"channelDeleted": "聊天频道 {} 已被删除",
"channelDelete": "删除聊天频道 {}",
"channelDeleteDescription": "你确定要删除这个聊天频道吗?该操作不可撤销,其频道内的所有消息将被永久删除。",
"channelDetailPersonalRegion": "个人区域",
"channelDetailMemberRegion": "成员管理",
"channelMemberManage": "管理成员",
"channelMemberManageDescription": "管理频道内现有成员。",
"channelMemberAdd": "添加成员",
"channelMemberAddDescription": "给当前频道添加新成员。",
"channelMemberAdded": "频道成员已添加。",
"fieldMemberRelatedName": "成员名 / 账户 ID",
"channelDetailAdminRegion": "管理区域",
"channelEditProfile": "更改频道身份",
"channelEdit": "编辑频道",
"channelEditDescription": "更改频道基本信息,元数据等。",
"channelProfileEdit": "编辑频道身份",
"channelActionDelete": "删除频道",
"channelActionDeleteDescription": "删除整个频道,并且删除频道里的所有信息。",
"channelLeave": "退出频道 {}",
"channelLeaveDescription": "退出该频道,但是你频道内的信息不会被移除。",
"channelActionLeave": "退出频道",
"channelActionLeaveDescription": "删除你在这个频道的身份。",
"channelNotifyLevel": "通知级别",
"channelNotifyLevelDescription": "有您决定要接受多少来自这个频道的消息。",
"channelNotifyLevelAll": "全部通知",
"channelNotifyLevelMentioned": "仅提及",
"channelNotifyLevelNone": "全部静音",
"channelNotifyLevelApplied": "已经保存并应用频道通知级别配置。",
"fieldChannelProfileNick": "频道内显示名",
"fieldChannelProfileNickHint": "在频道内显示的昵称,留空则使用账号显示名。",
"fieldRealmAlias": "领域别名",
"fieldRealmAliasHint": "全站范围内唯一的领域别名,用于在 URL 中表示该领域,留空则自动生成。应遵循 URL-Safe 的原则。",
"fieldRealmName": "名称",
"fieldRealmDescription": "描述",
"realmEditingNotice": "您正在编辑领域 {}",
"realmDeleted": "领域 {} 已被删除",
"realmDelete": "删除领域 {}",
"realmDeleteDescription": "你确定要删除这个领域吗?该操作不可撤销,其隶属于该领域的所有资源(帖子、聊天频道、发布者、制品等)都将被永久删除。三思而后行!",
"realmActionDelete": "删除领域",
"realmActionDeleteDescription": "删除整个领域及其附属的资源。",
"realmEdit": "编辑领域",
"realmEditDescription": "更改领域基本信息,元数据等。",
"realmMemberAdd": "添加成员",
"realmMemberAddDescription": "给当前领域添加新成员。",
"realmMemberAdded": "领域成员已添加。",
"fieldChatMessage": "在 {} 中发消息",
"fieldChatMessageDirect": "给 {} 发消息",
"eventResourceTag": "消息 {}",
"messageDelete": "删除消息 {}",
"messageDeleteDescription": "你确定要删除这个消息吗?该操作不可撤销。同时您将留下一条删除消息的记录。",
"messageDeleted": "消息 {} 已被删除",
"messageEdited": "消息 {} 已被编辑",
"messageEditedHint": "已编辑",
"messageUnsupported": "不支持的消息 {}",
"messageFileHint": {
"zero": "没有附件",
"one": "{} 个附件",
"other": "{} 个附件"
},
"addAttachmentFromAlbum": "从相册中添加附件",
"addAttachmentFromClipboard": "粘贴附件",
"addAttachmentFromCameraPhoto": "拍摄照片",
"addAttachmentFromCameraVideo": "拍摄视频",
"attachmentPastedImage": "粘贴的图片",
"attachmentInsertLink": "插入连接",
"attachmentSetAsPostThumbnail": "设置为帖子缩略图",
"attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图",
"attachmentSetThumbnail": "设置缩略图",
"attachmentUpload": "上传",
"notification": "通知",
"notificationUnreadCount": {
"zero": "无未读通知",
"one": "有 {} 个未读通知",
"other": "有 {} 个未读通知"
},
"notificationUnread": "未读",
"notificationRead": "已读",
"notificationMarkAllRead": "已读所有通知",
"notificationMarkAllReadDescription": "您确定要将所有通知设置为已读吗?该操作不可撤销。",
"notificationMarkAllReadPrompt": {
"zero": "已将 0 个通知标记为已读。",
"one": "已将 {} 个通知标记为已读。",
"other": "已将 {} 个通知标记为已读。"
},
"notificationMarkOneReadPrompt": "已将通知 {} 标记为已读。",
"search": "搜索",
"postSearchResult": {
"zero": "没有搜索到结果",
"one": "搜索到 {} 个结果",
"other": "搜索到 {} 个结果"
},
"postSearchTook": "耗时 {}",
"postDelete": "删除帖子 {}",
"postDeleteDescription": "你确定要删除这个帖子吗?该操作不可撤销。",
"postDeleted": "帖子 {} 已被删除。",
"call": "通话",
"callOngoingNotice": "一则通话进行中",
"callJoin": "加入",
"callResume": "恢复",
"callMicrophone": "麦克风",
"callCamera": "摄像头",
"callMicrophoneDisabled": "麦克风已禁用",
"callMicrophoneSelect": "选择麦克风",
"callCameraDisabled": "摄像头已禁用",
"callCameraSelect": "选择摄像头",
"callDisconnected": "通话已断开",
"callEnded": "通话已结束",
"callStatusConnected": "已连接",
"callStatusDisconnected": "未连接",
"callStatusConnecting": "正在连接",
"callStatusReconnecting": "正在重连",
"callDisconnect": "断开连接",
"callDisconnectDescription": "您确定要与通话断开连接吗?",
"callMicrophoneOff": "关闭麦克风",
"callMicrophoneOn": "打开麦克风",
"callCameraOff": "关闭摄像头",
"callCameraOn": "打开摄像头",
"callVideoFlip": "镜像画面",
"callSpeakerphoneToggle": "切换扬声器",
"callScreenOff": "关闭屏幕共享",
"callScreenOn": "开启屏幕共享",
"callMessageEnded": "通话持续了 {}",
"callMessageStarted": "通话开始了",
"dailyCheckIn": "每日签到",
"dailyCheckInNone": "今日尚未签到",
"dailyCheckAction": "现在签到",
"dailyCheckDetail": "看不懂符?大师帮我解惑!",
"dailyCheckDetailTitle": "{} 的运势详情",
"dailyCheckPositiveHint": "宜 {}",
"dailyCheckNegativeHint": "忌 {}",
"dailyCheckEverythingIsPositive": "诸事皆宜",
"dailyCheckEverythingIsNegative": "诸事不宜",
"dailyCheckPositiveHint1": "交友",
"dailyCheckPositiveHint1Description": "友谊地久天长",
"dailyCheckPositiveHint2": "饮酒",
"dailyCheckPositiveHint2Description": "对影成三人",
"dailyCheckPositiveHint3": "旅行",
"dailyCheckPositiveHint3Description": "千里之行,始于足下",
"dailyCheckPositiveHint4": "运动",
"dailyCheckPositiveHint4Description": "生命在于运动",
"dailyCheckPositiveHint5": "学习",
"dailyCheckPositiveHint5Description": "学无止境,日有所进",
"dailyCheckPositiveHint6": "种植",
"dailyCheckPositiveHint6Description": "种下希望,收获未来",
"dailyCheckNegativeHint1": "吃饭",
"dailyCheckNegativeHint1Description": "吃饭咬到舌头",
"dailyCheckNegativeHint2": "考试",
"dailyCheckNegativeHint2Description": "考的东西刚好没复习",
"dailyCheckNegativeHint3": "坐公交",
"dailyCheckNegativeHint3Description": "赶车刚好错过一班",
"dailyCheckNegativeHint4": "购物",
"dailyCheckNegativeHint4Description": "买回来的衣服发现不合适",
"dailyCheckNegativeHint5": "打游戏",
"dailyCheckNegativeHint5Description": "关键时刻断网",
"dailyCheckNegativeHint6": "出门",
"dailyCheckNegativeHint6Description": "忘带伞遇上大雨",
"happyBirthday": "生日快乐,{}",
"friendNew": "添加好友",
"friendRequests": "好友请求",
"friendRequestsDescription": {
"zero": "你没有好友请求",
"one": "你有 {} 个好友请求",
"other": "你有 {} 个好友请求"
},
"friendBlocklist": "屏蔽列表",
"friendBlocklistDescription": {
"zero": "你没有屏蔽任何人",
"one": "你屏蔽了 {} 个用户",
"other": "你屏蔽了 {} 个用户"
},
"friendStatusPending": "待处理",
"friendStatusWaiting": "等待中",
"friendStatusActive": "正活跃",
"friendStatusBlocked": "已屏蔽",
"friendRequestSent": "好友请求已发送。",
"fieldFriendRelatedName": "好友名 / 账户 ID",
"friendBlock": "屏蔽",
"friendUnblock": "解除屏蔽",
"friendDeleteAction": "遗忘",
"friendDelete": "遗忘跟 {} 的关系",
"friendDeleteDescription": "你确定要遗忘跟 {} 的关系吗?这个操作无法撤销。",
"friendRequestAccept": "接受",
"friendRequestDecline": "拒绝",
"subscribe": "订阅",
"unsubscribe": "取消订阅",
"attachmentUploadBy": "上传者",
"attachmentShotOn": "由 {} 拍摄",
"accountJoinedAt": "加入于 {}",
"accountBirthday": "出生于 {}",
"accountBadge": "徽章",
"badgeCompanyStaff": "索尔辛茨士大夫 · 员工",
"badgeSiteMigration": "Solar Network 原住民",
"accountStatus": "状态",
"accountStatusOnline": "在线",
"accountStatusOffline": "离线",
"accountStatusLastSeen": "最后一次在 {} 上线",
"postArticle": "Solar Network 上的文章",
"articleWrittenAt": "发表于 {}",
"articleEditedAt": "编辑于 {}",
"attachmentSaved": "已保存到相册",
"openInAlbum": "在相册中打开",
"postAbuseReport": "检举帖子",
"postAbuseReportDescription": "检举不符合我们用户协议以及社区准则的帖子,来帮助我们更好的维护 Solar Network 上的内容。请在下面描述该帖子如何违反我么的相关规定。请勿填写任何敏感信息。我们将会在 24 小时内处理您的检举。",
"abuseReport": "检举",
"abuseReportDescription": "检举不符合我们用户协议以及社区准则的任何资源,来帮助我们更好的维护 Solar Network 上的内容。请在下面描述资源的位置(提供资源 ID 为佳)以及如何违反我么的相关规定。请勿填写任何敏感信息。我们将会在 24 小时内处理您的检举。",
"abuseReportAction": "提交检举",
"abuseReportActionDescription": "检举不合规行为。",
"abuseReportResource": "资源位置 / ID",
"abuseReportReason": "检举原因",
"abuseReportSubmitted": "检举已提交,感谢你的贡献。",
"submit": "提交",
"accountDeletion": "删除帐户",
"accountDeletionDescription": "你确定要删除这个帐户吗?该操作不可撤销,其隶属于该帐户的所有资源(帖子、聊天频道、发布者、制品等)都将被永久删除。三思而后行!",
"accountDeletionActionDescription": "删除你的 Solarpass 帐户。",
"accountDeletionSubmitted": "帐户删除申请已发出,你可以检查你的收件箱并根据邮件内的指示完成删除操作。",
"channelNewChannel": "新建频道",
"channelNewDirectMessage": "发起私信",
"channelDirectMessageDescription": "与 {} 的私聊",
"fieldCannotBeEmpty": "此字段不能为空。",
"termAcceptLink": "浏览条款",
"termAcceptNextWithAgree": "点击 “下一步”,即表示你同意我们的各项条款,包括其之后的更新。",
"unauthorized": "未登陆",
"unauthorizedDescription": "登陆以探索整个 Solar Network。",
"serviceStatus": "服务状态",
"termRelated": "相关条款",
"appDetails": "应用程序详情",
"postRecommendation": "推荐帖子"
}

1
firebase.json Normal file
View File

@ -0,0 +1 @@
{"flutter":{"platforms":{"android":{"default":{"projectId":"solian-0x001","appId":"1:961776991058:android:a8d3f7995b0b8e86f4188b","fileOutput":"android/app/google-services.json"}},"ios":{"default":{"projectId":"solian-0x001","appId":"1:961776991058:ios:727229d368cc47e1f4188b","uploadDebugSymbols":false,"fileOutput":"ios/Runner/GoogleService-Info.plist"}},"macos":{"default":{"projectId":"solian-0x001","appId":"1:961776991058:ios:727229d368cc47e1f4188b","uploadDebugSymbols":false,"fileOutput":"macos/Runner/GoogleService-Info.plist"}},"dart":{"lib/firebase_options.dart":{"projectId":"solian-0x001","configurations":{"android":"1:961776991058:android:a8d3f7995b0b8e86f4188b","ios":"1:961776991058:ios:727229d368cc47e1f4188b","macos":"1:961776991058:ios:727229d368cc47e1f4188b","web":"1:961776991058:web:b91d12f2892a5609f4188b","windows":"1:961776991058:web:f152fd119699e13ef4188b"}}}}}}

View File

@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '12.0'
platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@ -40,5 +40,9 @@ end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
# Workaround for https://github.com/flutter/flutter/issues/64502
config.build_settings['ONLY_ACTIVE_ARCH'] = 'YES'
end
end
end

View File

@ -6,6 +6,8 @@ PODS:
- Flutter
- cupertino_http (0.0.1):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
@ -40,19 +42,166 @@ PODS:
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Firebase/Analytics (11.4.0):
- Firebase/Core
- Firebase/Core (11.4.0):
- Firebase/CoreOnly
- FirebaseAnalytics (~> 11.4.0)
- Firebase/CoreOnly (11.4.0):
- FirebaseCore (= 11.4.0)
- Firebase/Messaging (11.4.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 11.4.0)
- firebase_analytics (11.3.6):
- Firebase/Analytics (= 11.4.0)
- firebase_core
- Flutter
- firebase_core (3.8.1):
- Firebase/CoreOnly (= 11.4.0)
- Flutter
- firebase_messaging (15.1.6):
- Firebase/Messaging (= 11.4.0)
- firebase_core
- Flutter
- FirebaseAnalytics (11.4.0):
- FirebaseAnalytics/AdIdSupport (= 11.4.0)
- FirebaseCore (~> 11.0)
- FirebaseInstallations (~> 11.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0)
- FirebaseAnalytics/AdIdSupport (11.4.0):
- FirebaseCore (~> 11.0)
- FirebaseInstallations (~> 11.0)
- GoogleAppMeasurement (= 11.4.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0)
- FirebaseCore (11.4.0):
- FirebaseCoreInternal (~> 11.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/Logger (~> 8.0)
- FirebaseCoreInternal (11.5.0):
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- FirebaseInstallations (11.4.0):
- FirebaseCore (~> 11.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- PromisesObjC (~> 2.4)
- FirebaseMessaging (11.4.0):
- FirebaseCore (~> 11.0)
- FirebaseInstallations (~> 11.0)
- GoogleDataTransport (~> 10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/Reachability (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- nanopb (~> 3.30910.0)
- Flutter (1.0.0)
- flutter_native_splash (0.0.1):
- flutter_native_splash (2.4.3):
- Flutter
- flutter_secure_storage (6.0.0):
- flutter_udid (0.0.1):
- Flutter
- SAMKeychain
- flutter_webrtc (0.12.2):
- Flutter
- WebRTC-SDK (= 125.6422.06)
- gal (1.0.0):
- Flutter
- FlutterMacOS
- GoogleAppMeasurement (11.4.0):
- GoogleAppMeasurement/AdIdSupport (= 11.4.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/AdIdSupport (11.4.0):
- GoogleAppMeasurement/WithoutAdIdSupport (= 11.4.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/WithoutAdIdSupport (11.4.0):
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0)
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleUtilities/AppDelegateSwizzler (8.0.2):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Privacy
- GoogleUtilities/Environment (8.0.2):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.0.2):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/MethodSwizzler (8.0.2):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/Network (8.0.2):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Privacy
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (8.0.2)":
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.0.2)
- GoogleUtilities/Reachability (8.0.2):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/UserDefaults (8.0.2):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- image_picker_ios (0.0.1):
- Flutter
- livekit_client (2.3.1):
- Flutter
- WebRTC-SDK (= 125.6422.06)
- media_kit_libs_ios_video (1.0.4):
- Flutter
- media_kit_native_event_loop (1.0.0):
- Flutter
- media_kit_video (0.0.1):
- Flutter
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- package_info_plus (0.4.5):
- Flutter
- pasteboard (0.0.1):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- SDWebImage (5.19.7):
- SDWebImage/Core (= 5.19.7)
- SDWebImage/Core (5.19.7)
- permission_handler_apple (9.3.0):
- Flutter
- PromisesObjC (2.4.0)
- SAMKeychain (1.5.3)
- screen_brightness_ios (0.1.0):
- Flutter
- SDWebImage (5.20.0):
- SDWebImage/Core (= 5.20.0)
- SDWebImage/Core (5.20.0)
- Sentry/HybridSDK (8.40.1)
- sentry_flutter (8.10.1):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.40.1)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
@ -62,27 +211,64 @@ PODS:
- SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1):
- Flutter
- volume_controller (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
- WebRTC-SDK (125.6422.06)
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
- croppy (from `.symlinks/plugins/croppy/ios`)
- cupertino_http (from `.symlinks/plugins/cupertino_http/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- Flutter (from `Flutter`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
- gal (from `.symlinks/plugins/gal/darwin`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- livekit_client (from `.symlinks/plugins/livekit_client/ios`)
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
- media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- pasteboard (from `.symlinks/plugins/pasteboard/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- volume_controller (from `.symlinks/plugins/volume_controller/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
trunk:
- DKImagePickerController
- DKPhotoGallery
- Firebase
- FirebaseAnalytics
- FirebaseCore
- FirebaseCoreInternal
- FirebaseInstallations
- FirebaseMessaging
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleUtilities
- nanopb
- PromisesObjC
- SAMKeychain
- SDWebImage
- Sentry
- SwiftyGif
- WebRTC-SDK
EXTERNAL SOURCES:
connectivity_plus:
@ -91,43 +277,111 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/croppy/ios"
cupertino_http:
:path: ".symlinks/plugins/cupertino_http/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
firebase_analytics:
:path: ".symlinks/plugins/firebase_analytics/ios"
firebase_core:
:path: ".symlinks/plugins/firebase_core/ios"
firebase_messaging:
:path: ".symlinks/plugins/firebase_messaging/ios"
Flutter:
:path: Flutter
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_udid:
:path: ".symlinks/plugins/flutter_udid/ios"
flutter_webrtc:
:path: ".symlinks/plugins/flutter_webrtc/ios"
gal:
:path: ".symlinks/plugins/gal/darwin"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
livekit_client:
:path: ".symlinks/plugins/livekit_client/ios"
media_kit_libs_ios_video:
:path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
media_kit_native_event_loop:
:path: ".symlinks/plugins/media_kit_native_event_loop/ios"
media_kit_video:
:path: ".symlinks/plugins/media_kit_video/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
pasteboard:
:path: ".symlinks/plugins/pasteboard/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
screen_brightness_ios:
:path: ".symlinks/plugins/screen_brightness_ios/ios"
sentry_flutter:
:path: ".symlinks/plugins/sentry_flutter/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
volume_controller:
:path: ".symlinks/plugins/volume_controller/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
connectivity_plus: 4c41c08fc6d7c91f63bc7aec70ffe3730b04f563
croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321
cupertino_http: 1a3a0f163c1b26e7f1a293b33d476e0fde7a64ec
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
Firebase: cf1b19f21410b029b6786a54e9764a0cacad3c99
firebase_analytics: 2815af29d49c1a994652abd37a5b001a88bc7b75
firebase_core: 418aed674e9a0b8b6088aec16cde82a811f6261f
firebase_messaging: 98619a0572d82cfb3668e78859ba9f1110e268c9
FirebaseAnalytics: 3feef9ae8733c567866342a1000691baaa7cad49
FirebaseCore: e0510f1523bc0eb21653cac00792e1e2bd6f1771
FirebaseCoreInternal: f47dd28ae7782e6a4738aad3106071a8fe0af604
FirebaseInstallations: 6ef4a1c7eb2a61ee1f74727d7f6ce2e72acf1414
FirebaseMessaging: f8a160d99c2c2e5babbbcc90c4a3e15db036aee2
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
flutter_native_splash: e8a1e01082d97a8099d973f919f57904c925008a
flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04
flutter_webrtc: 1a53bd24f97bcfeff512f13699e721897f261563
gal: 61e868295d28fe67ffa297fae6dacebf56fd53e1
GoogleAppMeasurement: 987769c4ca6b968f2479fbcc9fe3ce34af454b8e
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
livekit_client: dbb906ef427fe96dde5854471c3dda0a50cc15f9
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8
Sentry: e9215d7b17f7902692b4f8700e061e4f853e3521
sentry_flutter: 927eed60d66951d1b0f1db37fe94ff5cb7c80231
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db
PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796
PODFILE CHECKSUM: d2bdaa1cc7915e14cf47235c34a21fcb07b00390
COCOAPODS: 1.15.2
COCOAPODS: 1.16.2

View File

@ -11,7 +11,9 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
73DA8A012D05C7620024A03E /* SolarNotifyService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73DA89FA2D05C7620024A03E /* SolarNotifyService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
8CD0929C27BC410DD5056EAB /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A2C24C5238FAC44EA2CCF738 /* GoogleService-Info.plist */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@ -26,9 +28,27 @@
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
73DA89FF2D05C7620024A03E /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 73DA89F92D05C7620024A03E;
remoteInfo = SolarNotifyService;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
73DA8A022D05C7620024A03E /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
73DA8A012D05C7620024A03E /* SolarNotifyService.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
@ -53,6 +73,8 @@
4A2F84B6033057E3BD2C7CB8 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
64FBE78F9C282712818D6D95 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
72E9279EFA6DAC00BBAC493C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
73111C212CEE3D5E004CF4B3 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
73DA89FA2D05C7620024A03E /* SolarNotifyService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolarNotifyService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
@ -64,10 +86,32 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A2C24C5238FAC44EA2CCF738 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; };
EDF483E994343CDFBF9BA347 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
73DA8A062D05C7620024A03E /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 73DA89F92D05C7620024A03E /* SolarNotifyService */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
73DA89FB2D05C7620024A03E /* SolarNotifyService */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (73DA8A062D05C7620024A03E /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = SolarNotifyService; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
73DA89F72D05C7620024A03E /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@ -120,10 +164,12 @@
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
73DA89FB2D05C7620024A03E /* SolarNotifyService */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
F5165E3BD1F2519F85CD4BE2 /* Pods */,
09229EB4EB35A0678AB9738D /* Frameworks */,
A2C24C5238FAC44EA2CCF738 /* GoogleService-Info.plist */,
);
sourceTree = "<group>";
};
@ -132,6 +178,7 @@
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
73DA89FA2D05C7620024A03E /* SolarNotifyService.appex */,
);
name = Products;
sourceTree = "<group>";
@ -139,6 +186,7 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
73111C212CEE3D5E004CF4B3 /* Runner.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
@ -186,6 +234,28 @@
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
73DA89F92D05C7620024A03E /* SolarNotifyService */ = {
isa = PBXNativeTarget;
buildConfigurationList = 73DA8A072D05C7620024A03E /* Build configuration list for PBXNativeTarget "SolarNotifyService" */;
buildPhases = (
73DA89F62D05C7620024A03E /* Sources */,
73DA89F72D05C7620024A03E /* Frameworks */,
73DA89F82D05C7620024A03E /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
73DA89FB2D05C7620024A03E /* SolarNotifyService */,
);
name = SolarNotifyService;
packageProductDependencies = (
);
productName = SolarNotifyService;
productReference = 73DA89FA2D05C7620024A03E /* SolarNotifyService.appex */;
productType = "com.apple.product-type.app-extension";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
@ -195,13 +265,17 @@
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
73DA8A022D05C7620024A03E /* Embed Foundation Extensions */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
FC4815D44D909666EB1FA614 /* [CP] Embed Pods Frameworks */,
244E364B35B507EB14F7681C /* FlutterFire: "flutterfire upload-crashlytics-symbols" */,
43B5CF57FD79BC21654EE037 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
dependencies = (
73DA8A002D05C7620024A03E /* PBXTargetDependency */,
);
name = Runner;
productName = Runner;
@ -215,6 +289,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 1610;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
@ -222,6 +297,9 @@
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
73DA89F92D05C7620024A03E = {
CreatedOnToolsVersion = 16.1;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
@ -243,6 +321,7 @@
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
73DA89F92D05C7620024A03E /* SolarNotifyService */,
);
};
/* End PBXProject section */
@ -255,6 +334,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
73DA89F82D05C7620024A03E /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@ -263,12 +349,31 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
8CD0929C27BC410DD5056EAB /* GoogleService-Info.plist in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
244E364B35B507EB14F7681C /* FlutterFire: "flutterfire upload-crashlytics-symbols" */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "FlutterFire: \"flutterfire upload-crashlytics-symbols\"";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\n#!/bin/bash\nPATH=${PATH}:$FLUTTER_ROOT/bin:$HOME/.pub-cache/bin\nflutterfire upload-crashlytics-symbols --upload-symbols-script-path=$PODS_ROOT/FirebaseCrashlytics/upload-symbols --platform=ios --apple-project-path=${SRCROOT} --env-platform-name=${PLATFORM_NAME} --env-configuration=${CONFIGURATION} --env-project-dir=${PROJECT_DIR} --env-built-products-dir=${BUILT_PRODUCTS_DIR} --env-dwarf-dsym-folder-path=${DWARF_DSYM_FOLDER_PATH} --env-dwarf-dsym-file-name=${DWARF_DSYM_FILE_NAME} --env-infoplist-path=${INFOPLIST_PATH} --default-config=default\n";
};
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@ -285,6 +390,23 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
43B5CF57FD79BC21654EE037 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@ -372,6 +494,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
73DA89F62D05C7620024A03E /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@ -389,6 +518,11 @@
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
73DA8A002D05C7620024A03E /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 73DA89F92D05C7620024A03E /* SolarNotifyService */;
targetProxy = 73DA89FF2D05C7620024A03E /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
@ -469,11 +603,13 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -536,6 +672,120 @@
};
name = Profile;
};
73DA8A032D05C7620024A03E /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SolarNotifyService/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SolarNotifyService;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolarNotifyService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
73DA8A042D05C7620024A03E /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SolarNotifyService/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SolarNotifyService;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolarNotifyService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
73DA8A052D05C7620024A03E /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SolarNotifyService/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SolarNotifyService;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolarNotifyService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@ -653,11 +903,13 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -677,11 +929,13 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -707,6 +961,16 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
73DA8A072D05C7620024A03E /* Build configuration list for PBXNativeTarget "SolarNotifyService" */ = {
isa = XCConfigurationList;
buildConfigurations = (
73DA8A032D05C7620024A03E /* Debug */,
73DA8A042D05C7620024A03E /* Release */,
73DA8A052D05C7620024A03E /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_KEY</key>
<string>AIzaSyCzQIyiYKoYHTpGXhN-IjgMML8z797WVD8</string>
<key>GCM_SENDER_ID</key>
<string>961776991058</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>dev.solsynth.solian</string>
<key>PROJECT_ID</key>
<string>solian-0x001</string>
<key>STORAGE_BUCKET</key>
<string>solian-0x001.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:961776991058:ios:727229d368cc47e1f4188b</string>
</dict>
</plist>

View File

@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
@ -12,6 +14,11 @@
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>zh_CN</string>
</array>
<key>CFBundleName</key>
<string>Solian</string>
<key>CFBundlePackageType</key>
@ -22,18 +29,43 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Grant access to Photo Library will allow Solian take photo or video for your post.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Grant access to Photo Library will allow Solian record audio for your post.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Grant access to Photo Library will allow Solian upload photo or video for your post.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Grant access to Photo Library will allow Solian download photo to album for you.</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>remote-notification</string>
<string>audio</string>
<string>voip</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
@ -41,24 +73,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>zh_CN</string>
</array>
<key>NSPhotoLibraryUsageDescription</key>
<string>Grant access to Photo Library will allow Solian upload photo or video for your post.</string>
<key>NSCameraUsageDescription</key>
<string>Grant access to Photo Library will allow Solian take photo or video for your post.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Grant access to Photo Library will allow Solian record audio for your post.</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIStatusBarHidden</key>
<false/>
</dict>
</plist>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSUserActivityTypes</key>
<array>
<string>INStartCallIntent</string>
<string>INSendMessageIntent</string>
</array>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>

View File

@ -0,0 +1,143 @@
//
// NotificationService.swift
// SolarNotifyService
//
// Created by LittleSheep on 2024/12/8.
//
import UserNotifications
import Intents
enum ParseNotificationPayloadError: Error {
case missingMetadata(String)
case missingAvatarUrl(String)
}
class NotificationService: UNNotificationServiceExtension {
private var contentHandler: ((UNNotificationContent) -> Void)?
private var bestAttemptContent: UNMutableNotificationContent?
private let serverBaseUrl = "https://api.sn.solsynth.dev"
private func getAttachmentUrl(for identifier: String) -> String {
identifier.starts(with: "http") ? identifier : "\(serverBaseUrl)/cgi/uc/attachments/\(identifier)"
}
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
guard let bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
contentHandler(request.content)
return
}
self.bestAttemptContent = bestAttemptContent
do {
try processNotification(request: request, content: bestAttemptContent)
} catch {
contentHandler(bestAttemptContent)
}
}
override func serviceExtensionTimeWillExpire() {
if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
contentHandler(bestAttemptContent)
}
}
private func processNotification(request: UNNotificationRequest, content: UNMutableNotificationContent) throws {
switch content.categoryIdentifier {
case "messaging.message", "messaging.callStart":
try handleMessagingNotification(request: request, content: content)
default:
try handleDefaultNotification(content: content)
}
}
private func handleMessagingNotification(request: UNNotificationRequest, content: UNMutableNotificationContent) throws {
guard let metadata = content.userInfo["metadata"] as? [AnyHashable: Any] else {
throw ParseNotificationPayloadError.missingMetadata("The notification has no metadata.")
}
guard var avatarUrl = metadata["avatar"] as? String else {
throw ParseNotificationPayloadError.missingAvatarUrl("The notification has no avatar.")
}
avatarUrl = getAttachmentUrl(for: avatarUrl)
let handle = INPersonHandle(value: "\(metadata["user_id"] ?? "")", type: .unknown)
let avatar = INImage(url: URL(string: avatarUrl)!)
let sender = INPerson(
personHandle: handle,
nameComponents: nil,
displayName: content.title,
image: avatar,
contactIdentifier: nil,
customIdentifier: nil
)
if content.categoryIdentifier == "messaging.callStart" {
let intent = createCallIntent(with: sender)
donateInteraction(for: intent)
let updatedContent = try request.content.updating(from: intent)
contentHandler?(updatedContent)
} else {
let intent = createMessageIntent(with: sender, metadata: metadata, body: content.body)
donateInteraction(for: intent)
let updatedContent = try request.content.updating(from: intent)
contentHandler?(updatedContent)
}
}
private func handleDefaultNotification(content: UNMutableNotificationContent) throws {
guard let metadata = content.userInfo["metadata"] as? [AnyHashable: Any] else {
throw ParseNotificationPayloadError.missingMetadata("The notification has no metadata.")
}
if let imageIdentifier = metadata["image"] as? String {
attachMedia(to: content, withIdentifier: imageIdentifier)
} else if let avatarIdentifier = metadata["avatar"] as? String {
attachMedia(to: content, withIdentifier: avatarIdentifier)
}
contentHandler?(content)
}
private func attachMedia(to content: UNMutableNotificationContent, withIdentifier identifier: String) {
let attachmentUrl = getAttachmentUrl(for: identifier)
if let url = URL(string: attachmentUrl), let attachment = try? UNNotificationAttachment(identifier: identifier, url: url) {
content.attachments = [attachment]
}
}
private func createCallIntent(with sender: INPerson) -> INStartCallIntent {
INStartCallIntent(
callRecordFilter: nil,
callRecordToCallBack: nil,
audioRoute: .unknown,
destinationType: .normal,
contacts: [sender],
callCapability: .unknown
)
}
private func createMessageIntent(with sender: INPerson, metadata: [AnyHashable: Any], body: String) -> INSendMessageIntent {
INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: body,
speakableGroupName: nil,
conversationIdentifier: "\(metadata["channel_id"] ?? "")",
serviceName: nil,
sender: sender,
attachments: nil
)
}
private func donateInteraction(for intent: INIntent) {
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
interaction.donate(completion: nil)
}
}

View File

@ -0,0 +1,432 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/websocket.dart';
import 'package:surface/types/chat.dart';
import 'package:uuid/uuid.dart';
class ChatMessageController extends ChangeNotifier {
static const kChatMessageBoxPrefix = 'nex_chat_messages_';
static const kSingleBatchLoadLimit = 100;
late final SnNetworkProvider _sn;
late final UserDirectoryProvider _ud;
late final WebSocketProvider _ws;
late final SnAttachmentProvider _attach;
StreamSubscription? _wsSubscription;
ChatMessageController(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
_ud = context.read<UserDirectoryProvider>();
_ws = context.read<WebSocketProvider>();
_attach = context.read<SnAttachmentProvider>();
}
bool isPending = true;
bool isLoading = false;
int? messageTotal;
bool get isAllLoaded =>
messageTotal != null && messages.length >= messageTotal!;
String? _boxKey;
SnChannel? channel;
SnChannelMember? profile;
/// Messages are the all the messages that in the channel
final List<SnChatMessage> messages = List.empty(growable: true);
/// Unconfirmed messages are the messages that sent by client but did not receive the reply from websocket server.
/// Stored as a list of nonce to provide the loading state
final List<String> unconfirmedMessages = List.empty(growable: true);
Box<SnChatMessage>? get _box =>
(_boxKey == null || isPending) ? null : Hive.box<SnChatMessage>(_boxKey!);
Future<void> initialize(SnChannel chan) async {
channel = chan;
// Initialize local data
_boxKey = '$kChatMessageBoxPrefix${chan.id}';
await Hive.openBox<SnChatMessage>(_boxKey!);
// Fetch channel profile
final resp = await _sn.client.get(
'/cgi/im/channels/${chan.keyPath}/me',
);
profile = SnChannelMember.fromJson(
resp.data as Map<String, dynamic>,
);
_wsSubscription = _ws.stream.stream.listen((event) {
switch (event.method) {
case 'events.new':
final payload = SnChatMessage.fromJson(event.payload!);
_addMessage(payload);
break;
case 'status.typing':
if (event.payload?['channel_id'] != channel?.id) break;
final member = SnChannelMember.fromJson(event.payload!['member']);
if (member.id == profile?.id) break;
// TODO impl typing users
// if (!_typingUsers.any((x) => x.id == member.id)) {
// setState(() {
// _typingUsers.add(member);
// });
// }
// _typingInactiveTimer[member.id]?.cancel();
// _typingInactiveTimer[member.id] = Timer(
// const Duration(seconds: 3),
// () {
// setState(() {
// _typingUsers.removeWhere((x) => x.id == member.id);
// _typingInactiveTimer.remove(member.id);
// });
// },
// );
}
});
isPending = false;
notifyListeners();
}
Future<void> _saveMessageToLocal(Iterable<SnChatMessage> messages) async {
if (_box == null) return;
await _box!.putAll({
for (final message in messages) message.id: message,
});
}
Future<void> _addUnconfirmedMessage(SnChatMessage message) async {
SnChatMessage? quoteEvent;
if (message.quoteEventId != null) {
quoteEvent = await getMessage(message.quoteEventId as int);
}
final attachmentRid = List<String>.from(
message.body['attachments']?.cast<String>() ?? [],
);
final attachments = await _attach.getMultiple(attachmentRid);
message = message.copyWith(
preload: SnChatMessagePreload(
quoteEvent: quoteEvent,
attachments: attachments,
),
);
messages.insert(0, message);
unconfirmedMessages.add(message.uuid);
notifyListeners();
}
Future<void> _addMessage(SnChatMessage message) async {
SnChatMessage? quoteEvent;
if (message.quoteEventId != null) {
quoteEvent = await getMessage(message.quoteEventId as int);
}
final attachmentRid = List<String>.from(
message.body['attachments']?.cast<String>() ?? [],
);
final attachments = await _attach.getMultiple(attachmentRid);
message = message.copyWith(
preload: SnChatMessagePreload(
quoteEvent: quoteEvent,
attachments: attachments,
),
);
final idx = messages.indexWhere((e) => e.uuid == message.uuid);
if (idx != -1) {
unconfirmedMessages.remove(message.uuid);
messages[idx] = message;
} else {
messages.insert(0, message);
}
await _applyMessage(message);
notifyListeners();
if (_box == null) return;
await _box!.put(message.id, message);
}
Future<void> _applyMessage(SnChatMessage message) async {
if (message.channelId != channel?.id) return;
switch (message.type) {
case 'messages.edit':
if (message.relatedEventId != null) {
final idx =
messages.indexWhere((x) => x.id == message.relatedEventId);
if (idx != -1) {
final newBody = message.body;
newBody.remove('related_event');
messages[idx] = messages[idx].copyWith(
body: newBody,
updatedAt: message.updatedAt,
);
if (_box!.containsKey(message.relatedEventId)) {
await _box!.put(message.relatedEventId, messages[idx]);
}
}
}
case 'messages.delete':
if (message.relatedEventId != null) {
messages.removeWhere((x) => x.id == message.relatedEventId);
if (_box!.containsKey(message.relatedEventId)) {
await _box!.delete(message.relatedEventId);
}
}
}
}
Future<void> sendMessage(
String type,
String content, {
int? quoteId,
int? relatedId,
List<String>? attachments,
SnChatMessage? editingMessage,
}) async {
if (channel == null) return;
const uuid = Uuid();
final nonce = uuid.v4();
final body = {
'text': content,
'algorithm': 'plain',
if (quoteId != null) 'quote_event': quoteId,
if (relatedId != null) 'related_event': relatedId,
if (attachments != null && attachments.isNotEmpty)
'attachments': attachments,
};
// Mock the message locally
final createdAt = DateTime.now();
final message = SnChatMessage(
id: 0,
createdAt: createdAt,
updatedAt: createdAt,
deletedAt: null,
uuid: nonce,
body: body,
type: type,
channel: channel!,
channelId: channel!.id,
sender: profile!,
senderId: profile!.id,
quoteEventId: quoteId,
relatedEventId: relatedId,
);
_addUnconfirmedMessage(message);
// Send to server
try {
await _sn.client.request(
editingMessage != null
? '/cgi/im/channels/${channel!.keyPath}/messages/${editingMessage.id}'
: '/cgi/im/channels/${channel!.keyPath}/messages',
data: {
'type': type,
'uuid': nonce,
'body': body,
},
options: Options(
method: editingMessage != null ? 'PUT' : 'POST',
),
);
} catch (err) {
// ignore
}
}
Future<void> deleteMessage(SnChatMessage message) async {
if (message.channelId != channel?.id) return;
try {
await _sn.client.delete(
'/cgi/im/channels/${channel!.keyPath}/messages/${message.id}',
);
} catch (err) {
// ignore
}
}
/// Check the local storage is up to date with the server.
/// If the local storage is not up to date, it will be updated.
Future<void> checkUpdate() async {
if (_box == null) return;
if (_box!.isEmpty) return;
isLoading = true;
notifyListeners();
try {
final resp = await _sn.client.get(
'/cgi/im/channels/${channel!.keyPath}/events/update',
queryParameters: {
'pivot': _box!.values.last.id,
},
);
if (resp.data['up_to_date'] == true) return;
// Only preload the first 100 messages to prevent first time check update cause load to server and waste local storage.
// FIXME If the local is missing more than 100 messages, it won't be fetched, this is a problem, we need to fix it.
final countToFetch = math.min(resp.data['count'] as int, 100);
for (int idx = 0; idx < countToFetch; idx += kSingleBatchLoadLimit) {
await getMessages(kSingleBatchLoadLimit, idx, forceRemote: true);
}
} catch (err) {
rethrow;
} finally {
await loadMessages();
isLoading = false;
notifyListeners();
}
}
/// Get a single event from the current channel
/// If it was not found in local storage we will look it up in remote
Future<SnChatMessage?> getMessage(int id) async {
SnChatMessage? out;
if (_box != null && _box!.containsKey(id)) {
out = _box!.get(id);
}
if (out == null) {
try {
final resp = await _sn.client
.get('/cgi/im/channels/${channel!.keyPath}/events/$id');
out = SnChatMessage.fromJson(resp.data);
_saveMessageToLocal([out]);
} catch (_) {
// ignore, maybe not found
}
}
// Preload some related things if found
if (out != null) {
await _ud.listAccount([out.sender.accountId]);
final attachments = await _attach.getMultiple(
out.body['attachments']?.cast<String>() ?? [],
);
out = out.copyWith(
preload: SnChatMessagePreload(
attachments: attachments,
),
);
}
return out;
}
/// Get message from local storage first, then from the server.
/// Will not check local storage is up to date with the server.
/// If you need to do the sync, do the `checkUpdate` instead.
Future<List<SnChatMessage>> getMessages(
int take,
int offset, {
bool forceLocal = false,
bool forceRemote = false,
}) async {
late List<SnChatMessage> out;
if (_box != null &&
(_box!.length >= take + offset || forceLocal) &&
!forceRemote) {
out = _box!.keys
.toList()
.cast<int>()
.sorted((a, b) => b.compareTo(a))
.skip(offset)
.take(take)
.map((key) => _box!.get(key)!)
.toList();
} else {
final resp = await _sn.client.get(
'/cgi/im/channels/${channel!.keyPath}/events',
queryParameters: {
'take': take,
'offset': offset,
},
);
messageTotal = resp.data['count'] as int?;
out = List<SnChatMessage>.from(
resp.data['data']?.map((e) => SnChatMessage.fromJson(e)) ?? [],
);
_saveMessageToLocal(out);
}
// Preload attachments
final attachmentRid = List<String>.from(
out.expand((e) => (e.body['attachments'] as List<dynamic>?) ?? []),
);
final attachments = await _attach.getMultiple(attachmentRid);
// Putting preload back to data
for (var i = 0; i < out.length; i++) {
// Preload related events (quoted)
SnChatMessage? quoteEvent;
if (out[i].quoteEventId != null) {
quoteEvent = await getMessage(out[i].quoteEventId as int);
}
out[i] = out[i].copyWith(
preload: SnChatMessagePreload(
quoteEvent: quoteEvent,
attachments: attachments
.where(
(ele) =>
out[i].body['attachments']?.contains(ele?.rid) ?? false,
)
.toList(),
),
);
}
// Preload sender accounts
final accountId = out
.where((ele) => ele.sender.accountId >= 0)
.map((ele) => ele.sender.accountId)
.toSet();
await _ud.listAccount(accountId);
return out;
}
/// The load messages method work as same as the `getMessages` method.
/// But it won't return the messages instead append them to the value that controller has.
/// At the same time, this method provide the `isLoading` state.
/// The `skip` parameter is no longer required since it will skip the messages count that already loaded.
Future<void> loadMessages({int take = 20}) async {
isLoading = true;
notifyListeners();
try {
final out = await getMessages(take, messages.length);
messages.addAll(out);
} catch (err) {
rethrow;
} finally {
isLoading = false;
notifyListeners();
}
}
@override
void dispose() {
_box?.close();
_wsSubscription?.cancel();
super.dispose();
}
}

View File

@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:mime/mime.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/attachment.dart';
@ -27,6 +28,8 @@ class PostWriteMedia {
final XFile? file;
final Uint8List? raw;
PostWriteMedia? thumbnail;
PostWriteMedia(this.attachment, {this.file, this.raw}) {
name = attachment!.name;
@ -66,8 +69,7 @@ class PostWriteMedia {
}
}
PostWriteMedia.fromBytes(this.raw, this.name, this.type,
{this.attachment, this.file});
PostWriteMedia.fromBytes(this.raw, this.name, this.type, {this.attachment, this.file});
bool get isEmpty => attachment == null && file == null && raw == null;
@ -86,7 +88,10 @@ class PostWriteMedia {
if (file != null) {
return file!;
} else if (raw != null) {
return XFile.fromData(raw!, name: name);
return XFile.fromData(
raw!,
name: name,
);
}
return null;
}
@ -98,8 +103,7 @@ class PostWriteMedia {
}) {
if (attachment != null) {
final sn = context.read<SnNetworkProvider>();
final ImageProvider provider =
UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
final ImageProvider provider = UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
if (width != null && height != null) {
return ResizeImage(
provider,
@ -110,8 +114,7 @@ class PostWriteMedia {
}
return provider;
} else if (file != null) {
final ImageProvider provider =
kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path));
final ImageProvider provider = kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path));
if (width != null && height != null) {
return ResizeImage(
provider,
@ -158,9 +161,10 @@ class PostWriteController extends ChangeNotifier {
String mode = kTitleMap.keys.first;
String get title => titleController.text;
String get description => descriptionController.text;
bool get isRelatedNull =>
![editingPost, repostingPost, replyingPost].any((ele) => ele != null);
bool get isRelatedNull => ![editingPost, repostingPost, replyingPost].any((ele) => ele != null);
bool isLoading = false, isBusy = false;
double? progress;
@ -168,6 +172,11 @@ class PostWriteController extends ChangeNotifier {
SnPublisher? publisher;
SnPost? editingPost, repostingPost, replyingPost;
int visibility = 0;
List<int> visibleUsers = List.empty();
List<int> invisibleUsers = List.empty();
List<String> tags = List.empty();
PostWriteMedia? thumbnail;
List<PostWriteMedia> attachments = List.empty(growable: true);
DateTime? publishedAt, publishedUntil;
@ -177,53 +186,41 @@ class PostWriteController extends ChangeNotifier {
int? reposting,
int? replying,
}) async {
final sn = context.read<SnNetworkProvider>();
final attach = context.read<SnAttachmentProvider>();
final pt = context.read<SnPostContentProvider>();
isLoading = true;
notifyListeners();
try {
if (editing != null) {
final resp = await sn.client.get('/cgi/co/posts/$editing');
final post = SnPost.fromJson(resp.data);
final alts = await attach
.getMultiple(post.body['attachments']?.cast<String>() ?? []);
final post = await pt.getPost(editing);
publisher = post.publisher;
titleController.text = post.body['title'] ?? '';
descriptionController.text = post.body['description'] ?? '';
contentController.text = post.body['content'] ?? '';
publishedAt = post.publishedAt;
publishedUntil = post.publishedUntil;
attachments.addAll(alts.map((ele) => PostWriteMedia(ele)));
visibleUsers = List.from(post.visibleUsersList ?? []);
invisibleUsers = List.from(post.invisibleUsersList ?? []);
visibility = post.visibility;
tags = List.from(post.tags.map((ele) => ele.alias));
attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
editingPost = post.copyWith(
preload: SnPostPreload(
attachments: alts,
),
);
if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
thumbnail = PostWriteMedia(post.preload!.thumbnail);
}
editingPost = post;
}
if (replying != null) {
final resp = await sn.client.get('/cgi/co/posts/$replying');
final post = SnPost.fromJson(resp.data);
replyingPost = post.copyWith(
preload: SnPostPreload(
attachments: await attach
.getMultiple(post.body['attachments']?.cast<String>() ?? []),
),
);
final post = await pt.getPost(replying);
replyingPost = post;
}
if (reposting != null) {
final resp = await sn.client.get('/cgi/co/posts/$reposting');
final post = SnPost.fromJson(resp.data);
repostingPost = post.copyWith(
preload: SnPostPreload(
attachments: await attach
.getMultiple(post.body['attachments']?.cast<String>() ?? []),
),
);
final post = await pt.getPost(reposting);
repostingPost = post;
}
} catch (err) {
if (!context.mounted) return;
@ -234,6 +231,44 @@ class PostWriteController extends ChangeNotifier {
}
}
Future<SnAttachment> _uploadAttachment(BuildContext context, PostWriteMedia media) async {
final attach = context.read<SnAttachmentProvider>();
final place = await attach.chunkedUploadInitialize(
(await media.length())!,
media.name,
'interactive',
null,
mimetype: media.raw != null && media.type == PostWriteMediaType.image ? 'image/png' : null,
);
final item = await attach.chunkedUploadParts(
media.toFile()!,
place.$1,
place.$2,
onProgress: (progress) {
progress = progress;
notifyListeners();
},
);
return item;
}
Future<void> uploadSingleAttachment(BuildContext context, int idx) async {
if (isBusy) return;
final media = idx == -1 ? thumbnail! : attachments[idx];
isBusy = true;
notifyListeners();
final item = await _uploadAttachment(context, media);
attachments[idx] = PostWriteMedia(item);
isBusy = false;
notifyListeners();
}
Future<void> post(BuildContext context) async {
if (isBusy || publisher == null) return;
@ -246,6 +281,11 @@ class PostWriteController extends ChangeNotifier {
// Uploading attachments
try {
if (thumbnail != null && thumbnail!.attachment == null) {
final thumb = await _uploadAttachment(context, thumbnail!);
thumbnail = PostWriteMedia(thumb);
}
for (int i = 0; i < attachments.length; i++) {
final media = attachments[i];
if (media.attachment != null) continue; // Already uploaded, skip
@ -256,6 +296,7 @@ class PostWriteController extends ChangeNotifier {
media.name,
'interactive',
null,
mimetype: media.raw != null && media.type == PostWriteMediaType.image ? 'image/png' : null,
);
final item = await attach.chunkedUploadParts(
@ -264,8 +305,7 @@ class PostWriteController extends ChangeNotifier {
place.$2,
onProgress: (progress) {
// Calculate overall progress for attachments
progress = ((i + progress) / attachments.length) *
kAttachmentProgressWeight;
progress = ((i + progress) / attachments.length) * kAttachmentProgressWeight;
notifyListeners();
},
);
@ -295,28 +335,24 @@ class PostWriteController extends ChangeNotifier {
'publisher': publisher!.id,
'content': contentController.text,
if (titleController.text.isNotEmpty) 'title': titleController.text,
if (descriptionController.text.isNotEmpty)
'description': descriptionController.text,
'attachments': attachments
.where((e) => e.attachment != null)
.map((e) => e.attachment!.rid)
.toList(),
if (publishedAt != null)
'published_at': publishedAt!.toUtc().toIso8601String(),
if (publishedUntil != null)
'published_until': publishedAt!.toUtc().toIso8601String(),
if (descriptionController.text.isNotEmpty) 'description': descriptionController.text,
if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid,
'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(),
'tags': tags.map((ele) => {'alias': ele}).toList(),
'visibility': visibility,
'visible_users_list': visibleUsers,
'invisible_users_list': invisibleUsers,
if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(),
if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
if (replyingPost != null) 'reply_to': replyingPost!.id,
if (repostingPost != null) 'repost_to': repostingPost!.id,
},
onSendProgress: (count, total) {
progress =
baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
notifyListeners();
},
onReceiveProgress: (count, total) {
progress = baseProgressVal +
(kPostingProgressWeight / 2) +
(count / total) * (kPostingProgressWeight / 2);
progress = baseProgressVal + (kPostingProgressWeight / 2) + (count / total) * (kPostingProgressWeight / 2);
notifyListeners();
},
options: Options(
@ -338,12 +374,34 @@ class PostWriteController extends ChangeNotifier {
}
void setAttachmentAt(int idx, PostWriteMedia item) {
attachments[idx] = item;
if (idx == -1) {
thumbnail = item;
} else {
attachments[idx] = item;
}
notifyListeners();
}
void removeAttachmentAt(int idx) {
attachments.removeAt(idx);
if (idx == -1) {
thumbnail = null;
} else {
attachments.removeAt(idx);
}
notifyListeners();
}
void setThumbnail(int? idx) {
if (idx == null) {
attachments.add(thumbnail!);
thumbnail = null;
} else {
if (thumbnail != null) {
attachments.add(thumbnail!);
}
thumbnail = attachments[idx];
attachments.removeAt(idx);
}
notifyListeners();
}
@ -362,11 +420,41 @@ class PostWriteController extends ChangeNotifier {
notifyListeners();
}
void setTags(List<String> value) {
tags = value;
notifyListeners();
}
void setVisibility(int value) {
visibility = value;
notifyListeners();
}
void setVisibleUsers(List<int> value) {
visibleUsers = value;
notifyListeners();
}
void setInvisibleUsers(List<int> value) {
invisibleUsers = value;
notifyListeners();
}
void setProgress(double? value) {
progress = value;
notifyListeners();
}
void setIsBusy(bool value) {
isBusy = value;
notifyListeners();
}
void setMode(String value) {
mode = value;
notifyListeners();
}
void reset() {
publishedAt = null;
publishedUntil = null;

89
lib/firebase_options.dart Normal file
View File

@ -0,0 +1,89 @@
// File generated by FlutterFire CLI.
// ignore_for_file: type=lint
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
/// Default [FirebaseOptions] for use with your Firebase apps.
///
/// Example:
/// ```dart
/// import 'firebase_options.dart';
/// // ...
/// await Firebase.initializeApp(
/// options: DefaultFirebaseOptions.currentPlatform,
/// );
/// ```
class DefaultFirebaseOptions {
static FirebaseOptions get currentPlatform {
if (kIsWeb) {
return web;
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return android;
case TargetPlatform.iOS:
return ios;
case TargetPlatform.macOS:
return macos;
case TargetPlatform.windows:
return windows;
case TargetPlatform.linux:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for linux - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
default:
throw UnsupportedError(
'DefaultFirebaseOptions are not supported for this platform.',
);
}
}
static const FirebaseOptions web = FirebaseOptions(
apiKey: 'AIzaSyBKfIQpTouj5rXnlzkEieSlbAzepm4mgJE',
appId: '1:961776991058:web:b91d12f2892a5609f4188b',
messagingSenderId: '961776991058',
projectId: 'solian-0x001',
authDomain: 'solian-0x001.firebaseapp.com',
storageBucket: 'solian-0x001.firebasestorage.app',
measurementId: 'G-XY3HHKG0PE',
);
static const FirebaseOptions android = FirebaseOptions(
apiKey: 'AIzaSyDvFNudXYs29uDtcCv6pFR8h5tXBs90FYk',
appId: '1:961776991058:android:a8d3f7995b0b8e86f4188b',
messagingSenderId: '961776991058',
projectId: 'solian-0x001',
storageBucket: 'solian-0x001.firebasestorage.app',
);
static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'AIzaSyCzQIyiYKoYHTpGXhN-IjgMML8z797WVD8',
appId: '1:961776991058:ios:727229d368cc47e1f4188b',
messagingSenderId: '961776991058',
projectId: 'solian-0x001',
storageBucket: 'solian-0x001.firebasestorage.app',
iosBundleId: 'dev.solsynth.solian',
);
static const FirebaseOptions macos = FirebaseOptions(
apiKey: 'AIzaSyCzQIyiYKoYHTpGXhN-IjgMML8z797WVD8',
appId: '1:961776991058:ios:727229d368cc47e1f4188b',
messagingSenderId: '961776991058',
projectId: 'solian-0x001',
storageBucket: 'solian-0x001.firebasestorage.app',
iosBundleId: 'dev.solsynth.solian',
);
static const FirebaseOptions windows = FirebaseOptions(
apiKey: 'AIzaSyBKfIQpTouj5rXnlzkEieSlbAzepm4mgJE',
appId: '1:961776991058:web:f152fd119699e13ef4188b',
messagingSenderId: '961776991058',
projectId: 'solian-0x001',
authDomain: 'solian-0x001.firebaseapp.com',
storageBucket: 'solian-0x001.firebasestorage.app',
measurementId: 'G-19FCN0CD9X',
);
}

View File

@ -1,28 +1,71 @@
import 'dart:io';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:croppy/croppy.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:easy_localization_loader/easy_localization_loader.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:surface/firebase_options.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/chat_call.dart';
import 'package:surface/providers/navigation.dart';
import 'package:surface/providers/notification.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/providers/relationship.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/theme.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/websocket.dart';
import 'package:surface/router.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/types/realm.dart';
import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await EasyLocalization.ensureInitialized();
if (!kReleaseMode) {
debugInvertOversizedImages = true;
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)) {
doWhenWindowReady(() {
appWindow.minSize = Size(480, 640);
appWindow.size = Size(1280, 720);
appWindow.alignment = Alignment.center;
appWindow.show();
});
}
runApp(const SolianApp());
await SentryFlutter.init(
(options) {
options.dsn =
'https://c218d44126d59d69301e730498494def@o4506965897117696.ingest.us.sentry.io/4508346768228352';
options.tracesSampleRate = 1.0;
options.profilesSampleRate = 1.0;
},
appRunner: () => runApp(const SolianApp()),
);
}
class SolianApp extends StatelessWidget {
@ -46,9 +89,15 @@ class SolianApp extends StatelessWidget {
// Data layer
Provider(create: (_) => SnNetworkProvider()),
Provider(create: (ctx) => UserDirectoryProvider(ctx)),
Provider(create: (ctx) => SnAttachmentProvider(ctx)),
Provider(create: (ctx) => SnPostContentProvider(ctx)),
Provider(create: (ctx) => SnRelationshipProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
],
child: AppMainContent(),
),
@ -69,6 +118,8 @@ class AppMainContent extends StatelessWidget {
Widget build(BuildContext context) {
context.read<NavigationProvider>();
context.read<WebSocketProvider>();
context.read<ChatChannelProvider>();
context.read<NotificationProvider>();
final th = context.watch<ThemeProvider>();

View File

@ -1,12 +0,0 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:native_dio_adapter/native_dio_adapter.dart';
Dio addClientAdapter(Dio client) {
if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS) {
// Switch to native implementation if possible
client.httpClientAdapter = NativeAdapter();
}
return client;
}

View File

@ -1,2 +0,0 @@
export 'package:surface/providers/adapters/sn_network_web.dart'
if (dart.library.io) 'package:surface/providers/adapters/sn_network_native.dart';

View File

@ -1,5 +0,0 @@
import 'package:dio/dio.dart';
Dio addClientAdapter(Dio client) {
return client;
}

144
lib/providers/channel.dart Normal file
View File

@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:provider/provider.dart';
import 'package:surface/controllers/chat_message_controller.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/types/realm.dart';
class ChatChannelProvider extends ChangeNotifier {
static const kChatChannelBoxName = 'nex_chat_channels';
late final SnNetworkProvider _sn;
late final UserDirectoryProvider _ud;
Box<SnChannel>? get _channelBox => Hive.box<SnChannel>(kChatChannelBoxName);
ChatChannelProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
_ud = context.read<UserDirectoryProvider>();
_initializeLocalData();
}
Future<void> _initializeLocalData() async {
await Hive.openBox<SnChannel>(kChatChannelBoxName);
}
Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async {
if (_channelBox == null) return;
await _channelBox!.putAll({
for (final channel in channels) channel.key: channel,
});
}
Future<List<SnChannel>> _fetchChannelsFromServer({
String scope = 'global',
bool direct = false,
bool doNotSave = false,
}) async {
final resp = await _sn.client.get(
'/cgi/im/channels/$scope/me/available',
queryParameters: {
'direct': direct,
},
);
final out = List<SnChannel>.from(
resp.data?.map((e) => SnChannel.fromJson(e)) ?? [],
);
if (!doNotSave) _saveChannelToLocal(out);
return out;
}
/// The get channel method will return the channel with the given alias.
/// It will use the local storage as much as possible.
/// The alias should include the scope, formatted as `scope:alias`.
Future<SnChannel> getChannel(String key) async {
if (_channelBox != null) {
final local = _channelBox!.get(key);
if (local != null) return local;
}
var resp = await _sn.client.get('/cgi/im/channels/$key');
var out = SnChannel.fromJson(resp.data);
// Preload realm of the channel
if (out.realmId != null) {
resp = await _sn.client.get('/cgi/id/realms/${out.realmId}');
out = out.copyWith(realm: SnRealm.fromJson(resp.data));
}
_saveChannelToLocal([out]);
return out;
}
/// The fetch channel method return a stream, which will emit twice.
/// The first time is when the data was fetched from the local storage.
/// And the second time is when the data was fetched from the server.
/// But there is some exception that will only cause one of them to be emitted.
/// Like the local storage is broken or the server is down.
Stream<List<SnChannel>> fetchChannels() async* {
if (_channelBox != null) yield _channelBox!.values.toList();
var resp = await _sn.client.get('/cgi/id/realms/me/available');
final realms = List<SnRealm>.from(
resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
);
final realmMap = {
for (final realm in realms) realm.alias: realm,
};
final scopeToFetch = {'global', ...realms.map((e) => e.alias)};
final List<SnChannel> result = List.empty(growable: true);
final directMessages = await _fetchChannelsFromServer(
scope: scopeToFetch.first,
direct: true,
);
result.addAll(directMessages);
final nonBelongsChannels = await _fetchChannelsFromServer(
scope: scopeToFetch.first,
direct: false,
);
result.addAll(nonBelongsChannels);
for (final scope in scopeToFetch.skip(1)) {
final channel = await _fetchChannelsFromServer(
scope: scope,
direct: false,
doNotSave: true,
);
final out = channel.map((ele) => ele.copyWith(realm: realmMap[scope]));
_saveChannelToLocal(out);
result.addAll(out);
}
yield result;
}
Future<List<SnChatMessage>> getLastMessages(
Iterable<SnChannel> channels,
) async {
final result = List<SnChatMessage>.empty(growable: true);
for (final channel in channels) {
final channelBox = await Hive.openBox<SnChatMessage>(
'${ChatMessageController.kChatMessageBoxPrefix}${channel.id}',
);
final lastMessage = channelBox.isNotEmpty
? channelBox.values
.reduce((a, b) => a.createdAt.isAfter(b.createdAt) ? a : b)
: null;
if (lastMessage != null) result.add(lastMessage);
channelBox.close();
}
await _ud.listAccount(result.map((ele) => ele.sender.accountId).toSet());
return result;
}
@override
void dispose() {
_channelBox?.close();
super.dispose();
}
}

View File

@ -0,0 +1,459 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/chat.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
class ChatCallProvider extends ChangeNotifier {
late final SnNetworkProvider _sn;
ChatCallProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
}
SnChatCall? _current;
SnChannel? _channel;
bool _isReady = false;
bool _isMounted = false;
bool _isInitialized = false;
bool _isBusy = false;
String _lastDuration = '00:00:00';
Timer? _lastDurationUpdateTimer;
String? token;
String? endpoint;
StreamSubscription? hwSubscription;
List<MediaDevice> _audioInputs = [];
List<MediaDevice> _videoInputs = [];
bool _enableAudio = true;
bool _enableVideo = false;
LocalAudioTrack? _audioTrack;
LocalVideoTrack? _videoTrack;
MediaDevice? _videoDevice;
MediaDevice? _audioDevice;
late Room _room;
late EventsListener<RoomEvent> _listener;
List<ParticipantTrack> _participantTracks = [];
ParticipantTrack? _focusTrack;
// Getters for private fields
SnChatCall? get current => _current;
SnChannel? get channel => _channel;
bool get isReady => _isReady;
bool get isMounted => _isMounted;
bool get isInitialized => _isInitialized;
bool get isBusy => _isBusy;
String get lastDuration => _lastDuration;
List<MediaDevice> get audioInputs => _audioInputs;
List<MediaDevice> get videoInputs => _videoInputs;
bool get enableAudio => _enableAudio;
bool get enableVideo => _enableVideo;
LocalAudioTrack? get audioTrack => _audioTrack;
LocalVideoTrack? get videoTrack => _videoTrack;
MediaDevice? get videoDevice => _videoDevice;
MediaDevice? get audioDevice => _audioDevice;
List<ParticipantTrack> get participantTracks => _participantTracks;
ParticipantTrack? get focusTrack => _focusTrack;
Room get room => _room;
void _updateDuration() {
if (_current == null) {
_lastDuration = '00:00:00';
} else {
Duration duration = DateTime.now().difference(_current!.createdAt);
String twoDigits(int n) => n.toString().padLeft(2, '0');
_lastDuration = '${twoDigits(duration.inHours)}:'
'${twoDigits(duration.inMinutes.remainder(60))}:'
'${twoDigits(duration.inSeconds.remainder(60))}';
}
notifyListeners();
}
void enableDurationUpdater() {
_updateDuration();
_lastDurationUpdateTimer = Timer.periodic(
const Duration(seconds: 1),
(_) => _updateDuration(),
);
}
void disableDurationUpdater() {
_lastDurationUpdateTimer?.cancel();
_lastDurationUpdateTimer = null;
}
Future<void> checkPermissions() async {
if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) {
return;
}
await Permission.camera.request();
await Permission.microphone.request();
await Permission.bluetooth.request();
await Permission.bluetoothConnect.request();
}
void setCall(SnChatCall call, SnChannel related) {
_current = call;
_channel = related;
notifyListeners();
}
Future<(String, String)> getRoomToken() async {
final resp = await _sn.client.post(
'/cgi/im/channels/${_channel!.keyPath}/calls/ongoing/token',
);
token = resp.data['token'];
endpoint = 'wss://${resp.data['endpoint']}';
return (token!, endpoint!);
}
void initHardware() {
if (_isReady) return;
_isReady = true;
hwSubscription = Hardware.instance.onDeviceChange.stream.listen(
_revertDevices,
);
Hardware.instance.enumerateDevices().then(_revertDevices);
notifyListeners();
}
void initRoom() {
initHardware();
_room = Room(
roomOptions: const RoomOptions(
dynacast: true,
adaptiveStream: true,
defaultAudioPublishOptions: AudioPublishOptions(
name: 'call_voice',
stream: 'call_stream',
),
defaultVideoPublishOptions: VideoPublishOptions(
name: 'call_video',
stream: 'call_stream',
simulcast: true,
backupVideoCodec: BackupVideoCodec(enabled: true),
),
defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(
useiOSBroadcastExtension: true,
params: VideoParametersPresets.screenShareH1080FPS30,
),
defaultCameraCaptureOptions: CameraCaptureOptions(
maxFrameRate: 30,
params: VideoParametersPresets.h1080_169,
),
),
);
_listener = _room.createListener();
WakelockPlus.enable();
}
Future<void> joinRoom(String url, String token) async {
if (_isMounted) return;
try {
await _room.connect(
url,
token,
fastConnectOptions: FastConnectOptions(
microphone: TrackOption(track: _audioTrack),
camera: TrackOption(track: _videoTrack),
),
);
} finally {
_isMounted = true;
notifyListeners();
}
}
void setupRoom() {
if (isInitialized) return;
sortParticipants();
_room.addListener(_onRoomDidUpdate);
WidgetsBindingCompatible.instance?.addPostFrameCallback(
(_) => autoPublish(),
);
if (lkPlatformIsMobile()) {
Hardware.instance.setSpeakerphoneOn(true);
}
_isBusy = false;
_isInitialized = true;
notifyListeners();
}
void autoPublish() async {
try {
if (enableVideo) {
await _room.localParticipant?.setCameraEnabled(true);
}
if (enableAudio) {
await _room.localParticipant?.setMicrophoneEnabled(true);
}
} catch (error) {
rethrow;
}
}
Future<void> setEnableAudio(bool value) async {
_enableAudio = value;
if (!_enableAudio) {
await _audioTrack?.stop();
_audioTrack = null;
} else {
await _changeLocalAudioTrack();
}
notifyListeners();
}
Future<void> setEnableVideo(bool value) async {
_enableVideo = value;
if (!_enableVideo) {
await _videoTrack?.stop();
_videoTrack = null;
} else {
await _changeLocalVideoTrack();
}
notifyListeners();
}
void setupRoomListeners({
required Function(DisconnectReason?) onDisconnected,
}) {
_listener
..on<RoomDisconnectedEvent>((event) async {
onDisconnected(event.reason);
})
..on<ParticipantEvent>((event) => sortParticipants())
..on<LocalTrackPublishedEvent>((_) => sortParticipants())
..on<LocalTrackUnpublishedEvent>((_) => sortParticipants())
..on<TrackSubscribedEvent>((_) => sortParticipants())
..on<TrackUnsubscribedEvent>((_) => sortParticipants())
..on<ParticipantNameUpdatedEvent>((event) {
sortParticipants();
});
}
void sortParticipants() {
Map<String, ParticipantTrack> mediaTracks = {};
for (var participant in _room.remoteParticipants.values) {
mediaTracks[participant.sid] = ParticipantTrack(
participant: participant,
videoTrack: null,
isScreenShare: false,
);
for (var t in participant.videoTrackPublications) {
mediaTracks[participant.sid]?.videoTrack = t.track;
mediaTracks[participant.sid]?.isScreenShare = t.isScreenShare;
}
}
final newTracks = List<ParticipantTrack>.empty(growable: true);
final mediaTrackList = mediaTracks.values.toList();
mediaTrackList.sort((a, b) {
// Loudest people first
if (a.participant.isSpeaking && b.participant.isSpeaking) {
if (a.participant.audioLevel > b.participant.audioLevel) {
return -1;
} else {
return 1;
}
}
// Last spoke first
final aSpokeAt = a.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0;
final bSpokeAt = b.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0;
if (aSpokeAt != bSpokeAt) {
return aSpokeAt > bSpokeAt ? -1 : 1;
}
// Has video first
if (a.participant.hasVideo != b.participant.hasVideo) {
return a.participant.hasVideo ? -1 : 1;
}
// First joined people first
return a.participant.joinedAt.millisecondsSinceEpoch -
b.participant.joinedAt.millisecondsSinceEpoch;
});
newTracks.addAll(mediaTrackList);
if (_room.localParticipant != null) {
ParticipantTrack localTrack = ParticipantTrack(
participant: _room.localParticipant!,
videoTrack: null,
isScreenShare: false,
);
final localParticipantTracks =
_room.localParticipant?.videoTrackPublications;
if (localParticipantTracks != null) {
for (var t in localParticipantTracks) {
localTrack.videoTrack = t.track;
localTrack.isScreenShare = t.isScreenShare;
}
}
newTracks.add(localTrack);
}
_participantTracks = newTracks;
if (focusTrack != null) {
final idx = participantTracks
.indexWhere((x) => x.participant.sid == _focusTrack!.participant.sid);
if (idx == -1) {
_focusTrack = null;
}
}
if (focusTrack == null) {
_focusTrack = participantTracks.firstOrNull;
} else {
final idx = participantTracks.indexWhere(
(x) => _focusTrack!.participant.sid == x.participant.sid,
);
if (idx > -1) {
_focusTrack = participantTracks[idx];
}
}
notifyListeners();
}
Future<void> _changeLocalAudioTrack() async {
if (_audioTrack != null) {
await _audioTrack!.stop();
_audioTrack = null;
}
if (_audioDevice != null) {
_audioTrack = await LocalAudioTrack.create(
AudioCaptureOptions(deviceId: _audioDevice!.deviceId),
);
await _audioTrack!.start();
}
notifyListeners();
}
Future<void> _changeLocalVideoTrack() async {
if (_videoTrack != null) {
await _videoTrack!.stop();
_videoTrack = null;
}
if (_videoDevice != null) {
_videoTrack = await LocalVideoTrack.createCameraTrack(
CameraCaptureOptions(
deviceId: _videoDevice!.deviceId,
params: VideoParametersPresets.h1080_169,
),
);
await _videoTrack!.start();
}
notifyListeners();
}
void _revertDevices(List<MediaDevice> devices) {
_audioInputs = devices.where((d) => d.kind == 'audioinput').toList();
_videoInputs = devices.where((d) => d.kind == 'videoinput').toList();
notifyListeners();
}
void _onRoomDidUpdate() => sortParticipants();
Future<void> changeLocalAudioTrack() async {
if (audioTrack != null) {
await audioTrack!.stop();
_audioTrack = null;
}
if (audioDevice != null) {
_audioTrack = await LocalAudioTrack.create(
AudioCaptureOptions(
deviceId: audioDevice!.deviceId,
),
);
await audioTrack!.start();
}
}
Future<void> changeLocalVideoTrack() async {
if (videoTrack != null) {
await _videoTrack!.stop();
_videoTrack = null;
}
if (videoDevice != null) {
_videoTrack = await LocalVideoTrack.createCameraTrack(
CameraCaptureOptions(
deviceId: videoDevice!.deviceId,
params: VideoParametersPresets.h1080_169,
),
);
await videoTrack!.start();
}
}
void deactivateHardware() {
hwSubscription?.cancel();
}
void disposeRoom() {
_isBusy = false;
_isMounted = false;
_isInitialized = false;
_current = null;
_channel = null;
_room.removeListener(_onRoomDidUpdate);
_room.disconnect();
_room.dispose();
_listener.dispose();
WakelockPlus.disable();
}
void disposeHardware() {
_isReady = false;
_audioTrack?.stop();
_audioTrack = null;
_videoTrack?.stop();
_videoTrack = null;
}
void setVideoDevice(MediaDevice? value) {
_videoDevice = value;
notifyListeners();
}
void setAudioDevice(MediaDevice? value) {
_audioDevice = value;
notifyListeners();
}
void setFocusTrack(ParticipantTrack? value) {
_focusTrack = value;
notifyListeners();
}
void setIsBusy(bool value) {
_isBusy = value;
notifyListeners();
}
}

View File

@ -24,6 +24,14 @@ class NavigationProvider extends ChangeNotifier {
int? get currentIndex => _currentIndex;
static const List<String> kShowBottomNavScreen = [
'home',
'explore',
'account',
'album',
'chat',
];
static const List<AppNavDestination> kAllDestination = [
AppNavDestination(
icon: Icon(Symbols.home, weight: 400, opticalSize: 20),
@ -35,26 +43,42 @@ class NavigationProvider extends ChangeNotifier {
screen: 'explore',
label: 'screenExplore',
),
AppNavDestination(
icon: Icon(Symbols.chat, weight: 400, opticalSize: 20),
screen: 'chat',
label: 'screenChat',
),
AppNavDestination(
icon: Icon(Symbols.account_circle, weight: 400, opticalSize: 20),
screen: 'account',
label: 'screenAccount',
),
AppNavDestination(
icon: Icon(Symbols.album, weight: 400, opticalSize: 20),
icon: Icon(Symbols.group, weight: 400, opticalSize: 20),
screen: 'realm',
label: 'screenRealm',
),
AppNavDestination(
icon: Icon(Symbols.photo_library, weight: 400, opticalSize: 20),
screen: 'album',
label: 'screenAlbum',
),
AppNavDestination(
icon: Icon(Symbols.chat, weight: 400, opticalSize: 20),
screen: 'chat',
label: 'screenChat',
icon: Icon(Symbols.diversity_4, weight: 400, opticalSize: 20),
screen: 'friend',
label: 'screenFriend',
),
AppNavDestination(
icon: Icon(Symbols.notifications, weight: 400, opticalSize: 20),
screen: 'notification',
label: 'screenNotification',
),
];
static const List<String> kDefaultPinnedDestination = [
'home',
'explore',
'account'
'chat',
'account',
];
List<AppNavDestination> destinations = [];

View File

@ -0,0 +1,73 @@
import 'dart:developer';
import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_udid/flutter_udid.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
class NotificationProvider extends ChangeNotifier {
late final SnNetworkProvider _sn;
late final UserProvider _ua;
NotificationProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
_ua = context.read<UserProvider>();
// Delay to wait user provider ready to use
Future.delayed(const Duration(milliseconds: 3000), () async {
if (!_ua.isAuthorized) return;
log("Registering push notifications...");
await registerPushNotifications();
log("Registered push notification subscriber successfully!");
});
}
Future<void> registerPushNotifications() async {
if (kIsWeb || Platform.isWindows || Platform.isLinux) return;
if (!_ua.isAuthorized) return;
await FirebaseMessaging.instance.requestPermission(
alert: true,
announcement: true,
badge: true,
carPlay: false,
criticalAlert: false,
provisional: false,
sound: true,
);
late final String? token;
late final String provider;
var deviceUuid = await FlutterUdid.consistentUdid;
if (deviceUuid.isEmpty) {
log("Unable to active push notifications, couldn't get device uuid");
return;
} else {
log('Device UUID is $deviceUuid');
log('Registering device push notifications...');
}
if (Platform.isIOS || Platform.isMacOS) {
provider = 'apns';
token = await FirebaseMessaging.instance.getAPNSToken();
} else {
provider = 'fcm';
token = await FirebaseMessaging.instance.getToken();
}
log('Device Push Token is $token');
await _sn.client.post(
'/cgi/id/notifications/subscription',
data: {
'provider': provider,
'device_token': token,
'device_id': deviceUuid,
},
);
}
}

132
lib/providers/post.dart Normal file
View File

@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/types/post.dart';
class SnPostContentProvider {
late final SnNetworkProvider _sn;
late final UserDirectoryProvider _ud;
late final SnAttachmentProvider _attach;
SnPostContentProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
_ud = context.read<UserDirectoryProvider>();
_attach = context.read<SnAttachmentProvider>();
}
Future<List<SnPost>> _preloadRelatedDataInBatch(List<SnPost> out) async {
Set<String> rids = {};
for (var i = 0; i < out.length; i++) {
rids.addAll(out[i].body['attachments']?.cast<String>() ?? []);
if (out[i].body['thumbnail'] != null) {
rids.add(out[i].body['thumbnail']);
}
}
final attachments = await _attach.getMultiple(rids.toList());
for (var i = 0; i < out.length; i++) {
out[i] = out[i].copyWith(
preload: SnPostPreload(
thumbnail: attachments.where((ele) => ele?.rid == out[i].body['thumbnail']).firstOrNull,
attachments: attachments.where((ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false).toList(),
),
);
}
await _ud.listAccount(
attachments.where((ele) => ele != null).map((ele) => ele!.accountId).toSet(),
);
return out;
}
Future<SnPost> _preloadRelatedDataSingle(SnPost out) async {
Set<String> rids = {};
rids.addAll(out.body['attachments']?.cast<String>() ?? []);
if (out.body['thumbnail'] != null) {
rids.add(out.body['thumbnail']);
}
final attachments = await _attach.getMultiple(rids.toList());
out = out.copyWith(
preload: SnPostPreload(
thumbnail: attachments.where((ele) => ele?.rid == out.body['thumbnail']).firstOrNull,
attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(),
),
);
return out;
}
Future<List<SnPost>> listRecommendations() async {
final resp = await _sn.client.get('/cgi/co/recommendations');
final out = _preloadRelatedDataInBatch(
List.from(resp.data.map((ele) => SnPost.fromJson(ele))),
);
return out;
}
Future<(List<SnPost>, int)> listPosts({
int take = 10,
int offset = 0,
String? type,
String? author,
}) async {
final resp = await _sn.client.get('/cgi/co/posts', queryParameters: {
'take': take,
'offset': offset,
if (type != null) 'type': type,
if (author != null) 'author': author,
});
final List<SnPost> out = await _preloadRelatedDataInBatch(
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
);
return (out, resp.data['count'] as int);
}
Future<(List<SnPost>, int)> listPostReplies(
dynamic parentId, {
int take = 10,
int offset = 0,
}) async {
final resp = await _sn.client.get('/cgi/co/posts/$parentId/replies', queryParameters: {
'take': take,
'offset': offset,
});
final List<SnPost> out = await _preloadRelatedDataInBatch(
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
);
return (out, resp.data['count'] as int);
}
Future<(List<SnPost>, int)> searchPosts(
String searchTerm, {
int take = 10,
int offset = 0,
Iterable<String>? tags,
}) async {
final resp = await _sn.client.get('/cgi/co/posts/search', queryParameters: {
'take': take,
'offset': offset,
'probe': searchTerm,
if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
});
final List<SnPost> out = await _preloadRelatedDataInBatch(
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
);
return (out, resp.data['count'] as int);
}
Future<SnPost> getPost(dynamic id) async {
final resp = await _sn.client.get('/cgi/co/posts/$id');
final out = _preloadRelatedDataSingle(
SnPost.fromJson(resp.data),
);
return out;
}
}

View File

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
class SnRelationshipProvider {
late final SnNetworkProvider _sn;
SnRelationshipProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
}
Future<void> updateRelationship(
int relatedId,
int status,
Map<String, dynamic> permNodes,
) async {
await _sn.client.put('/cgi/id/users/me/relations/$relatedId', data: {
'status': status,
'perm_nodes': permNodes,
});
}
Future<void> deleteRelationship(int relatedId) async {
await _sn.client.delete('/cgi/id/users/me/relations/$relatedId');
}
Future<void> acceptFriendRequest(int relatedId) async {
await _sn.client.post('/cgi/id/users/me/relations/$relatedId/accept');
}
Future<void> declineFriendRequest(int relatedId) async {
await _sn.client.post('/cgi/id/users/me/relations/$relatedId/decline');
}
}

View File

@ -19,6 +19,14 @@ class SnAttachmentProvider {
_sn = context.read<SnNetworkProvider>();
}
void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) {
for (final item in items) {
if ((item.isAnalyzed && item.isUploaded) || noCheck) {
_cache[item.rid] = item;
}
}
}
Future<SnAttachment> getOne(String rid, {noCache = false}) async {
if (!noCache && _cache.containsKey(rid)) {
return _cache[rid]!;
@ -26,37 +34,49 @@ class SnAttachmentProvider {
final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta');
final out = SnAttachment.fromJson(resp.data);
_cache[rid] = out;
if (out.isAnalyzed && out.isUploaded) {
_cache[rid] = out;
}
return out;
}
Future<List<SnAttachment>> getMultiple(List<String> rids,
Future<List<SnAttachment?>> getMultiple(List<String> rids,
{noCache = false}) async {
final pendingFetch =
noCache ? rids : rids.where((rid) => !_cache.containsKey(rid)).toList();
final result = List<SnAttachment?>.filled(rids.length, null);
final Map<String, int> randomMapping = {};
for (int i = 0; i < rids.length; i++) {
final rid = rids[i];
if (noCache || !_cache.containsKey(rid)) {
randomMapping[rid] = i;
} else {
result[i] = _cache[rid]!;
}
}
final pendingFetch = randomMapping.keys;
if (pendingFetch.isEmpty) {
return rids.map((rid) => _cache[rid]!).toList();
if (pendingFetch.isNotEmpty) {
final resp = await _sn.client.get(
'/cgi/uc/attachments',
queryParameters: {
'take': pendingFetch.length,
'id': pendingFetch.join(','),
},
);
final out = resp.data['data']
.map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e))
.toList();
for (final item in out) {
if (item == null) continue;
if (item.isAnalyzed && item.isUploaded) {
_cache[item.rid] = item;
}
result[randomMapping[item.rid]!] = item;
}
}
final resp = await _sn.client.get('/cgi/uc/attachments', queryParameters: {
'take': pendingFetch.length,
'id': pendingFetch.join(','),
});
final out = resp.data['data']
.where((e) => e['id'] != 0)
.map((e) => SnAttachment.fromJson(e))
.toList();
for (final item in out) {
_cache[item.rid] = item;
}
return rids
.where((rid) => _cache.containsKey(rid))
.map((rid) => _cache[rid]!)
.toList();
return result;
}
static Map<String, String> mimetypeOverrides = {
@ -110,8 +130,9 @@ class SnAttachmentProvider {
int size,
String filename,
String pool,
Map<String, dynamic>? metadata,
) async {
Map<String, dynamic>? metadata, {
String? mimetype,
}) async {
final fileAlt = filename.contains('.')
? filename.substring(0, filename.lastIndexOf('.'))
: filename;
@ -119,8 +140,10 @@ class SnAttachmentProvider {
filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
String? mimetypeOverride;
if (mimetypeOverrides.keys.contains(fileExt)) {
if (mimetype == null && mimetypeOverrides.keys.contains(fileExt)) {
mimetypeOverride = mimetypeOverrides[fileExt];
} else {
mimetypeOverride = mimetype;
}
final resp = await _sn.client.post('/cgi/uc/attachments/multipart', data: {

View File

@ -1,29 +1,27 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:dio/dio.dart';
import 'package:dio_smart_retry/dio_smart_retry.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:surface/providers/adapters/sn_network_universal.dart';
import 'package:synchronized/synchronized.dart';
const kAtkStoreKey = 'nex_user_atk';
const kRtkStoreKey = 'nex_user_rtk';
const kNetworkServerDefault = 'https://api.sn-next.solsynth.dev';
const kNetworkServerDefault = 'https://api.sn.solsynth.dev';
const kNetworkServerStoreKey = 'app_server_url';
const kNetworkServerDirectory = [
('SN Preview', 'https://api.sn-next.solsynth.dev'),
('SN Stable', 'https://api.sn.solsynth.dev'),
('Solar Network', 'https://api.sn.solsynth.dev'),
('Local', 'http://localhost:8001'),
];
class SnNetworkProvider {
late Dio client;
late final Dio client;
late final SharedPreferences _prefs;
late final FlutterSecureStorage _storage = FlutterSecureStorage();
SnNetworkProvider() {
client = Dio();
@ -53,8 +51,6 @@ class SnNetworkProvider {
),
);
client = addClientAdapter(client);
SharedPreferences.getInstance().then((prefs) {
_prefs = prefs;
client.options.baseUrl =
@ -62,9 +58,19 @@ class SnNetworkProvider {
});
}
final tkLock = Lock();
Completer<String?>? _refreshCompleter;
Future<String?> getFreshAtk() async {
if (_refreshCompleter != null) {
return await _refreshCompleter!.future;
} else {
_refreshCompleter = Completer<String?>();
}
try {
var atk = await _storage.read(key: kAtkStoreKey);
var atk = _prefs.getString(kAtkStoreKey);
if (atk != null) {
final atkParts = atk.split('.');
if (atkParts.length != 3) {
@ -94,13 +100,18 @@ class SnNetworkProvider {
}
if (atk != null) {
_refreshCompleter!.complete(atk);
return atk;
} else {
log('Access token refresh failed...');
_refreshCompleter!.complete(null);
}
}
} catch (err) {
log('Failed to authenticate user: $err');
_refreshCompleter!.completeError(err);
} finally {
_refreshCompleter = null;
}
return null;
@ -111,22 +122,18 @@ class SnNetworkProvider {
return '${client.options.baseUrl}/cgi/uc/attachments/$ky';
}
Future<void> setTokenPair(String atk, String rtk) async {
await Future.wait([
_storage.write(key: kAtkStoreKey, value: atk),
_storage.write(key: kRtkStoreKey, value: rtk),
]);
void setTokenPair(String atk, String rtk) {
_prefs.setString(kAtkStoreKey, atk);
_prefs.setString(kRtkStoreKey, rtk);
}
Future<void> clearTokenPair() async {
await Future.wait([
_storage.delete(key: kAtkStoreKey),
_storage.delete(key: kRtkStoreKey),
]);
void clearTokenPair() {
_prefs.remove(kAtkStoreKey);
_prefs.remove(kRtkStoreKey);
}
Future<String?> refreshToken() async {
final rtk = await _storage.read(key: kRtkStoreKey);
final rtk = _prefs.getString(kRtkStoreKey);
if (rtk == null) return null;
final dio = Dio();
@ -139,7 +146,7 @@ class SnNetworkProvider {
final atk = resp.data['access_token'];
final nRtk = resp.data['refresh_token'];
await setTokenPair(atk, nRtk);
setTokenPair(atk, nRtk);
return atk;
}

View File

@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/account.dart';
class UserDirectoryProvider {
late final SnNetworkProvider _sn;
UserDirectoryProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
}
final Map<String, int> _idCache = {};
final Map<int, SnAccount> _cache = {};
Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async {
final out = await Future.wait(
id.map((e) => getAccount(e)),
);
return out;
}
Future<SnAccount?> getAccount(dynamic id) async {
if (id is String && _idCache.containsKey(id)) {
id = _idCache[id];
}
if (_cache.containsKey(id)) {
return _cache[id];
}
try {
final resp = await _sn.client.get('/cgi/id/users/$id');
final account = SnAccount.fromJson(
resp.data as Map<String, dynamic>,
);
_cache[account.id] = account;
if (id is String) _idCache[id] = account.id;
return account;
} catch (err) {
return null;
}
}
SnAccount? getAccountFromCache(dynamic id) {
if (id is String && _idCache.containsKey(id)) {
id = _idCache[id];
}
return _cache[id];
}
}

View File

@ -1,8 +1,8 @@
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/account.dart';
@ -11,14 +11,17 @@ class UserProvider extends ChangeNotifier {
SnAccount? user;
late final SnNetworkProvider _sn;
late final FlutterSecureStorage _storage = FlutterSecureStorage();
Future<String?> get atk => _storage.read(key: kAtkStoreKey);
Future<String?> get atk async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(kAtkStoreKey);
}
UserProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
_storage.read(key: kAtkStoreKey).then((value) {
SharedPreferences.getInstance().then((prefs) {
final value = prefs.getString(kAtkStoreKey);
isAuthorized = value != null;
notifyListeners();
refreshUser().then((value) {
@ -41,7 +44,7 @@ class UserProvider extends ChangeNotifier {
}
void logoutUser() async {
await _sn.clearTokenPair();
_sn.clearTokenPair();
isAuthorized = false;
user = null;
notifyListeners();

View File

@ -52,6 +52,7 @@ class WebSocketProvider extends ChangeNotifier {
try {
conn = WebSocketChannel.connect(uri);
await conn!.ready;
listen();
log('[WebSocket] Connected to server!');
isConnected = true;
} catch (err) {
@ -88,12 +89,6 @@ class WebSocketProvider extends ChangeNotifier {
final packet = WebSocketPackage.fromJson(jsonDecode(event));
log('Websocket incoming message: ${packet.method} ${packet.message}');
stream.sink.add(packet);
// TODO handle notification
// if (packet.method == 'notifications.new') {
// final NotificationProvider nty = Get.find();
// nty.notifications.add(Notification.fromJson(packet.payload!));
// nty.notificationUnread.value++;
// }
},
onDone: () {
isConnected = false;

View File

@ -1,5 +1,9 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:surface/screens/abuse_report.dart';
import 'package:surface/screens/account.dart';
import 'package:surface/screens/account/pfp.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_new.dart';
@ -8,122 +12,305 @@ import 'package:surface/screens/album.dart';
import 'package:surface/screens/auth/login.dart';
import 'package:surface/screens/auth/register.dart';
import 'package:surface/screens/chat.dart';
import 'package:surface/screens/chat/call_room.dart';
import 'package:surface/screens/chat/channel_detail.dart';
import 'package:surface/screens/chat/manage.dart';
import 'package:surface/screens/chat/room.dart';
import 'package:surface/screens/explore.dart';
import 'package:surface/screens/friend.dart';
import 'package:surface/screens/home.dart';
import 'package:surface/screens/notification.dart';
import 'package:surface/screens/post/post_detail.dart';
import 'package:surface/screens/post/post_editor.dart';
import 'package:surface/screens/post/publisher_page.dart';
import 'package:surface/screens/post/post_search.dart';
import 'package:surface/screens/realm.dart';
import 'package:surface/screens/realm/manage.dart';
import 'package:surface/screens/realm/realm_detail.dart';
import 'package:surface/screens/settings.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/about.dart';
import 'package:surface/widgets/navigation/app_background.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
final _appRoutes = [
ShellRoute(
builder: (context, state, child) => AppScaffold(
builder: (context, state, child) => AppPageScaffold(
body: child,
showBottomNavigation: true,
showAppBar: false,
),
routes: [
GoRoute(
path: '/',
name: 'home',
builder: (context, state) => const HomeScreen(),
pageBuilder: (context, state) => NoTransitionPage(
child: const HomeScreen(),
),
),
GoRoute(
path: '/posts',
name: 'explore',
builder: (context, state) => const ExploreScreen(),
pageBuilder: (context, state) => NoTransitionPage(
child: const ExploreScreen(),
),
routes: [
GoRoute(
path: '/write/:mode',
name: 'postEditor',
builder: (context, state) => AppBackground(
isLessOptimization: true,
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'] ?? '',
),
),
),
),
GoRoute(
path: '/search',
name: 'postSearch',
builder: (context, state) => const AppBackground(
isLessOptimization: true,
child: PostSearchScreen(),
),
),
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',
builder: (context, state) => const AccountScreen(),
pageBuilder: (context, state) => NoTransitionPage(
child: const AccountScreen(),
),
routes: [],
),
GoRoute(
path: '/chat',
name: 'chat',
builder: (context, state) => const ChatScreen(),
pageBuilder: (context, state) => NoTransitionPage(
child: const ChatScreen(),
),
routes: [
GoRoute(
path: '/:scope/:alias',
name: 'chatRoom',
builder: (context, state) => AppBackground(
isLessOptimization: true,
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(
isLessOptimization: true,
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(
isLessOptimization: true,
child: child,
),
);
},
),
),
],
),
GoRoute(
path: '/album',
name: 'album',
builder: (context, state) => const AlbumScreen(),
pageBuilder: (context, state) => NoTransitionPage(
child: const AlbumScreen(),
),
),
GoRoute(
path: '/friend',
name: 'friend',
pageBuilder: (context, state) => NoTransitionPage(
child: const FriendScreen(),
),
),
GoRoute(
path: '/notification',
name: 'notification',
pageBuilder: (context, state) => NoTransitionPage(
child: const NotificationScreen(),
),
),
],
),
ShellRoute(
builder: (context, state, child) => child,
routes: [
GoRoute(
path: '/post/write/:mode',
name: 'postEditor',
builder: (context, state) => 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'] ?? '',
),
),
),
GoRoute(
path: '/post/:slug',
name: 'postDetail',
builder: (context, state) => PostDetailScreen(
slug: state.pathParameters['slug']!,
preload: state.extra as SnPost?,
),
)
],
),
ShellRoute(
builder: (context, state, child) => AppScaffold(body: child),
builder: (context, state, child) => AppPageScaffold(body: child),
routes: [
GoRoute(
path: '/auth/login',
name: 'authLogin',
builder: (context, state) => const LoginScreen(),
builder: (context, state) => const AppBackground(
child: LoginScreen(),
),
),
GoRoute(
path: '/auth/register',
name: 'authRegister',
builder: (context, state) => const RegisterScreen(),
builder: (context, state) => const AppBackground(
child: RegisterScreen(),
),
),
GoRoute(
path: '/reports',
name: 'abuseReport',
builder: (context, state) => const AppBackground(
child: AbuseReportScreen(),
),
),
GoRoute(
path: '/account/profile/edit',
name: 'accountProfileEdit',
builder: (context, state) => const ProfileEditScreen(),
builder: (context, state) => const AppBackground(
child: ProfileEditScreen(),
),
),
GoRoute(
path: '/account/publishers',
name: 'accountPublishers',
builder: (context, state) => const PublisherScreen(),
builder: (context, state) => const AppBackground(
child: PublisherScreen(),
),
),
GoRoute(
path: '/account/publishers/new',
name: 'accountPublisherNew',
builder: (context, state) => const AccountPublisherNewScreen(),
builder: (context, state) => const AppBackground(
child: AccountPublisherNewScreen(),
),
),
GoRoute(
path: '/account/publishers/edit/:name',
name: 'accountPublisherEdit',
builder: (context, state) => AccountPublisherEditScreen(
name: state.pathParameters['name']!,
builder: (context, state) => AppBackground(
child: AccountPublisherEditScreen(
name: state.pathParameters['name']!,
),
),
),
],
),
GoRoute(
path: '/account/:name',
name: 'accountProfilePage',
pageBuilder: (context, state) => NoTransitionPage(
child: UserScreen(name: state.pathParameters['name']!),
),
),
ShellRoute(
builder: (context, state, child) => AppPageScaffold(body: child),
routes: [
GoRoute(
path: '/settings',
name: 'settings',
builder: (context, state) => const AppBackground(
child: SettingsScreen(),
),
),
],
),
ShellRoute(
builder: (context, state, child) => AppScaffold(body: child),
builder: (context, state, child) => AppPageScaffold(body: child),
routes: [
GoRoute(
path: '/settings',
name: 'settings',
builder: (context, state) => const SettingsScreen(),
path: '/about',
name: 'about',
builder: (context, state) => const AppBackground(
child: AboutScreen(),
),
),
],
),
@ -132,8 +319,8 @@ final _appRoutes = [
final appRouter = GoRouter(
routes: [
ShellRoute(
builder: (context, state, child) => AppRootScaffold(body: child),
routes: _appRoutes,
builder: (context, state, child) => AppRootScaffold(body: child),
),
],
);

View File

@ -0,0 +1,178 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/widgets/dialog.dart';
import '../types/account.dart';
class AbuseReportScreen extends StatefulWidget {
const AbuseReportScreen({super.key});
@override
State<AbuseReportScreen> createState() => _AbuseReportScreenState();
}
class _AbuseReportScreenState extends State<AbuseReportScreen> {
bool _isBusy = false;
List<SnAbuseReport> _reports = List.empty();
Future<void> _fetchReports() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/reports/abuse');
if (!mounted) return;
_reports = resp.data.map((e) => SnAbuseReport.fromJson(e)).cast<SnAbuseReport>().toList();
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
void _showAbuseReportDialog() {
showDialog(
context: context,
builder: (context) => _AbuseReportDialog(),
).then((value) {
if (value == true && mounted) {
_fetchReports();
context.showSnackbar('abuseReportSubmitted'.tr());
}
});
}
@override
void initState() {
super.initState();
_fetchReports();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
ListTile(
title: Text('abuseReportAction').tr(),
subtitle: Text('abuseReportActionDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.report),
trailing: const Icon(Icons.chevron_right),
onTap: _showAbuseReportDialog,
),
const Divider(height: 1),
if (_isBusy)
const CircularProgressIndicator().padding(all: 24).center()
else
Expanded(
child: ListView.builder(
itemCount: _reports.length,
itemBuilder: (context, idx) {
return ListTile(
isThreeLine: true,
title: Text(_reports[idx].resource, style: GoogleFonts.robotoMono(fontSize: 13)),
subtitle: Text(_reports[idx].reason),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.flag),
);
},
),
),
],
),
);
}
}
class _AbuseReportDialog extends StatefulWidget {
const _AbuseReportDialog({super.key});
@override
State<_AbuseReportDialog> createState() => _AbuseReportDialogState();
}
class _AbuseReportDialogState extends State<_AbuseReportDialog> {
bool _isBusy = false;
final _resourceController = TextEditingController();
final _reasonController = TextEditingController();
@override
dispose() {
_resourceController.dispose();
_reasonController.dispose();
super.dispose();
}
Future<void> _performAction() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.post(
'/cgi/id/reports/abuse',
data: {
'resource': _resourceController.text,
'reason': _reasonController.text,
},
);
if (!mounted) return;
Navigator.pop(context, true);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('abuseReport'.tr()),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('abuseReportDescription'.tr()),
const Gap(12),
TextField(
controller: _resourceController,
maxLength: null,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'abuseReportResource'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
TextField(
controller: _reasonController,
maxLength: null,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'abuseReportReason'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
],
),
actions: [
TextButton(
onPressed: _isBusy ? null : () => Navigator.pop(context),
child: Text('dialogDismiss').tr(),
),
TextButton(
onPressed: _isBusy ? null : _performAction,
child: Text('submit').tr(),
),
],
);
}
}

View File

@ -2,11 +2,15 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hive/hive.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/providers/websocket.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart';
class AccountScreen extends StatelessWidget {
@ -18,6 +22,7 @@ class AccountScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(
leading: AutoAppBarLeading(),
title: Text("screenAccount").tr(),
actions: [
IconButton(
@ -26,12 +31,11 @@ class AccountScreen extends StatelessWidget {
GoRouter.of(context).pushNamed('settings');
},
),
const Gap(8),
],
),
body: SingleChildScrollView(
child: ua.isAuthorized
? _AuthorizedAccountScreen()
: _UnauthorizedAccountScreen(),
child: ua.isAuthorized ? _AuthorizedAccountScreen() : _UnauthorizedAccountScreen(),
),
);
}
@ -67,15 +71,12 @@ class _AuthorizedAccountScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(ua.user!.nick)
.textStyle(Theme.of(context).textTheme.titleLarge!),
Text(ua.user!.nick).textStyle(Theme.of(context).textTheme.titleLarge!),
const Gap(4),
Text('@${ua.user!.name}')
.textStyle(Theme.of(context).textTheme.bodySmall!),
Text('@${ua.user!.name}').textStyle(Theme.of(context).textTheme.bodySmall!),
],
),
Text(ua.user!.description)
.textStyle(Theme.of(context).textTheme.bodyMedium!),
Text(ua.user!.description).textStyle(Theme.of(context).textTheme.bodyMedium!),
],
),
);
@ -101,6 +102,16 @@ class _AuthorizedAccountScreen extends StatelessWidget {
GoRouter.of(context).pushNamed('accountPublishers');
},
),
ListTile(
title: Text('abuseReport').tr(),
subtitle: Text('abuseReportActionDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.flag),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('abuseReport');
},
),
ListTile(
title: Text('accountLogout').tr(),
subtitle: Text('accountLogoutSubtitle').tr(),
@ -114,7 +125,38 @@ class _AuthorizedAccountScreen extends StatelessWidget {
'accountLogoutConfirm'.tr(),
)
.then((value) {
if(!context.mounted) return;
if (value) ua.logoutUser();
final ws = context.read<WebSocketProvider>();
ws.disconnect();
Hive.deleteFromDisk();
});
},
),
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);
}
});
});
},
),
@ -141,9 +183,7 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
child: Icon(Symbols.waving_hand, size: 28),
),
const Gap(8),
Text('accountIntroTitle')
.tr()
.textStyle(Theme.of(context).textTheme.titleLarge!),
Text('accountIntroTitle').tr().textStyle(Theme.of(context).textTheme.titleLarge!),
Text('accountIntroSubtitle').tr(),
],
).padding(all: 20),
@ -156,7 +196,14 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
leading: const Icon(Symbols.login),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('authLogin');
GoRouter.of(context).pushNamed('authLogin').then((value) {
if (value == true && context.mounted) {
final ua = context.read<UserProvider>();
context.showSnackbar('loginSuccess'.tr(args: [
'@${ua.user?.name} (${ua.user?.nick})',
]));
}
});
},
),
ListTile(

View File

@ -0,0 +1,354 @@
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/account.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/universal_image.dart';
const Map<String, (String, IconData, Color)> kBadgesMeta = {
'company.staff': (
'badgeCompanyStaff',
Symbols.tools_wrench,
Colors.teal,
),
'site.migration': (
'badgeSiteMigration',
Symbols.flag,
Colors.orange,
),
};
class UserScreen extends StatefulWidget {
final String name;
const UserScreen({super.key, required this.name});
@override
State<UserScreen> createState() => _UserScreenState();
}
class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateMixin {
late final ScrollController _scrollController = ScrollController();
SnAccount? _account;
Future<void> _fetchAccount() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/${widget.name}');
if (!mounted) return;
_account = SnAccount.fromJson(resp.data);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err).then((_) {
if (mounted) Navigator.pop(context);
});
} finally {
setState(() {});
}
}
SnAccountStatusInfo? _status;
Future<void> _fetchStatus() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/${widget.name}/status');
if (!mounted) return;
_status = SnAccountStatusInfo.fromJson(resp.data);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() {});
}
}
double _appBarBlur = 0.0;
late final _appBarWidth = MediaQuery.of(context).size.width;
late final _appBarHeight = (_appBarWidth * kBannerAspectRatio).roundToDouble();
void _updateAppBarBlur() {
if (_scrollController.offset > _appBarHeight) return;
setState(() {
_appBarBlur = (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
});
}
@override
void initState() {
super.initState();
_fetchAccount().then((_) {
_fetchStatus();
});
_scrollController.addListener(_updateAppBarBlur);
}
@override
void dispose() {
_scrollController.removeListener(_updateAppBarBlur);
_scrollController.dispose();
super.dispose();
}
static const kBannerAspectRatio = 7 / 16;
@override
Widget build(BuildContext context) {
final imageHeight = _appBarHeight + kToolbarHeight + 8;
const labelShadows = <Shadow>[
Shadow(
offset: Offset(1, 1),
blurRadius: 5.0,
color: Color.fromARGB(255, 0, 0, 0),
),
];
final sn = context.read<SnNetworkProvider>();
return Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
SliverAppBar(
expandedHeight: _appBarHeight,
title: _account == null
? Text('loading').tr()
: RichText(
textAlign: TextAlign.center,
text: TextSpan(children: [
TextSpan(
text: _account!.nick,
style: Theme.of(context).textTheme.titleLarge!.copyWith(
color: Colors.white,
shadows: labelShadows,
),
),
const TextSpan(text: '\n'),
TextSpan(
text: '@${_account!.name}',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Colors.white,
shadows: labelShadows,
),
),
]),
),
pinned: true,
flexibleSpace: _account != null
? Stack(
fit: StackFit.expand,
children: [
UniversalImage(
sn.getAttachmentUrl(_account!.banner),
fit: BoxFit.cover,
height: imageHeight,
width: _appBarWidth,
cacheHeight: imageHeight,
cacheWidth: _appBarWidth,
),
Positioned(
top: 0,
left: 0,
right: 0,
height: 56 + MediaQuery.of(context).padding.top,
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: _appBarBlur,
sigmaY: _appBarBlur,
),
child: Container(
color: Colors.black.withOpacity(
clampDouble(_appBarBlur * 0.1, 0, 0.5),
),
),
),
),
),
],
)
: null,
),
if (_account != null)
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
AccountImage(
content: _account!.avatar,
radius: 28,
),
const Gap(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_account!.nick,
style: Theme.of(context).textTheme.titleMedium,
).bold(),
Text('@${_account!.name}').fontSize(13),
],
),
),
],
).padding(right: 8),
const Gap(12),
Text(_account!.description).padding(horizontal: 8),
const Gap(4),
Card(
child: 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(vertical: 8, horizontal: 12),
),
const Gap(8),
Wrap(
children: _account!.badges
.map(
(ele) => Tooltip(
richMessage: TextSpan(
children: [
TextSpan(text: kBadgesMeta[ele.type]?.$1.tr() ?? 'unknown'.tr()),
if (ele.metadata['title'] != null)
TextSpan(
text: '\n${ele.metadata['title']}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: '\n'),
TextSpan(
text: DateFormat.yMEd().format(ele.createdAt),
),
],
),
child: Icon(
kBadgesMeta[ele.type]?.$2 ?? Symbols.question_mark,
color: kBadgesMeta[ele.type]?.$3,
fill: 1,
),
),
)
.toList(),
).padding(horizontal: 8),
const Gap(8),
Column(
children: [
Row(
children: [
const Icon(Symbols.calendar_add_on),
const Gap(8),
Text('publisherJoinedAt').tr(args: [DateFormat('y/M/d').format(_account!.createdAt)]),
],
),
Row(
children: [
const Icon(Symbols.cake),
const Gap(8),
Text('accountBirthday').tr(args: [
_account!.profile?.birthday == null
? 'unknown'.tr()
: DateFormat('M/d').format(
_account!.profile!.birthday!.toLocal(),
)
]),
],
),
Row(
children: [
const Icon(Symbols.identity_platform),
const Gap(8),
Text(
'#${_account!.id.toString().padLeft(8, '0')}',
style: GoogleFonts.robotoMono(),
).opacity(0.8),
],
),
],
).padding(horizontal: 8),
],
).padding(all: 16),
),
SliverToBoxAdapter(child: const Divider()),
const SliverGap(12),
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('accountBadge').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
SizedBox(
height: 80,
width: double.infinity,
child: ListView(
padding: EdgeInsets.symmetric(horizontal: 8),
scrollDirection: Axis.horizontal,
children: [
for (final badge in _account?.badges ?? [])
SizedBox(
width: 280,
child: Card(
child: ListTile(
leading: Icon(
kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark,
color: kBadgesMeta[badge.type]?.$3,
fill: 1,
),
title: Text(
kBadgesMeta[badge.type]?.$1 ?? 'unknown',
).tr(),
subtitle: badge.metadata['title'] != null
? Text(badge.metadata['title'])
: Text(
DateFormat('y/M/d').format(badge.createdAt),
),
),
),
),
],
),
),
],
),
)
],
),
);
}
}

View File

@ -148,20 +148,14 @@ class _AccountPublisherEditScreenState
mimetype: 'image/png',
);
if (!mounted) return;
final sn = context.read<SnNetworkProvider>();
await sn.client.put(
'/cgi/id/users/me/$place',
data: {'attachment': attachment.rid},
);
if (!mounted) return;
final ua = context.read<UserProvider>();
await ua.refreshUser();
if (!mounted) return;
context.showSnackbar('accountProfileEditApplied'.tr());
_syncWidget();
switch (place) {
case 'avatar':
_avatar = attachment.rid;
break;
case 'banner':
_banner = attachment.rid;
break;
}
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
@ -273,11 +267,14 @@ class _AccountPublisherEditScreenState
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton.icon(
onPressed: _syncWithAccount,
label: Text('publisherSyncWithAccount').tr(),
icon: const Icon(Symbols.sync),
),
if (_publisher?.type == 0)
TextButton.icon(
onPressed: _syncWithAccount,
label: Text('publisherSyncWithAccount').tr(),
icon: const Icon(Symbols.sync),
)
else
const SizedBox(),
ElevatedButton.icon(
onPressed: _isBusy ? null : _performAction,
label: Text('apply').tr(),
@ -286,7 +283,7 @@ class _AccountPublisherEditScreenState
],
)
],
).padding(horizontal: 16, vertical: 12),
).padding(horizontal: 24, vertical: 12),
),
);
}

View File

@ -1,3 +1,4 @@
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
@ -6,6 +7,7 @@ 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/realm.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
@ -47,6 +49,7 @@ class _AccountPublisherNewScreenState extends State<AccountPublisherNewScreen> {
),
switch (mode) {
'personal' => const _PublisherNewPersonal(),
'organization' => const _PublisherNewOrganization(),
_ => const Placeholder(),
},
],
@ -66,6 +69,10 @@ class _PublisherNewPersonal extends StatefulWidget {
class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
bool _isBusy = false;
final TextEditingController _nameController = TextEditingController();
final TextEditingController _nickController = TextEditingController();
final TextEditingController _descriptionController = TextEditingController();
void _performAction() async {
final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>();
@ -74,15 +81,48 @@ class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
setState(() => _isBusy = true);
try {
await sn.client.post('/cgi/co/publishers/personal');
await sn.client.post('/cgi/co/publishers/personal', data: {
'name': _nameController.text,
'nick': _nickController.text,
'description': _descriptionController.text,
'avatar': ua.user!.avatar,
'banner': ua.user!.banner,
});
if (!mounted) return;
Navigator.pop(context, true);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
void _syncState() {
final ua = context.read<UserProvider>();
if (ua.user == null) return;
_nameController.text = ua.user!.name;
_nickController.text = ua.user!.nick;
_descriptionController.text = ua.user!.description;
}
@override
void initState() {
super.initState();
_syncState();
_nameController.addListener(() => setState(() => {}));
_nickController.addListener(() => setState(() => {}));
}
@override
void dispose() {
super.dispose();
_nameController.dispose();
_nickController.dispose();
_descriptionController.dispose();
}
@override
Widget build(BuildContext context) {
final ua = context.watch<UserProvider>();
@ -90,10 +130,41 @@ class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('preview')
.tr()
.textStyle(Theme.of(context).textTheme.titleMedium!)
.padding(horizontal: 16, vertical: 4),
Column(
children: [
TextField(
controller: _nameController,
decoration: InputDecoration(
labelText: 'fieldUsername'.tr(),
helperText: 'fieldUsernameCannotEditHint'.tr(),
helperMaxLines: 2,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
TextField(
controller: _nickController,
decoration: InputDecoration(
labelText: 'fieldNickname'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
TextField(
controller: _descriptionController,
minLines: 3,
maxLines: null,
decoration: InputDecoration(
labelText: 'fieldDescription'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
],
).padding(horizontal: 8),
const Gap(16),
Card(
child: SizedBox(
width: double.infinity,
@ -105,10 +176,254 @@ class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(ua.user!.nick)
Text(_nickController.text)
.textStyle(Theme.of(context).textTheme.titleLarge!),
const Gap(4),
Text('@${ua.user!.name}')
Text('@${_nameController.text}')
.textStyle(Theme.of(context).textTheme.bodySmall!),
],
),
],
),
).padding(all: 16),
),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isBusy ? null : _performAction,
icon: const Icon(Symbols.add),
label: Text('create').tr(),
),
).padding(horizontal: 2),
],
);
}
}
class _PublisherNewOrganization extends StatefulWidget {
const _PublisherNewOrganization({super.key});
@override
State<_PublisherNewOrganization> createState() =>
_PublisherNewOrganizationState();
}
class _PublisherNewOrganizationState extends State<_PublisherNewOrganization> {
bool _isBusy = false;
final TextEditingController _nameController = TextEditingController();
final TextEditingController _nickController = TextEditingController();
final TextEditingController _descriptionController = TextEditingController();
void _performAction() async {
final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) return;
if (_belongToRealm == null) return;
setState(() => _isBusy = true);
try {
await sn.client.post('/cgi/co/publishers/organization', data: {
'realm': _belongToRealm!.alias,
'name': _nameController.text,
'nick': _nickController.text,
'description': _descriptionController.text,
'avatar': _belongToRealm!.avatar,
'banner': _belongToRealm!.banner,
});
if (!mounted) return;
Navigator.pop(context, true);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
List<SnRealm>? _realms;
SnRealm? _belongToRealm;
Future<void> _fetchRealms() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/realms/me/available');
_realms = List<SnRealm>.from(
resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
);
} catch (err) {
if (mounted) context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
void _syncState() {
if (_belongToRealm == null) return;
_nameController.text = _belongToRealm!.alias;
_nickController.text = _belongToRealm!.name;
_descriptionController.text = _belongToRealm!.description;
}
@override
void initState() {
super.initState();
_fetchRealms();
}
@override
void dispose() {
super.dispose();
_nameController.dispose();
_nickController.dispose();
_descriptionController.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
DropdownButtonHideUnderline(
child: DropdownButton2<SnRealm>(
isExpanded: true,
hint: Text(
'fieldPublisherBelongToRealm'.tr(),
style: TextStyle(
color: Theme.of(context).hintColor,
),
),
items: [
...(_realms?.map(
(SnRealm item) => DropdownMenuItem<SnRealm>(
value: item,
child: Row(
children: [
AccountImage(
content: item.avatar,
radius: 16,
fallbackWidget: const Icon(
Symbols.group,
size: 16,
),
),
const Gap(12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.name).textStyle(
Theme.of(context).textTheme.bodyMedium!),
Text(
item.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
).textStyle(
Theme.of(context).textTheme.bodySmall!),
],
),
),
],
),
),
) ??
[]),
DropdownMenuItem<SnRealm>(
value: null,
child: Row(
children: [
CircleAvatar(
radius: 16,
backgroundColor: Colors.transparent,
foregroundColor: Theme.of(context).colorScheme.onSurface,
child: const Icon(Symbols.clear),
),
const Gap(12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('fieldPublisherBelongToRealmUnset')
.tr()
.textStyle(
Theme.of(context).textTheme.bodyMedium!,
),
],
),
),
],
),
),
],
value: _belongToRealm,
onChanged: (SnRealm? value) {
_belongToRealm = value;
_syncState();
setState(() {});
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.only(right: 16),
height: 60,
),
menuItemStyleData: const MenuItemStyleData(
height: 60,
),
),
),
Column(
children: [
TextField(
controller: _nameController,
decoration: InputDecoration(
labelText: 'fieldUsername'.tr(),
helperText: 'fieldUsernameCannotEditHint'.tr(),
helperMaxLines: 2,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
TextField(
controller: _nickController,
decoration: InputDecoration(
labelText: 'fieldNickname'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
TextField(
controller: _descriptionController,
minLines: 3,
maxLines: null,
decoration: InputDecoration(
labelText: 'fieldDescription'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
],
).padding(horizontal: 8),
const Gap(16),
Card(
child: SizedBox(
width: double.infinity,
child: Row(
children: [
AccountImage(content: _belongToRealm?.avatar, radius: 24),
const Gap(16),
Column(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(_nickController.text)
.textStyle(Theme.of(context).textTheme.titleLarge!),
const Gap(4),
Text('@${_nameController.text}')
.textStyle(Theme.of(context).textTheme.bodySmall!),
],
),

View File

@ -1,10 +1,132 @@
import 'package:dismissible_page/dismissible_page.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/types/attachment.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/attachment/attachment_zoom.dart';
import 'package:surface/widgets/attachment/attachment_item.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:uuid/uuid.dart';
class AlbumScreen extends StatelessWidget {
class AlbumScreen extends StatefulWidget {
const AlbumScreen({super.key});
@override
State<AlbumScreen> createState() => _AlbumScreenState();
}
class _AlbumScreenState extends State<AlbumScreen> {
final ScrollController _scrollController = ScrollController();
bool _isBusy = false;
int? _totalCount;
final List<SnAttachment> _attachments = List.empty(growable: true);
final List<String> _heroTags = List.empty(growable: true);
Future<void> _fetchAttachments() async {
setState(() => _isBusy = true);
const uuid = Uuid();
try {
final sn = context.read<SnNetworkProvider>();
final ud = context.read<UserDirectoryProvider>();
final resp = await sn.client.get('/cgi/uc/attachments', queryParameters: {
'take': 10,
'offset': _attachments.length,
});
final attachments = List<SnAttachment>.from(
resp.data['data']?.map((e) => SnAttachment.fromJson(e)) ?? [],
).where((e) => e.mimetype.startsWith('image')).toList();
_attachments.addAll(attachments);
_heroTags.addAll(_attachments.map((_) => uuid.v4()));
await ud.listAccount(attachments.map((e) => e.accountId).toSet());
_totalCount = resp.data['count'] as int?;
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchAttachments();
_scrollController.addListener(() {
if (_scrollController.position.atEdge) {
bool isTop = _scrollController.position.pixels == 0;
if (!isTop && !_isBusy) {
if (_totalCount == null || _attachments.length < _totalCount!) {
_fetchAttachments();
}
}
}
});
}
@override
void dispose() {
super.dispose();
_scrollController.dispose();
}
@override
Widget build(BuildContext context) {
return const Placeholder();
return Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
SliverAppBar(
leading: AutoAppBarLeading(),
title: Text('screenAlbum').tr(),
),
SliverMasonryGrid.extent(
childCount: _attachments.length,
maxCrossAxisExtent: 320,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
itemBuilder: (context, idx) {
final attachment = _attachments[idx];
return GestureDetector(
child: ClipRRect(
child: AspectRatio(
aspectRatio: attachment.metadata['ratio']?.toDouble() ?? 1,
child: AttachmentItem(
data: attachment,
heroTag: _heroTags[idx],
),
),
),
onTap: () {
context.pushTransparentRoute(
AttachmentZoomView(
data: [attachment],
heroTags: [_heroTags[idx]],
),
backgroundColor: Colors.black.withOpacity(0.7),
rootNavigator: true,
);
},
);
},
),
if (_isBusy)
SliverToBoxAdapter(
child:
const CircularProgressIndicator().padding(all: 24).center(),
),
],
),
);
}
}

View File

@ -11,6 +11,8 @@ import 'package:surface/types/auth.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:url_launcher/url_launcher_string.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),
@ -33,67 +35,67 @@ class _LoginScreenState extends State<LoginScreen> {
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxWidth: 280),
child: Theme(
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
child: SingleChildScrollView(
child: PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
return Theme(
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
child: SingleChildScrollView(
child: PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: Container(
constraints: BoxConstraints(maxWidth: 380),
child: child,
);
},
child: switch (_period % 3) {
1 => _LoginPickerScreen(
key: const ValueKey(1),
ticket: _currentTicket,
factors: _factors,
onTicket: (p0) => setState(() {
_currentTicket = p0;
}),
onPickFactor: (p0) => setState(() {
_factorPicked = p0;
}),
onNext: () => setState(() {
_period++;
}),
),
2 => _LoginCheckScreen(
key: const ValueKey(2),
ticket: _currentTicket,
factor: _factorPicked,
onTicket: (p0) => setState(() {
_currentTicket = p0;
}),
onNext: () => setState(() {
_period = 1;
}),
),
_ => _LoginLookupScreen(
key: const ValueKey(0),
ticket: _currentTicket,
onTicket: (p0) => setState(() {
_currentTicket = p0;
}),
onFactor: (p0) => setState(() {
_factors = p0;
}),
onNext: () => setState(() {
_period++;
}),
),
},
).padding(all: 24),
).center(),
),
),
);
},
child: switch (_period % 3) {
1 => _LoginPickerScreen(
key: const ValueKey(1),
ticket: _currentTicket,
factors: _factors,
onTicket: (p0) => setState(() {
_currentTicket = p0;
}),
onPickFactor: (p0) => setState(() {
_factorPicked = p0;
}),
onNext: () => setState(() {
_period++;
}),
),
2 => _LoginCheckScreen(
key: const ValueKey(2),
ticket: _currentTicket,
factor: _factorPicked,
onTicket: (p0) => setState(() {
_currentTicket = p0;
}),
onNext: () => setState(() {
_period = 1;
}),
),
_ => _LoginLookupScreen(
key: const ValueKey(0),
ticket: _currentTicket,
onTicket: (p0) => setState(() {
_currentTicket = p0;
}),
onFactor: (p0) => setState(() {
_factors = p0;
}),
onNext: () => setState(() {
_period++;
}),
),
},
).padding(all: 24),
).center(),
);
}
}
@ -151,16 +153,15 @@ class _LoginCheckScreenState extends State<_LoginCheckScreen> {
});
final atk = tokenResp.data['access_token'];
final rtk = tokenResp.data['refresh_token'];
await sn.setTokenPair(atk, rtk);
sn.setTokenPair(atk, rtk);
if (!mounted) return;
final user = context.read<UserProvider>();
final userinfo = await user.refreshUser();
context.showSnackbar('loginSuccess'.tr(args: [
'@${userinfo!.name} (${userinfo.nick})',
]));
await Future.delayed(const Duration(milliseconds: 1850), () {
Navigator.pop(context);
});
await user.refreshUser();
if (!mounted) return;
final ws = context.read<WebSocketProvider>();
await ws.connect();
if (!mounted) return;
Navigator.pop(context, true);
} catch (err) {
context.showErrorDialog(err);
return;

View File

@ -1,4 +1,5 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:email_validator/email_validator.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
@ -7,6 +8,7 @@ import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:url_launcher/url_launcher_string.dart';
class RegisterScreen extends StatefulWidget {
const RegisterScreen({super.key});
@ -16,20 +18,21 @@ class RegisterScreen extends StatefulWidget {
}
class _RegisterScreenState extends State<RegisterScreen> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _usernameController = TextEditingController();
final _nicknameController = TextEditingController();
final _passwordController = TextEditingController();
void _performAction(BuildContext context) async {
if (!_formKey.currentState!.validate()) return;
final email = _emailController.value.text;
final username = _usernameController.value.text;
final nickname = _nicknameController.value.text;
final password = _passwordController.value.text;
if (email.isEmpty ||
username.isEmpty ||
nickname.isEmpty ||
password.isEmpty) {
if (email.isEmpty || username.isEmpty || nickname.isEmpty || password.isEmpty) {
return;
}
@ -42,8 +45,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
'password': password,
});
if (!mounted) return;
if (!context.mounted) return;
GoRouter.of(context).replaceNamed("authLogin");
} catch (err) {
context.showErrorDialog(err);
@ -52,33 +54,44 @@ class _RegisterScreenState extends State<RegisterScreen> {
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxWidth: 280),
child: StyledWidget(
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(
Symbols.person_add,
size: 28,
),
).padding(bottom: 8),
),
Text(
'screenAuthRegister',
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
return StyledWidget(Container(
constraints: const BoxConstraints(maxWidth: 380),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(
Symbols.person_add,
size: 28,
),
).tr().padding(left: 4, bottom: 16),
Column(
).padding(bottom: 8),
),
Text(
'screenAuthRegister',
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
),
).tr().padding(left: 4, bottom: 16),
Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
children: [
TextField(
TextFormField(
validator: (value) {
if (value == null || value.length < 4 || value.length > 32) {
return 'fieldUsernameLengthLimit'.tr(args: [4.toString(), 32.toString()]);
}
if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value)) {
return 'fieldUsernameAlphanumOnly'.tr();
}
return null;
},
autocorrect: false,
enableSuggestions: false,
controller: _usernameController,
@ -88,11 +101,16 @@ class _RegisterScreenState extends State<RegisterScreen> {
border: const UnderlineInputBorder(),
labelText: 'fieldUsername'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextField(
TextFormField(
validator: (value) {
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,
@ -102,11 +120,19 @@ class _RegisterScreenState extends State<RegisterScreen> {
border: const UnderlineInputBorder(),
labelText: 'fieldNickname'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextField(
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,
@ -116,11 +142,16 @@ class _RegisterScreenState extends State<RegisterScreen> {
border: const UnderlineInputBorder(),
labelText: 'fieldEmail'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextField(
TextFormField(
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
return null;
},
obscureText: true,
autocorrect: false,
enableSuggestions: false,
@ -131,30 +162,67 @@ class _RegisterScreenState extends State<RegisterScreen> {
border: const UnderlineInputBorder(),
labelText: 'fieldPassword'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (_) => _performAction(context),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
],
).padding(horizontal: 7),
const Gap(16),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => _performAction(context),
child: Row(
mainAxisSize: MainAxisSize.min,
),
const Gap(16),
Align(
alignment: Alignment.centerRight,
child: StyledWidget(
Container(
constraints: const BoxConstraints(maxWidth: 290),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('next').tr(),
const Icon(Symbols.chevron_right),
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');
},
),
),
],
),
),
)
],
),
).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

@ -1,10 +1,283 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/account/account_select.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/unauthorized_hint.dart';
import 'package:uuid/uuid.dart';
class ChatScreen extends StatelessWidget {
import '../providers/sn_network.dart';
import '../providers/userinfo.dart';
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final _fabKey = GlobalKey<ExpandableFabState>();
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);
});
}
void _newDirectMessage() async {
final user = await showModalBottomSheet(
context: context,
builder: (context) => AccountSelect(title: 'channelNewDirectMessage'.tr()),
);
if (user == null) return;
if (!mounted) return;
try {
const uuid = Uuid();
final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>();
await sn.client.post('/cgi/im/channels/global/dm', data: {
'alias': uuid.v4().replaceAll('-', '').substring(0, 12),
'name': 'DM',
'description': 'A direct message channel between @${ua.user?.name} and @${user.name}',
'related_user': user.id,
});
_fabKey.currentState!.toggle();
_refreshChannels();
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
}
}
@override
void initState() {
super.initState();
_refreshChannels();
}
@override
Widget build(BuildContext context) {
return const Placeholder();
final ud = context.read<UserDirectoryProvider>();
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) {
return Scaffold(
appBar: AppBar(
leading: AutoAppBarLeading(),
title: Text('screenChat').tr(),
),
body: Center(
child: UnauthorizedHint(),
),
);
}
return Scaffold(
appBar: AppBar(
leading: AutoAppBarLeading(),
title: Text('screenChat').tr(),
),
floatingActionButtonLocation: ExpandableFab.location,
floatingActionButton: ExpandableFab(
key: _fabKey,
distance: 75,
type: ExpandableFabType.up,
childrenAnimation: ExpandableFabAnimation.none,
overlayStyle: ExpandableFabOverlayStyle(
color: Theme.of(context).colorScheme.surface.withAlpha((255 * 0.5).round()),
),
openButtonBuilder: RotateFloatingActionButtonBuilder(
child: const Icon(Symbols.add, size: 28),
fabSize: ExpandableFabSize.regular,
foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
),
closeButtonBuilder: DefaultFloatingActionButtonBuilder(
child: const Icon(Symbols.close, size: 28),
fabSize: ExpandableFabSize.regular,
foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
),
children: [
Row(
children: [
Text('channelNewChannel').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'channelNewChannel'.tr(),
onPressed: () {
_fabKey.currentState!.toggle();
GoRouter.of(context).pushNamed('chatManage').then((value) {
if (value != null && context.mounted) _refreshChannels();
});
},
child: const Icon(Symbols.chat_add_on),
),
],
),
Row(
children: [
Text('channelNewDirectMessage').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'channelNewDirectMessage'.tr(),
onPressed: _newDirectMessage,
child: const Icon(Symbols.communication),
),
],
),
],
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
Expanded(
child: RefreshIndicator(
onRefresh: () => Future.sync(() => _refreshChannels()),
child: ListView.builder(
itemCount: _channels?.length ?? 0,
itemBuilder: (context, idx) {
final channel = _channels![idx];
final lastMessage = _lastMessages?[channel.id];
if (channel.type == 1) {
final otherMember = channel.members?.cast<SnChannelMember?>().firstWhere(
(ele) => ele?.accountId != ua.user?.id,
orElse: () => null,
);
return ListTile(
title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name),
subtitle: lastMessage != null
? Text(
'${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: Text(
'channelDirectMessageDescription'.tr(args: [
'@${ud.getAccountFromCache(otherMember?.accountId)?.name}',
]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
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 (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

@ -0,0 +1,324 @@
import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:livekit_client/livekit_client.dart' as livekit;
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/chat_call.dart';
import 'package:surface/widgets/chat/call/call_controls.dart';
import 'package:surface/widgets/chat/call/call_participant.dart';
class CallRoomScreen extends StatefulWidget {
final String scope;
final String alias;
const CallRoomScreen({super.key, required this.scope, required this.alias});
@override
State<CallRoomScreen> createState() => _CallRoomScreenState();
}
class _CallRoomScreenState extends State<CallRoomScreen> {
int _layoutMode = 0;
void _switchLayout() {
if (_layoutMode < 1) {
setState(() => _layoutMode++);
} else {
setState(() => _layoutMode = 0);
}
}
Widget _buildListLayout() {
final call = context.read<ChatCallProvider>();
return Stack(
children: [
Container(
color:
Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.75),
child: call.focusTrack != null
? InteractiveParticipantWidget(
isFixedAvatar: false,
participant: call.focusTrack!,
onTap: () {},
)
: const SizedBox.shrink(),
),
Positioned(
left: 0,
right: 0,
top: 0,
child: SizedBox(
height: 128,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: math.max(0, call.participantTracks.length),
itemBuilder: (BuildContext context, int index) {
final track = call.participantTracks[index];
if (track.participant.sid == call.focusTrack?.participant.sid) {
return Container();
}
return Padding(
padding: const EdgeInsets.only(top: 8, left: 8),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: InteractiveParticipantWidget(
isFixedAvatar: true,
width: 120,
height: 120,
color: Theme.of(context).cardColor,
participant: track,
onTap: () {
if (track.participant.sid !=
call.focusTrack?.participant.sid) {
call.setFocusTrack(track);
}
},
),
),
);
},
),
),
),
],
);
}
Widget _buildGridLayout() {
final call = context.read<ChatCallProvider>();
return LayoutBuilder(builder: (context, constraints) {
double screenWidth = constraints.maxWidth;
double screenHeight = constraints.maxHeight;
int columns = (math.sqrt(call.participantTracks.length)).ceil();
int rows = (call.participantTracks.length / columns).ceil();
double tileWidth = screenWidth / columns;
double tileHeight = screenHeight / rows;
return StyledWidget(GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
childAspectRatio: tileWidth / tileHeight,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: math.max(0, call.participantTracks.length),
itemBuilder: (BuildContext context, int index) {
final track = call.participantTracks[index];
return Card(
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: InteractiveParticipantWidget(
color: Theme.of(context)
.colorScheme
.surfaceContainerHigh
.withOpacity(0.75),
participant: track,
onTap: () {
if (track.participant.sid !=
call.focusTrack?.participant.sid) {
call.setFocusTrack(track);
}
},
),
),
);
},
)).padding(all: 8);
});
}
@override
void initState() {
super.initState();
final call = context.read<ChatCallProvider>();
Future.delayed(Duration.zero, () {
call
..setupRoom()
..enableDurationUpdater();
});
}
@override
Widget build(BuildContext context) {
final call = context.read<ChatCallProvider>();
return ListenableBuilder(
listenable: call,
builder: (context, _) {
return Scaffold(
appBar: AppBar(
title: RichText(
textAlign: TextAlign.center,
text: TextSpan(children: [
TextSpan(
text: 'call'.tr(),
style: Theme.of(context)
.textTheme
.titleLarge!
.copyWith(color: Colors.white),
),
const TextSpan(text: '\n'),
TextSpan(
text: call.lastDuration.toString(),
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(color: Colors.white),
),
]),
),
),
body: SafeArea(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
child: Column(
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(
width: MediaQuery.of(context).size.width,
child: ControlsWidget(
call.room,
call.room.localParticipant!,
),
),
],
),
onTap: () {},
),
),
);
});
}
@override
void deactivate() {
final call = context.read<ChatCallProvider>();
call.disableDurationUpdater();
super.deactivate();
}
@override
void activate() {
final call = context.read<ChatCallProvider>();
call.enableDurationUpdater();
super.activate();
}
}

View File

@ -0,0 +1,659 @@
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: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/channel.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.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';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class ChannelDetailScreen extends StatefulWidget {
final String scope;
final String alias;
const ChannelDetailScreen({
super.key,
required this.scope,
required this.alias,
});
@override
State<ChannelDetailScreen> createState() => _ChannelDetailScreenState();
}
class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
bool _isBusy = false;
SnChannel? _channel;
SnChannelMember? _profile;
Future<void> _fetchChannel() async {
setState(() => _isBusy = true);
try {
final chan = context.read<ChatChannelProvider>();
_channel = await chan.getChannel('${widget.scope}:${widget.alias}');
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _fetchChannelProfile() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client
.get('/cgi/im/channels/${_channel!.keyPath}/members/me');
_profile = SnChannelMember.fromJson(resp.data);
_notifyLevel = _profile!.notify;
if (!mounted) return;
final ud = context.read<UserDirectoryProvider>();
await ud.getAccount(_profile!.accountId);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _deleteChannel() async {
final confirm = await context.showConfirmDialog(
'channelDelete'.tr(args: [_channel!.name]),
'channelDeleteDescription'.tr(),
);
if (!confirm) return;
if (!mounted) return;
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.delete(
'/cgi/im/channels/${_channel!.realm?.alias ?? 'global'}/${_channel!.id}',
);
if (!mounted) return;
Navigator.pop(context, false);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
}
}
Future<void> _leaveChannel() async {
final confirm = await context.showConfirmDialog(
'channelLeave'.tr(args: [_channel!.name]),
'channelLeaveDescription'.tr(),
);
if (!confirm) return;
if (!mounted) return;
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.delete(
'/cgi/im/channels/${_channel!.realm?.alias ?? 'global'}/${_channel!.id}/members/me',
);
if (!mounted) return;
Navigator.pop(context, false);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
}
}
int _notifyLevel = 0;
bool _isUpdatingNotifyLevel = false;
final kNotifyLevels = {
0: 'channelNotifyLevelAll'.tr(),
1: 'channelNotifyLevelMentioned'.tr(),
2: 'channelNotifyLevelNone'.tr(),
};
Future<void> _updateNotifyLevel(int value) async {
if (_isUpdatingNotifyLevel) return;
setState(() => _isUpdatingNotifyLevel = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.put(
'/cgi/im/channels/${_channel!.keyPath}/members/me/notify',
data: {'notify_level': value},
);
_notifyLevel = value;
if (!mounted) return;
context.showSnackbar('channelNotifyLevelApplied'.tr());
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isUpdatingNotifyLevel = false);
}
}
void _showChannelProfileDetail() {
showDialog(
context: context,
builder: (context) => _ChannelProfileDetailDialog(
channel: _channel!,
current: _profile!,
),
).then((value) {
if (value != null && mounted) {
Navigator.pop(context, true);
}
});
}
void _showMemberList() {
showModalBottomSheet(
context: context,
builder: (context) => _ChannelMemberListWidget(
channel: _channel!,
),
);
}
void _showMemberAdd() {
showModalBottomSheet(
context: context,
builder: (context) => _NewChannelMemberWidget(
channel: _channel!,
),
);
}
@override
void initState() {
super.initState();
_fetchChannel().then((_) {
_fetchChannelProfile();
});
}
@override
Widget build(BuildContext context) {
final ud = context.read<UserDirectoryProvider>();
final ua = context.read<UserProvider>();
final isOwned = ua.isAuthorized && _channel?.accountId == ua.user?.id;
return Scaffold(
appBar: AppBar(
title: _channel != null ? Text(_channel!.name) : Text('loading').tr(),
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LoadingIndicator(isActive: _isBusy),
const Gap(24),
if (_channel != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_channel!.name,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
_channel!.description,
style: Theme.of(context).textTheme.bodyMedium,
),
],
).padding(horizontal: 24),
const Gap(16),
const Divider(),
const Gap(12),
if (_profile != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('channelDetailPersonalRegion')
.bold()
.fontSize(17)
.tr()
.padding(horizontal: 20, bottom: 4),
ListTile(
leading: const Icon(Symbols.notifications),
trailing: DropdownButtonHideUnderline(
child: DropdownButton2<int>(
isExpanded: true,
items: kNotifyLevels.entries
.map((item) => DropdownMenuItem<int>(
enabled: !_isUpdatingNotifyLevel,
value: item.key,
child: Text(
item.value,
style: const TextStyle(
fontSize: 14,
),
),
))
.toList(),
value: _notifyLevel,
onChanged: (int? value) {
if (value == null) return;
_updateNotifyLevel(value);
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.only(left: 16, right: 1),
height: 40,
width: 140,
),
menuItemStyleData: const MenuItemStyleData(
height: 40,
),
),
),
title: Text('channelNotifyLevel').tr(),
subtitle: Text('channelNotifyLevelDescription').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 20),
),
ListTile(
leading: AccountImage(
content:
ud.getAccountFromCache(_profile!.accountId)?.avatar,
radius: 18,
),
trailing: const Icon(Symbols.chevron_right),
title: Text('channelEditProfile').tr(),
subtitle: Text(
(_profile?.nick?.isEmpty ?? true)
? ud.getAccountFromCache(_profile!.accountId)!.nick
: _profile!.nick!,
),
contentPadding: const EdgeInsets.only(left: 20, right: 20),
onTap: _showChannelProfileDetail,
),
if (!isOwned)
ListTile(
leading: const Icon(Symbols.exit_to_app),
trailing: const Icon(Symbols.chevron_right),
title: Text('channelActionLeave').tr(),
subtitle: Text('channelActionLeaveDescription').tr(),
contentPadding:
const EdgeInsets.symmetric(horizontal: 24),
onTap: _leaveChannel,
),
],
).padding(bottom: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('channelDetailMemberRegion')
.bold()
.fontSize(17)
.tr()
.padding(horizontal: 20, bottom: 4),
ListTile(
leading: const Icon(Symbols.group),
trailing: const Icon(Symbols.chevron_right),
title: Text('channelMemberManage').tr(),
subtitle: Text('channelMemberManageDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: _showMemberList,
),
ListTile(
leading: const Icon(Symbols.group_add),
trailing: const Icon(Symbols.chevron_right),
title: Text('channelMemberAdd').tr(),
subtitle: Text('channelMemberAddDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: _showMemberAdd,
),
],
).padding(bottom: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('channelDetailAdminRegion')
.bold()
.fontSize(17)
.tr()
.padding(horizontal: 20, bottom: 4),
ListTile(
leading: const Icon(Symbols.edit),
trailing: const Icon(Symbols.chevron_right),
title: Text('channelEdit').tr(),
subtitle: Text('channelEditDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
GoRouter.of(context).pushNamed(
'chatManage',
queryParameters: {'editing': _channel!.keyPath},
).then((value) {
if (value != null && context.mounted) {
Navigator.pop(context, value);
}
});
},
),
if (isOwned)
ListTile(
leading: const Icon(Symbols.delete),
trailing: const Icon(Symbols.chevron_right),
title: Text('channelActionDelete').tr(),
subtitle: Text('channelActionDeleteDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: _deleteChannel,
),
],
),
],
),
),
);
}
}
class _ChannelProfileDetailDialog extends StatefulWidget {
final SnChannel channel;
final SnChannelMember current;
const _ChannelProfileDetailDialog({
required this.channel,
required this.current,
});
@override
State<_ChannelProfileDetailDialog> createState() =>
_ChannelProfileDetailDialogState();
}
class _ChannelProfileDetailDialogState
extends State<_ChannelProfileDetailDialog> {
bool _isBusy = false;
final TextEditingController _nickController = TextEditingController();
Future<void> _updateProfile() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.put(
'/cgi/im/channels/${widget.channel.keyPath}/members/me',
data: {'nick': _nickController.text},
);
if (!mounted) return;
Navigator.pop(context, true);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_nickController.text = widget.current.nick ?? '';
}
@override
void dispose() {
_nickController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('channelProfileEdit').tr(),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _nickController,
decoration: InputDecoration(
labelText: 'fieldChannelProfileNick'.tr(),
helperText: 'fieldChannelProfileNickHint'.tr(),
helperMaxLines: 2,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
],
),
actions: [
TextButton(
onPressed: _isBusy ? null : () => Navigator.pop(context),
child: Text('dialogCancel').tr(),
),
TextButton(
onPressed: _isBusy ? null : _updateProfile,
child: Text('apply').tr(),
),
],
);
}
}
class _ChannelMemberListWidget extends StatefulWidget {
final SnChannel channel;
const _ChannelMemberListWidget({super.key, required this.channel});
@override
State<_ChannelMemberListWidget> createState() =>
_ChannelMemberListWidgetState();
}
class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
bool _isBusy = false;
int? _totalCount;
final List<SnChannelMember> _members = List.empty(growable: true);
Future<void> _fetchMembers() async {
setState(() => _isBusy = true);
try {
final ud = context.read<UserDirectoryProvider>();
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get(
'/cgi/im/channels/${widget.channel.keyPath}/members',
queryParameters: {
'take': 10,
'offset': 0,
});
final out = List<SnChannelMember>.from(
resp.data['data']?.map((e) => SnChannelMember.fromJson(e)) ?? [],
);
_totalCount = resp.data['count'];
_members.addAll(out);
await ud.listAccount(out.map((ele) => ele.accountId).toSet());
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
bool _isUpdating = false;
Future<void> _deleteMember(SnChannelMember member) async {
if (_isUpdating) return;
setState(() => _isUpdating = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.delete(
'/cgi/im/channels/${widget.channel.keyPath}/members/${member.id}',
);
if (!mounted) return;
_members.clear();
_fetchMembers();
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isUpdating = false);
}
}
@override
void initState() {
super.initState();
_fetchMembers();
}
@override
Widget build(BuildContext context) {
final ud = context.read<UserDirectoryProvider>();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.group, size: 24),
const Gap(16),
Text('channelMemberManage')
.tr()
.textStyle(Theme.of(context).textTheme.titleLarge!),
],
).padding(horizontal: 20, top: 16, bottom: 12),
Expanded(
child: RefreshIndicator(
onRefresh: () {
_members.clear();
return _fetchMembers();
},
child: InfiniteList(
itemCount: _members.length,
hasReachedMax:
_totalCount != null && _members.length >= _totalCount!,
isLoading: _isBusy,
onFetchData: _fetchMembers,
itemBuilder: (context, index) {
final member = _members[index];
return ListTile(
contentPadding: const EdgeInsets.only(right: 24, left: 16),
leading: AccountImage(
content: ud.getAccountFromCache(member.accountId)?.avatar,
),
title: Text(
ud.getAccountFromCache(member.accountId)?.name ??
'unknown'.tr(),
),
subtitle: Text(member.nick ?? 'unknown'.tr()),
trailing: SizedBox(
height: 48,
width: 120,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
IconButton(
onPressed:
_isUpdating ? null : () => _deleteMember(member),
icon: const Icon(Symbols.person_remove),
),
],
),
),
);
},
),
),
),
],
);
}
}
class _NewChannelMemberWidget extends StatefulWidget {
final SnChannel channel;
const _NewChannelMemberWidget({super.key, 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

@ -0,0 +1,295 @@
import 'package:dio/dio.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/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:uuid/uuid.dart';
class ChatManageScreen extends StatefulWidget {
final String? editingChannelAlias;
const ChatManageScreen({super.key, this.editingChannelAlias});
@override
State<ChatManageScreen> createState() => _ChatManageScreenState();
}
class _ChatManageScreenState extends State<ChatManageScreen> {
bool _isBusy = false;
final _aliasController = TextEditingController();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
List<SnRealm>? _realms;
SnRealm? _belongToRealm;
Future<void> _fetchRealms() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/realms/me/available');
_realms = List<SnRealm>.from(
resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
);
} catch (err) {
if (mounted) context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
SnChannel? _editingChannel;
Future<void> _fetchChannel() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get(
'/cgi/im/channels/${widget.editingChannelAlias}',
);
_editingChannel = SnChannel.fromJson(resp.data);
_aliasController.text = _editingChannel!.alias;
_nameController.text = _editingChannel!.name;
_descriptionController.text = _editingChannel!.description;
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _performAction() async {
final uuid = const Uuid();
final sn = context.read<SnNetworkProvider>();
setState(() => _isBusy = true);
final scope = _belongToRealm != null ? _belongToRealm!.alias : 'global';
final payload = {
'alias': _aliasController.text.isNotEmpty
? _aliasController.text.toLowerCase()
: uuid.v4().replaceAll('-', '').substring(0, 12),
'name': _nameController.text,
'description': _descriptionController.text,
};
try {
final resp = await sn.client.request(
widget.editingChannelAlias != null
? '/cgi/im/channels/$scope/${widget.editingChannelAlias}'
: '/cgi/im/channels/$scope',
data: payload,
options: Options(
method: widget.editingChannelAlias != null ? 'PUT' : 'POST',
),
);
// ignore: use_build_context_synchronously
if (context.mounted) Navigator.pop(context, resp.data);
} catch (err) {
// ignore: use_build_context_synchronously
if (context.mounted) context.showErrorDialog(err);
}
setState(() => _isBusy = false);
}
@override
void initState() {
super.initState();
if (widget.editingChannelAlias != null) _fetchChannel();
_fetchRealms();
}
@override
void dispose() {
super.dispose();
_aliasController.dispose();
_nameController.dispose();
_descriptionController.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: widget.editingChannelAlias != null
? Text('screenChatManage').tr()
: Text('screenChatNew').tr(),
),
body: SingleChildScrollView(
child: Column(
children: [
LoadingIndicator(isActive: _isBusy),
if (_editingChannel != null)
MaterialBanner(
leading: const Icon(Symbols.edit),
leadingPadding: const EdgeInsets.only(left: 10, right: 20),
dividerColor: Colors.transparent,
content: Text(
'channelEditingNotice'
.tr(args: ['#${_editingChannel!.alias}']),
),
actions: [
TextButton(
child: Text('cancel').tr(),
onPressed: () {
Navigator.pop(context);
},
),
],
),
DropdownButtonHideUnderline(
child: DropdownButton2<SnRealm>(
isExpanded: true,
hint: Text(
'fieldChatBelongToRealm'.tr(),
style: TextStyle(
color: Theme.of(context).hintColor,
),
),
items: [
...(_realms?.map(
(SnRealm item) => DropdownMenuItem<SnRealm>(
value: item,
child: Row(
children: [
AccountImage(
content: item.avatar,
radius: 16,
fallbackWidget: const Icon(
Symbols.group,
size: 16,
),
),
const Gap(12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.name).textStyle(Theme.of(context)
.textTheme
.bodyMedium!),
Text(
item.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
).textStyle(
Theme.of(context).textTheme.bodySmall!),
],
),
),
],
),
),
) ??
[]),
DropdownMenuItem<SnRealm>(
value: null,
child: Row(
children: [
CircleAvatar(
radius: 16,
backgroundColor: Colors.transparent,
foregroundColor:
Theme.of(context).colorScheme.onSurface,
child: const Icon(Symbols.clear),
),
const Gap(12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('fieldChatBelongToRealmUnset')
.tr()
.textStyle(
Theme.of(context).textTheme.bodyMedium!,
),
],
),
),
],
),
),
],
value: _belongToRealm,
onChanged: (SnRealm? value) {
setState(() => _belongToRealm = value);
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.only(right: 16),
height: 60,
),
menuItemStyleData: const MenuItemStyleData(
height: 60,
),
),
),
const Divider(height: 1),
const Gap(12),
Column(
children: [
TextField(
controller: _aliasController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldChatAlias'.tr(),
helperText: 'fieldChatAliasHint'.tr(),
helperMaxLines: 2,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
TextField(
controller: _nameController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldChatName'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
TextField(
controller: _descriptionController,
maxLines: null,
minLines: 3,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldChatDescription'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ElevatedButton.icon(
onPressed: _isBusy ? null : _performAction,
icon: const Icon(Symbols.save),
label: Text('apply').tr(),
),
],
),
],
).padding(horizontal: 24),
],
),
),
);
}
}

348
lib/screens/chat/room.dart Normal file
View File

@ -0,0 +1,348 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/chat_message_controller.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/chat_call.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/websocket.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/widgets/chat/call/call_prejoin.dart';
import 'package:surface/widgets/chat/chat_message.dart';
import 'package:surface/widgets/chat/chat_message_input.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
import '../../providers/user_directory.dart';
import '../../providers/userinfo.dart';
class ChatRoomScreen extends StatefulWidget {
final String scope;
final String alias;
const ChatRoomScreen({super.key, required this.scope, required this.alias});
@override
State<ChatRoomScreen> createState() => _ChatRoomScreenState();
}
class _ChatRoomScreenState extends State<ChatRoomScreen> {
bool _isBusy = false;
bool _isCalling = false;
SnChannel? _channel;
SnChannelMember? _otherMember;
SnChatCall? _ongoingCall;
final GlobalKey<ChatMessageInputState> _inputGlobalKey = GlobalKey();
late final ChatMessageController _messageController;
StreamSubscription? _wsSubscription;
Future<void> _fetchChannel() async {
setState(() => _isBusy = true);
try {
final chan = context.read<ChatChannelProvider>();
_channel = await chan.getChannel('${widget.scope}:${widget.alias}');
if (!mounted || _channel == null) return;
final ud = context.read<UserDirectoryProvider>();
final ua = context.read<UserProvider>();
if (_channel!.type == 1) {
await ud.listAccount(
_channel!.members
?.cast<SnChannelMember?>()
.map((ele) => ele?.accountId)
.where((ele) => ele != null && ele != ua.user?.id)
.toSet() ??
{},
);
_otherMember = _channel!.members?.cast<SnChannelMember?>().firstWhere(
(ele) => ele?.accountId != ua.user?.id,
orElse: () => null,
);
}
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _fetchOngoingCall() async {
setState(() => _isCalling = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get(
'/cgi/im/channels/${_messageController.channel!.keyPath}/calls/ongoing',
options: Options(
validateStatus: (status) => status != null && status < 500,
),
);
if (resp.statusCode == 200) {
_ongoingCall = SnChatCall.fromJson(resp.data);
}
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isCalling = false);
}
}
Future<void> _makeCall() async {
setState(() => _isCalling = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.post(
'/cgi/im/channels/${_messageController.channel!.keyPath}/calls',
options: Options(
sendTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
),
);
log(jsonDecode(resp.data));
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isCalling = false);
}
}
Future<void> _endCall() async {
setState(() => _isCalling = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.delete(
'/cgi/im/channels/${_messageController.channel!.keyPath}/calls/ongoing',
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isCalling = false);
}
}
Future<void> _onCallJoin() async {
await showModalBottomSheet(
context: context,
builder: (context) => ChatCallPrejoinPopup(
ongoingCall: _ongoingCall!,
channel: _channel!,
onJoin: _onCallResume,
),
);
}
void _onCallResume() {
GoRouter.of(context).pushNamed(
'chatCallRoom',
pathParameters: {
'scope': _channel!.realm!.alias,
'alias': _channel!.alias,
},
);
}
bool _checkMessageMergeable(SnChatMessage? a, SnChatMessage? b) {
if (a == null || b == null) return false;
if (a.sender.accountId != b.sender.accountId) return false;
return a.createdAt.difference(b.createdAt).inMinutes <= 3;
}
@override
void initState() {
super.initState();
_messageController = ChatMessageController(context);
_fetchChannel().then((_) async {
await _messageController.initialize(_channel!);
await _messageController.checkUpdate();
await _fetchOngoingCall();
});
final ws = context.read<WebSocketProvider>();
_wsSubscription = ws.stream.stream.listen((event) {
switch (event.method) {
case 'calls.new':
final payload = SnChatCall.fromJson(event.payload!);
if (payload.channelId == _channel?.id) {
setState(() => _ongoingCall = payload);
}
break;
case 'calls.end':
final payload = SnChatCall.fromJson(event.payload!);
if (payload.channelId == _channel?.id) {
setState(() => _ongoingCall = null);
}
break;
}
});
}
@override
void dispose() {
_wsSubscription?.cancel();
_messageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final call = context.watch<ChatCallProvider>();
final ud = context.read<UserDirectoryProvider>();
return Scaffold(
appBar: AppBar(
title: Text(
_channel?.type == 1
? ud.getAccountFromCache(_otherMember?.accountId)?.nick ?? _channel!.name
: _channel?.name ?? 'loading'.tr(),
),
actions: [
IconButton(
icon: _ongoingCall == null ? const Icon(Symbols.call) : const Icon(Symbols.call_end),
onPressed: _isCalling
? null
: _ongoingCall == null
? _makeCall
: _endCall,
),
IconButton(
icon: const Icon(Symbols.more_vert),
onPressed: () {
GoRouter.of(context).pushNamed('channelDetail', pathParameters: {
'scope': widget.scope,
'alias': widget.alias,
}).then((value) {
if (value == false && context.mounted) {
Navigator.pop(context, true);
} else if (value != null && context.mounted) {
_fetchChannel();
}
});
},
),
const Gap(8),
],
),
body: ListenableBuilder(
listenable: _messageController,
builder: (context, _) {
return Column(
children: [
LoadingIndicator(isActive: _isBusy),
SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: MaterialBanner(
dividerColor: Colors.transparent,
leading: const Icon(Symbols.call_received),
content: Text('callOngoingNotice').tr().padding(top: 2),
actions: [
if (call.current == null)
TextButton(
onPressed: _onCallJoin,
child: Text('callJoin').tr(),
)
else if (call.current?.channelId == _channel?.id)
TextButton(
onPressed: _onCallResume,
child: Text('callResume').tr(),
)
],
),
)
.height(_ongoingCall != null ? 54 : 0, animate: true)
.animate(const Duration(milliseconds: 300), Curves.fastLinearToSlowEaseIn),
if (_messageController.isPending)
Expanded(
child: const CircularProgressIndicator().center(),
),
if (!_messageController.isPending)
Expanded(
child: InfiniteList(
reverse: true,
padding: const EdgeInsets.only(
left: 12,
right: 12,
top: 12,
),
hasReachedMax: _messageController.isAllLoaded,
itemCount: _messageController.messages.length,
isLoading: _messageController.isLoading,
onFetchData: () {
_messageController.loadMessages();
},
itemBuilder: (context, idx) {
final message = _messageController.messages[idx];
bool canMerge = false, canMergePrevious = false;
if (idx > 0) {
canMergePrevious = _checkMessageMergeable(
_messageController.messages[idx - 1],
_messageController.messages[idx],
);
}
if (idx + 1 < _messageController.messages.length) {
canMerge = _checkMessageMergeable(
_messageController.messages[idx],
_messageController.messages[idx + 1],
);
}
return Align(
alignment: Alignment.centerLeft,
child: Container(
constraints: BoxConstraints(maxWidth: 480),
child: ChatMessage(
data: message,
isMerged: canMerge,
hasMerged: canMergePrevious,
isPending: _messageController.unconfirmedMessages.contains(message.uuid),
onReply: (value) {
_inputGlobalKey.currentState?.setReply(value);
},
onEdit: (value) {
_inputGlobalKey.currentState?.setEdit(value);
},
onDelete: (value) {
_inputGlobalKey.currentState?.deleteMessage(value);
},
),
),
);
},
),
),
if (!_messageController.isPending)
Material(
elevation: 2,
child: ChatMessageInput(
key: _inputGlobalKey,
otherMember: _otherMember,
controller: _messageController,
).padding(bottom: MediaQuery.of(context).padding.bottom),
),
],
);
},
),
);
}
}

View File

@ -5,10 +5,9 @@ import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
@ -32,35 +31,13 @@ class _ExploreScreenState extends State<ExploreScreen> {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/co/posts', queryParameters: {
'take': 10,
'offset': _posts.length,
});
final List<SnPost> out =
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []);
Set<String> rids = {};
for (var i = 0; i < out.length; i++) {
rids.addAll(out[i].body['attachments']?.cast<String>() ?? []);
}
final pt = context.read<SnPostContentProvider>();
final result = await pt.listPosts(take: 10, offset: _posts.length);
final out = result.$1;
if (!mounted) return;
final attach = context.read<SnAttachmentProvider>();
final attachments = await attach.getMultiple(rids.toList());
for (var i = 0; i < out.length; i++) {
out[i] = out[i].copyWith(
preload: SnPostPreload(
attachments: attachments
.where(
(ele) => out[i].body['attachments']?.contains(ele.rid) ?? false,
)
.toList(),
),
);
}
_postCount = resp.data['count'];
_postCount = result.$2;
_posts.addAll(out);
if (mounted) setState(() => _isBusy = false);
@ -161,9 +138,19 @@ class _ExploreScreenState extends State<ExploreScreen> {
child: CustomScrollView(
slivers: [
SliverAppBar(
leading: AutoAppBarLeading(),
title: Text('screenExplore').tr(),
floating: true,
snap: true,
actions: [
IconButton(
icon: const Icon(Symbols.search),
onPressed: () {
GoRouter.of(context).pushNamed('postSearch');
},
),
const Gap(8),
],
),
SliverInfiniteList(
itemCount: _posts.length,
@ -173,10 +160,17 @@ class _ExploreScreenState extends State<ExploreScreen> {
onFetchData: _fetchPosts,
itemBuilder: (context, idx) {
return GestureDetector(
child: Container(
constraints: const BoxConstraints(maxWidth: 640),
child: PostItem(data: _posts[idx]),
).center(),
child: PostItem(
data: _posts[idx],
maxWidth: 640,
onChanged: (data) {
setState(() => _posts[idx] = data);
},
onDeleted: () {
_posts.clear();
_fetchPosts();
},
),
onTap: () {
GoRouter.of(context).pushNamed(
'postDetail',

515
lib/screens/friend.dart Normal file
View File

@ -0,0 +1,515 @@
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/relationship.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/account.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import '../providers/userinfo.dart';
import '../widgets/unauthorized_hint.dart';
const kFriendStatus = {
0: 'friendStatusPending',
1: 'friendStatusActive',
2: 'friendStatusBlocked',
3: 'friendStatusWaiting',
};
class FriendScreen extends StatefulWidget {
const FriendScreen({super.key});
@override
State<FriendScreen> createState() => _FriendScreenState();
}
class _FriendScreenState extends State<FriendScreen> {
bool _isBusy = false;
List<SnRelationship> _requests = List.empty();
List<SnRelationship> _relations = List.empty();
List<SnRelationship> _blocks = List.empty();
Future<void> _fetchRelations() async {
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) return;
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/me/relations?status=1');
_relations = List<SnRelationship>.from(
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [],
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _fetchRequests() async {
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) return;
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/me/relations?status=0,3');
_requests = List<SnRelationship>.from(
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [],
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _fetchBlocks() async {
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) return;
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/me/relations?status=2');
_blocks = List<SnRelationship>.from(
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [],
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
bool _isUpdating = false;
Future<void> _changeRelation(SnRelationship relation, int dstStatus) async {
setState(() => _isUpdating = true);
try {
final rel = context.read<SnRelationshipProvider>();
await rel.updateRelationship(
relation.relatedId,
dstStatus,
relation.permNodes,
);
if (!mounted) return;
_fetchRelations();
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isUpdating = false);
}
}
Future<void> _deleteRelation(SnRelationship relation) async {
final confirm = await context.showConfirmDialog(
'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
'friendDeleteDescription'.tr(args: [
relation.related?.nick ?? 'unknown'.tr(),
]),
);
if (!confirm) return;
if (!mounted) return;
setState(() => _isUpdating = true);
try {
final rel = context.read<SnRelationshipProvider>();
await rel.deleteRelationship(relation.relatedId);
if (!mounted) return;
_fetchRelations();
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isUpdating = false);
}
}
void _showRequests() {
showModalBottomSheet(
context: context,
builder: (context) => _FriendshipListWidget(relations: _requests),
).then((value) {
if (value != null) {
_fetchRequests();
_fetchRelations();
}
});
}
void _showBlocks() {
showModalBottomSheet(
context: context,
builder: (context) => _FriendshipListWidget(relations: _blocks),
).then((value) {
if (value != null) {
_fetchBlocks();
_fetchRelations();
}
});
}
@override
void initState() {
super.initState();
_fetchRelations();
_fetchRequests();
_fetchBlocks();
}
@override
Widget build(BuildContext context) {
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) {
return Scaffold(
appBar: AppBar(
leading: AutoAppBarLeading(),
title: Text('screenFriend').tr(),
),
body: Center(
child: UnauthorizedHint(),
),
);
}
return Scaffold(
appBar: AppBar(
leading: AutoAppBarLeading(),
title: Text('screenFriend').tr(),
),
floatingActionButton: FloatingActionButton(
child: const Icon(Symbols.add),
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) => _NewFriendWidget(),
);
},
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy || _isUpdating),
if (_requests.isNotEmpty)
ListTile(
title: Text('friendRequests').tr(),
subtitle: Text(
'friendRequestsDescription',
).plural(_requests.length),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.group_add),
trailing: const Icon(Symbols.chevron_right),
onTap: _showRequests,
),
if (_blocks.isNotEmpty)
ListTile(
title: Text('friendBlocklist').tr(),
subtitle: Text(
'friendBlocklistDescription',
).plural(_blocks.length),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.block),
trailing: const Icon(Symbols.chevron_right),
onTap: _showBlocks,
),
if (_requests.isNotEmpty || _blocks.isNotEmpty)
const Divider(height: 1),
Expanded(
child: RefreshIndicator(
onRefresh: () => Future.wait([
_fetchRelations(),
_fetchRequests(),
]),
child: ListView.builder(
itemCount: _relations.length,
itemBuilder: (context, index) {
final relation = _relations[index];
final other = relation.related;
return ListTile(
contentPadding: const EdgeInsets.only(right: 24, left: 16),
leading: AccountImage(content: other?.avatar),
title: Text(other?.nick ?? 'unknown'),
subtitle: Text(other?.nick ?? 'unknown'),
trailing: SizedBox(
height: 48,
width: 120,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
InkWell(
onTap: _isUpdating
? null
: () => _changeRelation(relation, 2),
child: Text('friendBlock').tr(),
),
const Gap(8),
InkWell(
onTap: _isUpdating
? null
: () => _deleteRelation(relation),
child: Text('friendDeleteAction').tr(),
),
],
),
],
),
),
);
},
),
),
),
],
),
);
}
}
class _NewFriendWidget extends StatefulWidget {
const _NewFriendWidget({super.key});
@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 {
final List<SnRelationship> relations;
const _FriendshipListWidget({super.key, required this.relations});
@override
State<_FriendshipListWidget> createState() => _FriendshipListWidgetState();
}
class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
bool _isBusy = false;
Future<void> _acceptRequest(SnRelationship relation) async {
setState(() => _isBusy = true);
try {
final rel = context.read<SnRelationshipProvider>();
await rel.acceptFriendRequest(relation.relatedId);
if (!mounted) return;
Navigator.pop(context, true);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _declineRequest(SnRelationship relation) async {
setState(() => _isBusy = true);
try {
final rel = context.read<SnRelationshipProvider>();
await rel.declineFriendRequest(relation.relatedId);
if (!mounted) return;
Navigator.pop(context, true);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _changeRelation(SnRelationship relation, int dstStatus) async {
setState(() => _isBusy = true);
try {
final rel = context.read<SnRelationshipProvider>();
await rel.updateRelationship(
relation.relatedId,
dstStatus,
relation.permNodes,
);
if (!mounted) return;
Navigator.pop(context, true);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _deleteRelation(SnRelationship relation) async {
final confirm = await context.showConfirmDialog(
'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
'friendDeleteDescription'.tr(args: [
relation.related?.nick ?? 'unknown'.tr(),
]),
);
if (!confirm) return;
if (!mounted) return;
setState(() => _isBusy = true);
try {
final rel = context.read<SnRelationshipProvider>();
await rel.deleteRelationship(relation.relatedId);
if (!mounted) return;
Navigator.pop(context, true);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: widget.relations.length,
itemBuilder: (context, index) {
final relation = widget.relations[index];
final other = relation.related;
return ListTile(
contentPadding: const EdgeInsets.only(right: 24, left: 16),
leading: AccountImage(content: other?.avatar),
title: Text(other?.nick ?? 'unknown'.tr()),
subtitle: Text(other?.nick ?? 'unknown'.tr()),
trailing: SizedBox(
height: 48,
width: 120,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(kFriendStatus[relation.status] ?? 'unknown')
.tr()
.opacity(0.75),
if (relation.status == 0)
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
InkWell(
onTap: _isBusy ? null : () => _acceptRequest(relation),
child: Text('friendRequestAccept').tr(),
),
const Gap(8),
InkWell(
onTap: _isBusy ? null : () => _declineRequest(relation),
child: Text('friendRequestDecline').tr(),
),
],
)
else if (relation.status == 2)
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
InkWell(
onTap:
_isBusy ? null : () => _changeRelation(relation, 1),
child: Text('friendUnblock').tr(),
),
const Gap(8),
InkWell(
onTap: _isBusy ? null : () => _deleteRelation(relation),
child: Text('friendDeleteAction').tr(),
),
],
),
],
),
),
);
},
);
}
}

View File

@ -1,7 +1,35 @@
import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:flutter/material.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/check_in.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/post/post_item.dart';
class HomeScreenDashEntry {
final String name;
final Widget child;
final int rows, cols;
const HomeScreenDashEntry({
required this.name,
required this.child,
this.rows = 1,
this.cols = 1,
});
}
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@ -11,26 +39,432 @@ class HomeScreen extends StatefulWidget {
}
class _HomeScreenState extends State<HomeScreen> {
static const List<HomeScreenDashEntry> kCards = [
HomeScreenDashEntry(
name: 'dashEntryRecommendation',
cols: 2,
rows: 2,
child: _HomeDashRecommendationPostWidget(),
),
HomeScreenDashEntry(
name: 'dashEntryCheckIn',
child: _HomeDashCheckInWidget(),
),
HomeScreenDashEntry(
name: 'dashEntryNotification',
child: _HomeDashNotificationWidget(),
),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: AutoAppBarLeading(),
title: Text("screenHome").tr(),
),
body: Column(
body: LayoutBuilder(
builder: (context, constraints) {
return Align(
alignment: constraints.maxWidth > 640 ? Alignment.center : Alignment.topCenter,
child: Container(
constraints: const BoxConstraints(maxWidth: 640),
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: constraints.maxWidth > 640 ? MainAxisAlignment.center : MainAxisAlignment.start,
children: [
if (constraints.maxWidth <= 640) const Gap(8),
_HomeDashSpecialDayWidget().padding(top: 8, horizontal: 8),
StaggeredGrid.count(
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
children: kCards.map((card) {
return StaggeredGridTile.count(
crossAxisCellCount: card.cols,
mainAxisCellCount: card.rows,
child: card.child,
);
}).toList(),
).padding(horizontal: 8),
],
),
),
),
);
},
),
);
}
}
class _HomeDashSpecialDayWidget extends StatelessWidget {
const _HomeDashSpecialDayWidget({super.key});
@override
Widget build(BuildContext context) {
final ua = context.watch<UserProvider>();
final today = DateTime.now();
final birthday = ua.user?.profile?.birthday?.toLocal();
final isBirthday = birthday != null && birthday.day == today.day && birthday.month == today.month;
return Column(
children: [
if (isBirthday)
Card(
child: ListTile(
leading: Text('🎂').fontSize(24),
title: Text('happyBirthday').tr(args: [ua.user?.nick ?? 'user']),
),
).padding(bottom: 8),
],
);
}
}
class _HomeDashCheckInWidget extends StatefulWidget {
const _HomeDashCheckInWidget({super.key});
@override
State<_HomeDashCheckInWidget> createState() => _HomeDashCheckInWidgetState();
}
class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
bool _isBusy = false;
SnCheckInRecord? _todayRecord;
static const int kSuggestionPositiveHintCount = 6;
static const int kSuggestionNegativeHintCount = 6;
Future<void> _pullCheckIn() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/check-in/today');
_todayRecord = SnCheckInRecord.fromJson(resp.data);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _doCheckIn() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.post('/cgi/id/check-in');
_todayRecord = SnCheckInRecord.fromJson(resp.data);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Widget _buildDetailChunk(int index, bool positive) {
final prefix = positive ? 'dailyCheckPositiveHint' : 'dailyCheckNegativeHint';
final mod = positive ? kSuggestionPositiveHintCount : kSuggestionNegativeHintCount;
final pos = math.max(1, _todayRecord!.resultModifiers[index] % mod);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
prefix.tr(args: ['$prefix$pos'.tr()]),
style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold),
).tr(),
Text(
'$prefix${pos}Description',
style: Theme.of(context).textTheme.bodyMedium,
).tr(),
],
);
}
void _showCheckInDetail() {
showDialog(
useRootNavigator: true,
context: context,
builder: (context) {
return AlertDialog(
title: Text('dailyCheckDetailTitle'.tr(args: [
DateFormat('MM/dd').format(DateTime.now().toUtc()),
])),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (_todayRecord?.resultTier != 0)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailChunk(0, true),
const Gap(8),
_buildDetailChunk(1, true),
],
)
else
Text(
'dailyCheckEverythingIsNegative',
style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold),
).tr(),
const Gap(8),
if (_todayRecord?.resultTier != 4)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildDetailChunk(2, false),
const Gap(8),
_buildDetailChunk(3, false),
],
)
else
Text(
'dailyCheckEverythingIsPositive',
style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold),
).tr(),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('dialogDismiss').tr(),
)
],
);
},
);
}
@override
void initState() {
super.initState();
final ua = context.read<UserProvider>();
Future.delayed(const Duration(milliseconds: 500), () async {
if (!ua.isAuthorized) return;
await _pullCheckIn();
});
}
@override
Widget build(BuildContext context) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MaterialBanner(
leading: const Icon(Symbols.construction),
content: Column(
Expanded(
child: AnimatedSwitcher(
switchInCurve: Curves.fastOutSlowIn,
switchOutCurve: Curves.fastOutSlowIn,
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return ScaleTransition(
scale: animation,
child: child,
);
},
child: _todayRecord == null
? Column(
key: Key('daily-check-in-overview-none'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'dailyCheckIn',
style: Theme.of(context).textTheme.titleLarge,
).tr(),
Text(
'dailyCheckInNone',
style: Theme.of(context).textTheme.bodyLarge,
).tr(),
],
)
: Column(
key: Key('daily-check-in-overview-has'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_todayRecord!.symbol,
style: GoogleFonts.notoSerifHk(
textStyle: Theme.of(context).textTheme.titleLarge,
),
),
Text(
'+${_todayRecord!.resultExperience} EXP',
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
DateFormat('EEE\nMM/dd').format(DateTime.now().toUtc()),
).fontSize(13).opacity(0.75),
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: AnimatedSwitcher(
switchInCurve: Curves.fastOutSlowIn,
switchOutCurve: Curves.fastOutSlowIn,
duration: const Duration(milliseconds: 300),
child: _todayRecord == null
? IconButton(
key: UniqueKey(),
tooltip: 'dailyCheckAction'.tr(),
icon: const Icon(Symbols.local_fire_department),
onPressed: _isBusy ? null : _doCheckIn,
)
: IconButton(
key: UniqueKey(),
tooltip: 'dailyCheckDetail'.tr(),
icon: const Icon(Symbols.help),
onPressed: _showCheckInDetail,
),
),
),
],
),
],
).padding(all: 24),
);
}
}
class _HomeDashNotificationWidget extends StatefulWidget {
const _HomeDashNotificationWidget({super.key});
@override
State<_HomeDashNotificationWidget> createState() => _HomeDashNotificationWidgetState();
}
class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget> {
int? _count;
Future<void> _fetchNotificationCount() async {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/notifications/count');
_count = resp.data['count'];
setState(() {});
}
@override
void initState() {
super.initState();
_fetchNotificationCount();
}
@override
Widget build(BuildContext context) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('nextVersionAlert').tr().bold(),
Text('nextVersionNotice').tr(),
Text(
'notification',
style: Theme.of(context).textTheme.titleLarge,
).tr(),
Text(
_count == null ? 'loading'.tr() : 'notificationUnreadCount'.plural(_count ?? 0),
style: Theme.of(context).textTheme.bodyLarge,
),
],
).padding(vertical: 16),
actions: [
const SizedBox(),
],
),
),
Align(
alignment: Alignment.centerRight,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: IconButton(
icon: const Icon(Symbols.arrow_right_alt),
onPressed: () {
GoRouter.of(context).goNamed('notification');
},
),
),
)
],
).padding(all: 24),
);
}
}
class _HomeDashRecommendationPostWidget extends StatefulWidget {
const _HomeDashRecommendationPostWidget({super.key});
@override
State<_HomeDashRecommendationPostWidget> createState() => _HomeDashRecommendationPostWidgetState();
}
class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendationPostWidget> {
bool _isBusy = false;
List<SnPost>? _posts;
Future<void> _fetchRecommendationPosts() async {
setState(() => _isBusy = true);
try {
final pt = context.read<SnPostContentProvider>();
_posts = await pt.listRecommendations();
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchRecommendationPosts();
}
@override
Widget build(BuildContext context) {
if (_isBusy) {
return Card(
child: CircularProgressIndicator().center(),
);
}
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'postRecommendation',
style: Theme.of(context).textTheme.titleLarge,
).tr().padding(horizontal: 20, top: 16, bottom: 8),
Expanded(
child: PageView.builder(
itemCount: _posts?.length ?? 0,
itemBuilder: (context, index) {
return SingleChildScrollView(
child: GestureDetector(
child: PostItem(
data: _posts![index],
showMenu: false,
).padding(left: 8, right: 8, bottom: 8),
onTap: () {
GoRouter.of(context).pushNamed('postDetail', pathParameters: {
'slug': _posts![index].id.toString(),
});
},
),
);
},
),
),
],
),

View File

@ -0,0 +1,286 @@
import 'dart:math' as math;
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:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/notification.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
import '../providers/userinfo.dart';
import '../widgets/unauthorized_hint.dart';
class NotificationScreen extends StatefulWidget {
const NotificationScreen({super.key});
@override
State<NotificationScreen> createState() => _NotificationScreenState();
}
class _NotificationScreenState extends State<NotificationScreen> {
bool _isBusy = false;
bool _isFirstLoading = true;
bool _isSubmitting = false;
final List<SnNotification> _notifications = List.empty(growable: true);
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 {
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) return;
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/notifications?take=10');
_totalCount = resp.data['count'];
_notifications.addAll(
resp.data['data']
?.map((e) => SnNotification.fromJson(e))
.cast<SnNotification>() ??
[],
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
_isFirstLoading = false;
setState(() => _isBusy = false);
}
}
void _markAllAsRead() async {
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) return;
if (_notifications.isEmpty) return;
final confirm = await context.showConfirmDialog(
'notificationMarkAllRead'.tr(),
'notificationMarkAllReadDescription'.tr(),
);
if (!confirm) return;
if (!mounted) return;
setState(() => _isSubmitting = true);
List<int> markList = List.empty(growable: true);
for (final element in _notifications) {
if (element.id <= 0) continue;
if (element.readAt != null) continue;
markList.add(element.id);
}
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.put('/cgi/id/notifications/read', data: {
'messages': markList,
});
_notifications.clear();
_fetchNotifications();
if (!mounted) return;
context.showSnackbar(
'notificationMarkAllReadPrompt'.plural(markList.length),
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isSubmitting = false);
}
}
void _markOneAsRead(SnNotification notification) async {
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) return;
if (notification.readAt != null) return;
setState(() => _isSubmitting = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.put('/cgi/id/notifications/read/${notification.id}');
_notifications.clear();
_fetchNotifications();
if (!mounted) return;
context.showSnackbar(
'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']),
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isSubmitting = false);
}
}
@override
void initState() {
super.initState();
_fetchNotifications();
}
@override
Widget build(BuildContext context) {
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) {
return Scaffold(
appBar: AppBar(
leading: AutoAppBarLeading(),
title: Text('screenNotification').tr(),
),
body: Center(
child: UnauthorizedHint(),
),
);
}
return Scaffold(
appBar: AppBar(
leading: AutoAppBarLeading(),
title: Text('screenNotification').tr(),
actions: [
IconButton(
icon: const Icon(Symbols.checklist),
onPressed: _isSubmitting ? null : _markAllAsRead,
),
const Gap(8),
],
),
body: Column(
children: [
LoadingIndicator(isActive: _isFirstLoading),
Expanded(
child: RefreshIndicator(
onRefresh: () {
_notifications.clear();
return _fetchNotifications();
},
child: InfiniteList(
padding: EdgeInsets.only(
top: 16,
bottom: math.max(MediaQuery.of(context).padding.bottom, 16),
),
itemCount: _notifications.length,
onFetchData: () {
_fetchNotifications();
},
isLoading: _isBusy,
hasReachedMax: _totalCount != null &&
_notifications.length >= _totalCount!,
itemBuilder: (context, idx) {
final nty = _notifications[idx];
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(kNotificationTopicIcons[nty.topic]),
const Gap(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (nty.readAt == null)
StyledWidget(Badge(
label: Text('notificationUnread').tr(),
)).padding(bottom: 4),
Text(
nty.title,
style: Theme.of(context).textTheme.titleMedium,
),
if (nty.subtitle != null)
Text(
nty.subtitle!,
style: Theme.of(context).textTheme.titleSmall,
),
if (nty.subtitle != null) const Gap(4),
MarkdownTextContent(
content: nty.body,
isAutoWarp: true,
isSelectable: true,
),
if ([
'interactive.feedback',
'interactive.subscription'
].contains(nty.topic) &&
nty.metadata['related_post'] != null)
StyledWidget(Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(8)),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
),
child: PostItem(
data: SnPost.fromJson(
nty.metadata['related_post']!,
),
showComments: false,
showReactions: false,
showMenu: false,
),
)).padding(top: 8),
const Gap(8),
Row(
children: [
Text(
DateFormat('yy/MM/dd').format(nty.createdAt),
).fontSize(12),
const Gap(4),
Text(
'·',
style: TextStyle(fontSize: 12),
),
const Gap(4),
Text(
RelativeTime(context).format(nty.createdAt),
).fontSize(12),
],
).opacity(0.75),
],
),
),
const Gap(16),
IconButton(
icon: const Icon(Symbols.check),
padding: EdgeInsets.all(0),
visualDensity:
const VisualDensity(horizontal: -4, vertical: -4),
onPressed:
_isSubmitting ? null : () => _markOneAsRead(nty),
),
],
).padding(horizontal: 16);
},
separatorBuilder: (_, __) => const Divider(),
),
),
),
],
),
);
}
}

View File

@ -6,9 +6,9 @@ import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/dialog.dart';
@ -20,6 +20,7 @@ import 'package:surface/widgets/post/post_mini_editor.dart';
class PostDetailScreen extends StatefulWidget {
final String slug;
final SnPost? preload;
const PostDetailScreen({
super.key,
required this.slug,
@ -39,19 +40,10 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final attach = context.read<SnAttachmentProvider>();
final resp = await sn.client.get('/cgi/co/posts/${widget.slug}');
final pt = context.read<SnPostContentProvider>();
final post = await pt.getPost(widget.slug);
if (!mounted) return;
final attachments = await attach.getMultiple(
resp.data['body']['attachments']?.cast<String>() ?? [],
);
if (!mounted) return;
_data = SnPost.fromJson(resp.data).copyWith(
preload: SnPostPreload(
attachments: attachments,
),
);
_data = post;
} catch (err) {
context.showErrorDialog(err);
} finally {
@ -80,27 +72,28 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
leading: BackButton(
onPressed: () {
if (GoRouter.of(context).canPop()) {
Navigator.pop(context);
GoRouter.of(context).pop(context);
return;
}
GoRouter.of(context).replaceNamed('explore');
},
),
flexibleSpace: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_data?.body['title'] != null)
Text(_data?.body['title'] ?? 'postNoun'.tr())
.textStyle(Theme.of(context).textTheme.titleLarge!)
.textColor(Colors.white),
if (_data?.body['title'] != null)
Text('postDetail'.tr())
.textColor(Colors.white.withAlpha((255 * 0.9).round()))
else
Text('postDetail'.tr())
.textStyle(Theme.of(context).textTheme.titleLarge!)
.textColor(Colors.white),
],
).padding(top: math.max(MediaQuery.of(context).padding.top, 8)),
title: _data?.body['title'] != null
? RichText(
textAlign: TextAlign.center,
text: TextSpan(children: [
TextSpan(
text: _data?.body['title'] ?? 'postNoun'.tr(),
style: Theme.of(context).textTheme.titleLarge!.copyWith(color: Colors.white),
),
const TextSpan(text: '\n'),
TextSpan(
text: 'postDetail'.tr(),
style: Theme.of(context).textTheme.bodySmall!.copyWith(color: Colors.white),
),
]),
)
: Text('postDetail').tr(),
),
body: CustomScrollView(
slivers: [
@ -109,44 +102,62 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
),
if (_data != null)
SliverToBoxAdapter(
child: Container(
constraints: const BoxConstraints(maxWidth: 640),
child: PostItem(
data: _data!,
showComments: false,
),
).center(),
child: PostItem(
data: _data!,
maxWidth: 640,
showComments: false,
showFullPost: true,
onChanged: (data) {
setState(() => _data = data);
},
onDeleted: () {
Navigator.pop(context);
},
),
),
const SliverToBoxAdapter(child: Divider(height: 1)),
if (_data != null)
SliverToBoxAdapter(
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),
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(
border: Border.symmetric(
horizontal: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / devicePixelRatio,
),
),
borderRadius: ResponsiveBreakpoints.of(context).largerThan(MOBILE)
? const BorderRadius.all(Radius.circular(8))
: BorderRadius.zero,
border: ResponsiveBreakpoints.of(context).largerThan(MOBILE)
? Border.all(
color: Theme.of(context).dividerColor,
width: 1 / devicePixelRatio,
)
: Border.symmetric(
horizontal: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / devicePixelRatio,
),
),
),
child: PostMiniEditor(
postReplyId: _data!.id,
onPost: () {
_childListKey.currentState!.refresh();
setState(() {
_data = _data!.copyWith(
metric: _data!.metric.copyWith(
@ -154,9 +165,10 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
),
);
});
_childListKey.currentState!.refresh();
},
),
),
).center(),
),
if (_data != null)
PostCommentSliverList(

View File

@ -1,14 +1,16 @@
import 'dart:math' as math;
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:pasteboard/pasteboard.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/sn_network.dart';
@ -26,6 +28,7 @@ class PostEditorScreen extends StatefulWidget {
final int? postEditId;
final int? postReplyId;
final int? postRepostId;
const PostEditorScreen({
super.key,
required this.mode,
@ -42,6 +45,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
final PostWriteController _writeController = PostWriteController();
bool _isFetching = false;
bool get _isLoading => _isFetching || _writeController.isLoading;
List<SnPublisher>? _publishers;
@ -74,13 +78,34 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
final _imagePicker = ImagePicker();
void _takeMedia(bool isVideo) async {
final result = isVideo
? await _imagePicker.pickVideo(source: ImageSource.camera)
: await _imagePicker.pickImage(source: ImageSource.camera);
if (result == null) return;
_writeController.addAttachments([
PostWriteMedia.fromFile(result),
]);
}
void _selectMedia() async {
final result = await _imagePicker.pickMultipleMedia();
if (result.isEmpty) return;
_writeController.addAttachments(
result.map((e) => PostWriteMedia.fromFile(e)),
);
setState(() {});
}
void _pasteMedia() async {
final imageBytes = await Pasteboard.image;
if (imageBytes == null) return;
_writeController.addAttachments([
PostWriteMedia.fromBytes(
imageBytes,
'attachmentPastedImage'.tr(),
PostWriteMediaType.image,
),
]);
}
@override
@ -95,6 +120,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
if (!PostWriteController.kTitleMap.keys.contains(widget.mode)) {
context.showErrorDialog('Unknown post type');
Navigator.pop(context);
} else {
_writeController.setMode(widget.mode);
}
_fetchPublishers();
_writeController.fetchRelatedPost(
@ -117,23 +144,26 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
Navigator.pop(context);
},
),
flexibleSpace: Column(
children: [
Text(_writeController.title.isNotEmpty
? _writeController.title
: 'untitled'.tr())
.textStyle(Theme.of(context).textTheme.titleLarge!)
.textColor(Colors.white),
Text(PostWriteController.kTitleMap[widget.mode]!)
.tr()
.textColor(Colors.white.withAlpha((255 * 0.9).round())),
],
).padding(top: math.max(MediaQuery.of(context).padding.top, 8)),
title: RichText(
textAlign: TextAlign.center,
text: TextSpan(children: [
TextSpan(
text: _writeController.title.isNotEmpty ? _writeController.title : 'untitled'.tr(),
style: Theme.of(context).textTheme.titleLarge!.copyWith(color: Colors.white),
),
const TextSpan(text: '\n'),
TextSpan(
text: PostWriteController.kTitleMap[widget.mode]!.tr(),
style: Theme.of(context).textTheme.bodySmall!.copyWith(color: Colors.white),
),
]),
),
actions: [
IconButton(
icon: const Icon(Symbols.tune),
onPressed: _writeController.isBusy ? null : _updateMeta,
),
const Gap(8),
],
),
body: Column(
@ -160,17 +190,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.nick).textStyle(
Theme.of(context)
.textTheme
.bodyMedium!),
Text(item.nick).textStyle(Theme.of(context).textTheme.bodyMedium!),
Text('@${item.name}')
.textStyle(Theme.of(context)
.textTheme
.bodySmall!)
.textStyle(Theme.of(context).textTheme.bodySmall!)
.fontSize(12),
],
),
@ -187,8 +211,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
CircleAvatar(
radius: 16,
backgroundColor: Colors.transparent,
foregroundColor:
Theme.of(context).colorScheme.onSurface,
foregroundColor: Theme.of(context).colorScheme.onSurface,
child: const Icon(Symbols.add),
),
const Gap(8),
@ -197,8 +220,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('publishersNew').tr().textStyle(
Theme.of(context).textTheme.bodyMedium!),
Text('publishersNew').tr().textStyle(Theme.of(context).textTheme.bodyMedium!),
],
),
),
@ -209,9 +231,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
value: _writeController.publisher,
onChanged: (SnPublisher? value) {
if (value == null) {
GoRouter.of(context)
.pushNamed('accountPublisherNew')
.then((value) {
GoRouter.of(context).pushNamed('accountPublisherNew').then((value) {
if (value == true) {
_publishers = null;
_fetchPublishers();
@ -246,16 +266,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
),
child: ExpansionTile(
minTileHeight: 48,
leading:
const Icon(Symbols.reply).padding(left: 4),
leading: const Icon(Symbols.reply).padding(left: 4),
title: Text('postReplyingNotice')
.fontSize(15)
.tr(args: [
'@${_writeController.replyingPost!.publisher.name}'
]),
children: <Widget>[
PostItem(data: _writeController.replyingPost!)
],
.tr(args: ['@${_writeController.replyingPost!.publisher.name}']),
children: <Widget>[PostItem(data: _writeController.replyingPost!)],
),
),
const Divider(height: 1),
@ -271,16 +286,14 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
),
child: ExpansionTile(
minTileHeight: 48,
leading: const Icon(Symbols.forward)
.padding(left: 4),
leading: const Icon(Symbols.forward).padding(left: 4),
title: Text('postRepostingNotice')
.fontSize(15)
.tr(args: [
'@${_writeController.repostingPost!.publisher.name}'
]),
.tr(args: ['@${_writeController.repostingPost!.publisher.name}']),
children: <Widget>[
PostItem(
data: _writeController.repostingPost!)
data: _writeController.repostingPost!,
)
],
),
),
@ -297,16 +310,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
),
child: ExpansionTile(
minTileHeight: 48,
leading: const Icon(Symbols.edit_note)
.padding(left: 4),
leading: const Icon(Symbols.edit_note).padding(left: 4),
title: Text('postEditingNotice')
.fontSize(15)
.tr(args: [
'@${_writeController.editingPost!.publisher.name}'
]),
children: <Widget>[
PostItem(data: _writeController.editingPost!)
],
.tr(args: ['@${_writeController.editingPost!.publisher.name}']),
children: <Widget>[PostItem(data: _writeController.editingPost!)],
),
),
const Divider(height: 1),
@ -325,14 +333,12 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
),
border: InputBorder.none,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
]
.expandIndexed(
(idx, ele) => [
if (idx != 0 || _writeController.isRelatedNull)
const Gap(8),
if (idx != 0 || _writeController.isRelatedNull) const Gap(8),
ele,
],
)
@ -340,9 +346,38 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
),
),
),
if (_writeController.attachments.isNotEmpty)
if (_writeController.attachments.isNotEmpty || _writeController.thumbnail != null)
PostMediaPendingList(
controller: _writeController,
thumbnail: _writeController.thumbnail,
attachments: _writeController.attachments,
isBusy: _writeController.isBusy,
onUpload: (int idx) async {
await _writeController.uploadSingleAttachment(context, idx);
},
onPostSetThumbnail: (int? idx) {
_writeController.setThumbnail(idx);
},
onInsertLink: (int idx) async {
_writeController.contentController.text +=
'\n![](solink://attachments/${_writeController.attachments[idx].attachment!.rid})';
},
onUpdate: (int idx, PostWriteMedia updatedMedia) async {
_writeController.setIsBusy(true);
try {
_writeController.setAttachmentAt(idx, updatedMedia);
} finally {
_writeController.setIsBusy(false);
}
},
onRemove: (int idx) async {
_writeController.setIsBusy(true);
try {
_writeController.removeAttachmentAt(idx);
} finally {
_writeController.setIsBusy(false);
}
},
onUpdateBusy: (state) => _writeController.setIsBusy(state),
).padding(bottom: 8),
Material(
elevation: 2,
@ -350,13 +385,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LoadingIndicator(isActive: _isLoading),
if (_writeController.isBusy &&
_writeController.progress != null)
if (_writeController.isBusy && _writeController.progress != null)
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: _writeController.progress),
duration: Duration(milliseconds: 300),
builder: (context, value, _) =>
LinearProgressIndicator(value: value, minHeight: 2),
builder: (context, value, _) => LinearProgressIndicator(value: value, minHeight: 2),
)
else if (_writeController.isBusy)
const LinearProgressIndicator(value: null, minHeight: 2),
@ -370,15 +403,63 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
scrollDirection: Axis.vertical,
child: Row(
children: [
IconButton(
onPressed: _writeController.isBusy
? null
: _selectMedia,
PopupMenuButton(
icon: Icon(
Symbols.add_photo_alternate,
color:
Theme.of(context).colorScheme.primary,
color: Theme.of(context).colorScheme.primary,
),
itemBuilder: (context) => [
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows)
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.photo_camera),
const Gap(16),
Text('addAttachmentFromCameraPhoto').tr(),
],
),
onTap: () {
_takeMedia(false);
},
),
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows)
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.videocam),
const Gap(16),
Text('addAttachmentFromCameraVideo').tr(),
],
),
onTap: () {
_takeMedia(true);
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.photo_library),
const Gap(16),
Text('addAttachmentFromAlbum').tr(),
],
),
onTap: () {
_selectMedia();
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.content_paste),
const Gap(16),
Text('addAttachmentFromClipboard').tr(),
],
),
onTap: () {
_pasteMedia();
},
),
],
),
],
),
@ -386,8 +467,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
),
),
TextButton.icon(
onPressed: (_writeController.isBusy ||
_writeController.publisher == null)
onPressed: (_writeController.isBusy || _writeController.publisher == null)
? null
: () {
_writeController.post(context).then((_) {
@ -402,7 +482,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
).padding(horizontal: 16),
],
).padding(
bottom: MediaQuery.of(context).padding.bottom,
bottom: MediaQuery.of(context).padding.bottom + 8,
top: 4,
),
),

View File

@ -0,0 +1,204 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/post/post_tags_field.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class PostSearchScreen extends StatefulWidget {
const PostSearchScreen({super.key});
@override
State<PostSearchScreen> createState() => _PostSearchScreenState();
}
class _PostSearchScreenState extends State<PostSearchScreen> {
bool _isBusy = false;
List<String> _searchTags = List.empty(growable: true);
final List<SnPost> _posts = List.empty(growable: true);
int? _postCount;
String _searchTerm = '';
Duration? _lastTook;
Future<void> _fetchPosts() async {
if (_searchTerm.isEmpty && _searchTags.isEmpty) return;
if (_postCount != null && _posts.length >= _postCount!) return;
setState(() => _isBusy = true);
final stopwatch = Stopwatch()..start();
try {
final pt = context.read<SnPostContentProvider>();
final result = await pt.searchPosts(
_searchTerm,
take: 10,
offset: _posts.length,
tags: _searchTags,
);
final List<SnPost> out = result.$1;
_postCount = result.$2;
_posts.addAll(out);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
return;
} finally {
stopwatch.stop();
_lastTook = stopwatch.elapsed;
}
if (mounted) setState(() => _isBusy = false);
}
void _showAdvancedSearchTune() {
showModalBottomSheet(
context: context,
builder: (context) => Column(
children: [
PostTagsField(
labelText: 'fieldPostTags'.tr(),
initialTags: _searchTags,
onUpdate: (value) {
setState(() => _searchTags = value);
},
),
],
).padding(horizontal: 24, vertical: 16),
);
}
@override
Widget build(BuildContext context) {
const labelShadows = <Shadow>[
Shadow(
offset: Offset(1, 1),
blurRadius: 8.0,
color: Color.fromARGB(255, 0, 0, 0),
),
];
return Scaffold(
appBar: AppBar(
title: Text('screenPostSearch').tr(),
actions: [
IconButton(
icon: const Icon(Symbols.tune),
onPressed: _showAdvancedSearchTune,
),
const Gap(8),
],
),
body: Stack(
children: [
InfiniteList(
padding: const EdgeInsets.only(top: 100),
itemCount: _posts.length,
isLoading: _isBusy,
hasReachedMax: _postCount != null && _posts.length >= _postCount!,
onFetchData: () {
_fetchPosts();
},
itemBuilder: (context, idx) {
return GestureDetector(
child: PostItem(
data: _posts[idx],
maxWidth: 640,
onChanged: (data) {
setState(() => _posts[idx] = data);
},
onDeleted: () {
_posts.clear();
_fetchPosts();
},
),
onTap: () {
GoRouter.of(context).pushNamed(
'postDetail',
pathParameters: {'slug': _posts[idx].id.toString()},
extra: _posts[idx],
);
},
);
},
separatorBuilder: (context, index) => const Divider(height: 1),
),
Positioned(
top: 16,
left: 16,
right: 16,
child: Column(
children: [
SearchBar(
elevation: const WidgetStatePropertyAll(1),
leading: const Icon(Symbols.search),
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 24),
),
onChanged: (value) {
_searchTerm = value;
},
onSubmitted: (value) {
setState(() => _posts.clear());
_searchTerm = value;
_fetchPosts();
},
),
if (_lastTook != null)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Symbols.summarize,
color: Colors.white,
shadows: labelShadows,
size: 16,
),
const Gap(4),
Text(
'postSearchResult'.plural(_postCount ?? 0),
style: TextStyle(
color: Colors.white,
shadows: labelShadows,
fontSize: 13,
),
),
const Gap(8),
Icon(
Symbols.pace,
color: Colors.white,
shadows: labelShadows,
size: 16,
),
const Gap(4),
Text(
'postSearchTook'.tr(args: [
'${(_lastTook!.inMilliseconds / 1000).toStringAsFixed(3)}s',
]),
style: TextStyle(
color: Colors.white,
shadows: labelShadows,
fontSize: 13,
),
),
],
).padding(vertical: 8),
],
),
),
],
),
);
}
}

View File

@ -0,0 +1,489 @@
import 'dart:ui';
import 'dart:math' as math;
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:sliver_tools/sliver_tools.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/types/account.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/universal_image.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class PostPublisherScreen extends StatefulWidget {
final String name;
const PostPublisherScreen({super.key, required this.name});
@override
State<PostPublisherScreen> createState() => _PostPublisherScreenState();
}
class _PostPublisherScreenState extends State<PostPublisherScreen>
with SingleTickerProviderStateMixin {
late final ScrollController _scrollController = ScrollController();
late final TabController _tabController =
TabController(length: 3, vsync: this);
SnPublisher? _publisher;
SnAccount? _account;
Future<void> _fetchPublisher() async {
try {
final sn = context.read<SnNetworkProvider>();
final ud = context.read<UserDirectoryProvider>();
final resp = await sn.client.get('/cgi/co/publishers/${widget.name}');
if (!mounted) return;
_publisher = SnPublisher.fromJson(resp.data);
_account = await ud.getAccount(_publisher?.accountId);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err).then((_) {
if (mounted) Navigator.pop(context);
});
} finally {
setState(() {});
}
}
bool _isSubscribing = false;
SnSubscription? _subscription;
Future<void> _fetchSubscription() async {
try {
setState(() => _isSubscribing = true);
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get(
'/cgi/co/subscriptions/users/${_publisher!.id}',
);
if (!mounted) return;
_subscription = SnSubscription.fromJson(resp.data);
} catch (_) {
// ignore due to maybe 404
} finally {
setState(() => _isSubscribing = false);
}
}
Future<void> _toggleSubscription() async {
if (_subscription == null) {
try {
setState(() => _isSubscribing = true);
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.post(
'/cgi/co/subscriptions/users/${_publisher!.id}',
);
if (!mounted) return;
_subscription = SnSubscription.fromJson(resp.data);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isSubscribing = false);
}
} else {
try {
setState(() => _isSubscribing = true);
final sn = context.read<SnNetworkProvider>();
await sn.client.delete(
'/cgi/co/subscriptions/users/${_publisher!.id}',
);
if (!mounted) return;
_subscription = null;
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isSubscribing = false);
}
}
}
double _appBarBlur = 0.0;
late final _appBarWidth = MediaQuery.of(context).size.width;
late final _appBarHeight =
(_appBarWidth * kBannerAspectRatio).roundToDouble();
void _updateAppBarBlur() {
if (_scrollController.offset > _appBarHeight) return;
setState(() {
_appBarBlur =
(_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
});
}
bool _isBusy = false;
int? _postCount;
final List<SnPost> _posts = List.empty(growable: true);
Future<void> _fetchPosts() async {
if (_isBusy) return;
setState(() => _isBusy = true);
try {
final pt = context.read<SnPostContentProvider>();
final result = await pt.listPosts(
offset: _posts.length,
author: widget.name,
type: switch (_tabController.index) {
1 => 'story',
2 => 'article',
_ => null,
},
);
_postCount = result.$2;
_posts.addAll(result.$1);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
void _updateFetchType() {
_posts.clear();
_fetchPosts();
}
@override
void initState() {
super.initState();
_fetchPublisher().then((_) {
_fetchPosts();
_fetchSubscription();
});
_scrollController.addListener(_updateAppBarBlur);
_tabController.addListener(_updateFetchType);
}
@override
void dispose() {
_scrollController.removeListener(_updateAppBarBlur);
_scrollController.dispose();
_tabController.removeListener(_updateFetchType);
_tabController.dispose();
super.dispose();
}
static const kBannerAspectRatio = 7 / 16;
@override
Widget build(BuildContext context) {
final imageHeight = _appBarHeight + kToolbarHeight + 8;
const labelShadows = <Shadow>[
Shadow(
offset: Offset(1, 1),
blurRadius: 5.0,
color: Color.fromARGB(255, 0, 0, 0),
),
];
final sn = context.read<SnNetworkProvider>();
return Scaffold(
body: NestedScrollView(
controller: _scrollController,
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: MultiSliver(
children: [
SliverAppBar(
expandedHeight: _appBarHeight,
title: _publisher == null
? Text('loading').tr()
: RichText(
textAlign: TextAlign.center,
text: TextSpan(children: [
TextSpan(
text: _publisher!.nick,
style: Theme.of(context)
.textTheme
.titleLarge!
.copyWith(
color: Colors.white,
shadows: labelShadows,
),
),
const TextSpan(text: '\n'),
TextSpan(
text: '@${_publisher!.name}',
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(
color: Colors.white,
shadows: labelShadows,
),
),
]),
),
pinned: true,
flexibleSpace: _publisher != null
? Stack(
fit: StackFit.expand,
children: [
UniversalImage(
sn.getAttachmentUrl(_publisher!.banner),
fit: BoxFit.cover,
height: imageHeight,
width: _appBarWidth,
cacheHeight: imageHeight,
cacheWidth: _appBarWidth,
),
Positioned(
top: 0,
left: 0,
right: 0,
height: 56 + MediaQuery.of(context).padding.top,
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: _appBarBlur,
sigmaY: _appBarBlur,
),
child: Container(
color: Colors.black.withOpacity(
clampDouble(_appBarBlur * 0.1, 0, 0.5),
),
),
),
),
),
],
)
: null,
),
if (_publisher != null)
SliverToBoxAdapter(
child: Container(
constraints: const BoxConstraints(maxWidth: 640),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
AccountImage(
content: _publisher!.avatar,
radius: 28,
),
const Gap(16),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
_publisher!.nick,
style: Theme.of(context)
.textTheme
.titleMedium,
).bold(),
Text('@${_publisher!.name}').fontSize(13),
],
),
),
if (_subscription == null)
ElevatedButton.icon(
style: ButtonStyle(
elevation: WidgetStatePropertyAll(0),
),
onPressed: _isSubscribing
? null
: _toggleSubscription,
label: Text('subscribe').tr(),
icon: const Icon(Symbols.add),
)
else
OutlinedButton.icon(
style: ButtonStyle(
elevation: WidgetStatePropertyAll(0),
),
onPressed: _isSubscribing
? null
: _toggleSubscription,
label: Text('unsubscribe').tr(),
icon: const Icon(Symbols.remove),
),
],
).padding(right: 8),
const Gap(12),
Text(_publisher!.description)
.padding(horizontal: 8),
const Gap(12),
Column(
children: [
Row(
children: [
const Icon(Symbols.calendar_add_on),
const Gap(8),
Text('publisherJoinedAt').tr(args: [
DateFormat('y/M/d')
.format(_publisher!.createdAt)
]),
],
),
Row(
children: [
const Icon(Symbols.trending_up),
const Gap(8),
Text('publisherSocialPointTotal').plural(
_publisher!.totalUpvote -
_publisher!.totalDownvote,
),
],
),
Row(
children: [
const Icon(Symbols.tools_wrench),
const Gap(8),
InkWell(
child: Text('publisherRunBy').tr(args: [
'@${_account?.name ?? 'unknown'}',
]),
onTap: () {
GoRouter.of(context).pushNamed(
'accountProfilePage',
pathParameters: {
'name': _account!.name,
},
);
},
),
const Gap(8),
AccountImage(
content: _account?.avatar, radius: 8),
],
),
],
).padding(horizontal: 8),
],
).padding(all: 16),
).center(),
),
SliverToBoxAdapter(child: const Divider(height: 1)),
TabBar(
controller: _tabController,
tabs: [
Tab(
icon: Icon(
Symbols.pages,
color: Theme.of(context).colorScheme.onSurface,
),
),
Tab(
icon: Icon(
Symbols.sticky_note_2,
color: Theme.of(context).colorScheme.onSurface,
),
),
Tab(
icon: Icon(
Symbols.article,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
SliverToBoxAdapter(child: const Divider(height: 1)),
],
),
),
];
},
body: Column(
children: [
Gap(math.max(MediaQuery.of(context).padding.top, 64)),
Expanded(
child: TabBarView(
controller: _tabController,
children: List.filled(
3,
_PublisherPostList(
isBusy: _isBusy,
postCount: _postCount,
posts: _posts,
fetchPosts: _fetchPosts,
onChanged: (idx, data) {
setState(() => _posts[idx] = data);
},
onDeleted: () {
_posts.clear();
_fetchPosts();
},
),
),
),
),
],
),
),
);
}
}
class _PublisherPostList extends StatelessWidget {
final bool isBusy;
final int? postCount;
final List<SnPost> posts;
final void Function() fetchPosts;
final void Function(int index, SnPost data) onChanged;
final void Function() onDeleted;
const _PublisherPostList({
super.key,
required this.isBusy,
required this.postCount,
required this.posts,
required this.fetchPosts,
required this.onChanged,
required this.onDeleted,
});
@override
Widget build(BuildContext context) {
return InfiniteList(
itemCount: posts.length,
isLoading: isBusy,
hasReachedMax: postCount != null && posts.length >= postCount!,
onFetchData: fetchPosts,
itemBuilder: (context, idx) {
return GestureDetector(
child: PostItem(
data: posts[idx],
maxWidth: 640,
onChanged: (data) {
onChanged(idx, data);
},
onDeleted: onDeleted,
),
onTap: () {
GoRouter.of(context).pushNamed(
'postDetail',
pathParameters: {'slug': posts[idx].id.toString()},
extra: posts[idx],
);
},
);
},
separatorBuilder: (context, index) => const Divider(height: 1),
);
}
}

256
lib/screens/realm.dart Normal file
View File

@ -0,0 +1,256 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/realm.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/universal_image.dart';
import '../providers/userinfo.dart';
import '../widgets/unauthorized_hint.dart';
class RealmScreen extends StatefulWidget {
const RealmScreen({super.key});
@override
State<RealmScreen> createState() => _RealmScreenState();
}
class _RealmScreenState extends State<RealmScreen> {
bool _isBusy = false;
bool _isCompactView = false;
List<SnRealm>? _realms;
Future<void> _fetchRealms() async {
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) return;
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/realms/me/available');
_realms = List<SnRealm>.from(
resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
);
} catch (err) {
if (mounted) context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _deleteRealm(SnRealm realm) async {
final confirm = await context.showConfirmDialog(
'realmDelete'.tr(args: ['#${realm.alias}']),
'realmDeleteDescription'.tr(),
);
if (!confirm) return;
if (!mounted) return;
final sn = context.read<SnNetworkProvider>();
setState(() => _isBusy = true);
try {
await sn.client.delete('/cgi/id/realms/${realm.alias}');
if (!mounted) return;
context.showSnackbar('realmDeleted'.tr(args: ['#${realm.alias}']));
_fetchRealms();
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchRealms();
}
@override
Widget build(BuildContext context) {
final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) {
return Scaffold(
appBar: AppBar(
leading: AutoAppBarLeading(),
title: Text('screenRealm').tr(),
),
body: Center(
child: UnauthorizedHint(),
),
);
}
return Scaffold(
appBar: AppBar(
leading: AutoAppBarLeading(),
title: Text('screenRealm').tr(),
actions: [
IconButton(
icon: !_isCompactView
? const Icon(Symbols.view_list)
: const Icon(Symbols.view_module),
onPressed: () {
setState(() => _isCompactView = !_isCompactView);
},
),
const Gap(8),
],
),
floatingActionButton: FloatingActionButton(
child: const Icon(Symbols.group_add),
onPressed: () {
GoRouter.of(context).pushNamed('realmManage');
},
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
Expanded(
child: RefreshIndicator(
onRefresh: _fetchRealms,
child: ListView.builder(
itemCount: _realms?.length ?? 0,
itemBuilder: (context, idx) {
final realm = _realms![idx];
if (_isCompactView) {
return ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 16),
leading: AccountImage(
content: realm.avatar,
fallbackWidget: const Icon(Symbols.group, size: 20),
),
title: Text(realm.name),
subtitle: Text(
realm.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: PopupMenuButton(
itemBuilder: (BuildContext context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.edit),
const Gap(16),
Text('edit').tr(),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'realmManage',
queryParameters: {'editing': realm.alias},
).then((value) {
if (value != null) {
_fetchRealms();
}
});
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.delete),
const Gap(16),
Text('delete').tr(),
],
),
onTap: () {
_deleteRealm(realm);
},
),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'realmDetail',
pathParameters: {'alias': realm.alias},
);
},
);
}
return Container(
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: [
Container(
color: Theme.of(context)
.colorScheme
.surfaceContainer,
child: (realm.banner?.isEmpty ?? true)
? const SizedBox.shrink()
: AutoResizeUniversalImage(
sn.getAttachmentUrl(realm.banner!),
fit: BoxFit.cover,
),
),
Positioned(
bottom: -30,
left: 18,
child: AccountImage(
content: realm.avatar,
radius: 24,
fallbackWidget:
const Icon(Symbols.group, size: 24),
),
),
],
),
),
const Gap(20 + 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(realm.name).textStyle(
Theme.of(context).textTheme.titleMedium!),
Text(realm.description).textStyle(
Theme.of(context).textTheme.bodySmall!),
],
).padding(horizontal: 24, bottom: 14),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'realmDetail',
pathParameters: {'alias': realm.alias},
);
},
),
),
).center();
},
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,312 @@
import 'dart:io';
import 'dart:ui';
import 'package:croppy/croppy.dart';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:image_picker/image_picker.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' show basename;
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.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/universal_image.dart';
import 'package:uuid/uuid.dart';
class RealmManageScreen extends StatefulWidget {
final String? editingRealmAlias;
const RealmManageScreen({super.key, this.editingRealmAlias});
@override
State<RealmManageScreen> createState() => _RealmManageScreenState();
}
class _RealmManageScreenState extends State<RealmManageScreen> {
bool _isBusy = false;
SnRealm? _editingRealm;
Future<void> _fetchRealm() async {
final sn = context.read<SnNetworkProvider>();
setState(() => _isBusy = true);
try {
final resp =
await sn.client.get('/cgi/id/realms/${widget.editingRealmAlias}');
final out = SnRealm.fromJson(resp.data);
_editingRealm = out;
_avatar = out.avatar;
_banner = out.banner;
_aliasController.text = out.alias;
_nameController.text = out.name;
_descriptionController.text = out.description;
} catch (err) {
// ignore: use_build_context_synchronously
if (context.mounted) context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
String? _avatar;
String? _banner;
final _aliasController = TextEditingController();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
final _imagePicker = ImagePicker();
Future<void> _updateImage(String place) async {
final image = await _imagePicker.pickImage(source: ImageSource.gallery);
if (image == null) return;
if (!mounted) return;
final ImageProvider imageProvider =
kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
final aspectRatios = place == 'banner'
? [CropAspectRatio(width: 16, height: 7)]
: [CropAspectRatio(width: 1, height: 1)];
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
)
: await showMaterialImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
);
if (result == null) return;
if (!mounted) return;
final attach = context.read<SnAttachmentProvider>();
setState(() => _isBusy = true);
final rawBytes =
(await result.uiImage.toByteData(format: ImageByteFormat.png))!
.buffer
.asUint8List();
try {
final attachment = await attach.directUploadOne(
rawBytes,
basename(image.path),
'avatar',
null,
mimetype: 'image/png',
);
switch (place) {
case 'avatar':
_avatar = attachment.rid;
break;
case 'banner':
_banner = attachment.rid;
break;
}
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _performAction() async {
final uuid = const Uuid();
final payload = {
'alias': _aliasController.text.isNotEmpty
? _aliasController.text.toLowerCase()
: uuid.v4().replaceAll('-', '').substring(0, 12),
'name': _nameController.text,
'description': _descriptionController.text,
'avatar': _avatar,
'banner': _banner,
};
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.request(
widget.editingRealmAlias != null
? '/cgi/id/realms/${widget.editingRealmAlias}'
: '/cgi/id/realms',
data: payload,
options: Options(
method: widget.editingRealmAlias != null ? 'PUT' : 'POST',
),
);
final out = SnRealm.fromJson(resp.data);
// ignore: use_build_context_synchronously
if (context.mounted) Navigator.pop(context, out);
} catch (err) {
// ignore: use_build_context_synchronously
if (context.mounted) context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
if (widget.editingRealmAlias != null) _fetchRealm();
}
@override
void dispose() {
_aliasController.dispose();
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final sn = context.read<SnNetworkProvider>();
return Scaffold(
appBar: AppBar(
title: widget.editingRealmAlias != null
? Text('screenRealmManage').tr()
: Text('screenRealmNew').tr(),
),
body: SingleChildScrollView(
child: Column(
children: [
LoadingIndicator(isActive: _isBusy),
if (_editingRealm != null)
MaterialBanner(
leading: const Icon(Symbols.edit),
leadingPadding: const EdgeInsets.only(left: 10, right: 20),
dividerColor: Colors.transparent,
content: Text(
'realmEditingNotice'.tr(args: ['#${_editingRealm!.alias}']),
),
actions: [
TextButton(
child: Text('cancel').tr(),
onPressed: () {
Navigator.pop(context);
},
),
],
),
const Gap(24),
Stack(
clipBehavior: Clip.none,
children: [
Material(
elevation: 0,
child: InkWell(
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Container(
color: Theme.of(context)
.colorScheme
.surfaceContainerHigh,
child: _banner != null
? 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,
fallbackWidget: const Icon(Symbols.group, size: 40),
),
onTap: () {
_updateImage('avatar');
},
),
),
),
],
).padding(horizontal: 24),
const Gap(8 + 28),
Column(
children: [
TextField(
controller: _aliasController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldRealmAlias'.tr(),
helperText: 'fieldRealmAliasHint'.tr(),
helperMaxLines: 2,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
TextField(
controller: _nameController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldRealmName'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
TextField(
controller: _descriptionController,
maxLines: null,
minLines: 3,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldRealmDescription'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ElevatedButton.icon(
onPressed: _isBusy ? null : _performAction,
icon: const Icon(Symbols.save),
label: Text('apply').tr(),
),
],
),
],
).padding(horizontal: 24 + 8),
],
),
),
);
}
}

View File

@ -0,0 +1,414 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/realm.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class RealmDetailScreen extends StatefulWidget {
final String alias;
const RealmDetailScreen({super.key, required this.alias});
@override
State<RealmDetailScreen> createState() => _RealmDetailScreenState();
}
class _RealmDetailScreenState extends State<RealmDetailScreen> {
SnRealm? _realm;
Future<void> _fetchRealm() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/realms/${widget.alias}');
_realm = SnRealm.fromJson(resp.data);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() {});
}
}
@override
void initState() {
super.initState();
_fetchRealm();
}
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
body: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
// These are the slivers that show up in the "outer" scroll view.
return <Widget>[
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),
sliver: SliverAppBar(
title: Text(_realm?.name ?? 'loading'.tr()),
bottom: TabBar(
tabs: [
Tab(icon: const Icon(Symbols.home)),
Tab(icon: const Icon(Symbols.group)),
Tab(icon: const Icon(Symbols.settings)),
],
),
),
),
];
},
body: TabBarView(
children: [
_RealmDetailHomeWidget(realm: _realm),
_RealmMemberListWidget(realm: _realm),
_RealmSettingsWidget(
realm: _realm,
onUpdate: () {
_fetchRealm();
},
),
],
),
),
),
);
}
}
class _RealmDetailHomeWidget extends StatelessWidget {
final SnRealm? realm;
const _RealmDetailHomeWidget({super.key, required this.realm});
@override
Widget build(BuildContext context) {
return Column(
children: [
const Gap(24),
if (realm != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
realm!.name,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
realm!.description,
style: Theme.of(context).textTheme.bodyMedium,
),
],
).padding(horizontal: 24),
const Gap(16),
const Divider(),
],
);
}
}
class _RealmMemberListWidget extends StatefulWidget {
final SnRealm? realm;
const _RealmMemberListWidget({super.key, this.realm});
@override
State<_RealmMemberListWidget> createState() => _RealmMemberListWidgetState();
}
class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
bool _isBusy = false;
int? _totalCount;
final List<SnRealmMember> _members = List.empty(growable: true);
Future<void> _fetchMembers() async {
setState(() => _isBusy = true);
try {
final ud = context.read<UserDirectoryProvider>();
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get(
'/cgi/id/realms/${widget.realm!.alias}/members',
queryParameters: {
'take': 10,
'offset': 0,
});
final out = List<SnRealmMember>.from(
resp.data['data']?.map((e) => SnRealmMember.fromJson(e)) ?? [],
);
await ud.listAccount(out.map((ele) => ele.accountId).toSet());
_totalCount = resp.data['count'];
_members.addAll(out);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
bool _isUpdating = false;
Future<void> _deleteMember(SnRealmMember member) async {
if (_isUpdating) return;
setState(() => _isUpdating = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.delete(
'/cgi/id/realms/${widget.realm!.alias}/members/${member.id}',
);
if (!mounted) return;
_members.clear();
_fetchMembers();
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isUpdating = false);
}
}
void _showMemberAdd() {
showModalBottomSheet(
context: context,
builder: (context) => _NewRealmMemberWidget(
realm: widget.realm!,
),
);
}
@override
void initState() {
super.initState();
_fetchMembers();
}
@override
Widget build(BuildContext context) {
final ud = context.read<UserDirectoryProvider>();
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.group_add),
trailing: const Icon(Symbols.chevron_right),
title: Text('realmMemberAdd').tr(),
subtitle: Text('realmMemberAddDescription').tr(),
onTap: _showMemberAdd,
),
),
SliverToBoxAdapter(child: const Divider(height: 1)),
SliverInfiniteList(
// padding: EdgeInsets.zero,
itemCount: _members.length,
isLoading: _isBusy,
hasReachedMax: _totalCount != null && _members.length >= _totalCount!,
onFetchData: _fetchMembers,
itemBuilder: (context, index) {
final member = _members[index];
return ListTile(
contentPadding: const EdgeInsets.only(right: 24, left: 16),
leading: AccountImage(
content: ud.getAccountFromCache(member.accountId)?.avatar,
fallbackWidget: const Icon(Symbols.group, size: 24),
),
title: Text(
ud.getAccountFromCache(member.accountId)?.nick ??
'unknown'.tr(),
),
subtitle: Text(
ud.getAccountFromCache(member.accountId)?.name ??
'unknown'.tr(),
),
trailing: IconButton(
icon: const Icon(Symbols.person_remove),
onPressed: _isUpdating ? null : () => _deleteMember(member),
),
);
},
),
],
);
}
}
class _NewRealmMemberWidget extends StatefulWidget {
final SnRealm realm;
const _NewRealmMemberWidget({super.key, 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 {
final SnRealm? realm;
final Function() onUpdate;
const _RealmSettingsWidget(
{super.key, required this.realm, required this.onUpdate});
@override
State<_RealmSettingsWidget> createState() => _RealmSettingsWidgetState();
}
class _RealmSettingsWidgetState extends State<_RealmSettingsWidget> {
bool _isBusy = false;
Future<void> _deleteRealm() async {
final confirm = await context.showConfirmDialog(
'realmDelete'.tr(args: ['#${widget.realm!.alias}']),
'realmDeleteDescription'.tr(),
);
if (!confirm) return;
if (!mounted) return;
final sn = context.read<SnNetworkProvider>();
setState(() => _isBusy = true);
try {
await sn.client.delete('/cgi/id/realms/${widget.realm!.alias}');
if (!mounted) return;
Navigator.pop(context, true);
context.showSnackbar('realmDeleted'.tr(args: [
'#${widget.realm!.alias}',
]));
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
Widget build(BuildContext context) {
final ua = context.read<UserProvider>();
final isOwned = ua.isAuthorized && widget.realm?.accountId == ua.user?.id;
return Column(
children: [
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)
ListTile(
leading: const Icon(Symbols.delete),
trailing: const Icon(Symbols.chevron_right),
title: Text('realmActionDelete').tr(),
subtitle: Text('realmActionDeleteDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: _isBusy ? null : _deleteRealm,
),
],
);
}
}

View File

@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path_provider/path_provider.dart';
@ -41,8 +42,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
SharedPreferences.getInstance().then((prefs) {
setState(() {
_prefs = prefs;
_serverUrlController.text =
prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
_serverUrlController.text = prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
});
});
}
@ -65,11 +65,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('settingsAppearance')
.bold()
.fontSize(17)
.tr()
.padding(horizontal: 20, bottom: 4),
Text('settingsAppearance').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
if (!kIsWeb)
ListTile(
title: Text('settingsBackgroundImage').tr(),
@ -78,20 +74,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
leading: const Icon(Symbols.image),
trailing: const Icon(Symbols.chevron_right),
onTap: () async {
final image = await ImagePicker()
.pickImage(source: ImageSource.gallery);
final image = await ImagePicker().pickImage(source: ImageSource.gallery);
if (image == null) return;
await File(image.path)
.copy('$_docBasepath/app_background_image');
await File(image.path).copy('$_docBasepath/app_background_image');
_prefs?.setBool('has_background_image', true);
setState(() {});
},
),
if (!kIsWeb)
FutureBuilder<bool>(
future:
File('$_docBasepath/app_background_image').exists(),
future: File('$_docBasepath/app_background_image').exists(),
builder: (context, snapshot) {
if (!snapshot.hasData || !snapshot.data!) {
return const SizedBox.shrink();
@ -99,16 +93,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
return ListTile(
title: Text('settingsBackgroundImageClear').tr(),
subtitle:
Text('settingsBackgroundImageClearDescription')
.tr(),
contentPadding:
const EdgeInsets.symmetric(horizontal: 24),
subtitle: Text('settingsBackgroundImageClearDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.texture),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
File('$_docBasepath/app_background_image')
.deleteSync();
File('$_docBasepath/app_background_image').deleteSync();
_prefs?.remove('has_background_image');
setState(() {});
},
);
@ -136,11 +127,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('settingsNetwork')
.bold()
.fontSize(17)
.tr()
.padding(horizontal: 20, bottom: 4),
Text('settingsNetwork').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
TextField(
controller: _serverUrlController,
decoration: InputDecoration(
@ -161,8 +148,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 16, top: 8, bottom: 4),
ListTile(
title: Text('settingsNetworkServerPreset').tr(),
@ -174,9 +160,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
isExpanded: true,
items: [
...kNetworkServerDirectory,
if (!kNetworkServerDirectory
.map((ele) => ele.$2)
.contains(_serverUrlController.text))
if (!kNetworkServerDirectory.map((ele) => ele.$2).contains(_serverUrlController.text))
('Custom', _serverUrlController.text),
]
.map(
@ -188,8 +172,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.$1).fontSize(14),
Text(item.$2, overflow: TextOverflow.ellipsis)
.fontSize(11)
Text(item.$2, overflow: TextOverflow.ellipsis).fontSize(11)
],
),
),
@ -209,7 +192,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
vertical: 5,
),
height: 40,
width: 140,
width: 160,
),
menuItemStyleData: const MenuItemStyleData(
height: 60,
@ -232,6 +215,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('settingsMisc').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
ListTile(
title: Text('settingsMiscAbout').tr(),
subtitle: Text('settingsMiscAboutDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.info),
trailing: const Icon(Symbols.chevron_right),
onTap: () async {
GoRouter.of(context).pushNamed('about');
},
),
],
),
].expand((ele) => [ele, const Gap(16)]).toList(),
).padding(vertical: 20),
),

View File

@ -28,9 +28,10 @@ Future<ThemeData> createAppTheme(
brightness: brightness,
);
final hasBackground = prefs.getBool('has_background_image') ?? false;
return ThemeData(
useMaterial3:
useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? false),
useMaterial3: useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? false),
colorScheme: colorScheme,
brightness: brightness,
iconTheme: IconThemeData(
@ -39,6 +40,11 @@ Future<ThemeData> createAppTheme(
opticalSize: 20,
color: colorScheme.onSurface,
),
appBarTheme: AppBarTheme(
centerTitle: true,
backgroundColor: hasBackground ? colorScheme.primary.withOpacity(0.75) : colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
),
scaffoldBackgroundColor: Colors.transparent,
);
}

View File

@ -1,29 +1,33 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hive_flutter/hive_flutter.dart';
part 'account.freezed.dart';
part 'account.g.dart';
@freezed
class SnAccount with _$SnAccount {
const SnAccount._();
const factory SnAccount({
required int id,
required int? affiliatedId,
required int? affiliatedTo,
required int? automatedBy,
required int? automatedId,
@HiveField(0) required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required DateTime? confirmedAt,
required List<SnAccountContact>? contacts,
required String avatar,
required String banner,
required DateTime? confirmedAt,
required List<SnAccountContact> contacts,
required DateTime createdAt,
required DateTime? deletedAt,
required String description,
required String name,
required String nick,
required Map<String, dynamic> permNodes,
required SnAccountProfile? profile,
@Default([]) List<SnAccountBadge> badges,
required DateTime? suspendedAt,
required DateTime updatedAt,
required int? affiliatedId,
required int? affiliatedTo,
required int? automatedBy,
required int? automatedId,
}) = _SnAccount;
factory SnAccount.fromJson(Map<String, Object?> json) =>
@ -67,3 +71,68 @@ class SnAccountProfile with _$SnAccountProfile {
factory SnAccountProfile.fromJson(Map<String, Object?> json) =>
_$SnAccountProfileFromJson(json);
}
@freezed
class SnRelationship with _$SnRelationship {
const factory SnRelationship({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required int accountId,
required int relatedId,
required SnAccount? account,
required SnAccount? related,
required int status,
@Default({}) Map<String, dynamic> permNodes,
}) = _SnRelationship;
factory SnRelationship.fromJson(Map<String, Object?> json) =>
_$SnRelationshipFromJson(json);
}
@freezed
class SnAccountBadge with _$SnAccountBadge {
const factory SnAccountBadge({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required dynamic deletedAt,
required String type,
required int accountId,
@Default({}) Map<String, dynamic> metadata,
}) = _SnAccountBadge;
factory SnAccountBadge.fromJson(Map<String, Object?> json) =>
_$SnAccountBadgeFromJson(json);
}
@freezed
class SnAccountStatusInfo with _$SnAccountStatusInfo {
const factory SnAccountStatusInfo({
required bool isDisturbable,
required bool isOnline,
required DateTime? lastSeenAt,
required dynamic status,
}) = _SnAccountStatusInfo;
factory SnAccountStatusInfo.fromJson(Map<String, Object?> json) =>
_$SnAccountStatusInfoFromJson(json);
}
@freezed
class SnAbuseReport with _$SnAbuseReport {
const factory SnAbuseReport({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required String resource,
required String reason,
required String status,
required int accountId,
}) = _SnAbuseReport;
factory SnAbuseReport.fromJson(Map<String, Object?> json) =>
_$SnAbuseReportFromJson(json);
}

File diff suppressed because it is too large Load Diff

View File

@ -9,22 +9,19 @@ part of 'account.dart';
_$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
_$SnAccountImpl(
id: (json['id'] as num).toInt(),
affiliatedId: (json['affiliated_id'] as num?)?.toInt(),
affiliatedTo: (json['affiliated_to'] as num?)?.toInt(),
automatedBy: (json['automated_by'] as num?)?.toInt(),
automatedId: (json['automated_id'] as num?)?.toInt(),
avatar: json['avatar'] as String,
banner: json['banner'] as String,
confirmedAt: json['confirmed_at'] == null
? null
: DateTime.parse(json['confirmed_at'] as String),
contacts: (json['contacts'] as List<dynamic>)
.map((e) => SnAccountContact.fromJson(e as Map<String, dynamic>))
.toList(),
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),
confirmedAt: json['confirmed_at'] == null
? null
: DateTime.parse(json['confirmed_at'] as String),
contacts: (json['contacts'] as List<dynamic>?)
?.map((e) => SnAccountContact.fromJson(e as Map<String, dynamic>))
.toList(),
avatar: json['avatar'] as String,
banner: json['banner'] as String,
description: json['description'] as String,
name: json['name'] as String,
nick: json['nick'] as String,
@ -32,32 +29,40 @@ _$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
profile: json['profile'] == null
? null
: SnAccountProfile.fromJson(json['profile'] as Map<String, dynamic>),
badges: (json['badges'] as List<dynamic>?)
?.map((e) => SnAccountBadge.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
suspendedAt: json['suspended_at'] == null
? null
: DateTime.parse(json['suspended_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
affiliatedId: (json['affiliated_id'] as num?)?.toInt(),
affiliatedTo: (json['affiliated_to'] as num?)?.toInt(),
automatedBy: (json['automated_by'] as num?)?.toInt(),
automatedId: (json['automated_id'] as num?)?.toInt(),
);
Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) =>
<String, dynamic>{
'id': instance.id,
'affiliated_id': instance.affiliatedId,
'affiliated_to': instance.affiliatedTo,
'automated_by': instance.automatedBy,
'automated_id': instance.automatedId,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'confirmed_at': instance.confirmedAt?.toIso8601String(),
'contacts': instance.contacts?.map((e) => e.toJson()).toList(),
'avatar': instance.avatar,
'banner': instance.banner,
'confirmed_at': instance.confirmedAt?.toIso8601String(),
'contacts': instance.contacts.map((e) => e.toJson()).toList(),
'created_at': instance.createdAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'description': instance.description,
'name': instance.name,
'nick': instance.nick,
'perm_nodes': instance.permNodes,
'profile': instance.profile?.toJson(),
'badges': instance.badges.map((e) => e.toJson()).toList(),
'suspended_at': instance.suspendedAt?.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'affiliated_id': instance.affiliatedId,
'affiliated_to': instance.affiliatedTo,
'automated_by': instance.automatedBy,
'automated_id': instance.automatedId,
};
_$SnAccountContactImpl _$$SnAccountContactImplFromJson(
@ -129,3 +134,107 @@ Map<String, dynamic> _$$SnAccountProfileImplToJson(
'last_seen_at': instance.lastSeenAt?.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
};
_$SnRelationshipImpl _$$SnRelationshipImplFromJson(Map<String, dynamic> json) =>
_$SnRelationshipImpl(
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),
accountId: (json['account_id'] as num).toInt(),
relatedId: (json['related_id'] as num).toInt(),
account: json['account'] == null
? null
: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
related: json['related'] == null
? null
: SnAccount.fromJson(json['related'] as Map<String, dynamic>),
status: (json['status'] as num).toInt(),
permNodes: json['perm_nodes'] as Map<String, dynamic>? ?? const {},
);
Map<String, dynamic> _$$SnRelationshipImplToJson(
_$SnRelationshipImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'account_id': instance.accountId,
'related_id': instance.relatedId,
'account': instance.account?.toJson(),
'related': instance.related?.toJson(),
'status': instance.status,
'perm_nodes': instance.permNodes,
};
_$SnAccountBadgeImpl _$$SnAccountBadgeImplFromJson(Map<String, dynamic> json) =>
_$SnAccountBadgeImpl(
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'],
type: json['type'] as String,
accountId: (json['account_id'] as num).toInt(),
metadata: json['metadata'] as Map<String, dynamic>? ?? const {},
);
Map<String, dynamic> _$$SnAccountBadgeImplToJson(
_$SnAccountBadgeImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt,
'type': instance.type,
'account_id': instance.accountId,
'metadata': instance.metadata,
};
_$SnAccountStatusInfoImpl _$$SnAccountStatusInfoImplFromJson(
Map<String, dynamic> json) =>
_$SnAccountStatusInfoImpl(
isDisturbable: json['is_disturbable'] as bool,
isOnline: json['is_online'] as bool,
lastSeenAt: json['last_seen_at'] == null
? null
: DateTime.parse(json['last_seen_at'] as String),
status: json['status'],
);
Map<String, dynamic> _$$SnAccountStatusInfoImplToJson(
_$SnAccountStatusInfoImpl instance) =>
<String, dynamic>{
'is_disturbable': instance.isDisturbable,
'is_online': instance.isOnline,
'last_seen_at': instance.lastSeenAt?.toIso8601String(),
'status': instance.status,
};
_$SnAbuseReportImpl _$$SnAbuseReportImplFromJson(Map<String, dynamic> json) =>
_$SnAbuseReportImpl(
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),
resource: json['resource'] as String,
reason: json['reason'] as String,
status: json['status'] as String,
accountId: (json['account_id'] as num).toInt(),
);
Map<String, dynamic> _$$SnAbuseReportImplToJson(_$SnAbuseReportImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'resource': instance.resource,
'reason': instance.reason,
'status': instance.status,
'account_id': instance.accountId,
};

143
lib/types/chat.dart Normal file
View File

@ -0,0 +1,143 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:surface/types/account.dart';
import 'package:surface/types/attachment.dart';
import 'package:surface/types/realm.dart';
part 'chat.freezed.dart';
part 'chat.g.dart';
@freezed
class SnChannel with _$SnChannel {
const SnChannel._();
@HiveType(typeId: 2)
const factory SnChannel({
@HiveField(0) required int id,
@HiveField(1) required DateTime createdAt,
@HiveField(2) required DateTime updatedAt,
@HiveField(3) required dynamic deletedAt,
@HiveField(4) required String alias,
@HiveField(5) required String name,
@HiveField(6) required String description,
@HiveField(7) required List<SnChannelMember>? members,
List<SnChatMessage>? messages,
@HiveField(8) required int type,
@HiveField(9) required int accountId,
@HiveField(10) required SnRealm? realm,
@HiveField(11) required int? realmId,
@HiveField(12) required bool isPublic,
@HiveField(13) required bool isCommunity,
}) = _SnChannel;
factory SnChannel.fromJson(Map<String, dynamic> json) =>
_$SnChannelFromJson(json);
String get key => '${realm?.alias ?? 'global'}:$alias';
String get keyPath => '${realm?.alias ?? 'global'}/$alias';
}
@freezed
class SnChannelMember with _$SnChannelMember {
const SnChannelMember._();
@HiveType(typeId: 3)
const factory SnChannelMember({
@HiveField(0) required int id,
@HiveField(1) required DateTime createdAt,
@HiveField(2) required DateTime updatedAt,
@HiveField(3) required DateTime? deletedAt,
@HiveField(4) required int channelId,
@HiveField(5) required int accountId,
@HiveField(6) required String? nick,
@HiveField(7) required SnChannel? channel,
@HiveField(8) required SnAccount? account,
@Default(0) int notify,
@HiveField(9) required int powerLevel,
dynamic calls,
dynamic events,
}) = _SnChannelMember;
factory SnChannelMember.fromJson(Map<String, dynamic> json) =>
_$SnChannelMemberFromJson(json);
}
@freezed
class SnChatMessage with _$SnChatMessage {
const SnChatMessage._();
@HiveType(typeId: 4)
const factory SnChatMessage({
@HiveField(0) required int id,
@HiveField(1) required DateTime createdAt,
@HiveField(2) required DateTime updatedAt,
@HiveField(3) required DateTime? deletedAt,
@HiveField(4) required String uuid,
@HiveField(5) @Default({}) Map<String, dynamic> body,
@HiveField(6) required String type,
@HiveField(7) required SnChannel channel,
@HiveField(8) required SnChannelMember sender,
@HiveField(9) required int channelId,
@HiveField(10) required int senderId,
@HiveField(11) required int? quoteEventId,
@HiveField(12) required int? relatedEventId,
SnChatMessagePreload? preload,
}) = _SnChatMessage;
factory SnChatMessage.fromJson(Map<String, dynamic> json) =>
_$SnChatMessageFromJson(json);
}
@freezed
class SnChatMessagePreload with _$SnChatMessagePreload {
const SnChatMessagePreload._();
const factory SnChatMessagePreload({
List<SnAttachment?>? attachments,
SnChatMessage? quoteEvent,
}) = _SnChatMessagePreload;
factory SnChatMessagePreload.fromJson(Map<String, dynamic> json) =>
_$SnChatMessagePreloadFromJson(json);
}
@freezed
class SnChatCall with _$SnChatCall {
const factory SnChatCall({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required DateTime? endedAt,
required String externalId,
required int founderId,
required int channelId,
required SnChannelMember founder,
@Default([]) List<dynamic> participants,
}) = _SnChatCall;
factory SnChatCall.fromJson(Map<String, dynamic> json) =>
_$SnChatCallFromJson(json);
}
// Call stuff
enum ParticipantStatsType {
unknown,
localAudioSender,
localVideoSender,
remoteAudioReceiver,
remoteVideoReceiver,
}
class ParticipantTrack {
ParticipantTrack(
{required this.participant,
required this.videoTrack,
required this.isScreenShare});
VideoTrack? videoTrack;
Participant participant;
bool isScreenShare;
}

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