Compare commits

...

154 Commits

Author SHA1 Message Date
63ff6df93a 🐛 Fix android build failed 2025-04-07 23:59:38 +08:00
f95eadd3e6 🐛 Fix bugs 2025-04-07 20:56:34 +08:00
9a8e40b288 🚀 Launch 2.5.2+92 2025-04-07 00:53:30 +08:00
cb0986efee 🐛 Bug fixes on live stream post 2025-04-07 00:49:43 +08:00
ce3d19fb7b Live video 2025-04-07 00:01:36 +08:00
935cf774b1 Create attachment with referenced link 2025-04-06 23:03:01 +08:00
aa50561247 🐛 Fix bugs 2025-04-06 20:29:08 +08:00
7501139d4c 🐛 Update the meet room naming 2025-04-06 14:53:42 +08:00
33fc7b287e ♻️ Refactored news, mixed feed and call 2025-04-06 14:43:23 +08:00
5c9569ef36 ♻️ Replace livekit with jitsi in calling 2025-04-06 01:48:36 +08:00
48f40099f4 👽 Support new mixed feed 2025-04-06 01:20:55 +08:00
151f917b07 🐛 Fix login did not load user data 2025-04-05 23:44:11 +08:00
cead09f3aa Editable content rating 2025-04-05 16:11:09 +08:00
aed7c61ba0 🐛 Fix android screenshot share issue 2025-04-05 15:51:44 +08:00
9d685fa0d9 ♻️ Refactored attachment meta editor 2025-04-05 14:41:11 +08:00
60afc96da2 🐛 Fix loading other type of attachments missing authorization header 2025-04-04 00:56:26 +08:00
b5155ebc5f ♻️ New album 2025-04-03 00:44:34 +08:00
ed1b75bacf 🐛 Fix user did not refresh since login 2025-04-03 00:25:32 +08:00
f311c1898c 🐛 Fix captcha on web 2025-04-03 00:14:11 +08:00
4c9f3e799b 🐛 Fix attachment download the compressed version 2025-04-02 00:59:14 +08:00
e645db1630 🚀 Launch 2.4.2+89 Spring Boot Patch 2025-04-02 00:56:58 +08:00
d5cf2478d8 💄 Use bottom modal sheet instead of popover
 Show strike on user profile page
2025-04-02 00:52:03 +08:00
cf34a285b4 🐛 Fix attachments can't be zoom 2025-04-02 00:31:00 +08:00
a75083d916 ♻️ Improve the attachment item gesture 2025-04-01 23:48:45 +08:00
919ff5e464 💄 Optimized unread indicator 2025-04-01 22:40:43 +08:00
00863b94e8 🐛 Fix remain bugs 2025-04-01 22:36:06 +08:00
1ad42e6505 🚀 Launch 2.4.2+88 Hotfix 2025-04-01 00:46:53 +08:00
1cec1bf82e 🐛 Fix bugs 2025-04-01 00:46:46 +08:00
a4ecf30c5b 📝 Update docs 2025-04-01 00:26:52 +08:00
5da7ccc8ef 🐛 Fix getFeed isn't using the ListPostV2 2025-04-01 00:14:12 +08:00
b5f42863ce ⬆️ Upgrade deps 2025-03-31 23:34:16 +08:00
69d5e95565 🚀 Launch 2.4.2+86 March Update 2025-03-31 23:05:36 +08:00
3e3442fc89 💄 Enable new sfx on special days 2025-03-31 23:00:13 +08:00
8181010b0b 💄 New desktop loading animation 2025-03-31 22:50:08 +08:00
269caf7555 💄 Some improvements
🐛 Bug fixes
 The heart reaction
2025-03-31 01:27:45 +08:00
ae0809ad35 💄 Optimize background color 2025-03-31 00:51:37 +08:00
4005f03cf8 🐛 Fix notification 2025-03-30 23:25:38 +08:00
4bd8ec54f1 Optimize initialization 2025-03-30 20:43:47 +08:00
51a387851f 🐛 Fix infinite starting up 2025-03-30 20:37:04 +08:00
8ed847d870 ♻️ Use API Version 2 to load post 2025-03-30 15:31:02 +08:00
dfe13de220 Program Badges 2025-03-29 17:00:17 +08:00
b02a54c1e9 🐛 Fix sound mode 2025-03-29 16:41:23 +08:00
55a7e7d900 🐛 Fix app drawer did not close after selected 2025-03-29 01:04:37 +08:00
3585941ccb 🐛 Optimize noise cancellation 2025-03-29 01:03:11 +08:00
7c6f2cc4ab ♻️ Refactored call view 2025-03-29 00:58:13 +08:00
LittleSheep
61dbf92909
🌐 Merge pull request #19 from Texas0295/master
Add AppImage build tools & Update workflow
2025-03-28 18:37:55 +08:00
Texas0295
b69e4002e0 Add AppImage build tools & Update workflow 2025-03-28 02:01:45 +08:00
Texas0295
49aa24b79d
Merge branch 'Solsynth:master' into master 2025-03-28 01:59:19 +08:00
ceb5c53229 🚀 Launch 2.4.2+85 2025-03-28 01:01:34 +08:00
908f0cb59e 🐛 Fix some issues 2025-03-28 00:54:51 +08:00
7c2b8de931 Desktop chat list
🍱 Update launch sfx
2025-03-28 00:52:19 +08:00
Texas0295
ddd0a4c3d3 remove cache=true in build-linux 2025-03-28 00:41:58 +08:00
Texas0295
99e07de243 upload appimage file 2025-03-28 00:04:44 +08:00
6bb9c21759 Rollback drawer style on mobile
🗑️ Remove drawer prefer collapse & expand
2025-03-28 00:00:39 +08:00
8f2fc55608 User ear healthy 2025-03-27 23:52:37 +08:00
a1c4e5eca0 ♻️ Refactored large screen user experience 2025-03-27 23:18:40 +08:00
Texas0295
10bf0883e5 add appimage build 2025-03-27 23:11:15 +08:00
595050f89f ♻️ Explore two column 2025-03-27 22:58:06 +08:00
0722c99f21 ♻️ Openable Post Item now push pages 2025-03-27 22:46:36 +08:00
12d03836f9 ♻️ Updated nav & account page two column design 2025-03-27 22:42:44 +08:00
LittleSheep
f78d3f4fd5
🔀 Merge pull request #18 from Texas0295/master
Fix workflow
2025-03-27 22:04:02 +08:00
Texas0295
e798a8ba76 fix workflow 2025-03-27 21:24:38 +08:00
c28a664373 Memorable window size 2025-03-27 00:37:45 +08:00
4589722c3b Weird boot sound effects 2025-03-27 00:22:41 +08:00
38e1c51b45 🐛 Fix linux compile issue 2025-03-26 23:32:23 +08:00
610ddec05c Sound effects on notify 2025-03-26 23:16:55 +08:00
d0276f9ac6 🐛 Fix some date issue 2025-03-26 22:51:09 +08:00
c1e89a2ee6 Punishments 2025-03-26 22:43:27 +08:00
ecc79368a1 🐛 Fix attachment border in list 2025-03-26 00:29:00 +08:00
e6d732c86a 💄 Optimize status text 2025-03-26 00:26:37 +08:00
dd055fb077 💄 Optimization and bug fixes 2025-03-26 00:24:07 +08:00
280840c6d8 ⬆️ Upgrade deps 2025-03-25 21:33:58 +08:00
bde62a7b2c Add cache for audio and video (experimental) 2025-03-25 00:33:39 +08:00
5445c570a2 Add deps for google_mobile_ads 2025-03-24 23:12:49 +08:00
b2302f5b3c 🐛 Make initialize for push notification no longer waited 2025-03-24 20:55:55 +08:00
d7359cfd0d 🐛 Fixes and optimization in programs 2025-03-24 00:09:36 +08:00
9cc577adbe Programs, members
🐛 Fix web assets redirecting issue
2025-03-23 22:34:58 +08:00
dd196b7754 Golden points 2025-03-23 18:23:18 +08:00
16c07c2133 🐛 Fix deps 2025-03-23 17:01:21 +08:00
6bcb658d44 🐛 Fix platform specific captcha solution cause build failed. 2025-03-23 16:47:06 +08:00
9311bfc3b5 ⬆️ Upgrade deps & replace to own translation api 2025-03-23 16:26:41 +08:00
8dd6435a30 🐛 Fix some issues on Android and Web 2025-03-23 16:24:53 +08:00
21a1d4a2ad 🐛 Fix unable select answer 2025-03-23 00:01:48 +08:00
603875b1af 🐛 Fix styling issue 2025-03-22 23:07:13 +08:00
4209a13c84 🐛 Fix no nav to use 2025-03-22 22:51:50 +08:00
55b79bfd8f 🐛 Finish bug fixes 2025-03-22 21:50:01 +08:00
6e6c3f42f6 🚀 Launch 2.4.2+84 2025-03-22 20:28:53 +08:00
dc38b46b2c Support captcha 2025-03-22 20:24:05 +08:00
b4990308e9 ♻️ Refactored nav completely 2025-03-22 18:39:01 +08:00
237abe564d ♻️ Refactored drawer nav 2025-03-22 18:14:36 +08:00
71b41d470a Splash screen loading 2025-03-22 16:36:10 +08:00
7052b5b635 Join channel hint
🗃️ Realm local db
2025-03-22 14:12:46 +08:00
f356e08f79 💄 New navigation draft (skip ci) 2025-03-22 12:48:55 +08:00
152872db65 💄 Show nick instead of name in typing indicator 2025-03-22 00:16:59 +08:00
dfe117d04f Auth preference screen 2025-03-22 00:14:55 +08:00
caf63f0cbe Notification preferences 2025-03-21 23:59:42 +08:00
b8f5cc82f9 Add attachments from file 2025-03-20 23:20:24 +08:00
360bc50f21 🐛 Fix linux G_APPLICATION_FLAGS_NONE api deprecated 2025-03-20 22:52:32 +08:00
2de93a0486 🐛 Close #15 with vide coding (not tested) 2025-03-20 22:45:53 +08:00
02227852f8 🚀 Launch 2.4.2+83 for some platforms 2025-03-19 00:48:36 +08:00
ad16de595b 🐛 Fix menubar missing hide 2025-03-19 00:30:58 +08:00
9f8c8923d9 🐛 Bug fixes in posts 2025-03-19 00:29:29 +08:00
060bfa4887 🐛 Fix explore unmixed feed pagination issue 2025-03-19 00:23:57 +08:00
e68ada2d04 💄 Optimize post comments 2025-03-19 00:21:54 +08:00
d6013078bd 🚀 Launch 2.4.2+81 2025-03-17 00:38:15 +08:00
5976d61997 💄 Bunch of optimization 2025-03-17 00:36:20 +08:00
b492db90ca 🚀 Launch Feature Drop 2.4.2+80 2025-03-16 23:27:52 +08:00
c9f69fed2c Complete translation 2025-03-16 23:24:36 +08:00
d2f4e7a969 Message translation 2025-03-16 23:10:59 +08:00
aecd04e0b9 Translate infra & post translation 2025-03-16 23:05:07 +08:00
e5212419ae 🐛 Fix poll percentage background 2025-03-16 22:13:19 +08:00
ec7650a920 💄 Optimize nesting 2025-03-16 22:11:40 +08:00
7b96013406 Better(?) comment nesting 2025-03-16 21:41:38 +08:00
fc5a79b29b Blurry attachment background 2025-03-16 19:34:42 +08:00
4146820be5 🐛 Bug fixes 2025-03-16 19:24:21 +08:00
9ec0f1ff19 💄 Redesigned post item 2025-03-16 18:56:08 +08:00
ac2aec48aa Allow to delete contact methods 2025-03-16 11:55:03 +08:00
58421e5d5e Basic contact methods 2025-03-16 01:39:05 +08:00
172d0d24fb 🐛 Fix unconfirmed indicator display logic 2025-03-15 23:17:03 +08:00
71899dd4f2 Empty sticker picker placeholder 2025-03-15 23:07:46 +08:00
02ffe9866d Unconfirmed account indicator 2025-03-15 22:53:27 +08:00
1b7e668b3f Dispose current session when logout 2025-03-15 21:11:49 +08:00
f03d80ba88 Auth tickets management 2025-03-15 20:27:14 +08:00
14ee6845ed Action events 2025-03-15 19:28:37 +08:00
8fe6c2be46 Upgrade deps and add flutter map 2025-03-15 18:37:00 +08:00
78e765f69d 🐛 Bug fixes 2025-03-15 15:44:56 +08:00
ddd6ff7eee Service status on home
🗑️ Remove news from home
2025-03-15 15:38:50 +08:00
b8f379796f News in feed 2025-03-15 14:53:42 +08:00
3a10e9280c 💄 Optimize news rendering 2025-03-13 23:04:34 +08:00
65fe06de22 ♻️ Optimized fediverse post displaying 2025-03-13 22:26:35 +08:00
e44320e0fe Basic fediverse posts displaying 2025-03-13 00:09:28 +08:00
f2d913ffec 🐛 Fix un-centered text 2025-03-10 21:37:58 +08:00
e88dea8858 MacOS menubar 2025-03-10 21:35:33 +08:00
813679b161 🐛 Bug fixes on post editor 2025-03-10 21:02:18 +08:00
9d4ce6ca8c 🚀 Launch bug hotfix +79 2025-03-09 15:22:25 +08:00
88396647f3 🚀 Launch 2.4.2+78 Feature Drop 2025-03-09 14:04:18 +08:00
335318ae3f Status system 2025-03-09 14:00:35 +08:00
da25fb9c29 🐛 Fix user cache 2025-03-09 13:03:57 +08:00
c1aef89b84 ♻️ Refactor account badge showing 2025-03-09 12:57:53 +08:00
0241c5f804 Check in streak 2025-03-09 12:41:34 +08:00
f6939d7c23 💄 Adjust icon size 2025-03-09 01:31:31 +08:00
d654c162e3 Shuffle post 2025-03-09 00:49:13 +08:00
25550ba197 💄 Changes to the showing of realm post 2025-03-09 00:11:01 +08:00
3defd3a593 💄 Modify explore appbar 2025-03-08 23:51:22 +08:00
d62ed4c375 Adjust explore categorized mode 2025-03-08 22:40:17 +08:00
857f3cc832 Post drafts 2025-03-08 22:32:38 +08:00
e16bc80eea 🐛 Fix attachments 2025-03-08 19:19:06 +08:00
a4f6e8af56 ♻️ New post explore realm design 2025-03-08 18:43:58 +08:00
060a97f5ec ♻️ Refactored explore screen 2025-03-08 18:19:57 +08:00
92f7e92018 🐛 Bug fixes due to post editor changes 2025-03-08 16:04:51 +08:00
5c483bd3b8 ♻️ Move the post editor mode into editor itself 2025-03-08 16:00:10 +08:00
1c510d63fe 🐛 Fix share via image errored 2025-03-06 22:46:02 +08:00
115cb4adc1 💄 Redesigned attachment zoom view 2025-03-06 22:35:06 +08:00
54c098c274 🍱 Update assets
 Optimize loading of web version in some regions
2025-03-05 22:23:42 +08:00
188 changed files with 17892 additions and 7655 deletions

View File

@ -48,18 +48,34 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- run: |
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev
sudo apt-get install libmpv-dev mpv
sudo apt-get install libayatana-appindicator3-dev
sudo apt-get install keybinder-3.0
sudo apt-get install libnotify-dev
sudo apt-get install -y libmpv-dev mpv
sudo apt-get install -y libayatana-appindicator3-dev
sudo apt-get install -y keybinder-3.0
sudo apt-get install -y libnotify-dev
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
sudo apt-get install -y gstreamer-1.0
- run: flutter pub get
- run: flutter build linux
- name: Archive production artifacts
uses: actions/upload-artifact@v4
with:
name: build-output-linux
path: build/linux/x64/release/bundle
path: build/linux/x64/release/bundle
- name: Build AppImage
run: |
rm -r Solian.AppDir | true
mkdir Solian.AppDir
cp -r build/linux/x64/release/bundle/* Solian.AppDir
cp -r buildtools/appimage_config/* Solian.AppDir
cp assets/icon/icon-light-radius.png Solian.AppDir
sudo chmod +x buildtools/appimagetool-x86_64.AppImage
sudo chmod +x Solian.AppDir/AppRun
./buildtools/appimagetool-x86_64.AppImage Solian.AppDir
- name: Archive production artifacts
uses: actions/upload-artifact@v4
with:
name: build-output-linux-appimage
path: './*.AppImage*'

34
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,34 @@
# Code of Conduct
Welcome to the Solar Network / HyperNet project!
We're welcome for any contribution, from bug reports to feature requests to code contributions.
To get started, start from fork the repository.
## Project Structure
The current repository you're visiting is the front-end project for the Solar Network project. It's built by Flutter and also manages all feature requests and issues reports in this repository.
The backend of the Solar Network is written in Go and is a microservices app. The code is stored separately in different repositories. They're linked in the README.MD, you can have a look and try to contribute if you want.
## Commit Messages
We're using the gitmoji to clarify the reason and changes of the commit. To learn more about gitmoji, visit https://gitmoji.dev
## Translations & Localization
We're not accepting translation and localization improvements, or fixes on the GitHub or Solsynth Git Repository. If you want to contribute to those, please head to our Weblate: https://i18n.solsynth.dev. You will able to sign up / in via your Solar Network Account (Solarpass)
## New Features
To contribute new features, please create an issue or mention the feature you want in our official development chat channel. You should discuss the feature with us and the community first. You shouldn't just create a Pull Request for the feature you want, it will not be merged.
## Bug Reports / Ask for help
Read the error message, check for the update (including pre-releases), and wiki before creating an issue. At the same time, be respectful and don't argue with our developers and contributors in the development chat or GitHub issue. Otherwise your issue may got deleted and your Solar Network Account may got a strike.
-----------
We appreciate every single commit you contributed. Let's work together and create a better Solar Network!

View File

@ -2,7 +2,7 @@
![](https://solsynth.dev/_next/static/media/alpha.e779a584.webp)
Hello there! Welcome to the main repository of the HyperNet (also known as the Solar Network). The code here is mainly about the frontend app (also known as Solian). But you can still post issues here to get help and request new features!
Hello there! Welcome to the main repository of the HyperNet (also known as the Solar Network). The code here is mainly about the front-end app (also known as Solian). But you can still post issues here to get help and request new features!
## Sub Projects
@ -14,14 +14,55 @@ HyperNet, the Solar Network is a microservices project in which the backends are
- The Messaging Service: [Messaging](https://github.com/Solsynth/HyperNet.Messaging)
- The Wallet Service: [Wallet](https://github.com/Solsynth/HyperNet.Wallet)
- The Crawler: [Reader](https://github.com/Solsynth/HyperNet.Reader)
- Some others may not be listed, you can search in the organization with `HyperNet.` the prefix of all HyperNet projects.
- The Attachments Service: [Paperclip](https://github.com/Solsynth/HyperNet.Paperclip)
- Some others may not be listed, you can search in the organization with `HyperNet.` It's the prefix of all HyperNet projects.
## Tech Stack
For those people who want to know the tech stack of this project, the frontend was built by Flutter, which provides the cross-platform ability.
For those people who want to know the tech stack of this project, the front-end was built by Flutter, which provides cross-platform ability.
The backend was built in Go and PostgreSQL with our very own microservice framework included in the nexus.
-----
If you want to contribute to the project, learn more about the [Code of Conduct](./CODE_OF_CONDUCT.md).
## Getting Started
The content below will lead you to the world of Solar Network.
### For Normal Users
1. Go to the Github Releases page, and download the latest release / pre-release according to your platform.
- **What's the difference between stable and pre-release?** The pre-release is untested by the other users and includes the new cutting-edge features, usually the pre-release is the feature drop. At the same time, due to we're not doing the API versioning, some breaking changes may break the stable release, so use the pre-release one instead.
2. Create an account on the Solar Network
3. Go to your email inbox to confirm your registration
4. Start exploring!
### For Developers
To make the Solar Network App run in debug mode on your machine, you need to install the flutter development environment, for more environments, head to https://flutter.dev.
For the Linux platform, you need to install those extra development libs:
```bash
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev
sudo apt-get install -y libmpv-dev mpv
sudo apt-get install -y libayatana-appindicator3-dev
sudo apt-get install -y keybinder-3.0
sudo apt-get install -y libnotify-dev
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
sudo apt-get install -y gstreamer-1.0
```
Then, use the flutter run for the app running in debug mode.
```bash
flutter pub get
```
If you want to build the release version, use the flutter build command. Learn more from the flutter docs.
```bash
flutter build <platform>
```
The readme will be updated in the future, to be determined. For now, you can check out the link of this repository to learn more on our official website.

2
android/.gitignore vendored
View File

@ -11,3 +11,5 @@ GeneratedPluginRegistrant.java
key.properties
**/*.keystore
**/*.jks
app/.cxx

View File

@ -10,10 +10,15 @@ plugins {
}
dependencies {
// implementation('org.jitsi.react:jitsi-meet-sdk:11.1.1') { transitive = true }
// implementation 'com.facebook.fresco:webpsupport:2.6.0'
// implementation 'com.facebook.fresco:animated-webp:2.6.0'
// implementation 'com.facebook.react:react-android:0.75.5'
// implementation 'com.facebook.react:hermes-android:0.75.5'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.glance:glance:1.1.1'
implementation 'androidx.glance:glance-appwidget:1.1.1'
implementation 'androidx.compose.foundation:foundation-layout-android:1.7.6'
implementation 'androidx.compose.foundation:foundation-layout-android:1.7.8'
implementation 'com.google.code.gson:gson:2.10.1'
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'io.coil-kt.coil3:coil-compose:3.0.4'
@ -73,8 +78,10 @@ android {
}
release {
signingConfig = signingConfigs.release
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}

96
android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,96 @@
-keepclassmembers class kotlin.Metadata { *; }
-keep class dev.solsynth.solian.** { *; }
-keep public class dev.solsynth.solian.data.** { public *; }
-keepclassmembers class dev.solsynth.solian.data.** { *; }
-keepattributes *Annotation*
-keepattributes Signature
-keepattributes EnclosingMethod
-keep class com.google.gson.** { *; }
-keepclassmembers class * {
@com.google.gson.annotations.SerializedName <fields>;
}
-dontwarn com.facebook.imagepipeline.nativecode.WebpTranscoder
-keep,allowobfuscation @interface com.facebook.proguard.annotations.DoNotStrip
-keep,allowobfuscation @interface com.facebook.proguard.annotations.KeepGettersAndSetters
# Do not strip any method/class that is annotated with @DoNotStrip
-keep @com.facebook.proguard.annotations.DoNotStrip class *
-keepclassmembers class * {
@com.facebook.proguard.annotations.DoNotStrip *;
}
-keep @com.facebook.proguard.annotations.DoNotStripAny class * {
*;
}
-keepclassmembers @com.facebook.proguard.annotations.KeepGettersAndSetters class * {
void set*(***);
*** get*();
}
-keep class * implements com.facebook.react.bridge.JavaScriptModule { *; }
-keep class * implements com.facebook.react.bridge.NativeModule { *; }
-keepclassmembers,includedescriptorclasses class * { native <methods>; }
-keepclassmembers class * { @com.facebook.react.uimanager.annotations.ReactProp <methods>; }
-keepclassmembers class * { @com.facebook.react.uimanager.annotations.ReactPropGroup <methods>; }
-dontwarn com.facebook.react.**
-keep,includedescriptorclasses class com.facebook.react.bridge.** { *; }
-keep,includedescriptorclasses class com.facebook.react.turbomodule.core.** { *; }
# hermes
-keep class com.facebook.jni.** { *; }
# okio
-keep class sun.misc.Unsafe { *; }
-dontwarn java.nio.file.*
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
-dontwarn okio.**
# yoga
-keep,allowobfuscation @interface com.facebook.yoga.annotations.DoNotStrip
-keep @com.facebook.yoga.annotations.DoNotStrip class *
-keepclassmembers class * {
@com.facebook.yoga.annotations.DoNotStrip *;
}
# WebRTC
-keep class org.webrtc.** { *; }
-dontwarn org.chromium.build.BuildHooksAndroid
# Jisti Meet SDK
-keep class org.jitsi.meet.** { *; }
-keep class org.jitsi.meet.sdk.** { *; }
# We added the following when we switched minifyEnabled on. Probably because we
# ran the app and hit problems...
-keep class com.facebook.react.bridge.CatalystInstanceImpl { *; }
-keep class com.facebook.react.bridge.ExecutorToken { *; }
-keep class com.facebook.react.bridge.JavaScriptExecutor { *; }
-keep class com.facebook.react.bridge.ModuleRegistryHolder { *; }
-keep class com.facebook.react.bridge.ReadableType { *; }
-keep class com.facebook.react.bridge.queue.NativeRunnable { *; }
-keep class com.facebook.react.devsupport.** { *; }
-dontwarn com.facebook.react.devsupport.**
-dontwarn com.google.appengine.**
-dontwarn com.squareup.okhttp.**
-dontwarn javax.servlet.**
# ^^^ We added the above when we switched minifyEnabled on.
# Rule to avoid build errors related to SVGs.
-keep public class com.horcrux.svg.** {*;}
# https://github.com/facebook/fresco/issues/2638
-keep public class com.facebook.imageutils.** {
public *;
}

View File

@ -1,4 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.INTERNET" />
@ -9,11 +9,13 @@
<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"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application
tools:replace="android:label"
android:label="Solian"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"

View File

@ -1,14 +0,0 @@
-keepclassmembers class kotlin.Metadata { *; }
-keep class dev.solsynth.solian.** { *; }
-keep public class dev.solsynth.solian.data.** { public *; }
-keepclassmembers class dev.solsynth.solian.data.** { *; }
-keepattributes *Annotation*
-keepattributes Signature
-keepattributes EnclosingMethod
-keep class com.google.gson.** { *; }
-keepclassmembers class * {
@com.google.gson.annotations.SerializedName <fields>;
}

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.9-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip

View File

@ -10,18 +10,22 @@ pluginManagement {
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
maven {
url "https://github.com/jitsi/jitsi-maven-repository/raw/master/releases"
}
google()
mavenCentral()
gradlePluginPortal()
maven { url 'https://www.jitpack.io' }
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.7.3' apply false
id "com.android.application" version '8.9.1' 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
id "com.google.gms.google-services" version "4.4.2" apply false
id "com.google.firebase.crashlytics" version "3.0.3" apply false
// END: FlutterFire Configuration
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
}

View File

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

View File

@ -5,14 +5,14 @@ meta {
}
put {
url: {{endpoint}}/cgi/id/reports/abuse/3/status
url: {{endpoint}}/cgi/id/reports/abuse/6/status
body: json
auth: inherit
}
body:json {
{
"status": "processed",
"message": "相关附件已经进行评级处理,未来会将该项权限下放到帖主以及社区成员。"
"status": "rejected",
"message": "Not a good reason"
}
}

View File

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

View File

@ -0,0 +1,20 @@
meta {
name: Give Punishment
type: http
seq: 4
}
post {
url: {{endpoint}}/cgi/id/punishments
body: json
auth: inherit
}
body:json {
{
"reason": "吹哨管理条例 / 滥用吹哨功能,累积三次复核无效吹哨。处以禁用吹哨功能 30 天。",
"type": 1,
"perm_nodes": {"FlagPost":false},
"account_id": 5
}
}

View File

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

View File

@ -11,8 +11,5 @@ post {
}
body:json {
{
"sources": ["taiwan-ltn"],
"eager": true
}
{}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

View File

@ -130,7 +130,7 @@
"accountPublishersSubtitle": "Manage your publish identities.",
"accountSettings": "Account Settings",
"accountSettingsSubtitle": "Manage your account and make it yours.",
"accountProfileEdit": "Edit your profile",
"accountProfileEdit": "Edit Profile",
"accountProfileEditSubtitle": "Make your Solarpass account more looks like you.",
"accountWallet": "Wallet",
"accountWalletSubtitle": "View your balance and transactions.",
@ -153,6 +153,11 @@
"publisherRunBy": "Run by {}",
"fieldPublisherBelongToRealm": "Belongs to",
"fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm",
"writePost": "Compose",
"postTypeStory": "Story",
"postTypeArticle": "Article",
"postTypeQuestion": "Question",
"postTypeVideo": "Video",
"writePostTypeStory": "Post a story",
"writePostTypeArticle": "Write an article",
"writePostTypeQuestion": "Ask a question",
@ -202,6 +207,7 @@
"one": "{} comment",
"other": "{} comments"
},
"postCommentExpand": "Show comments",
"settingsAppearance": "Appearance",
"settingsCustomFonts": "Custom Fonts",
"settingsCustomFontsDescription": "Set custom fonts for the application.",
@ -332,6 +338,7 @@
"fieldAttachmentRandomId": "Random ID",
"fieldAttachmentAlt": "Alternative text",
"addAttachmentFromAlbum": "Add from album",
"addAttachmentFromFiles": "Add from files",
"addAttachmentFromClipboard": "Paste file",
"addAttachmentFromCameraPhoto": "Take photo",
"addAttachmentFromCameraVideo": "Take video",
@ -536,6 +543,7 @@
"attachmentSaved": "Saved to album",
"attachmentSavedDesktop": "Saved to Downloads folder",
"openInAlbum": "Open in album",
"openInBrowser": "Open in browser",
"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",
@ -632,6 +640,7 @@
"postQuestionUnansweredWithReward": "Unanswered Question, reward source points {}",
"postQuestionAnswered": "Answered Question",
"postQuestionAnswerSelect": "Select as Answer",
"postQuestionAnswerTitle": "Selected Question",
"postQuestionAnswerSelected": "Answer has been selected, reward has been applied.",
"postVideoUpload": "Upload Video",
"realmJoin": "Join Realm",
@ -763,5 +772,198 @@
"decrypting": "Decrypting……",
"decryptingKeyNotFound": "Key not found or exchange failed, the other party may not be online",
"messageUnablePreview": "Unable preview",
"messageUnablePreviewEncrypted": "Unable preview encrypted message"
"messageUnablePreviewEncrypted": "Unable preview encrypted message",
"postViewInGlobalDescription": "Do not view the post in the specific realm.",
"postDraftSaved": "The draft has been saved.",
"postDraftBox": "Draft Box",
"postShuffle": "Read Randomly",
"checkInStreak": {
"zero": "No streak",
"one": "{} day streak",
"other": "{} days streak"
},
"accountChangeStatus": "Change Status",
"accountStatusSilent": "Do not Disturb",
"accountStatusSilentDesc": "The notification will stop popping up",
"accountStatusInvisible": "Invisible",
"accountStatusInvisibleDesc": "Will show as offline, but all features still remain normal",
"accountCustomStatus": "Custom Status",
"accountCustomStatusDescription": "Customize your status.",
"accountClearStatus": "Clear Status",
"accountClearStatusDescription": "Clear your status, and let server decide which status you are for you.",
"fieldAccountStatusLabel": "Status Text",
"fieldAccountStatusClearAt": "Clear At",
"accountStatusNegative": "Negative",
"accountStatusNeutral": "Neutral",
"accountStatusPositive": "Positive",
"mixedFeed": "Mixed Feed",
"mixedFeedDescription": "The Explore screen may not only display the user's posts, but may also contain other content. However, this mode does not apply to classification and filtering.",
"filterFeed": "Exploring Adjust",
"feedUnknownItem": "Unable to display this content, the current version of the client does not support the type of content, please try to update the application afterwards.",
"serviceStatusOperational": "All services operational",
"serviceStatusDowngraded": "Some services downgraded",
"serviceStatusFailed": "All services unavailable",
"serviceStatusFailedDescription": "The server is down or the maintenance is just finished.",
"serviceNameInsights": "Summarize and Insights",
"serviceNameInteractive": "Posts, Reactions and Explore",
"serviceNameReader": "News and Link Previews",
"serviceNameMessaging": "Chat",
"serviceNameMatrix": "Matrix Software and Game Marketplace",
"serviceNamePaperclip": "Attachments, Images and Files",
"serviceNameWallet": "Source Points Wallet",
"serviceNamePassport": "Authorization and Authentication",
"accountActionEvent": "Action Events",
"accountActionEventDescription": "View your action event logs.",
"eventMetadata": "Metadata",
"accountAuthTickets": "Auth Sessions",
"accountAuthTicketsDescription": "View and manage your auth sessions.",
"authTicketCreatedAt": "Issued at {}",
"authTicketExpiredAt": "Expired at {}",
"authTicketLastGrantAt": "Last granted at {}",
"authTicketCurrent": "Current",
"accountUnconfirmedTitle": "Unconfirmed Account",
"accountUnconfirmedSubtitle": "Your account is unconfirmed, which will make most features unavailable and your account will be destroyed in 24 hours. You should receive an email in your inbox with a confirmation link.",
"accountUnconfirmedUnreceived": "Didn't receive the email?",
"accountUnconfirmedResend": "Resend one",
"accountUnconfirmedResendSuccessful": "Email has been resent, you can resend it again in 60 minutes.",
"stickerPickerEmpty": "Sticker list is empty",
"stickerPickerEmptyHint": "To start using stickers, you need to add a sticker pack first.",
"goto": "Go to {}",
"accountContactMethods": "Contact Methods",
"accountContactMethodsDescription": "Manage your contact methods.",
"accountContactMethodsNameEmail": "Email address",
"accountContactMethodsNamePhone": "Phone number",
"accountContactMethodsNameAddress": "Address",
"accountContactMethodsPrimary": "Primary",
"accountContactMethodsVerified": "Verified",
"accountContactMethodsPublic": "Public",
"accountContactMethodsAdd": "Add Contact Method",
"accountContactMethodsEdit": "Edit Contact Method",
"accountContactMethodsAddDescription": "Add a new contact method.",
"fieldContactContent": "Contact method",
"accountContactMethodsPublicHint": "This contact method will be displayed publicly on your profile.",
"accountContactMethodsDelete": "Delete Contact Method",
"accountContactMethodsDeleteDescription": "Are you sure you want to delete contact method {}? This operation is irreversible.",
"postCommentAdd": "Write a comment",
"translate": "Translate",
"translating": "Translating…",
"translated": "Translated",
"settingsAutoTranslate": "Auto Translate",
"settingsAutoTranslateDescription": "Automatically translate text when viewing posts and messages.",
"trayMenuHide": "Hide",
"accountSettingsNotify": "Notify Settings",
"accountSettingsNotifyDescription": "Adjust the types of notifications you receive.",
"accountSettingsSecurity": "Security Settings",
"accountSettingsSecurityDescription": "Adjust your account security settings.",
"save": "Save",
"notificationTopicPostFeedback": "Post Feedback",
"notificationTopicPostReply": "Post Replies",
"notificationTopicPostSubscription": "Post Subscriptions",
"notificationTopicMessaging": "New Messages",
"notificationTopicMessagingCall": "Incoming Calls",
"notificationTopicGeneral": "General",
"authMaximumAuthSteps": "Maximum Authenticate Steps",
"authMaximumAuthStepsDescription": {
"one": "Maximum ask for {} step authenticate",
"other": "Maximum ask for {} steps authenticate"
},
"authAlwaysRisky": "Always Risky",
"authAlwaysRiskyDescription": "Always ask for the highest steps count of authentication when logging in.",
"chatUnjoined": "Unjoined Channel",
"chatUnjoinedDescription": "You haven't joined this channel, so you can't send messages either view messages in it.",
"chatUnjoinedPublicDescription": "Fortunately, this is a public channel, so you can join it as you want.",
"chatJoin": "Join the Channel",
"appInitStarting": "Starting",
"appInitNetwork": "Initializing Network",
"appInitUserdata": "Initializing User Data",
"appInitWebsocket": "Establishing Solar Link",
"appInitNotification": "Initializing Push Notifications",
"appInitKeyPair": "Initializing Key Pairs",
"appInitStickers": "Initializing Stickers",
"appInitUserDirectory": "Initializing User Directory",
"appInitRealm": "Initializing Realms",
"appInitChat": "Initializing Chat",
"appInitDone": "Completed",
"community": "Community",
"realmCommunity": "{}'s Community",
"postTotalCount": {
"one": "Total {} post",
"other": "Total {} posts"
},
"settingsHideBottomNav": "Hide Bottom Navigation",
"settingsHideBottomNavDescription": "Hide the bottom navigation bar, and show the navigation buttons in the drawer.",
"reCaptcha": "reCaptcha",
"friends": "Friends",
"friendsDescription": "Manage your friendships.",
"album": "Album",
"albumDescription": "View albums and manage attachments.",
"stickers": "Stickers",
"stickersDescription": "View sticker packs and manage stickers.",
"navBottomUnauthorizedCaption": "Or create an account",
"walletCurrencyGoldenShort": "GDP",
"walletCurrencyGolden": {
"one": "{} Golden Point",
"other": "{} Golden Points"
},
"walletTransactionTypeNormal": "Source Point",
"walletTransactionTypeGolden": "Golden Point",
"accountProgram": "Programs",
"accountProgramDescription": "Explore the available member programs.",
"accountProgramJoin": "Join Program",
"accountProgramJoinRequirements": "Requirements",
"accountProgramJoinPricing": "Pricing",
"accountProgramJoinPricingHint": "Billed every (30 days) month.",
"accountProgramLeaveHint": "After leaving the program, the source points will not be refunded.",
"accountProgramJoined": "Joined Program.",
"accountProgramAlreadyJoined": "Joined",
"accountProgramLeft": "Left Program.",
"leave": "Leave",
"attachmentFailedToLoadMedia": "Unable to load media file, please try again later. If this error occurs repeatedly, the source file may not exist or the network connection may be abnormal.",
"accountPunishments": "Punishments",
"accountPunishmentsDescription": "View your account's reputation status.",
"punishmentType0": "Strike",
"punishmentType1": "Limited",
"punishmentType2": "Banned",
"punishmentOverall": "Overall Status",
"punishmentStatusNormal": "All abilities normal",
"punishmentStatusWarned": "All abilities normal, but at least one strike is in effect",
"punishmentStatusLimited": "Some abilities limited, at least one limited punishment is in effect",
"punishmentStatusLimitedFully": "All abilities limited, at least one completely limited punishment is in effect",
"punishmentStatusBanned": "All services are terminated, banned",
"punishmentCreatedAt": "Applied since {}",
"punishmentExpiredAt": "Expired at {}",
"punishmentExpiredNever": "Never expired",
"punishmentModerator": "Moderator who made this punishment",
"punishmentMadeBySystem": "Made by auto-mod system",
"settingsAprilFoolFeatures": "April Fool Features",
"settingsAprilFoolFeaturesDescription": "Enable April Fool features during April Fool, this option will only be visible during April Fool.",
"settingsSoundEffects": "Sound Effects",
"settingsSoundEffectsDescription": "Enable the sound effects around the app.",
"settingsResetMemorizedWindowSize": "Reset Window Size",
"settingsResetMemorizedWindowSizeDescription": "Reset the memorized window size, and set it to the default size.",
"chatDirect": "Direct Messages",
"back": "Back",
"badgeProgramDeveloper": "Developer Program Member",
"badgeProgramStellar": "A Stellar",
"badgeProgramModerator": "Community Moderator",
"postEditedHint": "edited",
"splashScreenServer": "Server",
"splashScreenServerName": "Potato",
"splashScreenCaption": "Trying to establishing connection with HyperNet™",
"attachmentEditor": "Attachment editor",
"attachmentEditorUnUploadHint": "This attachment is not uploaded, metadata editing is unavailable, and you can crop this attachment.",
"attachmentEditorUploadHint": "This attachment is uploaded.",
"attachmentRating": "Rating",
"fieldAttachmentRating": "Content Rating",
"fieldAttachmentQuality": "Quality Rating",
"attachmentReferenceLink": "Use external attachment",
"fieldAttachmentReferenceLink": "Reference Link",
"attachmentReferenceLinkDescription": "It will be used as the source file of the attachment. The link needs to allow cross-origin access.",
"fieldAttachmentMimetype": "Mimetype",
"postVideoLive": "Live Stream",
"postVideoLiveDescription": "This is a live video, you can embed the source site by yourself.",
"postVideoRendererWeb": "WebView Rendering",
"postVideoRendererWebDescription": "Use WebView to render the content",
"fieldPostVideoUrl": "Video URL",
"fieldPostVideoUrlDescription": "The URL of the video content, it can be a webpage, and will be rendered by iFrame / WebView."
}

View File

@ -137,6 +137,11 @@
"publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所属领域",
"fieldPublisherBelongToRealmUnset": "未设置发布者所属领域",
"writePost": "撰写",
"postTypeStory": "动态",
"postTypeArticle": "文章",
"postTypeQuestion": "问题",
"postTypeVideo": "视频",
"writePostTypeStory": "发动态",
"writePostTypeArticle": "写文章",
"writePostTypeQuestion": "提问题",
@ -200,6 +205,7 @@
"one": "{} 条评论",
"other": "{} 条评论"
},
"postCommentExpand": "展开评论",
"settingsAppearance": "外观",
"settingsCustomFonts": "自定义字体",
"settingsCustomFontsDescription": "设置应用程序使用的字体。",
@ -330,6 +336,7 @@
"fieldAttachmentRandomId": "访问 ID",
"fieldAttachmentAlt": "概述文字",
"addAttachmentFromAlbum": "从相册中添加附件",
"addAttachmentFromFiles": "从文件中添加附件",
"addAttachmentFromClipboard": "粘贴附件",
"addAttachmentFromCameraPhoto": "拍摄照片",
"addAttachmentFromCameraVideo": "拍摄视频",
@ -534,6 +541,7 @@
"attachmentSaved": "已保存到相册",
"attachmentSavedDesktop": "已保存到下载目录",
"openInAlbum": "在相册中打开",
"openInBrowser": "在浏览器中打开",
"postAbuseReport": "检举帖子",
"postAbuseReportDescription": "检举不符合我们用户协议以及社区准则的帖子,来帮助我们更好的维护 Solar Network 上的内容。请在下面描述该帖子如何违反我么的相关规定。请勿填写任何敏感信息。我们将会在 24 小时内处理您的检举。",
"abuseReport": "检举",
@ -761,5 +769,198 @@
"decrypting": "解密中……",
"decryptingKeyNotFound": "未找到密钥对或交换失败,对方可能不在线",
"messageUnablePreview": "无法预览消息",
"messageUnablePreviewEncrypted": "无法预览加密消息"
"messageUnablePreviewEncrypted": "无法预览加密消息",
"postViewInGlobalDescription": "不查看特定领域的帖子。",
"postDraftSaved": "已保存为草稿。",
"postDraftBox": "草稿箱",
"postShuffle": "随便看看",
"checkInStreak": {
"zero": "无连击",
"one": "连续签到 {} 天",
"other": "连续签到 {} 天"
},
"accountChangeStatus": "修改状态",
"accountStatusSilent": "请勿打扰",
"accountStatusSilentDesc": "将会暂停所有通知推送",
"accountStatusInvisible": "隐身",
"accountStatusInvisibleDesc": "将会在他人界面显示离线,但不影响功能使用",
"accountCustomStatus": "自定义状态",
"accountCustomStatusDescription": "客制化你的状态。",
"accountClearStatus": "清除状态",
"accountClearStatusDescription": "清除你的状态,并让服务器决定你的状态。",
"fieldAccountStatusLabel": "状态文字",
"fieldAccountStatusClearAt": "清除时间",
"accountStatusNegative": "负面",
"accountStatusNeutral": "中性",
"accountStatusPositive": "正面",
"mixedFeed": "混合推荐流",
"mixedFeedDescription": "探索页面可能不只会展示用户的帖子,更可能包含其他的内容。但该模式不适用分类和过滤。",
"filterFeed": "探索队列调整",
"feedUnknownItem": "无法显示该内容,当前版本客户端不支持该类型的内容,请尝试更新应用程序后再试。",
"serviceStatusOperational": "所有服务正常",
"serviceStatusDowngraded": "部分服务异常",
"serviceStatusFailed": "服务状态异常",
"serviceStatusFailedDescription": "服务器炸了或者刚刚执行完维护程序。",
"serviceNameInsights": "总结、见解与洞察",
"serviceNameInteractive": "帖子与互动",
"serviceNameReader": "新闻与链接展开",
"serviceNameMessaging": "即使聊天",
"serviceNameMatrix": "矩阵市场",
"serviceNamePaperclip": "附件",
"serviceNameWallet": "源点钱包",
"serviceNamePassport": "身份验证与授权",
"accountActionEvent": "操作日志",
"accountActionEventDescription": "查看你的操作日志。",
"eventMetadata": "元数据",
"accountAuthTickets": "授权会话",
"accountAuthTicketsDescription": "查看和管理你的授权会话。",
"authTicketCreatedAt": "签发于 {}",
"authTicketExpiredAt": "到期于 {}",
"authTicketLastGrantAt": "上次刷新于 {}",
"authTicketCurrent": "当前会话",
"accountUnconfirmedTitle": "尚未未确认账户",
"accountUnconfirmedSubtitle": "您的账户尚未确认,这会导致大部分功能不可用,并且您的帐户将在 24 小时后自毁。您绑定的邮箱的收件箱应该会有一封邮件指引您确认您的帐户。",
"accountUnconfirmedUnreceived": "未收到邮件?",
"accountUnconfirmedResend": "重新发送一封",
"accountUnconfirmedResendSuccessful": "邮件已重新发送,你可以在 60 分钟后再重发一封。",
"stickerPickerEmpty": "贴图列表为空",
"stickerPickerEmptyHint": "想要开始使用贴图,请先添加贴图包。",
"goto": "跳转到 {}",
"accountContactMethods": "联系方式",
"accountContactMethodsDescription": "管理你的联系方式。",
"accountContactMethodsNameEmail": "电子邮箱",
"accountContactMethodsNamePhone": "电话",
"accountContactMethodsNameAddress": "地址",
"accountContactMethodsPrimary": "主要的",
"accountContactMethodsVerified": "已验证",
"accountContactMethodsPublic": "公开的",
"accountContactMethodsAdd": "添加联系方式",
"accountContactMethodsEdit": "编辑联系方式",
"accountContactMethodsAddDescription": "添加新的联系方式。",
"fieldContactContent": "联系方式",
"accountContactMethodsPublicHint": "这个联系方式公开地显示在个人资料中。",
"accountContactMethodsDelete": "删除联系方式",
"accountContactMethodsDeleteDescription": "你确定要删除联系方式 {} 吗?这个操作不可撤销。",
"postCommentAdd": "撰写一条评论",
"translate": "翻译",
"translating": "正在翻译……",
"translated": "已翻译",
"settingsAutoTranslate": "自动翻译",
"settingsAutoTranslateDescription": "在查看帖子、消息时自动翻译文本。",
"trayMenuHide": "隐藏",
"accountSettingsNotify": "通知设置",
"accountSettingsNotifyDescription": "调整你所收到的通知种类。",
"accountSettingsSecurity": "安全设置",
"accountSettingsSecurityDescription": "调整你的帐户安全设置。",
"save": "保存",
"notificationTopicPostFeedback": "帖子数据反馈",
"notificationTopicPostReply": "帖子回复",
"notificationTopicPostSubscription": "帖子订阅",
"notificationTopicMessaging": "消息",
"notificationTopicMessagingCall": "通话",
"notificationTopicGeneral": "杂项",
"authMaximumAuthSteps": "最大验证步骤",
"authMaximumAuthStepsDescription": {
"one": "登入时最多要求 {} 步验证",
"other": "登入时最多要求 {} 步验证"
},
"authAlwaysRisky": "总是风险",
"authAlwaysRiskyDescription": "在登入时始终按最高标准要求验证。",
"chatUnjoined": "未加入频道",
"chatUnjoinedDescription": "你没有加入这个频道,所以你也无法发送消息或者查看这个频道中的消息。",
"chatUnjoinedPublicDescription": "但幸运的是,这是一个公开频道,所以你可以主动加入。",
"chatJoin": "加入频道",
"appInitStarting": "启动中",
"appInitNetwork": "正在初始化网络",
"appInitUserdata": "正在初始化用户数据",
"appInitWebsocket": "正在建立 Solar Link",
"appInitNotification": "正在初始化推送通知",
"appInitKeyPair": "正在初始化密钥对",
"appInitStickers": "正在初始化贴图包",
"appInitUserDirectory": "正在初始化用户目录",
"appInitRealm": "正在初始化领域信息",
"appInitChat": "正在初始化聊天",
"appInitDone": "完成",
"community": "社区",
"realmCommunity": "{}的社区",
"postTotalCount": {
"zero": "没有帖子",
"one": "共 {} 条帖子"
},
"settingsHideBottomNav": "隐藏底部导航栏",
"settingsHideBottomNavDescription": "隐藏底部导航栏,在侧边栏抽屉显示导航按钮。",
"reCaptcha": "人机验证",
"friends": "好友",
"friendsDescription": "管理好友关系。",
"album": "相册",
"albumDescription": "查看相册与管理上传附件。",
"stickers": "贴图",
"stickersDescription": "查看贴图包与管理贴图。",
"navBottomUnauthorizedCaption": "或者注册一个账号",
"walletCurrencyGoldenShort": "金点",
"walletCurrencyGolden": {
"one": "{} 金点",
"other": "{} 金点"
},
"walletTransactionTypeNormal": "源点",
"walletTransactionTypeGolden": "金点",
"accountProgram": "计划",
"accountProgramDescription": "了解可用的成员计划。",
"accountProgramJoin": "加入计划",
"accountProgramJoinRequirements": "要求",
"accountProgramJoinPricing": "价格",
"accountProgramJoinPricingHint": "按月30 天)收费",
"accountProgramLeaveHint": "离开计划后,之前花费的源点不会退款。",
"accountProgramJoined": "已加入计划。",
"accountProgramLeft": "已离开计划。",
"accountProgramAlreadyJoined": "已加入",
"leave": "离开",
"attachmentFailedToLoadMedia": "无法加载媒体文件,请稍后重试。若此错误重复出现,可能源文件不存在或者网络连接异常。",
"accountPunishments": "处分",
"accountPunishmentsDescription": "查看你帐号的信誉状态。",
"punishmentType0": "警告",
"punishmentType1": "停权",
"punishmentType2": "封禁",
"punishmentOverall": "总体状态",
"punishmentStatusNormal": "所有功能正常",
"punishmentStatusWarned": "所有功能正常,但有警告生效",
"punishmentStatusLimited": "部份功能暂时受限,有至少一个停权生效",
"punishmentStatusLimitedFully": "所有功能暂时受限,有至少一个完全停权生效",
"punishmentStatusBanned": "所有服务终止,已被封禁",
"punishmentCreatedAt": "宣布于 {}",
"punishmentExpiredAt": "到期于 {}",
"punishmentExpiredNever": "永久生效",
"punishmentModerator": "责任管理员",
"punishmentMadeBySystem": "由系统自动裁决",
"settingsAprilFoolFeatures": "愚人节特性",
"settingsAprilFoolFeaturesDescription": "在愚人节期间启用愚人节特性,该选项只会在愚人节期间显示。",
"settingsSoundEffects": "声音效果",
"settingsSoundEffectsDescription": "在一些场合下启用声音特效。",
"settingsResetMemorizedWindowSize": "重置窗口大小",
"settingsResetMemorizedWindowSizeDescription": "重置记忆的窗口大小,以重新设置为默认大小。",
"chatDirect": "私信",
"back": "返回",
"badgeProgramDeveloper": "开发者计划成员",
"badgeProgramStellar": "一颗恒星",
"badgeProgramModerator": "社区管理员",
"postEditedHint": "已编辑",
"splashScreenServer": "服务器",
"splashScreenServerName": "土豆",
"splashScreenCaption": "正在尝试与 HyperNet™ 取得太阳链连接",
"attachmentEditor": "附件编辑器",
"attachmentEditorUnUploadHint": "该附件未上传,元数据编辑不可用,同时你可以裁剪本附件。",
"attachmentEditorUploadHint": "该附件已上传。",
"attachmentRating": "评级",
"fieldAttachmentRating": "内容分级",
"fieldAttachmentQuality": "质量评分",
"attachmentReferenceLink": "引用外部附件",
"fieldAttachmentReferenceLink": "引用连接",
"attachmentReferenceLinkDescription": "作为附件的源文件。需要链接允许跨域访问。",
"fieldAttachmentMimetype": "文件类型",
"postVideoLive": "直播",
"postVideoLiveDescription": "这是一条直播影片,允许用户自行嵌入源站。",
"postVideoRendererWeb": "网页渲染器",
"postVideoRendererWebDescription": "使用 WebView 渲染内容。",
"fieldPostVideoUrl": "视频流地址",
"fieldPostVideoUrlDescription": "视频内容的地址,可以为网页,将会使用 iFrame / WebView 渲染。"
}

View File

@ -137,6 +137,11 @@
"publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所屬領域",
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
"writePost": "撰寫",
"postTypeStory": "動態",
"postTypeArticle": "文章",
"postTypeQuestion": "問題",
"postTypeVideo": "視頻",
"writePostTypeStory": "發動態",
"writePostTypeArticle": "寫文章",
"writePostTypeQuestion": "提問題",
@ -200,6 +205,7 @@
"one": "{} 條評論",
"other": "{} 條評論"
},
"postCommentExpand": "展開評論",
"settingsAppearance": "外觀",
"settingsCustomFonts": "自定義字體",
"settingsCustomFontsDescription": "設置應用程序使用的字體。",
@ -330,6 +336,7 @@
"fieldAttachmentRandomId": "訪問 ID",
"fieldAttachmentAlt": "概述文字",
"addAttachmentFromAlbum": "從相冊中添加附件",
"addAttachmentFromFiles": "從文件中添加附件",
"addAttachmentFromClipboard": "粘貼附件",
"addAttachmentFromCameraPhoto": "拍攝照片",
"addAttachmentFromCameraVideo": "拍攝視頻",
@ -761,5 +768,132 @@
"decrypting": "解密中……",
"decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線",
"messageUnablePreview": "無法預覽消息",
"messageUnablePreviewEncrypted": "無法預覽加密消息"
"messageUnablePreviewEncrypted": "無法預覽加密消息",
"postViewInGlobalDescription": "不查看特定領域的帖子。",
"postDraftSaved": "已保存為草稿。",
"postDraftBox": "草稿箱",
"postShuffle": "隨便看看",
"checkInStreak": {
"zero": "無連擊",
"one": "連續簽到 {} 天",
"other": "連續簽到 {} 天"
},
"accountChangeStatus": "修改狀態",
"accountStatusSilent": "請勿打擾",
"accountStatusSilentDesc": "將會暫停所有通知推送",
"accountStatusInvisible": "隱身",
"accountStatusInvisibleDesc": "將會在他人界面顯示離線,但不影響功能使用",
"accountCustomStatus": "自定義狀態",
"accountCustomStatusDescription": "客製化你的狀態。",
"accountClearStatus": "清除狀態",
"accountClearStatusDescription": "清除你的狀態,並讓服務器決定你的狀態。",
"fieldAccountStatusLabel": "狀態文字",
"fieldAccountStatusClearAt": "清除時間",
"accountStatusNegative": "負面",
"accountStatusNeutral": "中性",
"accountStatusPositive": "正面",
"mixedFeed": "混合推薦流",
"mixedFeedDescription": "探索頁面可能不只會展示用户的帖子,更可能包含其他的內容。但該模式不適用分類和過濾。",
"filterFeed": "探索隊列調整",
"feedUnknownItem": "無法顯示該內容,當前版本客户端不支持該類型的內容,請嘗試更新應用程序後再試。",
"serviceStatusOperational": "所有服務正常",
"serviceStatusDowngraded": "部分服務異常",
"serviceStatusFailed": "服務狀態異常",
"serviceStatusFailedDescription": "服務器炸了或者剛剛執行完維護程序。",
"serviceNameInsights": "總結、見解與洞察",
"serviceNameInteractive": "帖子與互動",
"serviceNameReader": "新聞與鏈接展開",
"serviceNameMessaging": "即使聊天",
"serviceNameMatrix": "矩陣市場",
"serviceNamePaperclip": "附件",
"serviceNameWallet": "源點錢包",
"serviceNamePassport": "身份驗證與授權",
"accountActionEvent": "操作日誌",
"accountActionEventDescription": "查看你的操作日誌。",
"eventMetadata": "元數據",
"accountAuthTickets": "授權會話",
"accountAuthTicketsDescription": "查看和管理你的授權會話。",
"authTicketCreatedAt": "簽發於 {}",
"authTicketExpiredAt": "到期於 {}",
"authTicketLastGrantAt": "上次刷新於 {}",
"authTicketCurrent": "當前會話",
"accountUnconfirmedTitle": "尚未未確認賬户",
"accountUnconfirmedSubtitle": "您的賬户尚未確認,這會導致大部分功能不可用,並且您的帳户將在 24 小時後自毀。您綁定的郵箱的收件箱應該會有一封郵件指引您確認您的帳户。",
"accountUnconfirmedUnreceived": "未收到郵件?",
"accountUnconfirmedResend": "重新發送一封",
"accountUnconfirmedResendSuccessful": "郵件已重新發送,你可以在 60 分鐘後再重發一封。",
"stickerPickerEmpty": "貼圖列表為空",
"stickerPickerEmptyHint": "想要開始使用貼圖,請先添加貼圖包。",
"goto": "跳轉到 {}",
"accountContactMethods": "聯繫方式",
"accountContactMethodsDescription": "管理你的聯繫方式。",
"accountContactMethodsNameEmail": "電子郵箱",
"accountContactMethodsNamePhone": "電話",
"accountContactMethodsNameAddress": "地址",
"accountContactMethodsPrimary": "主要的",
"accountContactMethodsVerified": "已驗證",
"accountContactMethodsPublic": "公開的",
"accountContactMethodsAdd": "添加聯繫方式",
"accountContactMethodsEdit": "編輯聯繫方式",
"accountContactMethodsAddDescription": "添加新的聯繫方式。",
"fieldContactContent": "聯繫方式",
"accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。",
"accountContactMethodsDelete": "刪除聯繫方式",
"accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。",
"postCommentAdd": "撰寫一條評論",
"translate": "翻譯",
"translating": "正在翻譯……",
"translated": "已翻譯",
"settingsAutoTranslate": "自動翻譯",
"settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。",
"trayMenuHide": "隱藏",
"accountSettingsNotify": "通知設置",
"accountSettingsNotifyDescription": "調整你所收到的通知種類。",
"accountSettingsSecurity": "安全設置",
"accountSettingsSecurityDescription": "調整你的帳户安全設置。",
"save": "保存",
"notificationTopicPostFeedback": "帖子數據反饋",
"notificationTopicPostReply": "帖子回覆",
"notificationTopicPostSubscription": "帖子訂閲",
"notificationTopicMessaging": "消息",
"notificationTopicMessagingCall": "通話",
"notificationTopicGeneral": "雜項",
"authMaximumAuthSteps": "最大驗證步驟",
"authMaximumAuthStepsDescription": {
"one": "登入時最多要求 {} 步驗證",
"other": "登入時最多要求 {} 步驗證"
},
"authAlwaysRisky": "總是風險",
"authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。",
"chatUnjoined": "未加入頻道",
"chatUnjoinedDescription": "你沒有加入這個頻道,所以你也無法發送消息或者查看這個頻道中的消息。",
"chatUnjoinedPublicDescription": "但幸運的是,這是一個公開頻道,所以你可以主動加入。",
"chatJoin": "加入頻道",
"appInitStarting": "啓動中",
"appInitNetwork": "正在初始化網絡",
"appInitUserdata": "正在初始化用户數據",
"appInitWebsocket": "正在建立 Solar Link",
"appInitNotification": "正在初始化推送通知",
"appInitKeyPair": "正在初始化密鑰對",
"appInitStickers": "正在初始化貼圖包",
"appInitUserDirectory": "正在初始化用户目錄",
"appInitRealm": "正在初始化領域信息",
"appInitChat": "正在初始化聊天",
"appInitDone": "完成",
"community": "社區",
"realmCommunity": "{}的社區",
"postTotalCount": {
"zero": "沒有帖子",
"one": "共 {} 條帖子"
},
"settingsHideBottomNav": "隱藏底部導航欄",
"settingsHideBottomNavDescription": "隱藏底部導航欄,在側邊欄抽屜顯示導航按鈕。",
"reCaptcha": "人機驗證",
"friends": "好友",
"friendsDescription": "管理好友關係。",
"album": "相冊",
"albumDescription": "查看相冊與管理上傳附件。",
"stickers": "貼圖",
"stickersDescription": "查看貼圖包與管理貼圖。",
"navBottomUnauthorizedCaption": "或者註冊一個賬號"
}

View File

@ -137,6 +137,11 @@
"publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所屬領域",
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
"writePost": "撰寫",
"postTypeStory": "動態",
"postTypeArticle": "文章",
"postTypeQuestion": "問題",
"postTypeVideo": "視頻",
"writePostTypeStory": "發動態",
"writePostTypeArticle": "寫文章",
"writePostTypeQuestion": "提問題",
@ -200,6 +205,7 @@
"one": "{} 條評論",
"other": "{} 條評論"
},
"postCommentExpand": "展開評論",
"settingsAppearance": "外觀",
"settingsCustomFonts": "自定義字體",
"settingsCustomFontsDescription": "設置應用程序使用的字體。",
@ -330,6 +336,7 @@
"fieldAttachmentRandomId": "訪問 ID",
"fieldAttachmentAlt": "概述文字",
"addAttachmentFromAlbum": "從相冊中添加附件",
"addAttachmentFromFiles": "從文件中添加附件",
"addAttachmentFromClipboard": "粘貼附件",
"addAttachmentFromCameraPhoto": "拍攝照片",
"addAttachmentFromCameraVideo": "拍攝視頻",
@ -761,5 +768,132 @@
"decrypting": "解密中……",
"decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線",
"messageUnablePreview": "無法預覽消息",
"messageUnablePreviewEncrypted": "無法預覽加密消息"
"messageUnablePreviewEncrypted": "無法預覽加密消息",
"postViewInGlobalDescription": "不查看特定領域的帖子。",
"postDraftSaved": "已保存為草稿。",
"postDraftBox": "草稿箱",
"postShuffle": "隨便看看",
"checkInStreak": {
"zero": "無連擊",
"one": "連續簽到 {} 天",
"other": "連續簽到 {} 天"
},
"accountChangeStatus": "修改狀態",
"accountStatusSilent": "請勿打擾",
"accountStatusSilentDesc": "將會暫停所有通知推送",
"accountStatusInvisible": "隱身",
"accountStatusInvisibleDesc": "將會在他人界面顯示離線,但不影響功能使用",
"accountCustomStatus": "自定義狀態",
"accountCustomStatusDescription": "客製化你的狀態。",
"accountClearStatus": "清除狀態",
"accountClearStatusDescription": "清除你的狀態,並讓服務器決定你的狀態。",
"fieldAccountStatusLabel": "狀態文字",
"fieldAccountStatusClearAt": "清除時間",
"accountStatusNegative": "負面",
"accountStatusNeutral": "中性",
"accountStatusPositive": "正面",
"mixedFeed": "混合推薦流",
"mixedFeedDescription": "探索頁面可能不只會展示用戶的帖子,更可能包含其他的內容。但該模式不適用分類和過濾。",
"filterFeed": "探索隊列調整",
"feedUnknownItem": "無法顯示該內容,當前版本客戶端不支持該類型的內容,請嘗試更新應用程序後再試。",
"serviceStatusOperational": "所有服務正常",
"serviceStatusDowngraded": "部分服務異常",
"serviceStatusFailed": "服務狀態異常",
"serviceStatusFailedDescription": "服務器炸了或者剛剛執行完維護程序。",
"serviceNameInsights": "總結、見解與洞察",
"serviceNameInteractive": "帖子與互動",
"serviceNameReader": "新聞與鏈接展開",
"serviceNameMessaging": "即使聊天",
"serviceNameMatrix": "矩陣市場",
"serviceNamePaperclip": "附件",
"serviceNameWallet": "源點錢包",
"serviceNamePassport": "身份驗證與授權",
"accountActionEvent": "操作日誌",
"accountActionEventDescription": "查看你的操作日誌。",
"eventMetadata": "元數據",
"accountAuthTickets": "授權會話",
"accountAuthTicketsDescription": "查看和管理你的授權會話。",
"authTicketCreatedAt": "簽發於 {}",
"authTicketExpiredAt": "到期於 {}",
"authTicketLastGrantAt": "上次刷新於 {}",
"authTicketCurrent": "當前會話",
"accountUnconfirmedTitle": "尚未未確認賬戶",
"accountUnconfirmedSubtitle": "您的賬戶尚未確認,這會導致大部分功能不可用,並且您的帳戶將在 24 小時後自毀。您綁定的郵箱的收件箱應該會有一封郵件指引您確認您的帳戶。",
"accountUnconfirmedUnreceived": "未收到郵件?",
"accountUnconfirmedResend": "重新發送一封",
"accountUnconfirmedResendSuccessful": "郵件已重新發送,你可以在 60 分鐘後再重發一封。",
"stickerPickerEmpty": "貼圖列表為空",
"stickerPickerEmptyHint": "想要開始使用貼圖,請先添加貼圖包。",
"goto": "跳轉到 {}",
"accountContactMethods": "聯繫方式",
"accountContactMethodsDescription": "管理你的聯繫方式。",
"accountContactMethodsNameEmail": "電子郵箱",
"accountContactMethodsNamePhone": "電話",
"accountContactMethodsNameAddress": "地址",
"accountContactMethodsPrimary": "主要的",
"accountContactMethodsVerified": "已驗證",
"accountContactMethodsPublic": "公開的",
"accountContactMethodsAdd": "添加聯繫方式",
"accountContactMethodsEdit": "編輯聯繫方式",
"accountContactMethodsAddDescription": "添加新的聯繫方式。",
"fieldContactContent": "聯繫方式",
"accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。",
"accountContactMethodsDelete": "刪除聯繫方式",
"accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。",
"postCommentAdd": "撰寫一條評論",
"translate": "翻譯",
"translating": "正在翻譯……",
"translated": "已翻譯",
"settingsAutoTranslate": "自動翻譯",
"settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。",
"trayMenuHide": "隱藏",
"accountSettingsNotify": "通知設置",
"accountSettingsNotifyDescription": "調整你所收到的通知種類。",
"accountSettingsSecurity": "安全設置",
"accountSettingsSecurityDescription": "調整你的帳戶安全設置。",
"save": "保存",
"notificationTopicPostFeedback": "帖子數據反饋",
"notificationTopicPostReply": "帖子回覆",
"notificationTopicPostSubscription": "帖子訂閱",
"notificationTopicMessaging": "消息",
"notificationTopicMessagingCall": "通話",
"notificationTopicGeneral": "雜項",
"authMaximumAuthSteps": "最大驗證步驟",
"authMaximumAuthStepsDescription": {
"one": "登入時最多要求 {} 步驗證",
"other": "登入時最多要求 {} 步驗證"
},
"authAlwaysRisky": "總是風險",
"authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。",
"chatUnjoined": "未加入頻道",
"chatUnjoinedDescription": "你沒有加入這個頻道,所以你也無法發送消息或者查看這個頻道中的消息。",
"chatUnjoinedPublicDescription": "但幸運的是,這是一個公開頻道,所以你可以主動加入。",
"chatJoin": "加入頻道",
"appInitStarting": "啟動中",
"appInitNetwork": "正在初始化網絡",
"appInitUserdata": "正在初始化用戶數據",
"appInitWebsocket": "正在建立 Solar Link",
"appInitNotification": "正在初始化推送通知",
"appInitKeyPair": "正在初始化密鑰對",
"appInitStickers": "正在初始化貼圖包",
"appInitUserDirectory": "正在初始化用戶目錄",
"appInitRealm": "正在初始化領域信息",
"appInitChat": "正在初始化聊天",
"appInitDone": "完成",
"community": "社區",
"realmCommunity": "{}的社區",
"postTotalCount": {
"zero": "沒有帖子",
"one": "共 {} 條帖子"
},
"settingsHideBottomNav": "隱藏底部導航欄",
"settingsHideBottomNavDescription": "隱藏底部導航欄,在側邊欄抽屜顯示導航按鈕。",
"reCaptcha": "人機驗證",
"friends": "好友",
"friendsDescription": "管理好友關係。",
"album": "相冊",
"albumDescription": "查看相冊與管理上傳附件。",
"stickers": "貼圖",
"stickersDescription": "查看貼圖包與管理貼圖。",
"navBottomUnauthorizedCaption": "或者註冊一個賬號"
}

View File

@ -0,0 +1,4 @@
#!/bin/sh
cd "$(dirname "$0")"
exec ./surface

View File

@ -0,0 +1,8 @@
[Desktop Entry]
Version=1.0
Type=Application
Terminal=false
Name=Solian
Exec=surface %u
Icon=icon-light-radius
Categories=Network;

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
platform :ios, '13.0'
platform :ios, '15.1'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

@ -1,5 +1,7 @@
PODS:
- Alamofire (5.10.2)
- audioplayers_darwin (0.0.1):
- Flutter
- connectivity_plus (0.0.1):
- Flutter
- croppy (0.0.1):
@ -44,58 +46,58 @@ PODS:
- Flutter
- file_saver (0.0.1):
- Flutter
- Firebase/Analytics (11.8.0):
- Firebase/Analytics (11.10.0):
- Firebase/Core
- Firebase/Core (11.8.0):
- Firebase/Core (11.10.0):
- Firebase/CoreOnly
- FirebaseAnalytics (~> 11.8.0)
- Firebase/CoreOnly (11.8.0):
- FirebaseCore (~> 11.8.0)
- Firebase/Messaging (11.8.0):
- FirebaseAnalytics (~> 11.10.0)
- Firebase/CoreOnly (11.10.0):
- FirebaseCore (~> 11.10.0)
- Firebase/Messaging (11.10.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 11.8.0)
- firebase_analytics (11.4.4):
- Firebase/Analytics (= 11.8.0)
- FirebaseMessaging (~> 11.10.0)
- firebase_analytics (11.4.5):
- Firebase/Analytics (= 11.10.0)
- firebase_core
- Flutter
- firebase_core (3.12.1):
- Firebase/CoreOnly (= 11.8.0)
- firebase_core (3.13.0):
- Firebase/CoreOnly (= 11.10.0)
- Flutter
- firebase_messaging (15.2.4):
- Firebase/Messaging (= 11.8.0)
- firebase_messaging (15.2.5):
- Firebase/Messaging (= 11.10.0)
- firebase_core
- Flutter
- FirebaseAnalytics (11.8.0):
- FirebaseAnalytics/AdIdSupport (= 11.8.0)
- FirebaseCore (~> 11.8.0)
- FirebaseAnalytics (11.10.0):
- FirebaseAnalytics/AdIdSupport (= 11.10.0)
- FirebaseCore (~> 11.10.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.8.0):
- FirebaseCore (~> 11.8.0)
- FirebaseAnalytics/AdIdSupport (11.10.0):
- FirebaseCore (~> 11.10.0)
- FirebaseInstallations (~> 11.0)
- GoogleAppMeasurement (= 11.8.0)
- GoogleAppMeasurement (= 11.10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0)
- FirebaseCore (11.8.1):
- FirebaseCoreInternal (~> 11.8.0)
- FirebaseCore (11.10.0):
- FirebaseCoreInternal (~> 11.10.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/Logger (~> 8.0)
- FirebaseCoreInternal (11.8.0):
- FirebaseCoreInternal (11.10.0):
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- FirebaseInstallations (11.8.0):
- FirebaseCore (~> 11.8.0)
- FirebaseInstallations (11.10.0):
- FirebaseCore (~> 11.10.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- PromisesObjC (~> 2.4)
- FirebaseMessaging (11.8.0):
- FirebaseCore (~> 11.8.0)
- FirebaseMessaging (11.10.0):
- FirebaseCore (~> 11.10.0)
- FirebaseInstallations (~> 11.0)
- GoogleDataTransport (~> 10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
@ -120,29 +122,26 @@ PODS:
- flutter_udid (0.0.1):
- Flutter
- SAMKeychain
- flutter_webrtc (0.12.6):
- Flutter
- WebRTC-SDK (= 125.6422.06)
- gal (1.0.0):
- Flutter
- FlutterMacOS
- geolocator_apple (1.2.0):
- Flutter
- GoogleAppMeasurement (11.8.0):
- GoogleAppMeasurement/AdIdSupport (= 11.8.0)
- Giphy (2.2.12):
- libwebp
- GoogleAppMeasurement (11.10.0):
- GoogleAppMeasurement/AdIdSupport (= 11.10.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.8.0):
- GoogleAppMeasurement/WithoutAdIdSupport (= 11.8.0)
- GoogleAppMeasurement/AdIdSupport (11.10.0):
- GoogleAppMeasurement/WithoutAdIdSupport (= 11.10.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.8.0):
- GoogleAppMeasurement/WithoutAdIdSupport (11.10.0):
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
@ -184,15 +183,28 @@ PODS:
- Flutter
- in_app_review (2.0.0):
- Flutter
- Kingfisher (8.2.0)
- livekit_client (2.4.0):
- jitsi_meet_flutter_sdk (11.1.1):
- Flutter
- flutter_webrtc
- WebRTC-SDK (= 125.6422.06)
- JitsiMeetSDK (= 11.1.1)
- JitsiMeetSDK (11.1.1):
- Giphy (= 2.2.12)
- JitsiWebRTC (~> 124.0)
- JitsiWebRTC (124.0.2)
- Kingfisher (8.3.1)
- libwebp (1.5.0):
- libwebp/demux (= 1.5.0)
- libwebp/mux (= 1.5.0)
- libwebp/sharpyuv (= 1.5.0)
- libwebp/webp (= 1.5.0)
- libwebp/demux (1.5.0):
- libwebp/webp
- libwebp/mux (1.5.0):
- libwebp/demux
- libwebp/sharpyuv (1.5.0)
- libwebp/webp (1.5.0):
- libwebp/sharpyuv
- 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):
@ -214,11 +226,9 @@ PODS:
- receive_sharing_intent (1.8.1):
- Flutter
- SAMKeychain (1.5.3)
- screen_brightness_ios (0.1.0):
- Flutter
- SDWebImage (5.20.1):
- SDWebImage/Core (= 5.20.1)
- SDWebImage/Core (5.20.1)
- SDWebImage (5.21.0):
- SDWebImage/Core (= 5.21.0)
- SDWebImage/Core (5.21.0)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
@ -234,6 +244,8 @@ PODS:
- sqlite3/common
- sqlite3/fts5 (3.49.1):
- sqlite3/common
- sqlite3/math (3.49.1):
- sqlite3/common
- sqlite3/perf-threadsafe (3.49.1):
- sqlite3/common
- sqlite3/rtree (3.49.1):
@ -244,6 +256,7 @@ PODS:
- sqlite3 (~> 3.49.1)
- sqlite3/dbstatvtab
- sqlite3/fts5
- sqlite3/math
- sqlite3/perf-threadsafe
- sqlite3/rtree
- SwiftyGif (5.4.5)
@ -255,12 +268,12 @@ PODS:
- Flutter
- wakelock_plus (0.0.1):
- Flutter
- WebRTC-SDK (125.6422.06)
- workmanager (0.0.1):
- Flutter
DEPENDENCIES:
- Alamofire
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- croppy (from `.symlinks/plugins/croppy/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
@ -276,23 +289,19 @@ DEPENDENCIES:
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`)
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
- gal (from `.symlinks/plugins/gal/darwin`)
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`)
- home_widget (from `.symlinks/plugins/home_widget/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- jitsi_meet_flutter_sdk (from `.symlinks/plugins/jitsi_meet_flutter_sdk/ios`)
- Kingfisher (~> 8.0)
- livekit_client (from `.symlinks/plugins/livekit_client/ios`)
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
- media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- pasteboard (from `.symlinks/plugins/pasteboard/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
@ -314,10 +323,14 @@ SPEC REPOS:
- FirebaseCoreInternal
- FirebaseInstallations
- FirebaseMessaging
- Giphy
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleUtilities
- JitsiMeetSDK
- JitsiWebRTC
- Kingfisher
- libwebp
- nanopb
- OrderedSet
- PromisesObjC
@ -325,9 +338,10 @@ SPEC REPOS:
- SDWebImage
- sqlite3
- SwiftyGif
- WebRTC-SDK
EXTERNAL SOURCES:
audioplayers_darwin:
:path: ".symlinks/plugins/audioplayers_darwin/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
croppy:
@ -358,24 +372,18 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_timezone/ios"
flutter_udid:
:path: ".symlinks/plugins/flutter_udid/ios"
flutter_webrtc:
:path: ".symlinks/plugins/flutter_webrtc/ios"
gal:
:path: ".symlinks/plugins/gal/darwin"
geolocator_apple:
:path: ".symlinks/plugins/geolocator_apple/ios"
home_widget:
:path: ".symlinks/plugins/home_widget/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
livekit_client:
:path: ".symlinks/plugins/livekit_client/ios"
jitsi_meet_flutter_sdk:
:path: ".symlinks/plugins/jitsi_meet_flutter_sdk/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:
@ -388,8 +396,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/permission_handler_apple/ios"
receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios"
screen_brightness_ios:
:path: ".symlinks/plugins/screen_brightness_ios/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
@ -411,67 +417,67 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
audioplayers_darwin: ccf9c770ee768abb07e26d90af093f7bab1c12ab
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
fast_rsa: dc48fb05f26bb108863de122b2a9f5554e8e2591
file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
Firebase: d80354ed7f6df5f9aca55e9eb47cc4b634735eaf
firebase_analytics: e3b6782e70e32b7fa18f7cd233e3201975dd86aa
firebase_core: ac395f994af4e28f6a38b59e05a88ca57abeb874
firebase_messaging: 7e223f4ee7ca053bf4ce43748e84a6d774ec9728
FirebaseAnalytics: 4fd42def128146e24e480e89f310e3d8534ea42b
FirebaseCore: 99fe0c4b44a39f37d99e6404e02009d2db5d718d
FirebaseCoreInternal: df24ce5af28864660ecbd13596fc8dd3a8c34629
FirebaseInstallations: 6c963bd2a86aca0481eef4f48f5a4df783ae5917
FirebaseMessaging: 487b634ccdf6f7b7ff180fdcb2a9935490f764e8
fast_rsa: d99f8e1809a4a312fa9216d830186869b2e9eb65
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
Firebase: 1fe1c0a7d9aaea32efe01fbea5f0ebd8d70e53a2
firebase_analytics: 1998960b8fa16fd0cd9e77a6f9fd35a2009ad65e
firebase_core: 2d4534e7b489907dcede540c835b48981d890943
firebase_messaging: 75bc93a4df25faccad67f6662ae872ac9ae69b64
FirebaseAnalytics: 4e42333f02cf78ed93703a5c36f36dd518aebdef
FirebaseCore: 8344daef5e2661eb004b177488d6f9f0f24251b7
FirebaseCoreInternal: ef4505d2afb1d0ebbc33162cb3795382904b5679
FirebaseInstallations: 9980995bdd06ec8081dfb6ab364162bdd64245c3
FirebaseMessaging: 2b9f56aa4ed286e1f0ce2ee1d413aabb8f9f5cb9
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc
flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
flutter_timezone: ac3da59ac941ff1c98a2e1f0293420e020120282
flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab
flutter_webrtc: 90260f83024b1b96d239a575ea4e3708e79344d1
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
geolocator_apple: 9bcea1918ff7f0062d98345d238ae12718acfbc1
GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896
flutter_app_update: 816fdb2e30e4832a7c45e3f108d391c42ef040a9
flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544
flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9
gal: baecd024ebfd13c441269ca7404792a7152fde89
Giphy: 83628960ed04e1c3428ff1b4fb2b027f65e82f50
GoogleAppMeasurement: 36684bfb3ee034e2b42b4321eb19da3a1b81e65d
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
Kingfisher: 323e5c4ec7983aaace12af655a7b51a7f88a599d
livekit_client: 9819ebc8be8ef00ed0fae7d806bf8938ec689573
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
in_app_review: 5596fe56fab799e8edb3561c03d053363ab13457
jitsi_meet_flutter_sdk: 0283a60730922d608fbad9872e07afdd5bb3578a
JitsiMeetSDK: 4e1c269aaaed8f2cb7b0fff2d3c00f08359b170e
JitsiWebRTC: b47805ab5668be38e7ee60e2258f49badfe8e1d0
Kingfisher: 3204d23de16b5ea53541c44ca5a8efb55741dec3
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
SDWebImage: 33d0f23bddeb5d209ae959153883247be6703713
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
sqlite3_flutter_libs: cc304edcb8e1d8c595d1b08c7aeb46a47691d9db
sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe
volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56
WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
video_compress: f2133a07762889d67f0711ac831faa26f956980e
volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12
wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49
workmanager: 01be2de7f184bd15de93a1812936a2b7f42ef07e
PODFILE CHECKSUM: 9b244e02f87527430136c8d21cbdcf1cd586b6bc
PODFILE CHECKSUM: d278ce52a331dda323590121247d2046cd085ae7
COCOAPODS: 1.16.2

View File

@ -961,7 +961,7 @@
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -1521,7 +1521,7 @@
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -1549,7 +1549,7 @@
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",

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>NSCalendarsUsageDescription</key>
<string>Grant access to Calander help us to shows Solar Calander with your own events.</string>
<key>AppGroupId</key>
<string>group.solsynth.solian</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
@ -79,6 +81,8 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>

View File

@ -71,7 +71,8 @@ class PostWriteMedia {
}
}
PostWriteMedia.fromBytes(this.raw, this.name, this.type, {this.attachment, this.file});
PostWriteMedia.fromBytes(this.raw, this.name, this.type,
{this.attachment, this.file});
bool get isEmpty => attachment == null && file == null && raw == null;
@ -105,7 +106,8 @@ class PostWriteMedia {
}) {
if (attachment != null) {
final sn = context.read<SnNetworkProvider>();
final ImageProvider provider = UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
final ImageProvider provider =
UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
if (width != null && height != null && !kIsWeb) {
return ResizeImage(
provider,
@ -116,7 +118,8 @@ class PostWriteMedia {
}
return provider;
} else if (file != null) {
final ImageProvider provider = kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path));
final ImageProvider provider =
kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path));
if (width != null && height != null) {
return ResizeImage(
provider,
@ -159,11 +162,14 @@ class PostWriteController extends ChangeNotifier {
final TextEditingController aliasController = TextEditingController();
final TextEditingController rewardController = TextEditingController();
ContentInsertionConfiguration get contentInsertionConfiguration => ContentInsertionConfiguration(
ContentInsertionConfiguration get contentInsertionConfiguration =>
ContentInsertionConfiguration(
onContentInserted: (KeyboardInsertedContent content) {
if (content.hasData) {
addAttachments(
[PostWriteMedia.fromBytes(content.data!, 'attachmentInsertedImage'.tr(), SnMediaType.image)]);
addAttachments([
PostWriteMedia.fromBytes(content.data!,
'attachmentInsertedImage'.tr(), SnMediaType.image)
]);
}
},
);
@ -193,7 +199,8 @@ class PostWriteController extends ChangeNotifier {
String get description => descriptionController.text;
bool get isRelatedNull => ![editingPost, repostingPost, replyingPost].any((ele) => ele != null);
bool get isRelatedNull =>
![editingPost, repostingPost, replyingPost].any((ele) => ele != null);
bool isLoading = false, isBusy = false;
double? progress;
@ -201,6 +208,7 @@ class PostWriteController extends ChangeNotifier {
SnRealm? realm;
SnPublisher? publisher;
SnPost? editingPost, repostingPost, replyingPost;
bool editingDraft = false;
int visibility = 0;
List<int> visibleUsers = List.empty();
@ -211,6 +219,8 @@ class PostWriteController extends ChangeNotifier {
List<PostWriteMedia> attachments = List.empty(growable: true);
DateTime? publishedAt, publishedUntil;
SnAttachment? videoAttachment;
String videoUrl = '';
bool videoLive = false;
SnPoll? poll;
Future<void> fetchRelatedPost(
@ -233,22 +243,40 @@ class PostWriteController extends ChangeNotifier {
contentController.text = post.body['content'] ?? '';
aliasController.text = post.alias ?? '';
rewardController.text = post.body['reward']?.toString() ?? '';
videoAttachment = post.preload?.video;
if (post.body['video'] != null) {
if (post.body['video'] is String) {
videoUrl = post.body['video'];
} else {
videoAttachment = SnAttachment.fromJson(post.body['video']);
}
}
publishedAt = post.publishedAt;
publishedUntil = post.publishedUntil;
visibleUsers = List.from(post.visibleUsersList ?? [], growable: true);
invisibleUsers = List.from(post.invisibleUsersList ?? [], growable: true);
invisibleUsers =
List.from(post.invisibleUsersList ?? [], growable: true);
visibility = post.visibility;
tags = List.from(post.tags.map((ele) => ele.alias), growable: true);
categories = List.from(post.categories.map((ele) => ele.alias), growable: true);
attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
poll = post.preload?.poll;
categories =
List.from(post.categories.map((ele) => ele.alias), growable: true);
attachments.addAll(
post.body['attachments']
?.map((ele) => SnAttachment.fromJson(ele))
?.map((ele) => PostWriteMedia(ele))
?.cast<PostWriteMedia>() ??
[],
);
poll = post.poll;
if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
thumbnail = PostWriteMedia(post.preload!.thumbnail);
videoLive = post.body['is_live'] ?? false;
editingDraft = post.isDraft;
if (post.body['thumbnail'] != null) {
thumbnail =
PostWriteMedia(SnAttachment.fromJson(post.body['thumbnail']));
}
if (post.preload?.realm != null) {
realm = post.preload!.realm!;
if (post.realm != null) {
realm = post.realm!;
}
editingPost = post;
@ -272,7 +300,8 @@ class PostWriteController extends ChangeNotifier {
}
}
Future<SnAttachment> _uploadAttachment(BuildContext context, PostWriteMedia media,
Future<SnAttachment> _uploadAttachment(
BuildContext context, PostWriteMedia media,
{bool isCompressed = false}) async {
final attach = context.read<SnAttachmentProvider>();
@ -281,7 +310,9 @@ class PostWriteController extends ChangeNotifier {
media.name,
'interactive',
null,
mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null,
mimetype: media.raw != null && media.type == SnMediaType.image
? 'image/png'
: null,
);
var item = await attach.chunkedUploadParts(
@ -297,9 +328,11 @@ class PostWriteController extends ChangeNotifier {
if (media.type == SnMediaType.video && !isCompressed && context.mounted) {
try {
final compressedAttachment = await _tryCompressVideoCopy(context, media);
final compressedAttachment =
await _tryCompressVideoCopy(context, media);
if (compressedAttachment != null) {
item = await attach.updateOne(item, compressedId: compressedAttachment.id);
item = await attach.updateOne(item,
compressedId: compressedAttachment.id);
}
} catch (err) {
if (context.mounted) context.showErrorDialog(err);
@ -309,8 +342,10 @@ class PostWriteController extends ChangeNotifier {
return item;
}
Future<SnAttachment?> _tryCompressVideoCopy(BuildContext context, PostWriteMedia media) async {
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) return null;
Future<SnAttachment?> _tryCompressVideoCopy(
BuildContext context, PostWriteMedia media) async {
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS))
return null;
if (media.type != SnMediaType.video) return null;
if (media.file == null) return null;
if (VideoCompress.isCompressing) return null;
@ -334,7 +369,8 @@ class PostWriteController extends ChangeNotifier {
if (!context.mounted) return null;
final compressedMedia = PostWriteMedia.fromFile(XFile(mediaInfo.path!));
final compressedAttachment = await _uploadAttachment(context, compressedMedia, isCompressed: true);
final compressedAttachment =
await _uploadAttachment(context, compressedMedia, isCompressed: true);
return compressedAttachment;
}
@ -370,18 +406,25 @@ class PostWriteController extends ChangeNotifier {
'content': contentController.text,
if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
if (titleController.text.isNotEmpty) 'title': titleController.text,
if (descriptionController.text.isNotEmpty) 'description': descriptionController.text,
if (descriptionController.text.isNotEmpty)
'description': descriptionController.text,
if (rewardController.text.isNotEmpty) 'reward': rewardController.text,
if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.toJson(),
'attachments':
attachments.where((e) => e.attachment != null).map((e) => e.attachment!.toJson()).toList(growable: true),
if (thumbnail != null && thumbnail!.attachment != null)
'thumbnail': thumbnail!.attachment!.toJson(),
'attachments': attachments
.where((e) => e.attachment != null)
.map((e) => e.attachment!.toJson())
.toList(growable: true),
'tags': tags.map((ele) => {'alias': ele}).toList(growable: true),
'categories': categories.map((ele) => {'alias': ele}).toList(growable: true),
'categories':
categories.map((ele) => {'alias': ele}).toList(growable: true),
'visibility': visibility,
'visible_users_list': visibleUsers,
'invisible_users_list': invisibleUsers,
if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(),
if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
if (publishedAt != null)
'published_at': publishedAt!.toUtc().toIso8601String(),
if (publishedUntil != null)
'published_until': publishedAt!.toUtc().toIso8601String(),
if (replyingPost != null) 'reply_to': replyingPost!.toJson(),
if (repostingPost != null) 'repost_to': repostingPost!.toJson(),
if (poll != null) 'poll': poll!.toJson(),
@ -391,6 +434,12 @@ class PostWriteController extends ChangeNotifier {
});
}
bool get isNotEmpty =>
title.isNotEmpty ||
description.isNotEmpty ||
contentController.text.isNotEmpty ||
attachments.isNotEmpty;
bool temporaryRestored = false;
void _temporaryLoad() {
@ -403,18 +452,27 @@ class PostWriteController extends ChangeNotifier {
titleController.text = data['title'] ?? '';
descriptionController.text = data['description'] ?? '';
rewardController.text = data['reward']?.toString() ?? '';
if (data['thumbnail'] != null) thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail']));
attachments
.addAll(data['attachments'].map((ele) => PostWriteMedia(SnAttachment.fromJson(ele))).cast<PostWriteMedia>());
if (data['thumbnail'] != null) {
thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail']));
}
attachments.addAll(data['attachments']
.map((ele) => PostWriteMedia(SnAttachment.fromJson(ele)))
.cast<PostWriteMedia>());
tags = List.from(data['tags'].map((ele) => ele['alias']));
categories = List.from(data['categories'].map((ele) => ele['alias']));
visibility = data['visibility'];
visibleUsers = List.from(data['visible_users_list'] ?? []);
invisibleUsers = List.from(data['invisible_users_list'] ?? []);
if (data['published_at'] != null) publishedAt = DateTime.tryParse(data['published_at'])?.toLocal();
if (data['published_until'] != null) publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal();
replyingPost = data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null;
repostingPost = data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null;
if (data['published_at'] != null) {
publishedAt = DateTime.tryParse(data['published_at'])?.toLocal();
}
if (data['published_until'] != null) {
publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal();
}
replyingPost =
data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null;
repostingPost =
data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null;
poll = data['poll'] != null ? SnPoll.fromJson(data['poll']) : null;
realm = data['realm'] != null ? SnRealm.fromJson(data['realm']) : null;
temporaryRestored = true;
@ -436,7 +494,10 @@ class PostWriteController extends ChangeNotifier {
notifyListeners();
}
Future<void> sendPost(BuildContext context) async {
Future<void> sendPost(
BuildContext context, {
bool saveAsDraft = false,
}) async {
if (isBusy || publisher == null) return;
final sn = context.read<SnNetworkProvider>();
@ -463,7 +524,9 @@ class PostWriteController extends ChangeNotifier {
media.name,
'interactive',
null,
mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null,
mimetype: media.raw != null && media.type == SnMediaType.image
? 'image/png'
: null,
);
var item = await attach.chunkedUploadParts(
@ -472,16 +535,20 @@ class PostWriteController extends ChangeNotifier {
place.$2,
onProgress: (value) {
// Calculate overall progress for attachments
progress = math.max(((i + value) / attachments.length) * kAttachmentProgressWeight, value);
progress = math.max(
((i + value) / attachments.length) * kAttachmentProgressWeight,
value);
notifyListeners();
},
);
try {
if (context.mounted) {
final compressedAttachment = await _tryCompressVideoCopy(context, media);
final compressedAttachment =
await _tryCompressVideoCopy(context, media);
if (compressedAttachment != null) {
item = await attach.updateOne(item, compressedId: compressedAttachment.id);
item = await attach.updateOne(item,
compressedId: compressedAttachment.id);
}
}
} catch (err) {
@ -508,7 +575,7 @@ class PostWriteController extends ChangeNotifier {
// Posting the content
try {
final baseProgressVal = progress!;
await sn.client.request(
final resp = await sn.client.request(
[
'/cgi/co/$mode',
if (editingPost != null) '${editingPost!.id}',
@ -518,36 +585,58 @@ class PostWriteController extends ChangeNotifier {
'content': contentController.text,
if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
if (titleController.text.isNotEmpty) 'title': titleController.text,
if (descriptionController.text.isNotEmpty) 'description': descriptionController.text,
if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid,
'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(),
if (descriptionController.text.isNotEmpty)
'description': descriptionController.text,
if (thumbnail != null && thumbnail!.attachment != null)
'thumbnail': thumbnail!.attachment!.rid,
'attachments': attachments
.where((e) => e.attachment != null)
.map((e) => e.attachment!.rid)
.toList(),
'tags': tags.map((ele) => {'alias': ele}).toList(),
'categories': categories.map((ele) => {'alias': ele}).toList(),
'visibility': visibility,
'visible_users_list': visibleUsers,
'invisible_users_list': invisibleUsers,
if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(),
if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
if (publishedAt != null)
'published_at': publishedAt!.toUtc().toIso8601String(),
if (publishedUntil != null)
'published_until': publishedAt!.toUtc().toIso8601String(),
if (replyingPost != null) 'reply_to': replyingPost!.id,
if (repostingPost != null) 'repost_to': repostingPost!.id,
if (reward != null) 'reward': reward,
if (videoAttachment != null) 'video': videoAttachment!.rid,
if (videoAttachment != null || videoUrl.isNotEmpty)
'video': videoUrl.isNotEmpty ? videoUrl : videoAttachment!.rid,
if (poll != null) 'poll': poll!.id,
if (realm != null) 'realm': realm!.id,
if (videoLive) 'is_live': videoLive,
'is_draft': saveAsDraft,
},
onSendProgress: (count, total) {
progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
progress =
baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
notifyListeners();
},
onReceiveProgress: (count, total) {
progress = baseProgressVal + (kPostingProgressWeight / 2) + (count / total) * (kPostingProgressWeight / 2);
progress = baseProgressVal +
(kPostingProgressWeight / 2) +
(count / total) * (kPostingProgressWeight / 2);
notifyListeners();
},
options: Options(
method: editingPost != null ? 'PUT' : 'POST',
),
);
reset();
if (saveAsDraft) {
if (!context.mounted) return;
editingDraft = true;
final out = SnPost.fromJson(resp.data);
final pt = context.read<SnPostContentProvider>();
editingPost = await pt.completePostData(out);
notifyListeners();
} else {
reset();
}
} catch (err) {
if (!context.mounted) return;
context.showErrorDialog(err);
@ -661,6 +750,16 @@ class PostWriteController extends ChangeNotifier {
notifyListeners();
}
void setVideoUrl(String value) {
videoUrl = value;
notifyListeners();
}
void setVideoLive(bool value) {
videoLive = value;
notifyListeners();
}
void setPoll(SnPoll? value) {
poll = value;
notifyListeners();
@ -683,7 +782,8 @@ class PostWriteController extends ChangeNotifier {
repostingPost = null;
mode = kTitleMap.keys.first;
temporaryRestored = false;
SharedPreferences.getInstance().then((prefs) => prefs.remove(kTemporaryStorageKey));
SharedPreferences.getInstance()
.then((prefs) => prefs.remove(kTemporaryStorageKey));
notifyListeners();
}

View File

@ -6,10 +6,12 @@ import 'package:surface/database/attachment.dart';
import 'package:surface/database/chat.dart';
import 'package:surface/database/database.steps.dart';
import 'package:surface/database/keypair.dart';
import 'package:surface/database/realm.dart';
import 'package:surface/database/sticker.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/types/attachment.dart';
import 'package:surface/types/account.dart';
import 'package:surface/types/realm.dart';
part 'database.g.dart';
@ -22,12 +24,13 @@ part 'database.g.dart';
SnLocalAttachment,
SnLocalSticker,
SnLocalStickerPack,
SnLocalRealm,
])
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? e]) : super(e ?? _openConnection());
@override
int get schemaVersion => 3;
int get schemaVersion => 4;
static QueryExecutor _openConnection() {
return driftDatabase(
@ -49,6 +52,10 @@ class AppDatabase extends _$AppDatabase {
// Nothing else to do here
}, from2To3: (m, schema) async {
// Nothing else to do here, too
}, from3To4: (m, schema) async {
m.createTable(schema.snLocalRealm);
m.createIndex(schema.idxRealmAccount);
m.createIndex(schema.idxRealmAlias);
}),
);
}

View File

@ -23,8 +23,6 @@ class $SnLocalChatChannelTable extends SnLocalChatChannel
late final GeneratedColumn<String> alias = GeneratedColumn<String>(
'alias', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override
late final GeneratedColumnWithTypeConverter<SnChannel, String> content =
GeneratedColumn<String>('content', aliasedName, false,
@ -60,7 +58,6 @@ class $SnLocalChatChannelTable extends SnLocalChatChannel
} else if (isInserting) {
context.missing(_aliasMeta);
}
context.handle(_contentMeta, const VerificationResult.success());
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
@ -295,8 +292,6 @@ class $SnLocalChatMessageTable extends SnLocalChatMessage
late final GeneratedColumn<int> senderId = GeneratedColumn<int>(
'sender_id', aliasedName, true,
type: DriftSqlType.int, requiredDuringInsert: false);
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override
late final GeneratedColumnWithTypeConverter<SnChatMessage, String> content =
GeneratedColumn<String>('content', aliasedName, false,
@ -338,7 +333,6 @@ class $SnLocalChatMessageTable extends SnLocalChatMessage
context.handle(_senderIdMeta,
senderId.isAcceptableOrUnknown(data['sender_id']!, _senderIdMeta));
}
context.handle(_contentMeta, const VerificationResult.success());
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
@ -604,8 +598,6 @@ class $SnLocalChannelMemberTable extends SnLocalChannelMember
late final GeneratedColumn<int> accountId = GeneratedColumn<int>(
'account_id', aliasedName, false,
type: DriftSqlType.int, requiredDuringInsert: true);
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override
late final GeneratedColumnWithTypeConverter<SnChannelMember, String> content =
GeneratedColumn<String>('content', aliasedName, false,
@ -655,7 +647,6 @@ class $SnLocalChannelMemberTable extends SnLocalChannelMember
} else if (isInserting) {
context.missing(_accountIdMeta);
}
context.handle(_contentMeta, const VerificationResult.success());
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
@ -1265,8 +1256,6 @@ class $SnLocalAccountTable extends SnLocalAccount
late final GeneratedColumn<String> name = GeneratedColumn<String>(
'name', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override
late final GeneratedColumnWithTypeConverter<SnAccount, String> content =
GeneratedColumn<String>('content', aliasedName, false,
@ -1308,7 +1297,6 @@ class $SnLocalAccountTable extends SnLocalAccount
} else if (isInserting) {
context.missing(_nameMeta);
}
context.handle(_contentMeta, const VerificationResult.success());
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
@ -1582,8 +1570,6 @@ class $SnLocalAttachmentTable extends SnLocalAttachment
type: DriftSqlType.string,
requiredDuringInsert: true,
defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'));
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override
late final GeneratedColumnWithTypeConverter<SnAttachment, String> content =
GeneratedColumn<String>('content', aliasedName, false,
@ -1639,7 +1625,6 @@ class $SnLocalAttachmentTable extends SnLocalAttachment
} else if (isInserting) {
context.missing(_uuidMeta);
}
context.handle(_contentMeta, const VerificationResult.success());
if (data.containsKey('account_id')) {
context.handle(_accountIdMeta,
accountId.isAcceptableOrUnknown(data['account_id']!, _accountIdMeta));
@ -1968,8 +1953,6 @@ class $SnLocalStickerTable extends SnLocalSticker
late final GeneratedColumn<String> fullAlias = GeneratedColumn<String>(
'full_alias', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override
late final GeneratedColumnWithTypeConverter<SnSticker, String> content =
GeneratedColumn<String>('content', aliasedName, false,
@ -2011,7 +1994,6 @@ class $SnLocalStickerTable extends SnLocalSticker
} else if (isInserting) {
context.missing(_fullAliasMeta);
}
context.handle(_contentMeta, const VerificationResult.success());
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
@ -2261,8 +2243,6 @@ class $SnLocalStickerPackTable extends SnLocalStickerPack
requiredDuringInsert: false,
defaultConstraints:
GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override
late final GeneratedColumnWithTypeConverter<SnStickerPack, String> content =
GeneratedColumn<String>('content', aliasedName, false,
@ -2293,7 +2273,6 @@ class $SnLocalStickerPackTable extends SnLocalStickerPack
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
}
context.handle(_contentMeta, const VerificationResult.success());
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
@ -2475,6 +2454,351 @@ class SnLocalStickerPackCompanion
}
}
class $SnLocalRealmTable extends SnLocalRealm
with TableInfo<$SnLocalRealmTable, SnLocalRealmData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$SnLocalRealmTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _idMeta = const VerificationMeta('id');
@override
late final GeneratedColumn<int> id = GeneratedColumn<int>(
'id', aliasedName, false,
hasAutoIncrement: true,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultConstraints:
GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
static const VerificationMeta _aliasMeta = const VerificationMeta('alias');
@override
late final GeneratedColumn<String> alias = GeneratedColumn<String>(
'alias', aliasedName, false,
type: DriftSqlType.string,
requiredDuringInsert: true,
defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'));
@override
late final GeneratedColumnWithTypeConverter<SnRealm, String> content =
GeneratedColumn<String>('content', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true)
.withConverter<SnRealm>($SnLocalRealmTable.$convertercontent);
static const VerificationMeta _accountIdMeta =
const VerificationMeta('accountId');
@override
late final GeneratedColumn<int> accountId = GeneratedColumn<int>(
'account_id', aliasedName, false,
type: DriftSqlType.int, requiredDuringInsert: true);
static const VerificationMeta _createdAtMeta =
const VerificationMeta('createdAt');
@override
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at', aliasedName, false,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: currentDateAndTime);
static const VerificationMeta _cacheExpiredAtMeta =
const VerificationMeta('cacheExpiredAt');
@override
late final GeneratedColumn<DateTime> cacheExpiredAt =
GeneratedColumn<DateTime>('cache_expired_at', aliasedName, false,
type: DriftSqlType.dateTime, requiredDuringInsert: true);
@override
List<GeneratedColumn> get $columns =>
[id, alias, content, accountId, createdAt, cacheExpiredAt];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'sn_local_realm';
@override
VerificationContext validateIntegrity(Insertable<SnLocalRealmData> instance,
{bool isInserting = false}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
}
if (data.containsKey('alias')) {
context.handle(
_aliasMeta, alias.isAcceptableOrUnknown(data['alias']!, _aliasMeta));
} else if (isInserting) {
context.missing(_aliasMeta);
}
if (data.containsKey('account_id')) {
context.handle(_accountIdMeta,
accountId.isAcceptableOrUnknown(data['account_id']!, _accountIdMeta));
} else if (isInserting) {
context.missing(_accountIdMeta);
}
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
}
if (data.containsKey('cache_expired_at')) {
context.handle(
_cacheExpiredAtMeta,
cacheExpiredAt.isAcceptableOrUnknown(
data['cache_expired_at']!, _cacheExpiredAtMeta));
} else if (isInserting) {
context.missing(_cacheExpiredAtMeta);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
SnLocalRealmData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return SnLocalRealmData(
id: attachedDatabase.typeMapping
.read(DriftSqlType.int, data['${effectivePrefix}id'])!,
alias: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}alias'])!,
content: $SnLocalRealmTable.$convertercontent.fromSql(attachedDatabase
.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}content'])!),
accountId: attachedDatabase.typeMapping
.read(DriftSqlType.int, data['${effectivePrefix}account_id'])!,
createdAt: attachedDatabase.typeMapping
.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!,
cacheExpiredAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime, data['${effectivePrefix}cache_expired_at'])!,
);
}
@override
$SnLocalRealmTable createAlias(String alias) {
return $SnLocalRealmTable(attachedDatabase, alias);
}
static JsonTypeConverter2<SnRealm, String, Map<String, Object?>>
$convertercontent = const SnRealmConverter();
}
class SnLocalRealmData extends DataClass
implements Insertable<SnLocalRealmData> {
final int id;
final String alias;
final SnRealm content;
final int accountId;
final DateTime createdAt;
final DateTime cacheExpiredAt;
const SnLocalRealmData(
{required this.id,
required this.alias,
required this.content,
required this.accountId,
required this.createdAt,
required this.cacheExpiredAt});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<int>(id);
map['alias'] = Variable<String>(alias);
{
map['content'] =
Variable<String>($SnLocalRealmTable.$convertercontent.toSql(content));
}
map['account_id'] = Variable<int>(accountId);
map['created_at'] = Variable<DateTime>(createdAt);
map['cache_expired_at'] = Variable<DateTime>(cacheExpiredAt);
return map;
}
SnLocalRealmCompanion toCompanion(bool nullToAbsent) {
return SnLocalRealmCompanion(
id: Value(id),
alias: Value(alias),
content: Value(content),
accountId: Value(accountId),
createdAt: Value(createdAt),
cacheExpiredAt: Value(cacheExpiredAt),
);
}
factory SnLocalRealmData.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return SnLocalRealmData(
id: serializer.fromJson<int>(json['id']),
alias: serializer.fromJson<String>(json['alias']),
content: $SnLocalRealmTable.$convertercontent
.fromJson(serializer.fromJson<Map<String, Object?>>(json['content'])),
accountId: serializer.fromJson<int>(json['accountId']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
cacheExpiredAt: serializer.fromJson<DateTime>(json['cacheExpiredAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'alias': serializer.toJson<String>(alias),
'content': serializer.toJson<Map<String, Object?>>(
$SnLocalRealmTable.$convertercontent.toJson(content)),
'accountId': serializer.toJson<int>(accountId),
'createdAt': serializer.toJson<DateTime>(createdAt),
'cacheExpiredAt': serializer.toJson<DateTime>(cacheExpiredAt),
};
}
SnLocalRealmData copyWith(
{int? id,
String? alias,
SnRealm? content,
int? accountId,
DateTime? createdAt,
DateTime? cacheExpiredAt}) =>
SnLocalRealmData(
id: id ?? this.id,
alias: alias ?? this.alias,
content: content ?? this.content,
accountId: accountId ?? this.accountId,
createdAt: createdAt ?? this.createdAt,
cacheExpiredAt: cacheExpiredAt ?? this.cacheExpiredAt,
);
SnLocalRealmData copyWithCompanion(SnLocalRealmCompanion data) {
return SnLocalRealmData(
id: data.id.present ? data.id.value : this.id,
alias: data.alias.present ? data.alias.value : this.alias,
content: data.content.present ? data.content.value : this.content,
accountId: data.accountId.present ? data.accountId.value : this.accountId,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
cacheExpiredAt: data.cacheExpiredAt.present
? data.cacheExpiredAt.value
: this.cacheExpiredAt,
);
}
@override
String toString() {
return (StringBuffer('SnLocalRealmData(')
..write('id: $id, ')
..write('alias: $alias, ')
..write('content: $content, ')
..write('accountId: $accountId, ')
..write('createdAt: $createdAt, ')
..write('cacheExpiredAt: $cacheExpiredAt')
..write(')'))
.toString();
}
@override
int get hashCode =>
Object.hash(id, alias, content, accountId, createdAt, cacheExpiredAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is SnLocalRealmData &&
other.id == this.id &&
other.alias == this.alias &&
other.content == this.content &&
other.accountId == this.accountId &&
other.createdAt == this.createdAt &&
other.cacheExpiredAt == this.cacheExpiredAt);
}
class SnLocalRealmCompanion extends UpdateCompanion<SnLocalRealmData> {
final Value<int> id;
final Value<String> alias;
final Value<SnRealm> content;
final Value<int> accountId;
final Value<DateTime> createdAt;
final Value<DateTime> cacheExpiredAt;
const SnLocalRealmCompanion({
this.id = const Value.absent(),
this.alias = const Value.absent(),
this.content = const Value.absent(),
this.accountId = const Value.absent(),
this.createdAt = const Value.absent(),
this.cacheExpiredAt = const Value.absent(),
});
SnLocalRealmCompanion.insert({
this.id = const Value.absent(),
required String alias,
required SnRealm content,
required int accountId,
this.createdAt = const Value.absent(),
required DateTime cacheExpiredAt,
}) : alias = Value(alias),
content = Value(content),
accountId = Value(accountId),
cacheExpiredAt = Value(cacheExpiredAt);
static Insertable<SnLocalRealmData> custom({
Expression<int>? id,
Expression<String>? alias,
Expression<String>? content,
Expression<int>? accountId,
Expression<DateTime>? createdAt,
Expression<DateTime>? cacheExpiredAt,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (alias != null) 'alias': alias,
if (content != null) 'content': content,
if (accountId != null) 'account_id': accountId,
if (createdAt != null) 'created_at': createdAt,
if (cacheExpiredAt != null) 'cache_expired_at': cacheExpiredAt,
});
}
SnLocalRealmCompanion copyWith(
{Value<int>? id,
Value<String>? alias,
Value<SnRealm>? content,
Value<int>? accountId,
Value<DateTime>? createdAt,
Value<DateTime>? cacheExpiredAt}) {
return SnLocalRealmCompanion(
id: id ?? this.id,
alias: alias ?? this.alias,
content: content ?? this.content,
accountId: accountId ?? this.accountId,
createdAt: createdAt ?? this.createdAt,
cacheExpiredAt: cacheExpiredAt ?? this.cacheExpiredAt,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<int>(id.value);
}
if (alias.present) {
map['alias'] = Variable<String>(alias.value);
}
if (content.present) {
map['content'] = Variable<String>(
$SnLocalRealmTable.$convertercontent.toSql(content.value));
}
if (accountId.present) {
map['account_id'] = Variable<int>(accountId.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
if (cacheExpiredAt.present) {
map['cache_expired_at'] = Variable<DateTime>(cacheExpiredAt.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('SnLocalRealmCompanion(')
..write('id: $id, ')
..write('alias: $alias, ')
..write('content: $content, ')
..write('accountId: $accountId, ')
..write('createdAt: $createdAt, ')
..write('cacheExpiredAt: $cacheExpiredAt')
..write(')'))
.toString();
}
}
abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this);
@ -2491,6 +2815,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
late final $SnLocalStickerTable snLocalSticker = $SnLocalStickerTable(this);
late final $SnLocalStickerPackTable snLocalStickerPack =
$SnLocalStickerPackTable(this);
late final $SnLocalRealmTable snLocalRealm = $SnLocalRealmTable(this);
late final Index idxChannelAlias = Index('idx_channel_alias',
'CREATE INDEX idx_channel_alias ON sn_local_chat_channel (alias)');
late final Index idxChatChannel = Index('idx_chat_channel',
@ -2501,6 +2826,10 @@ abstract class _$AppDatabase extends GeneratedDatabase {
'CREATE INDEX idx_attachment_rid ON sn_local_attachment (rid)');
late final Index idxAttachmentAccount = Index('idx_attachment_account',
'CREATE INDEX idx_attachment_account ON sn_local_attachment (account_id)');
late final Index idxRealmAlias = Index('idx_realm_alias',
'CREATE INDEX idx_realm_alias ON sn_local_realm (alias)');
late final Index idxRealmAccount = Index('idx_realm_account',
'CREATE INDEX idx_realm_account ON sn_local_realm (account_id)');
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@ -2514,11 +2843,14 @@ abstract class _$AppDatabase extends GeneratedDatabase {
snLocalAttachment,
snLocalSticker,
snLocalStickerPack,
snLocalRealm,
idxChannelAlias,
idxChatChannel,
idxAccountName,
idxAttachmentRid,
idxAttachmentAccount
idxAttachmentAccount,
idxRealmAlias,
idxRealmAccount
];
}
@ -3909,6 +4241,192 @@ typedef $$SnLocalStickerPackTableProcessedTableManager = ProcessedTableManager<
),
SnLocalStickerPackData,
PrefetchHooks Function()>;
typedef $$SnLocalRealmTableCreateCompanionBuilder = SnLocalRealmCompanion
Function({
Value<int> id,
required String alias,
required SnRealm content,
required int accountId,
Value<DateTime> createdAt,
required DateTime cacheExpiredAt,
});
typedef $$SnLocalRealmTableUpdateCompanionBuilder = SnLocalRealmCompanion
Function({
Value<int> id,
Value<String> alias,
Value<SnRealm> content,
Value<int> accountId,
Value<DateTime> createdAt,
Value<DateTime> cacheExpiredAt,
});
class $$SnLocalRealmTableFilterComposer
extends Composer<_$AppDatabase, $SnLocalRealmTable> {
$$SnLocalRealmTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<int> get id => $composableBuilder(
column: $table.id, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get alias => $composableBuilder(
column: $table.alias, builder: (column) => ColumnFilters(column));
ColumnWithTypeConverterFilters<SnRealm, SnRealm, String> get content =>
$composableBuilder(
column: $table.content,
builder: (column) => ColumnWithTypeConverterFilters(column));
ColumnFilters<int> get accountId => $composableBuilder(
column: $table.accountId, builder: (column) => ColumnFilters(column));
ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt, builder: (column) => ColumnFilters(column));
ColumnFilters<DateTime> get cacheExpiredAt => $composableBuilder(
column: $table.cacheExpiredAt,
builder: (column) => ColumnFilters(column));
}
class $$SnLocalRealmTableOrderingComposer
extends Composer<_$AppDatabase, $SnLocalRealmTable> {
$$SnLocalRealmTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<int> get id => $composableBuilder(
column: $table.id, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get alias => $composableBuilder(
column: $table.alias, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get content => $composableBuilder(
column: $table.content, builder: (column) => ColumnOrderings(column));
ColumnOrderings<int> get accountId => $composableBuilder(
column: $table.accountId, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> get cacheExpiredAt => $composableBuilder(
column: $table.cacheExpiredAt,
builder: (column) => ColumnOrderings(column));
}
class $$SnLocalRealmTableAnnotationComposer
extends Composer<_$AppDatabase, $SnLocalRealmTable> {
$$SnLocalRealmTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get alias =>
$composableBuilder(column: $table.alias, builder: (column) => column);
GeneratedColumnWithTypeConverter<SnRealm, String> get content =>
$composableBuilder(column: $table.content, builder: (column) => column);
GeneratedColumn<int> get accountId =>
$composableBuilder(column: $table.accountId, builder: (column) => column);
GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
GeneratedColumn<DateTime> get cacheExpiredAt => $composableBuilder(
column: $table.cacheExpiredAt, builder: (column) => column);
}
class $$SnLocalRealmTableTableManager extends RootTableManager<
_$AppDatabase,
$SnLocalRealmTable,
SnLocalRealmData,
$$SnLocalRealmTableFilterComposer,
$$SnLocalRealmTableOrderingComposer,
$$SnLocalRealmTableAnnotationComposer,
$$SnLocalRealmTableCreateCompanionBuilder,
$$SnLocalRealmTableUpdateCompanionBuilder,
(
SnLocalRealmData,
BaseReferences<_$AppDatabase, $SnLocalRealmTable, SnLocalRealmData>
),
SnLocalRealmData,
PrefetchHooks Function()> {
$$SnLocalRealmTableTableManager(_$AppDatabase db, $SnLocalRealmTable table)
: super(TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$SnLocalRealmTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$SnLocalRealmTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$SnLocalRealmTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback: ({
Value<int> id = const Value.absent(),
Value<String> alias = const Value.absent(),
Value<SnRealm> content = const Value.absent(),
Value<int> accountId = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<DateTime> cacheExpiredAt = const Value.absent(),
}) =>
SnLocalRealmCompanion(
id: id,
alias: alias,
content: content,
accountId: accountId,
createdAt: createdAt,
cacheExpiredAt: cacheExpiredAt,
),
createCompanionCallback: ({
Value<int> id = const Value.absent(),
required String alias,
required SnRealm content,
required int accountId,
Value<DateTime> createdAt = const Value.absent(),
required DateTime cacheExpiredAt,
}) =>
SnLocalRealmCompanion.insert(
id: id,
alias: alias,
content: content,
accountId: accountId,
createdAt: createdAt,
cacheExpiredAt: cacheExpiredAt,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
));
}
typedef $$SnLocalRealmTableProcessedTableManager = ProcessedTableManager<
_$AppDatabase,
$SnLocalRealmTable,
SnLocalRealmData,
$$SnLocalRealmTableFilterComposer,
$$SnLocalRealmTableOrderingComposer,
$$SnLocalRealmTableAnnotationComposer,
$$SnLocalRealmTableCreateCompanionBuilder,
$$SnLocalRealmTableUpdateCompanionBuilder,
(
SnLocalRealmData,
BaseReferences<_$AppDatabase, $SnLocalRealmTable, SnLocalRealmData>
),
SnLocalRealmData,
PrefetchHooks Function()>;
class $AppDatabaseManager {
final _$AppDatabase _db;
@ -3929,4 +4447,6 @@ class $AppDatabaseManager {
$$SnLocalStickerTableTableManager(_db, _db.snLocalSticker);
$$SnLocalStickerPackTableTableManager get snLocalStickerPack =>
$$SnLocalStickerPackTableTableManager(_db, _db.snLocalStickerPack);
$$SnLocalRealmTableTableManager get snLocalRealm =>
$$SnLocalRealmTableTableManager(_db, _db.snLocalRealm);
}

View File

@ -412,9 +412,214 @@ class Shape8 extends i0.VersionedTable {
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
}
final class Schema4 extends i0.VersionedSchema {
Schema4({required super.database}) : super(version: 4);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
snLocalChatChannel,
snLocalChatMessage,
snLocalChannelMember,
snLocalKeyPair,
snLocalAccount,
snLocalAttachment,
snLocalSticker,
snLocalStickerPack,
snLocalRealm,
idxChannelAlias,
idxChatChannel,
idxAccountName,
idxAttachmentRid,
idxAttachmentAccount,
idxRealmAlias,
idxRealmAccount,
];
late final Shape0 snLocalChatChannel = Shape0(
source: i0.VersionedTable(
entityName: 'sn_local_chat_channel',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_1,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape3 snLocalChatMessage = Shape3(
source: i0.VersionedTable(
entityName: 'sn_local_chat_message',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_4,
_column_10,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape4 snLocalChannelMember = Shape4(
source: i0.VersionedTable(
entityName: 'sn_local_channel_member',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_4,
_column_6,
_column_2,
_column_3,
_column_11,
],
attachedDatabase: database,
),
alias: null);
late final Shape2 snLocalKeyPair = Shape2(
source: i0.VersionedTable(
entityName: 'sn_local_key_pair',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(id)',
],
columns: [
_column_5,
_column_6,
_column_7,
_column_8,
_column_9,
],
attachedDatabase: database,
),
alias: null);
late final Shape5 snLocalAccount = Shape5(
source: i0.VersionedTable(
entityName: 'sn_local_account',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_12,
_column_2,
_column_3,
_column_11,
],
attachedDatabase: database,
),
alias: null);
late final Shape6 snLocalAttachment = Shape6(
source: i0.VersionedTable(
entityName: 'sn_local_attachment',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_13,
_column_14,
_column_2,
_column_6,
_column_3,
_column_11,
],
attachedDatabase: database,
),
alias: null);
late final Shape7 snLocalSticker = Shape7(
source: i0.VersionedTable(
entityName: 'sn_local_sticker',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_1,
_column_15,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape8 snLocalStickerPack = Shape8(
source: i0.VersionedTable(
entityName: 'sn_local_sticker_pack',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape9 snLocalRealm = Shape9(
source: i0.VersionedTable(
entityName: 'sn_local_realm',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_16,
_column_2,
_column_6,
_column_3,
_column_11,
],
attachedDatabase: database,
),
alias: null);
final i1.Index idxChannelAlias = i1.Index('idx_channel_alias',
'CREATE INDEX idx_channel_alias ON sn_local_chat_channel (alias)');
final i1.Index idxChatChannel = i1.Index('idx_chat_channel',
'CREATE INDEX idx_chat_channel ON sn_local_chat_message (channel_id)');
final i1.Index idxAccountName = i1.Index('idx_account_name',
'CREATE INDEX idx_account_name ON sn_local_account (name)');
final i1.Index idxAttachmentRid = i1.Index('idx_attachment_rid',
'CREATE INDEX idx_attachment_rid ON sn_local_attachment (rid)');
final i1.Index idxAttachmentAccount = i1.Index('idx_attachment_account',
'CREATE INDEX idx_attachment_account ON sn_local_attachment (account_id)');
final i1.Index idxRealmAlias = i1.Index('idx_realm_alias',
'CREATE INDEX idx_realm_alias ON sn_local_realm (alias)');
final i1.Index idxRealmAccount = i1.Index('idx_realm_account',
'CREATE INDEX idx_realm_account ON sn_local_realm (account_id)');
}
class Shape9 extends i0.VersionedTable {
Shape9({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get alias =>
columnsByName['alias']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get content =>
columnsByName['content']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get accountId =>
columnsByName['account_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<String> _column_16(String aliasedName) =>
i1.GeneratedColumn<String>('alias', aliasedName, false,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'));
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@ -428,6 +633,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from2To3(migrator, schema);
return 3;
case 3:
final schema = Schema4(database: database);
final migrator = i1.Migrator(database, schema);
await from3To4(migrator, schema);
return 4;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@ -437,9 +647,11 @@ i0.MigrationStepWithVersion migrationSteps({
i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
}) =>
i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
from2To3: from2To3,
from3To4: from3To4,
));

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

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

View File

@ -3,6 +3,7 @@ import 'dart:developer';
import 'dart:io';
import 'dart:ui';
import 'package:audioplayers/audioplayers.dart';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:croppy/croppy.dart';
import 'package:dio/dio.dart';
@ -12,17 +13,19 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/firebase_options.dart';
import 'package:surface/logger.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/chat_call.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/database.dart';
import 'package:surface/providers/keypair.dart';
@ -37,6 +40,7 @@ import 'package:surface/providers/sn_realm.dart';
import 'package:surface/providers/sn_sticker.dart';
import 'package:surface/providers/special_day.dart';
import 'package:surface/providers/theme.dart';
import 'package:surface/providers/translation.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/websocket.dart';
@ -44,6 +48,9 @@ import 'package:surface/providers/widget.dart';
import 'package:surface/router.dart';
import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/menu_bar.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/version_label.dart';
import 'package:tray_manager/tray_manager.dart';
import 'package:version/version.dart';
import 'package:workmanager/workmanager.dart';
@ -51,6 +58,7 @@ import 'package:in_app_review/in_app_review.dart';
import 'package:image_picker_android/image_picker_android.dart';
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
import 'package:local_notifier/local_notifier.dart';
import 'package:flutter_animate/flutter_animate.dart';
@pragma('vm:entry-point')
void appBackgroundDispatcher() {
@ -69,13 +77,40 @@ void appBackgroundDispatcher() {
});
}
// Desktop size tools
Future<Size> _getSavedWindowSize() async {
final prefs = await SharedPreferences.getInstance();
String? sizeString = prefs.getString(kAppWindowSize);
if (sizeString != null) {
List<String> parts = sizeString.split('x');
if (parts.length == 2) {
double? width = double.tryParse(parts[0]);
double? height = double.tryParse(parts[1]);
if (width != null && height != null) {
return Size(width, height);
}
}
}
return const Size(1280, 720); // Default size
}
Future<void> _saveWindowSize() async {
final prefs = await SharedPreferences.getInstance();
final size = appWindow.size;
await prefs.setString(kAppWindowSize, '${size.width}x${size.height}');
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
final Size savedSize = await _getSavedWindowSize();
doWhenWindowReady(() {
appWindow.minSize = Size(480, 640);
appWindow.size = Size(1280, 720);
appWindow.size = savedSize;
appWindow.alignment = Alignment.center;
appWindow.show();
});
@ -85,18 +120,15 @@ void main() async {
if (!kIsWeb && !Platform.isLinux) {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
options: DefaultFirebaseOptions.currentPlatform);
}
GoRouter.optionURLReflectsImperativeAPIs = true;
usePathUrlStrategy();
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
Workmanager().initialize(
appBackgroundDispatcher,
isInDebugMode: kDebugMode,
);
Workmanager()
.initialize(appBackgroundDispatcher, isInDebugMode: kDebugMode);
if (Platform.isAndroid) {
Workmanager().registerPeriodicTask(
"widget-update-random-post",
@ -131,7 +163,7 @@ class SolianApp extends StatelessWidget {
Locale('en', 'US'),
Locale('zh', 'CN'),
Locale('zh', 'TW'),
Locale('zh', 'HK'),
Locale('zh', 'HK')
],
fallbackLocale: Locale('en', 'US'),
useFallbackTranslations: true,
@ -155,7 +187,7 @@ class SolianApp extends StatelessWidget {
Provider(create: (ctx) => SnNetworkProvider(ctx)),
Provider(create: (ctx) => UserDirectoryProvider(ctx)),
Provider(create: (ctx) => SnAttachmentProvider(ctx)),
Provider(create: (ctx) => SnRealmProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => SnRealmProvider(ctx)),
Provider(create: (ctx) => SnPostContentProvider(ctx)),
Provider(create: (ctx) => SnRelationshipProvider(ctx)),
Provider(create: (ctx) => SnLinkPreviewProvider(ctx)),
@ -165,7 +197,7 @@ class SolianApp extends StatelessWidget {
Provider(create: (ctx) => KeyPairProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
Provider(create: (ctx) => SnTranslator()),
// Additional helper layer
Provider(create: (ctx) => SpecialDayProvider(ctx)),
@ -225,6 +257,10 @@ class _AppSplashScreen extends StatefulWidget {
}
class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
bool _isBusy = false;
double _initPercentage = 0;
String _phaseText = 'appInitStarting';
void _tryRequestRating() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.containsKey('first_boot_time')) {
@ -253,12 +289,10 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
final localVersionString = '${info.version}+${info.buildNumber}';
final resp = await Dio(
BaseOptions(
sendTimeout: const Duration(seconds: 60),
receiveTimeout: const Duration(seconds: 60),
),
sendTimeout: const Duration(seconds: 60),
receiveTimeout: const Duration(seconds: 60)),
).get(
'https://api.github.com/repos/Solsynth/HyperNet.Surface/releases/latest',
);
'https://api.github.com/repos/Solsynth/HyperNet.Surface/releases/latest');
final remoteVersionString = resp.data?['tag_name'] ?? '0.0.0+0';
final remoteVersion = Version.parse(remoteVersionString.split('+').first);
final localVersion = Version.parse(localVersionString.split('+').first);
@ -282,6 +316,11 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
}
}
void _setPhaseText(String text) {
_phaseText = 'appInit${text.capitalize()}'.tr();
if (mounted) setState(() {});
}
Future<void> _initialize() async {
try {
final cfg = context.read<ConfigProvider>();
@ -294,31 +333,57 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
// The Network initialization must be done after the HomeWidget initialization
// The Network initialization will save the server url to the HomeWidget
// The Network initialization will also save initialize the Config, so it not need to be initialized again
_initPercentage = 0.1;
_setPhaseText('network');
final sn = context.read<SnNetworkProvider>();
await sn.initializeUserAgent();
await sn.setConfigWithNative();
if (!mounted) return;
_initPercentage = 0.2;
_setPhaseText('userdata');
final ua = context.read<UserProvider>();
await ua.initialize();
if (!mounted) return;
_initPercentage = 0.3;
_setPhaseText('websocket');
final ws = context.read<WebSocketProvider>();
await ws.tryConnect();
if (!mounted) return;
final notify = context.read<NotificationProvider>();
notify.listen();
await notify.registerPushNotifications();
if (!mounted) return;
final kp = context.read<KeyPairProvider>();
await kp.reloadActive();
kp.listen();
if (!mounted) return;
final sticker = context.read<SnStickerProvider>();
await sticker.listSticker();
if (!mounted) return;
final ud = context.read<UserDirectoryProvider>();
final userCacheSize = await ud.loadAccountCache();
logging.info('[Users] Loaded local user cache, size: $userCacheSize');
logging.info('[Bootstrap] Everything initialized!');
try {
if (!mounted) return;
_initPercentage = 0.9;
_setPhaseText('keyPair');
final kp = context.read<KeyPairProvider>();
kp.reloadActive();
kp.listen();
} catch (_) {}
if (ua.isAuthorized) {
if (!mounted) return;
_setPhaseText('notification');
final notify = context.read<NotificationProvider>();
notify.listen();
try {
notify.registerPushNotifications();
if (!mounted) return;
_setPhaseText('stickers');
final sticker = context.read<SnStickerProvider>();
await sticker.listSticker();
if (!mounted) return;
_setPhaseText('userDirectory');
final ud = context.read<UserDirectoryProvider>();
await ud.loadAccountCache();
if (!mounted) return;
_setPhaseText('realm');
final rm = context.read<SnRealmProvider>();
await rm.refreshAvailableRealms();
if (!mounted) return;
_setPhaseText('chat');
final ct = context.read<ChatChannelProvider>();
await ct.refreshAvailableChannels();
_initPercentage = 1;
_setPhaseText('done');
} catch (_) {}
_playIntro();
}
} catch (err) {
if (!mounted) return;
await context.showErrorDialog(err);
@ -331,42 +396,45 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
Future<void> _hotkeyInitialization() async {
if (kIsWeb) return;
// The quit key has been removed, and the logic of the quit key is moved to system menu bar activator.
}
if (Platform.isMacOS) {
HotKey quitHotKey = HotKey(
key: PhysicalKeyboardKey.keyQ,
modifiers: [HotKeyModifier.meta],
scope: HotKeyScope.inapp,
);
await hotKeyManager.register(quitHotKey, keyUpHandler: (_) {
_appLifecycleListener?.dispose();
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
});
}
void _playIntro() async {
final cfg = context.read<ConfigProvider>();
if (!cfg.soundEffects) return;
final date = DateTime.now();
final player = AudioPlayer(playerId: 'launch-done-player');
await player.play(
(cfg.aprilFoolFeatures && date.month == 4 && date.day == 1)
? AssetSource('audio/sfx/launch-intro.mp3')
: AssetSource('audio/sfx/launch-done.mp3'),
volume: 0.8,
ctx: AudioContext(
android: AudioContextAndroid(
contentType: AndroidContentType.sonification,
usageType: AndroidUsageType.notificationEvent,
),
iOS: AudioContextIOS(category: AVAudioSessionCategory.ambient),
),
mode: PlayerMode.lowLatency,
);
player.onPlayerComplete.listen((_) {
player.dispose();
});
}
final Menu _appTrayMenu = Menu(
items: [
MenuItem(
key: 'version_label',
label: 'Solian',
disabled: true,
),
MenuItem(key: 'version_label', label: 'Solian', disabled: true),
MenuItem.separator(),
MenuItem.checkbox(
checked: false,
key: 'mute_notification',
label: 'trayMenuMuteNotification'.tr(),
),
checked: false,
key: 'mute_notification',
label: 'trayMenuMuteNotification'.tr()),
MenuItem.separator(),
MenuItem(
key: 'window_show',
label: 'trayMenuShow'.tr(),
),
MenuItem(
key: 'exit',
label: 'trayMenuExit'.tr(),
),
MenuItem(key: 'window_show', label: 'trayMenuShow'.tr()),
MenuItem(key: 'exit', label: 'trayMenuExit'.tr()),
],
);
@ -394,9 +462,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
await localNotifier.setup(
appName: 'Solian',
shortcutPolicy: ShortcutPolicy.requireCreate,
);
appName: 'Solian', shortcutPolicy: ShortcutPolicy.requireCreate);
}
AppLifecycleListener? _appLifecycleListener;
@ -405,20 +471,28 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
void initState() {
super.initState();
_isBusy = true;
if (!kIsWeb && !(Platform.isIOS || Platform.isAndroid)) {
_appLifecycleListener = AppLifecycleListener(
onExitRequested: _onExitRequested,
);
_appLifecycleListener =
AppLifecycleListener(onExitRequested: _onExitRequested);
}
_trayInitialization();
_hotkeyInitialization();
_notifyInitialization();
_initialize().then((_) {
_postInitialization();
_tryRequestRating();
_checkForUpdate();
});
try {
_trayInitialization();
_hotkeyInitialization();
_notifyInitialization();
_initialize().then((_) {
_postInitialization();
_tryRequestRating();
_checkForUpdate();
setState(() => _isBusy = false);
}).catchError((err) {
logging.error('[Bootstrap] Unable to initialize app', err);
setState(() => _isBusy = false);
});
} catch (err) {
logging.error('[Bootstrap] Unable to initialize (pre-stage) app', err);
}
}
Future<AppExitResponse> _onExitRequested() async {
@ -426,6 +500,16 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
return AppExitResponse.cancel;
}
void _quitApp() {
_saveWindowSize();
_appLifecycleListener?.dispose();
if (Platform.isWindows) {
appWindow.close();
} else {
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
}
}
@override
void onTrayIconMouseDown() {
if (Platform.isWindows) {
@ -460,12 +544,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
Timer(const Duration(milliseconds: 100), () => appWindow.show());
break;
case 'exit':
_appLifecycleListener?.dispose();
if (Platform.isWindows) {
appWindow.close();
} else {
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
}
_quitApp();
break;
}
}
@ -482,28 +561,268 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
@override
Widget build(BuildContext context) {
final cfg = context.read<ConfigProvider>();
return NotificationListener<SizeChangedLayoutNotification>(
onNotification: (notification) {
WidgetsBinding.instance.addPostFrameCallback((_) {
cfg.calcDrawerSize(context);
});
return false;
},
child: OrientationBuilder(
builder: (context, orientation) {
final cfg = context.read<ConfigProvider>();
return AppSystemMenuBar(
onQuit: _quitApp,
child: NotificationListener<SizeChangedLayoutNotification>(
onNotification: (notification) {
WidgetsBinding.instance.addPostFrameCallback((_) {
cfg.calcDrawerSize(context);
});
Future.delayed(const Duration(milliseconds: 300), () {
if (context.mounted) {
cfg.calcDrawerSize(context);
}
});
return SizeChangedLayoutNotifier(
child: widget.child,
);
return false;
},
child: OrientationBuilder(
builder: (context, orientation) {
final cfg = context.read<ConfigProvider>();
WidgetsBinding.instance.addPostFrameCallback((_) {
cfg.calcDrawerSize(context);
});
Future.delayed(const Duration(milliseconds: 300), () {
if (context.mounted) {
cfg.calcDrawerSize(context);
}
});
return SizeChangedLayoutNotifier(
child: _isBusy
? _AppLoadingScreen(
isBusy: _isBusy,
initPercentage: _initPercentage,
phaseText: _phaseText,
)
: widget.child,
);
},
),
),
);
}
}
class _AppLoadingScreen extends StatelessWidget {
const _AppLoadingScreen({
required this.isBusy,
required this.initPercentage,
required this.phaseText,
});
final bool isBusy;
final double initPercentage;
final String phaseText;
@override
Widget build(BuildContext context) {
if (ResponsiveScaffold.getIsExpand(context)) {
return Material(
key: Key('app-splash-screen-$isBusy'),
child: Stack(
children: [
Container(
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/icon/kanban-1st.jpg'),
fit: BoxFit.cover,
opacity: 0.1,
),
color: Theme.of(context).colorScheme.surface,
backgroundBlendMode: BlendMode.darken,
),
),
Center(
child: Row(
children: [
Expanded(
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: initPercentage),
duration: Duration(milliseconds: 300),
builder: (context, value, _) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${(value * 100).toStringAsFixed(0)}%')
.padding(left: 32, bottom: 4),
LinearProgressIndicator(
value: value,
borderRadius: const BorderRadius.all(
Radius.circular(0),
),
stopIndicatorColor: Colors.transparent,
backgroundColor: Colors.transparent,
),
const Gap(24),
],
),
),
),
Expanded(
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: initPercentage),
duration: Duration(milliseconds: 300),
builder: (context, value, _) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${(value * 100).toStringAsFixed(0)}%')
.padding(right: 32, bottom: 4),
Transform.flip(
flipX: true,
child: LinearProgressIndicator(
value: value,
borderRadius: const BorderRadius.all(
Radius.circular(0),
),
stopIndicatorColor: Colors.transparent,
backgroundColor: Colors.transparent,
),
),
const Gap(24),
],
),
),
),
],
),
),
Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 240, minWidth: 160),
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 24),
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.surface.withOpacity(0.85),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 3,
),
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'splashScreenServer',
style: GoogleFonts.notoSerifHk(height: 1, fontSize: 11),
textAlign: TextAlign.center,
).tr().opacity(0.85),
Text(
'splashScreenServerName',
style: GoogleFonts.notoSerifHk(
fontSize: 24,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
).tr().opacity(0.85),
Text.rich(
TextSpan(
text: '#',
style: GoogleFonts.notoSerifHk(),
children: [
TextSpan(
text: '0',
style: GoogleFonts.notoSerifHk(
fontSize: 80,
fontWeight: FontWeight.bold,
),
),
],
),
textAlign: TextAlign.center,
).padding(vertical: 16),
],
),
),
),
Positioned(
left: 0,
right: 0,
bottom: MediaQuery.of(context).size.height * 0.2,
child: Column(
children: [
Text(
phaseText,
textAlign: TextAlign.center,
),
AnimateWidgetExtensions(Text(
'splashScreenCaption',
textAlign: TextAlign.center,
).tr())
.animate(onPlay: (e) => e.repeat())
.fadeIn(duration: 500.ms, curve: Curves.easeOut)
.then()
.fadeOut(
duration: 500.ms,
delay: 1000.ms,
curve: Curves.easeIn,
),
],
),
),
Positioned(
bottom: 8,
left: 16,
right: 16,
child: Row(
children: [
Image.asset(
'assets/icon/icon.png',
width: 40,
height: 40,
color: Theme.of(context).colorScheme.onSurface,
).padding(all: 4),
const Gap(4),
Text('Solar Network').bold(),
Expanded(child: const SizedBox()),
AppVersionLabel(),
const Gap(12),
],
),
),
],
),
);
}
return Material(
key: Key('app-splash-screen-$isBusy'),
child: Stack(
children: [
Container(
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/icon/kanban-1st.jpg'),
fit: BoxFit.cover,
opacity: 0.1,
),
color: Theme.of(context).colorScheme.surface,
backgroundBlendMode: BlendMode.darken,
),
),
Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 240),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/icon/icon.png',
width: 64,
height: 64,
color: Theme.of(context).colorScheme.onSurface,
),
Text('Solar Network').bold(),
AppVersionLabel(),
Gap(8),
Text(phaseText, textAlign: TextAlign.center),
Gap(16),
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: initPercentage),
duration: Duration(milliseconds: 300),
builder: (context, value, _) =>
LinearProgressIndicator(value: value),
),
],
),
),
),
],
),
);
}

View File

@ -28,6 +28,24 @@ class ChatChannelProvider extends ChangeNotifier {
_rels = context.read<SnRealmProvider>();
}
final List<SnChannel> _availableChannels = List.empty(growable: true);
List<SnChannel> get availableChannels => _availableChannels;
Future<void> refreshAvailableChannels() async {
final stream = fetchChannels();
stream.listen((ele) {
_availableChannels.clear();
_availableChannels.addAll(ele);
notifyListeners();
});
}
void addAvailableChannel(SnChannel channel) {
_availableChannels.add(channel);
notifyListeners();
}
Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async {
await Future.wait(
channels.map(

View File

@ -1,459 +0,0 @@
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

@ -13,12 +13,17 @@ const kNetworkServerStoreKey = 'app_server_url';
const kAppbarTransparentStoreKey = 'app_bar_transparent';
const kAppBackgroundStoreKey = 'app_has_background';
const kAppColorSchemeStoreKey = 'app_color_scheme';
const kAppDrawerPreferCollapse = 'app_drawer_prefer_collapse';
const kAppNotifyWithHaptic = 'app_notify_with_haptic';
const kAppExpandPostLink = 'app_expand_post_link';
const kAppExpandChatLink = 'app_expand_chat_link';
const kAppRealmCompactView = 'app_realm_compact_view';
const kAppCustomFonts = 'app_custom_fonts';
const kAppMixedFeed = 'app_mixed_feed';
const kAppAutoTranslate = 'app_auto_translate';
const kAppHideBottomNav = 'app_hide_bottom_nav';
const kAppSoundEffects = 'app_sound_effects';
const kAppAprilFoolFeatures = 'app_april_fool_features';
const kAppWindowSize = 'app_window_size';
const Map<String, FilterQuality> kImageQualityLevel = {
'settingsImageQualityLowest': FilterQuality.none,
@ -41,27 +46,17 @@ class ConfigProvider extends ChangeNotifier {
}
bool drawerIsCollapsed = false;
bool drawerIsExpanded = false;
void calcDrawerSize(BuildContext context, {bool withMediaQuery = false}) {
bool newDrawerIsCollapsed = false;
bool newDrawerIsExpanded = false;
if (withMediaQuery) {
newDrawerIsCollapsed = MediaQuery.of(context).size.width < 600;
newDrawerIsExpanded = MediaQuery.of(context).size.width >= 601;
} else {
final rpb = ResponsiveBreakpoints.of(context);
newDrawerIsCollapsed = rpb.smallerOrEqualTo(MOBILE);
newDrawerIsExpanded = rpb.largerThan(TABLET)
? (prefs.getBool(kAppDrawerPreferCollapse) ?? false)
? false
: true
: false;
}
if (newDrawerIsExpanded != drawerIsExpanded ||
newDrawerIsCollapsed != drawerIsCollapsed) {
drawerIsExpanded = newDrawerIsExpanded;
if (newDrawerIsCollapsed != drawerIsCollapsed) {
drawerIsCollapsed = newDrawerIsCollapsed;
notifyListeners();
}
@ -81,8 +76,54 @@ class ConfigProvider extends ChangeNotifier {
return prefs.getBool(kAppRealmCompactView) ?? false;
}
bool get mixedFeed {
return prefs.getBool(kAppMixedFeed) ?? true;
}
bool get autoTranslate {
return prefs.getBool(kAppAutoTranslate) ?? false;
}
bool get hideBottomNav {
return prefs.getBool(kAppHideBottomNav) ?? false;
}
bool get aprilFoolFeatures {
return prefs.getBool(kAppAprilFoolFeatures) ?? true;
}
bool get soundEffects {
return prefs.getBool(kAppSoundEffects) ?? true;
}
set soundEffects(bool value) {
prefs.setBool(kAppSoundEffects, value);
notifyListeners();
}
set aprilFoolFeatures(bool value) {
prefs.setBool(kAppAprilFoolFeatures, value);
notifyListeners();
}
set hideBottomNav(bool value) {
prefs.setBool(kAppHideBottomNav, value);
notifyListeners();
}
set autoTranslate(bool value) {
prefs.setBool(kAppAutoTranslate, value);
notifyListeners();
}
set mixedFeed(bool value) {
prefs.setBool(kAppMixedFeed, value);
notifyListeners();
}
set realmCompactView(bool value) {
prefs.setBool(kAppRealmCompactView, value);
notifyListeners();
}
set serverUrl(String url) {

View File

@ -152,7 +152,7 @@ class KeyPairProvider {
Future<SnKeyPair?> reloadActive({bool autoEnroll = true}) async {
final kp = await (_dt.db.snLocalKeyPair.select()
..where((e) => e.accountId.equals(_ua.user!.id))
..where((e) => e.accountId.equals(_ua.user?.id ?? 0))
..where((e) => e.privateKey.isNotNull())
..where((e) => e.isActive.equals(true))
..limit(1))

View File

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

View File

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:audioplayers/audioplayers.dart';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
@ -22,6 +23,8 @@ class NotificationProvider extends ChangeNotifier {
late final WebSocketProvider _ws;
late final ConfigProvider _cfg;
final AudioPlayer _notifySoundPlayer = AudioPlayer(playerId: 'notify-sound');
NotificationProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
_ua = context.read<UserProvider>();
@ -66,14 +69,19 @@ class NotificationProvider extends ChangeNotifier {
}
logging.info('[Push Notification] Device Push Token is $token');
await _sn.client.post(
'/cgi/id/notifications/subscription',
data: {
'provider': provider,
'device_token': token,
'device_id': deviceUuid,
},
);
try {
await _sn.client.post(
'/cgi/id/notifications/subscription',
data: {
'provider': provider,
'device_token': token,
'device_id': deviceUuid
},
);
} catch (err) {
logging.error(
'[Push Notification] Unable to register push notifications: $err');
}
}
int showingCount = 0;
@ -91,6 +99,25 @@ class NotificationProvider extends ChangeNotifier {
final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true;
if (doHaptic) HapticFeedback.mediumImpact();
// April fool notification sfx
if (_cfg.prefs.getBool(kAppAprilFoolFeatures) ?? true) {
final now = DateTime.now();
if (now.day == 1 && now.month == 4) {
_notifySoundPlayer.play(
AssetSource('audio/notify/metal-pipe.mp3'),
volume: 0.6,
ctx: AudioContext(
android: AudioContextAndroid(
contentType: AndroidContentType.sonification,
usageType: AndroidUsageType.notificationEvent,
),
iOS: AudioContextIOS(category: AVAudioSessionCategory.ambient),
),
mode: PlayerMode.lowLatency,
);
}
}
if (notification.topic == 'messaging.message' &&
skippableNotifyChannel != null) {
if (notification.metadata['channel_id'] != null &&

View File

@ -1,134 +1,70 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/sn_realm.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/types/poll.dart';
import 'package:surface/types/post.dart';
import 'package:surface/types/realm.dart';
class SnPostContentProvider {
late final SnNetworkProvider _sn;
late final UserDirectoryProvider _ud;
late final SnAttachmentProvider _attach;
late final SnRealmProvider _realm;
SnPostContentProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
_ud = context.read<UserDirectoryProvider>();
_attach = context.read<SnAttachmentProvider>();
_realm = context.read<SnRealmProvider>();
}
Future<SnPoll> _fetchPoll(int id) async {
final resp = await _sn.client.get('/cgi/co/polls/$id');
return SnPoll.fromJson(resp.data);
}
Future<List<SnPost>> _preloadRelatedDataInBatch(List<SnPost> out) async {
Set<String> rids = {};
Set<int> uids = {};
for (var i = 0; i < out.length; i++) {
rids.addAll(out[i].body['attachments']?.cast<String>() ?? []);
if (out[i].body['thumbnail'] != null) {
rids.add(out[i].body['thumbnail']);
}
if (out[i].body['video'] != null) {
rids.add(out[i].body['video']);
}
if (out[i].repostTo != null) {
out[i] = out[i].copyWith(
repostTo: await _preloadRelatedDataSingle(out[i].repostTo!),
);
}
if (out[i].publisher.type == 0) {
uids.add(out[i].publisher.accountId);
}
}
final attachments = await _attach.getMultiple(rids.toList());
for (var i = 0; i < out.length; i++) {
SnPoll? poll;
SnRealm? realm;
if (out[i].pollId != null) {
poll = await _fetchPoll(out[i].pollId!);
}
if (out[i].realmId != null) {
realm = await _realm.getRealm(out[i].realmId!);
}
out[i] = out[i].copyWith(
preload: SnPostPreload(
thumbnail: attachments.where((ele) => ele?.rid == out[i].body['thumbnail']).firstOrNull,
attachments: attachments.where((ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false).toList(),
video: attachments.where((ele) => ele?.rid == out[i].body['video']).firstOrNull,
poll: poll,
realm: realm,
),
);
}
uids.addAll(attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
await _ud.listAccount(uids);
return out;
}
Future<SnPost> _preloadRelatedDataSingle(SnPost out) async {
Set<String> rids = {};
Set<int> uids = {};
rids.addAll(out.body['attachments']?.cast<String>() ?? []);
if (out.body['thumbnail'] != null) {
rids.add(out.body['thumbnail']);
}
if (out.body['video'] != null) {
rids.add(out.body['video']);
}
if (out.repostTo != null) {
out = out.copyWith(
repostTo: await _preloadRelatedDataSingle(out.repostTo!),
);
}
if (out.publisher.type == 0) {
uids.add(out.publisher.accountId);
}
final attachments = await _attach.getMultiple(rids.toList());
SnPoll? poll;
SnRealm? realm;
if (out.pollId != null) {
poll = await _fetchPoll(out.pollId!);
}
if (out.realmId != null) {
realm = await _realm.getRealm(out.realmId!);
}
out = out.copyWith(
preload: SnPostPreload(
thumbnail: attachments.where((ele) => ele?.rid == out.body['thumbnail']).firstOrNull,
attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(),
video: attachments.where((ele) => ele?.rid == out.body['video']).firstOrNull,
poll: poll,
realm: realm,
),
);
uids.addAll(attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
await _ud.listAccount(uids);
return out;
}
Future<List<SnPost>> listRecommendations() async {
final resp = await _sn.client.get('/cgi/co/recommendations');
final resp = await _sn.client.get(
'/cgi/co/recommendations',
options: Options(headers: {
'X-API-Version': '2',
}),
);
final out = _preloadRelatedDataInBatch(
List.from(resp.data.map((ele) => SnPost.fromJson(ele))),
);
return out;
}
Future<List<SnFeedEntry>> getFeed({int take = 20, DateTime? cursor}) async {
final resp = await _sn.client.get(
'/cgi/co/recommendations/feed',
queryParameters: {
'take': take,
if (cursor != null) 'cursor': cursor.toUtc().millisecondsSinceEpoch,
},
options: Options(headers: {'X-API-Version': '2'}),
);
final List<SnFeedEntry> out =
List.from(resp.data.map((ele) => SnFeedEntry.fromJson(ele)));
List<SnPost> posts = List.empty(growable: true);
for (var idx = 0; idx < out.length; idx++) {
final ele = out[idx];
if (ele.type == 'interactive.post') {
posts.add(SnPost.fromJson(ele.data));
}
}
posts = await _preloadRelatedDataInBatch(posts);
var postsIdx = 0;
for (var idx = 0; idx < out.length; idx++) {
final ele = out[idx];
if (ele.type == 'interactive.post') {
out[idx] = ele.copyWith(data: posts[postsIdx].toJson());
postsIdx++;
}
}
return out;
}
Future<(List<SnPost>, int)> listPosts({
int take = 10,
int offset = 0,
@ -138,17 +74,28 @@ class SnPostContentProvider {
Iterable<String>? tags,
String? realm,
String? channel,
bool isDraft = false,
bool isShuffle = false,
}) async {
final resp = await _sn.client.get('/cgi/co/posts', queryParameters: {
'take': take,
'offset': offset,
if (type != null) 'type': type,
if (author != null) 'author': author,
if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','),
if (realm != null) 'realm': realm,
if (channel != null) 'channel': channel,
});
final resp = await _sn.client.get(
isShuffle
? '/cgi/co/recommendations/shuffle'
: '/cgi/co/posts${isDraft ? '/drafts' : ''}',
queryParameters: {
'take': take,
'offset': offset,
if (type != null) 'type': type,
if (author != null) 'author': author,
if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
if (categories?.isNotEmpty ?? false)
'categories': categories!.join(','),
if (realm != null) 'realm': realm,
if (channel != null) 'channel': channel,
},
options: Options(headers: {
'X-API-Version': '2',
}),
);
final List<SnPost> out = await _preloadRelatedDataInBatch(
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
);
@ -161,10 +108,16 @@ class SnPostContentProvider {
int take = 10,
int offset = 0,
}) async {
final resp = await _sn.client.get('/cgi/co/posts/$parentId/replies', queryParameters: {
'take': take,
'offset': offset,
});
final resp = await _sn.client.get(
'/cgi/co/posts/$parentId/replies',
queryParameters: {
'take': take,
'offset': offset,
},
options: Options(headers: {
'X-API-Version': '2',
}),
);
final List<SnPost> out = await _preloadRelatedDataInBatch(
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
);
@ -179,13 +132,20 @@ class SnPostContentProvider {
Iterable<String>? tags,
Iterable<String>? categories,
}) 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(','),
if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','),
});
final resp = await _sn.client.get(
'/cgi/co/posts/search',
queryParameters: {
'take': take,
'offset': offset,
'probe': searchTerm,
if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
if (categories?.isNotEmpty ?? false)
'categories': categories!.join(','),
},
options: Options(headers: {
'X-API-Version': '2',
}),
);
final List<SnPost> out = await _preloadRelatedDataInBatch(
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
);
@ -194,10 +154,20 @@ class SnPostContentProvider {
}
Future<SnPost> getPost(dynamic id) async {
final resp = await _sn.client.get('/cgi/co/posts/$id');
final resp = await _sn.client.get(
'/cgi/co/posts/$id',
options: Options(headers: {
'X-API-Version': '2',
}),
);
final out = _preloadRelatedDataSingle(
SnPost.fromJson(resp.data),
);
return out;
}
Future<SnPost> completePostData(SnPost post) async {
final out = await _preloadRelatedDataSingle(post);
return out;
}
}

View File

@ -120,6 +120,25 @@ class SnAttachmentProvider {
'webp': 'image/webp',
};
Future<SnAttachment> createWithReferenceLink(
String url,
String pool,
Map<String, dynamic>? metadata, {
String? mimetype,
}) async {
final resp = await _sn.client.post(
'/cgi/uc/attachments/referenced',
data: {
'url': url,
'pool': pool,
'metadata': metadata,
if (mimetype != null) 'mimetype': mimetype,
},
);
return SnAttachment.fromJson(resp.data);
}
Future<SnAttachment> directUploadOne(
Uint8List data,
String filename,
@ -311,6 +330,23 @@ class SnAttachmentProvider {
return out;
}
Future<SnAttachment> rateOne(
SnAttachment item, {
int? content,
int? quality,
}) async {
final resp = await _sn.client.put(
'/cgi/uc/attachments/${item.id}/rating',
data: {
'content_rating': content ?? item.contentRating,
'quality_rating': quality ?? item.qualityRating,
},
);
final out = SnAttachment.fromJson(resp.data);
_saveToLocal([out]);
return out;
}
Future<void> _saveToLocal(Iterable<SnAttachment> out) async {
for (final ele in out) {
if (!ele.isAnalyzed || ele.destination == 0) continue;
@ -321,13 +357,13 @@ class SnAttachmentProvider {
uuid: ele.uuid,
content: ele,
accountId: ele.accountId,
cacheExpiredAt: DateTime.now().add(const Duration(days: 7)),
cacheExpiredAt: DateTime.now().add(const Duration(hours: 1)),
),
onConflict: DoUpdate(
(_) => SnLocalAttachmentCompanion.custom(
content: Constant(jsonEncode(ele.toJson())),
cacheExpiredAt:
Constant(DateTime.now().add(const Duration(days: 7))),
Constant(DateTime.now().add(const Duration(hours: 1))),
),
),
);

View File

@ -17,6 +17,20 @@ import 'package:synchronized/synchronized.dart';
import 'package:talker_dio_logger/talker_dio_logger_interceptor.dart';
import 'package:talker_dio_logger/talker_dio_logger_settings.dart';
enum ServiceStatus { operational, downgraded, failed }
const Map<String, String> kServicesName = {
'ai': 'Insights',
'co': 'Interactive',
're': 'Reader',
'im': 'Messaging',
'ma': 'Matrix',
'uc': 'Paperclip',
'wa': 'Wallet',
'id': 'Passport',
'pusher': 'Pusher',
};
const kNetworkServerDirectory = [
('Solar Network', 'https://api.sn.solsynth.dev'),
('Local', 'http://localhost:8001'),
@ -235,8 +249,11 @@ class SnNetworkProvider {
return null;
}
String getAttachmentUrl(String ky) {
String getAttachmentUrl(String ky, {bool preview = true}) {
if (ky.startsWith("http")) return ky;
if (!preview) {
return '${client.options.baseUrl}/cgi/uc/attachments/$ky?preview=false';
}
return '${client.options.baseUrl}/cgi/uc/attachments/$ky';
}

View File

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

View File

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

View File

@ -19,6 +19,7 @@ class UserDirectoryProvider {
final Map<String, int> _idCache = {};
final Map<int, SnAccount> _cache = {};
DateTime? _cacheExpiredAt;
Future<int> loadAccountCache({int max = 100}) async {
final out = await (_dt.db.snLocalAccount.select()..limit(max)).get();
@ -26,11 +27,18 @@ class UserDirectoryProvider {
_cache[ele.id] = ele.content;
_idCache[ele.name] = ele.id;
}
_cacheExpiredAt = DateTime.now().add(const Duration(hours: 1));
return out.length;
}
Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async {
// In-memory cache
if (_cacheExpiredAt != null && _cacheExpiredAt!.isBefore(DateTime.now())) {
_cache.clear();
_cacheExpiredAt = DateTime.now().add(const Duration(hours: 1));
} else {
_cacheExpiredAt ??= DateTime.now().add(const Duration(hours: 1));
}
final out = List<SnAccount?>.generate(id.length, (e) => null);
final plannedQuery = <int>{};
for (var idx = 0; idx < out.length; idx++) {
@ -62,6 +70,7 @@ class UserDirectoryProvider {
plannedQuery.remove(dbResp[idx].id);
}
// Remote server
_saveToLocal(out.where((ele) => ele != null).cast());
if (plannedQuery.isEmpty) return out;
final resp = await _sn.client
.get('/cgi/id/users', queryParameters: {'id': plannedQuery.join(',')});

View File

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

View File

@ -1,22 +1,27 @@
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/account_settings.dart';
import 'package:surface/screens/account/punishments.dart';
import 'package:surface/screens/account/settings.dart';
import 'package:surface/screens/account/action_events.dart';
import 'package:surface/screens/account/badges.dart';
import 'package:surface/screens/account/contact_methods.dart';
import 'package:surface/screens/account/factor_settings.dart';
import 'package:surface/screens/account/keypairs.dart';
import 'package:surface/screens/account/prefs/notify.dart';
import 'package:surface/screens/account/prefs/security.dart';
import 'package:surface/screens/account/profile_page.dart';
import 'package:surface/screens/account/profile_edit.dart';
import 'package:surface/screens/account/programs.dart';
import 'package:surface/screens/account/publishers/publisher_edit.dart';
import 'package:surface/screens/account/publishers/publisher_new.dart';
import 'package:surface/screens/account/publishers/publishers.dart';
import 'package:surface/screens/account/auth_tickets.dart';
import 'package:surface/screens/album.dart';
import 'package:surface/screens/auth/login.dart';
import 'package:surface/screens/auth/register.dart';
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';
@ -24,14 +29,16 @@ import 'package:surface/screens/explore.dart';
import 'package:surface/screens/friend.dart';
import 'package:surface/screens/home.dart';
import 'package:surface/screens/logging.dart';
import 'package:surface/screens/news/news_detail.dart';
import 'package:surface/screens/news/news_list.dart';
import 'package:surface/screens/feed/feed_detail.dart';
import 'package:surface/screens/notification.dart';
import 'package:surface/screens/post/post_detail.dart';
import 'package:surface/screens/post/post_draft.dart';
import 'package:surface/screens/post/post_editor.dart';
import 'package:surface/screens/post/post_shuffle.dart';
import 'package:surface/screens/post/publisher_page.dart';
import 'package:surface/screens/post/post_search.dart';
import 'package:surface/screens/realm.dart';
import 'package:surface/screens/realm/community.dart';
import 'package:surface/screens/realm/manage.dart';
import 'package:surface/screens/realm/realm_detail.dart';
import 'package:surface/screens/realm/realm_discovery.dart';
@ -44,16 +51,6 @@ import 'package:surface/types/post.dart';
import 'package:surface/widgets/about.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
Widget _fadeThroughTransition(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation, Widget child) {
return FadeThroughTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
fillColor: Colors.transparent,
child: child,
);
}
final _appRoutes = [
GoRoute(
path: '/',
@ -62,14 +59,19 @@ final _appRoutes = [
),
GoRoute(
path: '/posts',
name: 'explore',
builder: (context, state) => const ExploreScreen(),
name: 'posts',
builder: (_, __) => const SizedBox.shrink(),
routes: [
GoRoute(
path: '/write/:mode',
path: '/draft',
name: 'postDraftBox',
builder: (context, state) => const PostDraftBox(),
),
GoRoute(
path: '/write',
name: 'postEditor',
builder: (context, state) => PostEditorScreen(
mode: state.pathParameters['mode']!,
mode: state.uri.queryParameters['mode'],
postEditId: int.tryParse(
state.uri.queryParameters['editing'] ?? '',
),
@ -82,6 +84,11 @@ final _appRoutes = [
extraProps: state.extra as PostEditorExtra?,
),
),
GoRoute(
path: '/shuffle',
name: 'postShuffle',
builder: (context, state) => const PostShuffleScreen(),
),
GoRoute(
path: '/search',
name: 'postSearch',
@ -91,130 +98,208 @@ final _appRoutes = [
state.uri.queryParameters['categories']?.split(','),
),
),
],
),
ShellRoute(
builder: (context, state, child) => ResponsiveScaffold(
asideFlex: 2,
contentFlex: 3,
aside: const ExploreScreen(),
child: child,
),
routes: [
GoRoute(
path: '/explore',
name: 'explore',
builder: (context, state) => const ResponsiveScaffoldLanding(
child: ExploreScreen(),
),
),
GoRoute(
path: '/posts/:slug',
name: 'postDetail',
builder: (context, state) => PostDetailScreen(
key: ValueKey(state.pathParameters['slug']!),
slug: state.pathParameters['slug']!,
preload: state.extra as SnPost?,
),
),
GoRoute(
path: '/pages/:id',
name: 'readerFeedDetail',
builder: (context, state) => ReaderPageScreen(
id: state.pathParameters['id']!,
),
),
GoRoute(
path: '/publishers/:name',
name: 'postPublisher',
builder: (context, state) =>
PostPublisherScreen(name: state.pathParameters['name']!),
),
],
),
ShellRoute(
builder: (context, state, child) => ResponsiveScaffold(
aside: const AccountScreen(),
child: child,
),
routes: [
GoRoute(
path: '/:slug',
name: 'postDetail',
builder: (context, state) => PostDetailScreen(
slug: state.pathParameters['slug']!,
preload: state.extra as SnPost?,
),
path: '/account',
name: 'account',
builder: (context, state) =>
const ResponsiveScaffoldLanding(child: AccountScreen()),
routes: [
GoRoute(
path: '/punishments',
name: 'accountPunishments',
builder: (context, state) => const PunishmentsScreen(),
),
GoRoute(
path: '/programs',
name: 'accountProgram',
builder: (context, state) => const AccountProgramScreen(),
),
GoRoute(
path: '/contacts',
name: 'accountContactMethods',
builder: (context, state) => const AccountContactMethod(),
),
GoRoute(
path: '/events',
name: 'accountActionEvents',
builder: (context, state) => const ActionEventScreen(),
),
GoRoute(
path: '/tickets',
name: 'accountAuthTickets',
builder: (context, state) => const AccountAuthTicket(),
),
GoRoute(
path: '/badges',
name: 'accountBadges',
builder: (context, state) => const AccountBadgesScreen(),
),
GoRoute(
path: '/wallet',
name: 'accountWallet',
builder: (context, state) => const WalletScreen(),
),
GoRoute(
path: '/keypairs',
name: 'accountKeyPairs',
builder: (context, state) => const KeyPairScreen(),
),
GoRoute(
path: '/settings',
name: 'accountSettings',
builder: (context, state) => AccountSettingsScreen(),
routes: [
GoRoute(
path: '/notify',
name: 'accountSettingsNotify',
builder: (context, state) => const AccountNotifyPrefsScreen(),
),
GoRoute(
path: '/auth',
name: 'accountSettingsSecurity',
builder: (context, state) => const AccountSecurityPrefsScreen(),
),
],
),
GoRoute(
path: '/settings/factors',
name: 'factorSettings',
builder: (context, state) => FactorSettingsScreen(),
),
GoRoute(
path: '/profile/edit',
name: 'accountProfileEdit',
builder: (context, state) => ProfileEditScreen(),
),
GoRoute(
path: '/publishers',
name: 'accountPublishers',
builder: (context, state) => PublisherScreen(),
),
GoRoute(
path: '/publishers/new',
name: 'accountPublisherNew',
builder: (context, state) => AccountPublisherNewScreen(),
),
GoRoute(
path: '/publishers/edit/:name',
name: 'accountPublisherEdit',
builder: (context, state) => AccountPublisherEditScreen(
name: state.pathParameters['name']!,
),
),
],
),
],
),
GoRoute(
path: '/account',
name: 'account',
builder: (context, state) => const AccountScreen(),
routes: [
GoRoute(
path: '/badges',
name: 'accountBadges',
builder: (context, state) => const AccountBadgesScreen(),
),
GoRoute(
path: '/wallet',
name: 'accountWallet',
builder: (context, state) => const WalletScreen(),
),
GoRoute(
path: '/keypairs',
name: 'accountKeyPairs',
builder: (context, state) => const KeyPairScreen(),
),
GoRoute(
path: '/settings',
name: 'accountSettings',
builder: (context, state) => AccountSettingsScreen(),
),
GoRoute(
path: '/settings/factors',
name: 'factorSettings',
builder: (context, state) => FactorSettingsScreen(),
),
GoRoute(
path: '/profile/edit',
name: 'accountProfileEdit',
builder: (context, state) => ProfileEditScreen(),
),
GoRoute(
path: '/publishers',
name: 'accountPublishers',
builder: (context, state) => PublisherScreen(),
),
GoRoute(
path: '/publishers/new',
name: 'accountPublisherNew',
builder: (context, state) => AccountPublisherNewScreen(),
),
GoRoute(
path: '/publishers/edit/:name',
name: 'accountPublisherEdit',
builder: (context, state) => AccountPublisherEditScreen(
name: state.pathParameters['name']!,
),
),
GoRoute(
path: '/:name',
name: 'accountProfilePage',
pageBuilder: (context, state) => NoTransitionPage(
child: UserScreen(name: state.pathParameters['name']!),
),
),
],
path: '/accounts/:name',
name: 'accountProfilePage',
pageBuilder: (context, state) => NoTransitionPage(
child: UserScreen(name: state.pathParameters['name']!),
),
),
GoRoute(
path: '/chat',
name: 'chat',
builder: (context, state) => const ChatScreen(),
ShellRoute(
builder: (context, state, child) =>
ResponsiveScaffold(aside: const ChatScreen(), child: child),
routes: [
GoRoute(
path: '/:scope/:alias',
name: 'chatRoom',
builder: (context, state) => ChatRoomScreen(
scope: state.pathParameters['scope']!,
alias: state.pathParameters['alias']!,
extra: state.extra as ChatRoomScreenExtra?,
),
),
GoRoute(
path: '/:scope/:alias/call',
name: 'chatCallRoom',
builder: (context, state) => CallRoomScreen(
scope: state.pathParameters['scope']!,
alias: state.pathParameters['alias']!,
),
),
GoRoute(
path: '/:scope/:alias/detail',
name: 'channelDetail',
builder: (context, state) => ChannelDetailScreen(
scope: state.pathParameters['scope']!,
alias: state.pathParameters['alias']!,
),
),
GoRoute(
path: '/manage',
name: 'chatManage',
builder: (context, state) => ChatManageScreen(
editingChannelAlias: state.uri.queryParameters['editing'],
path: '/chat',
name: 'chat',
builder: (context, state) => const ResponsiveScaffoldLanding(
child: ChatScreen(),
),
routes: [
GoRoute(
path: '/:scope/:alias',
name: 'chatRoom',
builder: (context, state) => ChatRoomScreen(
key: ValueKey(
'${state.pathParameters['scope']!}:${state.pathParameters['alias']!}',
),
scope: state.pathParameters['scope']!,
alias: state.pathParameters['alias']!,
extra: state.extra as ChatRoomScreenExtra?,
),
),
GoRoute(
path: '/:scope/:alias/detail',
name: 'channelDetail',
builder: (context, state) => ChannelDetailScreen(
scope: state.pathParameters['scope']!,
alias: state.pathParameters['alias']!,
),
),
GoRoute(
path: '/manage',
name: 'chatManage',
builder: (context, state) => ChatManageScreen(
editingChannelAlias: state.uri.queryParameters['editing'],
),
),
],
),
],
),
GoRoute(
path: '/realm',
name: 'realm',
pageBuilder: (context, state) => CustomTransitionPage(
transitionsBuilder: _fadeThroughTransition,
child: const RealmScreen(),
),
builder: (context, state) => const RealmScreen(),
routes: [
GoRoute(
path: '/:alias/community',
name: 'realmCommunity',
builder: (context, state) => RealmCommunityScreen(
alias: state.pathParameters['alias']!,
),
),
GoRoute(
path: '/manage',
name: 'realmManage',
@ -235,20 +320,6 @@ final _appRoutes = [
),
],
),
GoRoute(
path: '/news',
name: 'news',
builder: (context, state) => const NewsScreen(),
routes: [
GoRoute(
path: '/:hash',
name: 'newsDetail',
builder: (context, state) => NewsDetailScreen(
hash: state.pathParameters['hash']!,
),
),
],
),
GoRoute(
path: '/stickers',
name: 'stickers',
@ -319,4 +390,10 @@ final appRouter = GoRouter(
),
),
],
onException: (context, state, router) {
if (state.error is GoException) {
router.goNamed('/');
}
},
navigatorKey: GlobalKey(),
);

View File

@ -8,11 +8,13 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/database.dart';
import 'package:surface/providers/navigation.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/websocket.dart';
import 'package:surface/types/account.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/account/account_status.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/universal_image.dart';
@ -20,27 +22,97 @@ import 'package:surface/widgets/universal_image.dart';
class AccountScreen extends StatelessWidget {
const AccountScreen({super.key});
static const List<AppNavListItem> kNavList = [
AppNavListItem(
title: "accountPublishers",
subtitle: "accountPublishersSubtitle",
screen: "accountPublishers",
icon: Symbols.face,
),
AppNavListItem(
title: "accountProgram",
subtitle: "accountProgramDescription",
screen: "accountProgram",
icon: Symbols.communities,
),
AppNavListItem(
title: "friends",
subtitle: "friendsDescription",
screen: "friend",
icon: Symbols.person,
),
AppNavListItem(
title: "album",
subtitle: "albumDescription",
screen: "album",
icon: Symbols.photo_library,
),
AppNavListItem(
title: "stickers",
subtitle: "stickersDescription",
screen: "stickers",
icon: Symbols.emoji_emotions,
),
AppNavListItem(
title: "accountWallet",
subtitle: "accountWalletSubtitle",
screen: "accountWallet",
icon: Symbols.wallet,
),
AppNavListItem(
title: "accountBadges",
subtitle: "accountBadgesDescription",
screen: "accountBadges",
icon: Symbols.award_star,
),
AppNavListItem(
title: "accountKeyPairs",
subtitle: "accountKeyPairsDescription",
screen: "accountKeyPairs",
icon: Symbols.key,
),
AppNavListItem(
title: "accountPunishments",
subtitle: "accountPunishmentsDescription",
screen: "accountPunishments",
icon: Symbols.credit_score,
),
AppNavListItem(
title: "accountActionEvent",
subtitle: "accountActionEventDescription",
screen: "accountActionEvents",
icon: Symbols.history,
),
AppNavListItem(
title: "accountAuthTickets",
subtitle: "accountAuthTicketsDescription",
screen: "accountAuthTickets",
icon: Symbols.confirmation_number,
),
AppNavListItem(
title: "accountSettings",
subtitle: "accountSettingsSubtitle",
screen: "accountSettings",
icon: Symbols.manage_accounts,
),
AppNavListItem(
title: "abuseReport",
subtitle: "abuseReportActionDescription",
screen: "abuseReport",
icon: Symbols.flag,
),
];
@override
Widget build(BuildContext context) {
final ua = context.watch<UserProvider>();
final sn = context.read<SnNetworkProvider>();
return AppScaffold(
noBackground: ResponsiveScaffold.getIsExpand(context),
appBar: AppBar(
leading: AutoAppBarLeading(),
title: Text(
"screenAccount",
style: TextStyle(
color: Colors.white,
shadows: [
Shadow(
offset: Offset(1, 1),
blurRadius: 5.0,
color: Color.fromARGB(255, 0, 0, 0),
),
],
),
).tr(),
leading: const PageBackButton(),
title: Text("screenAccount").tr(),
flexibleSpace: ua.user != null && ua.user!.banner.isNotEmpty
? Stack(
fit: StackFit.expand,
@ -69,15 +141,6 @@ class AccountScreen extends StatelessWidget {
],
)
: null,
actions: [
IconButton(
icon: const Icon(Symbols.settings, fill: 1),
onPressed: () {
GoRouter.of(context).pushNamed('settings');
},
),
const Gap(8),
],
),
body: SingleChildScrollView(
child: ua.isAuthorized
@ -112,7 +175,25 @@ class _AuthorizedAccountScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AccountImage(content: ua.user!.avatar, radius: 28),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
child: AccountImage(
content: ua.user!.avatar,
radius: 28,
),
onTap: () {
GoRouter.of(context)
.pushNamed('accountProfilePage', pathParameters: {
'name': ua.user!.name,
});
},
),
_AccountStatusWidget(account: ua.user!),
],
),
const Gap(8),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
@ -138,95 +219,42 @@ class _AuthorizedAccountScreen extends StatelessWidget {
);
}).padding(all: 20),
).padding(horizontal: 8, top: 16, bottom: 4),
ListTile(
title: Text('accountPublishers').tr(),
subtitle: Text('accountPublishersSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.face),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountPublishers');
},
),
ListTile(
title: Text('abuseReport').tr(),
subtitle: Text('abuseReportActionDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.flag),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('abuseReport');
},
),
ListTile(
title: Text('factorSettings').tr(),
subtitle: Text('factorSettingsSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.lock),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('factorSettings');
},
),
ListTile(
title: Text('accountWallet').tr(),
subtitle: Text('accountWalletSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.wallet),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountWallet');
},
),
ListTile(
title: Text('accountBadges').tr(),
subtitle: Text('accountBadgesDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.award_star),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountBadges');
},
),
ListTile(
title: Text('accountKeyPairs').tr(),
subtitle: Text('accountKeyPairsDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.key),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountKeyPairs');
},
),
ListTile(
title: Text('accountSettings').tr(),
subtitle: Text('accountSettingsSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.manage_accounts),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountSettings');
},
),
ListTile(
title: Text('accountLogout').tr(),
subtitle: Text('accountLogoutSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.logout),
trailing: const Icon(Symbols.chevron_right),
onTap: () async {
final confirm = await context.showConfirmDialog(
'accountLogoutConfirmTitle'.tr(),
'accountLogoutConfirm'.tr(),
);
for (final item in AccountScreen.kNavList)
Tooltip(
message: item.subtitle.tr(),
child: ListTile(
minTileHeight: 48,
title: Text(item.title).tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(item.icon),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed(item.screen);
},
),
),
Tooltip(
message: 'accountLogoutSubtitle'.tr(),
child: ListTile(
title: Text('accountLogout').tr(),
minTileHeight: 48,
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.logout),
trailing: const Icon(Symbols.chevron_right),
onTap: () async {
final confirm = await context.showConfirmDialog(
'accountLogoutConfirmTitle'.tr(),
'accountLogoutConfirm'.tr(),
);
if (!confirm) return;
if (!context.mounted) return;
ua.logoutUser();
final ws = context.read<WebSocketProvider>();
ws.disconnect();
context.read<DatabaseProvider>().removeDatabase();
},
if (!confirm) return;
if (!context.mounted) return;
ua.logoutUser();
final ws = context.read<WebSocketProvider>();
ws.disconnect();
context.read<DatabaseProvider>().removeDatabase();
},
),
),
],
);
@ -266,14 +294,7 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
leading: const Icon(Symbols.login),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
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})',
]));
}
});
GoRouter.of(context).pushNamed('authLogin');
},
),
ListTile(
@ -290,3 +311,81 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
);
}
}
class _AccountStatusWidget extends StatefulWidget {
final SnAccount account;
const _AccountStatusWidget({required this.account});
@override
State<_AccountStatusWidget> createState() => _AccountStatusWidgetState();
}
class _AccountStatusWidgetState extends State<_AccountStatusWidget> {
SnAccountStatusInfo? _status;
Future<void> _fetchStatus() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp =
await sn.client.get('/cgi/id/users/${widget.account.name}/status');
setState(() {
_status = SnAccountStatusInfo.fromJson(resp.data);
});
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() {});
}
}
@override
void initState() {
super.initState();
_fetchStatus();
}
@override
Widget build(BuildContext context) {
return InkWell(
child: Row(
children: [
Text(
_status != null
? (_status!.status?.label.isNotEmpty ?? false)
? _status!.status!.label
: _status!.isOnline
? 'accountStatusOnline'.tr()
: 'accountStatusOffline'.tr()
: 'loading'.tr(),
),
const Gap(4),
Icon(
(_status?.isDisturbable ?? true)
? Symbols.circle
: Symbols.do_not_disturb_on,
fill: (_status?.isOnline ?? false) ? 1 : 0,
size: 16,
color: (_status?.isOnline ?? false)
? (_status?.isDisturbable ?? true)
? Colors.green
: Colors.red
: Colors.grey,
).padding(all: 4),
],
),
onTap: () {
showModalBottomSheet(
context: context,
builder: (context) => AccountStatusActionPopup(
currentStatus: _status,
),
).then((value) {
if (value == true && mounted) {
_fetchStatus();
}
});
},
);
}
}

View File

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

View File

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

View File

@ -70,6 +70,7 @@ class _AccountBadgesScreenState extends State<AccountBadgesScreen> {
@override
Widget build(BuildContext context) {
return AppScaffold(
noBackground: ResponsiveScaffold.getIsExpand(context),
appBar: AppBar(
title: Text('screenAccountBadges').tr(),
),

View File

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

View File

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

View File

@ -37,6 +37,7 @@ class _KeyPairScreenState extends State<KeyPairScreen> {
@override
Widget build(BuildContext context) {
return AppScaffold(
noBackground: ResponsiveScaffold.getIsExpand(context),
appBar: AppBar(
title: Text('screenKeyPairs').tr(),
),

View File

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

View File

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

View File

@ -66,37 +66,40 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
_locationController.text = prof.profile!.location;
_avatar = prof.avatar;
_banner = prof.banner;
_links = prof.profile!.links.entries.map((ele) => (ele.key, ele.value)).toList();
_links =
prof.profile!.links.entries.map((ele) => (ele.key, ele.value)).toList();
_birthday = prof.profile!.birthday?.toLocal();
if (_birthday != null) {
_birthdayController.text = DateFormat(_kDateFormat).format(prof.profile!.birthday!.toLocal());
_birthdayController.text =
DateFormat(_kDateFormat).format(prof.profile!.birthday!.toLocal());
}
}
void _selectBirthday() async {
await showCupertinoModalPopup<DateTime?>(
context: context,
builder:
(BuildContext context) => Container(
height: 216,
padding: const EdgeInsets.only(top: 6.0),
margin: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
color: Theme.of(context).colorScheme.surface,
child: SafeArea(
top: false,
child: CupertinoDatePicker(
initialDateTime: _birthday?.toLocal(),
mode: CupertinoDatePickerMode.date,
use24hFormat: true,
onDateTimeChanged: (DateTime newDate) {
setState(() {
_birthday = newDate;
_birthdayController.text = DateFormat(_kDateFormat).format(_birthday!);
});
},
),
),
builder: (BuildContext context) => Container(
height: 216,
padding: const EdgeInsets.only(top: 6.0),
margin:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
color: Theme.of(context).colorScheme.surface,
child: SafeArea(
top: false,
child: CupertinoDatePicker(
initialDateTime: _birthday?.toLocal(),
mode: CupertinoDatePickerMode.date,
use24hFormat: true,
onDateTimeChanged: (DateTime newDate) {
setState(() {
_birthday = newDate;
_birthdayController.text =
DateFormat(_kDateFormat).format(_birthday!);
});
},
),
),
),
);
}
@ -109,29 +112,32 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
Uint8List? rawBytes;
if (!skipCrop) {
final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
final aspectRatios =
place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
final result =
(!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
)
: await showMaterialImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
);
final ImageProvider imageProvider =
kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
final aspectRatios = place == 'banner'
? [CropAspectRatio(width: 16, height: 7)]
: [CropAspectRatio(width: 1, height: 1)];
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
)
: await showMaterialImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
);
if (result == null) return;
if (!mounted) return;
setState(() => _isBusy = true);
rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!
.buffer
.asUint8List();
} else {
if (!mounted) return;
setState(() => _isBusy = true);
@ -152,7 +158,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
if (!mounted) return;
final sn = context.read<SnNetworkProvider>();
await sn.client.put('/cgi/id/users/me/$place', data: {'attachment': attachment.rid});
await sn.client
.put('/cgi/id/users/me/$place', data: {'attachment': attachment.rid});
if (!mounted) return;
final ua = context.read<UserProvider>();
@ -188,7 +195,9 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
'location': _locationController.value.text,
'birthday': _birthday?.toUtc().toIso8601String(),
'links': {
for (final link in _links!.where((ele) => ele.$1.isNotEmpty && ele.$2.isNotEmpty)) link.$1: link.$2,
for (final link in _links!
.where((ele) => ele.$1.isNotEmpty && ele.$2.isNotEmpty))
link.$1: link.$2,
},
},
);
@ -235,7 +244,10 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
final sn = context.read<SnNetworkProvider>();
return AppScaffold(
appBar: AppBar(leading: const PageBackButton(), title: Text('screenAccountProfileEdit').tr()),
noBackground: ResponsiveScaffold.getIsExpand(context),
appBar: AppBar(
leading: const PageBackButton(),
title: Text('screenAccountProfileEdit').tr()),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -251,13 +263,16 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio(
aspectRatio: 16 / 9,
aspectRatio: 16 / 7,
child: Container(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child:
_banner != null
? AutoResizeUniversalImage(sn.getAttachmentUrl(_banner!), fit: BoxFit.cover)
: const SizedBox.shrink(),
color: Theme.of(context)
.colorScheme
.surfaceContainerHigh,
child: _banner != null
? AutoResizeUniversalImage(
sn.getAttachmentUrl(_banner!),
fit: BoxFit.cover)
: const SizedBox.shrink(),
),
),
),
@ -294,12 +309,16 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
labelText: 'fieldUsername'.tr(),
helperText: 'fieldUsernameCannotEditHint'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
TextField(
controller: _nicknameController,
decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldNickname'.tr()),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldNickname'.tr()),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
Row(
children: [
@ -311,7 +330,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
border: const UnderlineInputBorder(),
labelText: 'fieldFirstName'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
const Gap(8),
@ -323,7 +343,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
border: const UnderlineInputBorder(),
labelText: 'fieldLastName'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
],
@ -338,7 +359,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
border: const UnderlineInputBorder(),
labelText: 'fieldGender'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
const Gap(4),
@ -350,7 +372,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
border: const UnderlineInputBorder(),
labelText: 'fieldPronouns'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
],
@ -360,8 +383,11 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
keyboardType: TextInputType.multiline,
maxLines: null,
minLines: 3,
decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldDescription'.tr()),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldDescription'.tr()),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
@ -373,18 +399,21 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
border: const UnderlineInputBorder(),
labelText: 'fieldTimeZone'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
const Gap(4),
StyledWidget(
IconButton(
icon: const Icon(Symbols.calendar_month),
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
visualDensity:
VisualDensity(horizontal: -4, vertical: -4),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () async {
_timezoneController.text = await FlutterTimezone.getLocalTimezone();
_timezoneController.text =
await FlutterTimezone.getLocalTimezone();
},
),
).padding(top: 6),
@ -392,7 +421,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
StyledWidget(
IconButton(
icon: const Icon(Symbols.clear),
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
visualDensity:
VisualDensity(horizontal: -4, vertical: -4),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
@ -404,13 +434,18 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
),
TextField(
controller: _locationController,
decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldLocation'.tr()),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldLocation'.tr()),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
TextField(
controller: _birthdayController,
readOnly: true,
decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldBirthday'.tr()),
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldBirthday'.tr()),
onTap: () => _selectBirthday(),
),
if (_links != null)
@ -418,7 +453,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
margin: const EdgeInsets.only(top: 16, bottom: 4),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -427,13 +463,17 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
Expanded(
child: Text(
'fieldLinks'.tr(),
style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 17),
style: Theme.of(context)
.textTheme
.titleMedium!
.copyWith(fontSize: 17),
),
),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
visualDensity:
VisualDensity(horizontal: -4, vertical: -4),
icon: const Icon(Symbols.add),
onPressed: () {
setState(() => _links!.add(('', '')));
@ -457,7 +497,9 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
onChanged: (value) {
_links![idx] = (value, _links![idx].$2);
},
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) => FocusManager
.instance.primaryFocus
?.unfocus(),
),
),
const Gap(8),
@ -473,7 +515,9 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
onChanged: (value) {
_links![idx] = (_links![idx].$1, value);
},
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) => FocusManager
.instance.primaryFocus
?.unfocus(),
),
),
],

View File

@ -1,3 +1,4 @@
import 'dart:math' as math;
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
@ -14,10 +15,12 @@ import 'package:surface/providers/experience.dart';
import 'package:surface/providers/relationship.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/screens/abuse_report.dart';
import 'package:surface/screens/account/punishments.dart';
import 'package:surface/types/account.dart';
import 'package:surface/types/check_in.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/account/badge.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/universal_image.dart';
import 'package:surface/theme.dart';
@ -59,6 +62,21 @@ final Map<String, (String, IconData, Color)> kBadgesMeta = {
Symbols.thumb_up,
Colors.lightGreen,
),
'programs.developers': (
'badgeProgramDeveloper',
Symbols.code,
Colors.blue,
),
'programs.stellar': (
'badgeProgramStellar',
Symbols.family_star,
Colors.orange,
),
'programs.moderator': (
'badgeProgramModerator',
Symbols.sword_rose,
Colors.blue,
),
};
class UserScreen extends StatefulWidget {
@ -226,7 +244,7 @@ class _UserScreenState extends State<UserScreen>
late final _appBarWidth = MediaQuery.of(context).size.width;
late final _appBarHeight =
(_appBarWidth * kBannerAspectRatio).roundToDouble();
math.min((_appBarWidth * kBannerAspectRatio), 360).roundToDouble();
void _updateAppBarBlur() {
if (_scrollController.offset > _appBarHeight) return;
@ -440,7 +458,7 @@ class _UserScreenState extends State<UserScreen>
],
).padding(right: 8),
if (_account!.profile!.description.isNotEmpty)
const Gap(12)
const Gap(4)
else
const Gap(8),
if (_account!.profile!.description.isNotEmpty)
@ -450,19 +468,25 @@ class _UserScreenState extends State<UserScreen>
child: Row(
children: [
Icon(
Symbols.circle,
fill: 1,
(_status?.isDisturbable ?? true)
? Symbols.circle
: Symbols.do_not_disturb_on,
fill: (_status?.isOnline ?? false) ? 1 : 0,
size: 16,
color: (_status?.isOnline ?? false)
? Colors.green
? (_status?.isDisturbable ?? true)
? Colors.green
: Colors.red
: Colors.grey,
).padding(all: 4),
const Gap(8),
Text(
_status != null
? _status!.isOnline
? 'accountStatusOnline'.tr()
: 'accountStatusOffline'.tr()
? (_status!.status?.label.isNotEmpty ?? false)
? _status!.status!.label
: _status!.isOnline
? 'accountStatusOnline'.tr()
: 'accountStatusOffline'.tr()
: 'loading'.tr(),
),
if (_status != null &&
@ -480,41 +504,15 @@ class _UserScreenState extends State<UserScreen>
],
).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: ele.metadata['color'] != null
? HexColor.fromHex(ele.metadata['color']!)
: kBadgesMeta[ele.type]?.$3,
fill: 1,
),
),
)
.toList(),
).padding(horizontal: 8),
if (_account!.badges.isNotEmpty) const Gap(8),
if (_account!.badges.isNotEmpty)
Wrap(
spacing: 4,
runSpacing: 4,
children: _account!.badges
.map((ele) => AccountBadge(badge: ele))
.toList(),
).padding(horizontal: 8),
const Gap(8),
Column(
children: [
@ -623,6 +621,17 @@ class _UserScreenState extends State<UserScreen>
],
).padding(all: 16),
),
if (_account?.punishments.isNotEmpty ?? false)
SliverToBoxAdapter(child: const Divider()),
if (_account?.punishments.isNotEmpty ?? false)
SliverToBoxAdapter(
child: Column(
children: [
for (final ele in _account!.punishments)
PunishmentInfoCard(ele: ele),
],
),
),
if (_account?.profile?.links.isNotEmpty ?? false)
SliverToBoxAdapter(child: const Divider()),
if (_account?.profile?.links.isNotEmpty ?? false)

View File

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

View File

@ -27,10 +27,12 @@ class AccountPublisherEditScreen extends StatefulWidget {
const AccountPublisherEditScreen({super.key, required this.name});
@override
State<AccountPublisherEditScreen> createState() => _AccountPublisherEditScreenState();
State<AccountPublisherEditScreen> createState() =>
_AccountPublisherEditScreenState();
}
class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> {
class _AccountPublisherEditScreenState
extends State<AccountPublisherEditScreen> {
bool _isBusy = false;
SnPublisher? _publisher;
@ -115,29 +117,32 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
Uint8List? rawBytes;
if (!skipCrop) {
final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
final aspectRatios =
place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
final result =
(!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
)
: await showMaterialImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
);
final ImageProvider imageProvider =
kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
final aspectRatios = place == 'banner'
? [CropAspectRatio(width: 16, height: 7)]
: [CropAspectRatio(width: 1, height: 1)];
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
)
: await showMaterialImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
);
if (result == null) return;
if (!mounted) return;
setState(() => _isBusy = true);
rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!
.buffer
.asUint8List();
} else {
if (!mounted) return;
setState(() => _isBusy = true);
@ -191,7 +196,10 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
final sn = context.read<SnNetworkProvider>();
return AppScaffold(
appBar: AppBar(leading: PageBackButton(), title: Text('screenAccountPublisherEdit').tr()),
noBackground: ResponsiveScaffold.getIsExpand(context),
appBar: AppBar(
leading: PageBackButton(),
title: Text('screenAccountPublisherEdit').tr()),
body: SingleChildScrollView(
child: Column(
children: [
@ -206,13 +214,16 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio(
aspectRatio: 16 / 9,
aspectRatio: 16 / 7,
child: Container(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child:
_banner != null
? AutoResizeUniversalImage(sn.getAttachmentUrl(_banner!), fit: BoxFit.cover)
: const SizedBox.shrink(),
color: Theme.of(context)
.colorScheme
.surfaceContainerHigh,
child: _banner != null
? AutoResizeUniversalImage(
sn.getAttachmentUrl(_banner!),
fit: BoxFit.cover)
: const SizedBox.shrink(),
),
),
),
@ -245,13 +256,15 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
labelText: 'fieldUsername'.tr(),
helperText: 'fieldUsernameCannotEditHint'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
TextField(
controller: _nickController,
decoration: InputDecoration(labelText: 'fieldNickname'.tr()),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
TextField(
@ -259,7 +272,8 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
maxLines: null,
minLines: 3,
decoration: InputDecoration(labelText: 'fieldDescription'.tr()),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
Row(

View File

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

View File

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

View File

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

View File

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

View File

@ -1,21 +1,21 @@
import 'package:dismissible_page/dismissible_page.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' show withoutExtension;
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/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/attachment/attachment_zoom.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:uuid/uuid.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class AlbumScreen extends StatefulWidget {
const AlbumScreen({super.key});
@ -50,23 +50,23 @@ class _AlbumScreenState extends State<AlbumScreen> {
Future<void> _fetchAttachments() async {
setState(() => _isBusy = true);
final ua = context.read<UserProvider>();
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,
'author': ua.user?.name,
});
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;
@ -102,92 +102,127 @@ class _AlbumScreenState extends State<AlbumScreen> {
@override
Widget build(BuildContext context) {
return AppScaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
SliverAppBar(
leading: AutoAppBarLeading(),
title: Text('screenAlbum').tr(),
),
SliverToBoxAdapter(
child: Card(
child: Row(
children: [
SizedBox(
width: 80,
height: 80,
child: CircularProgressIndicator(
value: _billing?.includedRatio ?? 0,
strokeWidth: 8,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
appBar: AppBar(
leading: PageBackButton(),
title: Text('screenAlbum').tr(),
),
body: Column(
children: [
Card(
margin: EdgeInsets.zero,
child: Row(
children: [
SizedBox(
width: 80,
height: 80,
child: CircularProgressIndicator(
value: _billing?.includedRatio ?? 0,
strokeWidth: 8,
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHigh,
),
).padding(all: 12),
const Gap(24),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('attachmentBillingUploaded').tr().bold(),
Text(
(_billing?.currentBytes ?? 0).formatBytes(decimals: 4),
style: GoogleFonts.robotoMono(),
),
Text('attachmentBillingDiscount').tr().bold(),
Text(
'${(_billing?.discountFileSize ?? 0).formatBytes(decimals: 2)} · ${((_billing?.includedRatio ?? 0) * 100).toStringAsFixed(2)}%',
style: GoogleFonts.robotoMono(),
),
],
),
),
Tooltip(
message: 'attachmentBillingHint'.tr(),
child: IconButton(
icon: const Icon(Symbols.info),
onPressed: () {},
),
),
],
).padding(horizontal: 24, vertical: 8),
).padding(horizontal: 8, top: 8),
Expanded(
child: InfiniteList(
padding: EdgeInsets.only(top: 8),
itemCount: _attachments.length,
isLoading: _isBusy,
hasReachedMax:
_totalCount != null && _attachments.length >= _totalCount!,
onFetchData: _fetchAttachments,
itemBuilder: (context, index) {
final ele = _attachments[index];
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
child: AspectRatio(
aspectRatio: (ele.data['ratio'] ?? 1).toDouble(),
child: AttachmentItem(
data: ele,
heroTag: _heroTags[index],
onZoom: () {
context.pushTransparentRoute(
AttachmentZoomView(
data: [ele],
),
backgroundColor: Colors.black.withOpacity(0.7),
rootNavigator: true,
);
},
),
),
),
).padding(all: 12),
const Gap(24),
Expanded(
child: Column(
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('attachmentBillingUploaded').tr().bold(),
Text(
(_billing?.currentBytes ?? 0).formatBytes(decimals: 4),
style: GoogleFonts.robotoMono(),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(ele.name),
if (ele.alt != withoutExtension(ele.name))
Text(ele.alt),
Text(DateFormat().format(ele.createdAt)),
const Gap(4),
Text(ele.size.formatBytes()).fontSize(12),
],
).padding(horizontal: 16, vertical: 12),
),
Text('attachmentBillingDiscount').tr().bold(),
Text(
'${(_billing?.discountFileSize ?? 0).formatBytes(decimals: 2)} · ${((_billing?.includedRatio ?? 0) * 100).toStringAsFixed(2)}%',
style: GoogleFonts.robotoMono(),
Padding(
padding: EdgeInsets.only(left: 12, right: 12, top: 4),
child: IconButton(
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
icon: const Icon(Symbols.info),
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) => AttachmentZoomDetailPopup(
data: ele,
),
);
},
),
),
],
),
),
Tooltip(
message: 'attachmentBillingHint'.tr(),
child: IconButton(
icon: const Icon(Symbols.info),
onPressed: () {},
),
),
],
).padding(horizontal: 24, vertical: 8),
),
),
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: Padding(
padding: const EdgeInsets.all(24),
child: const CircularProgressIndicator(),
).center(),
],
);
},
separatorBuilder: (_, __) => const Gap(8),
),
)
],
),
);

View File

@ -160,6 +160,7 @@ class _LoginCheckScreenState extends State<_LoginCheckScreen> {
sn.setTokenPair(atk, rtk);
if (!mounted) return;
final user = context.read<UserProvider>();
user.isAuthorized = true;
await user.refreshUser();
if (!mounted) return;
final ws = context.read<WebSocketProvider>();

View File

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

View File

@ -0,0 +1 @@
export 'captcha_native.dart' if (dart.library.html) 'captcha_web.dart';

View File

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

View File

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

View File

@ -1,3 +1,5 @@
import 'package:animations/animations.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
@ -6,21 +8,22 @@ 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:responsive_framework/responsive_framework.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/sn_realm.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/screens/chat/room.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/types/realm.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/account/account_select.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_background.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/unauthorized_hint.dart';
import 'package:surface/widgets/universal_image.dart';
import 'package:uuid/uuid.dart';
class ChatScreen extends StatefulWidget {
@ -38,6 +41,7 @@ class _ChatScreenState extends State<ChatScreen> {
List<SnChannel>? _channels;
Map<int, SnChatMessage>? _lastMessages;
Map<int, int>? _unreadCounts;
Map<int, int>? _unreadCountsGrouped;
Future<void> _fetchWhatsNew() async {
final sn = context.read<SnNetworkProvider>();
@ -45,19 +49,48 @@ class _ChatScreenState extends State<ChatScreen> {
if (resp.data == null) return;
final List<dynamic> out = resp.data;
setState(() {
_unreadCounts = {for (var v in out) v['channel_id']: v['count']};
_unreadCounts ??= {};
_unreadCountsGrouped ??= {};
for (var v in out) {
_unreadCounts![v['channel_id']] = v['count'];
final channel =
_channels?.firstWhereOrNull((ele) => ele.id == v['channel_id']);
if (channel != null) {
if (channel.realmId != null) {
_unreadCountsGrouped![channel.realmId!] ??= 0;
_unreadCountsGrouped![channel.realmId!] =
(_unreadCountsGrouped![channel.realmId!]! + v['count']).toInt();
}
if (channel.type == 1) {
_unreadCountsGrouped![0] ??= 0;
_unreadCountsGrouped![0] =
(_unreadCountsGrouped![0]! + v['count']).toInt();
}
}
}
});
}
void _refreshChannels({bool noRemote = false}) {
void _refreshChannels({bool withBoost = false, bool noRemote = false}) {
final ct = context.read<ChatChannelProvider>();
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) {
setState(() => _isBusy = false);
return;
}
if (!withBoost) {
if (!noRemote) {
ct.refreshAvailableChannels();
}
} else {
setState(() {
_channels = ct.availableChannels;
});
}
final chan = context.read<ChatChannelProvider>();
chan.fetchChannels(noRemote: noRemote).listen((channels) async {
chan.fetchChannels(noRemote: true).listen((channels) async {
final lastMessages = await chan.getLastMessages(channels);
_lastMessages = {for (final val in lastMessages) val.channelId: val};
channels.sort((a, b) {
@ -99,6 +132,7 @@ class _ChatScreenState extends State<ChatScreen> {
..onDone(() {
if (!mounted) return;
setState(() => _isBusy = false);
_fetchWhatsNew();
});
}
@ -130,40 +164,60 @@ class _ChatScreenState extends State<ChatScreen> {
}
}
SnChannel? _focusChannel;
@override
void initState() {
super.initState();
_refreshChannels();
_fetchWhatsNew();
_refreshChannels(withBoost: true);
}
void _onTapChannel(SnChannel channel) {
final doExpand = ResponsiveBreakpoints.of(context).largerOrEqualTo(DESKTOP);
if (doExpand) {
setState(() => _focusChannel = channel);
return;
}
GoRouter.of(context).pushNamed(
'chatRoom',
pathParameters: {
'scope': channel.realm?.alias ?? 'global',
'alias': channel.alias,
},
).then((value) {
if (mounted) {
_unreadCounts?[channel.id] = 0;
setState(() => _unreadCounts?[channel.id] = 0);
_refreshChannels(noRemote: true);
setState(() {
_unreadCounts?[channel.id] = 0;
if (channel.realmId != null) {
_unreadCountsGrouped?[channel.realmId!] =
(_unreadCountsGrouped?[channel.realmId!] ?? 0) -
(_unreadCounts?[channel.id] ?? 0);
}
if (channel.type == 1) {
_unreadCountsGrouped?[0] =
(_unreadCountsGrouped?[0] ?? 0) - (_unreadCounts?[channel.id] ?? 0);
}
});
if (ResponsiveScaffold.getIsExpand(context)) {
GoRouter.of(context).pushReplacementNamed(
'chatRoom',
pathParameters: {
'scope': channel.realm?.alias ?? 'global',
'alias': channel.alias,
},
).then((value) {
if (mounted && value == true) {
_refreshChannels();
}
});
} else {
GoRouter.of(context).pushNamed(
'chatRoom',
pathParameters: {
'scope': channel.realm?.alias ?? 'global',
'alias': channel.alias,
},
).then((value) {
if (mounted && value == true) {
_refreshChannels();
}
});
}
}
SnRealm? _focusedRealm;
bool _isDirect = false;
@override
Widget build(BuildContext context) {
final ua = context.read<UserProvider>();
final sn = context.read<SnNetworkProvider>();
final rel = context.read<SnRealmProvider>();
if (!ua.isAuthorized) {
return AppScaffold(
@ -177,10 +231,8 @@ class _ChatScreenState extends State<ChatScreen> {
);
}
final doExpand = ResponsiveBreakpoints.of(context).largerOrEqualTo(DESKTOP);
final chatList = AppScaffold(
noBackground: doExpand,
return AppScaffold(
noBackground: ResponsiveScaffold.getIsExpand(context),
appBar: AppBar(
leading: AutoAppBarLeading(),
title: Text('screenChat').tr(),
@ -204,7 +256,6 @@ class _ChatScreenState extends State<ChatScreen> {
Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
),
closeButtonBuilder: DefaultFloatingActionButtonBuilder(
child: const Icon(Symbols.close, size: 28),
@ -213,7 +264,6 @@ class _ChatScreenState extends State<ChatScreen> {
Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
),
children: [
Row(
@ -250,64 +300,198 @@ class _ChatScreenState extends State<ChatScreen> {
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
Expanded(
child: MediaQuery.removePadding(
context: context,
removeTop: true,
if (_channels != null && ResponsiveScaffold.getIsExpand(context))
Expanded(
child: RefreshIndicator(
onRefresh: () => Future.wait([
Future.sync(() => _refreshChannels()),
_fetchWhatsNew(),
]),
child: ListView.builder(
itemCount: _channels?.length ?? 0,
itemBuilder: (context, idx) {
final channel = _channels![idx];
final lastMessage = _lastMessages?[channel.id];
onRefresh: () => Future.sync(() => _refreshChannels()),
child: Builder(builder: (context) {
final scopeList = ListView(
key: const Key('realm-list-view'),
padding: EdgeInsets.zero,
children: [
ListTile(
minTileHeight: 48,
leading:
const Icon(Symbols.inbox_text).padding(right: 4),
contentPadding: EdgeInsets.only(left: 24, right: 24),
title: Text('chatDirect').tr(),
trailing: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (_unreadCountsGrouped?[0] != null &&
(_unreadCountsGrouped?[0] ?? 0) > 0)
Badge(
label: Text(
_unreadCountsGrouped![0].toString(),
),
),
],
),
onTap: () {
setState(() => _isDirect = true);
},
),
...rel.availableRealms.map((ele) {
return ListTile(
minTileHeight: 48,
contentPadding: EdgeInsets.only(left: 20, right: 24),
leading: AccountImage(
content: ele.avatar,
radius: 16,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (_unreadCountsGrouped?[ele.id] != null &&
(_unreadCountsGrouped?[ele.id] ?? 0) > 0)
Badge(
label: Text(
_unreadCountsGrouped![ele.id].toString(),
),
),
],
),
title: Text(ele.name),
onTap: () {
setState(() => _focusedRealm = ele);
},
);
}),
],
);
return _ChatChannelEntry(
channel: channel,
lastMessage: lastMessage,
unreadCount: _unreadCounts?[channel.id],
onTap: () {
if (doExpand) {
_unreadCounts?[channel.id] = 0;
setState(() => _focusChannel = channel);
return;
}
_onTapChannel(channel);
},
);
},
final directChatList = ListView(
key: Key('direct-chat-list-view'),
padding: EdgeInsets.zero,
children: [
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.arrow_left_alt),
contentPadding: EdgeInsets.only(left: 24),
title: Text('back').tr(),
onTap: () {
setState(() => _isDirect = false);
},
),
const Divider(height: 1),
..._channels!.where((ele) => ele.type == 1).map(
(ele) {
return _ChatChannelEntry(
channel: ele,
unreadCount: _unreadCounts?[ele.id],
lastMessage: _lastMessages?[ele.id],
isCompact: true,
onTap: () => _onTapChannel(ele),
);
},
)
],
);
final realmScopedChatList = _focusedRealm == null
? const SizedBox.shrink()
: ListView(
key: ValueKey(_focusedRealm),
padding: EdgeInsets.zero,
children: [
if (_focusedRealm!.banner != null)
AspectRatio(
aspectRatio: 16 / 7,
child: AutoResizeUniversalImage(
sn.getAttachmentUrl(
_focusedRealm!.banner!,
),
fit: BoxFit.cover,
),
),
ListTile(
minTileHeight: 48,
tileColor: Theme.of(context)
.colorScheme
.surfaceContainer,
leading: AccountImage(
content: _focusedRealm!.avatar,
radius: 16,
),
contentPadding: EdgeInsets.only(
left: 20,
right: 16,
),
trailing: IconButton(
icon: const Icon(Symbols.close),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
visualDensity: VisualDensity.compact,
onPressed: () {
setState(() => _focusedRealm = null);
},
),
title: Text(_focusedRealm!.name),
),
...(_channels!
.where(
(ele) => ele.realmId == _focusedRealm?.id)
.map(
(ele) {
return _ChatChannelEntry(
channel: ele,
unreadCount: _unreadCounts?[ele.id],
lastMessage: _lastMessages?[ele.id],
onTap: () => _onTapChannel(ele),
isCompact: true,
);
},
))
],
);
return PageTransitionSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
fillColor: Colors.transparent,
transitionType: SharedAxisTransitionType.horizontal,
child: child,
);
},
child: (_focusedRealm == null && !_isDirect)
? scopeList
: _isDirect
? directChatList
: realmScopedChatList,
);
}),
),
)
else if (_channels != null)
Expanded(
child: RefreshIndicator(
onRefresh: () => Future.sync(() => _refreshChannels()),
child: ListView(
key: const Key('chat-list-view'),
padding: EdgeInsets.zero,
children: [
...(_channels!.map((ele) {
return _ChatChannelEntry(
channel: ele,
unreadCount: _unreadCounts?[ele.id],
lastMessage: _lastMessages?[ele.id],
onTap: () => _onTapChannel(ele),
);
}))
],
),
),
),
),
],
),
);
if (doExpand) {
return AppBackground(
isRoot: true,
child: Row(
children: [
SizedBox(width: 340, child: chatList),
const VerticalDivider(width: 1),
if (_focusChannel != null)
Expanded(
child: ChatRoomScreen(
key: ValueKey(_focusChannel!.id),
scope: _focusChannel!.realm?.alias ?? 'global',
alias: _focusChannel!.alias,
),
),
],
),
);
}
return chatList;
}
}
@ -316,11 +500,13 @@ class _ChatChannelEntry extends StatelessWidget {
final int? unreadCount;
final SnChatMessage? lastMessage;
final Function? onTap;
final bool isCompact;
const _ChatChannelEntry({
required this.channel,
this.unreadCount,
this.lastMessage,
this.onTap,
this.isCompact = false,
});
@override
@ -339,6 +525,34 @@ class _ChatChannelEntry extends StatelessWidget {
? ud.getFromCache(otherMember.accountId)?.nick ?? channel.name
: channel.name;
if (isCompact) {
return ListTile(
minTileHeight: 48,
contentPadding:
EdgeInsets.only(left: otherMember != null ? 20 : 24, right: 24),
leading: otherMember != null
? AccountImage(
content: ud.getFromCache(otherMember.accountId)?.avatar,
radius: 16,
)
: const Icon(Symbols.tag),
trailing: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (unreadCount != null && (unreadCount ?? 0) > 0)
Badge(
label: Text(unreadCount.toString()),
),
],
),
title: Text(title),
onTap: () {
onTap?.call();
},
);
}
return ListTile(
title: Row(
children: [
@ -401,7 +615,7 @@ class _ChatChannelEntry extends StatelessWidget {
content: otherMember != null
? ud.getFromCache(otherMember.accountId)?.avatar
: channel.realm?.avatar,
fallbackWidget: const Icon(Symbols.chat, size: 20),
fallbackWidget: const Icon(Symbols.tag, size: 20),
),
onTap: () => onTap?.call(),
);

View File

@ -1,301 +0,0 @@
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';
import 'package:surface/widgets/navigation/app_scaffold.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 AppScaffold(
appBar: AppBar(
title: RichText(
textAlign: TextAlign.center,
text: TextSpan(children: [
TextSpan(
text: 'call'.tr(),
style: Theme.of(context).textTheme.titleLarge!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor,
),
),
const TextSpan(text: '\n'),
TextSpan(
text: call.lastDuration.toString(),
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor,
),
),
]),
),
),
body: 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

@ -220,6 +220,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
final isOwned = ua.isAuthorized && _channel?.accountId == ua.user?.id;
return AppScaffold(
noBackground: ResponsiveScaffold.getIsExpand(context),
appBar: AppBar(
title: _channel != null ? Text(_channel!.name) : Text('loading').tr(),
),

View File

@ -49,7 +49,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
);
if (_editingChannel != null) {
_belongToRealm = _realms?.firstWhereOrNull((e) => e.id == _editingChannel!.realmId);
_belongToRealm =
_realms?.firstWhereOrNull((e) => e.id == _editingChannel!.realmId);
}
} catch (err) {
if (mounted) context.showErrorDialog(err);
@ -97,7 +98,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
'is_community': _isCommunity,
if (_editingChannel != null && _belongToRealm == null)
'new_belongs_realm': 'global'
else if (_editingChannel != null && _belongToRealm?.id != _editingChannel?.realm?.id)
else if (_editingChannel != null &&
_belongToRealm?.id != _editingChannel?.realm?.id)
'new_belongs_realm': _belongToRealm!.alias,
};
@ -139,8 +141,11 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
@override
Widget build(BuildContext context) {
return AppScaffold(
noBackground: ResponsiveScaffold.getIsExpand(context),
appBar: AppBar(
title: widget.editingChannelAlias != null ? Text('screenChatManage').tr() : Text('screenChatNew').tr(),
title: widget.editingChannelAlias != null
? Text('screenChatManage').tr()
: Text('screenChatNew').tr(),
),
body: SingleChildScrollView(
child: Column(
@ -152,7 +157,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
leadingPadding: const EdgeInsets.only(left: 10, right: 20),
dividerColor: Colors.transparent,
content: Text(
'channelEditingNotice'.tr(args: ['#${_editingChannel!.alias}']),
'channelEditingNotice'
.tr(args: ['#${_editingChannel!.alias}']),
),
actions: [
TextButton(
@ -192,12 +198,15 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.name).textStyle(Theme.of(context).textTheme.bodyMedium!),
Text(item.name).textStyle(Theme.of(context)
.textTheme
.bodyMedium!),
Text(
item.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
).textStyle(Theme.of(context).textTheme.bodySmall!),
).textStyle(
Theme.of(context).textTheme.bodySmall!),
],
),
),
@ -213,7 +222,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
CircleAvatar(
radius: 16,
backgroundColor: Colors.transparent,
foregroundColor: Theme.of(context).colorScheme.onSurface,
foregroundColor:
Theme.of(context).colorScheme.onSurface,
child: const Icon(Symbols.clear),
),
const Gap(12),
@ -222,7 +232,9 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('fieldChatBelongToRealmUnset').tr().textStyle(
Text('fieldChatBelongToRealmUnset')
.tr()
.textStyle(
Theme.of(context).textTheme.bodyMedium!,
),
],
@ -257,7 +269,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
helperText: 'fieldChatAliasHint'.tr(),
helperMaxLines: 2,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
TextField(
@ -266,7 +279,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
border: const UnderlineInputBorder(),
labelText: 'fieldChatName'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
TextField(
@ -277,7 +291,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
border: const UnderlineInputBorder(),
labelText: 'fieldChatDescription'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
CheckboxListTile(

View File

@ -1,19 +1,20 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
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:go_router/go_router.dart';
import 'package:jitsi_meet_flutter_sdk/jitsi_meet_flutter_sdk.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/controllers/post_write_controller.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/chat_call.dart';
import 'package:surface/providers/notification.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/user_directory.dart';
@ -21,13 +22,13 @@ import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/websocket.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/types/websocket.dart';
import 'package:surface/widgets/chat/call/call_prejoin.dart';
import 'package:surface/widgets/chat/chat_message.dart';
import 'package:surface/widgets/chat/chat_message_input.dart';
import 'package:surface/widgets/chat/chat_typing_indicator.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class ChatRoomScreenExtra {
@ -51,11 +52,11 @@ class ChatRoomScreen extends StatefulWidget {
class _ChatRoomScreenState extends State<ChatRoomScreen> {
bool _isBusy = false;
bool _isCalling = false;
bool _isJoining = false;
SnChannel? _channel;
SnChannelMember? _currentMember;
SnChannelMember? _otherMember;
SnChatCall? _ongoingCall;
final GlobalKey<ChatMessageInputState> _inputGlobalKey = GlobalKey();
late final ChatMessageController _messageController;
@ -67,7 +68,24 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
StreamSubscription? _wsSubscription;
// TODO fetch user identity and ask them to join the channel or not
Future<void> _joinChannel() async {
try {
setState(() => _isJoining = true);
final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>();
await sn.client
.post('/cgi/im/channels/${_channel!.keyPath}/members', data: {
'related': ua.user?.name,
});
_initializeChat();
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isJoining = true);
}
}
Future<void> _fetchChannel() async {
setState(() => _isBusy = true);
@ -76,6 +94,12 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
_channel = await chan.getChannel('${widget.scope}:${widget.alias}');
if (!mounted || _channel == null) return;
final ct = context.read<ChatChannelProvider>();
try {
_currentMember = await ct.getChannelProfile(_channel!);
} catch (_) {}
if (!mounted || _currentMember == null) return;
final ud = context.read<UserDirectoryProvider>();
final ua = context.read<UserProvider>();
if (_channel!.type == 1) {
@ -114,88 +138,35 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
}
}
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,
receiveTimeout: const Duration(seconds: 60),
sendTimeout: const Duration(seconds: 60),
),
);
if (resp.statusCode == 200) {
_ongoingCall = SnChatCall.fromJson(resp.data);
}
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isCalling = false);
Future<void> _joinCall() async {
if (kIsWeb || !(Platform.isIOS || Platform.isAndroid)) {
return await _joinCallWeb();
}
}
Future<void> _makeCall() async {
setState(() => _isCalling = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.post(
'/cgi/im/channels/${_messageController.channel!.keyPath}/calls',
options: Options(
sendTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
),
);
} catch (err) {
if (!mounted) return;
if (_ongoingCall == null) {
// ignore the error because the call is already ongoing
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,
final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>();
final meet = JitsiMeet();
final confOpts = JitsiMeetConferenceOptions(
room: 'sn-chat-${_channel!.alias}-${_channel!.id}',
serverURL: 'https://meet.element.io',
configOverrides: {
"subject": _channel!.name,
},
userInfo: JitsiMeetUserInfo(
avatar: ua.user!.avatar.isNotEmpty
? sn.getAttachmentUrl(ua.user!.avatar)
: null,
displayName: _currentMember!.nick ?? ua.user!.nick,
),
);
meet.join(confOpts);
}
void _onCallResume() {
GoRouter.of(context).pushNamed(
'chatCallRoom',
pathParameters: {
'scope': _channel!.realm?.alias ?? 'global',
'alias': _channel!.alias,
},
);
Future<void> _joinCallWeb() async {
final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>();
final url =
'${sn.client.options.baseUrl}/meet/${_channel!.alias}-${_channel!.id}?tk=${await ua.atk}';
launchUrlString(url);
}
bool _checkMessageMergeable(SnChatMessage? a, SnChatMessage? b) {
@ -204,11 +175,9 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
return a.createdAt.difference(b.createdAt).inMinutes <= 3;
}
@override
void initState() {
super.initState();
_messageController = ChatMessageController(context);
Future<void> _initializeChat() async {
_fetchChannel().then((_) async {
if (_currentMember == null) return;
await _messageController.initialize(_channel!);
if (widget.extra != null) {
@ -225,28 +194,15 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
});
}
await Future.wait([
_messageController.checkUpdate(),
_fetchOngoingCall(),
]);
await _messageController.checkUpdate();
});
}
_wsSubscription = _ws.pk.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 initState() {
super.initState();
_messageController = ChatMessageController(context);
_initializeChat();
}
@override
@ -270,10 +226,10 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
@override
Widget build(BuildContext context) {
final call = context.watch<ChatCallProvider>();
final ud = context.read<UserDirectoryProvider>();
return AppScaffold(
noBackground: ResponsiveScaffold.getIsExpand(context),
appBar: AppBar(
title: Text(
_channel?.type == 1
@ -281,25 +237,22 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
: _channel?.name ?? 'loading'.tr(),
),
actions: [
IconButton(
onPressed: () {
setState(() => _isEncrypted = !_isEncrypted);
_inputGlobalKey.currentState?.setEncrypted(_isEncrypted);
},
icon: _isEncrypted
? const Icon(Symbols.lock)
: const Icon(Symbols.no_encryption),
),
IconButton(
icon: _ongoingCall == null
? const Icon(Symbols.call)
: const Icon(Symbols.call_end),
onPressed: _isCalling
? null
: _ongoingCall == null
? _makeCall
: _endCall,
),
if (_currentMember != null)
IconButton(
onPressed: () {
setState(() => _isEncrypted = !_isEncrypted);
_inputGlobalKey.currentState?.setEncrypted(_isEncrypted);
},
icon: _isEncrypted
? const Icon(Symbols.lock)
: const Icon(Symbols.no_encryption),
),
if (_currentMember != null)
IconButton(
icon: const Icon(Symbols.video_call),
onPressed: _joinCall,
onLongPress: _joinCallWeb,
),
IconButton(
icon: const Icon(Symbols.more_vert),
onPressed: () {
@ -326,29 +279,41 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
LoadingIndicator(
isActive: _isBusy || _messageController.isAggressiveLoading,
),
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)
if (_currentMember == null && !_isBusy)
Expanded(
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Symbols.person_remove, size: 40, fill: 1),
const Gap(8),
Text('chatUnjoined'.tr(), textAlign: TextAlign.center)
.fontSize(16)
.bold(),
Text('chatUnjoinedDescription'.tr(),
textAlign: TextAlign.center)
.fontSize(13),
if (_channel!.isPublic)
Text('chatUnjoinedPublicDescription'.tr(),
textAlign: TextAlign.center)
.fontSize(13)
.padding(top: 8),
if (_channel!.isPublic)
TextButton(
style: ButtonStyle(
visualDensity: VisualDensity.compact,
),
onPressed: _isJoining ? null : _joinChannel,
child: Text('chatJoin').tr(),
),
],
),
),
),
)
else if (_messageController.isPending)
Expanded(
child: const CircularProgressIndicator().center(),
)
@ -403,7 +368,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
},
),
),
if (!_messageController.isPending)
if (!_messageController.isPending && _currentMember != null)
Material(
elevation: 2,
child: Column(

View File

@ -1,4 +1,3 @@
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
@ -6,19 +5,27 @@ import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/sn_realm.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/post.dart';
import 'package:surface/types/realm.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/feed/feed_reader.dart';
import 'package:surface/widgets/feed/feed_unknown.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
const kPostChannels = ['Global', 'Friends', 'Following'];
const kPostChannelIcons = [Symbols.globe, Symbols.group, Symbols.subscriptions];
const Map<String, IconData> kCategoryIcons = {
'technology': Symbols.tools_wrench,
'gaming': Symbols.gamepad,
@ -39,17 +46,17 @@ class ExploreScreen extends StatefulWidget {
State<ExploreScreen> createState() => _ExploreScreenState();
}
// You know what? I'm not going to make this a global variable.
// Cuz the global key make the selected category not update to child widget when the category is changed.
SnPostCategory? _selectedCategory;
class _ExploreScreenState extends State<ExploreScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController =
TabController(length: 4, vsync: this);
with TickerProviderStateMixin {
late TabController _tabController = TabController(
length: kPostChannels.length,
vsync: this,
);
final _fabKey = GlobalKey<ExpandableFabState>();
final _listKeys = List.generate(4, (_) => GlobalKey<_PostListWidgetState>());
final _listKey = GlobalKey<_PostListWidgetState>();
bool _showCategories = false;
final List<SnPostCategory> _categories = List.empty(growable: true);
@ -69,14 +76,70 @@ class _ExploreScreenState extends State<ExploreScreen>
}
}
void _clearFilter() {
_selectedCategory = null;
final List<SnRealm> _realms = List.empty(growable: true);
Future<void> _fetchRealms() async {
try {
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) return;
final rels = context.read<SnRealmProvider>();
final out = await rels.listAvailableRealms();
setState(() {
_realms.addAll(out);
});
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
rethrow;
}
}
void _toggleShowCategories() {
_showCategories = !_showCategories;
if (_showCategories) {
_tabController = TabController(length: _categories.length, vsync: this);
_listKey.currentState?.setCategory(_categories[_tabController.index]);
_listKey.currentState?.refreshPosts();
} else {
_tabController = TabController(length: kPostChannels.length, vsync: this);
_listKey.currentState?.setCategory(null);
_listKey.currentState?.refreshPosts();
}
_tabListen();
setState(() {});
}
void _tabListen() {
_tabController.addListener(() {
if (_tabController.indexIsChanging) {
if (_showCategories) {
_listKey.currentState?.setCategory(_categories[_tabController.index]);
_listKey.currentState?.refreshPosts();
return;
}
switch (_tabController.index) {
case 0:
case 3:
_listKey.currentState?.setChannel(null);
break;
case 1:
_listKey.currentState?.setChannel('friends');
break;
case 2:
_listKey.currentState?.setChannel('following');
break;
}
_listKey.currentState?.refreshPosts();
}
});
}
@override
void initState() {
_fetchCategories();
super.initState();
_tabListen();
_fetchCategories();
_fetchRealms();
}
@override
@ -86,12 +149,14 @@ class _ExploreScreenState extends State<ExploreScreen>
}
Future<void> refreshPosts() async {
await _listKeys[_tabController.index].currentState?.refreshPosts();
await _listKey.currentState?.refreshPosts();
}
@override
Widget build(BuildContext context) {
final cfg = context.watch<ConfigProvider>();
return AppScaffold(
noBackground: ResponsiveScaffold.getIsExpand(context),
floatingActionButtonLocation: ExpandableFab.location,
floatingActionButton: ExpandableFab(
key: _fabKey,
@ -111,7 +176,6 @@ class _ExploreScreenState extends State<ExploreScreen>
Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
),
closeButtonBuilder: DefaultFloatingActionButtonBuilder(
child: const Icon(Symbols.close, size: 28),
@ -120,90 +184,39 @@ class _ExploreScreenState extends State<ExploreScreen>
Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
),
children: [
Row(
children: [
Text('writePostTypeStory').tr(),
Text('writePost').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'writePostTypeStory'.tr(),
tooltip: 'writePost'.tr(),
onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
'mode': 'stories',
}).then((value) {
GoRouter.of(context).pushNamed('postEditor').then((value) {
if (value == true) {
refreshPosts();
}
});
_fabKey.currentState!.toggle();
},
child: const Icon(Symbols.post_rounded),
child: const Icon(Symbols.edit),
),
],
),
Row(
children: [
Text('writePostTypeArticle').tr(),
Text('postDraftBox').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'writePostTypeArticle'.tr(),
tooltip: 'postDraftBox'.tr(),
onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
'mode': 'articles',
}).then((value) {
if (value == true) {
refreshPosts();
}
});
GoRouter.of(context).pushNamed('postDraftBox');
_fabKey.currentState!.toggle();
},
child: const Icon(Symbols.news),
),
],
),
Row(
children: [
Text('writePostTypeQuestion').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'writePostTypeQuestion'.tr(),
onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
'mode': 'questions',
}).then((value) {
if (value == true) {
refreshPosts();
}
});
_fabKey.currentState!.toggle();
},
child: const Icon(Symbols.question_answer),
),
],
),
Row(
children: [
Text('writePostTypeVideo').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'writePostTypeVideo'.tr(),
onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
'mode': 'videos',
}).then((value) {
if (value == true) {
refreshPosts();
}
});
_fabKey.currentState!.toggle();
},
child: const Icon(Symbols.video_call),
child: const Icon(Symbols.box_edit),
),
],
),
@ -215,27 +228,93 @@ class _ExploreScreenState extends State<ExploreScreen>
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar(
leading: AutoAppBarLeading(),
title: Text('screenExplore').tr(),
leading:
ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)
? AutoAppBarLeading()
: null,
titleSpacing: 0,
title: Row(
children: [
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE))
const Gap(8),
IconButton(
icon: const Icon(Symbols.shuffle),
onPressed: () {
GoRouter.of(context).pushNamed('postShuffle');
},
),
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE))
const Gap(48),
Expanded(
child: Center(
child: IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
visualDensity: VisualDensity.compact,
icon: _listKey.currentState?.realm != null
? AccountImage(
content: _listKey.currentState!.realm!.avatar,
radius: 14,
)
: Image.asset(
'assets/icon/icon-dark.png',
width: 32,
height: 32,
color: Theme.of(context)
.appBarTheme
.foregroundColor,
),
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) => _PostListRealmPopup(
realms: _realms,
onUpdate: (realm) {
_listKey.currentState?.setRealm(realm);
_listKey.currentState?.refreshPosts();
Future.delayed(
const Duration(milliseconds: 100), () {
if (mounted) {
setState(() {});
}
});
},
onMixedFeedChanged: (flag) {
_listKey.currentState?.setRealm(null);
_listKey.currentState?.setCategory(null);
if (_showCategories && flag) {
_toggleShowCategories();
}
_listKey.currentState?.refreshPosts();
},
),
);
},
),
),
),
],
),
floating: true,
snap: true,
actions: [
IconButton(
icon: const Icon(Symbols.category),
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) => _PostCategoryPickerPopup(
categories: _categories,
selected: _selectedCategory,
),
).then((value) {
if (value != null && context.mounted) {
_selectedCategory = value == false ? null : value;
refreshPosts();
}
});
},
style: _showCategories
? ButtonStyle(
foregroundColor: WidgetStateProperty.all(
Theme.of(context).colorScheme.primary,
),
backgroundColor: MaterialStateProperty.all(
Theme.of(context).colorScheme.secondaryContainer,
),
)
: null,
onPressed: cfg.mixedFeed
? null
: () {
_toggleShowCategories();
},
),
IconButton(
icon: const Icon(Symbols.search),
@ -245,123 +324,84 @@ class _ExploreScreenState extends State<ExploreScreen>
),
const Gap(8),
],
bottom: TabBar(
controller: _tabController,
tabs: [
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Symbols.globe,
size: 20,
color: Theme.of(context)
.appBarTheme
.foregroundColor),
const Gap(8),
Flexible(
child: Text(
'postChannelGlobal',
maxLines: 1,
).tr().textColor(
Theme.of(context).appBarTheme.foregroundColor),
),
],
bottom: cfg.mixedFeed
? null
: TabBar(
isScrollable: _showCategories,
controller: _tabController,
tabs: _showCategories
? [
for (final category in _categories)
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
Icon(
kCategoryIcons[category.alias] ??
Symbols.question_mark,
color: Theme.of(context)
.appBarTheme
.foregroundColor!,
),
const Gap(8),
Flexible(
child: Text(
'postCategory${category.alias.capitalize()}'
.trExists()
? 'postCategory${category.alias.capitalize()}'
.tr()
: category.name,
maxLines: 1,
).textColor(
Theme.of(context)
.appBarTheme
.foregroundColor!,
),
),
],
),
),
]
: [
for (final channel in kPostChannels)
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
Icon(
kPostChannelIcons[
kPostChannels.indexOf(channel)],
size: 20,
color: Theme.of(context)
.appBarTheme
.foregroundColor,
),
const Gap(8),
Flexible(
child: Text(
'postChannel$channel',
maxLines: 1,
).tr().textColor(
Theme.of(context)
.appBarTheme
.foregroundColor,
),
),
],
),
),
],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Symbols.group,
size: 20,
color: Theme.of(context)
.appBarTheme
.foregroundColor),
const Gap(8),
Flexible(
child: Text(
'postChannelFriends',
maxLines: 1,
textAlign: TextAlign.center,
).tr().textColor(
Theme.of(context).appBarTheme.foregroundColor),
),
],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Symbols.subscriptions,
size: 20,
color: Theme.of(context)
.appBarTheme
.foregroundColor),
const Gap(8),
Flexible(
child: Text(
'postChannelFollowing',
maxLines: 1,
).tr().textColor(
Theme.of(context).appBarTheme.foregroundColor),
),
],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Symbols.workspaces,
size: 20,
color: Theme.of(context)
.appBarTheme
.foregroundColor),
const Gap(8),
Flexible(
child: Text(
'postChannelRealm',
maxLines: 1,
).tr().textColor(
Theme.of(context).appBarTheme.foregroundColor),
),
],
),
),
],
),
),
),
];
},
body: TabBarView(
controller: _tabController,
children: [
_PostListWidget(
key: _listKeys[0],
onClearFilter: _clearFilter,
),
_PostListWidget(
key: _listKeys[1],
channel: 'friends',
onClearFilter: _clearFilter,
),
_PostListWidget(
key: _listKeys[2],
channel: 'following',
onClearFilter: _clearFilter,
),
_PostListWidget(
key: _listKeys[3],
withRealm: true,
onClearFilter: _clearFilter,
),
],
body: _PostListWidget(
key: _listKey,
),
),
);
@ -369,15 +409,7 @@ class _ExploreScreenState extends State<ExploreScreen>
}
class _PostListWidget extends StatefulWidget {
final String? channel;
final bool withRealm;
final Function onClearFilter;
const _PostListWidget(
{super.key,
this.channel,
this.withRealm = false,
required this.onClearFilter});
const _PostListWidget({super.key});
@override
State<_PostListWidget> createState() => _PostListWidgetState();
@ -386,62 +418,98 @@ class _PostListWidget extends StatefulWidget {
class _PostListWidgetState extends State<_PostListWidget> {
bool _isBusy = false;
final List<SnPost> _posts = List.empty(growable: true);
final List<SnRealm> _realms = List.empty(growable: true);
SnRealm? get realm => _selectedRealm;
final List<SnFeedEntry> _feed = List.empty(growable: true);
SnRealm? _selectedRealm;
int? _postCount;
Future<void> _fetchRealms() async {
try {
final rels = context.read<SnRealmProvider>();
final out = await rels.listAvailableRealms();
setState(() {
_realms.addAll(out);
_selectedRealm = out.firstOrNull;
});
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
rethrow;
}
}
String? _selectedChannel;
SnPostCategory? _selectedCategory;
bool _hasLoadedAll = false;
// Called when using regular feed
Future<void> _fetchPosts() async {
if (_postCount != null && _posts.length >= _postCount!) return;
if (_hasLoadedAll) return;
setState(() => _isBusy = true);
final pt = context.read<SnPostContentProvider>();
final result = await pt.listPosts(
take: 10,
offset: _posts.length,
offset: _feed.length,
categories: _selectedCategory != null ? [_selectedCategory!.alias] : null,
channel: widget.channel,
channel: _selectedChannel,
realm: _selectedRealm?.alias,
);
final out = result.$1;
if (!mounted) return;
_postCount = result.$2;
_posts.addAll(out);
final postCount = result.$2;
_feed.addAll(
out.map((ele) => SnFeedEntry(
type: 'interactive.post',
data: ele.toJson(),
createdAt: ele.createdAt)),
);
_hasLoadedAll = _feed.length >= postCount;
if (mounted) setState(() => _isBusy = false);
}
// Called when mixed feed is enabled
Future<void> _fetchFeed() async {
if (_hasLoadedAll) return;
setState(() => _isBusy = true);
final pt = context.read<SnPostContentProvider>();
final result = await pt.getFeed(
cursor: _feed
.where((ele) => !['reader.feed'].contains(ele.type))
.lastOrNull
?.createdAt,
);
if (!mounted) return;
_feed.addAll(result);
_hasLoadedAll = result.isEmpty;
if (mounted) setState(() => _isBusy = false);
}
void setChannel(String? channel) {
_selectedChannel = channel;
setState(() {});
}
void setRealm(SnRealm? realm) {
_selectedRealm = realm;
setState(() {});
}
void setCategory(SnPostCategory? category) {
_selectedCategory = category;
setState(() {});
}
Future<void> refreshPosts() {
_postCount = null;
_posts.clear();
return _fetchPosts();
_hasLoadedAll = false;
_feed.clear();
final cfg = context.read<ConfigProvider>();
if (cfg.mixedFeed) {
return _fetchFeed();
} else {
return _fetchPosts();
}
}
@override
void initState() {
super.initState();
if (widget.withRealm) {
_fetchRealms().then((_) {
_fetchPosts();
});
final cfg = context.read<ConfigProvider>();
if (cfg.mixedFeed) {
_fetchFeed();
} else {
_fetchPosts();
}
@ -449,178 +517,126 @@ class _PostListWidgetState extends State<_PostListWidget> {
@override
Widget build(BuildContext context) {
return Column(
children: [
if (_selectedCategory != null)
MaterialBanner(
content: Text(
'postFilterWithCategory'.tr(args: [
'postCategory${_selectedCategory!.alias.capitalize()}'.trExists()
? 'postCategory${_selectedCategory!.alias.capitalize()}'
.tr()
: _selectedCategory!.name,
]),
),
leading: Icon(kCategoryIcons[_selectedCategory!.alias] ??
Symbols.question_mark),
actions: [
IconButton(
icon: const Icon(Symbols.clear),
onPressed: () {
widget.onClearFilter.call();
refreshPosts();
},
),
],
padding: const EdgeInsets.only(left: 20, right: 4),
),
if (widget.withRealm)
DropdownButtonHideUnderline(
child: DropdownButton2<SnRealm>(
isExpanded: true,
items: _realms
.map(
(ele) => DropdownMenuItem<SnRealm>(
value: ele,
child: Row(
children: [
AccountImage(
content: ele.avatar,
fallbackWidget: const Icon(Symbols.group, size: 16),
radius: 14,
),
const Gap(8),
Text(
ele.name,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
)
.toList(),
value: _selectedRealm,
onChanged: (SnRealm? value) {
setState(() => _selectedRealm = value);
refreshPosts();
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.only(left: 4, right: 12),
),
menuItemStyleData: const MenuItemStyleData(
height: 48,
),
),
),
if (widget.withRealm) const Divider(height: 1),
Expanded(
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: RefreshIndicator(
displacement: 40 + MediaQuery.of(context).padding.top,
onRefresh: () => refreshPosts(),
child: InfiniteList(
itemCount: _posts.length,
isLoading: _isBusy,
centerLoading: true,
hasReachedMax:
_postCount != null && _posts.length >= _postCount!,
onFetchData: _fetchPosts,
itemBuilder: (context, idx) {
return OpenablePostItem(
data: _posts[idx],
maxWidth: 640,
onChanged: (data) {
setState(() => _posts[idx] = data);
},
onDeleted: () {
refreshPosts();
},
);
},
separatorBuilder: (_, __) => const Gap(8),
),
),
).padding(top: 8),
final cfg = context.watch<ConfigProvider>();
return MediaQuery.removePadding(
context: context,
removeTop: true,
child: RefreshIndicator(
displacement: 40 + MediaQuery.of(context).padding.top,
onRefresh: () => refreshPosts(),
child: InfiniteList(
padding: EdgeInsets.only(top: 8),
itemCount: _feed.length,
isLoading: _isBusy,
centerLoading: true,
hasReachedMax: _hasLoadedAll,
onFetchData: cfg.mixedFeed ? _fetchFeed : _fetchPosts,
itemBuilder: (context, idx) {
final ele = _feed[idx];
switch (ele.type) {
case 'interactive.post':
return OpenablePostItem(
useReplace: true,
data: SnPost.fromJson(ele.data),
maxWidth: 640,
onChanged: (data) {
setState(() {
_feed[idx] = _feed[idx].copyWith(data: data.toJson());
});
},
onDeleted: () {
refreshPosts();
},
);
case 'reader.feed':
return Center(
child: Container(
constraints: BoxConstraints(maxWidth: 640),
child: NewsFeedEntry(data: ele),
),
);
default:
return Container(
constraints: BoxConstraints(maxWidth: 640),
child: FeedUnknownEntry(data: ele),
);
}
},
separatorBuilder: (_, __) => const Divider().padding(vertical: 2),
),
],
),
);
}
}
class _PostCategoryPickerPopup extends StatelessWidget {
final List<SnPostCategory> categories;
final SnPostCategory? selected;
class _PostListRealmPopup extends StatelessWidget {
final List<SnRealm>? realms;
final Function(SnRealm?) onUpdate;
final Function(bool) onMixedFeedChanged;
const _PostCategoryPickerPopup({required this.categories, this.selected});
const _PostListRealmPopup({
required this.realms,
required this.onUpdate,
required this.onMixedFeedChanged,
});
@override
Widget build(BuildContext context) {
final cfg = context.watch<ConfigProvider>();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.category, size: 24),
const Icon(Symbols.tune, size: 24),
const Gap(16),
Text('postCategory')
.tr()
.textStyle(Theme.of(context).textTheme.titleLarge!),
Text('filterFeed', style: Theme.of(context).textTheme.titleLarge)
.tr(),
],
).padding(horizontal: 20, top: 16, bottom: 12),
ListTile(
leading: const Icon(Symbols.clear),
title: Text('postFilterReset').tr(),
subtitle: Text('postFilterResetDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
onTap: () {
Navigator.pop(context, false);
SwitchListTile(
secondary: const Icon(Symbols.merge_type),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text('mixedFeed').tr(),
subtitle: Text('mixedFeedDescription').tr(),
value: cfg.mixedFeed,
onChanged: (value) {
cfg.mixedFeed = value;
onMixedFeedChanged.call(value);
},
),
const Divider(height: 1),
Expanded(
child: GridView.count(
crossAxisCount: 4,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 1,
children: categories
.map(
(ele) => InkWell(
onTap: () {
_selectedCategory = ele;
Navigator.pop(context, ele);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
kCategoryIcons[ele.alias] ?? Symbols.question_mark,
color: selected == ele
? Theme.of(context).colorScheme.primary
: null,
),
const Gap(4),
Text(
'postCategory${ele.alias.capitalize()}'.trExists()
? 'postCategory${ele.alias.capitalize()}'.tr()
: ele.name,
)
.textStyle(Theme.of(context).textTheme.titleMedium!)
.textColor(selected == ele
? Theme.of(context).colorScheme.primary
: null),
],
),
),
)
.toList(),
if (!cfg.mixedFeed)
ListTile(
leading: const Icon(Symbols.close),
title: Text('postInGlobal').tr(),
subtitle: Text('postViewInGlobalDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
onUpdate.call(null);
Navigator.pop(context);
},
),
if (!cfg.mixedFeed) const Divider(height: 1),
if (!cfg.mixedFeed)
Expanded(
child: ListView.builder(
itemCount: realms?.length ?? 0,
itemBuilder: (context, idx) {
final realm = realms![idx];
return ListTile(
title: Text(realm.name),
subtitle: Text('@${realm.alias}'),
leading: AccountImage(content: realm.avatar, radius: 18),
onTap: () {
onUpdate.call(realm);
Navigator.pop(context);
},
);
},
),
),
),
],
);
}

View File

@ -0,0 +1,158 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:html/parser.dart';
import 'package:html2md/html2md.dart' as html2md;
import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/news.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:url_launcher/url_launcher_string.dart';
class ReaderPageScreen extends StatefulWidget {
final String id;
const ReaderPageScreen({super.key, required this.id});
@override
State<ReaderPageScreen> createState() => _ReaderPageScreenState();
}
class _ReaderPageScreenState extends State<ReaderPageScreen> {
SnSubscriptionItem? _article;
Future<void> _fetchArticle() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/re/subscriptions/${widget.id}');
_article = SnSubscriptionItem.fromJson(resp.data);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err).then((_) {
if (!mounted) return;
Navigator.pop(context);
});
} finally {
setState(() {});
}
}
@override
void initState() {
super.initState();
_fetchArticle();
}
bool _isReadingFromReader = true;
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
leading: const PageBackButton(),
title: Text(_article?.title ?? 'loading'.tr()),
),
body: Column(
children: [
MaterialBanner(
dividerColor: Colors.transparent,
leading: const Icon(Icons.info),
content: Text(_isReadingFromReader
? 'newsReadingFromReader'.tr()
: 'newsReadingFromOriginal'.tr()),
actions: [
TextButton(
child: Text('newsReadingProviderSwap').tr(),
onPressed: () {
setState(() => _isReadingFromReader = !_isReadingFromReader);
},
),
],
),
if (_article != null && _isReadingFromReader)
Expanded(
child: Container(
constraints: BoxConstraints(maxWidth: 640),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Text(_article!.title,
style: Theme.of(context).textTheme.titleLarge),
Builder(builder: (context) {
final htmlDescription = parse(_article!.description);
return Text(
htmlDescription.children
.map((ele) => ele.text.trim())
.join(),
style: Theme.of(context).textTheme.bodyMedium,
);
}),
Builder(builder: (context) {
final date =
_article!.publishedAt ?? _article!.createdAt;
return Row(
spacing: 2,
children: [
Text(DateFormat().format(date)).textStyle(
Theme.of(context).textTheme.bodySmall!),
Text(' · ')
.textStyle(
Theme.of(context).textTheme.bodySmall!)
.bold(),
Text(RelativeTime(context).format(date)).textStyle(
Theme.of(context).textTheme.bodySmall!),
],
).opacity(0.75);
}),
Text('newsDisclaimer')
.tr()
.textStyle(Theme.of(context).textTheme.bodySmall!)
.opacity(0.75),
const Divider(),
MarkdownTextContent(
textScaler: TextScaler.linear(1.2),
content: html2md.convert(_article!.content),
),
const Divider(),
InkWell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Reference from original website',
style: TextStyle(
decoration: TextDecoration.underline),
),
const Gap(4),
Icon(Icons.launch, size: 16),
],
).opacity(0.85),
onTap: () {
launchUrlString(_article!.url);
},
),
Gap(MediaQuery.of(context).padding.bottom),
],
).padding(horizontal: 12, vertical: 16),
),
).center(),
)
else if (_article != null)
Expanded(
child: InAppWebView(
key: GlobalKey(),
initialUrlRequest: URLRequest(url: WebUri(_article!.url)),
),
),
],
),
);
}
}

View File

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

View File

@ -6,7 +6,6 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:html/parser.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart';
@ -19,14 +18,16 @@ import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/special_day.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/widget.dart';
import 'package:surface/screens/captcha/captcha.dart';
import 'package:surface/types/check_in.dart';
import 'package:surface/types/news.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/updater.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:url_launcher/url_launcher_string.dart';
class HomeScreenDashEntry {
final String name;
@ -66,7 +67,7 @@ class _HomeScreenState extends State<HomeScreen> {
),
HomeScreenDashEntry(
name: 'dashEntryTodayNews',
child: _HomeDashTodayNews(),
child: _HomeDashServiceStatus(),
cols: MediaQuery.of(context).size.width >= 640 ? 3 : 2,
),
];
@ -99,6 +100,7 @@ class _HomeScreenState extends State<HomeScreen> {
right: 8,
),
),
_HomeDashUnconfirmedWidget().padding(horizontal: 8),
_HomeDashSpecialDayWidget().padding(horizontal: 8),
StaggeredGrid.extent(
maxCrossAxisExtent: 280,
@ -123,6 +125,64 @@ class _HomeScreenState extends State<HomeScreen> {
}
}
class _HomeDashUnconfirmedWidget extends StatelessWidget {
const _HomeDashUnconfirmedWidget();
Future<void> _resendConfirmationEmail(BuildContext context) async {
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.patch('/cgi/id/users/me/confirm');
if (!context.mounted) return;
context.showSnackbar('accountUnconfirmedResendSuccessful'.tr());
} catch (err) {
if (!context.mounted) return;
context.showErrorDialog(err);
}
}
@override
Widget build(BuildContext context) {
final ua = context.watch<UserProvider>();
if (ua.user == null || ua.user?.confirmedAt != null) {
return SizedBox.shrink();
}
return Card(
margin: EdgeInsets.zero,
child: ListTile(
leading: const Icon(Symbols.shield),
title: Text('accountUnconfirmedTitle').tr(),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('accountUnconfirmedSubtitle').tr(),
const Gap(4),
Row(
children: [
Text('accountUnconfirmedUnreceived').tr(),
const Gap(4),
InkWell(
child: Text(
'accountUnconfirmedResend',
style: TextStyle(
decoration: TextDecoration.underline,
color: Theme.of(context).colorScheme.onSurface,
),
).tr(),
onTap: () {
_resendConfirmationEmail(context);
},
),
],
),
],
),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
),
).padding(bottom: 8);
}
}
class _HomeDashUpdateWidget extends StatelessWidget {
final EdgeInsets? padding;
@ -131,7 +191,6 @@ class _HomeDashUpdateWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final config = context.watch<ConfigProvider>();
return ListenableBuilder(
listenable: config,
builder: (context, _) {
@ -245,21 +304,31 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
}
}
class _HomeDashTodayNews extends StatefulWidget {
const _HomeDashTodayNews();
class _HomeDashServiceStatus extends StatefulWidget {
const _HomeDashServiceStatus();
@override
State<_HomeDashTodayNews> createState() => _HomeDashTodayNewsState();
State<_HomeDashServiceStatus> createState() => _HomeDashServiceStatusState();
}
class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
SnNewsArticle? _article;
class _HomeDashServiceStatusState extends State<_HomeDashServiceStatus> {
Map<String, dynamic>? _statuses;
ServiceStatus? _serviceStatus;
Future<void> _fetchArticle() async {
Future<void> _fetchStatuses() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/re/news/today');
_article = SnNewsArticle.fromJson(resp.data['data']);
final resp = await sn.client.get('/directory/status');
_statuses = resp.data;
if (_statuses!.values.contains(false)) {
if (_statuses!.values.contains(true)) {
_serviceStatus = ServiceStatus.downgraded;
} else {
_serviceStatus = ServiceStatus.failed;
}
} else {
_serviceStatus = ServiceStatus.operational;
}
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
@ -272,7 +341,7 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
@override
initState() {
super.initState();
_fetchArticle();
_fetchStatuses();
}
@override
@ -284,73 +353,136 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
children: [
Row(
children: [
const Icon(Symbols.newspaper),
const Icon(Symbols.flare),
const Gap(8),
Text(
'newsToday',
style: Theme.of(context).textTheme.titleLarge,
).tr()
],
).padding(horizontal: 18, top: 12, bottom: 8),
if (_article != null)
Expanded(
child: InkWell(
borderRadius: BorderRadius.all(Radius.circular(8)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
Text(
_article!.title,
style: Theme.of(context)
.textTheme
.titleMedium!
.copyWith(fontSize: 18),
maxLines:
MediaQuery.of(context).size.width >= 640 ? 2 : 1,
overflow: TextOverflow.ellipsis,
),
Text(
parse(_article!.description)
.children
.map((e) => e.text.trim())
.join(),
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
Builder(builder: (context) {
final date = _article!.publishedAt ?? _article!.createdAt;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 2,
children: [
Text(DateFormat().format(date)).textStyle(
Theme.of(context).textTheme.bodySmall!),
Text(' · ')
.textStyle(Theme.of(context).textTheme.bodySmall!)
.bold(),
Text(RelativeTime(context).format(date)).textStyle(
Theme.of(context).textTheme.bodySmall!),
],
).opacity(0.75);
}),
],
).padding(horizontal: 16),
onTap: () {
GoRouter.of(context).pushNamed(
'newsDetail',
pathParameters: {'hash': _article!.hash},
);
Expanded(
child: Text(
'serviceStatus',
style: Theme.of(context).textTheme.titleLarge,
).tr(),
),
IconButton(
icon: const Icon(Symbols.launch, size: 20),
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
onPressed: () {
launchUrlString('https://status.solsynth.dev');
},
),
)
else
],
).padding(horizontal: 18, top: 12, bottom: 8),
Container(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 6),
width: double.infinity,
color: _serviceStatus == null
? Theme.of(context).colorScheme.surfaceContainerHigh
: switch (_serviceStatus) {
ServiceStatus.operational => Colors.green[300],
ServiceStatus.failed => Colors.red[300],
_ => Colors.orange[300],
},
child: _serviceStatus == null
? Row(
children: [
const Icon(
Symbols.more_horiz,
size: 20,
),
const Gap(10),
Text('loading').tr(),
],
)
: switch (_serviceStatus) {
ServiceStatus.operational => Row(
children: [
Icon(
Symbols.check,
size: 20,
color: Colors.green[900],
),
const Gap(10),
Text('serviceStatusOperational')
.tr()
.textColor(Colors.green[900]),
],
),
ServiceStatus.failed => Tooltip(
message: 'serviceStatusFailedDescription'.tr(),
child: Row(
children: [
Icon(
Symbols.dangerous,
size: 20,
color: Colors.red[900],
),
const Gap(10),
Text('serviceStatusFailed')
.tr()
.textColor(Colors.red[900]),
],
),
),
_ => Row(
children: [
Icon(
Symbols.error,
size: 20,
color: Colors.orange[900],
),
const Gap(10),
Text('serviceStatusDowngraded')
.tr()
.textColor(Colors.orange[900]),
],
),
},
),
if (_statuses != null)
Expanded(
child: Center(
child: CircularProgressIndicator(),
child: SingleChildScrollView(
padding: EdgeInsets.only(top: 6),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final entry in _statuses!.entries)
Tooltip(
message: kServicesName[entry.key] != null
? 'serviceName${kServicesName[entry.key]}'.tr()
: 'unknown'.tr(),
child: Chip(
visualDensity:
VisualDensity(horizontal: -4, vertical: -4),
avatar: entry.value
? const Icon(
Symbols.circle,
color: Colors.green,
fill: 1,
size: 16,
)
: AnimateWidgetExtensions(const Icon(
Symbols.error,
color: Colors.red,
fill: 1,
size: 16,
))
.animate(onPlay: (e) => e.repeat())
.fadeIn(
duration: 500.ms, curve: Curves.easeOut)
.then()
.fadeOut(
duration: 500.ms,
delay: 1000.ms,
curve: Curves.easeIn,
),
label: Text(kServicesName[entry.key] ?? entry.key),
),
),
],
).padding(horizontal: 12),
),
)
),
],
),
);
@ -386,11 +518,20 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
}
Future<void> _doCheckIn() async {
final captchaTk = await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => CaptchaScreen(),
),
);
if (captchaTk == null) return;
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final home = context.read<HomeWidgetProvider>();
final resp = await sn.client.post('/cgi/id/check-in');
final resp = await sn.client.post('/cgi/id/check-in', data: {
'captcha_token': captchaTk,
});
_todayRecord = SnCheckInRecord.fromJson(resp.data);
await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson());
} catch (err) {
@ -546,11 +687,26 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
'+${_todayRecord!.resultExperience} EXP',
style: Theme.of(context).textTheme.bodyLarge,
),
if (_todayRecord!.resultCoin >= 0)
if (_todayRecord!.resultCoin > 0)
Text(
'+${_todayRecord!.resultCoin} ${'walletCurrencyShort'.tr()}',
style: Theme.of(context).textTheme.bodyLarge,
)
),
if (_todayRecord!.currentStreak > 0)
Row(
children: [
const Icon(
Symbols.local_fire_department,
size: 14,
).padding(bottom: 2),
const Gap(4),
Text(
'checkInStreak'
.plural(_todayRecord!.currentStreak),
style: Theme.of(context).textTheme.bodySmall,
),
],
).padding(top: 4),
],
),
),
@ -659,7 +815,7 @@ class _HomeDashNotificationWidgetState
child: IconButton(
icon: const Icon(Symbols.arrow_right_alt),
onPressed: () {
GoRouter.of(context).goNamed('notification');
GoRouter.of(context).pushNamed('notification');
},
),
),
@ -743,8 +899,10 @@ class _HomeDashRecommendationPostWidgetState
).tr(),
],
),
Text('${_currentPage + 1}/${_posts?.length ?? 0}',
style: GoogleFonts.robotoMono())
Text(
'${_currentPage + 1}/${_posts?.length ?? 0}',
style: GoogleFonts.robotoMono(),
)
],
).padding(horizontal: 18, top: 12, bottom: 8),
Expanded(
@ -762,6 +920,7 @@ class _HomeDashRecommendationPostWidgetState
child: PostItem(
data: _posts![index],
showMenu: false,
showFullPost: true,
).padding(bottom: 8),
onTap: () {
GoRouter.of(context)

View File

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

View File

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

View File

@ -11,13 +11,10 @@ import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/notification.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/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
import '../providers/userinfo.dart';
@ -63,7 +60,10 @@ class _NotificationScreenState extends State<NotificationScreen> {
queryParameters: {'take': 10, 'offset': _notifications.length},
);
_totalCount = resp.data['count'];
_notifications.addAll(resp.data['data']?.map((e) => SnNotification.fromJson(e)).cast<SnNotification>() ?? []);
_notifications.addAll(resp.data['data']
?.map((e) => SnNotification.fromJson(e))
.cast<SnNotification>() ??
[]);
nty.updateTray();
} catch (err) {
if (!mounted) return;
@ -98,7 +98,8 @@ class _NotificationScreenState extends State<NotificationScreen> {
nty.clear();
if (!mounted) return;
context.showSnackbar('notificationMarkAllReadPrompt'.plural(resp.data['count']));
context.showSnackbar(
'notificationMarkAllReadPrompt'.plural(resp.data['count']));
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
@ -122,7 +123,8 @@ class _NotificationScreenState extends State<NotificationScreen> {
_fetchNotifications();
if (!mounted) return;
context.showSnackbar('notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']));
context.showSnackbar(
'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']));
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
@ -143,17 +145,22 @@ class _NotificationScreenState extends State<NotificationScreen> {
if (!ua.isAuthorized) {
return AppScaffold(
appBar: AppBar(leading: AutoAppBarLeading(), title: Text('screenNotification').tr()),
appBar: AppBar(
leading: PageBackButton(),
title: Text('screenNotification').tr(),
),
body: Center(child: UnauthorizedHint()),
);
}
return AppScaffold(
appBar: AppBar(
leading: AutoAppBarLeading(),
leading: PageBackButton(),
title: Text('screenNotification').tr(),
actions: [
IconButton(icon: const Icon(Symbols.checklist), onPressed: _isSubmitting ? null : _markAllAsRead),
IconButton(
icon: const Icon(Symbols.checklist),
onPressed: _isSubmitting ? null : _markAllAsRead),
const Gap(8),
],
),
@ -167,13 +174,17 @@ class _NotificationScreenState extends State<NotificationScreen> {
return _fetchNotifications();
},
child: InfiniteList(
padding: EdgeInsets.only(top: 16, bottom: math.max(MediaQuery.of(context).padding.bottom, 16)),
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!,
hasReachedMax: _totalCount != null &&
_notifications.length >= _totalCount!,
itemBuilder: (context, idx) {
final nty = _notifications[idx];
return Row(
@ -186,46 +197,55 @@ class _NotificationScreenState extends State<NotificationScreen> {
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),
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),
Text(nty.subtitle!,
style:
Theme.of(context).textTheme.titleSmall),
if (nty.subtitle != null) const Gap(4),
SelectionArea(child: MarkdownTextContent(content: nty.body, isAutoWarp: true)),
SelectionArea(
child: MarkdownTextContent(
content: nty.body, isAutoWarp: true)),
if ([
'interactive.reply',
'interactive.feedback',
'interactive.subscription',
].contains(nty.topic) &&
nty.metadata['related_post'] != null)
GestureDetector(
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
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,
TextButton(
style: ButtonStyle(
padding: WidgetStatePropertyAll(
EdgeInsets.zero,
),
visualDensity: VisualDensity.compact,
),
onTap: () {
child: Text('postReadMore').tr(),
onPressed: () {
GoRouter.of(context).pushNamed(
'postDetail',
pathParameters: {'slug': nty.metadata['related_post']!['id'].toString()},
pathParameters: {
'slug': nty.metadata['related_post']['id']
.toString(),
},
);
},
).padding(top: 8),
),
const Gap(8),
Row(
children: [
Text(DateFormat('yy/MM/dd').format(nty.createdAt)).fontSize(12),
Text(DateFormat('yy/MM/dd')
.format(nty.createdAt))
.fontSize(12),
const Gap(4),
Text('·', style: TextStyle(fontSize: 12)),
const Gap(4),
Text(RelativeTime(context).format(nty.createdAt)).fontSize(12),
Text(RelativeTime(context)
.format(nty.createdAt))
.fontSize(12),
],
).opacity(0.75),
],
@ -235,8 +255,10 @@ class _NotificationScreenState extends State<NotificationScreen> {
IconButton(
icon: const Icon(Symbols.check),
padding: EdgeInsets.all(0),
visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
onPressed: _isSubmitting ? null : () => _markOneAsRead(nty),
visualDensity:
const VisualDensity(horizontal: -4, vertical: -4),
onPressed:
_isSubmitting ? null : () => _markOneAsRead(nty),
),
],
).padding(horizontal: 16);

View File

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

View File

@ -0,0 +1,89 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.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/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class PostDraftBox extends StatefulWidget {
const PostDraftBox({super.key});
@override
State<PostDraftBox> createState() => _PostDraftBoxState();
}
class _PostDraftBoxState extends State<PostDraftBox> {
bool _isBusy = false;
final List<SnPost> _posts = List.empty(growable: true);
int? _totalCount;
Future<void> _fetchPosts() async {
setState(() => _isBusy = true);
try {
final pt = context.read<SnPostContentProvider>();
final resp = await pt.listPosts(
take: 10,
offset: _posts.length,
isDraft: true,
);
final out = resp.$1;
_totalCount = resp.$2;
if (!mounted) return;
_posts.addAll(out);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
title: Text('postDraftBox').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
Expanded(
child: RefreshIndicator(
onRefresh: () {
_posts.clear();
return _fetchPosts();
},
child: InfiniteList(
padding: EdgeInsets.only(top: 8),
hasReachedMax:
_totalCount != null && _posts.length >= _totalCount!,
itemCount: _posts.length,
onFetchData: () => _fetchPosts(),
itemBuilder: (context, idx) {
final ele = _posts[idx];
return OpenablePostItem(
data: ele,
onChanged: (data) {
_posts[idx] = data;
},
onDeleted: () {
_posts.clear();
_fetchPosts();
},
);
},
separatorBuilder: (_, __) =>
const Divider().padding(vertical: 2),
),
),
),
],
),
);
}
}

View File

@ -6,7 +6,6 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_context_menu/flutter_context_menu.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
@ -16,16 +15,15 @@ import 'package:responsive_framework/responsive_framework.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/sn_realm.dart';
import 'package:surface/types/attachment.dart';
import 'package:surface/types/post.dart';
import 'package:surface/types/realm.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/attachment/attachment_input.dart';
import 'package:surface/widgets/attachment/attachment_item.dart';
import 'package:surface/widgets/attachment/pending_attachment_alt.dart';
import 'package:surface/widgets/attachment/pending_attachment_boost.dart';
import 'package:surface/widgets/attachment/pending_attachment_actions.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
@ -36,24 +34,27 @@ import 'package:provider/provider.dart';
import 'package:surface/widgets/post/post_poll_editor.dart';
import 'package:uuid/uuid.dart';
import '../../providers/sn_realm.dart';
const kPostTypes = ['Story', 'Article', 'Question', 'Video'];
const kPostTypeAliases = ['stories', 'articles', 'questions', 'videos'];
class PostEditorExtra {
final String? text;
final String? title;
final String? description;
final List<PostWriteMedia>? attachments;
final SnRealm? realm;
const PostEditorExtra({
this.text,
this.title,
this.description,
this.attachments,
this.realm,
});
}
class PostEditorScreen extends StatefulWidget {
final String mode;
final String? mode;
final int? postEditId;
final int? postReplyId;
final int? postRepostId;
@ -72,7 +73,10 @@ class PostEditorScreen extends StatefulWidget {
State<PostEditorScreen> createState() => _PostEditorScreenState();
}
class _PostEditorScreenState extends State<PostEditorScreen> {
class _PostEditorScreenState extends State<PostEditorScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController =
TabController(length: 4, vsync: this);
late final PostWriteController _writeController = PostWriteController(
doLoadFromTemporary: widget.postEditId == null,
);
@ -133,6 +137,15 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
],
scope: HotKeyScope.inapp,
);
final HotKey _saveDraftHotKey = HotKey(
key: PhysicalKeyboardKey.keyS,
modifiers: [
(!kIsWeb && Platform.isMacOS)
? HotKeyModifier.meta
: HotKeyModifier.control
],
scope: HotKeyScope.inapp,
);
void _registerHotKey() {
if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
@ -148,6 +161,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
]);
setState(() {});
});
hotKeyManager.register(_saveDraftHotKey, keyDownHandler: (_) async {
if (mounted) {
_writeController.sendPost(context, saveAsDraft: true);
}
});
}
void _showPublisherPopup() {
@ -209,9 +227,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
@override
void dispose() {
_tabController.dispose();
_writeController.dispose();
if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) {
hotKeyManager.unregister(_pasteHotKey);
hotKeyManager.unregister(_saveDraftHotKey);
}
super.dispose();
}
@ -220,14 +240,16 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
void initState() {
super.initState();
_registerHotKey();
if (!PostWriteController.kTitleMap.keys.contains(widget.mode)) {
context.showErrorDialog('Unknown post type');
Navigator.pop(context);
} else {
_writeController.setMode(widget.mode);
}
_fetchRealms();
_fetchPublishers();
if (widget.mode != null) {
_writeController.setMode(widget.mode!);
}
_tabController.addListener(() {
if (_tabController.indexIsChanging) {
_writeController.setMode(kPostTypeAliases[_tabController.index]);
}
});
_writeController.fetchRelatedPost(
context,
editing: widget.postEditId,
@ -240,6 +262,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
_writeController.descriptionController.text =
widget.extraProps!.description ?? '';
_writeController.addAttachments(widget.extraProps!.attachments ?? []);
_writeController.setRealm(widget.extraProps!.realm);
}
}
@ -255,38 +278,55 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
Navigator.pop(context);
},
),
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: Theme.of(context).appBarTheme.foregroundColor!,
),
),
const TextSpan(text: '\n'),
TextSpan(
text: PostWriteController.kTitleMap[widget.mode]!.tr(),
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
]),
maxLines: 2,
title: Text(
_writeController.title.isNotEmpty
? _writeController.title
: 'untitled'.tr(),
),
actions: [
IconButton(
icon: _writeController.editingDraft
? const Icon(Icons.save)
: const Icon(Symbols.save_as),
onPressed: () {
_writeController.sendPost(context, saveAsDraft: true).then(
(_) {
if (!context.mounted) return;
context.showSnackbar('postDraftSaved'.tr());
HapticFeedback.mediumImpact();
},
);
},
),
IconButton(
icon: const Icon(Symbols.tune),
onPressed: _writeController.isBusy ? null : _updateMeta,
),
const Gap(8),
],
bottom: _writeController.isNotEmpty || widget.mode != null
? null
: TabBar(
controller: _tabController,
tabs: [
for (final type in kPostTypes)
Tab(
child: Text(
'postType$type'.tr(),
style: TextStyle(
color: Theme.of(context)
.appBarTheme
.foregroundColor!,
),
),
),
],
),
),
body: Column(
children: [
if (_writeController.editingPost != null)
if (_writeController.editingPost != null &&
!_writeController.editingDraft)
Container(
padding: const EdgeInsets.only(
top: 4, bottom: 4, left: 20, right: 20),
@ -303,9 +343,15 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
children: [
const Icon(Icons.edit, size: 16),
const Gap(10),
Text('postEditingNotice').tr(args: [
'@${_writeController.editingPost!.publisher.name}'
]),
Expanded(
child: Text(
'postEditingNotice',
maxLines: 1,
overflow: TextOverflow.ellipsis,
).tr(args: [
'@${_writeController.editingPost!.publisher.name}'
]),
),
],
),
),
@ -374,7 +420,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
children: [
SingleChildScrollView(
padding: EdgeInsets.only(bottom: 160),
child: StyledWidget(switch (_writeController.mode) {
child: switch (_writeController.mode) {
'stories' => _PostStoryEditor(
controller: _writeController,
onTapPublisher: _showPublisherPopup,
@ -396,8 +442,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
onTapRealm: _showRealmPopup,
),
_ => const Placeholder(),
})
.padding(top: 8),
},
),
if (_writeController.attachments.isNotEmpty ||
_writeController.thumbnail != null)
@ -410,7 +455,9 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
isBusy: _writeController.isBusy,
onUpload: (int idx) async {
await _writeController.uploadSingleAttachment(
context, idx);
context,
idx,
);
},
onInsertLink: (int idx) async {
_writeController.contentController.text +=
@ -720,7 +767,7 @@ class _PostStoryEditor extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
padding: const EdgeInsets.only(left: 12, right: 12, top: 8),
constraints: const BoxConstraints(maxWidth: 640),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
@ -969,7 +1016,7 @@ class _PostQuestionEditor extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
padding: const EdgeInsets.only(left: 12, right: 12, top: 8),
constraints: const BoxConstraints(maxWidth: 640),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
@ -1053,12 +1100,12 @@ class _PostQuestionEditor extends StatelessWidget {
),
),
],
).padding(top: 8),
),
);
}
}
class _PostVideoEditor extends StatelessWidget {
class _PostVideoEditor extends StatefulWidget {
final PostWriteController controller;
final Function? onTapPublisher;
final Function? onTapRealm;
@ -1066,7 +1113,14 @@ class _PostVideoEditor extends StatelessWidget {
const _PostVideoEditor(
{required this.controller, this.onTapPublisher, this.onTapRealm});
void _selectVideo(BuildContext context) async {
@override
State<_PostVideoEditor> createState() => _PostVideoEditorState();
}
class _PostVideoEditorState extends State<_PostVideoEditor> {
final TextEditingController _streamUrlController = TextEditingController();
void _selectVideo() async {
final video = await showDialog<SnAttachment?>(
context: context,
builder: (context) => AttachmentInputDialog(
@ -1077,84 +1131,31 @@ class _PostVideoEditor extends StatelessWidget {
);
if (!context.mounted) return;
if (video == null) return;
controller.setVideoAttachment(video);
widget.controller.setVideoAttachment(video);
}
void _setAlt(BuildContext context) async {
if (controller.videoAttachment == null) return;
final result = await showDialog<SnAttachment?>(
context: context,
builder: (context) => PendingAttachmentAltDialog(
media: PostWriteMedia(controller.videoAttachment)),
);
if (result == null) return;
controller.setVideoAttachment(result);
@override
void initState() {
_streamUrlController.addListener(() {
if (_streamUrlController.text.isEmpty) {
widget.controller.setVideoUrl('');
} else {
widget.controller.setVideoUrl(_streamUrlController.text);
}
});
super.initState();
}
Future<void> _createBoost(BuildContext context) async {
if (controller.videoAttachment == null) return;
final result = await showDialog<SnAttachmentBoost?>(
context: context,
builder: (context) => PendingAttachmentBoostDialog(
media: PostWriteMedia(controller.videoAttachment)),
);
if (result == null) return;
final newAttach = controller.videoAttachment!.copyWith(
boosts: [...controller.videoAttachment!.boosts, result],
);
controller.setVideoAttachment(newAttach);
}
void _setThumbnail(BuildContext context) async {
if (controller.videoAttachment == null) return;
final thumbnail = await showDialog<SnAttachment?>(
context: context,
builder: (context) => AttachmentInputDialog(
title: 'attachmentSetThumbnail'.tr(),
pool: 'interactive',
analyzeNow: true,
),
);
if (thumbnail == null) return;
if (!context.mounted) return;
try {
final attach = context.read<SnAttachmentProvider>();
final newAttach = await attach.updateOne(
controller.videoAttachment!,
thumbnailId: thumbnail.id,
);
controller.setVideoAttachment(newAttach);
} catch (err) {
if (!context.mounted) return;
context.showErrorDialog(err);
}
}
Future<void> _deleteAttachment(BuildContext context) async {
if (controller.videoAttachment == null) return;
try {
final sn = context.read<SnNetworkProvider>();
await sn.client
.delete('/cgi/uc/attachments/${controller.videoAttachment!.id}');
controller.setVideoAttachment(null);
} catch (err) {
if (!context.mounted) return;
context.showErrorDialog(err);
}
@override
void dispose() {
_streamUrlController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
padding: const EdgeInsets.only(left: 12, right: 12, top: 8),
constraints: const BoxConstraints(maxWidth: 640),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
@ -1166,10 +1167,10 @@ class _PostVideoEditor extends StatelessWidget {
borderRadius: const BorderRadius.all(Radius.circular(24)),
child: GestureDetector(
onTap: () {
onTapPublisher?.call();
widget.onTapPublisher?.call();
},
child: AccountImage(
content: controller.publisher?.avatar,
content: widget.controller.publisher?.avatar,
),
),
),
@ -1179,10 +1180,10 @@ class _PostVideoEditor extends StatelessWidget {
borderRadius: const BorderRadius.all(Radius.circular(24)),
child: GestureDetector(
onTap: () {
onTapRealm?.call();
widget.onTapRealm?.call();
},
child: AccountImage(
content: controller.realm?.avatar,
content: widget.controller.realm?.avatar,
fallbackWidget: const Icon(Symbols.globe, size: 20),
radius: 14,
),
@ -1195,7 +1196,7 @@ class _PostVideoEditor extends StatelessWidget {
children: [
const Gap(6),
TextField(
controller: controller.titleController,
controller: widget.controller.titleController,
decoration: InputDecoration.collapsed(
hintText: 'fieldPostTitle'.tr(),
border: InputBorder.none,
@ -1206,7 +1207,7 @@ class _PostVideoEditor extends StatelessWidget {
).padding(horizontal: 16),
const Gap(8),
TextField(
controller: controller.descriptionController,
controller: widget.controller.descriptionController,
decoration: InputDecoration.collapsed(
hintText: 'fieldPostDescription'.tr(),
border: InputBorder.none,
@ -1218,66 +1219,52 @@ class _PostVideoEditor extends StatelessWidget {
FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 16),
const Gap(12),
Container(
margin: const EdgeInsets.only(left: 16, right: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).dividerColor),
),
child: ContextMenuRegion(
contextMenu: ContextMenu(
entries: [
MenuItem(
label: 'attachmentSetAlt'.tr(),
icon: Symbols.description,
onSelected: () {
_setAlt(context);
},
),
MenuItem(
label: 'attachmentBoost'.tr(),
icon: Symbols.bolt,
onSelected: () {
_createBoost(context);
},
),
MenuItem(
label: 'attachmentSetThumbnail'.tr(),
icon: Symbols.image,
onSelected: () {
_setThumbnail(context);
},
),
MenuItem(
label: 'attachmentCopyRandomId'.tr(),
icon: Symbols.content_copy,
onSelected: () {
Clipboard.setData(ClipboardData(
text: controller.videoAttachment!.rid));
},
),
MenuItem(
label: 'delete'.tr(),
icon: Symbols.delete,
onSelected: () => _deleteAttachment(context),
),
MenuItem(
label: 'unlink'.tr(),
icon: Symbols.link_off,
onSelected: () {
controller.setVideoAttachment(null);
},
),
],
if (widget.controller.videoLive ||
widget.controller.videoAttachment == null)
TextField(
controller: _streamUrlController,
decoration: InputDecoration(
labelText: 'fieldPostVideoUrl'.tr(),
helperText: 'fieldPostVideoUrlDescription'.tr(),
border: OutlineInputBorder(),
isDense: true,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 16, bottom: 12, top: 2),
if (!widget.controller.videoLive &&
_streamUrlController.text.isEmpty)
Container(
margin: const EdgeInsets.only(left: 16, right: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).dividerColor),
),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: controller.videoAttachment == null
? () => _selectVideo(context)
: null,
onTap: widget.controller.videoAttachment == null
? () => _selectVideo()
: () {
showModalBottomSheet(
context: context,
builder: (context) =>
PendingAttachmentActionSheet(
media: PostWriteMedia(
widget.controller.videoAttachment!,
),
),
).then((value) async {
if (value is PostWriteMedia) {
widget.controller
.setVideoAttachment(value.attachment);
} else if (value == false) {
widget.controller.setVideoAttachment(null);
}
});
},
child: AspectRatio(
aspectRatio: 16 / 9,
child: controller.videoAttachment == null
child: widget.controller.videoAttachment == null
? Center(
child: Row(
mainAxisSize: MainAxisSize.min,
@ -1293,13 +1280,21 @@ class _PostVideoEditor extends StatelessWidget {
: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: AttachmentItem(
data: controller.videoAttachment!,
data: widget.controller.videoAttachment!,
heroTag: const Uuid().v4(),
),
),
),
),
),
const Gap(8),
CheckboxListTile(
secondary: const Icon(Symbols.live_tv),
title: Text('postVideoLive').tr(),
subtitle: Text('postVideoLiveDescription').tr(),
value: widget.controller.videoLive,
onChanged: (value) =>
widget.controller.setVideoLive(value ?? false),
),
],
),

View File

@ -45,7 +45,8 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
}
Future<void> _fetchPosts() async {
if (_searchTerm.isEmpty && _searchCategories.isEmpty && _searchTags.isEmpty) return;
if (_searchTerm.isEmpty && _searchCategories.isEmpty && _searchTags.isEmpty)
return;
if (_postCount != null && _posts.length >= _postCount!) return;
setState(() => _isBusy = true);
@ -152,7 +153,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
},
);
},
separatorBuilder: (_, __) => const Gap(8),
separatorBuilder: (_, __) => const Divider().padding(vertical: 2),
),
Positioned(
top: 16,
@ -166,7 +167,8 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 24),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onChanged: (value) {
_searchTerm = value;
},

View File

@ -0,0 +1,128 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_card_swiper/flutter_card_swiper.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/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart';
class PostShuffleScreen extends StatefulWidget {
const PostShuffleScreen({super.key});
@override
State<PostShuffleScreen> createState() => _PostShuffleScreenState();
}
class _PostShuffleScreenState extends State<PostShuffleScreen> {
late final CardSwiperController _cardController = CardSwiperController();
bool _isBusy = false;
final List<SnPost> _posts = List.empty(growable: true);
Future<void> _fetchPosts() async {
_posts.clear();
setState(() => _isBusy = true);
try {
final pt = context.read<SnPostContentProvider>();
final result =
await pt.listPosts(take: 10, offset: _posts.length, isShuffle: true);
_posts.addAll(result.$1);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchPosts();
}
@override
void dispose() {
super.dispose();
_cardController.dispose();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(title: Text('postShuffle').tr()),
body: Stack(
children: [
Column(
children: [
if (_isBusy || _posts.isEmpty)
const Expanded(
child: Center(child: CircularProgressIndicator()))
else
Expanded(
child: CardSwiper(
controller: _cardController,
isLoop: false,
padding: EdgeInsets.zero,
cardsCount: _posts.length,
cardBuilder: (context, idx, _, __) {
final ele = _posts[idx];
return SingleChildScrollView(
child: Center(
child: Card(
color: Theme.of(context).colorScheme.surface,
child: OpenablePostItem(
key: ValueKey(ele),
data: ele,
maxWidth: 640,
useReplace: true,
onChanged: (ele) {
_posts[idx] = ele;
setState(() {});
},
onDeleted: () {
_fetchPosts();
},
).padding(all: 8),
).padding(
all: 24,
bottom:
MediaQuery.of(context).padding.bottom + 16 + 50,
),
),
);
},
onEnd: () {
_fetchPosts();
},
),
),
],
),
if (!_isBusy && _posts.isNotEmpty)
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 16,
left: 16,
right: 16,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton.filled(
icon: const Icon(Symbols.next_plan),
color: Theme.of(context).colorScheme.onPrimary,
onPressed: () {
_cardController.swipe(CardSwiperDirection.right);
},
),
],
),
),
],
),
);
}
}

View File

@ -34,9 +34,11 @@ class PostPublisherScreen extends StatefulWidget {
State<PostPublisherScreen> createState() => _PostPublisherScreenState();
}
class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTickerProviderStateMixin {
class _PostPublisherScreenState extends State<PostPublisherScreen>
with SingleTickerProviderStateMixin {
late final ScrollController _scrollController = ScrollController();
late final TabController _tabController = TabController(length: 3, vsync: this);
late final TabController _tabController =
TabController(length: 5, vsync: this);
SnPublisher? _publisher;
SnAccount? _account;
@ -66,7 +68,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
_account = await ud.getAccount(_publisher?.accountId);
_accountRelationship = await rel.getRelationship(_account!.id);
if (_publisher?.realmId != null && _publisher!.realmId != 0) {
final resp = await sn.client.get('/cgi/id/realms/${_publisher!.realmId}');
final resp =
await sn.client.get('/cgi/id/realms/${_publisher!.realmId}');
_realm = SnRealm.fromJson(resp.data);
}
} catch (_) {
@ -133,12 +136,14 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
double _appBarBlur = 0.0;
late final _appBarWidth = MediaQuery.of(context).size.width;
late final _appBarHeight = (_appBarWidth * kBannerAspectRatio).roundToDouble();
late final _appBarHeight =
math.min((_appBarWidth * kBannerAspectRatio), 360).roundToDouble();
void _updateAppBarBlur() {
if (_scrollController.offset > _appBarHeight) return;
setState(() {
_appBarBlur = (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
_appBarBlur =
(_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
});
}
@ -160,6 +165,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
type: switch (_tabController.index) {
1 => 'story',
2 => 'article',
3 => 'question',
4 => 'video',
_ => null,
},
);
@ -193,7 +200,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
'related': _account!.name,
});
if (!mounted) return;
context.showSnackbar('userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}']));
context.showSnackbar(
'userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}']));
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
@ -209,9 +217,11 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
try {
final rel = context.read<SnRelationshipProvider>();
await rel.updateRelationship(_account!.id, 1, _accountRelationship?.permNodes ?? {});
await rel.updateRelationship(
_account!.id, 1, _accountRelationship?.permNodes ?? {});
if (!mounted) return;
context.showSnackbar('userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}']));
context.showSnackbar(
'userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}']));
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
@ -276,6 +286,7 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
final sn = context.read<SnNetworkProvider>();
return AppScaffold(
noBackground: ResponsiveScaffold.getIsExpand(context),
body: NestedScrollView(
controller: _scrollController,
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
@ -292,6 +303,7 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
),
child: SliverAppBar(
expandedHeight: _appBarHeight,
leading: const PageBackButton(),
title: _publisher == null
? Text('loading').tr()
: RichText(
@ -299,7 +311,10 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
text: TextSpan(children: [
TextSpan(
text: _publisher!.nick,
style: Theme.of(context).textTheme.titleLarge!.copyWith(
style: Theme.of(context)
.textTheme
.titleLarge!
.copyWith(
color: Colors.white,
shadows: labelShadows,
),
@ -307,7 +322,10 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
const TextSpan(text: '\n'),
TextSpan(
text: '@${_publisher!.name}',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(
color: Colors.white,
shadows: labelShadows,
),
@ -330,13 +348,16 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
)
else
Container(
color: Theme.of(context).colorScheme.surfaceContainer,
color: Theme.of(context)
.colorScheme
.surfaceContainer,
),
Positioned(
top: 0,
left: 0,
right: 0,
height: 56 + MediaQuery.of(context).padding.top,
height:
56 + MediaQuery.of(context).padding.top,
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
@ -345,7 +366,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
),
child: Container(
color: Colors.black.withOpacity(
clampDouble(_appBarBlur * 0.1, 0, 0.5),
clampDouble(
_appBarBlur * 0.1, 0, 0.5),
),
),
),
@ -372,11 +394,14 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
const Gap(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
_publisher!.nick,
style: Theme.of(context).textTheme.titleMedium,
style: Theme.of(context)
.textTheme
.titleMedium,
).bold(),
Text('@${_publisher!.name}').fontSize(13),
],
@ -387,7 +412,9 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
style: ButtonStyle(
elevation: WidgetStatePropertyAll(0),
),
onPressed: _isSubscribing ? null : _toggleSubscription,
onPressed: _isSubscribing
? null
: _toggleSubscription,
label: Text('subscribe').tr(),
icon: const Icon(Symbols.add),
)
@ -396,14 +423,17 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
style: ButtonStyle(
elevation: WidgetStatePropertyAll(0),
),
onPressed: _isSubscribing ? null : _toggleSubscription,
onPressed: _isSubscribing
? null
: _toggleSubscription,
label: Text('unsubscribe').tr(),
icon: const Icon(Symbols.remove),
),
PopupMenuButton(
padding: EdgeInsets.zero,
style: ButtonStyle(
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
visualDensity: VisualDensity(
horizontal: -4, vertical: -4),
),
itemBuilder: (BuildContext context) => [
PopupMenuItem(
@ -443,7 +473,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
],
),
const Gap(12),
Text(_publisher!.description).padding(horizontal: 8),
Text(_publisher!.description)
.padding(horizontal: 8),
const Gap(12),
Column(
children: [
@ -451,8 +482,10 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
children: [
const Icon(Symbols.calendar_add_on),
const Gap(8),
Text('publisherJoinedAt')
.tr(args: [DateFormat('y/M/d').format(_publisher!.createdAt)]),
Text('publisherJoinedAt').tr(args: [
DateFormat('y/M/d')
.format(_publisher!.createdAt)
]),
],
),
Row(
@ -460,7 +493,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
const Icon(Symbols.trending_up),
const Gap(8),
Text('publisherSocialPointTotal').plural(
_publisher!.totalUpvote - _publisher!.totalDownvote,
_publisher!.totalUpvote -
_publisher!.totalDownvote,
),
],
),
@ -470,18 +504,22 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
const Icon(Symbols.group_work),
const Gap(8),
InkWell(
child: Text('publisherAffiliatedBy').tr(args: [
child: Text('publisherAffiliatedBy')
.tr(args: [
'@${_realm?.alias ?? 'unknown'}',
]),
onTap: () {
GoRouter.of(context).pushNamed(
'realmDetail',
pathParameters: {'alias': _realm!.alias},
pathParameters: {
'alias': _realm!.alias
},
);
},
),
const Gap(8),
AccountImage(content: _realm?.avatar, radius: 8),
AccountImage(
content: _realm?.avatar, radius: 8),
],
),
Row(
@ -502,7 +540,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
},
),
const Gap(8),
AccountImage(content: _account?.avatar, radius: 8),
AccountImage(
content: _account?.avatar, radius: 8),
],
),
],
@ -533,6 +572,18 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
color: Theme.of(context).colorScheme.onSurface,
),
),
Tab(
icon: Icon(
Symbols.help,
color: Theme.of(context).colorScheme.onSurface,
),
),
Tab(
icon: Icon(
Symbols.video_call,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
SliverToBoxAdapter(child: const Divider(height: 1)),
@ -606,7 +657,7 @@ class _PublisherPostList extends StatelessWidget {
onDeleted: onDeleted,
);
},
separatorBuilder: (_, __) => const Gap(8),
separatorBuilder: (_, __) => const Divider().padding(vertical: 2),
);
}
}

View File

@ -0,0 +1,149 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/screens/post/post_editor.dart';
import 'package:surface/types/post.dart';
import 'package:surface/types/realm.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class RealmCommunityScreen extends StatefulWidget {
final String alias;
const RealmCommunityScreen({super.key, required this.alias});
@override
State<RealmCommunityScreen> createState() => _RealmCommunityScreenState();
}
class _RealmCommunityScreenState extends State<RealmCommunityScreen> {
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);
rethrow;
} finally {
setState(() {});
}
}
bool _isBusy = false;
int? _totalCount;
final List<SnPost> _posts = List.empty(growable: true);
Future<void> _fetchPosts() async {
setState(() => _isBusy = true);
try {
final pt = context.read<SnPostContentProvider>();
final out = await pt.listPosts(
take: 10,
offset: _posts.length,
realm: _realm?.id.toString(),
);
_totalCount = out.$2;
_posts.addAll(out.$1);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchRealm();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
title: Text(_realm?.name ?? 'loading'.tr()),
),
floatingActionButton: _realm != null
? FloatingActionButton(
child: const Icon(Symbols.edit),
onPressed: () {
GoRouter.of(context).pushNamed(
'postEditor',
extra: PostEditorExtra(realm: _realm!),
);
},
)
: null,
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_realm == null)
Expanded(
child: Center(
child: CircularProgressIndicator().center(),
),
),
if (_realm != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('realmCommunity'.tr(args: [_realm!.name]))
.fontSize(17)
.padding(horizontal: 20, bottom: 4),
Text('postTotalCount'.plural(_totalCount ?? 0))
.fontSize(13)
.opacity(0.8)
.padding(horizontal: 20, bottom: 4),
],
).padding(horizontal: 20, vertical: 16),
const Divider(height: 1),
if (_realm != null)
Expanded(
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: RefreshIndicator(
onRefresh: _fetchPosts,
child: InfiniteList(
padding: const EdgeInsets.only(top: 8),
itemCount: _posts.length,
isLoading: _isBusy,
hasReachedMax:
_totalCount != null && _posts.length >= _totalCount!,
onFetchData: _fetchPosts,
itemBuilder: (context, idx) {
final post = _posts[idx];
return OpenablePostItem(
data: post,
maxWidth: 640,
onChanged: (data) {
setState(() => _posts[idx] = data);
},
onDeleted: () {
setState(() => _posts.removeAt(idx));
},
);
},
separatorBuilder: (_, __) =>
const Divider().padding(vertical: 2),
),
),
),
),
],
),
);
}
}

View File

@ -318,7 +318,7 @@ class _RealmPostListWidgetState extends State<_RealmPostListWidget> {
},
);
},
separatorBuilder: (_, __) => const Gap(8),
separatorBuilder: (_, __) => const Divider().padding(vertical: 2),
),
),
).padding(top: 8);

View File

@ -4,8 +4,10 @@ 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/channel.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/sn_realm.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/types/realm.dart';
@ -57,7 +59,9 @@ class _RealmDiscoveryScreenState extends State<RealmDiscoveryScreen> {
title: Text('screenRealmDiscovery').tr(),
actions: [
IconButton(
icon: _isCompactView ? const Icon(Symbols.view_list) : const Icon(Symbols.view_module),
icon: _isCompactView
? const Icon(Symbols.view_list)
: const Icon(Symbols.view_module),
onPressed: () {
setState(() => _isCompactView = !_isCompactView);
context.read<ConfigProvider>().realmCompactView = _isCompactView;
@ -117,7 +121,8 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
try {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/im/channels/${widget.realm.alias}/public');
final resp =
await sn.client.get('/cgi/im/channels/${widget.realm.alias}/public');
final out = List<SnChannel>.from(
resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(),
);
@ -135,10 +140,13 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
setState(() => _isJoining = true);
final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>();
await sn.client.post('/cgi/id/realms/${widget.realm.alias}/members', data: {
final rel = context.read<SnRealmProvider>();
await sn.client
.post('/cgi/id/realms/${widget.realm.alias}/members', data: {
'related': ua.user?.name,
});
await _joinSelectedChannels();
rel.addAvailableRealm(widget.realm);
if (!mounted) return;
context.showSnackbar('realmJoined'.tr(args: [widget.realm.name]));
Navigator.pop(context);
@ -156,13 +164,20 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
try {
final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>();
await sn.client.post('/cgi/im/channels/${widget.realm.alias}/$channel/members', data: {
'related': ua.user?.name,
});
await sn.client.post(
'/cgi/im/channels/${widget.realm.alias}/$channel/members',
data: {
'related': ua.user?.name,
});
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
}
final ct = context.read<ChatChannelProvider>();
for (final channel
in _channels!.where((ele) => _planJoinChannels.contains(ele.alias))) {
ct.addAvailableChannel(channel);
}
}
}
@ -182,7 +197,8 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
children: [
const Icon(Symbols.group_add, size: 24),
const Gap(16),
Text('realmJoin', style: Theme.of(context).textTheme.titleLarge).tr(),
Text('realmJoin', style: Theme.of(context).textTheme.titleLarge)
.tr(),
],
).padding(horizontal: 20, top: 16, bottom: 12),
Row(
@ -216,7 +232,8 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
Container(
width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Text('realmCommunityPublicChannelsHint'.tr(), style: Theme.of(context).textTheme.bodyMedium)
child: Text('realmCommunityPublicChannelsHint'.tr(),
style: Theme.of(context).textTheme.bodyMedium)
.padding(horizontal: 24, vertical: 8),
),
Expanded(

View File

@ -80,6 +80,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget build(BuildContext context) {
final sn = context.read<SnNetworkProvider>();
final dt = context.read<DatabaseProvider>();
final cfg = context.watch<ConfigProvider>();
final now = DateTime.now();
return AppScaffold(
appBar: AppBar(
@ -323,19 +326,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
CheckboxListTile(
secondary: const Icon(Symbols.left_panel_close),
title: Text('settingsDrawerPreferCollapse').tr(),
subtitle:
Text('settingsDrawerPreferCollapseDescription').tr(),
secondary: const Icon(Symbols.hide),
title: Text('settingsHideBottomNav').tr(),
subtitle: Text('settingsHideBottomNavDescription').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
value: _prefs.getBool(kAppDrawerPreferCollapse) ?? false,
value: _prefs.getBool(kAppHideBottomNav) ?? false,
onChanged: (value) {
_prefs.setBool(kAppDrawerPreferCollapse, value ?? false);
_prefs.setBool(kAppHideBottomNav, value ?? false);
final cfg = context.read<ConfigProvider>();
cfg.calcDrawerSize(context);
setState(() {});
},
),
CheckboxListTile(
value: cfg.soundEffects,
onChanged: (value) {
cfg.soundEffects = value ?? false;
setState(() {});
},
contentPadding: const EdgeInsets.only(left: 24, right: 17),
title: Text('settingsSoundEffects').tr(),
subtitle: Text('settingsSoundEffectsDescription').tr(),
secondary: const Icon(Symbols.sound_sampler),
),
if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS))
ListTile(
leading: const Icon(Symbols.window),
title: Text('settingsResetMemorizedWindowSize').tr(),
subtitle:
Text('settingsResetMemorizedWindowSizeDescription')
.tr(),
trailing: const Icon(Symbols.chevron_right),
contentPadding: const EdgeInsets.only(left: 24, right: 24),
onTap: () {
final prefs = context.read<ConfigProvider>().prefs;
prefs.remove(kAppWindowSize);
},
),
ListTile(
leading: const Icon(Symbols.font_download),
title: Text('settingsCustomFonts').tr(),
@ -387,6 +414,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
.fontSize(17)
.tr()
.padding(horizontal: 20, bottom: 4),
CheckboxListTile(
secondary: const Icon(Symbols.translate),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
title: Text('settingsAutoTranslate').tr(),
subtitle: Text('settingsAutoTranslateDescription').tr(),
value: _prefs.getBool(kAppAutoTranslate) ?? false,
onChanged: (value) {
setState(() {
_prefs.setBool(kAppAutoTranslate, value ?? false);
});
},
),
CheckboxListTile(
secondary: const Icon(Symbols.vibration),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
@ -716,6 +755,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
GoRouter.of(context).pushNamed('about');
},
),
if (now.day == 1 && now.month == 4)
CheckboxListTile(
title: Text('settingsAprilFoolFeatures').tr(),
subtitle: Text('settingsAprilFoolFeaturesDescription').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
secondary: const Icon(Symbols.new_releases),
value: cfg.aprilFoolFeatures,
onChanged: (value) {
cfg.aprilFoolFeatures = value ?? false;
setState(() {});
},
)
],
),
],

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