Compare commits

..

137 Commits

Author SHA1 Message Date
29ca263130 🚀 Launch 1.2.1+13 2024-08-16 01:03:55 +08:00
7332f68d9c Live preview of post editor 2024-08-16 00:52:36 +08:00
e9e6f3313e 👽 Use capital to deal with mfa 2024-08-13 10:54:42 +08:00
85764c37c2 🚨 Fix livekit android complie issue
Following issue:
https://github.com/livekit/client-sdk-flutter/issues/569
2024-08-12 09:06:30 +08:00
ef1f29f905 🐛 Fix edit post won't rollback thumbnail 2024-08-11 02:07:09 +08:00
22026efa7d Thumbnail 2024-08-11 01:57:58 +08:00
4a3e6a9e15 🚀 Launch 1.2.1+12 2024-08-11 00:50:25 +08:00
00092ba7b6 Some useful options 2024-08-11 00:36:27 +08:00
b5da8ece4a Use capital share link 2024-08-10 18:24:47 +08:00
dfe9165bc9 🐛 Bug fixes on upload attachment 2024-08-10 01:17:31 +08:00
3d45b54236 ⬆️ Upgrade flutter & deps 2024-08-10 01:16:40 +08:00
7f63fe7f0e 💄 Better sidebar navigation 2024-08-10 00:51:54 +08:00
bc5dbab9c5 Dismissible refresh notification 2024-08-10 00:49:21 +08:00
9910fc7a92 Channel content auto refresh after long time background activity 2024-08-10 00:43:55 +08:00
2356eac118 Better side navigation bar 2024-08-09 22:59:24 +08:00
0135b8d838 Better screenshare 2024-08-09 22:40:05 +08:00
8ec33ccbf4 🚨 Fix CarouselController import issue 2024-08-07 19:21:01 +08:00
d267316a35 💄 Better emotes 2024-08-07 19:11:52 +08:00
138da60e55 🚸 Prevent user from sending empty message 2024-08-07 19:02:49 +08:00
4562c2f991 🐛 Fix able send space message 2024-08-07 18:31:26 +08:00
8009f4ca9b 💄 Better sidebar navigation 2024-08-07 18:24:16 +08:00
54dee9702b 🐛 Fix attachments max width 2024-08-07 14:34:41 +08:00
94385564bd 🐛 Fix dupe attachment notification 2024-08-07 14:27:23 +08:00
0b2309816f 🐛 Fix desktop panic when download things 2024-08-07 13:50:50 +08:00
8283272a3b 🗑️ Fix mis-import 2024-08-07 01:49:03 +08:00
eb02a47e9a 💄 Fixes and improvements 2024-08-07 01:47:53 +08:00
7c0c1ec94f 💄 Optimize styles 2024-08-07 01:20:23 +08:00
272044a77e 💄 Optimize logo in signup & signin popup 2024-08-07 01:06:57 +08:00
39c22b1cf6 Sticker has pack id 2024-08-07 00:56:06 +08:00
98c3bb912d Stickers auto resize 2024-08-07 00:52:34 +08:00
035b92d9b8 Rollback sized container 2024-08-07 00:12:44 +08:00
0bfc0bd61b 🌐 Update en translation 2024-08-07 00:08:29 +08:00
de00a20eee 💄 Better call ui 2024-08-06 23:23:02 +08:00
73982f48d6 🐛 Bug fixes 2024-08-06 20:00:13 +08:00
1d36b30361 Video won't load until click 2024-08-06 19:39:07 +08:00
dea743a307 Username hint 2024-08-06 18:34:46 +08:00
c48bd3e758 Stickers hint 2024-08-06 18:18:40 +08:00
56bbf73b5e Better sticker & able embed attachment into markdown 2024-08-06 16:24:47 +08:00
4f6c5aa053 🐛 Bug fixes 2024-08-04 21:12:35 +08:00
d8e79fb4f9 🚀 Launch 1.2.1+5 2024-08-04 20:49:11 +08:00
06e0fa465b Article has special badge 2024-08-04 20:48:51 +08:00
895a257f50 Better overflow effect 2024-08-04 20:43:25 +08:00
d9804ba00b 🚸 Enhanced share feature 2024-08-04 18:32:16 +08:00
62ff1c2f1c 🚀 Launch 1.2.1+4 2024-08-04 18:14:28 +08:00
a157596a2e Optimize and fixes 2024-08-04 18:13:59 +08:00
12102bf527 Limit content and read more in posts 2024-08-04 17:39:22 +08:00
c00a018380 🐛 Fix draft box 2024-08-04 17:15:56 +08:00
53b3cac4ca Show hint when dismissible error 2024-08-04 16:26:05 +08:00
19eabfaba1 🚀 Launch 1.2.1+2 2024-08-04 13:27:14 +08:00
ec2eadad6d 🐛 Fix bootstrapper icon issue 2024-08-04 12:59:13 +08:00
54e176e75d 🐛 Fix post editor cannot reply either repost 2024-08-04 12:55:05 +08:00
0a7ccaeefa 🐛 Fix attachment editor title overflow 2024-08-04 12:23:39 +08:00
a5f093e185 🐛 Fix unauthorized wont load stickers 2024-08-04 11:10:25 +08:00
a4f68dd175 🚀 Launch 1.2.1+1 2024-08-04 01:54:35 +08:00
8067c35c70 Follow the manifest to load emotes 2024-08-04 01:53:52 +08:00
ebe381053e Load emojis 2024-08-04 01:37:54 +08:00
03f2470dae Basic sticker management 2024-08-04 01:03:09 +08:00
ea434815cf Create sticker
 Single file mode attachment editor and more options
2024-08-03 21:29:48 +08:00
bbea4b4359 🍱 Update app icons 2024-08-03 17:44:36 +08:00
e0b485cc81 🐛 Fix mis-style 2024-08-03 14:00:52 +08:00
87bb37ac01 ⚗️ Markdown embed content 2024-08-03 12:29:13 +08:00
989b5babd9 Auto update checking 2024-08-03 01:14:42 +08:00
9ea364640d 🚀 Launch 1.2.0+8 2024-08-02 23:24:36 +08:00
a9f55a489d ⬆️ Clean and upgrade packages 2024-08-02 23:22:50 +08:00
4616f3a3e2 Friend request indicator 2024-08-02 23:15:28 +08:00
425bae9d13 💄 Better friend page loading indicator 2024-08-02 22:54:56 +08:00
07771e8979 Improve the speed of fetching attachments meta via batch api 2024-08-02 22:46:48 +08:00
0ad4854443 💄 Grid view in call 2024-08-02 21:12:37 +08:00
4238ea6fdc Call grid layout 2024-08-02 18:49:28 +08:00
7d45c06302 💄 Optimized signal indicator 2024-08-02 18:29:01 +08:00
7e8993fbd2 💫 Auto hide or show call controls 2024-08-02 18:09:07 +08:00
c88fcc84da Show call participants 2024-08-02 17:14:23 +08:00
11fb79623e Attachment can link exists things
 Optimize upload progress
2024-08-02 15:49:32 +08:00
98cc313a91 💫 Optimize chat event list animation 2024-08-02 14:14:09 +08:00
bc3401a897 🐛 Fix post item color mismatch 2024-08-02 05:10:10 +08:00
5b6a5d9046 🐛 Fix post popup color mismatch 2024-08-02 05:04:31 +08:00
6cbd78e836 💫 Optimize post editor transition 2024-08-02 04:59:35 +08:00
aefcbad02f 💫 Better animated post list 2024-08-02 04:42:38 +08:00
70617be687 💫 Animated chat 2024-08-02 04:24:12 +08:00
cccb3d5c16 🐛 Fix post won't refresh after post 2024-08-02 01:00:31 +08:00
a0a3a8d182 DM message last preview 2024-08-02 00:54:19 +08:00
c6b2ef8459 💄 Better about 2024-08-02 00:41:12 +08:00
34a2fe3988 Move about page link from account to settings 2024-08-02 00:29:51 +08:00
0a5604d0ff Crop image in personalize 2024-08-02 00:12:16 +08:00
5e754ad233 💫 About page icon will rotate 2024-08-01 23:51:03 +08:00
5b9c92e4d3 Crop image 2024-08-01 23:44:07 +08:00
b2a6ca7244 Improve attachments queue performance 2024-08-01 23:10:19 +08:00
27c60fc8cb Block user action when attachments isn't ready 2024-08-01 22:36:00 +08:00
8b3c45ab29 Queued upload 2024-08-01 22:13:08 +08:00
adb415700a 💄 Optimized attachment edit action 2024-08-01 17:19:55 +08:00
1e4b44a78b 💄 Better attachment editor previewing 2024-08-01 16:45:18 +08:00
9765b200b9 🐛 Fix content previewing will show attachments 2024-08-01 16:28:48 +08:00
47d03ce1e5 🐛 Bug fixes 2024-08-01 16:09:09 +08:00
c41a71388d Post with publish at and until 2024-08-01 15:49:42 +08:00
7655dfdf37 Post publish zone 2024-08-01 15:21:43 +08:00
190bb34958 Markdown toolbar 2024-08-01 14:46:01 +08:00
d02ed68afa Mention user in chat 2024-08-01 14:01:12 +08:00
2bc4513bb6 🐛 Fix post tag input issue 2024-08-01 11:49:28 +08:00
f10393f6d0 Download attachment 2024-08-01 02:10:57 +08:00
ecef8dab0c Fix post list ui jank 2024-08-01 01:21:27 +08:00
52e58fce3d Make theme switcher easier to use 2024-07-31 22:48:22 +08:00
31d50bfb1f 🐛 Fix web url issue 2024-07-31 21:01:32 +08:00
ca8ad12d93 🍱 Update font 2024-07-31 20:45:36 +08:00
f799900450 🐛 Fix crash on ratio 1 in attachment 2024-07-31 20:45:16 +08:00
dfdf7b23c8 🐛 Fix theme switching 2024-07-31 13:29:26 +08:00
771b2029b0 🍱 Add fonts 2024-07-31 13:29:17 +08:00
cc9c99f375 Global theme color 2024-07-31 02:44:49 +08:00
b70d3795d1 Better tags input 2024-07-31 02:00:03 +08:00
a16ff1b9a1 🍱 Update app icon 2024-07-30 21:24:30 +08:00
19751617cb Able to edit visibility 2024-07-30 20:49:01 +08:00
bb77b74356 Able to post article 2024-07-30 16:53:13 +08:00
fc77c8693f Post editor able to edit article 2024-07-30 16:44:04 +08:00
58bb549217 Post content local cache 2024-07-30 16:29:30 +08:00
6590062dcb Post overview w/ content length limit indicator 2024-07-30 14:49:26 +08:00
6ace977bf6 💄 Better fullscreen attachment viewer 2024-07-30 12:22:57 +08:00
387f0d14ac ⬆️ Upgrade packages 2024-07-30 12:21:39 +08:00
18bb0d3db2 🍱 Update app icon for v1.2.0 2024-07-30 11:50:26 +08:00
8ab3ca5633 ⬆️ Support latest Paperclip 2024-07-29 18:06:38 +08:00
3db6850d89 🐛 Fix attachment displaying according the latest server 2024-07-29 17:56:36 +08:00
3ca98fa58c 🐛 Fix share link issue 2024-07-27 20:37:04 +08:00
425c79d6fc 🚀 Launch the last version of 1.1.0 2024-07-27 20:34:02 +08:00
7e98edfbc9 🐛 Fix web issue 2024-07-27 20:27:29 +08:00
056b98db07 🐛 Fix web 404 issue 2024-07-27 19:58:44 +08:00
7bfbd37b76 🐛 Fix attachment fullscreen in dark mode 2024-07-27 19:52:22 +08:00
7800a70ef2 Deep link 2024-07-27 19:20:53 +08:00
74b6ccd5c7 🐛 Fix share 2024-07-27 14:32:31 +08:00
6ca4aad1c4 💄 Better bootstrapping 2024-07-27 14:16:49 +08:00
102df2ef1c Share 2024-07-27 02:11:59 +08:00
f08c9903b4 Bootstrapper 2024-07-27 01:39:20 +08:00
0d279842cf 💄 Better full screen attachment display 2024-07-27 00:20:11 +08:00
33d69908a6 Social credit points & quick send friend request 2024-07-26 22:37:08 +08:00
4552dfd3f3 Pinned post & Total vote counts 2024-07-26 18:23:51 +08:00
ae87e9ad31 💄 Optimized album page 2024-07-26 17:35:54 +08:00
277ba69513 Account profile page 2024-07-26 16:53:05 +08:00
6e3d0f9787 💄 Better attachments in posts 2024-07-26 14:21:00 +08:00
0237409d27 🐛 Fix search with tag won't work 2024-07-26 01:31:45 +08:00
a5b6ace79b 💄 Better attachments list styles 2024-07-26 01:16:32 +08:00
175 changed files with 8595 additions and 2771 deletions

View File

@@ -16,33 +16,12 @@ jobs:
channel: stable channel: stable
cache: true cache: true
- run: flutter pub get - run: flutter pub get
- run: flutter build web - run: flutter build web --release --base-href=/
- name: Archive production artifacts - name: Archive production artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: build-output-web name: build-output-web
path: build/web path: build/web
build-apk:
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- uses: actions/setup-java@v4
with:
distribution: "zulu"
java-version: "17"
- run: flutter pub get
- run: flutter build apk --split-per-abi
- name: Archive production artifacts
uses: actions/upload-artifact@v4
with:
name: build-output-android
path: build/app/outputs/flutter-apk/*.apk
build-exe: build-exe:
runs-on: windows-latest runs-on: windows-latest
steps: steps:

View File

@@ -7,6 +7,9 @@
# The following line activates a set of recommended lints for Flutter apps, # The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
analyzer:
errors:
use_build_context_synchronously: ignore
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
linter: linter:

View File

@@ -23,11 +23,26 @@ if (flutterVersionName == null) {
flutterVersionName = "1.0" flutterVersionName = "1.0"
} }
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android { android {
namespace = "dev.solsynth.solian" namespace = "dev.solsynth.solian"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = "26.1.10909125" ndkVersion = "26.1.10909125"
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storePassword keystoreProperties['storePassword']
}
}
compileOptions { compileOptions {
coreLibraryDesugaringEnabled true coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_17 sourceCompatibility JavaVersion.VERSION_17
@@ -45,7 +60,8 @@ android {
buildTypes { buildTypes {
release { release {
signingConfig = signingConfigs.debug // signingConfig = signingConfigs.debug
signingConfig = signingConfigs.release
} }
} }
} }

View File

@@ -21,7 +21,7 @@
<application <application
android:label="Solian" android:label="Solian"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_launcher"
android:supportsRtl="true"> android:supportsRtl="true">
<receiver android:exported="false" <receiver android:exported="false"
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver"/> android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver"/>
@@ -54,7 +54,9 @@
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" android:host="solsynth.dev" /> <data android:host="solsynth.dev" />
<data android:host="sn.solsynth.dev" />
<data android:scheme="https" />
<data android:scheme="https" /> <data android:scheme="https" />
<data android:scheme="solink" /> <data android:scheme="solink" />
</intent-filter> </intent-filter>
@@ -68,6 +70,12 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name="com.yalantis.ucrop.UCropActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.AppCompat.Light.NoActionBar"/>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -12,6 +12,17 @@ subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}" project.buildDir = "${rootProject.buildDir}/${project.name}"
} }
subprojects { subprojects {
// TO FIX LIVEKIT ISSUE BY THIS
// https://github.com/livekit/client-sdk-flutter/issues/569#issuecomment-2275686786
afterEvaluate { project ->
if (project.plugins.hasPlugin("com.android.application") ||
project.plugins.hasPlugin("com.android.library")) {
project.android {
compileSdkVersion 34
buildToolsVersion "34.0.0"
}
}
}
project.evaluationDependsOn(":app") project.evaluationDependsOn(":app")
} }

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 537 KiB

BIN
assets/icon-w-shadow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 360 KiB

View File

@@ -1,3 +1,4 @@
description: This file stores settings for Dart & Flutter DevTools. description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions: extensions:
- provider: true

View File

@@ -38,19 +38,19 @@ PODS:
- file_picker (0.0.1): - file_picker (0.0.1):
- DKImagePickerController/PhotoGallery - DKImagePickerController/PhotoGallery
- Flutter - Flutter
- Firebase/CoreOnly (10.28.0): - Firebase/CoreOnly (10.29.0):
- FirebaseCore (= 10.28.0) - FirebaseCore (= 10.29.0)
- Firebase/Messaging (10.28.0): - Firebase/Messaging (10.29.0):
- Firebase/CoreOnly - Firebase/CoreOnly
- FirebaseMessaging (~> 10.28.0) - FirebaseMessaging (~> 10.29.0)
- firebase_core (3.2.0): - firebase_core (3.3.0):
- Firebase/CoreOnly (= 10.28.0) - Firebase/CoreOnly (= 10.29.0)
- Flutter - Flutter
- firebase_messaging (15.0.3): - firebase_messaging (15.0.4):
- Firebase/Messaging (= 10.28.0) - Firebase/Messaging (= 10.29.0)
- firebase_core - firebase_core
- Flutter - Flutter
- FirebaseCore (10.28.0): - FirebaseCore (10.29.0):
- FirebaseCoreInternal (~> 10.0) - FirebaseCoreInternal (~> 10.0)
- GoogleUtilities/Environment (~> 7.12) - GoogleUtilities/Environment (~> 7.12)
- GoogleUtilities/Logger (~> 7.12) - GoogleUtilities/Logger (~> 7.12)
@@ -61,7 +61,7 @@ PODS:
- GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/UserDefaults (~> 7.8) - GoogleUtilities/UserDefaults (~> 7.8)
- PromisesObjC (~> 2.1) - PromisesObjC (~> 2.1)
- FirebaseMessaging (10.28.0): - FirebaseMessaging (10.29.0):
- FirebaseCore (~> 10.0) - FirebaseCore (~> 10.0)
- FirebaseInstallations (~> 10.0) - FirebaseInstallations (~> 10.0)
- GoogleDataTransport (~> 9.3) - GoogleDataTransport (~> 9.3)
@@ -71,11 +71,16 @@ PODS:
- GoogleUtilities/UserDefaults (~> 7.8) - GoogleUtilities/UserDefaults (~> 7.8)
- nanopb (< 2.30911.0, >= 2.30908.0) - nanopb (< 2.30911.0, >= 2.30908.0)
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_keyboard_visibility (0.0.1):
- Flutter
- flutter_secure_storage (6.0.0): - flutter_secure_storage (6.0.0):
- Flutter - Flutter
- flutter_webrtc (0.11.3): - flutter_webrtc (0.11.3):
- Flutter - Flutter
- WebRTC-SDK (= 125.6422.04) - WebRTC-SDK (= 125.6422.04)
- gal (1.0.0):
- Flutter
- FlutterMacOS
- GoogleDataTransport (9.4.1): - GoogleDataTransport (9.4.1):
- GoogleUtilities/Environment (~> 7.7) - GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30911.0, >= 2.30908.0) - nanopb (< 2.30911.0, >= 2.30908.0)
@@ -105,9 +110,12 @@ PODS:
- GoogleUtilities/UserDefaults (7.13.3): - GoogleUtilities/UserDefaults (7.13.3):
- GoogleUtilities/Logger - GoogleUtilities/Logger
- GoogleUtilities/Privacy - GoogleUtilities/Privacy
- image_cropper (0.0.4):
- Flutter
- TOCropViewController (~> 2.7.4)
- image_picker_ios (0.0.1): - image_picker_ios (0.0.1):
- Flutter - Flutter
- livekit_client (2.2.1): - livekit_client (2.2.3):
- Flutter - Flutter
- WebRTC-SDK (= 125.6422.04) - WebRTC-SDK (= 125.6422.04)
- media_kit_libs_ios_video (1.0.4): - media_kit_libs_ios_video (1.0.4):
@@ -130,23 +138,31 @@ PODS:
- FlutterMacOS - FlutterMacOS
- permission_handler_apple (9.3.0): - permission_handler_apple (9.3.0):
- Flutter - Flutter
- pointer_interceptor_ios (0.0.1):
- Flutter
- PromisesObjC (2.4.0) - PromisesObjC (2.4.0)
- protocol_handler_ios (0.0.1): - protocol_handler_ios (0.0.1):
- Flutter - Flutter
- screen_brightness_ios (0.1.0): - screen_brightness_ios (0.1.0):
- Flutter - Flutter
- SDWebImage (5.19.4): - SDWebImage (5.19.6):
- SDWebImage/Core (= 5.19.4) - SDWebImage/Core (= 5.19.6)
- SDWebImage/Core (5.19.4) - SDWebImage/Core (5.19.6)
- Sentry/HybridSDK (8.30.1) - Sentry/HybridSDK (8.33.0)
- sentry_flutter (8.4.0): - sentry_flutter (8.7.0):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.33.0)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- Sentry/HybridSDK (= 8.30.1)
- sqflite (0.0.3): - sqflite (0.0.3):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- SwiftyGif (5.4.5) - SwiftyGif (5.4.5)
- TOCropViewController (2.7.4)
- url_launcher_ios (0.0.1): - url_launcher_ios (0.0.1):
- Flutter - Flutter
- volume_controller (0.0.1): - volume_controller (0.0.1):
@@ -162,8 +178,11 @@ DEPENDENCIES:
- firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
- gal (from `.symlinks/plugins/gal/darwin`)
- image_cropper (from `.symlinks/plugins/image_cropper/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- livekit_client (from `.symlinks/plugins/livekit_client/ios`) - livekit_client (from `.symlinks/plugins/livekit_client/ios`)
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
@@ -173,9 +192,12 @@ DEPENDENCIES:
- pasteboard (from `.symlinks/plugins/pasteboard/ios`) - pasteboard (from `.symlinks/plugins/pasteboard/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`)
- protocol_handler_ios (from `.symlinks/plugins/protocol_handler_ios/ios`) - protocol_handler_ios (from `.symlinks/plugins/protocol_handler_ios/ios`)
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`) - screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`) - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`) - sqflite (from `.symlinks/plugins/sqflite/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- volume_controller (from `.symlinks/plugins/volume_controller/ios`) - volume_controller (from `.symlinks/plugins/volume_controller/ios`)
@@ -197,6 +219,7 @@ SPEC REPOS:
- SDWebImage - SDWebImage
- Sentry - Sentry
- SwiftyGif - SwiftyGif
- TOCropViewController
- WebRTC-SDK - WebRTC-SDK
EXTERNAL SOURCES: EXTERNAL SOURCES:
@@ -212,10 +235,16 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/firebase_messaging/ios" :path: ".symlinks/plugins/firebase_messaging/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
flutter_keyboard_visibility:
:path: ".symlinks/plugins/flutter_keyboard_visibility/ios"
flutter_secure_storage: flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios" :path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_webrtc: flutter_webrtc:
:path: ".symlinks/plugins/flutter_webrtc/ios" :path: ".symlinks/plugins/flutter_webrtc/ios"
gal:
:path: ".symlinks/plugins/gal/darwin"
image_cropper:
:path: ".symlinks/plugins/image_cropper/ios"
image_picker_ios: image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios" :path: ".symlinks/plugins/image_picker_ios/ios"
livekit_client: livekit_client:
@@ -234,12 +263,18 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/path_provider_foundation/darwin" :path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple: permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios" :path: ".symlinks/plugins/permission_handler_apple/ios"
pointer_interceptor_ios:
:path: ".symlinks/plugins/pointer_interceptor_ios/ios"
protocol_handler_ios: protocol_handler_ios:
:path: ".symlinks/plugins/protocol_handler_ios/ios" :path: ".symlinks/plugins/protocol_handler_ios/ios"
screen_brightness_ios: screen_brightness_ios:
:path: ".symlinks/plugins/screen_brightness_ios/ios" :path: ".symlinks/plugins/screen_brightness_ios/ios"
sentry_flutter: sentry_flutter:
:path: ".symlinks/plugins/sentry_flutter/ios" :path: ".symlinks/plugins/sentry_flutter/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite: sqflite:
:path: ".symlinks/plugins/sqflite/darwin" :path: ".symlinks/plugins/sqflite/darwin"
url_launcher_ios: url_launcher_ios:
@@ -255,20 +290,23 @@ SPEC CHECKSUMS:
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
Firebase: 5121c624121af81cbc81df3bda414b3c28c4f3c3 Firebase: cec914dab6fd7b1bd8ab56ea07ce4e03dd251c2d
firebase_core: a9d0180d5285527884d07a41eb4a9ec9ed12cdb6 firebase_core: 57aeb91680e5d5e6df6b888064be7c785f146efb
firebase_messaging: ccc82a143a74de75f440a4e413dbbb37ec3fddbc firebase_messaging: c862b3d2b973ecc769194dc8de09bd22c77ae757
FirebaseCore: 857dc1c6dd1255675047404d8466f7dfaac5d779 FirebaseCore: 30e9c1cbe3d38f5f5e75f48bfcea87d7c358ec16
FirebaseCoreInternal: df84dd300b561c27d5571684f389bf60b0a5c934 FirebaseCoreInternal: df84dd300b561c27d5571684f389bf60b0a5c934
FirebaseInstallations: 913cf60d0400ebd5d6b63a28b290372ab44590dd FirebaseInstallations: 913cf60d0400ebd5d6b63a28b290372ab44590dd
FirebaseMessaging: 087a7c7cadef7b9239f005bc4db823894844f323 FirebaseMessaging: 7b5d8033e183ab59eb5b852a53201559e976d366
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
flutter_webrtc: 75b868e4f9e817c7a9a42ca4b6169063de4eec9f flutter_webrtc: 75b868e4f9e817c7a9a42ca4b6169063de4eec9f
gal: 61e868295d28fe67ffa297fae6dacebf56fd53e1
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
image_cropper: 37d40f62177c101ff4c164906d259ea2c3aa70cf
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
livekit_client: 411d387fd6f993851081069afbe7f04a8e974f1b livekit_client: bad83a7776a41abc42e1f26d903eeac9164c8a9f
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1 media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
@@ -277,14 +315,18 @@ SPEC CHECKSUMS:
pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0 pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
pointer_interceptor_ios: 508241697ff0947f853c061945a8b822463947c1
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
protocol_handler_ios: a5db8abc38526ee326988b808be621e5fd568990 protocol_handler_ios: a5db8abc38526ee326988b808be621e5fd568990
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625 screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d SDWebImage: a79252b60f4678812d94316c91da69ec83089c9f
Sentry: 514a3ea653886e9a48c6287d8b7bf05ec24bf3be Sentry: 8560050221424aef0bebc8e31eedf00af80f90a6
sentry_flutter: edc037f7af0dc1512d6c33a5c2c7c838bd0d6806 sentry_flutter: e26b861f744e5037a3faf9bf56603ec65d658a61
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9 volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1 wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1

View File

@@ -654,7 +654,7 @@
730D64942C4AC4D0005A1975 /* Debug */ = { 730D64942C4AC4D0005A1975 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
@@ -694,7 +694,7 @@
730D64952C4AC4D0005A1975 /* Release */ = { 730D64952C4AC4D0005A1975 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
@@ -731,7 +731,7 @@
730D64962C4AC4D0005A1975 /* Profile */ = { 730D64962C4AC4D0005A1975 /* Profile */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;

View File

@@ -1,7 +1,7 @@
import UIKit import UIKit
import Flutter import Flutter
@UIApplicationMain @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate {
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 575 B

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 868 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -8,6 +8,8 @@
<array> <array>
<string>webcredentials:solsynth.dev</string> <string>webcredentials:solsynth.dev</string>
<string>applinks:solsynth.dev</string> <string>applinks:solsynth.dev</string>
<string>webcredentials:sn.solsynth.dev</string>
<string>applinks:sn.solsynth.dev</string>
</array> </array>
<key>com.apple.developer.usernotifications.communication</key> <key>com.apple.developer.usernotifications.communication</key>
<true/> <true/>

265
lib/bootstrapper.dart Normal file
View File

@@ -0,0 +1,265 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import 'package:solian/exts.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/channel.dart';
import 'package:solian/providers/content/realm.dart';
import 'package:solian/providers/relation.dart';
import 'package:solian/providers/stickers.dart';
import 'package:solian/providers/theme_switcher.dart';
import 'package:solian/providers/websocket.dart';
import 'package:solian/services.dart';
import 'package:solian/widgets/sized_container.dart';
class BootstrapperShell extends StatefulWidget {
final Widget child;
const BootstrapperShell({super.key, required this.child});
@override
State<BootstrapperShell> createState() => _BootstrapperShellState();
}
class _BootstrapperShellState extends State<BootstrapperShell> {
bool _isBusy = true;
bool _isErrored = false;
bool _isDismissable = true;
String? _subtitle;
Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
int _periodCursor = 0;
late final List<({String label, Future<void> Function() action})> _periods = [
(
label: 'bsLoadingTheme',
action: () async {
await context.read<ThemeSwitcher>().restoreTheme();
},
),
(
label: 'bsCheckForUpdate',
action: () async {
if (PlatformInfo.isWeb) return;
try {
final info = await PackageInfo.fromPlatform();
final localVersionString = '${info.version}+${info.buildNumber}';
final resp = await GetConnect().get(
'https://git.solsynth.dev/api/v1/repos/hydrogen/solian/tags?limit=1',
);
if (resp.body[0]['name'] != localVersionString) {
setState(() {
_isErrored = true;
_subtitle = PlatformInfo.isIOS || PlatformInfo.isMacOS
? 'bsCheckForUpdateDescApple'.tr
: 'bsCheckForUpdateDescCommon'.tr;
});
}
} catch (e) {
setState(() {
_isErrored = true;
_subtitle = 'bsCheckForUpdateFailed'.tr;
});
}
},
),
(
label: 'bsCheckingServer',
action: () async {
final client = ServiceFinder.configureClient('dealer');
final resp = await client.get('/.well-known');
if (resp.statusCode != null && resp.statusCode != 200) {
setState(() {
_isErrored = true;
_subtitle = 'bsCheckingServerDown'.tr;
_isDismissable = false;
});
throw Exception('unable connect to server');
} else if (resp.statusCode == null) {
setState(() {
_isErrored = true;
_subtitle = 'bsCheckingServerFail'.tr;
_isDismissable = false;
});
throw Exception('unable connect to server');
}
},
),
(
label: 'bsAuthorizing',
action: () async {
final AuthProvider auth = Get.find();
await auth.refreshAuthorizeStatus();
if (auth.isAuthorized.isTrue) {
await auth.refreshUserProfile();
}
},
),
(
label: 'bsEstablishingConn',
action: () async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isTrue) {
await Get.find<WebSocketProvider>().connect();
}
},
),
(
label: 'bsPreparingData',
action: () async {
final AuthProvider auth = Get.find();
await Future.wait([
Get.find<StickerProvider>().refreshAvailableStickers(),
if (auth.isAuthorized.isTrue)
Get.find<ChannelProvider>().refreshAvailableChannel(),
if (auth.isAuthorized.isTrue)
Get.find<RelationshipProvider>().refreshRelativeList(),
if (auth.isAuthorized.isTrue)
Get.find<RealmProvider>().refreshAvailableRealms(),
]);
},
),
(
label: 'bsRegisteringPushNotify',
action: () async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isTrue) {
try {
Get.find<WebSocketProvider>().registerPushNotifications();
} catch (err) {
context.showSnackbar(
'pushNotifyRegisterFailed'.trParams({'reason': err.toString()}),
);
}
}
},
),
];
Future<void> _runPeriods() async {
try {
for (var idx = 0; idx < _periods.length; idx++) {
await _periods[idx].action();
if (_isErrored && !_isDismissable) break;
if (_periodCursor < _periods.length - 1) {
setState(() => _periodCursor++);
}
}
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_runPeriods();
}
@override
Widget build(BuildContext context) {
if (_isBusy || _isErrored) {
return GestureDetector(
child: Material(
color: Theme.of(context).colorScheme.surface,
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
SizedBox(
height: 280,
child: Align(
alignment: Alignment.bottomCenter,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child:
Image.asset('assets/logo.png', width: 80, height: 80),
),
),
),
Column(
children: [
if (_isErrored && !_isDismissable && !_isBusy)
const Icon(Icons.cancel, size: 24),
if (_isErrored && _isDismissable && !_isBusy)
const Icon(Icons.warning, size: 24),
if ((_isErrored && _isDismissable && _isBusy) || _isBusy)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 3),
),
const SizedBox(height: 12),
CenteredContainer(
maxWidth: 280,
child: Column(
children: [
if (_subtitle == null)
Text(
'${_periods[_periodCursor].label.tr} (${_periodCursor + 1}/${_periods.length})',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: _unFocusColor,
),
),
if (_subtitle != null)
Text(
_subtitle!,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: _unFocusColor,
),
).paddingOnly(bottom: 4),
if (!_isBusy && _isErrored && _isDismissable)
Text(
'bsDismissibleErrorHint'.tr,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: _unFocusColor,
),
).paddingOnly(bottom: 5),
Text(
'2024 © Solsynth LLC',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
color: _unFocusColor,
),
),
],
),
),
],
),
],
),
),
onTap: () {
if (_isBusy) return;
if (_isDismissable) {
setState(() {
_isBusy = false;
_isErrored = false;
});
} else {
setState(() {
_isBusy = true;
_isErrored = false;
_periodCursor = 0;
});
_runPeriods();
}
},
);
}
return widget.child;
}
}

View File

@@ -16,7 +16,7 @@ class ChatEventController {
Channel? channel; Channel? channel;
String? scope; String? scope;
initialize() async { Future<void> initialize() async {
if (!PlatformInfo.isWeb) { if (!PlatformInfo.isWeb) {
database = await createHistoryDb(); database = await createHistoryDb();
} }
@@ -131,6 +131,8 @@ class ChatEventController {
} }
insertEvent(LocalEvent entry) { insertEvent(LocalEvent entry) {
if (entry.channelId != channel?.id) return;
final idx = currentEvents.indexWhere((x) => x.data.uuid == entry.data.uuid); final idx = currentEvents.indexWhere((x) => x.data.uuid == entry.data.uuid);
if (idx != -1) { if (idx != -1) {
currentEvents[idx] = entry; currentEvents[idx] = entry;

View File

@@ -0,0 +1,340 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/models/post.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/widgets/attachments/attachment_editor.dart';
import 'package:solian/widgets/posts/editor/post_editor_categories_tags.dart';
import 'package:solian/widgets/posts/editor/post_editor_date.dart';
import 'package:solian/widgets/posts/editor/post_editor_overview.dart';
import 'package:solian/widgets/posts/editor/post_editor_publish_zone.dart';
import 'package:solian/widgets/posts/editor/post_editor_thumbnail.dart';
import 'package:solian/widgets/posts/editor/post_editor_visibility.dart';
import 'package:shared_preferences/shared_preferences.dart';
class PostEditorController extends GetxController {
late final SharedPreferences _prefs;
final titleController = TextEditingController();
final descriptionController = TextEditingController();
final contentController = TextEditingController();
RxInt mode = 0.obs;
RxInt contentLength = 0.obs;
Rx<Post?> editTo = Rx(null);
Rx<Post?> replyTo = Rx(null);
Rx<Post?> repostTo = Rx(null);
Rx<Realm?> realmZone = Rx(null);
Rx<DateTime?> publishedAt = Rx(null);
Rx<DateTime?> publishedUntil = Rx(null);
RxList<int> attachments = RxList<int>.empty(growable: true);
RxList<String> tags = RxList<String>.empty(growable: true);
Rx<int?> thumbnail = Rx(null);
RxList<int> visibleUsers = RxList.empty(growable: true);
RxList<int> invisibleUsers = RxList.empty(growable: true);
RxInt visibility = 0.obs;
RxBool isDraft = false.obs;
RxBool isRestoreFromLocal = false.obs;
Rx<DateTime?> lastSaveTime = Rx(null);
Timer? _saveTimer;
PostEditorController() {
SharedPreferences.getInstance().then((inst) {
_prefs = inst;
_saveTimer = Timer.periodic(
const Duration(seconds: 3),
(Timer t) {
if (isNotEmpty) {
localSave();
lastSaveTime.value = DateTime.now();
lastSaveTime.refresh();
} else if (_prefs.containsKey('post_editor_local_save')) {
localClear();
lastSaveTime.value = null;
}
},
);
});
contentController.addListener(() {
contentLength.value = contentController.text.length;
});
}
Future<void> editOverview(BuildContext context) {
return showDialog(
context: context,
builder: (context) => PostEditorOverviewDialog(
controller: this,
),
);
}
Future<void> editVisibility(BuildContext context) {
return showDialog(
context: context,
builder: (context) => PostEditorVisibilityDialog(
controller: this,
),
);
}
Future<void> editCategoriesAndTags(BuildContext context) {
return showDialog(
context: context,
builder: (context) => PostEditorCategoriesDialog(
controller: this,
),
);
}
Future<void> editPublishZone(BuildContext context) {
return showDialog(
context: context,
builder: (context) => PostEditorPublishZoneDialog(
controller: this,
),
);
}
Future<void> editPublishDate(BuildContext context) {
return showDialog(
context: context,
builder: (context) => PostEditorDateDialog(
controller: this,
),
);
}
Future<void> editAttachment(BuildContext context) {
return showModalBottomSheet(
context: context,
builder: (context) => AttachmentEditorPopup(
usage: 'i.attachment',
initialAttachments: attachments,
onAdd: (int value) {
attachments.add(value);
},
onRemove: (int value) {
attachments.remove(value);
},
),
);
}
Future<void> editThumbnail(BuildContext context) {
return showDialog(
context: context,
builder: (context) => PostEditorThumbnailDialog(
controller: this,
),
);
}
void toggleDraftMode() {
isDraft.value = !isDraft.value;
}
void localSave() {
_prefs.setString(
'post_editor_local_save',
jsonEncode({
...payload,
'reply_to': replyTo.value?.toJson(),
'repost_to': repostTo.value?.toJson(),
'edit_to': editTo.value?.toJson(),
'realm': realmZone.value?.toJson(),
'type': type,
}),
);
}
void localRead() {
SharedPreferences.getInstance().then((inst) {
if (inst.containsKey('post_editor_local_save')) {
isRestoreFromLocal.value = true;
payload = jsonDecode(inst.getString('post_editor_local_save')!);
}
});
}
void localClear() {
_prefs.remove('post_editor_local_save');
}
void currentClear() {
titleController.clear();
descriptionController.clear();
contentController.clear();
attachments.clear();
tags.clear();
visibleUsers.clear();
invisibleUsers.clear();
visibility.value = 0;
thumbnail.value = null;
publishedAt.value = null;
publishedUntil.value = null;
isDraft.value = false;
isRestoreFromLocal.value = false;
lastSaveTime.value = null;
contentLength.value = 0;
editTo.value = null;
replyTo.value = null;
repostTo.value = null;
realmZone.value = null;
}
set editTarget(Post? value) {
if (value == null) {
editTo.value = null;
return;
}
type = value.type;
editTo.value = value;
isDraft.value = value.isDraft ?? false;
titleController.text = value.body['title'] ?? '';
descriptionController.text = value.body['description'] ?? '';
contentController.text = value.body['content'] ?? '';
publishedAt.value = value.publishedAt;
publishedUntil.value = value.publishedUntil;
tags.value =
value.body['tags']?.map((x) => x['alias']).toList() ?? List.empty();
tags.refresh();
attachments.value = value.body['attachments']?.cast<int>() ?? List.empty();
attachments.refresh();
thumbnail.value = value.body['thumbnail'];
contentLength.value = contentController.text.length;
}
String get typeEndpoint {
switch (mode.value) {
case 0:
return 'stories';
case 1:
return 'articles';
default:
return 'stories';
}
}
String get type {
switch (mode.value) {
case 0:
return 'story';
case 1:
return 'article';
default:
return 'story';
}
}
set type(String value) {
switch (value) {
case 'story':
mode.value = 0;
case 'article':
mode.value = 1;
}
}
String? get title {
if (titleController.text.isEmpty) return null;
return titleController.text;
}
String? get description {
if (descriptionController.text.isEmpty) return null;
return descriptionController.text;
}
Map<String, dynamic> get payload {
return {
'title': title,
'description': description,
'content': contentController.text,
'thumbnail': thumbnail.value,
'tags': tags.map((x) => {'alias': x}).toList(),
'attachments': attachments,
'visible_users': visibleUsers,
'invisible_users': invisibleUsers,
'visibility': visibility.value,
'published_at': publishedAt.value?.toUtc().toIso8601String() ??
DateTime.now().toUtc().toIso8601String(),
'published_until': publishedUntil.value?.toUtc().toIso8601String(),
'is_draft': isDraft.value,
if (replyTo.value != null) 'reply_to': replyTo.value!.id,
if (repostTo.value != null) 'repost_to': repostTo.value!.id,
if (realmZone.value != null) 'realm': realmZone.value!.alias,
};
}
set payload(Map<String, dynamic> value) {
type = value['type'];
tags.value = value['tags'].map((x) => x['alias']).toList().cast<String>();
titleController.text = value['title'] ?? '';
descriptionController.text = value['description'] ?? '';
contentController.text = value['content'] ?? '';
attachments.value = value['attachments'].cast<int>() ?? List.empty();
attachments.refresh();
thumbnail.value = value['thumbnail'];
visibility.value = value['visibility'];
isDraft.value = value['is_draft'];
if (value['visible_users'] != null) {
visibleUsers.value = value['visible_users'].cast<int>();
}
if (value['invisible_users'] != null) {
invisibleUsers.value = value['invisible_users'].cast<int>();
}
if (value['published_at'] != null) {
publishedAt.value = DateTime.parse(value['published_at']).toLocal();
}
if (value['published_until'] != null) {
publishedAt.value = DateTime.parse(value['published_until']).toLocal();
}
if (value['reply_to'] != null) {
replyTo.value = Post.fromJson(value['reply_to']);
}
if (value['repost_to'] != null) {
repostTo.value = Post.fromJson(value['repost_to']);
}
if (value['edit_to'] != null) {
editTo.value = Post.fromJson(value['edit_to']);
}
if (value['realm'] != null) {
realmZone.value = Realm.fromJson(value['realm']);
}
}
bool get isEmpty {
if (contentController.text.isEmpty) return true;
return false;
}
bool get isNotEmpty {
return [
titleController.text.isNotEmpty,
descriptionController.text.isNotEmpty,
contentController.text.isNotEmpty,
attachments.isNotEmpty,
tags.isNotEmpty,
thumbnail.value != null,
].any((x) => x);
}
@override
void dispose() {
_saveTimer?.cancel();
titleController.dispose();
descriptionController.dispose();
contentController.dispose();
super.dispose();
}
}

View File

@@ -5,6 +5,8 @@ import 'package:solian/models/post.dart';
import 'package:solian/providers/content/posts.dart'; import 'package:solian/providers/content/posts.dart';
class PostListController extends GetxController { class PostListController extends GetxController {
String? author;
/// The polling source modifier. /// The polling source modifier.
/// - `0`: default recommendations /// - `0`: default recommendations
/// - `1`: shuffle mode /// - `1`: shuffle mode
@@ -15,7 +17,7 @@ class PostListController extends GetxController {
PagingController<int, Post> pagingController = PagingController<int, Post> pagingController =
PagingController(firstPageKey: 0); PagingController(firstPageKey: 0);
PostListController() { PostListController({this.author}) {
_initPagingController(); _initPagingController();
} }
@@ -48,6 +50,7 @@ class PostListController extends GetxController {
RxBool isPreparing = false.obs; RxBool isPreparing = false.obs;
RxInt focusCursor = 0.obs; RxInt focusCursor = 0.obs;
Post get focusPost => postList[focusCursor.value]; Post get focusPost => postList[focusCursor.value];
RxInt postTotal = 0.obs; RxInt postTotal = 0.obs;
@@ -102,17 +105,24 @@ class PostListController extends GetxController {
Response resp; Response resp;
try { try {
if (author != null) {
resp = await provider.listPost(
pageKey,
author: author,
);
} else {
resp = await provider.listRecommendations( resp = await provider.listRecommendations(
pageKey, pageKey,
channel: mode.value == 0 ? null : 'shuffle', channel: mode.value == 0 ? null : 'shuffle',
); );
}
} catch (e) { } catch (e) {
rethrow; rethrow;
} finally { } finally {
isBusy.value = false; isBusy.value = false;
} }
final PaginationResult result = PaginationResult.fromJson(resp.body); final result = PaginationResult.fromJson(resp.body);
final out = result.data?.map((e) => Post.fromJson(e)).toList(); final out = result.data?.map((e) => Post.fromJson(e)).toList();
postTotal.value = result.count; postTotal.value = result.count;

View File

@@ -2,9 +2,10 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
extension SolianExtenions on BuildContext { extension SolianExtenions on BuildContext {
void showSnackbar(String content) { void showSnackbar(String content, {SnackBarAction? action}) {
ScaffoldMessenger.of(this).showSnackBar(SnackBar( ScaffoldMessenger.of(this).showSnackBar(SnackBar(
content: Text(content), content: Text(content),
action: action,
)); ));
} }
@@ -29,6 +30,23 @@ extension SolianExtenions on BuildContext {
); );
} }
Future<void> showInfoDialog(String title, body) {
return showDialog<void>(
useRootNavigator: true,
context: this,
builder: (ctx) => AlertDialog(
title: Text(title),
content: Text(body),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text('okay'.tr),
)
],
),
);
}
Future<void> showErrorDialog(dynamic exception) { Future<void> showErrorDialog(dynamic exception) {
var stack = StackTrace.current; var stack = StackTrace.current;
var stackTrace = '$stack'; var stackTrace = '$stack';

View File

@@ -2,12 +2,17 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_acrylic/flutter_acrylic.dart'; import 'package:flutter_acrylic/flutter_acrylic.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:go_router/go_router.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:protocol_handler/protocol_handler.dart'; import 'package:protocol_handler/protocol_handler.dart';
import 'package:provider/provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:solian/exts.dart'; import 'package:solian/bootstrapper.dart';
import 'package:solian/firebase_options.dart'; import 'package:solian/firebase_options.dart';
import 'package:solian/platform.dart'; import 'package:solian/platform.dart';
import 'package:solian/providers/attachment_uploader.dart';
import 'package:solian/providers/stickers.dart';
import 'package:solian/providers/theme_switcher.dart';
import 'package:solian/providers/websocket.dart'; import 'package:solian/providers/websocket.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/attachment.dart'; import 'package:solian/providers/content/attachment.dart';
@@ -21,6 +26,7 @@ import 'package:solian/router.dart';
import 'package:solian/shells/system_shell.dart'; import 'package:solian/shells/system_shell.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/translations.dart'; import 'package:solian/translations.dart';
import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
void main() async { void main() async {
await SentryFlutter.init( await SentryFlutter.init(
@@ -39,6 +45,9 @@ void main() async {
_initializePlatformComponents(), _initializePlatformComponents(),
]); ]);
GoRouter.optionURLReflectsImperativeAPIs = true;
usePathUrlStrategy();
runApp(const SolianApp()); runApp(const SolianApp());
}, },
); );
@@ -66,15 +75,25 @@ Future<void> _initializePlatformComponents() async {
} }
} }
final themeSwitcher = ThemeSwitcher(
lightThemeData: SolianTheme.build(Brightness.light),
darkThemeData: SolianTheme.build(Brightness.dark),
);
class SolianApp extends StatelessWidget { class SolianApp extends StatelessWidget {
const SolianApp({super.key}); const SolianApp({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: themeSwitcher,
child: Builder(builder: (context) {
final theme = Provider.of<ThemeSwitcher>(context);
return GetMaterialApp.router( return GetMaterialApp.router(
title: 'Solian', title: 'Solian',
theme: SolianTheme.build(Brightness.light), theme: theme.lightThemeData,
darkTheme: SolianTheme.build(Brightness.dark), darkTheme: theme.darkThemeData,
themeMode: ThemeMode.system, themeMode: ThemeMode.system,
routerDelegate: AppRouter.instance.routerDelegate, routerDelegate: AppRouter.instance.routerDelegate,
routeInformationParser: AppRouter.instance.routeInformationParser, routeInformationParser: AppRouter.instance.routeInformationParser,
@@ -87,39 +106,28 @@ class SolianApp extends StatelessWidget {
builder: (context, child) { builder: (context, child) {
return SystemShell( return SystemShell(
child: ScaffoldMessenger( child: ScaffoldMessenger(
child: BootstrapperShell(
child: child ?? const SizedBox(), child: child ?? const SizedBox(),
), ),
),
); );
}, },
); );
}),
);
} }
void _initializeProviders(BuildContext context) async { void _initializeProviders(BuildContext context) async {
Get.lazyPut(() => AuthProvider()); Get.lazyPut(() => AuthProvider());
Get.lazyPut(() => RelationshipProvider()); Get.lazyPut(() => RelationshipProvider());
Get.lazyPut(() => PostProvider()); Get.lazyPut(() => PostProvider());
Get.lazyPut(() => StickerProvider());
Get.lazyPut(() => AttachmentProvider()); Get.lazyPut(() => AttachmentProvider());
Get.lazyPut(() => WebSocketProvider()); Get.lazyPut(() => WebSocketProvider());
Get.lazyPut(() => StatusProvider()); Get.lazyPut(() => StatusProvider());
Get.lazyPut(() => ChannelProvider()); Get.lazyPut(() => ChannelProvider());
Get.lazyPut(() => RealmProvider()); Get.lazyPut(() => RealmProvider());
Get.lazyPut(() => ChatCallProvider()); Get.lazyPut(() => ChatCallProvider());
Get.lazyPut(() => AttachmentUploaderController());
final AuthProvider auth = Get.find();
auth.refreshAuthorizeStatus().then((_) {
if (auth.isAuthorized.isFalse) return;
Get.find<WebSocketProvider>().connect();
Get.find<ChannelProvider>().refreshAvailableChannel();
try {
Get.find<WebSocketProvider>().registerPushNotifications();
} catch (err) {
context.showSnackbar(
'pushNotifyRegisterFailed'.trParams({'reason': err.toString()}),
);
}
});
} }
} }

View File

@@ -4,7 +4,7 @@ class Attachment {
int id; int id;
DateTime createdAt; DateTime createdAt;
DateTime updatedAt; DateTime updatedAt;
dynamic deletedAt; DateTime? deletedAt;
String uuid; String uuid;
int size; int size;
String name; String name;
@@ -12,11 +12,12 @@ class Attachment {
String usage; String usage;
String mimetype; String mimetype;
String hash; String hash;
String destination; int destination;
bool isAnalyzed;
Map<String, dynamic>? metadata; Map<String, dynamic>? metadata;
bool isMature; bool isMature;
Account account; Account? account;
int accountId; int? accountId;
Attachment({ Attachment({
required this.id, required this.id,
@@ -31,6 +32,7 @@ class Attachment {
required this.mimetype, required this.mimetype,
required this.hash, required this.hash,
required this.destination, required this.destination,
required this.isAnalyzed,
required this.metadata, required this.metadata,
required this.isMature, required this.isMature,
required this.account, required this.account,
@@ -41,7 +43,7 @@ class Attachment {
id: json['id'], id: json['id'],
createdAt: DateTime.parse(json['created_at']), createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']), updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'], deletedAt: json['deleted_at'] != null ? DateTime.parse(json['deleted_at']) : null,
uuid: json['uuid'], uuid: json['uuid'],
size: json['size'], size: json['size'],
name: json['name'], name: json['name'],
@@ -50,9 +52,10 @@ class Attachment {
mimetype: json['mimetype'], mimetype: json['mimetype'],
hash: json['hash'], hash: json['hash'],
destination: json['destination'], destination: json['destination'],
isAnalyzed: json['is_analyzed'],
metadata: json['metadata'], metadata: json['metadata'],
isMature: json['is_mature'], isMature: json['is_mature'],
account: Account.fromJson(json['account']), account: json['account'] != null ? Account.fromJson(json['account']) : null,
accountId: json['account_id'], accountId: json['account_id'],
); );
@@ -60,7 +63,7 @@ class Attachment {
'id': id, 'id': id,
'created_at': createdAt.toIso8601String(), 'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt, 'deleted_at': deletedAt?.toIso8601String(),
'uuid': uuid, 'uuid': uuid,
'size': size, 'size': size,
'name': name, 'name': name,
@@ -69,9 +72,10 @@ class Attachment {
'mimetype': mimetype, 'mimetype': mimetype,
'hash': hash, 'hash': hash,
'destination': destination, 'destination': destination,
'is_analyzed': isAnalyzed,
'metadata': metadata, 'metadata': metadata,
'is_mature': isMature, 'is_mature': isMature,
'account': account.toJson(), 'account': account?.toJson(),
'account_id': accountId, 'account_id': accountId,
}; };
} }

View File

@@ -10,6 +10,7 @@ class Call {
String externalId; String externalId;
int founderId; int founderId;
int channelId; int channelId;
List<dynamic> participants;
Channel channel; Channel channel;
Call({ Call({
@@ -21,6 +22,7 @@ class Call {
required this.externalId, required this.externalId,
required this.founderId, required this.founderId,
required this.channelId, required this.channelId,
required this.participants,
required this.channel, required this.channel,
}); });
@@ -34,6 +36,7 @@ class Call {
externalId: json['external_id'], externalId: json['external_id'],
founderId: json['founder_id'], founderId: json['founder_id'],
channelId: json['channel_id'], channelId: json['channel_id'],
participants: json['participants'] ?? List.empty(),
channel: Channel.fromJson(json['channel']), channel: Channel.fromJson(json['channel']),
); );
@@ -46,6 +49,7 @@ class Call {
'external_id': externalId, 'external_id': externalId,
'founder_id': founderId, 'founder_id': founderId,
'channel_id': channelId, 'channel_id': channelId,
'participants': participants,
'channel': channel.toJson(), 'channel': channel.toJson(),
}; };
} }
@@ -63,6 +67,7 @@ class ParticipantTrack {
{required this.participant, {required this.participant,
required this.videoTrack, required this.videoTrack,
required this.isScreenShare}); required this.isScreenShare});
VideoTrack? videoTrack; VideoTrack? videoTrack;
Participant participant; Participant participant;
bool isScreenShare; bool isScreenShare;

View File

@@ -6,11 +6,13 @@ class Post {
int id; int id;
DateTime createdAt; DateTime createdAt;
DateTime updatedAt; DateTime updatedAt;
DateTime? editedAt;
DateTime? deletedAt; DateTime? deletedAt;
dynamic body; dynamic body;
List<Tag>? tags; List<Tag>? tags;
List<Category>? categories; List<Category>? categories;
List<Post>? replies; List<Post>? replies;
String type;
int? replyId; int? replyId;
int? repostId; int? repostId;
int? realmId; int? realmId;
@@ -18,6 +20,8 @@ class Post {
Post? repostTo; Post? repostTo;
Realm? realm; Realm? realm;
DateTime? publishedAt; DateTime? publishedAt;
DateTime? publishedUntil;
DateTime? pinnedAt;
bool? isDraft; bool? isDraft;
int authorId; int authorId;
Account author; Account author;
@@ -27,7 +31,9 @@ class Post {
required this.id, required this.id,
required this.createdAt, required this.createdAt,
required this.updatedAt, required this.updatedAt,
required this.editedAt,
required this.deletedAt, required this.deletedAt,
required this.type,
required this.body, required this.body,
required this.tags, required this.tags,
required this.categories, required this.categories,
@@ -39,6 +45,8 @@ class Post {
required this.repostTo, required this.repostTo,
required this.realm, required this.realm,
required this.publishedAt, required this.publishedAt,
required this.publishedUntil,
required this.pinnedAt,
required this.isDraft, required this.isDraft,
required this.authorId, required this.authorId,
required this.author, required this.author,
@@ -52,6 +60,7 @@ class Post {
deletedAt: json['deleted_at'] != null deletedAt: json['deleted_at'] != null
? DateTime.parse(json['deleted_at']) ? DateTime.parse(json['deleted_at'])
: null, : null,
type: json['type'],
body: json['body'], body: json['body'],
tags: json['tags']?.map((x) => Tag.fromJson(x)).toList().cast<Tag>(), tags: json['tags']?.map((x) => Tag.fromJson(x)).toList().cast<Tag>(),
categories: json['categories'] categories: json['categories']
@@ -67,9 +76,18 @@ class Post {
repostTo: repostTo:
json['repost_to'] != null ? Post.fromJson(json['repost_to']) : null, json['repost_to'] != null ? Post.fromJson(json['repost_to']) : null,
realm: json['realm'] != null ? Realm.fromJson(json['realm']) : null, realm: json['realm'] != null ? Realm.fromJson(json['realm']) : null,
editedAt: json['edited_at'] != null
? DateTime.parse(json['edited_at'])
: null,
publishedAt: json['published_at'] != null publishedAt: json['published_at'] != null
? DateTime.parse(json['published_at']) ? DateTime.parse(json['published_at'])
: null, : null,
publishedUntil: json['published_until'] != null
? DateTime.parse(json['published_until'])
: null,
pinnedAt: json['pinned_at'] != null
? DateTime.parse(json['pinned_at'])
: null,
isDraft: json['is_draft'], isDraft: json['is_draft'],
authorId: json['author_id'], authorId: json['author_id'],
author: Account.fromJson(json['author']), author: Account.fromJson(json['author']),
@@ -81,7 +99,9 @@ class Post {
'id': id, 'id': id,
'created_at': createdAt.toIso8601String(), 'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt, 'edited_at': editedAt?.toIso8601String(),
'deleted_at': deletedAt?.toIso8601String(),
'type': type,
'body': body, 'body': body,
'tags': tags, 'tags': tags,
'categories': categories, 'categories': categories,
@@ -93,6 +113,8 @@ class Post {
'repost_to': repostTo?.toJson(), 'repost_to': repostTo?.toJson(),
'realm': realm?.toJson(), 'realm': realm?.toJson(),
'published_at': publishedAt?.toIso8601String(), 'published_at': publishedAt?.toIso8601String(),
'published_until': publishedUntil?.toIso8601String(),
'pinned_at': pinnedAt?.toIso8601String(),
'is_draft': isDraft, 'is_draft': isDraft,
'author_id': authorId, 'author_id': authorId,
'author': author.toJson(), 'author': author.toJson(),

View File

@@ -52,6 +52,17 @@ class Realm {
'is_community': isCommunity, 'is_community': isCommunity,
'account_id': accountId, 'account_id': accountId,
}; };
@override
bool operator ==(Object other) {
if (other is Realm) {
return other.id == id;
}
return false;
}
@override
int get hashCode => id;
} }
class RealmMember { class RealmMember {

View File

@@ -46,12 +46,4 @@ class Relationship {
'related': related.toJson(), 'related': related.toJson(),
'status': status, 'status': status,
}; };
Account getOtherside(int selfId) {
if (accountId != selfId) {
return account;
} else {
return related;
}
}
} }

131
lib/models/stickers.dart Normal file
View File

@@ -0,0 +1,131 @@
import 'package:solian/models/account.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/services.dart';
class Sticker {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String alias;
String name;
int attachmentId;
Attachment attachment;
int packId;
StickerPack? pack;
int accountId;
Account account;
Sticker({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.alias,
required this.name,
required this.attachmentId,
required this.attachment,
required this.packId,
required this.pack,
required this.accountId,
required this.account,
});
String get textPlaceholder => '${pack?.prefix}$alias';
String get textWarpedPlaceholder => ':$textPlaceholder:';
String get imageUrl => ServiceFinder.buildUrl(
'files',
'/attachments/$attachmentId',
);
factory Sticker.fromJson(Map<String, dynamic> json) => Sticker(
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'] != null
? DateTime.parse(json['deleted_at'])
: json['deleted_at'],
alias: json['alias'],
name: json['name'],
attachmentId: json['attachment_id'],
attachment: Attachment.fromJson(json['attachment']),
packId: json['pack_id'],
pack: json['pack'] != null ? StickerPack.fromJson(json['pack']) : null,
accountId: json['account_id'],
account: Account.fromJson(json['account']),
);
Map<String, dynamic> toJson() => {
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt?.toIso8601String(),
'alias': alias,
'name': name,
'attachment_id': attachmentId,
'attachment': attachment.toJson(),
'pack_id': packId,
'account_id': accountId,
'account': account.toJson(),
};
}
class StickerPack {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String prefix;
String name;
String description;
List<Sticker>? stickers;
int accountId;
Account account;
StickerPack({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.prefix,
required this.name,
required this.description,
required this.stickers,
required this.accountId,
required this.account,
});
factory StickerPack.fromJson(Map<String, dynamic> json) => StickerPack(
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'] != null
? DateTime.parse(json['deleted_at'])
: json['deleted_at'],
prefix: json['prefix'],
name: json['name'],
description: json['description'],
stickers: json['stickers'] == null
? []
: List<Sticker>.from(
json['stickers']!.map((x) => Sticker.fromJson(x))),
accountId: json['account_id'],
account: Account.fromJson(json['account']),
);
Map<String, dynamic> toJson() => {
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt?.toIso8601String(),
'prefix': prefix,
'name': name,
'description': description,
'stickers': stickers == null
? []
: List<dynamic>.from(stickers!.map((x) => x.toJson())),
'account_id': accountId,
'account': account.toJson(),
};
}

View File

@@ -0,0 +1,213 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:get/get.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/providers/content/attachment.dart';
class AttachmentUploadTask {
File file;
String usage;
Map<String, dynamic>? metadata;
double progress = 0;
bool isUploading = false;
bool isCompleted = false;
dynamic error;
AttachmentUploadTask({
required this.file,
required this.usage,
this.metadata,
});
}
class AttachmentUploaderController extends GetxController {
RxBool isUploading = false.obs;
RxDouble progressOfUpload = 0.0.obs;
RxList<AttachmentUploadTask> queueOfUpload = RxList.empty(growable: true);
Timer? _progressSyncTimer;
double _progressOfUpload = 0.0;
void _syncProgress() {
progressOfUpload.value = _progressOfUpload;
queueOfUpload.refresh();
}
void _startProgressSyncTimer() {
if (_progressSyncTimer != null) {
_progressSyncTimer!.cancel();
}
_progressSyncTimer = Timer.periodic(
const Duration(milliseconds: 500),
(_) => _syncProgress(),
);
}
void _stopProgressSyncTimer() {
if (_progressSyncTimer == null) return;
_progressSyncTimer!.cancel();
_progressSyncTimer = null;
}
void enqueueTask(AttachmentUploadTask task) {
if (isUploading.value) throw Exception('uploading blocked');
queueOfUpload.add(task);
}
void enqueueTaskBatch(Iterable<AttachmentUploadTask> tasks) {
if (isUploading.value) throw Exception('uploading blocked');
queueOfUpload.addAll(tasks);
}
void dequeueTask(AttachmentUploadTask task) {
if (isUploading.value) throw Exception('uploading blocked');
queueOfUpload.remove(task);
}
Future<Attachment?> performSingleTask(int queueIndex) async {
isUploading.value = true;
progressOfUpload.value = 0;
_startProgressSyncTimer();
queueOfUpload[queueIndex].isUploading = true;
final task = queueOfUpload[queueIndex];
final result = await _rawUploadAttachment(
await task.file.readAsBytes(),
task.file.path,
task.usage,
null,
onProgress: (value) {
queueOfUpload[queueIndex].progress = value;
_progressOfUpload = value;
},
onError: (err) {
queueOfUpload[queueIndex].error = err;
queueOfUpload[queueIndex].isUploading = false;
},
);
if (queueOfUpload[queueIndex].error == null) {
queueOfUpload.removeAt(queueIndex);
}
_stopProgressSyncTimer();
_syncProgress();
isUploading.value = false;
return result;
}
Future<void> performUploadQueue({
required Function(Attachment item) onData,
}) async {
isUploading.value = true;
progressOfUpload.value = 0;
_startProgressSyncTimer();
for (var idx = 0; idx < queueOfUpload.length; idx++) {
if (queueOfUpload[idx].isUploading || queueOfUpload[idx].error != null) {
continue;
}
queueOfUpload[idx].isUploading = true;
final task = queueOfUpload[idx];
final result = await _rawUploadAttachment(
await task.file.readAsBytes(),
task.file.path,
task.usage,
null,
onProgress: (value) {
queueOfUpload[idx].progress = value;
_progressOfUpload = (idx + value) / queueOfUpload.length;
},
onError: (err) {
queueOfUpload[idx].error = err;
queueOfUpload[idx].isUploading = false;
},
);
_progressOfUpload = (idx + 1) / queueOfUpload.length;
if (result != null) onData(result);
queueOfUpload[idx].isUploading = false;
queueOfUpload[idx].isCompleted = true;
}
queueOfUpload.removeWhere((x) => x.error == null);
_stopProgressSyncTimer();
_syncProgress();
isUploading.value = false;
}
Future<void> uploadAttachmentWithCallback(
Uint8List data,
String path,
String usage,
Map<String, dynamic>? metadata,
Function(Attachment?) callback,
) async {
if (isUploading.value) throw Exception('uploading blocked');
isUploading.value = true;
final result = await _rawUploadAttachment(
data,
path,
usage,
metadata,
onProgress: (progress) {
progressOfUpload.value = progress;
},
);
isUploading.value = false;
callback(result);
}
Future<Attachment?> uploadAttachment(
Uint8List data,
String path,
String usage,
Map<String, dynamic>? metadata,
) async {
if (isUploading.value) throw Exception('uploading blocked');
isUploading.value = true;
final result = await _rawUploadAttachment(
data,
path,
usage,
metadata,
onProgress: (progress) {
progressOfUpload.value = progress;
},
);
isUploading.value = false;
return result;
}
Future<Attachment?> _rawUploadAttachment(
Uint8List data, String path, String usage, Map<String, dynamic>? metadata,
{Function(double)? onProgress, Function(dynamic err)? onError}) async {
final AttachmentProvider provider = Get.find();
try {
final result = await provider.createAttachment(
data,
path,
usage,
metadata,
onProgress: onProgress,
);
return result;
} catch (err) {
if (onError != null) {
onError(err);
}
return null;
}
}
}

View File

@@ -6,7 +6,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get/get_connect/http/src/request/request.dart'; import 'package:get/get_connect/http/src/request/request.dart';
import 'package:mutex/mutex.dart';
import 'package:solian/controllers/chat_events_controller.dart'; import 'package:solian/controllers/chat_events_controller.dart';
import 'package:solian/providers/websocket.dart'; import 'package:solian/providers/websocket.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
@@ -55,7 +54,6 @@ class AuthProvider extends GetConnect {
static const storage = FlutterSecureStorage(); static const storage = FlutterSecureStorage();
TokenSet? credentials; TokenSet? credentials;
Mutex credentialsRefreshMutex = Mutex();
@override @override
void onInit() { void onInit() {
@@ -66,9 +64,17 @@ class AuthProvider extends GetConnect {
}); });
} }
Completer<void>? _refreshCompleter;
Future<void> refreshCredentials() async { Future<void> refreshCredentials() async {
if (_refreshCompleter != null) {
await _refreshCompleter!.future;
return;
} else {
_refreshCompleter = Completer<void>();
}
try { try {
credentialsRefreshMutex.acquire();
if (!credentials!.isExpired) return; if (!credentials!.isExpired) return;
final resp = await post('/auth/token', { final resp = await post('/auth/token', {
'refresh_token': credentials!.refreshToken, 'refresh_token': credentials!.refreshToken,
@@ -86,10 +92,13 @@ class AuthProvider extends GetConnect {
key: 'auth_credentials', key: 'auth_credentials',
value: jsonEncode(credentials!.toJson()), value: jsonEncode(credentials!.toJson()),
); );
} catch (_) { _refreshCompleter!.complete();
log('Refreshed credentials at ${DateTime.now()}');
} catch (e) {
_refreshCompleter!.completeError(e);
rethrow; rethrow;
} finally { } finally {
credentialsRefreshMutex.release(); _refreshCompleter = null;
} }
} }
@@ -124,7 +133,6 @@ class AuthProvider extends GetConnect {
if (credentials!.isExpired) { if (credentials!.isExpired) {
await refreshCredentials(); await refreshCredentials();
log('Refreshed credentials at ${DateTime.now()}');
} }
} }
@@ -175,8 +183,6 @@ class AuthProvider extends GetConnect {
value: jsonEncode(credentials!.toJson()), value: jsonEncode(credentials!.toJson()),
); );
await refreshUserProfile();
Get.find<WebSocketProvider>().connect(); Get.find<WebSocketProvider>().connect();
Get.find<WebSocketProvider>().notifyPrefetch(); Get.find<WebSocketProvider>().notifyPrefetch();
@@ -210,7 +216,6 @@ class AuthProvider extends GetConnect {
Future<void> refreshUserProfile() async { Future<void> refreshUserProfile() async {
final client = configureClient('auth'); final client = configureClient('auth');
final resp = await client.get('/users/me'); final resp = await client.get('/users/me');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw Exception(resp.bodyString);

View File

@@ -16,6 +16,7 @@ class ChatCallProvider extends GetxController {
RxBool isReady = false.obs; RxBool isReady = false.obs;
RxBool isMounted = false.obs; RxBool isMounted = false.obs;
RxBool isInitialized = false.obs;
String? token; String? token;
String? endpoint; String? endpoint;
@@ -151,6 +152,8 @@ class ChatCallProvider extends GetxController {
void onRoomDidUpdate() => sortParticipants(); void onRoomDidUpdate() => sortParticipants();
void setupRoom() { void setupRoom() {
if(isInitialized.value) return;
sortParticipants(); sortParticipants();
room.addListener(onRoomDidUpdate); room.addListener(onRoomDidUpdate);
WidgetsBindingCompatible.instance?.addPostFrameCallback( WidgetsBindingCompatible.instance?.addPostFrameCallback(
@@ -160,6 +163,8 @@ class ChatCallProvider extends GetxController {
if (lkPlatformIsMobile()) { if (lkPlatformIsMobile()) {
Hardware.instance.setSpeakerphoneOn(true); Hardware.instance.setSpeakerphoneOn(true);
} }
isInitialized.value = true;
} }
void setupRoomListeners({ void setupRoomListeners({
@@ -362,6 +367,7 @@ class ChatCallProvider extends GetxController {
void disposeRoom() { void disposeRoom() {
isMounted.value = false; isMounted.value = false;
isInitialized.value = false;
current.value = null; current.value = null;
channel.value = null; channel.value = null;
room.removeListener(onRoomDidUpdate); room.removeListener(onRoomDidUpdate);

View File

@@ -1,58 +1,13 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:solian/platform.dart'; import 'package:solian/models/attachment.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:image/image.dart' as img; import 'package:dio/dio.dart' as dio;
Future<String> calculateBytesSha256(Uint8List data) async {
Digest digest;
if (PlatformInfo.isWeb) {
digest = sha256.convert(data);
} else {
digest = await Isolate.run(() => sha256.convert(data));
}
return digest.toString();
}
Future<String> calculateFileSha256(File file) async {
Uint8List bytes;
if (PlatformInfo.isWeb) {
bytes = await file.readAsBytes();
} else {
bytes = await Isolate.run(() => file.readAsBytesSync());
}
return await calculateBytesSha256(bytes);
}
Future<Map<String, dynamic>> calculateImageData(Uint8List data) async {
if (PlatformInfo.isWeb) {
return {};
}
final decoder = await Isolate.run(() => img.findDecoderForData(data));
if (decoder == null) return {};
final image = await Isolate.run(() => decoder.decode(data));
if (image == null) return {};
return {
'width': image.width,
'height': image.height,
'ratio': image.width / image.height
};
}
Future<Map<String, dynamic>> calculateImageMetaFromFile(File file) async {
if (PlatformInfo.isWeb) {
return {};
}
final bytes = await Isolate.run(() => file.readAsBytesSync());
return await calculateImageData(bytes);
}
class AttachmentProvider extends GetConnect { class AttachmentProvider extends GetConnect {
static Map<String, String> mimetypeOverrides = { static Map<String, String> mimetypeOverrides = {
@@ -65,35 +20,79 @@ class AttachmentProvider extends GetConnect {
httpClient.baseUrl = ServiceFinder.buildUrl('files', null); httpClient.baseUrl = ServiceFinder.buildUrl('files', null);
} }
final Map<int, Response> _cachedResponses = {}; final Map<int, Attachment> _cachedResponses = {};
Future<Response> getMetadata(int id, {noCache = false}) async { Future<List<Attachment?>> listMetadata(
List<int> id, {
noCache = false,
}) async {
if (id.isEmpty) return List.empty();
List<Attachment?> result = List.filled(id.length, null);
List<int> pendingQuery = List.empty(growable: true);
if (!noCache) {
for (var idx = 0; idx < id.length; idx++) {
if (_cachedResponses.containsKey(id[idx])) {
result[idx] = _cachedResponses[id[idx]];
} else {
pendingQuery.add(id[idx]);
}
}
}
final resp = await get(
'/attachments?take=${pendingQuery.length}&id=${pendingQuery.join(',')}',
);
if (resp.statusCode != 200) return result;
final rawOut = PaginationResult.fromJson(resp.body);
if (rawOut.data == null) return result;
final List<Attachment> out =
rawOut.data!.map((x) => Attachment.fromJson(x)).toList();
for (final item in out) {
if (item.destination != 0 && item.isAnalyzed) {
_cachedResponses[item.id] = item;
}
}
for (var i = 0; i < out.length; i++) {
for (var j = 0; j < id.length; j++) {
if (out[i].id == id[j]) {
result[j] = out[i];
}
}
}
return result;
}
Future<Attachment?> getMetadata(int id, {noCache = false}) async {
if (!noCache && _cachedResponses.containsKey(id)) { if (!noCache && _cachedResponses.containsKey(id)) {
return _cachedResponses[id]!; return _cachedResponses[id]!;
} }
final resp = await get('/attachments/$id/meta'); final resp = await get('/attachments/$id/meta');
_cachedResponses[id] = resp; if (resp.statusCode == 200) {
final result = Attachment.fromJson(resp.body);
return resp; if (result.destination != 0 && result.isAnalyzed) {
_cachedResponses[id] = result;
}
return result;
} }
Future<Response> createAttachment( return null;
Uint8List data, }
String path,
String hash, Future<Attachment> createAttachment(
String usage, Uint8List data, String path, String usage, Map<String, dynamic>? metadata,
Map<String, dynamic>? metadata, {Function(double)? onProgress}) async {
) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw Exception('unauthorized');
final client = auth.configureClient( await auth.ensureCredentials();
'files',
timeout: const Duration(minutes: 3),
);
final filePayload = MultipartFile(data, filename: basename(path)); final filePayload =
dio.MultipartFile.fromBytes(data, filename: basename(path));
final fileAlt = basename(path).contains('.') final fileAlt = basename(path).contains('.')
? basename(path).substring(0, basename(path).lastIndexOf('.')) ? basename(path).substring(0, basename(path).lastIndexOf('.'))
: basename(path); : basename(path);
@@ -106,27 +105,36 @@ class AttachmentProvider extends GetConnect {
if (mimetypeOverrides.keys.contains(fileExt)) { if (mimetypeOverrides.keys.contains(fileExt)) {
mimetypeOverride = mimetypeOverrides[fileExt]; mimetypeOverride = mimetypeOverrides[fileExt];
} }
final payload = FormData({ final payload = dio.FormData.fromMap({
'alt': fileAlt, 'alt': fileAlt,
'file': filePayload, 'file': filePayload,
'hash': hash,
'usage': usage, 'usage': usage,
if (mimetypeOverride != null) 'mimetype': mimetypeOverride, if (mimetypeOverride != null) 'mimetype': mimetypeOverride,
'metadata': jsonEncode(metadata), 'metadata': jsonEncode(metadata),
}); });
final resp = await client.post('/attachments', payload); final resp = await dio.Dio(
dio.BaseOptions(
baseUrl: ServiceFinder.buildUrl('files', null),
headers: {'Authorization': 'Bearer ${auth.credentials!.accessToken}'},
),
).post(
'/attachments',
data: payload,
onSendProgress: (count, total) {
if (onProgress != null) onProgress(count / total);
},
);
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw Exception(resp.data);
} }
return resp; return Attachment.fromJson(resp.data);
} }
Future<Response> updateAttachment( Future<Response> updateAttachment(
int id, int id,
String alt, String alt,
String usage, { String usage, {
double? ratio,
bool isMature = false, bool isMature = false,
}) async { }) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
@@ -135,9 +143,6 @@ class AttachmentProvider extends GetConnect {
final client = auth.configureClient('files'); final client = auth.configureClient('files');
var resp = await client.put('/attachments/$id', { var resp = await client.put('/attachments/$id', {
'metadata': {
if (ratio != null) 'ratio': ratio,
},
'alt': alt, 'alt': alt,
'usage': usage, 'usage': usage,
'is_mature': isMature, 'is_mature': isMature,

View File

@@ -9,12 +9,10 @@ class PostProvider extends GetConnect {
} }
Future<Response> listRecommendations(int page, Future<Response> listRecommendations(int page,
{int? realm, String? tag, category, String? channel}) async { {int? realm, String? channel}) async {
final queries = [ final queries = [
'take=${10}', 'take=${10}',
'offset=$page', 'offset=$page',
if (tag != null) 'tag=$tag',
if (category != null) 'category=$category',
if (realm != null) 'realmId=$realm', if (realm != null) 'realmId=$realm',
]; ];
final resp = await get( final resp = await get(
@@ -46,10 +44,14 @@ class PostProvider extends GetConnect {
return resp; return resp;
} }
Future<Response> listPost(int page, {int? realm}) async { Future<Response> listPost(int page,
{int? realm, String? author, tag, category}) async {
final queries = [ final queries = [
'take=${10}', 'take=${10}',
'offset=$page', 'offset=$page',
if (tag != null) 'tag=$tag',
if (category != null) 'category=$category',
if (author != null) 'author=$author',
if (realm != null) 'realmId=$realm', if (realm != null) 'realmId=$realm',
]; ];
final resp = await get('/posts?${queries.join('&')}'); final resp = await get('/posts?${queries.join('&')}');

View File

@@ -1,7 +1,24 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
class RealmProvider extends GetxController { class RealmProvider extends GetxController {
RxBool isLoading = false.obs;
RxList<Realm> availableRealms = RxList.empty(growable: true);
Future<void> refreshAvailableRealms() async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized');
isLoading.value = true;
final resp = await listAvailableRealm();
isLoading.value = false;
availableRealms.value =
resp.body.map((x) => Realm.fromJson(x)).toList().cast<Realm>();
availableRealms.refresh();
}
Future<Response> getRealm(String alias) async { Future<Response> getRealm(String alias) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw Exception('unauthorized');

View File

@@ -1,24 +1,46 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/relations.dart'; import 'package:solian/models/relations.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart';
class RelationshipProvider extends GetConnect { class RelationshipProvider extends GetxController {
@override final RxInt friendRequestCount = 0.obs;
void onInit() {
final AuthProvider auth = Get.find();
httpClient.baseUrl = ServiceFinder.buildUrl('auth', null); final RxList<Relationship> _friends = RxList.empty(growable: true);
httpClient.addAuthenticator(auth.requestAuthenticator);
Future<void> refreshRelativeList() async {
final resp = await listRelation();
final List<Relationship> result = resp.body
.map((e) => Relationship.fromJson(e))
.toList()
.cast<Relationship>();
_friends.value = result.where((x) => x.status == 1).toList();
_friends.refresh();
friendRequestCount.value = result.where((x) => x.status == 0).length;
} }
Future<Response> listRelation() => get('/users/me/relations'); bool hasFriend(Account account) {
final auth = Get.find<AuthProvider>();
if (auth.userProfile.value!['id'] == account.id) return true;
return _friends.any((x) => x.relatedId == account.id);
}
Future<Response> listRelationWithStatus(int status) => Future<Response> listRelation() {
get('/users/me/relations?status=$status'); final AuthProvider auth = Get.find();
final client = auth.configureClient('auth');
return client.get('/users/me/relations');
}
Future<Response> listRelationWithStatus(int status) {
final AuthProvider auth = Get.find();
final client = auth.configureClient('auth');
return client.get('/users/me/relations?status=$status');
}
Future<Response> makeFriend(String username) async { Future<Response> makeFriend(String username) async {
final resp = await post('/users/me/relations?related=$username', {}); final AuthProvider auth = Get.find();
final client = auth.configureClient('auth');
final resp = await client.post('/users/me/relations?related=$username', {});
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw Exception(resp.bodyString);
} }
@@ -44,10 +66,10 @@ class RelationshipProvider extends GetConnect {
Future<Response> editRelation(Relationship relationship, int status) async { Future<Response> editRelation(Relationship relationship, int status) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
final client = auth.configureClient('auth'); final client = auth.configureClient('auth');
final resp = final resp = await client.patch(
await client.patch('/users/me/relations/${relationship.relatedId}', { '/users/me/relations/${relationship.relatedId}',
'status': status, {'status': status},
}); );
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw Exception(resp.bodyString);
} }

View File

@@ -0,0 +1,34 @@
import 'package:get/get.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/models/stickers.dart';
import 'package:solian/services.dart';
class StickerProvider extends GetxController {
final RxMap<String, String> aliasImageMapping = RxMap();
final RxList<Sticker> availableStickers = RxList.empty(growable: true);
Future<void> refreshAvailableStickers() async {
availableStickers.clear();
aliasImageMapping.clear();
final client = ServiceFinder.configureClient('files');
final resp = await client.get(
'/stickers/manifest?take=100',
);
if (resp.statusCode == 200) {
final result = PaginationResult.fromJson(resp.body);
final out = result.data?.map((e) => StickerPack.fromJson(e)).toList();
if (out == null) return;
for (final pack in out) {
for (final sticker in (pack.stickers ?? List<Sticker>.empty())) {
sticker.pack = pack;
aliasImageMapping[sticker.textPlaceholder.toUpperCase()] =
sticker.imageUrl;
availableStickers.add(sticker);
}
}
}
availableStickers.refresh();
}
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:solian/theme.dart';
class ThemeSwitcher extends ChangeNotifier {
ThemeData lightThemeData;
ThemeData darkThemeData;
ThemeSwitcher({
required this.lightThemeData,
required this.darkThemeData,
});
Future<void> restoreTheme() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.containsKey('global_theme_color')) {
final value = prefs.getInt('global_theme_color')!;
final color = Color(value);
lightThemeData = SolianTheme.build(Brightness.light, seedColor: color);
darkThemeData = SolianTheme.build(Brightness.dark, seedColor: color);
notifyListeners();
}
}
void setTheme(ThemeData light, dark) {
lightThemeData = light;
darkThemeData = dark;
notifyListeners();
}
}

View File

@@ -42,7 +42,7 @@ class WebSocketProvider extends GetxController {
super.onInit(); super.onInit();
} }
void connect({noRetry = false}) async { Future<void> connect({noRetry = false}) async {
if (isConnected.value) { if (isConnected.value) {
return; return;
} else { } else {

View File

@@ -1,9 +1,13 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
import 'package:solian/screens/about.dart'; import 'package:solian/screens/about.dart';
import 'package:solian/screens/account.dart'; import 'package:solian/screens/account.dart';
import 'package:solian/screens/account/friend.dart'; import 'package:solian/screens/account/friend.dart';
import 'package:solian/screens/account/personalize.dart'; import 'package:solian/screens/account/personalize.dart';
import 'package:solian/screens/account/profile_page.dart';
import 'package:solian/screens/account/stickers.dart';
import 'package:solian/screens/channel/channel_chat.dart'; import 'package:solian/screens/channel/channel_chat.dart';
import 'package:solian/screens/channel/channel_detail.dart'; import 'package:solian/screens/channel/channel_detail.dart';
import 'package:solian/screens/channel/channel_organize.dart'; import 'package:solian/screens/channel/channel_organize.dart';
@@ -17,6 +21,7 @@ import 'package:solian/screens/realms/realm_organize.dart';
import 'package:solian/screens/realms/realm_view.dart'; import 'package:solian/screens/realms/realm_view.dart';
import 'package:solian/screens/home.dart'; import 'package:solian/screens/home.dart';
import 'package:solian/screens/posts/post_editor.dart'; import 'package:solian/screens/posts/post_editor.dart';
import 'package:solian/screens/settings.dart';
import 'package:solian/shells/root_shell.dart'; import 'package:solian/shells/root_shell.dart';
import 'package:solian/shells/title_shell.dart'; import 'package:solian/shells/title_shell.dart';
@@ -33,6 +38,22 @@ abstract class AppRouter {
_chatRoute, _chatRoute,
_realmRoute, _realmRoute,
_accountRoute, _accountRoute,
GoRoute(
path: '/about',
name: 'about',
builder: (context, state) => TitleShell(
state: state,
child: const AboutScreen(),
),
),
GoRoute(
path: '/settings',
name: 'settings',
builder: (context, state) => TitleShell(
state: state,
child: const SettingScreen(),
),
),
], ],
), ),
], ],
@@ -75,13 +96,25 @@ abstract class AppRouter {
GoRoute( GoRoute(
path: '/posts/editor', path: '/posts/editor',
name: 'postEditor', name: 'postEditor',
builder: (context, state) { pageBuilder: (context, state) {
final arguments = state.extra as PostPublishArguments?; final arguments = state.extra as PostPublishArguments?;
return PostPublishScreen( return CustomTransitionPage(
child: PostPublishScreen(
edit: arguments?.edit, edit: arguments?.edit,
reply: arguments?.reply, reply: arguments?.reply,
repost: arguments?.repost, repost: arguments?.repost,
realm: arguments?.realm, realm: arguments?.realm,
mode: int.tryParse(state.uri.queryParameters['mode'] ?? '0') ?? 0,
),
transitionsBuilder:
(context, animation, secondaryAnimation, child) {
return FadeThroughTransition(
fillColor: Theme.of(context).scaffoldBackgroundColor,
animation: animation,
secondaryAnimation: secondaryAnimation,
child: child,
);
},
); );
}, },
), ),
@@ -193,6 +226,14 @@ abstract class AppRouter {
name: 'accountFriend', name: 'accountFriend',
builder: (context, state) => const FriendScreen(), builder: (context, state) => const FriendScreen(),
), ),
GoRoute(
path: '/account/stickers',
name: 'accountStickers',
builder: (context, state) => TitleShell(
state: state,
child: const StickerScreen(),
),
),
GoRoute( GoRoute(
path: '/account/personalize', path: '/account/personalize',
name: 'accountPersonalize', name: 'accountPersonalize',
@@ -202,11 +243,10 @@ abstract class AppRouter {
), ),
), ),
GoRoute( GoRoute(
path: '/about', path: '/account/view/:name',
name: 'about', name: 'accountProfilePage',
builder: (context, state) => TitleShell( builder: (context, state) => AccountProfilePage(
state: state, name: state.pathParameters['name']!,
child: const AboutScreen(),
), ),
), ),
], ],

View File

@@ -17,7 +17,11 @@ class AboutScreen extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Image.asset('assets/logo.png', width: 64, height: 64), ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Image.asset('assets/logo.png', width: 120, height: 120),
),
const SizedBox(height: 8),
Text( Text(
'Solian', 'Solian',
style: Theme.of(context).textTheme.headlineMedium, style: Theme.of(context).textTheme.headlineMedium,
@@ -44,17 +48,28 @@ class AboutScreen extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
TextButton( TextButton(
style: denseButtonStyle, style: denseButtonStyle,
child: const Text('More Information'), child: const Text('App Details'),
onPressed: () { onPressed: () async {
launchUrlString('https://solsynth.dev/products/solar-network'); final info = await PackageInfo.fromPlatform();
showAboutDialog(
context: context,
applicationVersion: '${info.version} (${info.buildNumber})',
applicationLegalese:
'The Solar Network App is an intuitive and self-hostable social network and computing platform. Experience the freedom of a user-friendly design that empowers you to create and connect with communities on your own terms. Embrace the future of social networking with a platform that prioritizes your independence and privacy.',
applicationIcon: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Image.asset('assets/logo.png', width: 60, height: 60),
),
);
}, },
), ),
TextButton( TextButton(
style: denseButtonStyle, style: denseButtonStyle,
child: const Text('Project Website'),
onPressed: () { onPressed: () {
launchUrlString('https://solsynth.dev'); launchUrlString('https://solsynth.dev/products/solar-network');
}, },
child: const Text('Official Website'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( const Text(

View File

@@ -3,11 +3,13 @@ import 'package:get/get.dart';
import 'package:solian/models/account.dart'; import 'package:solian/models/account.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/account_status.dart'; import 'package:solian/providers/account_status.dart';
import 'package:solian/providers/relation.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/screens/auth/signin.dart'; import 'package:solian/screens/auth/signin.dart';
import 'package:solian/screens/auth/signup.dart'; import 'package:solian/screens/auth/signup.dart';
import 'package:solian/widgets/account/account_heading.dart'; import 'package:solian/widgets/account/account_heading.dart';
import 'package:solian/widgets/sized_container.dart'; import 'package:solian/widgets/sized_container.dart';
import 'package:badges/badges.dart' as badges;
class AccountScreen extends StatefulWidget { class AccountScreen extends StatefulWidget {
const AccountScreen({super.key}); const AccountScreen({super.key});
@@ -23,10 +25,32 @@ class _AccountScreenState extends State<AccountScreen> {
( (
const Icon(Icons.color_lens), const Icon(Icons.color_lens),
'accountPersonalize'.tr, 'accountPersonalize'.tr,
'accountPersonalize' 'accountPersonalize',
),
(
Obx(() {
final RelationshipProvider relations = Get.find();
return badges.Badge(
badgeContent: Text(
relations.friendRequestCount.value.toString(),
style: const TextStyle(color: Colors.white),
),
showBadge: relations.friendRequestCount.value > 0,
position: badges.BadgePosition.topEnd(
top: -12,
end: -8,
),
child: const Icon(Icons.diversity_1),
);
}),
'accountFriend'.tr,
'accountFriend',
),
(
const Icon(Icons.emoji_symbols),
'accountStickers'.tr,
'accountStickers',
), ),
(const Icon(Icons.diversity_1), 'accountFriend'.tr, 'accountFriend'),
(const Icon(Icons.info_outline), 'about'.tr, 'about'),
]; ];
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
@@ -41,7 +65,10 @@ class _AccountScreenState extends State<AccountScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ActionCard( ActionCard(
icon: const Icon(Icons.login, color: Colors.white), icon: Icon(
Icons.login,
color: Theme.of(context).colorScheme.onPrimary,
),
title: 'signin'.tr, title: 'signin'.tr,
caption: 'signinCaption'.tr, caption: 'signinCaption'.tr,
onTap: () { onTap: () {
@@ -59,7 +86,10 @@ class _AccountScreenState extends State<AccountScreen> {
}, },
), ),
ActionCard( ActionCard(
icon: const Icon(Icons.add, color: Colors.white), icon: Icon(
Icons.add,
color: Theme.of(context).colorScheme.onPrimary,
),
title: 'signup'.tr, title: 'signup'.tr,
caption: 'signupCaption'.tr, caption: 'signupCaption'.tr,
onTap: () { onTap: () {
@@ -82,6 +112,7 @@ class _AccountScreenState extends State<AccountScreen> {
return CenteredContainer( return CenteredContainer(
child: ListView( child: ListView(
children: [ children: [
if (auth.userProfile.value != null)
const AccountHeading().paddingOnly(bottom: 8, top: 8), const AccountHeading().paddingOnly(bottom: 8, top: 8),
...(actionItems.map( ...(actionItems.map(
(x) => ListTile( (x) => ListTile(
@@ -183,7 +214,7 @@ class ActionCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
CircleAvatar( CircleAvatar(
backgroundColor: Colors.indigo, backgroundColor: Theme.of(context).colorScheme.primary,
child: icon, child: icon,
).paddingOnly(bottom: 12), ).paddingOnly(bottom: 12),
Text( Text(

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/relations.dart'; import 'package:solian/models/relations.dart';
import 'package:solian/providers/relation.dart'; import 'package:solian/providers/relation.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/account/relative_list.dart'; import 'package:solian/widgets/account/relative_list.dart';
class FriendScreen extends StatefulWidget { class FriendScreen extends StatefulWidget {
@@ -21,15 +21,15 @@ class _FriendScreenState extends State<FriendScreen>
List<Relationship> _relations = List.empty(); List<Relationship> _relations = List.empty();
List<Relationship> filterByStatus(int status) { List<Relationship> _filterByStatus(int status) {
return _relations.where((x) => x.status == status).toList(); return _relations.where((x) => x.status == status).toList();
} }
Future<void> loadRelations() async { Future<void> _loadRelations() async {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final RelationshipProvider provider = Get.find(); final RelationshipProvider relations = Get.find();
final resp = await provider.listRelation(); final resp = await relations.listRelation();
setState(() { setState(() {
_relations = resp.body _relations = resp.body
@@ -38,9 +38,12 @@ class _FriendScreenState extends State<FriendScreen>
.cast<Relationship>(); .cast<Relationship>();
_isBusy = false; _isBusy = false;
}); });
relations.friendRequestCount.value =
_relations.where((x) => x.status == 0).length;
} }
void promptAddFriend() async { void _promptAddFriend() async {
final RelationshipProvider provider = Get.find(); final RelationshipProvider provider = Get.find();
final controller = TextEditingController(); final controller = TextEditingController();
@@ -104,8 +107,8 @@ class _FriendScreenState extends State<FriendScreen>
super.initState(); super.initState();
_tabController = TabController(length: 3, vsync: this); _tabController = TabController(length: 3, vsync: this);
loadRelations().then((_) { _loadRelations().then((_) {
if (filterByStatus(0).isEmpty) { if (_filterByStatus(0).isEmpty) {
_tabController.animateTo(1); _tabController.animateTo(1);
} }
}); });
@@ -119,6 +122,19 @@ class _FriendScreenState extends State<FriendScreen>
appBar: AppBar( appBar: AppBar(
centerTitle: false, centerTitle: false,
title: Text('accountFriend'.tr), title: Text('accountFriend'.tr),
actions: [
if (_isBusy)
SizedBox(
height: 48,
width: 48,
child: const CircularProgressIndicator(
strokeWidth: 3,
).paddingAll(14),
),
SizedBox(
width: SolianTheme.isLargeScreen(context) ? 8 : 16,
),
],
bottom: TabBar( bottom: TabBar(
controller: _tabController, controller: _tabController,
tabs: const [ tabs: const [
@@ -130,52 +146,40 @@ class _FriendScreenState extends State<FriendScreen>
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add), child: const Icon(Icons.add),
onPressed: () => promptAddFriend(), onPressed: () => _promptAddFriend(),
), ),
body: TabBarView( body: TabBarView(
controller: _tabController, controller: _tabController,
children: [ children: [
RefreshIndicator( RefreshIndicator(
onRefresh: () => loadRelations(), onRefresh: () => _loadRelations(),
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
if (_isBusy)
SliverToBoxAdapter(
child: const LinearProgressIndicator().animate().scaleX(),
),
SilverRelativeList( SilverRelativeList(
items: filterByStatus(0), items: _filterByStatus(0),
onUpdate: () => loadRelations(), onUpdate: () => _loadRelations(),
), ),
], ],
), ),
), ),
RefreshIndicator( RefreshIndicator(
onRefresh: () => loadRelations(), onRefresh: () => _loadRelations(),
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
if (_isBusy)
SliverToBoxAdapter(
child: const LinearProgressIndicator().animate().scaleX(),
),
SilverRelativeList( SilverRelativeList(
items: filterByStatus(1), items: _filterByStatus(1),
onUpdate: () => loadRelations(), onUpdate: () => _loadRelations(),
), ),
], ],
), ),
), ),
RefreshIndicator( RefreshIndicator(
onRefresh: () => loadRelations(), onRefresh: () => _loadRelations(),
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
if (_isBusy)
SliverToBoxAdapter(
child: const LinearProgressIndicator().animate().scaleX(),
),
SilverRelativeList( SilverRelativeList(
items: filterByStatus(3), items: _filterByStatus(3),
onUpdate: () => loadRelations(), onUpdate: () => _loadRelations(),
), ),
], ],
), ),

View File

@@ -3,9 +3,11 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:image_cropper/image_cropper.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/attachment.dart'; import 'package:solian/providers/content/attachment.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
@@ -34,7 +36,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
bool _isBusy = false; bool _isBusy = false;
void selectBirthday() async { void _selectBirthday() async {
final DateTime? picked = await showDatePicker( final DateTime? picked = await showDatePicker(
context: context, context: context,
initialDate: _birthday?.toLocal(), initialDate: _birthday?.toLocal(),
@@ -49,12 +51,11 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
} }
} }
void syncWidget() async { void _syncWidget() async {
setState(() => _isBusy = true); _isBusy = true;
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
final prof = auth.userProfile.value!; final prof = auth.userProfile.value!;
setState(() {
_usernameController.text = prof['name']; _usernameController.text = prof['name'];
_nicknameController.text = prof['nick']; _nicknameController.text = prof['nick'];
_descriptionController.text = prof['description']; _descriptionController.text = prof['description'];
@@ -69,32 +70,54 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
} }
_isBusy = false; _isBusy = false;
});
} }
Future<void> updateImage(String position) async { Future<void> _editImage(String position) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
final image = await _imagePicker.pickImage(source: ImageSource.gallery); final image = await _imagePicker.pickImage(source: ImageSource.gallery);
if (image == null) return; if (image == null) return;
CroppedFile? croppedFile = await ImageCropper().cropImage(
sourcePath: image.path,
uiSettings: [
AndroidUiSettings(
toolbarTitle: 'cropImage'.tr,
toolbarColor: Theme.of(context).colorScheme.primary,
toolbarWidgetColor: Theme.of(context).colorScheme.onPrimary,
aspectRatioPresets: [
if (position == 'avatar') CropAspectRatioPreset.square,
if (position == 'banner') _BannerCropAspectRatioPreset(),
],
),
IOSUiSettings(
title: 'cropImage'.tr,
aspectRatioPresets: [
if (position == 'avatar') CropAspectRatioPreset.square,
if (position == 'banner') _BannerCropAspectRatioPreset(),
],
),
WebUiSettings(
context: context,
),
],
);
if (croppedFile == null) return;
final file = File(croppedFile.path);
setState(() => _isBusy = true); setState(() => _isBusy = true);
final AttachmentProvider provider = Get.find(); final AttachmentProvider provider = Get.find();
Response? attachResp; Attachment? attachResult;
try { try {
final file = File(image.path); attachResult = await provider.createAttachment(
final hash = await calculateFileSha256(file);
final meta = await calculateImageMetaFromFile(file);
attachResp = await provider.createAttachment(
await file.readAsBytes(), await file.readAsBytes(),
file.path, file.path,
hash,
'p.$position', 'p.$position',
{...meta}, null,
); );
} catch (e) { } catch (e) {
setState(() => _isBusy = false); setState(() => _isBusy = false);
@@ -106,10 +129,10 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
final resp = await client.put( final resp = await client.put(
'/users/me/$position', '/users/me/$position',
{'attachment': attachResp.body['id']}, {'attachment': attachResult.id},
); );
if (resp.statusCode == 200) { if (resp.statusCode == 200) {
syncWidget(); _syncWidget();
context.showSnackbar('accountPersonalizeApplied'.tr); context.showSnackbar('accountPersonalizeApplied'.tr);
} else { } else {
context.showErrorDialog(resp.bodyString); context.showErrorDialog(resp.bodyString);
@@ -118,7 +141,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
void updatePersonalize() async { void _editUserInfo() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
@@ -138,7 +161,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
}, },
); );
if (resp.statusCode == 200) { if (resp.statusCode == 200) {
syncWidget(); _syncWidget();
context.showSnackbar('accountPersonalizeApplied'.tr); context.showSnackbar('accountPersonalizeApplied'.tr);
} else { } else {
context.showErrorDialog(resp.bodyString); context.showErrorDialog(resp.bodyString);
@@ -150,8 +173,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_syncWidget();
Future.delayed(Duration.zero, () => syncWidget());
} }
@override @override
@@ -172,7 +194,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
left: 40, left: 40,
child: FloatingActionButton.small( child: FloatingActionButton.small(
heroTag: const Key('avatar-editor'), heroTag: const Key('avatar-editor'),
onPressed: () => updateImage('avatar'), onPressed: () => _editImage('avatar'),
child: const Icon( child: const Icon(
Icons.camera, Icons.camera,
), ),
@@ -191,7 +213,8 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
color: Theme.of(context).colorScheme.surfaceContainerHigh, color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: _banner != null child: _banner != null
? Image.network( ? Image.network(
ServiceFinder.buildUrl('files', '/attachments/$_banner'), ServiceFinder.buildUrl(
'files', '/attachments/$_banner'),
fit: BoxFit.cover, fit: BoxFit.cover,
loadingBuilder: (BuildContext context, Widget child, loadingBuilder: (BuildContext context, Widget child,
ImageChunkEvent? loadingProgress) { ImageChunkEvent? loadingProgress) {
@@ -216,7 +239,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
right: 16, right: 16,
child: FloatingActionButton( child: FloatingActionButton(
heroTag: const Key('banner-editor'), heroTag: const Key('banner-editor'),
onPressed: () => updateImage('banner'), onPressed: () => _editImage('banner'),
child: const Icon( child: const Icon(
Icons.camera_alt, Icons.camera_alt,
), ),
@@ -297,18 +320,18 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: 'birthday'.tr, labelText: 'birthday'.tr,
), ),
onTap: () => selectBirthday(), onTap: () => _selectBirthday(),
).paddingSymmetric(horizontal: padding), ).paddingSymmetric(horizontal: padding),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
TextButton( TextButton(
onPressed: _isBusy ? null : () => syncWidget(), onPressed: _isBusy ? null : () => _syncWidget(),
child: Text('reset'.tr), child: Text('reset'.tr),
), ),
ElevatedButton( ElevatedButton(
onPressed: _isBusy ? null : () => updatePersonalize(), onPressed: _isBusy ? null : () => _editUserInfo(),
child: Text('apply'.tr), child: Text('apply'.tr),
), ),
], ],
@@ -329,3 +352,11 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
super.dispose(); super.dispose();
} }
} }
class _BannerCropAspectRatioPreset extends CropAspectRatioPresetData {
@override
(int, int)? get data => (16, 7);
@override
String get name => '16x7';
}

View File

@@ -0,0 +1,355 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/controllers/post_list_controller.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/relation.dart';
import 'package:solian/services.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/attachments/attachment_list.dart';
import 'package:solian/widgets/posts/post_list.dart';
import 'package:solian/widgets/posts/post_warped_list.dart';
import 'package:solian/widgets/sized_container.dart';
class AccountProfilePage extends StatefulWidget {
final String name;
const AccountProfilePage({super.key, required this.name});
@override
State<AccountProfilePage> createState() => _AccountProfilePageState();
}
class _AccountProfilePageState extends State<AccountProfilePage> {
late final RelationshipProvider _relationshipProvider;
late final PostListController _postController;
final PagingController<int, Attachment> _albumPagingController =
PagingController(firstPageKey: 0);
bool _isBusy = true;
bool _isMakingFriend = false;
bool _showMature = false;
Account? _userinfo;
List<Post> _pinnedPosts = List.empty();
int _totalUpvote = 0, _totalDownvote = 0;
Future<void> _getUserinfo() async {
setState(() => _isBusy = true);
var client = ServiceFinder.configureClient('auth');
var resp = await client.get('/users/${widget.name}');
if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString).then((_) {
Navigator.pop(context);
});
} else {
_userinfo = Account.fromJson(resp.body);
}
client = ServiceFinder.configureClient('interactive');
resp = await client.get('/users/${widget.name}');
if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString).then((_) {
Navigator.pop(context);
});
} else {
_totalUpvote = resp.body['total_upvote'];
_totalDownvote = resp.body['total_downvote'];
}
setState(() => _isBusy = false);
}
Future<void> getPinnedPosts() async {
final client = ServiceFinder.configureClient('interactive');
final resp = await client.get('/users/${widget.name}/pin');
if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString).then((_) {
Navigator.pop(context);
});
} else {
setState(() {
_pinnedPosts =
resp.body.map((x) => Post.fromJson(x)).toList().cast<Post>();
});
}
}
int get _userSocialCreditPoints {
int birthPart =
DateTime.now().difference(_userinfo!.createdAt.toLocal()).inSeconds;
birthPart = birthPart >> 16;
return _totalUpvote * 2 - _totalDownvote + birthPart;
}
@override
void initState() {
super.initState();
_relationshipProvider = Get.find();
_postController = PostListController(author: widget.name);
_albumPagingController.addPageRequestListener((pageKey) async {
final client = ServiceFinder.configureClient('files');
final resp = await client
.get('/attachments?take=10&offset=$pageKey&author=${widget.name}');
if (resp.statusCode == 200) {
final result = PaginationResult.fromJson(resp.body);
final out = result.data
?.map((e) => Attachment.fromJson(e))
.where((x) => x.mimetype.split('/').firstOrNull == 'image')
.toList();
if (out != null && result.data!.length >= 10) {
_albumPagingController.appendPage(out, pageKey + out.length);
} else if (out != null) {
_albumPagingController.appendLastPage(out);
}
} else {
_albumPagingController.error = resp.bodyString;
}
});
_getUserinfo();
getPinnedPosts();
}
Widget _buildStatisticsEntry(String label, String content) {
return Expanded(
child: Column(
children: [
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
Text(
content,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
);
}
@override
Widget build(BuildContext context) {
if (_isBusy || _userinfo == null) {
return const Center(child: CircularProgressIndicator());
}
return Material(
color: Theme.of(context).colorScheme.surface,
child: DefaultTabController(
length: 2,
child: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return [
SliverAppBar(
centerTitle: false,
floating: true,
toolbarHeight: SolianTheme.toolbarHeight(context),
leadingWidth: 24,
automaticallyImplyLeading: false,
flexibleSpace: Row(
children: [
AppBarLeadingButton.adaptive(context) ??
const SizedBox(width: 8),
const SizedBox(width: 8),
if (_userinfo != null)
AccountAvatar(content: _userinfo!.avatar, radius: 16),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_userinfo != null)
Text(
_userinfo!.nick,
style: Theme.of(context).textTheme.bodyLarge,
),
if (_userinfo != null)
Text(
'@${_userinfo!.name}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
if (_userinfo != null &&
!_relationshipProvider.hasFriend(_userinfo!))
IconButton(
icon: const Icon(Icons.person_add),
onPressed: _isMakingFriend
? null
: () async {
setState(() => _isMakingFriend = true);
try {
await _relationshipProvider
.makeFriend(widget.name);
context.showSnackbar(
'accountFriendRequestSent'.tr,
);
} catch (e) {
context.showErrorDialog(e);
} finally {
setState(() => _isMakingFriend = false);
}
},
)
else
const IconButton(
icon: Icon(Icons.handshake),
onPressed: null,
),
SizedBox(
width: SolianTheme.isLargeScreen(context) ? 8 : 16,
),
],
),
bottom: TabBar(
tabs: [
Tab(text: 'profilePosts'.tr),
Tab(text: 'profileAlbum'.tr),
],
),
)
];
},
body: TabBarView(
physics: const NeverScrollableScrollPhysics(),
children: [
RefreshIndicator(
onRefresh: () => Future.wait([
_postController.reloadAllOver(),
getPinnedPosts(),
]),
child: CustomScrollView(slivers: [
SliverToBoxAdapter(
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatisticsEntry(
'totalSocialCreditPoints'.tr,
_userinfo != null
? _userSocialCreditPoints.toString()
: 0.toString(),
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Obx(
() => _buildStatisticsEntry(
'totalPostCount'.tr,
_postController.postTotal.value.toString(),
),
),
_buildStatisticsEntry(
'totalUpvote'.tr,
_totalUpvote.toString(),
),
_buildStatisticsEntry(
'totalDownvote'.tr,
_totalDownvote.toString(),
),
],
),
],
).paddingOnly(top: 16, bottom: 12),
),
const SliverToBoxAdapter(
child: Divider(thickness: 0.3, height: 0.3),
),
SliverList.separated(
itemCount: _pinnedPosts.length,
itemBuilder: (context, idx) {
final element = _pinnedPosts[idx];
return Material(
color:
Theme.of(context).colorScheme.surfaceContainerLow,
child: PostListEntryWidget(
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerLow,
item: element,
isClickable: true,
isNestedClickable: true,
isShowEmbed: true,
onUpdate: () {
_postController.reloadAllOver();
},
),
);
},
separatorBuilder: (context, idx) =>
const Divider(thickness: 0.3, height: 0.3),
),
if (_userinfo == null)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
),
if (_userinfo != null)
PostWarpedListWidget(
isPinned: false,
controller: _postController.pagingController,
onUpdate: () => _postController.reloadAllOver(),
),
]),
),
CenteredContainer(
child: RefreshIndicator(
onRefresh: () =>
Future.sync(() => _albumPagingController.refresh()),
child: PagedGridView<int, Attachment>(
padding: EdgeInsets.zero,
pagingController: _albumPagingController,
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 8.0,
crossAxisSpacing: 8.0,
),
builderDelegate: PagedChildBuilderDelegate<Attachment>(
itemBuilder: (BuildContext context, item, int index) {
const radius = BorderRadius.all(Radius.circular(8));
return Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
width: 0.3,
),
borderRadius: radius,
),
child: ClipRRect(
borderRadius: radius,
child: AttachmentListEntry(
item: item,
isDense: true,
parentId: 'album',
showMature: _showMature,
onReveal: (value) {
setState(() => _showMature = value);
},
),
),
);
},
),
).paddingAll(16),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,191 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/models/stickers.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/stickers.dart';
import 'package:solian/services.dart';
import 'package:solian/widgets/stickers/sticker_uploader.dart';
class StickerScreen extends StatefulWidget {
const StickerScreen({super.key});
@override
State<StickerScreen> createState() => _StickerScreenState();
}
class _StickerScreenState extends State<StickerScreen> {
final PagingController<int, StickerPack> _pagingController =
PagingController(firstPageKey: 0);
Future<bool> _promptDelete(Sticker item, String prefix) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return false;
final confirm = await showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('stickerDeletionConfirm'.tr),
content: Text(
'stickerDeletionConfirmCaption'.trParams({
'name': ':${'$prefix${item.alias}'.camelCase}:',
}),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('cancel'.tr),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('confirm'.tr),
),
],
),
);
if (confirm != true) return false;
final client = auth.configureClient('files');
final resp = await client.delete('/stickers/${item.id}');
return resp.statusCode == 200;
}
Future<bool?> _promptUploadSticker({Sticker? edit}) {
return showDialog(
context: context,
builder: (context) => StickerUploadDialog(
edit: edit,
),
);
}
Widget _buildEmoteEntry(Sticker item, String prefix) {
final imageUrl = ServiceFinder.buildUrl(
'files',
'/attachments/${item.attachmentId}',
);
return ListTile(
title: Text(item.name),
subtitle: Text(item.textWarpedPlaceholder),
contentPadding: const EdgeInsets.only(left: 16, right: 14),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit_square),
onPressed: () {
_promptUploadSticker(edit: item).then((value) {
if (value == true) _pagingController.refresh();
});
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
_promptDelete(item, prefix).then((value) {
if (value == true) _pagingController.refresh();
});
},
),
],
),
leading: PlatformInfo.canCacheImage
? CachedNetworkImage(
imageUrl: imageUrl,
width: 28,
height: 28,
)
: Image.network(
imageUrl,
width: 28,
height: 28,
),
);
}
@override
void initState() {
final AuthProvider auth = Get.find();
final name = auth.userProfile.value!['name'];
_pagingController.addPageRequestListener((pageKey) async {
final client = ServiceFinder.configureClient('files');
final resp = await client.get(
'/stickers/manifest?take=10&offset=$pageKey&author=$name',
);
if (resp.statusCode == 200) {
final result = PaginationResult.fromJson(resp.body);
final out = result.data?.map((e) => StickerPack.fromJson(e)).toList();
if (out != null && result.data!.length >= 10) {
_pagingController.appendPage(out, pageKey + out.length);
} else if (out != null) {
_pagingController.appendLastPage(out);
}
} else {
_pagingController.error = resp.bodyString;
}
});
super.initState();
}
@override
void dispose() {
final StickerProvider sticker = Get.find();
sticker.refreshAvailableStickers();
_pagingController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () {
_promptUploadSticker().then((value) {
if (value == true) _pagingController.refresh();
});
},
),
body: RefreshIndicator(
onRefresh: () => Future.sync(() => _pagingController.refresh()),
child: CustomScrollView(
slivers: [
PagedSliverList<int, StickerPack>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate(
itemBuilder: (BuildContext context, item, int index) {
return ExpansionTile(
title: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(item.name),
const SizedBox(width: 6),
Badge(
label: Text('#${item.id}'),
)
],
),
subtitle: Text(
item.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
children: item.stickers?.map((x) {
x.pack = item;
return _buildEmoteEntry(x, item.prefix);
}).toList() ??
List.empty(),
);
},
),
),
],
),
),
);
}
}

View File

@@ -52,7 +52,7 @@ class _SignInPopupState extends State<SignInPopup> with ProtocolListener {
} }
void performAction() async { void performAction() async {
final AuthProvider provider = Get.find(); final AuthProvider auth = Get.find();
final username = _usernameController.value.text; final username = _usernameController.value.text;
final password = _passwordController.value.text; final password = _passwordController.value.text;
@@ -61,7 +61,11 @@ class _SignInPopupState extends State<SignInPopup> with ProtocolListener {
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
await provider.signin(context, username, password); await auth.signin(context, username, password);
await Future.delayed(const Duration(milliseconds: 250), () async {
await auth.refreshAuthorizeStatus();
await auth.refreshUserProfile();
});
} on RiskyAuthenticateException catch (e) { } on RiskyAuthenticateException catch (e) {
showDialog( showDialog(
context: context, context: context,
@@ -75,7 +79,8 @@ class _SignInPopupState extends State<SignInPopup> with ProtocolListener {
onPressed: () { onPressed: () {
const redirect = 'solink://auth?status=done'; const redirect = 'solink://auth?status=done';
launchUrlString( launchUrlString(
ServiceFinder.buildUrl('passport', '/mfa?redirect_uri=$redirect&ticketId=${e.ticketId}'), ServiceFinder.buildUrl('capital',
'/auth/mfa?redirect_uri=$redirect&ticketId=${e.ticketId}'),
mode: LaunchMode.inAppWebView, mode: LaunchMode.inAppWebView,
); );
Navigator.pop(context); Navigator.pop(context);
@@ -131,8 +136,10 @@ class _SignInPopupState extends State<SignInPopup> with ProtocolListener {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Image.asset('assets/logo.png', width: 64, height: 64) ClipRRect(
.paddingOnly(bottom: 4), borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Image.asset('assets/logo.png', width: 64, height: 64),
).paddingOnly(bottom: 4),
Text( Text(
'signinGreeting'.tr, 'signinGreeting'.tr,
style: const TextStyle( style: const TextStyle(

View File

@@ -70,8 +70,10 @@ class _SignUpPopupState extends State<SignUpPopup> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Image.asset('assets/logo.png', width: 64, height: 64) ClipRRect(
.paddingOnly(bottom: 4), borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Image.asset('assets/logo.png', width: 64, height: 64),
).paddingOnly(bottom: 4),
Text( Text(
'signupGreeting'.tr, 'signupGreeting'.tr,
style: const TextStyle( style: const TextStyle(

View File

@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:async/async.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/providers/call.dart'; import 'package:solian/providers/call.dart';
@@ -8,6 +9,7 @@ import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/chat/call/call_controls.dart'; import 'package:solian/widgets/chat/call/call_controls.dart';
import 'package:solian/widgets/chat/call/call_participant.dart'; import 'package:solian/widgets/chat/call/call_participant.dart';
import 'package:livekit_client/livekit_client.dart' as livekit;
class CallScreen extends StatefulWidget { class CallScreen extends StatefulWidget {
const CallScreen({super.key}); const CallScreen({super.key});
@@ -16,11 +18,26 @@ class CallScreen extends StatefulWidget {
State<CallScreen> createState() => _CallScreenState(); State<CallScreen> createState() => _CallScreenState();
} }
class _CallScreenState extends State<CallScreen> { class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
Timer? timer; Timer? _timer;
String currentDuration = '00:00:00'; String _currentDuration = '00:00:00';
String parseDuration() { int _layoutMode = 0;
bool _showControls = true;
CancelableOperation? _hideControlsOperation;
late final AnimationController _controlsAnimationController =
AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
late final Animation<double> _controlsAnimation = CurvedAnimation(
parent: _controlsAnimationController,
curve: Curves.fastOutSlowIn,
);
String _parseDuration() {
final ChatCallProvider provider = Get.find(); final ChatCallProvider provider = Get.find();
if (provider.current.value == null) return '00:00:00'; if (provider.current.value == null) return '00:00:00';
Duration duration = Duration duration =
@@ -34,9 +51,141 @@ class _CallScreenState extends State<CallScreen> {
return formattedTime; return formattedTime;
} }
void updateDuration() { void _updateDuration() {
setState(() { setState(() {
currentDuration = parseDuration(); _currentDuration = _parseDuration();
});
}
void _switchLayout() {
if (_layoutMode < 1) {
setState(() => _layoutMode++);
} else {
setState(() => _layoutMode = 0);
}
}
void _toggleControls() {
if (_showControls) {
setState(() => _showControls = false);
_controlsAnimationController.animateTo(0);
_hideControlsOperation?.cancel();
} else {
setState(() => _showControls = true);
_controlsAnimationController.animateTo(1);
_planAutoHideControls();
}
}
void _planAutoHideControls() {
_hideControlsOperation = CancelableOperation.fromFuture(
Future.delayed(const Duration(seconds: 3), () {
if (!mounted) return;
if (_showControls) _toggleControls();
}),
);
}
Widget _buildListLayout() {
final ChatCallProvider call = Get.find();
return Obx(
() => Stack(
children: [
Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: call.focusTrack.value != null
? InteractiveParticipantWidget(
isFixedAvatar: false,
participant: call.focusTrack.value!,
onTap: () {},
)
: const SizedBox(),
),
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.value?.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.value?.participant.sid) {
call.changeFocusTrack(track);
}
},
),
),
);
},
),
),
),
],
),
);
}
Widget _buildGridLayout() {
final ChatCallProvider call = Get.find();
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 Obx(
() => 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,
participant: track,
onTap: () {
if (track.participant.sid !=
call.focusTrack.value?.participant.sid) {
call.changeFocusTrack(track);
}
},
),
),
);
},
),
).paddingAll(8);
}); });
} }
@@ -45,7 +194,18 @@ class _CallScreenState extends State<CallScreen> {
Get.find<ChatCallProvider>().setupRoom(); Get.find<ChatCallProvider>().setupRoom();
super.initState(); super.initState();
timer = Timer.periodic(const Duration(seconds: 1), (_) => updateDuration()); _updateDuration();
_planAutoHideControls();
_timer = Timer.periodic(
const Duration(seconds: 1),
(_) => _updateDuration(),
);
}
@override
void dispose() {
_controlsAnimationController.dispose();
super.dispose();
} }
@override @override
@@ -68,98 +228,158 @@ class _CallScreenState extends State<CallScreen> {
), ),
const TextSpan(text: '\n'), const TextSpan(text: '\n'),
TextSpan( TextSpan(
text: currentDuration, text: _currentDuration,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
]), ]),
), ),
), ),
body: SafeArea( body: SafeArea(
child: Obx( child: GestureDetector(
() => Stack( behavior: HitTestBehavior.translucent,
child: Column(
children: [ children: [
Column( SizeTransition(
sizeFactor: _controlsAnimation,
axis: Axis.vertical,
child: SizedBox(
width: MediaQuery.of(context).size.width,
height: 64,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Expanded( Builder(builder: (context) {
child: Container( final call = Get.find<ChatCallProvider>();
color: Theme.of(context).colorScheme.surfaceContainer, final connectionQuality =
child: provider.focusTrack.value != null call.room.localParticipant?.connectionQuality ??
? InteractiveParticipantWidget( livekit.ConnectionQuality.unknown;
isFixed: false, return Expanded(
participant: provider.focusTrack.value!, child: Column(
onTap: () {}, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(call.room.serverRegion ?? 'unknown'),
const SizedBox(width: 6),
Text(call.room.serverVersion ?? 'unknown')
],
),
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 SizedBox(width: 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,
) )
: const SizedBox(), else
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
).paddingAll(3),
],
),
],
),
);
}),
IconButton(
icon: _layoutMode == 0
? const Icon(Icons.view_list)
: const Icon(Icons.grid_view),
onPressed: () {
_switchLayout();
},
),
],
).paddingOnly(left: 20, right: 16),
),
),
Expanded(
child: Material(
color: Theme.of(context).colorScheme.surfaceContainerLow,
elevation: 2,
child: Builder(
builder: (context) {
switch (_layoutMode) {
case 1:
return _buildGridLayout();
default:
return _buildListLayout();
}
},
),
), ),
), ),
if (provider.room.localParticipant != null) if (provider.room.localParticipant != null)
ControlsWidget( SizeTransition(
sizeFactor: _controlsAnimation,
axis: Axis.vertical,
child: SizedBox(
width: MediaQuery.of(context).size.width,
child: ControlsWidget(
provider.room, provider.room,
provider.room.localParticipant!, provider.room.localParticipant!,
), ),
),
),
], ],
), ),
Positioned(
left: 0,
right: 0,
top: 0,
child: SizedBox(
height: 128,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: math.max(0, provider.participantTracks.length),
itemBuilder: (BuildContext context, int index) {
final track = provider.participantTracks[index];
if (track.participant.sid ==
provider.focusTrack.value?.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(
isFixed: true,
width: 120,
height: 120,
color: Theme.of(context).cardColor,
participant: track,
onTap: () { onTap: () {
if (track.participant.sid != _toggleControls();
provider
.focusTrack.value?.participant.sid) {
provider.changeFocusTrack(track);
}
}, },
), ),
), ),
);
},
),
),
),
],
),
),
),
), ),
); );
} }
@override @override
void deactivate() { void deactivate() {
timer?.cancel(); _timer?.cancel();
timer = null; _timer = null;
super.deactivate(); super.deactivate();
} }
@override @override
void activate() { void activate() {
timer ??= Timer.periodic( _timer ??= Timer.periodic(
const Duration(seconds: 1), const Duration(seconds: 1),
(_) => updateDuration(), (_) => _updateDuration(),
); );
super.activate(); super.activate();
} }

View File

@@ -11,7 +11,6 @@ import 'package:solian/models/channel.dart';
import 'package:solian/models/event.dart'; import 'package:solian/models/event.dart';
import 'package:solian/models/packet.dart'; import 'package:solian/models/packet.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/call.dart';
import 'package:solian/providers/content/channel.dart'; import 'package:solian/providers/content/channel.dart';
import 'package:solian/providers/websocket.dart'; import 'package:solian/providers/websocket.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
@@ -19,9 +18,8 @@ import 'package:solian/screens/channel/channel_detail.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/chat/call/call_prejoin.dart'; import 'package:solian/widgets/channel/channel_call_indicator.dart';
import 'package:solian/widgets/chat/call/chat_call_action.dart'; import 'package:solian/widgets/chat/call/chat_call_action.dart';
import 'package:solian/widgets/chat/chat_event.dart';
import 'package:solian/widgets/chat/chat_event_list.dart'; import 'package:solian/widgets/chat/chat_event_list.dart';
import 'package:solian/widgets/chat/chat_message_input.dart'; import 'package:solian/widgets/chat/chat_message_input.dart';
import 'package:solian/widgets/current_state_action.dart'; import 'package:solian/widgets/current_state_action.dart';
@@ -40,7 +38,10 @@ class ChannelChatScreen extends StatefulWidget {
State<ChannelChatScreen> createState() => _ChannelChatScreenState(); State<ChannelChatScreen> createState() => _ChannelChatScreenState();
} }
class _ChannelChatScreenState extends State<ChannelChatScreen> { class _ChannelChatScreenState extends State<ChannelChatScreen>
with WidgetsBindingObserver {
DateTime? _isOutOfSyncSince;
bool _isBusy = false; bool _isBusy = false;
int? _accountId; int? _accountId;
@@ -53,7 +54,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
late final ChatEventController _chatController; late final ChatEventController _chatController;
getChannel({String? alias}) async { _getChannel({String? alias}) async {
final ChannelProvider provider = Get.find(); final ChannelProvider provider = Get.find();
setState(() => _isBusy = true); setState(() => _isBusy = true);
@@ -80,7 +81,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
getOngoingCall() async { _getOngoingCall() async {
final ChannelProvider provider = Get.find(); final ChannelProvider provider = Get.find();
setState(() => _isBusy = true); setState(() => _isBusy = true);
@@ -100,7 +101,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
void listenMessages() { void _listenMessages() {
final WebSocketProvider provider = Get.find(); final WebSocketProvider provider = Get.find();
_subscription = provider.stream.stream.listen((event) { _subscription = provider.stream.stream.listen((event) {
switch (event.method) { switch (event.method) {
@@ -110,66 +111,66 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
break; break;
case 'calls.new': case 'calls.new':
final payload = Call.fromJson(event.payload!); final payload = Call.fromJson(event.payload!);
if (payload.channel.id == _channel!.id) {
setState(() => _ongoingCall = payload); setState(() => _ongoingCall = payload);
}
break; break;
case 'calls.end': case 'calls.end':
final payload = Call.fromJson(event.payload!);
if (payload.channel.id == _channel!.id) {
setState(() => _ongoingCall = null); setState(() => _ongoingCall = null);
}
break; break;
} }
}); });
} }
void showCallPrejoin() { void _keepUpdateWithServer() {
showModalBottomSheet( _getOngoingCall();
useRootNavigator: true, _chatController.getEvents(_channel!, widget.realm);
context: context, setState(() => _isOutOfSyncSince = null);
builder: (context) => ChatCallPrejoinPopup(
ongoingCall: _ongoingCall!,
channel: _channel!,
),
);
} }
Event? _messageToReplying; Event? _messageToReplying;
Event? _messageToEditing; Event? _messageToEditing;
Widget buildHistoryBody(Event item, {bool isMerged = false}) { @override
return ChatEvent( void didChangeAppLifecycleState(AppLifecycleState state) {
key: Key('m${item.uuid}'), switch (state) {
item: item, case AppLifecycleState.resumed:
isMerged: isMerged, if (_isOutOfSyncSince == null) break;
chatController: _chatController, if (DateTime.now().difference(_isOutOfSyncSince!).inSeconds < 60) break;
); _keepUpdateWithServer();
break;
case AppLifecycleState.paused:
if (mounted) {
setState(() => _isOutOfSyncSince = DateTime.now());
}
break;
default:
break;
}
} }
@override @override
void initState() { void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_accountId = Get.find<AuthProvider>().userProfile.value!['id']; _accountId = Get.find<AuthProvider>().userProfile.value!['id'];
_chatController = ChatEventController(); _chatController = ChatEventController();
_chatController.initialize(); _chatController.initialize();
getChannel().then((_) { _getOngoingCall();
_getChannel().then((_) {
_chatController.getEvents(_channel!, widget.realm); _chatController.getEvents(_channel!, widget.realm);
listenMessages(); _listenMessages();
}); });
getOngoingCall();
super.initState();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_isBusy || _channel == null) {
return Material(
color: Theme.of(context).colorScheme.surface,
child: const Center(
child: CircularProgressIndicator(),
),
);
}
String title = _channel?.name ?? 'loading'.tr; String title = _channel?.name ?? 'loading'.tr;
String? placeholder; String? placeholder;
@@ -183,8 +184,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
); );
} }
final ChatCallProvider call = Get.find();
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context), leading: AppBarLeadingButton.adaptive(context),
@@ -195,7 +194,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
actions: [ actions: [
const BackgroundStateWidget(), const BackgroundStateWidget(),
Builder(builder: (context) { Builder(builder: (context) {
if (_isBusy) return const SizedBox(); if (_isBusy || _channel == null) return const SizedBox();
return ChatCallButton( return ChatCallButton(
realm: _channel!.realm, realm: _channel!.realm,
channel: _channel!, channel: _channel!,
@@ -205,6 +205,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
IconButton( IconButton(
icon: const Icon(Icons.more_vert), icon: const Icon(Icons.more_vert),
onPressed: () { onPressed: () {
if (_channel == null) return;
AppRouter.instance AppRouter.instance
.pushNamed( .pushNamed(
'channelDetail', 'channelDetail',
@@ -219,7 +221,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
if (value == false) AppRouter.instance.pop(); if (value == false) AppRouter.instance.pop();
if (value != null) { if (value != null) {
final resp = Channel.fromJson(value as Map<String, dynamic>); final resp = Channel.fromJson(value as Map<String, dynamic>);
getChannel(alias: resp.alias); _getChannel(alias: resp.alias);
} }
}); });
}, },
@@ -229,35 +231,19 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
), ),
], ],
), ),
body: Column( body: Builder(builder: (context) {
children: [ if (_isBusy || _channel == null) {
if (_ongoingCall != null) return const Center(
MaterialBanner( child: CircularProgressIndicator(),
padding: const EdgeInsets.only(left: 16, top: 4, bottom: 4),
leading: const Icon(Icons.call_received),
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
dividerColor: Colors.transparent,
content: Text('callOngoing'.tr),
actions: [
Obx(() {
if (call.current.value == null) {
return TextButton(
onPressed: showCallPrejoin,
child: Text('callJoin'.tr),
);
} else if (call.channel.value?.id == _channel?.id) {
return TextButton(
onPressed: () => call.gotoScreen(context),
child: Text('callResume'.tr),
);
} else {
return TextButton(
onPressed: null,
child: Text('callJoin'.tr),
); );
} }
})
], return Column(
children: [
if (_ongoingCall != null)
ChannelCallIndicator(
channel: _channel!,
ongoingCall: _ongoingCall!,
), ),
Expanded( Expanded(
child: ChatEventList( child: ChatEventList(
@@ -272,6 +258,25 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}, },
), ),
), ),
if (_isOutOfSyncSince != null)
ListTile(
contentPadding: const EdgeInsets.only(left: 16, right: 8),
tileColor: Theme.of(context).colorScheme.surfaceContainerLow,
leading: const Icon(Icons.history_toggle_off),
title: Text('messageOutOfSync'.tr),
subtitle: Text('messageOutOfSyncCaption'.tr),
trailing: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
setState(() => _isOutOfSyncSince = null);
},
),
onTap: _isBusy
? null
: () {
_keepUpdateWithServer();
},
),
Obx(() { Obx(() {
if (_chatController.isLoading.isTrue) { if (_chatController.isLoading.isTrue) {
return const LinearProgressIndicator().animate().slideY(); return const LinearProgressIndicator().animate().slideY();
@@ -305,13 +310,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
), ),
), ),
], ],
), );
}),
); );
} }
@override @override
void dispose() { void dispose() {
_subscription?.cancel(); _subscription?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();
} }
} }

View File

@@ -21,7 +21,7 @@ class _DraftBoxScreenState extends State<DraftBoxScreen> {
final PagingController<int, Post> _pagingController = final PagingController<int, Post> _pagingController =
PagingController(firstPageKey: 0); PagingController(firstPageKey: 0);
getPosts(int pageKey) async { _getPosts(int pageKey) async {
final PostProvider provider = Get.find(); final PostProvider provider = Get.find();
Response resp; Response resp;
@@ -49,7 +49,7 @@ class _DraftBoxScreenState extends State<DraftBoxScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_pagingController.addPageRequestListener(getPosts); _pagingController.addPageRequestListener(_getPosts);
} }
@override @override
@@ -76,6 +76,9 @@ class _DraftBoxScreenState extends State<DraftBoxScreen> {
itemBuilder: (context, item, index) { itemBuilder: (context, item, index) {
return PostOwnedListEntry( return PostOwnedListEntry(
item: item, item: item,
isFullContent: true,
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerLow,
onTap: () async { onTap: () async {
showModalBottomSheet( showModalBottomSheet(
useRootNavigator: true, useRootNavigator: true,
@@ -85,7 +88,13 @@ class _DraftBoxScreenState extends State<DraftBoxScreen> {
noReact: true, noReact: true,
), ),
).then((value) { ).then((value) {
if (value != null) _pagingController.refresh(); if (value is Future) {
value.then((_) {
_pagingController.refresh();
});
} else if (value != null) {
_pagingController.refresh();
}
}); });
}, },
).paddingOnly(left: 12, right: 12, bottom: 4); ).paddingOnly(left: 12, right: 12, bottom: 4);

View File

@@ -3,7 +3,7 @@ import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/pagination.dart'; import 'package:solian/models/pagination.dart';
import 'package:solian/providers/content/posts.dart'; import 'package:solian/providers/content/posts.dart';
import 'package:solian/widgets/feed/feed_list.dart'; import 'package:solian/widgets/posts/post_warped_list.dart';
import '../../models/post.dart'; import '../../models/post.dart';
@@ -26,7 +26,7 @@ class _FeedSearchScreenState extends State<FeedSearchScreen> {
Response resp; Response resp;
try { try {
resp = await provider.listRecommendations( resp = await provider.listPost(
pageKey, pageKey,
tag: widget.tag, tag: widget.tag,
category: widget.category, category: widget.category,
@@ -77,7 +77,10 @@ class _FeedSearchScreenState extends State<FeedSearchScreen> {
onRefresh: () => Future.sync(() => _pagingController.refresh()), onRefresh: () => Future.sync(() => _pagingController.refresh()),
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
FeedListWidget(controller: _pagingController), PostWarpedListWidget(
controller: _pagingController,
onUpdate: () => _pagingController.refresh(),
),
], ],
), ),
), ),

View File

@@ -8,8 +8,8 @@ import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/current_state_action.dart'; import 'package:solian/widgets/current_state_action.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/feed/feed_list.dart';
import 'package:solian/widgets/posts/post_shuffle_swiper.dart'; import 'package:solian/widgets/posts/post_shuffle_swiper.dart';
import 'package:solian/widgets/posts/post_warped_list.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@@ -25,9 +25,8 @@ class _HomeScreenState extends State<HomeScreen>
@override @override
void initState() { void initState() {
Get.lazyPut(() => PostListController());
super.initState(); super.initState();
_postController = Get.find(); _postController = PostListController();
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 2, vsync: this);
_tabController.addListener(() { _tabController.addListener(() {
switch (_tabController.index) { switch (_tabController.index) {
@@ -44,23 +43,27 @@ class _HomeScreenState extends State<HomeScreen>
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return Material(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: SafeArea(
bottom: false,
child: Scaffold( child: Scaffold(
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add), child: const Icon(Icons.add),
onPressed: () { onPressed: () async {
showModalBottomSheet( final value = await showModalBottomSheet(
useRootNavigator: true, useRootNavigator: true,
isScrollControlled: true, isScrollControlled: true,
context: context, context: context,
builder: (context) => const PostCreatePopup(), builder: (context) => const PostCreatePopup(),
); );
if (value is Future) {
value.then((_) {
_postController.reloadAllOver();
});
} else if (value != null) {
_postController.reloadAllOver();
}
}, },
), ),
body: NestedScrollView( body: NestedScrollView(
headerSliverBuilder: headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
(BuildContext context, bool innerBoxIsScrolled) {
return [ return [
SliverAppBar( SliverAppBar(
title: AppBarTitle('home'.tr), title: AppBarTitle('home'.tr),
@@ -99,8 +102,10 @@ class _HomeScreenState extends State<HomeScreen>
RefreshIndicator( RefreshIndicator(
onRefresh: () => _postController.reloadAllOver(), onRefresh: () => _postController.reloadAllOver(),
child: CustomScrollView(slivers: [ child: CustomScrollView(slivers: [
FeedListWidget( PostWarpedListWidget(
controller: _postController.pagingController), controller: _postController.pagingController,
onUpdate: () => _postController.reloadAllOver(),
),
]), ]),
), ),
PostShuffleSwiper(controller: _postController), PostShuffleSwiper(controller: _postController),
@@ -109,7 +114,6 @@ class _HomeScreenState extends State<HomeScreen>
}), }),
), ),
), ),
),
); );
} }
@@ -122,12 +126,10 @@ class _HomeScreenState extends State<HomeScreen>
class PostCreatePopup extends StatelessWidget { class PostCreatePopup extends StatelessWidget {
final bool hideDraftBox; final bool hideDraftBox;
final Function? onCreated;
const PostCreatePopup({ const PostCreatePopup({
super.key, super.key,
this.hideDraftBox = false, this.hideDraftBox = false,
this.onCreated,
}); });
@override @override
@@ -140,27 +142,49 @@ class PostCreatePopup extends StatelessWidget {
final List<dynamic> actionList = [ final List<dynamic> actionList = [
( (
icon: const Icon(Icons.edit_square), icon: const Icon(Icons.post_add),
label: 'postEditor'.tr, label: 'postEditorModeStory'.tr,
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(
AppRouter.instance.pushNamed('postEditor').then((val) { context,
if (val != null && onCreated != null) onCreated!(); AppRouter.instance.pushNamed(
}); 'postEditor',
queryParameters: {
'mode': 0.toString(),
},
),
);
},
),
(
icon: const Icon(Icons.description),
label: 'postEditorModeArticle'.tr,
onTap: () {
Navigator.pop(
context,
AppRouter.instance.pushNamed(
'postEditor',
queryParameters: {
'mode': 1.toString(),
},
),
);
}, },
), ),
( (
icon: const Icon(Icons.drafts), icon: const Icon(Icons.drafts),
label: 'draftBoxOpen'.tr, label: 'draftBoxOpen'.tr,
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(
AppRouter.instance.pushNamed('draftBox'); context,
AppRouter.instance.pushNamed('draftBox'),
);
}, },
), ),
]; ];
return SizedBox( return SizedBox(
height: MediaQuery.of(context).size.height * 0.35, height: MediaQuery.of(context).size.height * 0.38,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -184,7 +208,12 @@ class PostCreatePopup extends StatelessWidget {
children: [ children: [
x.icon, x.icon,
const SizedBox(height: 8), const SizedBox(height: 8),
Text(x.label), Expanded(
child: Text(
x.label,
overflow: TextOverflow.fade,
),
),
], ],
).paddingAll(18), ).paddingAll(18),
), ),

View File

@@ -3,14 +3,18 @@ import 'package:get/get.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/providers/content/posts.dart'; import 'package:solian/providers/content/posts.dart';
import 'package:solian/widgets/sized_container.dart';
import 'package:solian/widgets/posts/post_item.dart'; import 'package:solian/widgets/posts/post_item.dart';
import 'package:solian/widgets/posts/post_replies.dart'; import 'package:solian/widgets/posts/post_replies.dart';
class PostDetailScreen extends StatefulWidget { class PostDetailScreen extends StatefulWidget {
final String id; final String id;
final Post? post;
const PostDetailScreen({super.key, required this.id}); const PostDetailScreen({
super.key,
required this.id,
this.post,
});
@override @override
State<PostDetailScreen> createState() => _PostDetailScreenState(); State<PostDetailScreen> createState() => _PostDetailScreenState();
@@ -20,6 +24,11 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
Post? item; Post? item;
Future<Post?> getDetail() async { Future<Post?> getDetail() async {
if (widget.post != null) {
item = widget.post;
return widget.post;
}
final PostProvider provider = Get.find(); final PostProvider provider = Get.find();
try { try {
@@ -48,22 +57,21 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
SliverToBoxAdapter( SliverToBoxAdapter(
child: CenteredContainer(
child: PostItem( child: PostItem(
item: item!, item: item!,
isClickable: true, isClickable: false,
isOverrideEmbedClickable: true,
isFullDate: true, isFullDate: true,
isFullContent: true,
isShowReply: false, isShowReply: false,
isContentSelectable: true, isContentSelectable: true,
), ),
), ),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: const Divider(thickness: 0.3, height: 1) child: const Divider(thickness: 0.3, height: 1)
.paddingOnly(top: 4), .paddingOnly(top: 4),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: CenteredContainer(
child: Align( child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text( child: Text(
@@ -72,7 +80,6 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
).paddingOnly(left: 24, right: 24, top: 16), ).paddingOnly(left: 24, right: 24, top: 16),
), ),
), ),
),
PostReplyList(item: item!), PostReplyList(item: item!),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SizedBox(height: MediaQuery.of(context).padding.bottom), child: SizedBox(height: MediaQuery.of(context).padding.bottom),

View File

@@ -2,18 +2,20 @@ import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:markdown_toolbar/markdown_toolbar.dart';
import 'package:solian/controllers/post_editor_controller.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
import 'package:solian/providers/attachment_uploader.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/attachments/attachment_publish.dart'; import 'package:solian/widgets/markdown_text_content.dart';
import 'package:solian/widgets/posts/post_item.dart'; import 'package:solian/widgets/posts/post_item.dart';
import 'package:solian/widgets/feed/feed_tags_field.dart';
import 'package:textfield_tags/textfield_tags.dart';
import 'package:badges/badges.dart' as badges; import 'package:badges/badges.dart' as badges;
class PostPublishArguments { class PostPublishArguments {
@@ -22,7 +24,12 @@ class PostPublishArguments {
final Post? repost; final Post? repost;
final Realm? realm; final Realm? realm;
PostPublishArguments({this.edit, this.reply, this.repost, this.realm}); PostPublishArguments({
this.edit,
this.reply,
this.repost,
this.realm,
});
} }
class PostPublishScreen extends StatefulWidget { class PostPublishScreen extends StatefulWidget {
@@ -30,6 +37,7 @@ class PostPublishScreen extends StatefulWidget {
final Post? reply; final Post? reply;
final Post? repost; final Post? repost;
final Realm? realm; final Realm? realm;
final int mode;
const PostPublishScreen({ const PostPublishScreen({
super.key, super.key,
@@ -37,6 +45,7 @@ class PostPublishScreen extends StatefulWidget {
this.reply, this.reply,
this.repost, this.repost,
this.realm, this.realm,
required this.mode,
}); });
@override @override
@@ -44,87 +53,93 @@ class PostPublishScreen extends StatefulWidget {
} }
class _PostPublishScreenState extends State<PostPublishScreen> { class _PostPublishScreenState extends State<PostPublishScreen> {
final _contentController = TextEditingController(); final _editorController = PostEditorController();
final _tagsController = StringTagController(); final _contentFocusNode = FocusNode();
bool _isBusy = false; bool _isBusy = false;
List<int> _attachments = List.empty(); void _applyPost() async {
bool _isDraft = false;
void showAttachments() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => AttachmentPublishPopup(
usage: 'i.attachment',
current: _attachments,
onUpdate: (value) {
setState(() => _attachments = value);
},
),
);
}
void applyPost() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
if (_contentController.value.text.isEmpty) return; if (_editorController.isEmpty) return;
final AttachmentUploaderController uploader = Get.find();
if (uploader.queueOfUpload.any(
((x) => x.usage == 'i.attachment' && x.isUploading),
)) {
context.showErrorDialog('attachmentUploadInProgress'.tr);
return;
}
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = auth.configureClient('interactive'); final client = auth.configureClient('interactive');
final payload = {
'content': _contentController.value.text,
'tags': _tagsController.getTags?.map((x) => {'alias': x}).toList() ??
List.empty(),
'attachments': _attachments,
'is_draft': _isDraft,
if (widget.reply != null) 'reply_to': widget.reply!.id,
if (widget.repost != null) 'repost_to': widget.repost!.id,
if (widget.realm != null) 'realm': widget.realm!.alias,
};
Response resp; Response resp;
if (widget.edit != null) { if (widget.edit != null) {
resp = await client.put('/stories/${widget.edit!.id}', payload); resp = await client.put(
'/${_editorController.typeEndpoint}/${widget.edit!.id}',
_editorController.payload,
);
} else { } else {
resp = await client.post('/stories', payload); resp = await client.post(
'/${_editorController.typeEndpoint}',
_editorController.payload,
);
} }
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString); context.showErrorDialog(resp.bodyString);
} else { } else {
_editorController.localClear();
_editorController.currentClear();
AppRouter.instance.pop(resp.body); AppRouter.instance.pop(resp.body);
} }
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
void syncWidget() { void _syncWidget() {
_editorController.mode.value = widget.mode;
if (widget.edit != null) { if (widget.edit != null) {
_contentController.text = widget.edit!.body['content']; _editorController.editTarget = widget.edit;
_attachments = widget.edit!.body['attachments']?.cast<int>() ?? List.empty(); }
_isDraft = widget.edit!.isDraft ?? false; if (widget.realm != null) {
_editorController.realmZone.value = widget.realm;
} }
} }
void cancelAction() { void _cancelAction() {
_editorController.localClear();
AppRouter.instance.pop(); AppRouter.instance.pop();
} }
Post? get _editTo => _editorController.editTo.value;
Post? get _replyTo => _editorController.replyTo.value;
Post? get _repostTo => _editorController.repostTo.value;
@override @override
void initState() { void initState() {
syncWidget();
super.initState(); super.initState();
if (widget.edit == null && widget.reply == null && widget.repost == null) {
_editorController.localRead();
}
if (widget.reply != null) {
_editorController.replyTo.value = widget.reply;
}
if (widget.repost != null) {
_editorController.repostTo.value = widget.repost;
}
_editorController.contentController.addListener(() => setState(() {}));
_syncWidget();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final notifyBannerActions = [ final notifyBannerActions = [
TextButton( TextButton(
onPressed: cancelAction, onPressed: _cancelAction,
child: Text('cancel'.tr), child: Text('cancel'.tr),
) )
]; ];
@@ -134,26 +149,59 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context), leading: AppBarLeadingButton.adaptive(context),
title: AppBarTitle('postPublish'.tr), title: Obx(
() => AppBarTitle(
_editorController.mode.value == 0
? 'postEditorModeStory'.tr
: 'postEditorModeArticle'.tr,
),
),
centerTitle: false, centerTitle: false,
toolbarHeight: SolianTheme.toolbarHeight(context), toolbarHeight: SolianTheme.toolbarHeight(context),
actions: [ actions: [
TextButton( TextButton(
onPressed: _isBusy ? null : () => applyPost(), onPressed: _isBusy ? null : () => _applyPost(),
child: Text( child: Obx(
_isDraft () => Text(
_editorController.isDraft.isTrue
? 'draftSave'.tr.toUpperCase() ? 'draftSave'.tr.toUpperCase()
: 'postAction'.tr.toUpperCase(), : 'postAction'.tr.toUpperCase(),
), ),
),
) )
], ],
), ),
body: Stack( body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ListView( ListTile(
children: [ tileColor: Theme.of(context).colorScheme.surfaceContainerLow,
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), title: Text(
if (widget.edit != null && widget.edit!.isDraft != true) _editorController.title ?? 'title'.tr,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
_editorController.description ?? 'description'.tr,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
contentPadding: const EdgeInsets.only(
left: 17,
right: 8,
top: 0,
bottom: 0,
),
trailing: IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
_editorController.editOverview(context).then((_) {
setState(() {});
});
},
),
),
if (_editTo != null && _editTo!.isDraft != true)
MaterialBanner( MaterialBanner(
leading: const Icon(Icons.edit), leading: const Icon(Icons.edit),
leadingPadding: const EdgeInsets.only(left: 10, right: 20), leadingPadding: const EdgeInsets.only(left: 10, right: 20),
@@ -161,7 +209,7 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
content: Text('postEditingNotify'.tr), content: Text('postEditingNotify'.tr),
actions: notifyBannerActions, actions: notifyBannerActions,
), ),
if (widget.reply != null) if (_replyTo != null)
ExpansionTile( ExpansionTile(
leading: const FaIcon( leading: const FaIcon(
FontAwesomeIcons.reply, FontAwesomeIcons.reply,
@@ -173,13 +221,18 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
collapsedBackgroundColor: collapsedBackgroundColor:
Theme.of(context).colorScheme.surfaceContainer, Theme.of(context).colorScheme.surfaceContainer,
children: [ children: [
PostItem( Container(
item: widget.reply!, constraints: const BoxConstraints(maxHeight: 280),
child: SingleChildScrollView(
child: PostItem(
item: _replyTo!,
isReactable: false, isReactable: false,
).paddingOnly(bottom: 8), ).paddingOnly(bottom: 8),
),
),
], ],
), ),
if (widget.repost != null) if (_repostTo != null)
ExpansionTile( ExpansionTile(
leading: const FaIcon( leading: const FaIcon(
FontAwesomeIcons.retweet, FontAwesomeIcons.retweet,
@@ -191,34 +244,38 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
collapsedBackgroundColor: collapsedBackgroundColor:
Theme.of(context).colorScheme.surfaceContainer, Theme.of(context).colorScheme.surfaceContainer,
children: [ children: [
PostItem( Container(
item: widget.repost!, constraints: const BoxConstraints(maxHeight: 280),
child: SingleChildScrollView(
child: PostItem(
item: _repostTo!,
isReactable: false, isReactable: false,
).paddingOnly(bottom: 8), ).paddingOnly(bottom: 8),
),
),
], ],
), ),
if (widget.realm != null) Expanded(
MaterialBanner( child: Row(
leading: const Icon(Icons.group), crossAxisAlignment: CrossAxisAlignment.start,
leadingPadding: const EdgeInsets.only(left: 10, right: 20), children: [
dividerColor: Colors.transparent, Expanded(
content: Text( child: ListView(
'postInRealmNotify' children: [
.trParams({'realm': '#${widget.realm!.alias}'}), if (_isBusy)
), const LinearProgressIndicator().animate().scaleX(),
actions: notifyBannerActions,
),
const Divider(thickness: 0.3, height: 0.3)
.paddingOnly(bottom: 8),
Container( Container(
padding: padding: const EdgeInsets.symmetric(
const EdgeInsets.symmetric(horizontal: 16, vertical: 8), horizontal: 16,
vertical: 8,
),
child: TextField( child: TextField(
maxLines: null, maxLines: null,
autofocus: true, autofocus: true,
autocorrect: true, autocorrect: true,
keyboardType: TextInputType.multiline, keyboardType: TextInputType.multiline,
controller: _contentController, controller: _editorController.contentController,
focusNode: _contentFocusNode,
decoration: InputDecoration.collapsed( decoration: InputDecoration.collapsed(
hintText: 'postContentPlaceholder'.tr, hintText: 'postContentPlaceholder'.tr,
), ),
@@ -229,62 +286,250 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
const SizedBox(height: 120) const SizedBox(height: 120)
], ],
), ),
Positioned( ),
bottom: 0, if (SolianTheme.isLargeScreen(context))
left: 0, const VerticalDivider(width: 0.3, thickness: 0.3)
right: 0, .paddingSymmetric(
child: Material( horizontal: 8,
elevation: 8, ),
if (SolianTheme.isLargeScreen(context))
Expanded(
child: SingleChildScrollView(
child: MarkdownTextContent(
content: _editorController.contentController.text,
parentId: 'post-editor-preview',
).paddingOnly(top: 8),
),
),
],
),
),
Material(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
TagsField( Obx(() {
initialTags: final textStyle = TextStyle(
widget.edit?.tags?.map((x) => x.alias).toList(), fontSize: 12,
tagsController: _tagsController, color: Theme.of(context)
hintText: 'postTagsPlaceholder'.tr, .colorScheme
.onSurface
.withOpacity(0.75),
);
final showFactors = [
_editorController.isRestoreFromLocal.value,
_editorController.lastSaveTime.value != null,
];
final doShow = showFactors.any((x) => x);
return Container(
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 16,
),
child: Row(
children: [
if (showFactors[0])
Text('postRestoreFromLocal'.tr, style: textStyle)
.paddingOnly(right: 4),
if (showFactors[0])
InkWell(
child: Text('clear'.tr, style: textStyle),
onTap: () {
_editorController.localClear();
_editorController.currentClear();
setState(() {});
},
),
if (showFactors.where((x) => x).length > 1)
Text(
'·',
style: textStyle,
).paddingSymmetric(horizontal: 8),
if (showFactors[1])
Text(
'postAutoSaveAt'.trParams({
'date': DateFormat('HH:mm:ss').format(
_editorController.lastSaveTime.value ??
DateTime.now(),
)
}),
style: textStyle,
),
],
),
)
.animate(
key: const Key('post-editor-hint-animation'),
target: doShow ? 1 : 0,
)
.fade(curve: Curves.easeInOut, duration: 300.ms);
}),
if (_editorController.mode.value == 0)
Obx(
() => TweenAnimationBuilder<double>(
tween: Tween(
begin: 0,
end: _editorController.contentLength.value / 4096,
),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
builder: (context, value, _) => LinearProgressIndicator(
minHeight: 2,
color: _editorController.contentLength.value > 4096
? Colors.red[900]
: Theme.of(context).colorScheme.primary,
value: value,
),
),
), ),
const Divider(thickness: 0.3, height: 0.3),
SizedBox( SizedBox(
height: 56, height: 56,
child: ListView( child: ListView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
children: [ children: [
IconButton( Obx(() {
icon: _isDraft final isDraft = _editorController.isDraft.value;
? const Icon(Icons.drive_file_rename_outline) return IconButton(
: const Icon(Icons.public), icon: const Icon(
color: _isDraft Icons.drive_file_rename_outline,
? Colors.grey.shade600 color: Colors.grey,
: Colors.green.shade700, )
.animate(
target: isDraft ? 0 : 1,
)
.fadeOut(duration: 150.ms)
.swap(
duration: 150.ms,
builder: (_, __) => const Icon(
Icons.public,
color: Colors.green,
).animate().fadeIn(duration: 150.ms),
),
onPressed: () { onPressed: () {
setState(() => _isDraft = !_isDraft); _editorController.toggleDraftMode();
},
);
}),
IconButton(
icon: const Icon(Icons.disabled_visible),
color: Theme.of(context).colorScheme.primary,
onPressed: () {
_editorController.editVisibility(context);
}, },
), ),
IconButton( IconButton(
icon: badges.Badge( icon: Obx(() {
return badges.Badge(
badgeContent: Text( badgeContent: Text(
_attachments.length.toString(), _editorController.attachments.length.toString(),
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
), ),
showBadge: _attachments.isNotEmpty, showBadge:
_editorController.attachments.isNotEmpty,
position: badges.BadgePosition.topEnd( position: badges.BadgePosition.topEnd(
top: -12, top: -12,
end: -8, end: -8,
), ),
child: const Icon(Icons.camera_alt), child: const Icon(Icons.file_present_rounded),
), );
}),
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
onPressed: () => showAttachments(), onPressed: () {
_editorController.editAttachment(context);
},
), ),
IconButton(
icon: Obx(() {
return badges.Badge(
badgeContent: Text(
_editorController.tags.length.toString(),
style: const TextStyle(color: Colors.white),
),
showBadge: _editorController.tags.isNotEmpty,
position: badges.BadgePosition.topEnd(
top: -12,
end: -8,
),
child: const Icon(Icons.label),
);
}),
color: Theme.of(context).colorScheme.primary,
onPressed: () {
_editorController.editCategoriesAndTags(context);
},
),
IconButton(
icon: Obx(() {
return badges.Badge(
showBadge:
_editorController.realmZone.value != null,
position: badges.BadgePosition.topEnd(
top: -4,
end: -6,
),
child: const Icon(Icons.workspaces),
);
}),
color: Theme.of(context).colorScheme.primary,
onPressed: () {
_editorController.editPublishZone(context);
},
),
IconButton(
icon: Obx(() {
return badges.Badge(
showBadge:
_editorController.thumbnail.value != null,
position: badges.BadgePosition.topEnd(
top: -4,
end: -6,
),
child: const Icon(Icons.preview),
);
}),
color: Theme.of(context).colorScheme.primary,
onPressed: () {
_editorController.editThumbnail(context);
},
),
IconButton(
icon: Obx(() {
return badges.Badge(
showBadge:
_editorController.publishedAt.value != null ||
_editorController.publishedUntil.value !=
null,
position: badges.BadgePosition.topEnd(
top: -4,
end: -6,
),
child: const Icon(Icons.schedule),
);
}),
color: Theme.of(context).colorScheme.primary,
onPressed: () {
_editorController.editPublishDate(context);
},
),
MarkdownToolbar(
hideImage: true,
useIncludedTextField: false,
backgroundColor:
Theme.of(context).colorScheme.surface,
iconColor: Theme.of(context).colorScheme.onSurface,
controller: _editorController.contentController,
focusNode: _contentFocusNode,
borderRadius:
const BorderRadius.all(Radius.circular(20)),
width: 40,
).paddingSymmetric(horizontal: 2),
], ],
).paddingSymmetric(horizontal: 6, vertical: 8), ).paddingSymmetric(horizontal: 6, vertical: 8),
), ),
], ],
).paddingOnly(bottom: MediaQuery.of(context).padding.bottom), ).paddingOnly(bottom: MediaQuery.of(context).padding.bottom),
), ),
),
], ],
), ),
), ),
@@ -293,8 +538,8 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
@override @override
void dispose() { void dispose() {
_contentController.dispose(); _contentFocusNode.dispose();
_tagsController.dispose(); _editorController.dispose();
super.dispose(); super.dispose();
} }
} }

View File

@@ -26,7 +26,7 @@ class _RealmListScreenState extends State<RealmListScreen> {
final List<Realm> _realms = List.empty(growable: true); final List<Realm> _realms = List.empty(growable: true);
getRealms() async { _getRealms() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
@@ -48,7 +48,7 @@ class _RealmListScreenState extends State<RealmListScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
getRealms(); _getRealms();
} }
@override @override
@@ -71,7 +71,7 @@ class _RealmListScreenState extends State<RealmListScreen> {
onPressed: () { onPressed: () {
AppRouter.instance.pushNamed('realmOrganizing').then( AppRouter.instance.pushNamed('realmOrganizing').then(
(value) { (value) {
if (value != null) getRealms(); if (value != null) _getRealms();
}, },
); );
}, },
@@ -84,7 +84,7 @@ class _RealmListScreenState extends State<RealmListScreen> {
body: Obx(() { body: Obx(() {
if (auth.isAuthorized.isFalse) { if (auth.isAuthorized.isFalse) {
return SigninRequiredOverlay( return SigninRequiredOverlay(
onSignedIn: () => getRealms(), onSignedIn: () => _getRealms(),
); );
} }
@@ -94,12 +94,12 @@ class _RealmListScreenState extends State<RealmListScreen> {
Expanded( Expanded(
child: CenteredContainer( child: CenteredContainer(
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () => getRealms(), onRefresh: () => _getRealms(),
child: ListView.builder( child: ListView.builder(
itemCount: _realms.length, itemCount: _realms.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final element = _realms[index]; final element = _realms[index];
return buildRealm(element); return _buildEntry(element);
}, },
), ),
), ),
@@ -112,7 +112,7 @@ class _RealmListScreenState extends State<RealmListScreen> {
); );
} }
Widget buildRealm(Realm element) { Widget _buildEntry(Realm element) {
return Card( return Card(
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),

View File

@@ -12,7 +12,6 @@ import 'package:solian/providers/content/posts.dart';
import 'package:solian/providers/content/realm.dart'; import 'package:solian/providers/content/realm.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/screens/channel/channel_organize.dart'; import 'package:solian/screens/channel/channel_organize.dart';
import 'package:solian/screens/posts/post_editor.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/channel/channel_list.dart'; import 'package:solian/widgets/channel/channel_list.dart';
@@ -136,6 +135,7 @@ class _RealmViewScreenState extends State<RealmViewScreen> {
} }
return TabBarView( return TabBarView(
physics: const NeverScrollableScrollPhysics(),
children: [ children: [
RealmPostListWidget(realm: _realm!), RealmPostListWidget(realm: _realm!),
RealmChannelListWidget( RealmChannelListWidget(
@@ -189,7 +189,6 @@ class _RealmPostListWidgetState extends State<RealmPostListWidget> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_pagingController.addPageRequestListener(getPosts); _pagingController.addPageRequestListener(getPosts);
} }
@@ -199,28 +198,6 @@ class _RealmPostListWidgetState extends State<RealmPostListWidget> {
onRefresh: () => Future.sync(() => _pagingController.refresh()), onRefresh: () => Future.sync(() => _pagingController.refresh()),
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
SliverToBoxAdapter(
child: ListTile(
leading: const Icon(Icons.post_add),
contentPadding: const EdgeInsets.only(left: 24, right: 8),
tileColor: Theme.of(context).colorScheme.surfaceContainer,
title: Text('postNew'.tr),
subtitle: Text(
'postNewInRealmHint'
.trParams({'realm': '#${widget.realm.alias}'}),
),
onTap: () {
AppRouter.instance
.pushNamed(
'postEditor',
extra: PostPublishArguments(realm: widget.realm),
)
.then((value) {
if (value != null) _pagingController.refresh();
});
},
),
),
PostListWidget(controller: _pagingController), PostListWidget(controller: _pagingController),
], ],
), ),

115
lib/screens/settings.dart Normal file
View File

@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:solian/controllers/chat_events_controller.dart';
import 'package:solian/exts.dart';
import 'package:solian/providers/theme_switcher.dart';
import 'package:solian/router.dart';
import 'package:solian/theme.dart';
class SettingScreen extends StatefulWidget {
const SettingScreen({super.key});
@override
State<SettingScreen> createState() => _SettingScreenState();
}
class _SettingScreenState extends State<SettingScreen> {
late final SharedPreferences _prefs;
Widget _buildCaptionHeader(String title) {
return Container(
width: MediaQuery.of(context).size.width,
color: Theme.of(context).colorScheme.surfaceContainer,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Text(title),
);
}
Widget _buildThemeColorButton(String label, Color color) {
return IconButton(
icon: Icon(Icons.circle, color: color),
tooltip: label,
onPressed: () {
context.read<ThemeSwitcher>().setTheme(
SolianTheme.build(
Brightness.light,
seedColor: color,
),
SolianTheme.build(
Brightness.dark,
seedColor: color,
),
);
_prefs.setInt('global_theme_color', color.value);
context.clearSnackbar();
context.showSnackbar('themeColorApplied'.tr);
},
);
}
static final List<(String, Color)> _presentTheme = [
('themeColorRed', const Color.fromRGBO(154, 98, 91, 1)),
('themeColorBlue', const Color.fromRGBO(103, 96, 193, 1)),
('themeColorMiku', const Color.fromRGBO(56, 120, 126, 1)),
('themeColorKagamine', const Color.fromRGBO(244, 183, 63, 1)),
('themeColorLuka', const Color.fromRGBO(243, 174, 218, 1)),
];
@override
void initState() {
super.initState();
SharedPreferences.getInstance().then((inst) {
_prefs = inst;
});
}
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.surface,
child: ListView(
children: [
_buildCaptionHeader('themeColor'.tr),
SizedBox(
height: 56,
child: ListView(
scrollDirection: Axis.horizontal,
children: _presentTheme
.map((x) => _buildThemeColorButton(x.$1, x.$2))
.toList(),
).paddingSymmetric(horizontal: 12, vertical: 8),
),
_buildCaptionHeader('more'.tr),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
TextButton(
style: const ButtonStyle(
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
),
child: Text('about'.tr),
onPressed: () {
AppRouter.instance.pushNamed('about');
},
),
TextButton(
style: const ButtonStyle(
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
),
child: Text('messageHistoryWipe'.tr),
onPressed: () {
final chatHistory = ChatEventController();
chatHistory.initialize().then((_) async {
await chatHistory.database.localEvents.wipeLocalEvents();
});
},
),
],
).paddingSymmetric(horizontal: 12, vertical: 8),
],
),
);
}
}

View File

@@ -5,17 +5,17 @@ abstract class ServiceFinder {
static const String dealerUrl = static const String dealerUrl =
devFlag ? 'http://localhost:8442' : 'https://api.sn.solsynth.dev'; devFlag ? 'http://localhost:8442' : 'https://api.sn.solsynth.dev';
static const String passportUrl = static const String capitalUrl =
devFlag ? 'http://localhost:8444' : 'https://id.solsynth.dev'; devFlag ? 'http://localhost:8444' : 'https://solsynth.dev';
static String buildUrl(String serviceName, String? append) { static String buildUrl(String serviceName, String? append) {
append ??= ''; append ??= '';
if (serviceName == 'dealer') { if (serviceName == 'dealer') {
return '$dealerUrl$append'; return '$dealerUrl$append';
} else if (serviceName == 'passport') { } else if (serviceName == 'capital') {
return '$passportUrl$append'; return '$capitalUrl$append';
} }
return '$dealerUrl/srv/$serviceName$append'; return '$dealerUrl/cgi/$serviceName$append';
} }
static GetConnect configureClient(String serviceName, static GetConnect configureClient(String serviceName,

View File

@@ -8,13 +8,15 @@ import 'package:solian/widgets/app_bar_leading.dart';
class TitleShell extends StatelessWidget { class TitleShell extends StatelessWidget {
final bool showAppBar; final bool showAppBar;
final bool isCenteredTitle; final bool isCenteredTitle;
final GoRouterState state; final String? title;
final GoRouterState? state;
final Widget child; final Widget child;
const TitleShell({ const TitleShell({
super.key, super.key,
required this.child, required this.child,
required this.state, this.title,
this.state,
this.showAppBar = true, this.showAppBar = true,
this.isCenteredTitle = false, this.isCenteredTitle = false,
}); });
@@ -25,7 +27,9 @@ class TitleShell extends StatelessWidget {
appBar: showAppBar appBar: showAppBar
? AppBar( ? AppBar(
leading: AppBarLeadingButton.adaptive(context), leading: AppBarLeadingButton.adaptive(context),
title: AppBarTitle(state.topRoute?.name?.tr ?? 'page'.tr), title: AppBarTitle(
title ?? (state!.topRoute?.name?.tr ?? 'page'.tr),
),
centerTitle: isCenteredTitle, centerTitle: isCenteredTitle,
toolbarHeight: SolianTheme.toolbarHeight(context), toolbarHeight: SolianTheme.toolbarHeight(context),
) )

View File

@@ -27,12 +27,26 @@ abstract class SolianTheme {
} }
} }
static ThemeData build(Brightness brightness) { static ThemeData build(Brightness brightness, {Color? seedColor}) {
return ThemeData( return ThemeData(
brightness: brightness, brightness: brightness,
useMaterial3: true, useMaterial3: true,
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
brightness: brightness, seedColor: Colors.indigo), brightness: brightness,
seedColor: seedColor ?? const Color.fromRGBO(154, 98, 91, 1),
),
fontFamily: 'Comfortaa',
fontFamilyFallback: [
'NotoSansSC',
'NotoSansHK',
'NotoSansJP',
if (PlatformInfo.isWeb) 'NotoSansEmoji',
],
typography: Typography.material2021(
colorScheme: brightness == Brightness.light
? const ColorScheme.light()
: const ColorScheme.dark(),
),
); );
} }
} }

View File

@@ -5,7 +5,7 @@ import 'package:solian/translations/zh_cn.dart';
class SolianMessages extends Translations { class SolianMessages extends Translations {
@override @override
Map<String, Map<String, String>> get keys => { Map<String, Map<String, String>> get keys => {
'en_US': messagesEnglish, 'en_US': i18nEnglish,
'zh_CN': simplifiedChineseMessages, 'zh_CN': i18nSimplifiedChinese,
}; };
} }

View File

@@ -1,4 +1,4 @@
const messagesEnglish = { const i18nEnglish = {
'done': 'Done', 'done': 'Done',
'hide': 'Hide', 'hide': 'Hide',
'okay': 'Okay', 'okay': 'Okay',
@@ -10,10 +10,17 @@ const messagesEnglish = {
'draft': 'Draft', 'draft': 'Draft',
'draftSave': 'Save', 'draftSave': 'Save',
'draftBox': 'Draft Box', 'draftBox': 'Draft Box',
'more': 'More',
'share': 'Share',
'shareNoUri': 'Share text content',
'feed': 'Feed', 'feed': 'Feed',
'unlink': 'Unlink',
'feedSearch': 'Search Feed', 'feedSearch': 'Search Feed',
'feedSearchWithTag': 'Searching with tag #@key', 'feedSearchWithTag': 'Searching with tag #@key',
'feedSearchWithCategory': 'Searching in category @category', 'feedSearchWithCategory': 'Searching in category @category',
'visitProfilePage': 'Visit Profile Page',
'profilePosts': 'Posts',
'profileAlbum': 'Album',
'chat': 'Chat', 'chat': 'Chat',
'apply': 'Apply', 'apply': 'Apply',
'cancel': 'Cancel', 'cancel': 'Cancel',
@@ -23,11 +30,13 @@ const messagesEnglish = {
'about': 'About', 'about': 'About',
'edit': 'Edit', 'edit': 'Edit',
'delete': 'Delete', 'delete': 'Delete',
'settings': 'Settings',
'search': 'Search', 'search': 'Search',
'post': 'Post', 'post': 'Post',
'article': 'Article', 'article': 'Article',
'reply': 'Reply', 'reply': 'Reply',
'repost': 'Repost', 'repost': 'Repost',
'openInAlbum': 'Open in album',
'openInBrowser': 'Open in browser', 'openInBrowser': 'Open in browser',
'notification': 'Notification', 'notification': 'Notification',
'errorHappened': 'An error occurred', 'errorHappened': 'An error occurred',
@@ -36,6 +45,7 @@ const messagesEnglish = {
'username': 'Username', 'username': 'Username',
'nickname': 'Nickname', 'nickname': 'Nickname',
'password': 'Password', 'password': 'Password',
'title': 'Title',
'description': 'Description', 'description': 'Description',
'birthday': 'Birthday', 'birthday': 'Birthday',
'firstName': 'First Name', 'firstName': 'First Name',
@@ -43,6 +53,7 @@ const messagesEnglish = {
'account': 'Account', 'account': 'Account',
'accountPersonalize': 'Personalize', 'accountPersonalize': 'Personalize',
'accountPersonalizeApplied': 'Account personalize settings has been saved.', 'accountPersonalizeApplied': 'Account personalize settings has been saved.',
'accountStickers': 'Stickers',
'accountFriend': 'Friend', 'accountFriend': 'Friend',
'accountFriendNew': 'New friend', 'accountFriendNew': 'New friend',
'accountFriendNewHint': 'accountFriendNewHint':
@@ -50,6 +61,7 @@ const messagesEnglish = {
'accountFriendPending': 'Friend requests', 'accountFriendPending': 'Friend requests',
'accountFriendBlocked': 'Friend blocklist', 'accountFriendBlocked': 'Friend blocklist',
'accountFriendListHint': 'Swipe left to decline, right to approve', 'accountFriendListHint': 'Swipe left to decline, right to approve',
'accountFriendRequestSent': 'Friend request sent, waiting for processing...',
'accountSuspended': 'Account was suspended', 'accountSuspended': 'Account was suspended',
'accountSuspendedAt': 'Account was suspended since @date', 'accountSuspendedAt': 'Account was suspended since @date',
'aspectRatio': 'Aspect Ratio', 'aspectRatio': 'Aspect Ratio',
@@ -83,9 +95,39 @@ const messagesEnglish = {
'notifyAllRead': 'Mark all as read', 'notifyAllRead': 'Mark all as read',
'notifyEmpty': 'All notifications read', 'notifyEmpty': 'All notifications read',
'notifyEmptyCaption': 'It seems like nothing happened recently', 'notifyEmptyCaption': 'It seems like nothing happened recently',
'totalSocialCreditPoints': 'Social Credit Points',
'totalPostCount': 'Posts',
'totalUpvote': 'Upvote',
'totalDownvote': 'Downvote',
'clear': 'Clear',
'pinPost': 'Pin this post',
'unpinPost': 'Unpin this post',
'postRestoreFromLocal': 'Restore from local',
'postAutoSaveAt': 'Auto saved at @date',
'postCategoriesAndTags': 'Categories n\' Tags',
'postPublishDate': 'Publish Date',
'postPublishAt': 'Publish At',
'postPublishedUntil': 'Publish Until',
'postPublishZone': 'Publish Zone',
'postPublishZoneNone': 'None',
'postVisibility': 'Visibility',
'postVisibilityAll': 'Everyone',
'postVisibilityFriends': 'Friends',
'postVisibilitySelected': 'Selected visible',
'postVisibilityFiltered': 'Selected invisible',
'postVisibilityNone': 'Only me',
'postVisibleUsers': 'Visible users',
'postInvisibleUsers': 'Invisible users',
'postOverview': 'Overview',
'postThumbnail': 'Thumbnail',
'postThumbnailAttachmentNew': 'Upload thumbnail',
'postThumbnailAttachment': 'Attachment serial number',
'postPinned': 'Pinned',
'postListNews': 'News', 'postListNews': 'News',
'postListShuffle': 'Random', 'postListShuffle': 'Random',
'postEditor': 'Create new post', 'postEditorModeStory': 'Post a post',
'postEditorModeArticle': 'Post an article',
'postEditor': 'Post editor',
'articleEditor': 'Create new article', 'articleEditor': 'Create new article',
'articleDetail': 'Article details', 'articleDetail': 'Article details',
'draftBoxOpen': 'Open draft box', 'draftBoxOpen': 'Open draft box',
@@ -94,7 +136,7 @@ const messagesEnglish = {
'postAction': 'Post', 'postAction': 'Post',
'postEdited': 'Edited at @date', 'postEdited': 'Edited at @date',
'postNewCreated': 'Created at @date', 'postNewCreated': 'Created at @date',
'postAttachmentTip': '@count attachment(s)', 'attachmentHint': '@count attachment(s)',
'postInRealm': 'In @realm', 'postInRealm': 'In @realm',
'postDetail': 'Post', 'postDetail': 'Post',
'postReplies': 'Replies', 'postReplies': 'Replies',
@@ -121,6 +163,15 @@ const messagesEnglish = {
'reactAdd': 'React', 'reactAdd': 'React',
'reactCompleted': 'Your reaction has been added', 'reactCompleted': 'Your reaction has been added',
'reactUncompleted': 'Your reaction has been removed', 'reactUncompleted': 'Your reaction has been removed',
'attachmentUploadBy': 'Upload by',
'attachmentAutoUpload': 'Auto Upload',
'attachmentUploadQueue': 'Upload Queue',
'attachmentUploadQueueStart': 'Start All',
'attachmentUploadInProgress':
'There are attachments being uploaded. Please wait until all attachments have been uploaded before proceeding...',
'attachmentAttached': 'Exists Files',
'attachmentUploadBlocked':
'Upload blocked, there is currently a task in progress...',
'attachmentAdd': 'Attach attachments', 'attachmentAdd': 'Attach attachments',
'attachmentAddGalleryPhoto': 'Gallery photo', 'attachmentAddGalleryPhoto': 'Gallery photo',
'attachmentAddGalleryVideo': 'Gallery video', 'attachmentAddGalleryVideo': 'Gallery video',
@@ -128,6 +179,10 @@ const messagesEnglish = {
'attachmentAddCameraVideo': 'Capture video', 'attachmentAddCameraVideo': 'Capture video',
'attachmentAddClipboard': 'Paste file', 'attachmentAddClipboard': 'Paste file',
'attachmentAddFile': 'Attach file', 'attachmentAddFile': 'Attach file',
'attachmentAddLink': 'Link attachments',
'attachmentAddLinkHint':
'Enter attachment serial number to link that attachment',
'attachmentAddLinkInput': 'Serial number',
'attachmentSetting': 'Adjust attachment', 'attachmentSetting': 'Adjust attachment',
'attachmentAlt': 'Alternative text', 'attachmentAlt': 'Alternative text',
'attachmentLoadFailed': 'Load Attachment Failed', 'attachmentLoadFailed': 'Load Attachment Failed',
@@ -210,6 +265,8 @@ const messagesEnglish = {
'Are your sure to delete message @id? This action cannot be undone!', 'Are your sure to delete message @id? This action cannot be undone!',
'call': 'Call', 'call': 'Call',
'callOngoing': 'A call is ongoing...', 'callOngoing': 'A call is ongoing...',
'callOngoingEmpty': 'A call is on hold...',
'callOngoingParticipants': '@count people are calling...',
'callJoin': 'Join', 'callJoin': 'Join',
'callResume': 'Resume', 'callResume': 'Resume',
'callMicrophone': 'Microphone', 'callMicrophone': 'Microphone',
@@ -264,4 +321,60 @@ const messagesEnglish = {
'accountStatusNegative': 'Negative', 'accountStatusNegative': 'Negative',
'accountStatusNeutral': 'Neutral', 'accountStatusNeutral': 'Neutral',
'accountStatusPositive': 'Positive', 'accountStatusPositive': 'Positive',
'bsLoadingTheme': 'Loading Theme',
'bsCheckForUpdate': 'Checking For Updates',
'bsCheckForUpdateFailed': 'Unable to Check Updates',
'bsCheckForUpdateNew': 'Found New Version',
'bsCheckForUpdateDescApple':
'Please head to TestFlight and update your app to latest version to prevent error happens and get latest functions.',
'bsCheckForUpdateDescCommon':
'Please head to our website download and install latest version of application to prevent error happens and get latest functions.',
'bsCheckingServer': 'Checking Server Status',
'bsCheckingServerFail':
'Unable connect to server, check your network connection',
'bsCheckingServerDown': 'Server currently unavailable, please retry later',
'bsAuthorizing': 'Authorizing',
'bsEstablishingConn': 'Establishing Connection',
'bsPreparingData': 'Preparing User Data',
'bsRegisteringPushNotify': 'Enabling Push Notifications',
'bsDismissibleErrorHint': 'Click anywhere to ignore this error',
'postShareContent':
'@content\n\n@username on the Solar Network\nCheck it out: @link',
'postShareSubject': '@username posted a post on the Solar Network',
'themeColor': 'Global Theme Color',
'themeColorRed': 'Modern Red',
'themeColorBlue': 'Classic Blue',
'themeColorMiku': 'Miku Blue',
'themeColorKagamine': 'Kagamine Yellow',
'themeColorLuka': 'Luka Pink',
'stickerDeletionConfirm': 'Confirm sticker delete',
'stickerDeletionConfirmCaption':
'Are you sure to delete sticker @name? This action cannot be undo.',
'themeColorApplied': 'Global theme color has been applied.',
'attachmentSaved': 'Attachment saved to your system album.',
'cropImage': 'Crop Image',
'stickerUploader': 'Upload sticker',
'stickerUploaderAttachmentNew': 'Upload sticker',
'stickerUploaderAttachment': 'Attachment serial number',
'stickerUploaderPack': 'Sticker pack serial number',
'stickerUploaderPackHint':
'Don\'t have pack id? Head to creator platform and create one!',
'stickerUploaderAlias': 'Alias',
'stickerUploaderAliasHint':
'Will be used as a placeholder with the sticker pack prefix when entered.',
'stickerUploaderName': 'Name',
'stickerUploaderNameHint':
'A human-friendly name given to the user in the sticker selection interface.',
'readMore': 'Read more',
'attachmentUnload': 'Not Loaded',
'attachmentUnloadCaption':
'In order to save traffic, this attachment is not loaded automatically. Click it to start loading.',
'callStatusConnected': 'Connected',
'callStatusDisconnected': 'Disconnected',
'callStatusConnecting': 'Connecting',
'callStatusReconnected': 'Reconnecting',
'messageOutOfSync': 'May Out of Sync with Server',
'messageOutOfSyncCaption':
'Since the App has entered the background, there may be a time difference between the message list and the server. Click to Refresh.',
'messageHistoryWipe': 'Wipe local message history',
}; };

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