Compare commits

...

133 Commits

Author SHA1 Message Date
cc1e0599aa 🐛 Fix link expand match markdown link 2024-08-21 10:06:05 +08:00
221b97901f 💄 Optimize uploader 2024-08-21 10:01:09 +08:00
498bb0e5fb Run upload chunks at the same time (max 3) 2024-08-21 09:33:34 +08:00
aa94dfcfe0 Multipart upload 2024-08-21 01:53:16 +08:00
65d9253876 🐛 Fix svg site icon cause invalid image data 2024-08-21 00:48:51 +08:00
3ac510c4b1 🐛 Bug fixes 2024-08-20 01:19:18 +08:00
253cd1ecbd Call in same screen on large screen 2024-08-20 01:10:15 +08:00
c82c48dfec 🐛 Fix attachments padding 2024-08-19 22:45:22 +08:00
433beec2dd 💄 Optimize large screen ux 2024-08-19 22:38:36 +08:00
3a1e7537dd 🐛 Fix alignment issue 2024-08-19 22:25:49 +08:00
9170ae6be7 💄 Line up attachments & expansion of link 2024-08-19 22:25:17 +08:00
a5ee5b7f09 💄 Better attachment layout 2024-08-19 22:13:25 +08:00
32e6658f3d Better link expand layout on large screen 2024-08-19 20:13:08 +08:00
e45d9b39d5 Post link expand
 Cache link expansion image
2024-08-19 19:56:44 +08:00
cf1cfecb08 Link expand 2024-08-19 19:36:01 +08:00
95ea3e558f 🚀 Launch 1.2.1+18 2024-08-19 09:43:25 +08:00
0006a94632 🐛 Fix local db old data cause crash 2024-08-19 09:19:29 +08:00
7ea18dbe12 💄 Update styles 2024-08-19 01:54:32 +08:00
6004b74724 🚀 Launch 1.2.1+17 2024-08-19 01:35:57 +08:00
4d82ae8058 🐛 Bug fixes
⬆️ Add firebase performance
2024-08-19 01:35:38 +08:00
7fe26d0df0 🚀 Launch 1.2.1+16 2024-08-19 00:33:20 +08:00
80bade0e03 View posts posted by friends 2024-08-19 00:33:03 +08:00
b63db7fe76 👽 Support use realm alias instead of id 2024-08-19 00:14:09 +08:00
49f73f5f04 ⬆️ Support new attachments system 2024-08-18 22:51:52 +08:00
98749f42c0 ⬆️ Upgrade deps 2024-08-17 19:18:51 +08:00
f0e6bd64f4 ♻️ Refactor video player 2024-08-17 19:02:57 +08:00
3bea3a114a Post alias 2024-08-17 18:44:20 +08:00
454f711656 ⬆️ Upgrade deps 2024-08-16 23:27:38 +08:00
82e4c923e7 📈 Simple log user share 2024-08-16 23:08:05 +08:00
5b4d8282ae Re-google (firebase) 2024-08-16 22:59:34 +08:00
cf767a1d94 💄 Optimized post editor 2024-08-16 21:06:50 +08:00
af93a8386a ⬆️ Upgrade deps 2024-08-16 01:05:21 +08:00
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
156 changed files with 7166 additions and 2445 deletions

View File

@@ -16,7 +16,7 @@ jobs:
channel: stable
cache: true
- run: flutter pub get
- run: flutter build web
- run: flutter build web --release --base-href=/
- name: Archive production artifacts
uses: actions/upload-artifact@v4
with:

View File

@@ -1,6 +1,7 @@
plugins {
id "com.android.application"
id 'com.google.gms.google-services'
id 'com.google.firebase.crashlytics'
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}

View File

@@ -70,6 +70,12 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</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.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

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: 1.5 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 8.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: 6.8 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -12,6 +12,17 @@ subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
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")
}

View File

@@ -20,6 +20,7 @@ plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.4.0' apply false
id "com.google.gms.google-services" version "4.3.15" apply false
id "com.google.firebase.crashlytics" version "2.8.1" apply false
id "org.jetbrains.kotlin.android" version '2.0.0' apply false
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 360 KiB

View File

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

View File

@@ -38,24 +38,78 @@ PODS:
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Firebase/Analytics (10.29.0):
- Firebase/Core
- Firebase/Core (10.29.0):
- Firebase/CoreOnly
- FirebaseAnalytics (~> 10.29.0)
- Firebase/CoreOnly (10.29.0):
- FirebaseCore (= 10.29.0)
- Firebase/Crashlytics (10.29.0):
- Firebase/CoreOnly
- FirebaseCrashlytics (~> 10.29.0)
- Firebase/Messaging (10.29.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 10.29.0)
- Firebase/Performance (10.29.0):
- Firebase/CoreOnly
- FirebasePerformance (~> 10.29.0)
- firebase_analytics (11.2.1):
- Firebase/Analytics (= 10.29.0)
- firebase_core
- Flutter
- firebase_core (3.3.0):
- Firebase/CoreOnly (= 10.29.0)
- Flutter
- firebase_crashlytics (4.0.4):
- Firebase/Crashlytics (= 10.29.0)
- firebase_core
- Flutter
- firebase_messaging (15.0.4):
- Firebase/Messaging (= 10.29.0)
- firebase_core
- Flutter
- firebase_performance (0.10.0-4):
- Firebase/Performance (= 10.29.0)
- firebase_core
- Flutter
- FirebaseABTesting (10.29.0):
- FirebaseCore (~> 10.0)
- FirebaseAnalytics (10.29.0):
- FirebaseAnalytics/AdIdSupport (= 10.29.0)
- FirebaseCore (~> 10.0)
- FirebaseInstallations (~> 10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
- GoogleUtilities/MethodSwizzler (~> 7.11)
- GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30911.0, >= 2.30908.0)
- FirebaseAnalytics/AdIdSupport (10.29.0):
- FirebaseCore (~> 10.0)
- FirebaseInstallations (~> 10.0)
- GoogleAppMeasurement (= 10.29.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
- GoogleUtilities/MethodSwizzler (~> 7.11)
- GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30911.0, >= 2.30908.0)
- FirebaseCore (10.29.0):
- FirebaseCoreInternal (~> 10.0)
- GoogleUtilities/Environment (~> 7.12)
- GoogleUtilities/Logger (~> 7.12)
- FirebaseCoreExtension (10.29.0):
- FirebaseCore (~> 10.0)
- FirebaseCoreInternal (10.29.0):
- "GoogleUtilities/NSData+zlib (~> 7.8)"
- FirebaseCrashlytics (10.29.0):
- FirebaseCore (~> 10.5)
- FirebaseInstallations (~> 10.0)
- FirebaseRemoteConfigInterop (~> 10.23)
- FirebaseSessions (~> 10.5)
- GoogleDataTransport (~> 9.2)
- GoogleUtilities/Environment (~> 7.8)
- nanopb (< 2.30911.0, >= 2.30908.0)
- PromisesObjC (~> 2.1)
- FirebaseInstallations (10.29.0):
- FirebaseCore (~> 10.0)
- GoogleUtilities/Environment (~> 7.8)
@@ -70,12 +124,67 @@ PODS:
- GoogleUtilities/Reachability (~> 7.8)
- GoogleUtilities/UserDefaults (~> 7.8)
- nanopb (< 2.30911.0, >= 2.30908.0)
- FirebasePerformance (10.29.0):
- FirebaseCore (~> 10.5)
- FirebaseInstallations (~> 10.0)
- FirebaseRemoteConfig (~> 10.0)
- FirebaseSessions (~> 10.5)
- GoogleDataTransport (~> 9.2)
- GoogleUtilities/Environment (~> 7.13)
- GoogleUtilities/ISASwizzler (~> 7.13)
- GoogleUtilities/MethodSwizzler (~> 7.13)
- GoogleUtilities/UserDefaults (~> 7.13)
- nanopb (< 2.30911.0, >= 2.30908.0)
- FirebaseRemoteConfig (10.29.0):
- FirebaseABTesting (~> 10.0)
- FirebaseCore (~> 10.0)
- FirebaseInstallations (~> 10.0)
- FirebaseRemoteConfigInterop (~> 10.23)
- FirebaseSharedSwift (~> 10.0)
- GoogleUtilities/Environment (~> 7.8)
- "GoogleUtilities/NSData+zlib (~> 7.8)"
- FirebaseRemoteConfigInterop (10.29.0)
- FirebaseSessions (10.29.0):
- FirebaseCore (~> 10.5)
- FirebaseCoreExtension (~> 10.0)
- FirebaseInstallations (~> 10.0)
- GoogleDataTransport (~> 9.2)
- GoogleUtilities/Environment (~> 7.13)
- GoogleUtilities/UserDefaults (~> 7.13)
- nanopb (< 2.30911.0, >= 2.30908.0)
- PromisesSwift (~> 2.1)
- FirebaseSharedSwift (10.29.0)
- Flutter (1.0.0)
- flutter_keyboard_visibility (0.0.1):
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- flutter_webrtc (0.11.3):
- Flutter
- WebRTC-SDK (= 125.6422.04)
- gal (1.0.0):
- Flutter
- FlutterMacOS
- GoogleAppMeasurement (10.29.0):
- GoogleAppMeasurement/AdIdSupport (= 10.29.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
- GoogleUtilities/MethodSwizzler (~> 7.11)
- GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30911.0, >= 2.30908.0)
- GoogleAppMeasurement/AdIdSupport (10.29.0):
- GoogleAppMeasurement/WithoutAdIdSupport (= 10.29.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
- GoogleUtilities/MethodSwizzler (~> 7.11)
- GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30911.0, >= 2.30908.0)
- GoogleAppMeasurement/WithoutAdIdSupport (10.29.0):
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
- GoogleUtilities/MethodSwizzler (~> 7.11)
- GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30911.0, >= 2.30908.0)
- GoogleDataTransport (9.4.1):
- GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30911.0, >= 2.30908.0)
@@ -88,9 +197,14 @@ PODS:
- GoogleUtilities/Environment (7.13.3):
- GoogleUtilities/Privacy
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/ISASwizzler (7.13.3):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (7.13.3):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/MethodSwizzler (7.13.3):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/Network (7.13.3):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
@@ -105,9 +219,12 @@ PODS:
- GoogleUtilities/UserDefaults (7.13.3):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- image_cropper (0.0.4):
- Flutter
- TOCropViewController (~> 2.7.4)
- image_picker_ios (0.0.1):
- Flutter
- livekit_client (2.2.2):
- livekit_client (2.2.4):
- Flutter
- WebRTC-SDK (= 125.6422.04)
- media_kit_libs_ios_video (1.0.4):
@@ -130,19 +247,18 @@ PODS:
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- pointer_interceptor_ios (0.0.1):
- Flutter
- PromisesObjC (2.4.0)
- PromisesSwift (2.4.0):
- PromisesObjC (= 2.4.0)
- protocol_handler_ios (0.0.1):
- Flutter
- screen_brightness_ios (0.1.0):
- Flutter
- SDWebImage (5.19.4):
- SDWebImage/Core (= 5.19.4)
- SDWebImage/Core (5.19.4)
- Sentry/HybridSDK (8.32.0)
- sentry_flutter (8.5.0):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.32.0)
- SDWebImage (5.19.6):
- SDWebImage/Core (= 5.19.6)
- SDWebImage/Core (5.19.6)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
@@ -152,6 +268,7 @@ PODS:
- Flutter
- FlutterMacOS
- SwiftyGif (5.4.5)
- TOCropViewController (2.7.4)
- url_launcher_ios (0.0.1):
- Flutter
- volume_controller (0.0.1):
@@ -164,11 +281,17 @@ DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- firebase_performance (from `.symlinks/plugins/firebase_performance/ios`)
- Flutter (from `Flutter`)
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/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`)
- livekit_client (from `.symlinks/plugins/livekit_client/ios`)
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
@@ -178,9 +301,9 @@ DEPENDENCIES:
- pasteboard (from `.symlinks/plugins/pasteboard/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`)
- protocol_handler_ios (from `.symlinks/plugins/protocol_handler_ios/ios`)
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
@@ -193,17 +316,28 @@ SPEC REPOS:
- DKImagePickerController
- DKPhotoGallery
- Firebase
- FirebaseABTesting
- FirebaseAnalytics
- FirebaseCore
- FirebaseCoreExtension
- FirebaseCoreInternal
- FirebaseCrashlytics
- FirebaseInstallations
- FirebaseMessaging
- FirebasePerformance
- FirebaseRemoteConfig
- FirebaseRemoteConfigInterop
- FirebaseSessions
- FirebaseSharedSwift
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleUtilities
- nanopb
- PromisesObjC
- PromisesSwift
- SDWebImage
- Sentry
- SwiftyGif
- TOCropViewController
- WebRTC-SDK
EXTERNAL SOURCES:
@@ -213,16 +347,28 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
firebase_analytics:
:path: ".symlinks/plugins/firebase_analytics/ios"
firebase_core:
:path: ".symlinks/plugins/firebase_core/ios"
firebase_crashlytics:
:path: ".symlinks/plugins/firebase_crashlytics/ios"
firebase_messaging:
:path: ".symlinks/plugins/firebase_messaging/ios"
firebase_performance:
:path: ".symlinks/plugins/firebase_performance/ios"
Flutter:
:path: Flutter
flutter_keyboard_visibility:
:path: ".symlinks/plugins/flutter_keyboard_visibility/ios"
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_webrtc:
:path: ".symlinks/plugins/flutter_webrtc/ios"
gal:
:path: ".symlinks/plugins/gal/darwin"
image_cropper:
:path: ".symlinks/plugins/image_cropper/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
livekit_client:
@@ -241,12 +387,12 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
pointer_interceptor_ios:
:path: ".symlinks/plugins/pointer_interceptor_ios/ios"
protocol_handler_ios:
:path: ".symlinks/plugins/protocol_handler_ios/ios"
screen_brightness_ios:
:path: ".symlinks/plugins/screen_brightness_ios/ios"
sentry_flutter:
:path: ".symlinks/plugins/sentry_flutter/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
@@ -267,19 +413,35 @@ SPEC CHECKSUMS:
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
Firebase: cec914dab6fd7b1bd8ab56ea07ce4e03dd251c2d
firebase_analytics: 04491d1ee74c8e7c2330c96afc54188a969b06ee
firebase_core: 57aeb91680e5d5e6df6b888064be7c785f146efb
firebase_crashlytics: e3d3e0c99bad5aaab5908385133dea8ec344693f
firebase_messaging: c862b3d2b973ecc769194dc8de09bd22c77ae757
firebase_performance: 8643e815a354ee94da1192cd69335a48a7b625a4
FirebaseABTesting: d87f56707159bae64e269757a6e963d490f2eebe
FirebaseAnalytics: 23717de130b779aa506e757edb9713d24b6ffeda
FirebaseCore: 30e9c1cbe3d38f5f5e75f48bfcea87d7c358ec16
FirebaseCoreExtension: 705ca5b14bf71d2564a0ddc677df1fc86ffa600f
FirebaseCoreInternal: df84dd300b561c27d5571684f389bf60b0a5c934
FirebaseCrashlytics: 34647b41e18de773717fdd348a22206f2f9bc774
FirebaseInstallations: 913cf60d0400ebd5d6b63a28b290372ab44590dd
FirebaseMessaging: 7b5d8033e183ab59eb5b852a53201559e976d366
FirebasePerformance: d0ac4aa90f8c1aedeb8d0329a56e2d77d8d9e004
FirebaseRemoteConfig: 48ef3f243742a8d72422ccfc9f986e19d7de53fd
FirebaseRemoteConfigInterop: 6efda51fb5e2f15b16585197e26eaa09574e8a4d
FirebaseSessions: dbd14adac65ce996228652c1fc3a3f576bdf3ecc
FirebaseSharedSwift: 20530f495084b8d840f78a100d8c5ee613375f6e
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
flutter_webrtc: 75b868e4f9e817c7a9a42ca4b6169063de4eec9f
gal: 61e868295d28fe67ffa297fae6dacebf56fd53e1
GoogleAppMeasurement: f9de05ee17401e3355f68e8fc8b5064d429f5918
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
image_cropper: 37d40f62177c101ff4c164906d259ea2c3aa70cf
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
livekit_client: c767049a635d5b6d43de3273dca3c439b8a6e970
livekit_client: d079c5f040d4bf2b80440ff0ae997725a183e4bc
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
@@ -288,16 +450,17 @@ SPEC CHECKSUMS:
pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
pointer_interceptor_ios: 508241697ff0947f853c061945a8b822463947c1
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
protocol_handler_ios: a5db8abc38526ee326988b808be621e5fd568990
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d
Sentry: 96ae1dcdf01a644bc3a3b1dc279cecaf48a833fb
sentry_flutter: f1d86adcb93a959bc47a40d8d55059bdf7569bc5
SDWebImage: a79252b60f4678812d94316c91da69ec83089c9f
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1

View File

@@ -254,6 +254,7 @@
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
B1CDA9DD5B638A2BB88053CB /* [CP] Check Pods Manifest.lock */,
7356FAC42C72724B0051A465 /* [Crashlytics] Clear dSYM */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
@@ -263,6 +264,7 @@
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
287A33C298CA352A7E7F32A4 /* [CP] Embed Pods Frameworks */,
0818E8E4321C0D7433E07576 /* [CP] Copy Pods Resources */,
1A9FD6BE5DEE99CDA7399504 /* [Crashlytics] Upload dSYM */,
);
buildRules = (
);
@@ -365,6 +367,24 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
1A9FD6BE5DEE99CDA7399504 /* [Crashlytics] Upload dSYM */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "[Crashlytics] Upload dSYM";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\n#!/bin/bash\nsleep 1 # Without this, there seems a chance that the script runs before dSYM generation is finished \n$PODS_ROOT/FirebaseCrashlytics/upload-symbols -gsp $PROJECT_DIR/Runner/GoogleService-Info.plist -p ios $DWARF_DSYM_FOLDER_PATH/$DWARF_DSYM_FILE_NAME\n";
};
259653AE41D478F4C6BAE9B2 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -420,6 +440,24 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
7356FAC42C72724B0051A465 /* [Crashlytics] Clear dSYM */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "[Crashlytics] Clear dSYM";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\n#!/bin/bash\nrm -rf $DWARF_DSYM_FOLDER_PATH/$DWARF_DSYM_FILE_NAME\n";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -433,7 +471,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n";
};
B1CDA9DD5B638A2BB88053CB /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 815 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 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.1 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.4 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.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 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.5 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -1,10 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.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';
@@ -21,6 +26,7 @@ class BootstrapperShell extends StatefulWidget {
class _BootstrapperShellState extends State<BootstrapperShell> {
bool _isBusy = true;
bool _isErrored = false;
bool _isDismissable = true;
String? _subtitle;
Color get _unFocusColor =>
@@ -29,6 +35,38 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
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 {
@@ -38,12 +76,14 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
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');
}
@@ -72,12 +112,15 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
label: 'bsPreparingData',
action: () async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isTrue) {
await Future.wait([
await Future.wait([
Get.find<StickerProvider>().refreshAvailableStickers(),
if (auth.isAuthorized.isTrue)
Get.find<ChannelProvider>().refreshAvailableChannel(),
Get.find<RelationshipProvider>().refreshFriendList(),
]);
}
if (auth.isAuthorized.isTrue)
Get.find<RelationshipProvider>().refreshRelativeList(),
if (auth.isAuthorized.isTrue)
Get.find<RealmProvider>().refreshAvailableRealms(),
]);
},
),
(
@@ -101,7 +144,7 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
try {
for (var idx = 0; idx < _periods.length; idx++) {
await _periods[idx].action();
if (_isErrored) break;
if (_isErrored && !_isDismissable) break;
if (_periodCursor < _periods.length - 1) {
setState(() => _periodCursor++);
}
@@ -120,27 +163,31 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
@override
Widget build(BuildContext context) {
if (_isBusy || _isErrored) {
return 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: Image.asset('assets/logo.png', width: 80, height: 80)
.animate(onPlay: (c) => c.repeat())
.rotate(duration: 850.ms, curve: Curves.easeInOut),
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),
),
),
),
),
GestureDetector(
child: Column(
Column(
children: [
if (_isErrored)
const Icon(Icons.cancel, size: 24)
else
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,
@@ -151,15 +198,33 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
maxWidth: 280,
child: Column(
children: [
Text(
_subtitle ??
'${_periods[_periodCursor].label.tr} (${_periodCursor + 1}/${_periods.length})',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: _unFocusColor,
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,
@@ -173,18 +238,25 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
),
],
),
onTap: () {
if (_isBusy) return;
setState(() {
_isBusy = true;
_isErrored = false;
_periodCursor = 0;
});
_runPeriods();
},
)
],
],
),
),
onTap: () {
if (_isBusy) return;
if (_isDismissable) {
setState(() {
_isBusy = false;
_isErrored = false;
});
} else {
setState(() {
_isBusy = true;
_isErrored = false;
_periodCursor = 0;
});
_runPeriods();
}
},
);
}

View File

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

View File

@@ -7,13 +7,17 @@ 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 aliasController = TextEditingController();
final titleController = TextEditingController();
final descriptionController = TextEditingController();
final contentController = TextEditingController();
@@ -25,8 +29,11 @@ class PostEditorController extends GetxController {
Rx<Post?> replyTo = Rx(null);
Rx<Post?> repostTo = Rx(null);
Rx<Realm?> realmZone = Rx(null);
RxList<int> attachments = RxList<int>.empty(growable: true);
Rx<DateTime?> publishedAt = Rx(null);
Rx<DateTime?> publishedUntil = Rx(null);
RxList<String> attachments = RxList<String>.empty(growable: true);
RxList<String> tags = RxList<String>.empty(growable: true);
Rx<String?> thumbnail = Rx(null);
RxList<int> visibleUsers = RxList.empty(growable: true);
RxList<int> invisibleUsers = RxList.empty(growable: true);
@@ -41,7 +48,6 @@ class PostEditorController extends GetxController {
PostEditorController() {
SharedPreferences.getInstance().then((inst) {
_prefs = inst;
localRead();
_saveTimer = Timer.periodic(
const Duration(seconds: 3),
(Timer t) {
@@ -88,17 +94,45 @@ class PostEditorController extends GetxController {
);
}
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,
isScrollControlled: true,
builder: (context) => AttachmentEditorPopup(
usage: 'i.attachment',
current: attachments,
onUpdate: (value) {
attachments.value = value;
attachments.refresh();
pool: 'interactive',
initialAttachments: attachments,
onAdd: (String value) {
attachments.add(value);
},
onRemove: (String value) {
attachments.remove(value);
},
),
);
}
Future<void> editThumbnail(BuildContext context) {
return showDialog(
context: context,
builder: (context) => PostEditorThumbnailDialog(
controller: this,
),
);
}
@@ -122,10 +156,12 @@ class PostEditorController extends GetxController {
}
void localRead() {
if (_prefs.containsKey('post_editor_local_save')) {
isRestoreFromLocal.value = true;
payload = jsonDecode(_prefs.getString('post_editor_local_save')!);
}
SharedPreferences.getInstance().then((inst) {
if (inst.containsKey('post_editor_local_save')) {
isRestoreFromLocal.value = true;
payload = jsonDecode(inst.getString('post_editor_local_save')!);
}
});
}
void localClear() {
@@ -133,6 +169,7 @@ class PostEditorController extends GetxController {
}
void currentClear() {
aliasController.clear();
titleController.clear();
descriptionController.clear();
contentController.clear();
@@ -141,6 +178,9 @@ class PostEditorController extends GetxController {
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;
@@ -159,12 +199,25 @@ class PostEditorController extends GetxController {
type = value.type;
editTo.value = value;
realmZone.value = value.realm;
isDraft.value = value.isDraft ?? false;
aliasController.text = value.alias ?? '';
titleController.text = value.body['title'] ?? '';
descriptionController.text = value.body['description'] ?? '';
contentController.text = value.body['content'] ?? '';
attachments.value = value.body['attachments']?.cast<int>() ?? List.empty();
publishedAt.value = value.publishedAt;
publishedUntil.value = value.publishedUntil;
tags.value = List.from(
value.body['tags']?.map((x) => x['alias']).toList() ?? List.empty(),
growable: true,
);
tags.refresh();
attachments.value = List.from(
value.body['attachments'] ?? List.empty(),
growable: true,
);
attachments.refresh();
thumbnail.value = value.body['thumbnail'];
contentLength.value = contentController.text.length;
}
@@ -212,14 +265,19 @@ class PostEditorController extends GetxController {
Map<String, dynamic> get payload {
return {
'alias': aliasController.text,
'title': title,
'description': description,
'content': contentController.text,
'tags': tags,
'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,
@@ -229,18 +287,39 @@ class PostEditorController extends GetxController {
set payload(Map<String, dynamic> value) {
type = value['type'];
tags.value = List.from(
value['tags'].map((x) => x['alias']).toList(),
growable: true,
);
aliasController.text = value['alias'] ?? '';
titleController.text = value['title'] ?? '';
descriptionController.text = value['description'] ?? '';
contentController.text = value['content'] ?? '';
attachments.value = value['attachments'].cast<int>() ?? List.empty();
attachments.value = List.from(
value['attachments'] ?? List.empty(),
growable: true,
);
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>();
visibleUsers.value = List.from(
value['visible_users'],
growable: true,
);
}
if (value['invisible_users'] != null) {
invisibleUsers.value = value['invisible_users'].cast<int>();
invisibleUsers.value = List.from(
value['invisible_users'],
growable: true,
);
}
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']);
@@ -263,11 +342,13 @@ class PostEditorController extends GetxController {
bool get isNotEmpty {
return [
aliasController.text.isNotEmpty,
titleController.text.isNotEmpty,
descriptionController.text.isNotEmpty,
contentController.text.isNotEmpty,
attachments.isNotEmpty,
tags.isNotEmpty
tags.isNotEmpty,
thumbnail.value != null,
].any((x) => x);
}

View File

@@ -9,11 +9,12 @@ class PostListController extends GetxController {
/// The polling source modifier.
/// - `0`: default recommendations
/// - `1`: shuffle mode
/// - `1`: friend mode
/// - `2`: shuffle mode
RxInt mode = 0.obs;
/// The paging controller for infinite loading.
/// Only available when mode is `0`.
/// Only available when mode is `0` or `1`.
PagingController<int, Post> pagingController =
PagingController(firstPageKey: 0);
@@ -111,10 +112,23 @@ class PostListController extends GetxController {
author: author,
);
} else {
resp = await provider.listRecommendations(
pageKey,
channel: mode.value == 0 ? null : 'shuffle',
);
switch (mode.value) {
case 2:
resp = await provider.listRecommendations(
pageKey,
channel: 'shuffle',
);
break;
case 1:
resp = await provider.listRecommendations(
pageKey,
channel: 'friends',
);
break;
default:
resp = await provider.listRecommendations(pageKey);
break;
}
}
} catch (e) {
rethrow;

View File

@@ -2,9 +2,10 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
extension SolianExtenions on BuildContext {
void showSnackbar(String content) {
void showSnackbar(String content, {SnackBarAction? action}) {
ScaffoldMessenger.of(this).showSnackBar(SnackBar(
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) {
var stack = StackTrace.current;
var stackTrace = '$stack';

View File

@@ -85,4 +85,5 @@ class DefaultFirebaseOptions {
storageBucket: 'solian-0x001.appspot.com',
measurementId: 'G-EF9BZMKBC3',
);
}
}

View File

@@ -1,15 +1,21 @@
import 'dart:ui';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart';
import 'package:flutter_acrylic/flutter_acrylic.dart';
import 'package:get/get.dart';
import 'package:go_router/go_router.dart';
import 'package:media_kit/media_kit.dart';
import 'package:protocol_handler/protocol_handler.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:provider/provider.dart';
import 'package:solian/bootstrapper.dart';
import 'package:solian/firebase_options.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/attachment_uploader.dart';
import 'package:solian/providers/link_expander.dart';
import 'package:solian/providers/stickers.dart';
import 'package:solian/providers/theme_switcher.dart';
import 'package:solian/providers/websocket.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/attachment.dart';
@@ -26,43 +32,29 @@ import 'package:solian/translations.dart';
import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
void main() async {
await SentryFlutter.init(
(options) {
options.dsn =
'https://55438cdff9048aa2225df72fdc629c42@o4506965897117696.ingest.us.sentry.io/4507357676437504';
options.tracesSampleRate = 1.0;
options.profilesSampleRate = 1.0;
},
appRunner: () async {
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
await Future.wait([
_initializeTheme(),
_initializeFirebase(),
_initializePlatformComponents(),
]);
await Future.wait([
_initializeFirebase(),
_initializePlatformComponents(),
]);
GoRouter.optionURLReflectsImperativeAPIs = true;
GoRouter.optionURLReflectsImperativeAPIs = true;
usePathUrlStrategy();
runApp(const SolianApp());
},
);
}
Future<void> _initializeTheme() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.containsKey('global_theme_color')) {
final value = prefs.getInt('global_theme_color')!;
final color = Color(value);
currentLightTheme = SolianTheme.build(Brightness.light, seedColor: color);
currentDarkTheme = SolianTheme.build(Brightness.dark, seedColor: color);
}
usePathUrlStrategy();
runApp(const SolianApp());
}
Future<void> _initializeFirebase() async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
FlutterError.onError = (errorDetails) {
FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails);
};
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true;
};
}
Future<void> _initializePlatformComponents() async {
@@ -83,33 +75,45 @@ Future<void> _initializePlatformComponents() async {
}
}
final themeSwitcher = ThemeSwitcher(
lightThemeData: SolianTheme.build(Brightness.light),
darkThemeData: SolianTheme.build(Brightness.dark),
);
class SolianApp extends StatelessWidget {
const SolianApp({super.key});
@override
Widget build(BuildContext context) {
return GetMaterialApp.router(
title: 'Solian',
theme: currentLightTheme,
darkTheme: currentDarkTheme,
themeMode: ThemeMode.system,
routerDelegate: AppRouter.instance.routerDelegate,
routeInformationParser: AppRouter.instance.routeInformationParser,
routeInformationProvider: AppRouter.instance.routeInformationProvider,
backButtonDispatcher: AppRouter.instance.backButtonDispatcher,
translations: SolianMessages(),
locale: Get.deviceLocale,
fallbackLocale: const Locale('en', 'US'),
onInit: () => _initializeProviders(context),
builder: (context, child) {
return SystemShell(
child: ScaffoldMessenger(
child: BootstrapperShell(
child: child ?? const SizedBox(),
),
),
return ChangeNotifierProvider.value(
value: themeSwitcher,
child: Builder(builder: (context) {
final theme = Provider.of<ThemeSwitcher>(context);
return GetMaterialApp.router(
title: 'Solian',
theme: theme.lightThemeData,
darkTheme: theme.darkThemeData,
themeMode: ThemeMode.system,
routerDelegate: AppRouter.instance.routerDelegate,
routeInformationParser: AppRouter.instance.routeInformationParser,
routeInformationProvider: AppRouter.instance.routeInformationProvider,
backButtonDispatcher: AppRouter.instance.backButtonDispatcher,
translations: SolianMessages(),
locale: Get.deviceLocale,
fallbackLocale: const Locale('en', 'US'),
onInit: () => _initializeProviders(context),
builder: (context, child) {
return SystemShell(
child: ScaffoldMessenger(
child: BootstrapperShell(
child: child ?? const SizedBox(),
),
),
);
},
);
},
}),
);
}
@@ -117,11 +121,14 @@ class SolianApp extends StatelessWidget {
Get.lazyPut(() => AuthProvider());
Get.lazyPut(() => RelationshipProvider());
Get.lazyPut(() => PostProvider());
Get.lazyPut(() => StickerProvider());
Get.lazyPut(() => AttachmentProvider());
Get.lazyPut(() => WebSocketProvider());
Get.lazyPut(() => StatusProvider());
Get.lazyPut(() => ChannelProvider());
Get.lazyPut(() => RealmProvider());
Get.lazyPut(() => ChatCallProvider());
Get.lazyPut(() => AttachmentUploaderController());
Get.lazyPut(() => LinkExpandController());
}
}

View File

@@ -1,20 +1,47 @@
import 'package:solian/models/account.dart';
class AttachmentPlaceholder {
int chunkCount;
int chunkSize;
Attachment meta;
AttachmentPlaceholder({
required this.chunkCount,
required this.chunkSize,
required this.meta,
});
factory AttachmentPlaceholder.fromJson(Map<String, dynamic> json) =>
AttachmentPlaceholder(
chunkCount: json['chunk_count'],
chunkSize: json['chunk_size'],
meta: Attachment.fromJson(json['meta']),
);
Map<String, dynamic> toJson() => {
'chunk_count': chunkCount,
'chunk_size': chunkSize,
'meta': meta.toJson(),
};
}
class Attachment {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String rid;
String uuid;
int size;
String name;
String alt;
String usage;
String mimetype;
String hash;
int destination;
bool isAnalyzed;
bool isUploaded;
Map<String, dynamic>? metadata;
Map<String, dynamic>? fileChunks;
bool isMature;
Account? account;
int? accountId;
@@ -24,58 +51,67 @@ class Attachment {
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.rid,
required this.uuid,
required this.size,
required this.name,
required this.alt,
required this.usage,
required this.mimetype,
required this.hash,
required this.destination,
required this.isAnalyzed,
required this.isUploaded,
required this.metadata,
required this.fileChunks,
required this.isMature,
required this.account,
required this.accountId,
});
factory Attachment.fromJson(Map<String, dynamic> json) => Attachment(
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']) : null,
uuid: json['uuid'],
size: json['size'],
name: json['name'],
alt: json['alt'],
usage: json['usage'],
mimetype: json['mimetype'],
hash: json['hash'],
destination: json['destination'],
isAnalyzed: json['is_analyzed'],
metadata: json['metadata'],
isMature: json['is_mature'],
account: json['account'] != null ? Account.fromJson(json['account']) : null,
accountId: json['account_id'],
);
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'])
: null,
rid: json['rid'],
uuid: json['uuid'],
size: json['size'],
name: json['name'],
alt: json['alt'],
mimetype: json['mimetype'],
hash: json['hash'],
destination: json['destination'],
isAnalyzed: json['is_analyzed'],
isUploaded: json['is_uploaded'],
metadata: json['metadata'],
fileChunks: json['file_chunks'],
isMature: json['is_mature'],
account:
json['account'] != null ? Account.fromJson(json['account']) : null,
accountId: json['account_id'],
);
Map<String, dynamic> toJson() => {
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt?.toIso8601String(),
'uuid': uuid,
'size': size,
'name': name,
'alt': alt,
'usage': usage,
'mimetype': mimetype,
'hash': hash,
'destination': destination,
'is_analyzed': isAnalyzed,
'metadata': metadata,
'is_mature': isMature,
'account': account?.toJson(),
'account_id': accountId,
};
}
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt?.toIso8601String(),
'rid': rid,
'uuid': uuid,
'size': size,
'name': name,
'alt': alt,
'mimetype': mimetype,
'hash': hash,
'destination': destination,
'is_analyzed': isAnalyzed,
'is_uploaded': isUploaded,
'metadata': metadata,
'file_chunks': fileChunks,
'is_mature': isMature,
'account': account?.toJson(),
'account_id': accountId,
};
}

View File

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

View File

@@ -63,7 +63,7 @@ class Event {
class EventMessageBody {
String text;
String algorithm;
List<int>? attachments;
List<String>? attachments;
int? quoteEvent;
int? relatedEvent;
List<int>? relatedUsers;
@@ -82,7 +82,7 @@ class EventMessageBody {
text: json['text'] ?? '',
algorithm: json['algorithm'] ?? 'plain',
attachments: json['attachments'] != null
? List<int>.from(json['attachments'].map((x) => x))
? List<String>.from(json['attachments']?.whereType<String>())
: null,
quoteEvent: json['quote_event'],
relatedEvent: json['related_event'],

65
lib/models/link.dart Normal file
View File

@@ -0,0 +1,65 @@
class LinkMeta {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String entryId;
String? icon;
String url;
String? title;
String? image;
String? video;
String? audio;
String? description;
String? siteName;
LinkMeta({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.entryId,
required this.icon,
required this.url,
required this.title,
required this.image,
required this.video,
required this.audio,
required this.description,
required this.siteName,
});
factory LinkMeta.fromJson(Map<String, dynamic> json) => LinkMeta(
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'])
: null,
entryId: json['entry_id'],
icon: json['icon'],
url: json['url'],
title: json['title'],
image: json['image'],
video: json['video'],
audio: json['audio'],
description: json['description'],
siteName: json['site_name'],
);
Map<String, dynamic> toJson() => {
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt?.toIso8601String(),
'entry_id': entryId,
'icon': icon,
'url': url,
'title': title,
'image': image,
'video': video,
'audio': audio,
'description': description,
'site_name': siteName,
};
}

View File

@@ -8,6 +8,8 @@ class Post {
DateTime updatedAt;
DateTime? editedAt;
DateTime? deletedAt;
String? alias;
String? areaAlias;
dynamic body;
List<Tag>? tags;
List<Category>? categories;
@@ -20,6 +22,7 @@ class Post {
Post? repostTo;
Realm? realm;
DateTime? publishedAt;
DateTime? publishedUntil;
DateTime? pinnedAt;
bool? isDraft;
int authorId;
@@ -32,6 +35,8 @@ class Post {
required this.updatedAt,
required this.editedAt,
required this.deletedAt,
required this.alias,
required this.areaAlias,
required this.type,
required this.body,
required this.tags,
@@ -44,6 +49,7 @@ class Post {
required this.repostTo,
required this.realm,
required this.publishedAt,
required this.publishedUntil,
required this.pinnedAt,
required this.isDraft,
required this.authorId,
@@ -58,6 +64,8 @@ class Post {
deletedAt: json['deleted_at'] != null
? DateTime.parse(json['deleted_at'])
: null,
alias: json['alias'],
areaAlias: json['area_alias'],
type: json['type'],
body: json['body'],
tags: json['tags']?.map((x) => Tag.fromJson(x)).toList().cast<Tag>(),
@@ -80,6 +88,9 @@ class Post {
publishedAt: json['published_at'] != null
? DateTime.parse(json['published_at'])
: null,
publishedUntil: json['published_until'] != null
? DateTime.parse(json['published_until'])
: null,
pinnedAt: json['pinned_at'] != null
? DateTime.parse(json['pinned_at'])
: null,
@@ -96,6 +107,8 @@ class Post {
'updated_at': updatedAt.toIso8601String(),
'edited_at': editedAt?.toIso8601String(),
'deleted_at': deletedAt?.toIso8601String(),
'alias': alias,
'area_alias': areaAlias,
'type': type,
'body': body,
'tags': tags,
@@ -108,6 +121,7 @@ class Post {
'repost_to': repostTo?.toJson(),
'realm': realm?.toJson(),
'published_at': publishedAt?.toIso8601String(),
'published_until': publishedUntil?.toIso8601String(),
'pinned_at': pinnedAt?.toIso8601String(),
'is_draft': isDraft,
'author_id': authorId,

View File

@@ -52,6 +52,17 @@ class Realm {
'is_community': isCommunity,
'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 {

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/${attachment.rid}',
);
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,247 @@
import 'dart:async';
import 'dart:collection';
import 'dart:typed_data';
import 'package:cross_file/cross_file.dart';
import 'package:get/get.dart';
import 'package:path/path.dart' show basename;
import 'package:solian/models/attachment.dart';
import 'package:solian/providers/content/attachment.dart';
class AttachmentUploadTask {
XFile file;
String pool;
Map<String, dynamic>? metadata;
Map<String, int>? chunkFiles;
double? progress;
bool isUploading = false;
bool isCompleted = false;
dynamic error;
AttachmentUploadTask({
required this.file,
required this.pool,
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;
queueOfUpload[queueIndex].progress = 0;
final task = queueOfUpload[queueIndex];
try {
final result = await _chunkedUploadAttachment(
task.file,
task.pool,
null,
onData: (_) {},
onProgress: (progress) {
queueOfUpload[queueIndex].progress = progress;
_progressOfUpload = progress;
},
);
return result;
} catch (err) {
queueOfUpload[queueIndex].error = err;
queueOfUpload[queueIndex].isUploading = false;
} finally {
_progressOfUpload = 1;
if (queueOfUpload[queueIndex].error == null) {
queueOfUpload.removeAt(queueIndex);
}
_stopProgressSyncTimer();
_syncProgress();
isUploading.value = false;
}
return null;
}
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;
queueOfUpload[idx].progress = 0;
final task = queueOfUpload[idx];
try {
final result = await _chunkedUploadAttachment(
task.file,
task.pool,
null,
onData: (_) {},
onProgress: (progress) {
queueOfUpload[idx].progress = progress;
},
);
if (result != null) onData(result);
} catch (err) {
queueOfUpload[idx].error = err;
queueOfUpload[idx].isUploading = false;
} finally {
_progressOfUpload = (idx + 1) / queueOfUpload.length;
}
queueOfUpload[idx].isUploading = false;
queueOfUpload[idx].isCompleted = true;
}
queueOfUpload.removeWhere((x) => x.error == null);
_stopProgressSyncTimer();
_syncProgress();
isUploading.value = false;
}
Future<Attachment?> uploadAttachmentFromData(
Uint8List data,
String path,
String pool,
Map<String, dynamic>? metadata,
) async {
if (isUploading.value) throw Exception('uploading blocked');
isUploading.value = true;
final AttachmentProvider attach = Get.find();
try {
final result = await attach.createAttachmentDirectly(
data,
path,
pool,
metadata,
);
return result;
} catch (_) {
return null;
} finally {
isUploading.value = false;
}
}
Future<Attachment?> _chunkedUploadAttachment(
XFile file,
String pool,
Map<String, dynamic>? metadata, {
required Function(AttachmentPlaceholder) onData,
required Function(double) onProgress,
}) async {
final AttachmentProvider attach = Get.find();
final holder = await attach.createAttachmentMultipartPlaceholder(
await file.length(),
file.path,
pool,
metadata,
);
onData(holder);
onProgress(0);
final filename = basename(file.path);
final chunks = holder.meta.fileChunks ?? {};
var currentTask = 0;
final queue = Queue<Future<void>>();
final activeTasks = <Future<void>>[];
for (final entry in chunks.entries) {
queue.add(() async {
final beginCursor = entry.value * holder.chunkSize;
final endCursor = (entry.value + 1) * holder.chunkSize;
final data = Uint8List.fromList(await file
.openRead(beginCursor, endCursor)
.expand((chunk) => chunk)
.toList());
final out = await attach.uploadAttachmentMultipartChunk(
data,
filename,
holder.meta.rid,
entry.key,
);
holder.meta = out;
currentTask++;
onProgress(currentTask / chunks.length);
onData(holder);
}());
}
while (queue.isNotEmpty || activeTasks.isNotEmpty) {
while (activeTasks.length < 3 && queue.isNotEmpty) {
final task = queue.removeFirst();
activeTasks.add(task);
task.then((_) => activeTasks.remove(task));
}
if (activeTasks.isNotEmpty) {
await Future.any(activeTasks);
}
}
return holder.meta;
}
}

View File

@@ -16,6 +16,11 @@ class ChatCallProvider extends GetxController {
RxBool isReady = false.obs;
RxBool isMounted = false.obs;
RxBool isInitialized = false.obs;
RxBool isBusy = false.obs;
RxString lastDuration = '00:00:00'.obs;
Timer? lastDurationUpdateTimer;
String? token;
String? endpoint;
@@ -37,6 +42,34 @@ class ChatCallProvider extends GetxController {
RxList<ParticipantTrack> participantTracks = RxList.empty(growable: true);
Rx<ParticipantTrack?> focusTrack = Rx(null);
void _updateDuration() {
if (current.value == null) {
lastDuration.value = '00:00:00';
return;
}
Duration duration = DateTime.now().difference(current.value!.createdAt);
String twoDigits(int n) => n.toString().padLeft(2, '0');
String formattedTime = '${twoDigits(duration.inHours)}:'
'${twoDigits(duration.inMinutes.remainder(60))}:'
'${twoDigits(duration.inSeconds.remainder(60))}';
lastDuration.value = formattedTime;
}
void enableDurationUpdater() {
_updateDuration();
lastDurationUpdateTimer = Timer.periodic(
const Duration(seconds: 1),
(_) => _updateDuration(),
);
}
void disableDurationUpdater() {
lastDurationUpdateTimer?.cancel();
lastDurationUpdateTimer = null;
}
Future<void> checkPermissions() async {
if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) {
return;
@@ -87,7 +120,30 @@ class ChatCallProvider extends GetxController {
void initRoom() {
initHardware();
room = Room();
room = Room(
roomOptions: const RoomOptions(
dynacast: true,
adaptiveStream: true,
defaultAudioPublishOptions: AudioPublishOptions(
name: 'call_voice',
stream: 'call_stream',
),
defaultVideoPublishOptions: VideoPublishOptions(
name: 'call_video',
stream: 'call_stream',
simulcast: true,
backupVideoCodec: BackupVideoCodec(enabled: true),
),
defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(
useiOSBroadcastExtension: true,
params: VideoParametersPresets.screenShareH1080FPS30,
),
defaultCameraCaptureOptions: CameraCaptureOptions(
maxFrameRate: 30,
params: VideoParametersPresets.h1080_169,
),
),
);
listener = room.createListener();
WakelockPlus.enable();
}
@@ -95,36 +151,12 @@ class ChatCallProvider extends GetxController {
void joinRoom(String url, String token) async {
if (isMounted.value) {
return;
} else {
isMounted.value = true;
}
try {
await room.connect(
url,
token,
roomOptions: const RoomOptions(
dynacast: true,
adaptiveStream: true,
defaultAudioPublishOptions: AudioPublishOptions(
name: 'call_voice',
stream: 'call_stream',
),
defaultVideoPublishOptions: VideoPublishOptions(
name: 'call_video',
stream: 'call_stream',
simulcast: true,
backupVideoCodec: BackupVideoCodec(enabled: true),
),
defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(
useiOSBroadcastExtension: true,
params: VideoParametersPresets.screenShareH1080FPS30,
),
defaultCameraCaptureOptions: CameraCaptureOptions(
maxFrameRate: 30,
params: VideoParametersPresets.h1080_169,
),
),
fastConnectOptions: FastConnectOptions(
microphone: TrackOption(track: audioTrack.value),
camera: TrackOption(track: videoTrack.value),
@@ -132,6 +164,8 @@ class ChatCallProvider extends GetxController {
);
} catch (e) {
rethrow;
} finally {
isMounted.value = true;
}
}
@@ -151,6 +185,8 @@ class ChatCallProvider extends GetxController {
void onRoomDidUpdate() => sortParticipants();
void setupRoom() {
if (isInitialized.value) return;
sortParticipants();
room.addListener(onRoomDidUpdate);
WidgetsBindingCompatible.instance?.addPostFrameCallback(
@@ -160,6 +196,9 @@ class ChatCallProvider extends GetxController {
if (lkPlatformIsMobile()) {
Hardware.instance.setSpeakerphoneOn(true);
}
isBusy.value = false;
isInitialized.value = true;
}
void setupRoomListeners({
@@ -361,7 +400,9 @@ class ChatCallProvider extends GetxController {
}
void disposeRoom() {
isBusy.value = false;
isMounted.value = false;
isInitialized.value = false;
current.value = null;
channel.value = null;
room.removeListener(onRoomDidUpdate);

View File

@@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:get/get.dart';
import 'package:path/path.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart';
@@ -18,18 +19,62 @@ class AttachmentProvider extends GetConnect {
httpClient.baseUrl = ServiceFinder.buildUrl('files', null);
}
final Map<int, Attachment> _cachedResponses = {};
final Map<String, Attachment> _cachedResponses = {};
Future<Attachment?> getMetadata(int id, {noCache = false}) async {
if (!noCache && _cachedResponses.containsKey(id)) {
return _cachedResponses[id]!;
Future<List<Attachment?>> listMetadata(
List<String> rid, {
noCache = false,
}) async {
if (rid.isEmpty) return List.empty();
List<Attachment?> result = List.filled(rid.length, null);
List<String> pendingQuery = List.empty(growable: true);
if (!noCache) {
for (var idx = 0; idx < rid.length; idx++) {
if (_cachedResponses.containsKey(rid[idx])) {
result[idx] = _cachedResponses[rid[idx]];
} else {
pendingQuery.add(rid[idx]);
}
}
}
final resp = await get('/attachments/$id/meta');
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.rid] = item;
}
}
for (var i = 0; i < out.length; i++) {
for (var j = 0; j < rid.length; j++) {
if (out[i].rid == rid[j]) {
result[j] = out[i];
}
}
}
return result;
}
Future<Attachment?> getMetadata(String rid, {noCache = false}) async {
if (!noCache && _cachedResponses.containsKey(rid)) {
return _cachedResponses[rid]!;
}
final resp = await get('/attachments/$rid/meta');
if (resp.statusCode == 200) {
final result = Attachment.fromJson(resp.body);
if (result.destination != 0 && result.isAnalyzed) {
_cachedResponses[id] = result;
_cachedResponses[rid] = result;
}
return result;
}
@@ -37,17 +82,17 @@ class AttachmentProvider extends GetConnect {
return null;
}
Future<Response> createAttachment(
Future<Attachment> createAttachmentDirectly(
Uint8List data,
String path,
String usage,
String pool,
Map<String, dynamic>? metadata,
) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized');
final client = auth.configureClient(
'files',
'uc',
timeout: const Duration(minutes: 3),
);
@@ -67,7 +112,7 @@ class AttachmentProvider extends GetConnect {
final payload = FormData({
'alt': fileAlt,
'file': filePayload,
'usage': usage,
'pool': pool,
if (mimetypeOverride != null) 'mimetype': mimetypeOverride,
'metadata': jsonEncode(metadata),
});
@@ -76,13 +121,75 @@ class AttachmentProvider extends GetConnect {
throw Exception(resp.bodyString);
}
return resp;
return Attachment.fromJson(resp.body);
}
Future<AttachmentPlaceholder> createAttachmentMultipartPlaceholder(
int size,
String path,
String pool,
Map<String, dynamic>? metadata,
) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized');
final client = auth.configureClient('uc');
final fileAlt = basename(path).contains('.')
? basename(path).substring(0, basename(path).lastIndexOf('.'))
: basename(path);
final fileExt = basename(path)
.substring(basename(path).lastIndexOf('.') + 1)
.toLowerCase();
// Override for some files cannot be detected mimetype by server-side
String? mimetypeOverride;
if (mimetypeOverrides.keys.contains(fileExt)) {
mimetypeOverride = mimetypeOverrides[fileExt];
}
final resp = await client.post('/attachments/multipart', {
'alt': fileAlt,
'name': basename(path),
'size': size,
'pool': pool,
if (mimetypeOverride != null) 'mimetype': mimetypeOverride,
'metadata': metadata,
});
if (resp.statusCode != 200) {
throw Exception(resp.bodyString);
}
return AttachmentPlaceholder.fromJson(resp.body);
}
Future<Attachment> uploadAttachmentMultipartChunk(
Uint8List data,
String name,
String rid,
String cid,
) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized');
final client = auth.configureClient(
'uc',
timeout: const Duration(minutes: 3),
);
final payload = FormData({
'file': MultipartFile(data, filename: name),
});
final resp = await client.post('/attachments/multipart/$rid/$cid', payload);
if (resp.statusCode != 200) {
throw Exception(resp.bodyString);
}
return Attachment.fromJson(resp.body);
}
Future<Response> updateAttachment(
int id,
String alt,
String usage, {
String alt, {
bool isMature = false,
}) async {
final AuthProvider auth = Get.find();
@@ -92,7 +199,6 @@ class AttachmentProvider extends GetConnect {
var resp = await client.put('/attachments/$id', {
'alt': alt,
'usage': usage,
'is_mature': isMature,
});
@@ -117,7 +223,7 @@ class AttachmentProvider extends GetConnect {
return resp;
}
void clearCache({int? id}) {
void clearCache({String? id}) {
if (id != null) {
_cachedResponses.remove(id);
} else {

View File

@@ -9,13 +9,20 @@ class PostProvider extends GetConnect {
}
Future<Response> listRecommendations(int page,
{int? realm, String? channel}) async {
{String? realm, String? channel}) async {
GetConnect client;
final AuthProvider auth = Get.find();
final queries = [
'take=${10}',
'offset=$page',
if (realm != null) 'realmId=$realm',
if (realm != null) 'realm=$realm',
];
final resp = await get(
if (auth.isAuthorized.value) {
client = auth.configureClient('co');
} else {
client = ServiceFinder.configureClient('co');
}
final resp = await client.get(
channel == null
? '/recommendations?${queries.join('&')}'
: '/recommendations/$channel?${queries.join('&')}',
@@ -45,14 +52,14 @@ class PostProvider extends GetConnect {
}
Future<Response> listPost(int page,
{int? realm, String? author, tag, category}) async {
{String? realm, String? author, tag, category}) async {
final queries = [
'take=${10}',
'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) 'realm=$realm',
];
final resp = await get('/posts?${queries.join('&')}');
if (resp.statusCode != 200) {

View File

@@ -1,7 +1,24 @@
import 'package:get/get.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart';
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 {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized');

View File

@@ -0,0 +1,25 @@
import 'dart:convert';
import 'dart:developer';
import 'package:get/get.dart';
import 'package:solian/models/link.dart';
import 'package:solian/services.dart';
class LinkExpandController extends GetxController {
final Map<String, LinkMeta?> _cachedResponse = {};
Future<LinkMeta?> expandLink(String url) async {
final target = utf8.fuse(base64).encode(url);
if (_cachedResponse.containsKey(target)) return _cachedResponse[target];
final client = ServiceFinder.configureClient('dealer');
final resp = await client.get('/api/links/$target');
if (resp.statusCode != 200) {
log('Unable to expand link ($url), status: ${resp.statusCode}, response: ${resp.body}');
_cachedResponse[target] = null;
return null;
}
final result = LinkMeta.fromJson(resp.body);
_cachedResponse[target] = result;
return result;
}
}

View File

@@ -4,15 +4,19 @@ import 'package:solian/models/relations.dart';
import 'package:solian/providers/auth.dart';
class RelationshipProvider extends GetxController {
final RxInt friendRequestCount = 0.obs;
final RxList<Relationship> _friends = RxList.empty(growable: true);
Future<void> refreshFriendList() async {
final resp = await listRelationWithStatus(1);
_friends.value = resp.body
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;
}
bool hasFriend(Account account) {

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

@@ -1,3 +1,5 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/screens/about.dart';
@@ -5,6 +7,7 @@ import 'package:solian/screens/account.dart';
import 'package:solian/screens/account/friend.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_detail.dart';
import 'package:solian/screens/channel/channel_organize.dart';
@@ -93,14 +96,25 @@ abstract class AppRouter {
GoRoute(
path: '/posts/editor',
name: 'postEditor',
builder: (context, state) {
pageBuilder: (context, state) {
final arguments = state.extra as PostPublishArguments?;
return PostPublishScreen(
edit: arguments?.edit,
reply: arguments?.reply,
repost: arguments?.repost,
realm: arguments?.realm,
mode: int.tryParse(state.uri.queryParameters['mode'] ?? '0') ?? 0,
return CustomTransitionPage(
child: PostPublishScreen(
edit: arguments?.edit,
reply: arguments?.reply,
repost: arguments?.repost,
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,
);
},
);
},
),
@@ -212,6 +226,14 @@ abstract class AppRouter {
name: 'accountFriend',
builder: (context, state) => const FriendScreen(),
),
GoRoute(
path: '/account/stickers',
name: 'accountStickers',
builder: (context, state) => TitleShell(
state: state,
child: const StickerScreen(),
),
),
GoRoute(
path: '/account/personalize',
name: 'accountPersonalize',

View File

@@ -17,7 +17,11 @@ class AboutScreen extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
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(
'Solian',
style: Theme.of(context).textTheme.headlineMedium,
@@ -44,17 +48,28 @@ class AboutScreen extends StatelessWidget {
const SizedBox(height: 16),
TextButton(
style: denseButtonStyle,
child: const Text('More Information'),
onPressed: () {
launchUrlString('https://solsynth.dev/products/solar-network');
child: const Text('App Details'),
onPressed: () async {
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(
style: denseButtonStyle,
child: const Text('Project Website'),
onPressed: () {
launchUrlString('https://solsynth.dev');
launchUrlString('https://solsynth.dev/products/solar-network');
},
child: const Text('Official Website'),
),
const SizedBox(height: 16),
const Text(

View File

@@ -3,11 +3,13 @@ import 'package:get/get.dart';
import 'package:solian/models/account.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/account_status.dart';
import 'package:solian/providers/relation.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/auth/signin.dart';
import 'package:solian/screens/auth/signup.dart';
import 'package:solian/widgets/account/account_heading.dart';
import 'package:solian/widgets/sized_container.dart';
import 'package:badges/badges.dart' as badges;
class AccountScreen extends StatefulWidget {
const AccountScreen({super.key});
@@ -23,10 +25,32 @@ class _AccountScreenState extends State<AccountScreen> {
(
const Icon(Icons.color_lens),
'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();
@@ -41,7 +65,10 @@ class _AccountScreenState extends State<AccountScreen> {
mainAxisSize: MainAxisSize.min,
children: [
ActionCard(
icon: const Icon(Icons.login, color: Colors.white),
icon: Icon(
Icons.login,
color: Theme.of(context).colorScheme.onPrimary,
),
title: 'signin'.tr,
caption: 'signinCaption'.tr,
onTap: () {
@@ -59,7 +86,10 @@ class _AccountScreenState extends State<AccountScreen> {
},
),
ActionCard(
icon: const Icon(Icons.add, color: Colors.white),
icon: Icon(
Icons.add,
color: Theme.of(context).colorScheme.onPrimary,
),
title: 'signup'.tr,
caption: 'signupCaption'.tr,
onTap: () {
@@ -184,7 +214,7 @@ class ActionCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
backgroundColor: Colors.indigo,
backgroundColor: Theme.of(context).colorScheme.primary,
child: icon,
).paddingOnly(bottom: 12),
Text(

View File

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

View File

@@ -3,9 +3,11 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart';
import 'package:image_cropper/image_cropper.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/attachment.dart';
import 'package:solian/services.dart';
@@ -34,7 +36,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
bool _isBusy = false;
void selectBirthday() async {
void _selectBirthday() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _birthday?.toLocal(),
@@ -49,48 +51,73 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
}
}
void syncWidget() async {
setState(() => _isBusy = true);
void _syncWidget() async {
_isBusy = true;
final AuthProvider auth = Get.find();
final prof = auth.userProfile.value!;
setState(() {
_usernameController.text = prof['name'];
_nicknameController.text = prof['nick'];
_descriptionController.text = prof['description'];
_firstNameController.text = prof['profile']['first_name'];
_lastNameController.text = prof['profile']['last_name'];
_avatar = prof['avatar'];
_banner = prof['banner'];
if (prof['profile']['birthday'] != null) {
_birthday = DateTime.parse(prof['profile']['birthday']);
_birthdayController.text =
DateFormat('yyyy-MM-dd').format(_birthday!.toLocal());
}
_usernameController.text = prof['name'];
_nicknameController.text = prof['nick'];
_descriptionController.text = prof['description'];
_firstNameController.text = prof['profile']['first_name'];
_lastNameController.text = prof['profile']['last_name'];
_avatar = prof['avatar'];
_banner = prof['banner'];
if (prof['profile']['birthday'] != null) {
_birthday = DateTime.parse(prof['profile']['birthday']);
_birthdayController.text =
DateFormat('yyyy-MM-dd').format(_birthday!.toLocal());
}
_isBusy = false;
});
_isBusy = false;
}
Future<void> updateImage(String position) async {
Future<void> _editImage(String position) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
final image = await _imagePicker.pickImage(source: ImageSource.gallery);
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);
final AttachmentProvider provider = Get.find();
Response? attachResp;
Attachment? attachResult;
try {
final file = File(image.path);
attachResp = await provider.createAttachment(
attachResult = await provider.createAttachmentDirectly(
await file.readAsBytes(),
file.path,
'p.$position',
null
'avatar',
null,
);
} catch (e) {
setState(() => _isBusy = false);
@@ -102,10 +129,10 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
final resp = await client.put(
'/users/me/$position',
{'attachment': attachResp.body['id']},
{'attachment': attachResult.id},
);
if (resp.statusCode == 200) {
syncWidget();
_syncWidget();
context.showSnackbar('accountPersonalizeApplied'.tr);
} else {
context.showErrorDialog(resp.bodyString);
@@ -114,7 +141,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
setState(() => _isBusy = false);
}
void updatePersonalize() async {
void _editUserInfo() async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
@@ -134,7 +161,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
},
);
if (resp.statusCode == 200) {
syncWidget();
_syncWidget();
context.showSnackbar('accountPersonalizeApplied'.tr);
} else {
context.showErrorDialog(resp.bodyString);
@@ -146,8 +173,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () => syncWidget());
_syncWidget();
}
@override
@@ -168,7 +194,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
left: 40,
child: FloatingActionButton.small(
heroTag: const Key('avatar-editor'),
onPressed: () => updateImage('avatar'),
onPressed: () => _editImage('avatar'),
child: const Icon(
Icons.camera,
),
@@ -187,7 +213,8 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: _banner != null
? Image.network(
ServiceFinder.buildUrl('files', '/attachments/$_banner'),
ServiceFinder.buildUrl(
'files', '/attachments/$_banner'),
fit: BoxFit.cover,
loadingBuilder: (BuildContext context, Widget child,
ImageChunkEvent? loadingProgress) {
@@ -212,7 +239,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
right: 16,
child: FloatingActionButton(
heroTag: const Key('banner-editor'),
onPressed: () => updateImage('banner'),
onPressed: () => _editImage('banner'),
child: const Icon(
Icons.camera_alt,
),
@@ -293,18 +320,18 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
border: const OutlineInputBorder(),
labelText: 'birthday'.tr,
),
onTap: () => selectBirthday(),
onTap: () => _selectBirthday(),
).paddingSymmetric(horizontal: padding),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isBusy ? null : () => syncWidget(),
onPressed: _isBusy ? null : () => _syncWidget(),
child: Text('reset'.tr),
),
ElevatedButton(
onPressed: _isBusy ? null : () => updatePersonalize(),
onPressed: _isBusy ? null : () => _editUserInfo(),
child: Text('apply'.tr),
),
],
@@ -325,3 +352,11 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
super.dispose();
}
}
class _BannerCropAspectRatioPreset extends CropAspectRatioPresetData {
@override
(int, int)? get data => (16, 7);
@override
String get name => '16x7';
}

View File

@@ -13,8 +13,8 @@ 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/feed/feed_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 {
@@ -40,7 +40,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
List<Post> _pinnedPosts = List.empty();
int _totalUpvote = 0, _totalDownvote = 0;
Future<void> getUserinfo() async {
Future<void> _getUserinfo() async {
setState(() => _isBusy = true);
var client = ServiceFinder.configureClient('auth');
@@ -114,7 +114,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
}
});
getUserinfo();
_getUserinfo();
getPinnedPosts();
}
@@ -189,8 +189,11 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
: () async {
setState(() => _isMakingFriend = true);
try {
await _relationshipProvider.makeFriend(widget.name);
context.showSnackbar('accountFriendRequestSent'.tr);
await _relationshipProvider
.makeFriend(widget.name);
context.showSnackbar(
'accountFriendRequestSent'.tr,
);
} catch (e) {
context.showErrorDialog(e);
} finally {
@@ -274,6 +277,8 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
color:
Theme.of(context).colorScheme.surfaceContainerLow,
child: PostListEntryWidget(
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerLow,
item: element,
isClickable: true,
isNestedClickable: true,
@@ -292,9 +297,10 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
child: Center(child: CircularProgressIndicator()),
),
if (_userinfo != null)
FeedListWidget(
PostWarpedListWidget(
isPinned: false,
controller: _postController.pagingController,
onUpdate: () => _postController.reloadAllOver(),
),
]),
),

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.attachment.rid}',
);
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

@@ -79,8 +79,8 @@ class _SignInPopupState extends State<SignInPopup> with ProtocolListener {
onPressed: () {
const redirect = 'solink://auth?status=done';
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,
);
Navigator.pop(context);
@@ -136,8 +136,10 @@ class _SignInPopupState extends State<SignInPopup> with ProtocolListener {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Image.asset('assets/logo.png', width: 64, height: 64)
.paddingOnly(bottom: 4),
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Image.asset('assets/logo.png', width: 64, height: 64),
).paddingOnly(bottom: 4),
Text(
'signinGreeting'.tr,
style: const TextStyle(

View File

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

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:async/async.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/providers/call.dart';
@@ -8,140 +9,349 @@ import 'package:solian/theme.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_participant.dart';
import 'package:livekit_client/livekit_client.dart' as livekit;
class CallScreen extends StatefulWidget {
const CallScreen({super.key});
final bool hideAppBar;
const CallScreen({super.key, this.hideAppBar = false});
@override
State<CallScreen> createState() => _CallScreenState();
}
class _CallScreenState extends State<CallScreen> {
Timer? timer;
String currentDuration = '00:00:00';
class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
int _layoutMode = 0;
String parseDuration() {
final ChatCallProvider provider = Get.find();
if (provider.current.value == null) return '00:00:00';
Duration duration =
DateTime.now().difference(provider.current.value!.createdAt);
bool _showControls = true;
CancelableOperation? _hideControlsOperation;
String twoDigits(int n) => n.toString().padLeft(2, '0');
String formattedTime = '${twoDigits(duration.inHours)}:'
'${twoDigits(duration.inMinutes.remainder(60))}:'
'${twoDigits(duration.inSeconds.remainder(60))}';
late final AnimationController _controlsAnimationController =
AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
late final Animation<double> _controlsAnimation = CurvedAnimation(
parent: _controlsAnimationController,
curve: Curves.fastOutSlowIn,
);
return formattedTime;
void _switchLayout() {
if (_layoutMode < 1) {
setState(() => _layoutMode++);
} else {
setState(() => _layoutMode = 0);
}
}
void updateDuration() {
setState(() {
currentDuration = parseDuration();
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);
});
}
@override
void initState() {
Get.find<ChatCallProvider>().setupRoom();
super.initState();
timer = Timer.periodic(const Duration(seconds: 1), (_) => updateDuration());
Future.delayed(Duration.zero, () {
Get.find<ChatCallProvider>()
..setupRoom()
..enableDurationUpdater();
_planAutoHideControls();
});
}
@override
void dispose() {
_controlsAnimationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final ChatCallProvider provider = Get.find();
final ChatCallProvider ctrl = Get.find();
return Material(
color: Theme.of(context).colorScheme.surface,
child: Scaffold(
appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context),
centerTitle: true,
toolbarHeight: SolianTheme.toolbarHeight(context),
title: RichText(
textAlign: TextAlign.center,
text: TextSpan(children: [
TextSpan(
text: 'call'.tr,
style: Theme.of(context).textTheme.titleLarge,
),
const TextSpan(text: '\n'),
TextSpan(
text: currentDuration,
style: Theme.of(context).textTheme.bodySmall,
),
]),
),
),
body: SafeArea(
child: Obx(
() => Stack(
children: [
Column(
children: [
Expanded(
child: Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: provider.focusTrack.value != null
? InteractiveParticipantWidget(
isFixed: false,
participant: provider.focusTrack.value!,
onTap: () {},
)
: const SizedBox(),
appBar: widget.hideAppBar
? null
: AppBar(
leading: AppBarLeadingButton.adaptive(context),
centerTitle: true,
toolbarHeight: SolianTheme.toolbarHeight(context),
title: Obx(
() => RichText(
textAlign: TextAlign.center,
text: TextSpan(children: [
TextSpan(
text: 'call'.tr,
style: Theme.of(context).textTheme.titleLarge,
),
),
if (provider.room.localParticipant != null)
ControlsWidget(
provider.room,
provider.room.localParticipant!,
const TextSpan(text: '\n'),
TextSpan(
text: ctrl.lastDuration.value,
style: Theme.of(context).textTheme.bodySmall,
),
],
]),
),
),
Positioned(
left: 0,
right: 0,
top: 0,
),
body: SafeArea(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
child: Column(
children: [
SizeTransition(
sizeFactor: _controlsAnimation,
axis: Axis.vertical,
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: () {
if (track.participant.sid !=
provider
.focusTrack.value?.participant.sid) {
provider.changeFocusTrack(track);
}
},
width: MediaQuery.of(context).size.width,
height: 64,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Builder(builder: (context) {
final call = Get.find<ChatCallProvider>();
final connectionQuality =
call.room.localParticipant?.connectionQuality ??
livekit.ConnectionQuality.unknown;
return Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Obx(() {
return Row(
children: [
Text(
call.channel.value?.name ??
'unknown'.tr,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 6),
Text(call.lastDuration.value)
],
);
}),
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,
)
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,
child: Builder(
builder: (context) {
switch (_layoutMode) {
case 1:
return _buildGridLayout();
default:
return _buildListLayout();
}
},
),
),
),
if (ctrl.room.localParticipant != null)
SizeTransition(
sizeFactor: _controlsAnimation,
axis: Axis.vertical,
child: SizedBox(
width: MediaQuery.of(context).size.width,
child: ControlsWidget(
ctrl.room,
ctrl.room.localParticipant!,
),
),
),
],
),
onTap: () {
_toggleControls();
},
),
),
),
@@ -150,17 +360,13 @@ class _CallScreenState extends State<CallScreen> {
@override
void deactivate() {
timer?.cancel();
timer = null;
Get.find<ChatCallProvider>().disableDurationUpdater();
super.deactivate();
}
@override
void activate() {
timer ??= Timer.periodic(
const Duration(seconds: 1),
(_) => updateDuration(),
);
Get.find<ChatCallProvider>().enableDurationUpdater();
super.activate();
}
}

View File

@@ -15,13 +15,13 @@ import 'package:solian/providers/call.dart';
import 'package:solian/providers/content/channel.dart';
import 'package:solian/providers/websocket.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/channel/call/call.dart';
import 'package:solian/screens/channel/channel_detail.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.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/chat_event.dart';
import 'package:solian/widgets/chat/chat_event_list.dart';
import 'package:solian/widgets/chat/chat_message_input.dart';
import 'package:solian/widgets/current_state_action.dart';
@@ -40,7 +40,10 @@ class ChannelChatScreen extends StatefulWidget {
State<ChannelChatScreen> createState() => _ChannelChatScreenState();
}
class _ChannelChatScreenState extends State<ChannelChatScreen> {
class _ChannelChatScreenState extends State<ChannelChatScreen>
with WidgetsBindingObserver, TickerProviderStateMixin {
DateTime? _isOutOfSyncSince;
bool _isBusy = false;
int? _accountId;
@@ -53,7 +56,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
late final ChatEventController _chatController;
getChannel({String? alias}) async {
_getChannel({String? alias}) async {
final ChannelProvider provider = Get.find();
setState(() => _isBusy = true);
@@ -80,7 +83,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
setState(() => _isBusy = false);
}
getOngoingCall() async {
_getOngoingCall() async {
final ChannelProvider provider = Get.find();
setState(() => _isBusy = true);
@@ -100,7 +103,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
setState(() => _isBusy = false);
}
void listenMessages() {
void _listenMessages() {
final WebSocketProvider provider = Get.find();
_subscription = provider.stream.stream.listen((event) {
switch (event.method) {
@@ -110,66 +113,66 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
break;
case 'calls.new':
final payload = Call.fromJson(event.payload!);
setState(() => _ongoingCall = payload);
if (payload.channel.id == _channel!.id) {
setState(() => _ongoingCall = payload);
}
break;
case 'calls.end':
setState(() => _ongoingCall = null);
final payload = Call.fromJson(event.payload!);
if (payload.channel.id == _channel!.id) {
setState(() => _ongoingCall = null);
}
break;
}
});
}
void showCallPrejoin() {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => ChatCallPrejoinPopup(
ongoingCall: _ongoingCall!,
channel: _channel!,
),
);
void _keepUpdateWithServer() {
_getOngoingCall();
_chatController.getEvents(_channel!, widget.realm);
setState(() => _isOutOfSyncSince = null);
}
Event? _messageToReplying;
Event? _messageToEditing;
Widget buildHistoryBody(Event item, {bool isMerged = false}) {
return ChatEvent(
key: Key('m${item.uuid}'),
item: item,
isMerged: isMerged,
chatController: _chatController,
);
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
if (_isOutOfSyncSince == null) break;
if (DateTime.now().difference(_isOutOfSyncSince!).inSeconds < 60) break;
_keepUpdateWithServer();
break;
case AppLifecycleState.paused:
if (mounted) {
setState(() => _isOutOfSyncSince = DateTime.now());
}
break;
default:
break;
}
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_accountId = Get.find<AuthProvider>().userProfile.value!['id'];
_chatController = ChatEventController();
_chatController.initialize();
getChannel().then((_) {
_getOngoingCall();
_getChannel().then((_) {
_chatController.getEvents(_channel!, widget.realm);
listenMessages();
_listenMessages();
});
getOngoingCall();
super.initState();
}
@override
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? placeholder;
@@ -183,8 +186,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
}
final ChatCallProvider call = Get.find();
return Scaffold(
appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context),
@@ -195,7 +196,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
actions: [
const BackgroundStateWidget(),
Builder(builder: (context) {
if (_isBusy) return const SizedBox();
if (_isBusy || _channel == null) return const SizedBox();
return ChatCallButton(
realm: _channel!.realm,
channel: _channel!,
@@ -205,6 +207,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () {
if (_channel == null) return;
AppRouter.instance
.pushNamed(
'channelDetail',
@@ -219,7 +223,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
if (value == false) AppRouter.instance.pop();
if (value != null) {
final resp = Channel.fromJson(value as Map<String, dynamic>);
getChannel(alias: resp.alias);
_getChannel(alias: resp.alias);
}
});
},
@@ -229,89 +233,121 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
],
),
body: Column(
children: [
if (_ongoingCall != null)
MaterialBanner(
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),
);
}
})
],
),
Expanded(
child: ChatEventList(
scope: widget.realm,
channel: _channel!,
chatController: _chatController,
onEdit: (item) {
setState(() => _messageToEditing = item);
},
onReply: (item) {
setState(() => _messageToReplying = item);
},
),
),
Obx(() {
if (_chatController.isLoading.isTrue) {
return const LinearProgressIndicator().animate().slideY();
} else {
return const SizedBox();
}
}),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50),
child: SafeArea(
child: ChatMessageInput(
edit: _messageToEditing,
reply: _messageToReplying,
realm: widget.realm,
placeholder: placeholder,
channel: _channel!,
onSent: (Event item) {
setState(() {
_chatController.addPendingEvent(item);
});
},
onReset: () {
setState(() {
_messageToReplying = null;
_messageToEditing = null;
});
},
),
body: Builder(builder: (context) {
if (_isBusy || _channel == null) {
return const Center(
child: CircularProgressIndicator(),
);
}
return Row(
children: [
Expanded(
child: Column(
children: [
if (_ongoingCall != null)
ChannelCallIndicator(
channel: _channel!,
ongoingCall: _ongoingCall!,
onJoin: () {
if (!SolianTheme.isLargeScreen(context)) {
final ChatCallProvider call = Get.find();
call.gotoScreen(context);
}
},
),
Expanded(
child: ChatEventList(
scope: widget.realm,
channel: _channel!,
chatController: _chatController,
onEdit: (item) {
setState(() => _messageToEditing = item);
},
onReply: (item) {
setState(() => _messageToReplying = item);
},
),
),
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(() {
if (_chatController.isLoading.isTrue) {
return const LinearProgressIndicator().animate().slideY();
} else {
return const SizedBox();
}
}),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50),
child: SafeArea(
child: ChatMessageInput(
edit: _messageToEditing,
reply: _messageToReplying,
realm: widget.realm,
placeholder: placeholder,
channel: _channel!,
onSent: (Event item) {
setState(() {
_chatController.addPendingEvent(item);
});
},
onReset: () {
setState(() {
_messageToReplying = null;
_messageToEditing = null;
});
},
),
),
),
),
],
),
),
),
],
),
Obx(() {
final ChatCallProvider call = Get.find();
if (call.isMounted.value && SolianTheme.isLargeScreen(context)) {
return const Expanded(
child: Row(children: [
VerticalDivider(width: 0.3, thickness: 0.3),
Expanded(
child: CallScreen(hideAppBar: true),
),
]),
);
}
return const SizedBox();
}),
],
);
}),
);
}
@override
void dispose() {
_subscription?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
}

View File

@@ -21,7 +21,7 @@ class _DraftBoxScreenState extends State<DraftBoxScreen> {
final PagingController<int, Post> _pagingController =
PagingController(firstPageKey: 0);
getPosts(int pageKey) async {
_getPosts(int pageKey) async {
final PostProvider provider = Get.find();
Response resp;
@@ -49,7 +49,7 @@ class _DraftBoxScreenState extends State<DraftBoxScreen> {
@override
void initState() {
super.initState();
_pagingController.addPageRequestListener(getPosts);
_pagingController.addPageRequestListener(_getPosts);
}
@override
@@ -76,6 +76,9 @@ class _DraftBoxScreenState extends State<DraftBoxScreen> {
itemBuilder: (context, item, index) {
return PostOwnedListEntry(
item: item,
isFullContent: true,
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerLow,
onTap: () async {
showModalBottomSheet(
useRootNavigator: true,
@@ -85,7 +88,13 @@ class _DraftBoxScreenState extends State<DraftBoxScreen> {
noReact: true,
),
).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);

View File

@@ -3,7 +3,7 @@ import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/pagination.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';
@@ -77,7 +77,10 @@ class _FeedSearchScreenState extends State<FeedSearchScreen> {
onRefresh: () => Future.sync(() => _pagingController.refresh()),
child: CustomScrollView(
slivers: [
FeedListWidget(controller: _pagingController),
PostWarpedListWidget(
controller: _pagingController,
onUpdate: () => _pagingController.refresh(),
),
],
),
),

View File

@@ -5,11 +5,12 @@ import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/account/notification.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/account/signin_required_overlay.dart';
import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/current_state_action.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_warped_list.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@@ -27,32 +28,37 @@ class _HomeScreenState extends State<HomeScreen>
void initState() {
super.initState();
_postController = PostListController();
_tabController = TabController(length: 2, vsync: this);
_tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() {
switch (_tabController.index) {
case 0:
case 1:
if (_postController.mode.value == _tabController.index) return;
_postController.mode.value = _tabController.index;
_postController.reloadAllOver();
}
if (_postController.mode.value == _tabController.index) return;
_postController.mode.value = _tabController.index;
_postController.reloadAllOver();
});
}
@override
Widget build(BuildContext context) {
final AuthProvider auth = Get.find();
return Material(
color: Theme.of(context).colorScheme.surface,
child: Scaffold(
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () {
showModalBottomSheet(
onPressed: () async {
final value = await showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
context: context,
builder: (context) => const PostCreatePopup(),
);
if (value is Future) {
value.then((_) {
_postController.reloadAllOver();
});
} else if (value != null) {
_postController.reloadAllOver();
}
},
),
body: NestedScrollView(
@@ -75,6 +81,7 @@ class _HomeScreenState extends State<HomeScreen>
controller: _tabController,
tabs: [
Tab(text: 'postListNews'.tr),
Tab(text: 'postListFriends'.tr),
Tab(text: 'postListShuffle'.tr),
],
),
@@ -95,11 +102,29 @@ class _HomeScreenState extends State<HomeScreen>
RefreshIndicator(
onRefresh: () => _postController.reloadAllOver(),
child: CustomScrollView(slivers: [
FeedListWidget(
PostWarpedListWidget(
controller: _postController.pagingController,
onUpdate: () => _postController.reloadAllOver(),
),
]),
),
Obx(() {
if (auth.isAuthorized.value) {
return RefreshIndicator(
onRefresh: () => _postController.reloadAllOver(),
child: CustomScrollView(slivers: [
PostWarpedListWidget(
controller: _postController.pagingController,
onUpdate: () => _postController.reloadAllOver(),
),
]),
);
} else {
return SigninRequiredOverlay(
onSignedIn: () => _postController.reloadAllOver(),
);
}
}),
PostShuffleSwiper(controller: _postController),
],
);
@@ -118,12 +143,10 @@ class _HomeScreenState extends State<HomeScreen>
class PostCreatePopup extends StatelessWidget {
final bool hideDraftBox;
final Function? onCreated;
const PostCreatePopup({
super.key,
this.hideDraftBox = false,
this.onCreated,
});
@override
@@ -139,32 +162,40 @@ class PostCreatePopup extends StatelessWidget {
icon: const Icon(Icons.post_add),
label: 'postEditorModeStory'.tr,
onTap: () {
Navigator.pop(context);
AppRouter.instance.pushNamed('postEditor', queryParameters: {
'mode': 0.toString(),
}).then((val) {
if (val != null && onCreated != null) onCreated!();
});
Navigator.pop(
context,
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(),
}).then((val) {
if (val != null && onCreated != null) onCreated!();
});
Navigator.pop(
context,
AppRouter.instance.pushNamed(
'postEditor',
queryParameters: {
'mode': 1.toString(),
},
),
);
},
),
(
icon: const Icon(Icons.drafts),
label: 'draftBoxOpen'.tr,
onTap: () {
Navigator.pop(context);
AppRouter.instance.pushNamed('draftBox');
Navigator.pop(
context,
AppRouter.instance.pushNamed('draftBox'),
);
},
),
];
@@ -194,7 +225,12 @@ class PostCreatePopup extends StatelessWidget {
children: [
x.icon,
const SizedBox(height: 8),
Text(x.label),
Expanded(
child: Text(
x.label,
overflow: TextOverflow.fade,
),
),
],
).paddingAll(18),
),

View File

@@ -3,14 +3,18 @@ import 'package:get/get.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/post.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_replies.dart';
class PostDetailScreen extends StatefulWidget {
final String id;
final Post? post;
const PostDetailScreen({super.key, required this.id});
const PostDetailScreen({
super.key,
required this.id,
this.post,
});
@override
State<PostDetailScreen> createState() => _PostDetailScreenState();
@@ -20,6 +24,11 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
Post? item;
Future<Post?> getDetail() async {
if (widget.post != null) {
item = widget.post;
return widget.post;
}
final PostProvider provider = Get.find();
try {
@@ -48,14 +57,14 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: CenteredContainer(
child: PostItem(
item: item!,
isClickable: true,
isFullDate: true,
isShowReply: false,
isContentSelectable: true,
),
child: PostItem(
item: item!,
isClickable: false,
isOverrideEmbedClickable: true,
isFullDate: true,
isFullContent: true,
isShowReply: false,
isContentSelectable: true,
),
),
SliverToBoxAdapter(
@@ -63,14 +72,12 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
.paddingOnly(top: 4),
),
SliverToBoxAdapter(
child: CenteredContainer(
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'postReplies'.tr,
style: Theme.of(context).textTheme.headlineSmall,
).paddingOnly(left: 24, right: 24, top: 16),
),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'postReplies'.tr,
style: Theme.of(context).textTheme.headlineSmall,
).paddingOnly(left: 24, right: 24, top: 16),
),
),
PostReplyList(item: item!),

View File

@@ -3,15 +3,18 @@ import 'package:flutter_animate/flutter_animate.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.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/models/post.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/attachment_uploader.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/markdown_text_content.dart';
import 'package:solian/widgets/posts/post_item.dart';
import 'package:badges/badges.dart' as badges;
@@ -21,7 +24,12 @@ class PostPublishArguments {
final Post? repost;
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 {
@@ -46,6 +54,7 @@ class PostPublishScreen extends StatefulWidget {
class _PostPublishScreenState extends State<PostPublishScreen> {
final _editorController = PostEditorController();
final _contentFocusNode = FocusNode();
bool _isBusy = false;
@@ -54,6 +63,14 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
if (auth.isAuthorized.isFalse) return;
if (_editorController.isEmpty) return;
final AttachmentUploaderController uploader = Get.find();
if (uploader.queueOfUpload.any(
((x) => x.isUploading),
)) {
context.showErrorDialog('attachmentUploadInProgress'.tr);
return;
}
setState(() => _isBusy = true);
final client = auth.configureClient('interactive');
@@ -73,6 +90,7 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString);
} else {
_editorController.currentClear();
_editorController.localClear();
AppRouter.instance.pop(resp.body);
}
@@ -80,28 +98,48 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
setState(() => _isBusy = false);
}
void syncWidget() {
void _syncWidget() {
_editorController.mode.value = widget.mode;
if (widget.edit != null) {
_editorController.editTarget = widget.edit;
}
if (widget.realm != null) {
_editorController.realmZone.value = widget.realm;
}
}
void cancelAction() {
void _cancelAction() {
_editorController.localClear();
AppRouter.instance.pop();
}
Post? get _editTo => _editorController.editTo.value;
Post? get _replyTo => _editorController.replyTo.value;
Post? get _repostTo => _editorController.repostTo.value;
@override
void initState() {
super.initState();
syncWidget();
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
Widget build(BuildContext context) {
final notifyBannerActions = [
TextButton(
onPressed: cancelAction,
onPressed: _cancelAction,
child: Text('cancel'.tr),
)
];
@@ -134,13 +172,23 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
],
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainerLow,
title: Text(
_editorController.title ?? 'title'.tr,
maxLines: 1,
overflow: TextOverflow.ellipsis,
title: Row(
children: [
Text(
_editorController.title ?? 'title'.tr,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(width: 6),
if (_editorController.aliasController.text.isNotEmpty)
Badge(
label: Text('#${_editorController.aliasController.text}'),
),
],
),
subtitle: Text(
_editorController.description ?? 'description'.tr,
@@ -162,88 +210,170 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
},
),
),
Expanded(
child: ListView(
if (_editTo != null && _editTo!.isDraft != true)
MaterialBanner(
leading: const Icon(Icons.edit),
leadingPadding: const EdgeInsets.only(left: 10, right: 20),
dividerColor: Colors.transparent,
content: Text('postEditingNotify'.tr),
actions: notifyBannerActions,
),
if (_replyTo != null)
ExpansionTile(
leading: const FaIcon(
FontAwesomeIcons.reply,
size: 18,
).paddingOnly(left: 2),
title: Text('postReplyingNotify'.trParams(
{'username': '@${widget.reply!.author.name}'},
)),
collapsedBackgroundColor:
Theme.of(context).colorScheme.surfaceContainer,
children: [
if (_isBusy)
const LinearProgressIndicator().animate().scaleX(),
if (widget.edit != null && widget.edit!.isDraft != true)
MaterialBanner(
leading: const Icon(Icons.edit),
leadingPadding:
const EdgeInsets.only(left: 10, right: 20),
dividerColor: Colors.transparent,
content: Text('postEditingNotify'.tr),
actions: notifyBannerActions,
),
if (widget.reply != null)
ExpansionTile(
leading: const FaIcon(
FontAwesomeIcons.reply,
size: 18,
).paddingOnly(left: 2),
title: Text('postReplyingNotify'.trParams(
{'username': '@${widget.reply!.author.name}'},
)),
collapsedBackgroundColor:
Theme.of(context).colorScheme.surfaceContainer,
children: [
PostItem(
item: widget.reply!,
isReactable: false,
).paddingOnly(bottom: 8),
],
),
if (widget.repost != null)
ExpansionTile(
leading: const FaIcon(
FontAwesomeIcons.retweet,
size: 18,
).paddingOnly(left: 2),
title: Text('postRepostingNotify'.trParams(
{'username': '@${widget.repost!.author.name}'},
)),
collapsedBackgroundColor:
Theme.of(context).colorScheme.surfaceContainer,
children: [
PostItem(
item: widget.repost!,
isReactable: false,
).paddingOnly(bottom: 8),
],
),
if (widget.realm != null)
MaterialBanner(
leading: const Icon(Icons.group),
leadingPadding:
const EdgeInsets.only(left: 10, right: 20),
dividerColor: Colors.transparent,
content: Text(
'postInRealmNotify'
.trParams({'realm': '#${widget.realm!.alias}'}),
),
actions: notifyBannerActions,
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: TextField(
maxLines: null,
autofocus: true,
autocorrect: true,
keyboardType: TextInputType.multiline,
controller: _editorController.contentController,
decoration: InputDecoration(
border: InputBorder.none,
hintText: 'postContentPlaceholder'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
constraints: const BoxConstraints(maxHeight: 280),
child: SingleChildScrollView(
child: PostItem(
item: _replyTo!,
isReactable: false,
).paddingOnly(bottom: 8),
),
),
const SizedBox(height: 120)
],
),
if (_repostTo != null)
ExpansionTile(
leading: const FaIcon(
FontAwesomeIcons.retweet,
size: 18,
).paddingOnly(left: 2),
title: Text('postRepostingNotify'.trParams(
{'username': '@${widget.repost!.author.name}'},
)),
collapsedBackgroundColor:
Theme.of(context).colorScheme.surfaceContainer,
children: [
Container(
constraints: const BoxConstraints(maxHeight: 280),
child: SingleChildScrollView(
child: PostItem(
item: _repostTo!,
isReactable: false,
).paddingOnly(bottom: 8),
),
),
],
),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
children: [
Expanded(
child: ListView(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: TextField(
maxLines: null,
autofocus: true,
autocorrect: true,
keyboardType: TextInputType.multiline,
controller:
_editorController.contentController,
focusNode: _contentFocusNode,
decoration: InputDecoration.collapsed(
hintText: 'postContentPlaceholder'.tr,
),
onTapOutside: (_) => FocusManager
.instance.primaryFocus
?.unfocus(),
),
),
const SizedBox(height: 120)
],
),
),
Obx(() {
final textStyle = TextStyle(
fontSize: 12,
color: Theme.of(context)
.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 (SolianTheme.isLargeScreen(context))
const VerticalDivider(width: 0.3, thickness: 0.3)
.paddingSymmetric(
horizontal: 16,
),
if (SolianTheme.isLargeScreen(context))
Expanded(
child: SingleChildScrollView(
child: MarkdownTextContent(
content: _editorController.contentController.text,
parentId: 'post-editor-preview',
).paddingOnly(top: 12, right: 16),
),
),
],
),
),
@@ -252,85 +382,39 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Obx(() {
final textStyle = TextStyle(
fontSize: 12,
color: Theme.of(context)
.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(
height: 56,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
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, _) => SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 3,
backgroundColor: Theme.of(context)
.colorScheme
.secondaryContainer,
color: _editorController.contentLength.value >
4096
? Colors.red[900]
: Theme.of(context).colorScheme.primary,
value: value,
),
).paddingAll(10),
),
).paddingSymmetric(horizontal: 4),
Obx(() {
final isDraft = _editorController.isDraft.value;
return IconButton(
@@ -383,12 +467,90 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
},
),
IconButton(
icon: const Icon(Icons.tag),
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),
),
@@ -403,6 +565,7 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
@override
void dispose() {
_contentFocusNode.dispose();
_editorController.dispose();
super.dispose();
}

View File

@@ -26,7 +26,7 @@ class _RealmListScreenState extends State<RealmListScreen> {
final List<Realm> _realms = List.empty(growable: true);
getRealms() async {
_getRealms() async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
@@ -48,7 +48,7 @@ class _RealmListScreenState extends State<RealmListScreen> {
@override
void initState() {
super.initState();
getRealms();
_getRealms();
}
@override
@@ -71,7 +71,7 @@ class _RealmListScreenState extends State<RealmListScreen> {
onPressed: () {
AppRouter.instance.pushNamed('realmOrganizing').then(
(value) {
if (value != null) getRealms();
if (value != null) _getRealms();
},
);
},
@@ -84,7 +84,7 @@ class _RealmListScreenState extends State<RealmListScreen> {
body: Obx(() {
if (auth.isAuthorized.isFalse) {
return SigninRequiredOverlay(
onSignedIn: () => getRealms(),
onSignedIn: () => _getRealms(),
);
}
@@ -94,12 +94,12 @@ class _RealmListScreenState extends State<RealmListScreen> {
Expanded(
child: CenteredContainer(
child: RefreshIndicator(
onRefresh: () => getRealms(),
onRefresh: () => _getRealms(),
child: ListView.builder(
itemCount: _realms.length,
itemBuilder: (context, 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(
child: ClipRRect(
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/router.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/widgets/app_bar_leading.dart';
import 'package:solian/widgets/channel/channel_list.dart';
@@ -136,6 +135,7 @@ class _RealmViewScreenState extends State<RealmViewScreen> {
}
return TabBarView(
physics: const NeverScrollableScrollPhysics(),
children: [
RealmPostListWidget(realm: _realm!),
RealmChannelListWidget(
@@ -171,7 +171,7 @@ class _RealmPostListWidgetState extends State<RealmPostListWidget> {
Response resp;
try {
resp = await provider.listPost(pageKey, realm: widget.realm.id);
resp = await provider.listPost(pageKey, realm: widget.realm.alias);
} catch (e) {
_pagingController.error = e;
return;
@@ -189,7 +189,6 @@ class _RealmPostListWidgetState extends State<RealmPostListWidget> {
@override
void initState() {
super.initState();
_pagingController.addPageRequestListener(getPosts);
}
@@ -199,28 +198,6 @@ class _RealmPostListWidgetState extends State<RealmPostListWidget> {
onRefresh: () => Future.sync(() => _pagingController.refresh()),
child: CustomScrollView(
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),
],
),

View File

@@ -1,9 +1,11 @@
import 'dart:ui';
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 {
@@ -30,22 +32,16 @@ class _SettingScreenState extends State<SettingScreen> {
icon: Icon(Icons.circle, color: color),
tooltip: label,
onPressed: () {
currentLightTheme = SolianTheme.build(
Brightness.light,
seedColor: color,
);
currentDarkTheme = SolianTheme.build(
Brightness.dark,
seedColor: color,
);
if (!Get.isDarkMode) {
Get.changeTheme(
SolianTheme.build(Brightness.light, seedColor: color),
);
} else {
// Dark mode cannot be hot reload
// https://github.com/jonataslaw/getx/issues/1411
}
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);
@@ -85,6 +81,33 @@ class _SettingScreenState extends State<SettingScreen> {
.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,15 +5,15 @@ abstract class ServiceFinder {
static const String dealerUrl =
devFlag ? 'http://localhost:8442' : 'https://api.sn.solsynth.dev';
static const String passportUrl =
devFlag ? 'http://localhost:8444' : 'https://id.solsynth.dev';
static const String capitalUrl =
devFlag ? 'http://localhost:8444' : 'https://solsynth.dev';
static String buildUrl(String serviceName, String? append) {
append ??= '';
if (serviceName == 'dealer') {
return '$dealerUrl$append';
} else if (serviceName == 'passport') {
return '$passportUrl$append';
} else if (serviceName == 'capital') {
return '$capitalUrl$append';
}
return '$dealerUrl/cgi/$serviceName$append';
}

View File

@@ -1,3 +1,4 @@
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:solian/theme.dart';
@@ -29,6 +30,15 @@ class RootShell extends StatelessWidget {
Widget build(BuildContext context) {
final routeName = state.topRoute?.name;
if (routeName != null) {
FirebaseAnalytics.instance.logEvent(
name: 'screen_view',
parameters: {
'firebase_screen': routeName,
},
);
}
return Scaffold(
key: rootScaffoldKey,
drawer: SolianTheme.isLargeScreen(context)

View File

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

View File

@@ -1,9 +1,6 @@
import 'package:flutter/material.dart';
import 'package:solian/platform.dart';
ThemeData? currentLightTheme = SolianTheme.build(Brightness.light);
ThemeData? currentDarkTheme = SolianTheme.build(Brightness.dark);
abstract class SolianTheme {
static bool isLargeScreen(BuildContext context) =>
MediaQuery.of(context).size.width > 640;
@@ -39,11 +36,11 @@ abstract class SolianTheme {
seedColor: seedColor ?? const Color.fromRGBO(154, 98, 91, 1),
),
fontFamily: 'Comfortaa',
fontFamilyFallback: const [
fontFamilyFallback: [
'NotoSansSC',
'NotoSansHK',
'NotoSansJP',
'NotoSansEmoji'
if (PlatformInfo.isWeb) 'NotoSansEmoji',
],
typography: Typography.material2021(
colorScheme: brightness == Brightness.light

View File

@@ -10,8 +10,12 @@ const i18nEnglish = {
'draft': 'Draft',
'draftSave': 'Save',
'draftBox': 'Draft Box',
'more': 'More',
'share': 'Share',
'shareNoUri': 'Share text content',
'alias': 'Alias',
'feed': 'Feed',
'unlink': 'Unlink',
'feedSearch': 'Search Feed',
'feedSearchWithTag': 'Searching with tag #@key',
'feedSearchWithCategory': 'Searching in category @category',
@@ -33,6 +37,7 @@ const i18nEnglish = {
'article': 'Article',
'reply': 'Reply',
'repost': 'Repost',
'openInAlbum': 'Open in album',
'openInBrowser': 'Open in browser',
'notification': 'Notification',
'errorHappened': 'An error occurred',
@@ -49,6 +54,7 @@ const i18nEnglish = {
'account': 'Account',
'accountPersonalize': 'Personalize',
'accountPersonalizeApplied': 'Account personalize settings has been saved.',
'accountStickers': 'Stickers',
'accountFriend': 'Friend',
'accountFriendNew': 'New friend',
'accountFriendNewHint':
@@ -100,6 +106,11 @@ const i18nEnglish = {
'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',
@@ -109,8 +120,12 @@ const i18nEnglish = {
'postVisibleUsers': 'Visible users',
'postInvisibleUsers': 'Invisible users',
'postOverview': 'Overview',
'postThumbnail': 'Thumbnail',
'postThumbnailAttachmentNew': 'Upload thumbnail',
'postThumbnailAttachment': 'Attachment serial number',
'postPinned': 'Pinned',
'postListNews': 'News',
'postListFriends': 'Friends',
'postListShuffle': 'Random',
'postEditorModeStory': 'Post a post',
'postEditorModeArticle': 'Post an article',
@@ -123,7 +138,7 @@ const i18nEnglish = {
'postAction': 'Post',
'postEdited': 'Edited at @date',
'postNewCreated': 'Created at @date',
'postAttachmentTip': '@count attachment(s)',
'attachmentHint': '@count attachment(s)',
'postInRealm': 'In @realm',
'postDetail': 'Post',
'postReplies': 'Replies',
@@ -151,13 +166,25 @@ const i18nEnglish = {
'reactCompleted': 'Your reaction has been added',
'reactUncompleted': 'Your reaction has been removed',
'attachmentUploadBy': 'Upload by',
'attachmentAdd': 'Attach attachments',
'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 file',
'attachmentAddGalleryPhoto': 'Gallery photo',
'attachmentAddGalleryVideo': 'Gallery video',
'attachmentAddCameraPhoto': 'Capture photo',
'attachmentAddCameraVideo': 'Capture video',
'attachmentAddClipboard': 'Paste file',
'attachmentAddFile': 'Attach file',
'attachmentAddLink': 'Link attachments',
'attachmentAddLinkHint':
'Enter attachment serial number to link that attachment',
'attachmentAddLinkInput': 'Serial number',
'attachmentSetting': 'Adjust attachment',
'attachmentAlt': 'Alternative text',
'attachmentLoadFailed': 'Load Attachment Failed',
@@ -240,6 +267,9 @@ const i18nEnglish = {
'Are your sure to delete message @id? This action cannot be undone!',
'call': 'Call',
'callOngoing': 'A call is ongoing...',
'callOngoingEmpty': 'A call is on hold...',
'callOngoingParticipants': '@count people are calling...',
'callOngoingJoined': 'Call last @duration',
'callJoin': 'Join',
'callResume': 'Resume',
'callMicrophone': 'Microphone',
@@ -295,6 +325,13 @@ const i18nEnglish = {
'accountStatusNeutral': 'Neutral',
'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',
@@ -303,6 +340,7 @@ const i18nEnglish = {
'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',
@@ -312,6 +350,35 @@ const i18nEnglish = {
'themeColorMiku': 'Miku Blue',
'themeColorKagamine': 'Kagamine Yellow',
'themeColorLuka': 'Luka Pink',
'themeColorApplied':
'Global theme color has been applied, dark mode theme need restart to get applied.',
'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',
'unknown': 'Unknown',
};

View File

@@ -18,8 +18,12 @@ const i18nSimplifiedChinese = {
'draft': '草稿',
'draftSave': '存为草稿',
'draftBox': '草稿箱',
'more': '更多',
'share': '分享',
'shareNoUri': '分享文字内容',
'alias': '别名',
'feed': '资讯',
'unlink': '移除链接',
'feedSearch': '搜索资讯',
'feedSearchWithTag': '检索带有 #@key 标签的资讯',
'feedSearchWithCategory': '检索位于分类 @category 的资讯',
@@ -33,6 +37,7 @@ const i18nSimplifiedChinese = {
'article': '文章',
'reply': '回复',
'repost': '转帖',
'openInAlbum': '在相簿中打开',
'openInBrowser': '在浏览器中打开',
'notification': '通知',
'errorHappened': '发生错误了',
@@ -49,6 +54,7 @@ const i18nSimplifiedChinese = {
'account': '账号',
'accountPersonalize': '个性化',
'accountPersonalizeApplied': '账户的个性化设置已保存。',
'accountStickers': '贴图',
'accountFriend': '好友',
'accountFriendNew': '添加好友',
'accountFriendNewHint': '使用他人的用户名来发送一个好友请求吧!',
@@ -94,6 +100,11 @@ const i18nSimplifiedChinese = {
'postRestoreFromLocal': '内容从本地暂存回复',
'postAutoSaveAt': '已自动保存于 @date',
'postCategoriesAndTags': '分类与标签',
'postPublishDate': '发布时间',
'postPublishAt': '发布帖子于',
'postPublishedUntil': '取消发布于',
'postPublishZone': '帖子发布区',
'postPublishZoneNone': '无所属领域',
'postVisibility': '帖子可见性',
'postVisibilityAll': '所有人可见',
'postVisibilityFriends': '仅好友可见',
@@ -103,6 +114,9 @@ const i18nSimplifiedChinese = {
'postVisibleUsers': '可见帖子者',
'postInvisibleUsers': '隐藏帖子者',
'postOverview': '帖子概览',
'postThumbnail': '帖子缩略图',
'postThumbnailAttachmentNew': '上传附件作为缩略图',
'postThumbnailAttachment': '附件序列号',
'postPinned': '已置顶',
'postEditorModeStory': '发个帖子',
'postEditorModeArticle': '撰写文章',
@@ -111,6 +125,7 @@ const i18nSimplifiedChinese = {
'articleDetail': '文章详情',
'draftBoxOpen': '打开草稿箱',
'postListNews': '新鲜事',
'postListFriends': '好友圈',
'postListShuffle': '打乱看',
'postNew': '创建新帖子',
'postNewInRealmHint': '在领域 @realm 里发表新帖子',
@@ -118,7 +133,7 @@ const i18nSimplifiedChinese = {
'postEdited': '编辑于 @date',
'postNewCreated': '创建于 @date',
'postInRealm': '发表于 @realm',
'postAttachmentTip': '@count 个附件',
'attachmentHint': '@count 个附件',
'postDetail': '帖子详情',
'postReplies': '帖子回复',
'postPublish': '编辑帖子',
@@ -140,6 +155,12 @@ const i18nSimplifiedChinese = {
'reactCompleted': '你的反应已被添加',
'reactUncompleted': '你的反应已被移除',
'attachmentUploadBy': '由上传',
'attachmentAutoUpload': '自动上传',
'attachmentUploadQueue': '上传队列',
'attachmentUploadQueueStart': '整队上传',
'attachmentUploadInProgress': '有附件正在上传,请等待所有附件上传完毕后再进行操作……',
'attachmentAttached': '已附附件',
'attachmentUploadBlocked': '上传受阻,当前已有任务进行中……',
'attachmentAdd': '附加附件',
'attachmentAddGalleryPhoto': '相册照片',
'attachmentAddGalleryVideo': '相册视频',
@@ -147,6 +168,9 @@ const i18nSimplifiedChinese = {
'attachmentAddCameraVideo': '拍摄视频',
'attachmentAddClipboard': '粘贴文件',
'attachmentAddFile': '附加文件',
'attachmentAddLink': '链接附件',
'attachmentAddLinkHint': '输入附件的神秘代号来链接对应附件',
'attachmentAddLinkInput': '神秘代号',
'attachmentSetting': '调整附件',
'attachmentAlt': '替代文字',
'attachmentLoadFailed': '加载失败',
@@ -221,6 +245,9 @@ const i18nSimplifiedChinese = {
'messageDeletionConfirmCaption': '你确定要删除消息 @id 吗?该操作不可撤销。',
'call': '通话',
'callOngoing': '一则通话正在进行中…',
'callOngoingEmpty': '一则通话待机中…',
'callOngoingParticipants': '@count 人正在进行通话…',
'callOngoingJoined': '通话进行 @duration',
'callJoin': '加入',
'callResume': '恢复',
'callMicrophone': '麦克风',
@@ -274,6 +301,11 @@ const i18nSimplifiedChinese = {
'accountStatusNeutral': '中性',
'accountStatusPositive': '积极',
'bsLoadingTheme': '正在装载主题',
'bsCheckForUpdate': '正在检查更新',
'bsCheckForUpdateFailed': '无法检查更新',
'bsCheckForUpdateNew': '发现新版本',
'bsCheckForUpdateDescApple': '请前往 TestFlight 并将您的应用程序更新到最新版本,以防止出现错误并获取最新功能。',
'bsCheckForUpdateDescCommon': '请前往我们的网站下载并安装最新版本的应用程序,以防止出现错误并获取最新功能。',
'bsCheckingServer': '检查服务器状态中',
'bsCheckingServerFail': '无法连接至服务器,请检查你的网络连接状态',
'bsCheckingServerDown': '当前服务器不可用,请稍后重试',
@@ -281,13 +313,38 @@ const i18nSimplifiedChinese = {
'bsEstablishingConn': '部署连接中',
'bsPreparingData': '正在准备用户资料',
'bsRegisteringPushNotify': '正在启用推送通知',
'bsDismissibleErrorHint': '点击任意地方忽略此错误',
'postShareContent': '@content\n\n@username 在 Solar Network\n原帖地址:@link',
'postShareSubject': '@username 在 Solar Network 上发布了一篇帖子',
'themeColor': '全局主题色',
'themeColorRed': '现代红',
'themeColorBlue': '经典蓝',
'themeColorMiku': '未来',
'themeColorMiku': '未来',
'themeColorKagamine': '镜音黄',
'themeColorLuka': '流音粉',
'themeColorApplied': '全局主题颜色已应用,深色模式中主题需要重启生效',
'themeColorApplied': '全局主题颜色已应用',
'stickerDeletionConfirm': '确认删除贴图',
'stickerDeletionConfirmCaption': '你确认要删除贴图 @name 吗?该操作不可撤销。',
'attachmentSaved': '附件已保存到系统相册',
'cropImage': '裁剪图片',
'stickerUploader': '上传贴图',
'stickerUploaderAttachmentNew': '上传附件作为贴图',
'stickerUploaderAttachment': '附件序列号',
'stickerUploaderPack': '贴图包序号',
'stickerUploaderPackHint': '没有该序号?请转到我们的创作者平台创建一个贴图包。',
'stickerUploaderAlias': '贴图别名',
'stickerUploaderAliasHint': '将会在输入时使用和贴图包前缀组成占位符。',
'stickerUploaderName': '贴图名称',
'stickerUploaderNameHint': '在贴图选择界面提供给用户的人类友好名称。',
'readMore': '阅读更多',
'attachmentUnload': '附件未加载',
'attachmentUnloadCaption': '为了节省流量,本附件未自动加载,点一下来开始加载。',
'callStatusConnected': '已连接',
'callStatusDisconnected': '已断开',
'callStatusConnecting': '连接中',
'callStatusReconnected': '重连中',
'messageOutOfSync': '消息可能与服务器脱节',
'messageOutOfSyncCaption': '由于 App 进入后台,消息列表可能与服务器存在时差,点击刷新。',
'messageHistoryWipe': '清除消息记录',
'unknown': '未知',
};

View File

@@ -24,7 +24,6 @@ class AccountAvatar extends StatelessWidget {
if (content is String) {
direct = content.startsWith('http');
if (!isEmpty) isEmpty = content.isEmpty;
if (!isEmpty) isEmpty = content.endsWith('/attachments/0');
}
final url = direct

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/account.dart';
import 'package:solian/providers/account_status.dart';
import 'package:solian/router.dart';
@@ -8,9 +7,9 @@ import 'package:solian/services.dart';
import 'package:solian/widgets/account/account_heading.dart';
class AccountProfilePopup extends StatefulWidget {
final Account account;
final String name;
const AccountProfilePopup({super.key, required this.account});
const AccountProfilePopup({super.key, required this.name});
@override
State<AccountProfilePopup> createState() => _AccountProfilePopupState();
@@ -18,38 +17,75 @@ class AccountProfilePopup extends StatefulWidget {
class _AccountProfilePopupState extends State<AccountProfilePopup> {
bool _isBusy = true;
dynamic _hasError;
Account? _userinfo;
void getUserinfo() async {
void _getUserinfo() async {
setState(() => _isBusy = true);
final client = ServiceFinder.configureClient('auth');
final resp = await client.get('/users/${widget.account.name}');
if (resp.statusCode == 200) {
_userinfo = Account.fromJson(resp.body);
setState(() => _isBusy = false);
} else {
context.showErrorDialog(resp.bodyString);
Navigator.pop(context);
try {
final client = ServiceFinder.configureClient('auth');
final resp = await client.get('/users/${widget.name}');
if (resp.statusCode == 200) {
setState(() {
_userinfo = Account.fromJson(resp.body);
_isBusy = false;
});
} else {
setState(() {
_hasError = resp.bodyString;
_isBusy = false;
});
}
} catch (e) {
setState(() {
_hasError = e;
_isBusy = false;
});
}
}
Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
@override
void initState() {
super.initState();
getUserinfo();
_getUserinfo();
}
@override
Widget build(BuildContext context) {
if (_isBusy || _userinfo == null) {
if (_isBusy) {
return SizedBox(
height: MediaQuery.of(context).size.height * 0.75,
child: const Center(child: CircularProgressIndicator()),
);
}
if (_hasError != null) {
return SizedBox(
height: MediaQuery.of(context).size.height * 0.75,
width: double.infinity,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.cancel, size: 24),
const SizedBox(height: 12),
Text(
_hasError.toString(),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: _unFocusColor,
),
),
],
),
);
}
return SizedBox(
height: MediaQuery.of(context).size.height * 0.75,
child: Column(

View File

@@ -226,7 +226,7 @@ class _AccountStatusEditorDialogState extends State<AccountStatusEditorDialog> {
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 5),
const SizedBox(height: 8),
TextField(
controller: _clearAtController,
readOnly: true,
@@ -238,7 +238,7 @@ class _AccountStatusEditorDialogState extends State<AccountStatusEditorDialog> {
),
onTap: () => selectClearAt(),
),
const SizedBox(height: 5),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap(
@@ -281,7 +281,7 @@ class _AccountStatusEditorDialogState extends State<AccountStatusEditorDialog> {
],
),
),
const SizedBox(height: 5),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap(

View File

@@ -35,7 +35,7 @@ class SilverRelativeList extends StatelessWidget {
context: context,
builder: (context) =>
AccountProfilePopup(
account: element.related,
name: element.related.name,
),
);
},

View File

@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/providers/content/attachment.dart';
class AttachmentAttrEditorDialog extends StatefulWidget {
final Attachment item;
final Function(Attachment item) onUpdate;
const AttachmentAttrEditorDialog({
super.key,
required this.item,
required this.onUpdate,
});
@override
State<AttachmentAttrEditorDialog> createState() =>
_AttachmentAttrEditorDialogState();
}
class _AttachmentAttrEditorDialogState
extends State<AttachmentAttrEditorDialog> {
final _altController = TextEditingController();
bool _isBusy = false;
bool _isMature = false;
Future<Attachment?> _updateAttachment() async {
final AttachmentProvider provider = Get.find();
setState(() => _isBusy = true);
try {
final resp = await provider.updateAttachment(
widget.item.id,
_altController.value.text,
isMature: _isMature,
);
Get.find<AttachmentProvider>().clearCache(id: widget.item.rid);
setState(() => _isBusy = false);
return Attachment.fromJson(resp.body);
} catch (e) {
context.showErrorDialog(e);
setState(() => _isBusy = false);
return null;
}
}
void syncWidget() {
_isMature = widget.item.isMature;
_altController.text = widget.item.alt;
}
@override
void initState() {
syncWidget();
super.initState();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('attachmentSetting'.tr),
content: Container(
constraints: const BoxConstraints(minWidth: 400),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_isBusy)
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: const LinearProgressIndicator().animate().scaleX(),
),
const SizedBox(height: 18),
TextField(
controller: _altController,
decoration: InputDecoration(
isDense: true,
prefixIcon: const Icon(Icons.image_not_supported),
border: const OutlineInputBorder(),
labelText: 'attachmentAlt'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 8),
CheckboxListTile(
contentPadding: const EdgeInsets.only(left: 4, right: 18),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10))),
title: Text('matureContent'.tr),
secondary: const Icon(Icons.visibility_off),
value: _isMature,
onChanged: (newValue) {
setState(() => _isMature = newValue ?? false);
},
controlAffinity: ListTileControlAffinity.leading,
),
],
),
),
actionsAlignment: MainAxisAlignment.spaceBetween,
actions: <Widget>[
Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
style: TextButton.styleFrom(
foregroundColor:
Theme.of(context).colorScheme.onSurfaceVariant),
onPressed: () => Navigator.pop(context),
child: Text('cancel'.tr),
),
TextButton(
child: Text('apply'.tr),
onPressed: () {
_updateAttachment().then((value) {
if (value != null) {
widget.onUpdate(value);
Navigator.pop(context);
}
});
},
),
],
),
],
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,300 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:dio/dio.dart';
import 'package:dismissible_page/dismissible_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:gal/gal.dart';
import 'package:get/get.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/platform.dart';
import 'package:solian/services.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/attachments/attachment_item.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:path/path.dart' show extension;
class AttachmentFullScreen extends StatefulWidget {
final String parentId;
final Attachment item;
const AttachmentFullScreen(
{super.key, required this.parentId, required this.item});
@override
State<AttachmentFullScreen> createState() => _AttachmentFullScreenState();
}
class _AttachmentFullScreenState extends State<AttachmentFullScreen> {
bool _showDetails = true;
bool _isDownloading = false;
bool _isCompletedDownload = false;
double? _progressOfDownload = 0;
Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
String _formatBytes(int bytes, {int decimals = 2}) {
if (bytes == 0) return '0 Bytes';
const k = 1024;
final dm = decimals < 0 ? 0 : decimals;
final sizes = [
'Bytes',
'KiB',
'MiB',
'GiB',
'TiB',
'PiB',
'EiB',
'ZiB',
'YiB'
];
final i = (math.log(bytes) / math.log(k)).floor().toInt();
return '${(bytes / math.pow(k, i)).toStringAsFixed(dm)} ${sizes[i]}';
}
double _getRatio() {
final value = widget.item.metadata?['ratio'];
if (value == null) return 1;
if (value is int) return value.toDouble();
if (value is double) return value;
return 1;
}
Future<void> _saveToAlbum() async {
final url = ServiceFinder.buildUrl(
'files',
'/attachments/${widget.item.rid}',
);
if (PlatformInfo.isWeb || PlatformInfo.isDesktop) {
await launchUrlString(url);
return;
}
if (!await Gal.hasAccess(toAlbum: true)) {
if (!await Gal.requestAccess(toAlbum: true)) return;
}
setState(() => _isDownloading = true);
var extName = extension(widget.item.name);
if (extName.isEmpty) extName = '.png';
final imagePath =
'${Directory.systemTemp.path}/${widget.item.uuid}$extName';
await Dio().download(
url,
imagePath,
onReceiveProgress: (count, total) {
setState(() => _progressOfDownload = count / total);
},
);
bool isSuccess = false;
try {
await Gal.putImage(imagePath);
isSuccess = true;
} on GalException catch (e) {
context.showErrorDialog(e.type.message);
}
context.showSnackbar(
'attachmentSaved'.tr,
action: SnackBarAction(
label: 'openInAlbum'.tr,
onPressed: () async => Gal.open(),
),
);
setState(() {
_isDownloading = false;
_isCompletedDownload = isSuccess;
});
}
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
final metaTextStyle = TextStyle(
fontSize: 12,
color: _unFocusColor,
);
return DismissiblePage(
key: Key('attachment-dismissible${widget.item.id}'),
direction: DismissiblePageDismissDirection.vertical,
onDismissed: () => Navigator.pop(context),
dismissThresholds: const {
DismissiblePageDismissDirection.vertical: 0.0,
},
onDragStart: () {
setState(() => _showDetails = false);
},
onDragEnd: () {
setState(() => _showDetails = true);
},
child: GestureDetector(
child: Stack(
fit: StackFit.loose,
children: [
SizedBox(
height: MediaQuery.of(context).size.height,
width: MediaQuery.of(context).size.width,
child: InteractiveViewer(
boundaryMargin: EdgeInsets.zero,
minScale: 1,
maxScale: 16,
panEnabled: true,
scaleEnabled: true,
child: AttachmentItem(
parentId: widget.parentId,
showHideButton: false,
item: widget.item,
fit: BoxFit.contain,
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: IgnorePointer(
child: Container(
height: 300,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Theme.of(context).colorScheme.surface,
Theme.of(context).colorScheme.surface.withOpacity(0),
],
),
),
),
),
)
.animate(target: _showDetails ? 1 : 0)
.fadeIn(curve: Curves.fastEaseInToSlowEaseOut),
Positioned(
bottom: math.max(MediaQuery.of(context).padding.bottom, 16),
left: 16,
right: 16,
child: Material(
color: Colors.transparent,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.item.account != null)
Row(
children: [
IgnorePointer(
child: AccountAvatar(
content: widget.item.account!.avatar,
radius: 19,
),
),
const IgnorePointer(child: SizedBox(width: 8)),
Expanded(
child: IgnorePointer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'attachmentUploadBy'.tr,
style:
Theme.of(context).textTheme.bodySmall,
),
Text(
widget.item.account!.nick,
style:
Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
IconButton(
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -2,
),
icon: !_isDownloading
? !_isCompletedDownload
? const Icon(Icons.save_alt)
: const Icon(Icons.download_done)
: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
value: _progressOfDownload,
strokeWidth: 3,
),
),
onPressed:
_isDownloading ? null : () => _saveToAlbum(),
),
],
),
const IgnorePointer(child: SizedBox(height: 4)),
IgnorePointer(
child: Text(
widget.item.alt,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
),
const IgnorePointer(child: SizedBox(height: 2)),
IgnorePointer(
child: Wrap(
spacing: 6,
children: [
Text(
'#${widget.item.rid}',
style: metaTextStyle,
),
if (widget.item.metadata?['width'] != null &&
widget.item.metadata?['height'] != null)
Text(
'${widget.item.metadata?['width']}x${widget.item.metadata?['height']}',
style: metaTextStyle,
),
if (widget.item.metadata?['ratio'] != null)
Text(
'${_getRatio().toPrecision(2)}',
style: metaTextStyle,
),
Text(
_formatBytes(widget.item.size),
style: metaTextStyle,
),
Text(
widget.item.mimetype,
style: metaTextStyle,
),
],
),
),
],
),
),
)
.animate(target: _showDetails ? 1 : 0)
.fadeIn(curve: Curves.fastEaseInToSlowEaseOut),
],
),
onTap: () {
setState(() => _showDetails = !_showDetails);
},
),
);
}
}

View File

@@ -7,6 +7,7 @@ import 'package:media_kit_video/media_kit_video.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/platform.dart';
import 'package:solian/services.dart';
import 'package:solian/widgets/sized_container.dart';
import 'package:url_launcher/url_launcher_string.dart';
class AttachmentItem extends StatefulWidget {
@@ -14,6 +15,7 @@ class AttachmentItem extends StatefulWidget {
final Attachment item;
final bool showBadge;
final bool showHideButton;
final bool autoload;
final BoxFit fit;
final String? badge;
final Function? onHide;
@@ -26,6 +28,7 @@ class AttachmentItem extends StatefulWidget {
this.fit = BoxFit.cover,
this.showBadge = true,
this.showHideButton = true,
this.autoload = false,
this.onHide,
});
@@ -48,7 +51,10 @@ class _AttachmentItemState extends State<AttachmentItem> {
onHide: widget.onHide,
);
case 'video':
return _AttachmentItemVideo(item: widget.item);
return _AttachmentItemVideo(
item: widget.item,
autoload: widget.autoload,
);
default:
return Center(
child: Container(
@@ -85,7 +91,7 @@ class _AttachmentItemState extends State<AttachmentItem> {
launchUrlString(
ServiceFinder.buildUrl(
'files',
'/attachments/${widget.item.id}',
'/attachments/${widget.item.rid}',
),
);
},
@@ -129,7 +135,7 @@ class _AttachmentItemImage extends StatelessWidget {
fit: fit,
imageUrl: ServiceFinder.buildUrl(
'files',
'/attachments/${item.id}',
'/attachments/${item.rid}',
),
progressIndicatorBuilder: (context, url, downloadProgress) {
return Center(
@@ -213,8 +219,12 @@ class _AttachmentItemImage extends StatelessWidget {
class _AttachmentItemVideo extends StatefulWidget {
final Attachment item;
final bool autoload;
const _AttachmentItemVideo({required this.item});
const _AttachmentItemVideo({
required this.item,
this.autoload = false,
});
@override
State<_AttachmentItemVideo> createState() => _AttachmentItemVideoState();
@@ -222,25 +232,74 @@ class _AttachmentItemVideo extends StatefulWidget {
class _AttachmentItemVideoState extends State<_AttachmentItemVideo> {
late final _player = Player(
configuration: const PlayerConfiguration(logLevel: MPVLogLevel.error),
configuration: const PlayerConfiguration(
logLevel: MPVLogLevel.error,
),
);
late final _controller = VideoController(_player);
bool _showContent = false;
Future<void> _startLoad() async {
await _player.open(
Media(ServiceFinder.buildUrl('files', '/attachments/${widget.item.rid}')),
play: false,
);
setState(() => _showContent = true);
}
@override
void initState() {
super.initState();
_player.open(
Media(
ServiceFinder.buildUrl('files', '/attachments/${widget.item.id}'),
),
play: false,
);
if (widget.autoload) {
_startLoad();
}
}
@override
Widget build(BuildContext context) {
final ratio = widget.item.metadata?['ratio'] ?? 16 / 9;
if (!_showContent) {
return GestureDetector(
child: AspectRatio(
aspectRatio: ratio,
child: CenteredContainer(
maxWidth: 280,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.not_started,
color: Colors.white,
size: 32,
),
const SizedBox(height: 8),
Text(
'attachmentUnload'.tr,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
Text(
'attachmentUnloadCaption'.tr,
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
],
),
),
),
onTap: () {
_startLoad();
},
);
}
return Video(
aspectRatio: widget.item.metadata?['ratio'] ?? 16 / 9,
aspectRatio: ratio,
controller: _controller,
);
}

View File

@@ -1,22 +1,26 @@
import 'dart:math' show min;
import 'dart:math' as math;
import 'dart:ui';
import 'package:carousel_slider/carousel_slider.dart';
import 'package:dismissible_page/dismissible_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' hide CarouselController;
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/widgets/attachments/attachment_item.dart';
import 'package:solian/providers/content/attachment.dart';
import 'package:solian/widgets/attachments/attachment_list_fullscreen.dart';
import 'package:solian/widgets/attachments/attachment_fullscreen.dart';
import 'package:solian/widgets/sized_container.dart';
class AttachmentList extends StatefulWidget {
final String parentId;
final List<int> attachmentsId;
final List<String> attachmentsId;
final bool isGrid;
final bool isColumn;
final bool isForceGrid;
final bool autoload;
final double flatMaxHeight;
final double columnMaxWidth;
final double? width;
final double? viewport;
@@ -26,8 +30,11 @@ class AttachmentList extends StatefulWidget {
required this.parentId,
required this.attachmentsId,
this.isGrid = false,
this.isColumn = false,
this.isForceGrid = false,
this.autoload = false,
this.flatMaxHeight = 720,
this.columnMaxWidth = 480,
this.width,
this.viewport,
});
@@ -45,7 +52,7 @@ class _AttachmentListState extends State<AttachmentList> {
List<Attachment?> _attachmentsMeta = List.empty();
void _getMetadataList() {
final AttachmentProvider provider = Get.find();
final AttachmentProvider attach = Get.find();
if (widget.attachmentsId.isEmpty) {
return;
@@ -53,30 +60,24 @@ class _AttachmentListState extends State<AttachmentList> {
_attachmentsMeta = List.filled(widget.attachmentsId.length, null);
}
int progress = 0;
for (var idx = 0; idx < widget.attachmentsId.length; idx++) {
provider.getMetadata(widget.attachmentsId[idx]).then((resp) {
progress++;
if (resp != null) {
_attachmentsMeta[idx] = resp;
}
if (progress == widget.attachmentsId.length) {
calculateAspectRatio();
if (mounted) {
setState(() => _isLoading = false);
}
}
});
}
attach.listMetadata(widget.attachmentsId).then((result) {
if (mounted) {
setState(() {
_attachmentsMeta = result;
_isLoading = false;
});
}
_calculateAspectRatio();
});
}
void calculateAspectRatio() {
void _calculateAspectRatio() {
bool isConsistent = true;
double? consistentValue;
int portrait = 0, square = 0, landscape = 0;
for (var entry in _attachmentsMeta) {
if (entry!.metadata?['ratio'] != null) {
if (entry == null) continue;
if (entry.metadata?['ratio'] != null) {
if (entry.metadata?['ratio'] is int) {
consistentValue ??= entry.metadata?['ratio'].toDouble();
} else {
@@ -108,15 +109,17 @@ class _AttachmentListState extends State<AttachmentList> {
}
}
Widget _buildEntry(Attachment? element, int idx) {
Widget _buildEntry(Attachment? element, int idx, {double? width}) {
return AttachmentListEntry(
item: element,
parentId: widget.parentId,
width: widget.width,
width: width ?? widget.width,
badgeContent: '${idx + 1}/${_attachmentsMeta.length}',
showBadge: _attachmentsMeta.length > 1 && !widget.isGrid,
showBadge:
_attachmentsMeta.length > 1 && !widget.isGrid && !widget.isColumn,
showBorder: widget.attachmentsId.length > 1,
showMature: _showMature,
autoload: widget.autoload,
onReveal: (value) {
setState(() => _showMature = value);
},
@@ -144,8 +147,46 @@ class _AttachmentListState extends State<AttachmentList> {
);
}
final isNotPureImage = _attachmentsMeta
.any((x) => x?.mimetype.split('/').firstOrNull != 'image');
if (widget.isColumn) {
var idx = 0;
const radius = BorderRadius.all(Radius.circular(8));
return Wrap(
spacing: 8,
runSpacing: 8,
children: widget.attachmentsId.map((x) {
final element = _attachmentsMeta[idx];
idx++;
if (element == null) return const SizedBox();
double ratio = element.metadata!['ratio']?.toDouble() ?? 16 / 9;
return Container(
constraints: BoxConstraints(
maxWidth: widget.columnMaxWidth,
maxHeight: 640,
),
child: AspectRatio(
aspectRatio: ratio,
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
borderRadius: radius,
),
child: ClipRRect(
borderRadius: radius,
child: _buildEntry(element, idx),
),
),
),
);
}).toList(),
);
}
final isNotPureImage = _attachmentsMeta.any(
(x) => x?.mimetype.split('/').firstOrNull != 'image',
);
if (widget.isGrid && (widget.isForceGrid || !isNotPureImage)) {
const radius = BorderRadius.all(Radius.circular(8));
return GridView.builder(
@@ -154,7 +195,7 @@ class _AttachmentListState extends State<AttachmentList> {
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: min(3, widget.attachmentsId.length),
crossAxisCount: math.min(3, widget.attachmentsId.length),
mainAxisSpacing: 8.0,
crossAxisSpacing: 8.0,
),
@@ -163,8 +204,10 @@ class _AttachmentListState extends State<AttachmentList> {
final element = _attachmentsMeta[idx];
return Container(
decoration: BoxDecoration(
border:
Border.all(color: Theme.of(context).dividerColor, width: 1),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
borderRadius: radius,
),
child: ClipRRect(
@@ -212,10 +255,12 @@ class AttachmentListEntry extends StatelessWidget {
final Attachment? item;
final String? badgeContent;
final double? width;
final double? height;
final bool showBorder;
final bool showBadge;
final bool showMature;
final bool isDense;
final bool autoload;
final Function(bool) onReveal;
const AttachmentListEntry({
@@ -225,10 +270,12 @@ class AttachmentListEntry extends StatelessWidget {
this.item,
this.badgeContent,
this.width,
this.height,
this.showBorder = false,
this.showBadge = false,
this.showMature = false,
this.isDense = false,
this.autoload = false,
});
@override
@@ -248,6 +295,7 @@ class AttachmentListEntry extends StatelessWidget {
return GestureDetector(
child: Container(
width: width ?? MediaQuery.of(context).size.width,
height: height,
decoration: BoxDecoration(
border: showBorder
? Border.symmetric(
@@ -267,6 +315,7 @@ class AttachmentListEntry extends StatelessWidget {
item: item!,
badge: showBadge ? badgeContent : null,
showHideButton: !item!.isMature || showMature,
autoload: autoload,
onHide: () {
onReveal(false);
},
@@ -281,35 +330,33 @@ class AttachmentListEntry extends StatelessWidget {
),
),
if (item!.isMature && !showMature)
Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.visibility_off,
color: Colors.white,
size: 32,
CenteredContainer(
maxWidth: 280,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.visibility_off,
color: Colors.white,
size: 32,
),
if (!isDense) const SizedBox(height: 8),
if (!isDense)
Text(
'matureContent'.tr,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
if (!isDense) const SizedBox(height: 8),
if (!isDense)
Text(
'matureContent'.tr,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
if (!isDense)
Text(
'matureContentCaption'.tr,
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
],
),
if (!isDense)
Text(
'matureContentCaption'.tr,
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
],
),
),
],
@@ -320,9 +367,9 @@ class AttachmentListEntry extends StatelessWidget {
onReveal(true);
} else if (['image'].contains(item!.mimetype.split('/').first)) {
context.pushTransparentRoute(
AttachmentListFullScreen(
AttachmentFullScreen(
parentId: parentId,
attachment: item!,
item: item!,
),
rootNavigator: true,
);
@@ -331,3 +378,51 @@ class AttachmentListEntry extends StatelessWidget {
);
}
}
class AttachmentSelfContainedEntry extends StatefulWidget {
final String rid;
final String parentId;
final bool isDense;
const AttachmentSelfContainedEntry({
super.key,
required this.rid,
required this.parentId,
this.isDense = false,
});
@override
State<AttachmentSelfContainedEntry> createState() =>
_AttachmentSelfContainedEntryState();
}
class _AttachmentSelfContainedEntryState
extends State<AttachmentSelfContainedEntry> {
bool _showMature = false;
@override
Widget build(BuildContext context) {
final AttachmentProvider attachments = Get.find();
return FutureBuilder(
future: attachments.getMetadata(widget.rid),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator(),
);
}
return AttachmentListEntry(
item: snapshot.data,
isDense: widget.isDense,
parentId: widget.parentId,
showMature: _showMature,
onReveal: (value) {
setState(() => _showMature = value);
},
);
},
);
}
}

View File

@@ -1,205 +0,0 @@
import 'dart:math' as math;
import 'package:dismissible_page/dismissible_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/attachments/attachment_item.dart';
class AttachmentListFullScreen extends StatefulWidget {
final String parentId;
final Attachment attachment;
const AttachmentListFullScreen(
{super.key, required this.parentId, required this.attachment});
@override
State<AttachmentListFullScreen> createState() =>
_AttachmentListFullScreenState();
}
class _AttachmentListFullScreenState extends State<AttachmentListFullScreen> {
bool _showDetails = true;
Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
String _formatBytes(int bytes, {int decimals = 2}) {
if (bytes == 0) return '0 Bytes';
const k = 1024;
final dm = decimals < 0 ? 0 : decimals;
final sizes = [
'Bytes',
'KiB',
'MiB',
'GiB',
'TiB',
'PiB',
'EiB',
'ZiB',
'YiB'
];
final i = (math.log(bytes) / math.log(k)).floor().toInt();
return '${(bytes / math.pow(k, i)).toStringAsFixed(dm)} ${sizes[i]}';
}
double _getRatio() {
final value = widget.attachment.metadata?['ratio'];
if (value == null) return 1;
if (value is int) return value.toDouble();
if (value is double) return value;
return 1;
}
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return DismissiblePage(
key: Key('attachment-dismissible${widget.attachment.id}'),
direction: DismissiblePageDismissDirection.vertical,
onDismissed: () => Navigator.pop(context),
dismissThresholds: const {
DismissiblePageDismissDirection.vertical: 0.0,
},
onDragStart: () {
setState(() => _showDetails = false);
},
onDragEnd: () {
setState(() => _showDetails = true);
},
child: GestureDetector(
child: Stack(
fit: StackFit.loose,
children: [
SizedBox(
height: MediaQuery.of(context).size.height,
width: MediaQuery.of(context).size.width,
child: InteractiveViewer(
boundaryMargin: EdgeInsets.zero,
minScale: 1,
maxScale: 16,
panEnabled: true,
scaleEnabled: true,
child: AttachmentItem(
parentId: widget.parentId,
showHideButton: false,
item: widget.attachment,
fit: BoxFit.contain,
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: IgnorePointer(
child: Container(
height: 300,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Theme.of(context).colorScheme.surface,
Theme.of(context).colorScheme.surface.withOpacity(0),
],
),
),
),
),
)
.animate(target: _showDetails ? 1 : 0)
.fadeIn(curve: Curves.fastEaseInToSlowEaseOut),
Positioned(
bottom: math.max(MediaQuery.of(context).padding.bottom, 16),
left: 16,
right: 16,
child: IgnorePointer(
child: Material(
color: Colors.transparent,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.attachment.account != null)
Row(
children: [
AccountAvatar(
content: widget.attachment.account!.avatar,
radius: 19,
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'attachmentUploadBy'.tr,
style: Theme.of(context).textTheme.bodySmall,
),
Text(
widget.attachment.account!.nick,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
],
),
const SizedBox(height: 4),
Text(
widget.attachment.alt,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Wrap(
spacing: 6,
children: [
if (widget.attachment.metadata?['width'] != null &&
widget.attachment.metadata?['height'] != null)
Text(
'${widget.attachment.metadata?['width']}x${widget.attachment.metadata?['height']}',
style: TextStyle(
fontSize: 12,
color: _unFocusColor,
),
),
if (widget.attachment.metadata?['ratio'] != null)
Text(
'${_getRatio().toPrecision(2)}',
style: TextStyle(
fontSize: 12,
color: _unFocusColor,
),
),
Text(
_formatBytes(widget.attachment.size),
style: TextStyle(
fontSize: 12,
color: _unFocusColor,
),
)
],
),
],
),
),
),
)
.animate(target: _showDetails ? 1 : 0)
.fadeIn(curve: Curves.fastEaseInToSlowEaseOut),
],
),
onTap: () {
setState(() => _showDetails = !_showDetails);
},
),
);
}
}

View File

@@ -0,0 +1,120 @@
import 'dart:convert';
import 'package:avatar_stack/avatar_stack.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/call.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/call.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/chat/call/call_prejoin.dart';
class ChannelCallIndicator extends StatelessWidget {
final Channel channel;
final Call ongoingCall;
final Function onJoin;
const ChannelCallIndicator({
super.key,
required this.channel,
required this.ongoingCall,
required this.onJoin,
});
void _showCallPrejoin(BuildContext context) {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => ChatCallPrejoinPopup(
ongoingCall: ongoingCall,
channel: channel,
onJoin: onJoin,
),
);
}
@override
Widget build(BuildContext context) {
final ChatCallProvider call = Get.find();
return MaterialBanner(
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: Row(
children: [
Obx(() {
if (call.isInitialized.value) {
return Text('callOngoingJoined'.trParams({
'duration': call.lastDuration.value,
}));
} else if (ongoingCall.participants.isEmpty) {
return Text('callOngoingEmpty'.tr);
} else {
return Text('callOngoingParticipants'.trParams({
'count': ongoingCall.participants.length.toString(),
}));
}
}),
const SizedBox(width: 6),
Obx(() {
if (call.isInitialized.value) {
return const SizedBox();
}
if (ongoingCall.participants.isNotEmpty) {
return Container(
height: 28,
constraints: const BoxConstraints(maxWidth: 120),
child: AvatarStack(
height: 28,
borderWidth: 0,
avatars: ongoingCall.participants.map((x) {
final userinfo =
Account.fromJson(jsonDecode(x['metadata']));
return PlatformInfo.canCacheImage
? CachedNetworkImageProvider(userinfo.avatar)
as ImageProvider
: NetworkImage(userinfo.avatar) as ImageProvider;
}).toList(),
),
);
}
return const SizedBox();
})
],
),
actions: [
Obx(() {
if (call.isBusy.value) {
return const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 3),
).paddingAll(16);
} else if (call.current.value == null) {
return TextButton(
onPressed: () => _showCallPrejoin(context),
child: Text('callJoin'.tr),
);
} else if (call.channel.value?.id == channel.id &&
!SolianTheme.isLargeScreen(context)) {
return TextButton(
onPressed: () => onJoin(),
child: Text('callResume'.tr),
);
} else if (!SolianTheme.isLargeScreen(context)) {
return TextButton(
onPressed: null,
child: Text('callJoin'.tr),
);
}
return const SizedBox();
})
],
);
}
}

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart';
import 'package:solian/controllers/chat_events_controller.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/platform.dart';
import 'package:solian/router.dart';
import 'package:solian/widgets/account/account_avatar.dart';
@@ -31,7 +33,9 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
final List<Channel> _globalChannels = List.empty(growable: true);
final Map<String, List<Channel>> _inRealms = {};
void mapChannels() {
final ChatEventController _eventController = ChatEventController();
void _mapChannels() {
_inRealms.clear();
_globalChannels.clear();
@@ -55,16 +59,17 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
@override
void didUpdateWidget(covariant ChannelListWidget oldWidget) {
super.didUpdateWidget(oldWidget);
setState(() => mapChannels());
setState(() => _mapChannels());
}
@override
void initState() {
super.initState();
mapChannels();
_mapChannels();
_eventController.initialize();
}
void gotoChannel(Channel item) {
void _gotoChannel(Channel item) {
if (widget.useReplace) {
AppRouter.instance.pushReplacementNamed(
'channelChat',
@@ -88,7 +93,35 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
}
}
Widget buildItem(Channel item) {
Widget _buildDirectMessageDescription(Channel item, ChannelMember otherside) {
if (PlatformInfo.isWeb) {
return Text('channelDirectDescription'.trParams(
{'username': '@${otherside.account.name}'},
));
}
return FutureBuilder(
future: Future.delayed(
const Duration(milliseconds: 500),
() => _eventController.database.localEvents.findLastByChannel(item.id),
),
builder: (context, snapshot) {
if (!snapshot.hasData && snapshot.data == null) {
return Text('channelDirectDescription'.trParams(
{'username': '@${otherside.account.name}'},
));
}
return Text(
'${snapshot.data!.data.sender.account.nick}: ${snapshot.data!.data.body['text'] ?? 'Unsupported message to preview'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
);
}
Widget _buildEntry(Channel item) {
final padding = widget.isDense
? const EdgeInsets.symmetric(horizontal: 20)
: const EdgeInsets.symmetric(horizontal: 16);
@@ -102,37 +135,36 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
leading: AccountAvatar(
content: otherside.account.avatar,
radius: widget.isDense ? 12 : 20,
bgColor: Colors.indigo,
feColor: Colors.white,
bgColor: Theme.of(context).colorScheme.primary,
feColor: Theme.of(context).colorScheme.onPrimary,
),
contentPadding: padding,
title: Text(otherside.account.nick),
subtitle: !widget.isDense
? Text(
'channelDirectDescription'.trParams(
{'username': '@${otherside.account.name}'},
),
)
? _buildDirectMessageDescription(item, otherside)
: null,
onTap: () => gotoChannel(item),
onTap: () => _gotoChannel(item),
);
} else {
return ListTile(
minTileHeight: widget.isDense ? 48 : null,
leading: CircleAvatar(
backgroundColor:
item.realmId == null ? Colors.indigo : Colors.transparent,
backgroundColor: item.realmId == null
? Theme.of(context).colorScheme.primary
: Colors.transparent,
radius: widget.isDense ? 12 : 20,
child: FaIcon(
FontAwesomeIcons.hashtag,
color: item.realmId == null ? Colors.white : Colors.indigo,
color: item.realmId == null
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.primary,
size: widget.isDense ? 12 : 16,
),
),
contentPadding: padding,
title: Text(item.name),
subtitle: !widget.isDense ? Text(item.description) : null,
onTap: () => gotoChannel(item),
onTap: () => _gotoChannel(item),
);
}
}
@@ -146,7 +178,7 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
itemCount: _globalChannels.length,
itemBuilder: (context, index) {
final element = _globalChannels[index];
return buildItem(element);
return _buildEntry(element);
},
),
],
@@ -159,13 +191,13 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
itemCount: _globalChannels.length,
itemBuilder: (context, index) {
final element = _globalChannels[index];
return buildItem(element);
return _buildEntry(element);
},
),
SliverList.list(
children: _inRealms.entries.map((element) {
return ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 20),
tilePadding: const EdgeInsets.only(left: 20, right: 24),
minTileHeight: 48,
title: Text(element.value.first.realm!.name),
leading: CircleAvatar(
@@ -177,7 +209,7 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
size: widget.isDense ? 12 : 16,
),
),
children: element.value.map((x) => buildItem(x)).toList(),
children: element.value.map((x) => _buildEntry(x)).toList(),
);
}).toList(),
),

View File

@@ -57,7 +57,7 @@ class _ChannelMemberListPopupState extends State<ChannelMemberListPopup> {
setState(() => _isBusy = false);
}
void promptAddMember() async {
void _promptAddMember() async {
final input = await showModalBottomSheet(
context: context,
builder: (context) {
@@ -141,7 +141,7 @@ class _ChannelMemberListPopupState extends State<ChannelMemberListPopup> {
'channelMembersAddHint'
.trParams({'channel': '#${widget.channel.alias}'}),
),
onTap: () => promptAddMember(),
onTap: () => _promptAddMember(),
),
Expanded(
child: ListView.builder(
@@ -160,7 +160,7 @@ class _ChannelMemberListPopupState extends State<ChannelMemberListPopup> {
backgroundColor: Theme.of(context).colorScheme.surface,
context: context,
builder: (context) => AccountProfilePopup(
account: element.account,
name: element.account.name,
),
);
},

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