Compare commits

..

76 Commits

Author SHA1 Message Date
989b5babd9 Auto update checking 2024-08-03 01:14:42 +08:00
9ea364640d 🚀 Launch 1.2.0+8 2024-08-02 23:24:36 +08:00
a9f55a489d ⬆️ Clean and upgrade packages 2024-08-02 23:22:50 +08:00
4616f3a3e2 Friend request indicator 2024-08-02 23:15:28 +08:00
425bae9d13 💄 Better friend page loading indicator 2024-08-02 22:54:56 +08:00
07771e8979 Improve the speed of fetching attachments meta via batch api 2024-08-02 22:46:48 +08:00
0ad4854443 💄 Grid view in call 2024-08-02 21:12:37 +08:00
4238ea6fdc Call grid layout 2024-08-02 18:49:28 +08:00
7d45c06302 💄 Optimized signal indicator 2024-08-02 18:29:01 +08:00
7e8993fbd2 💫 Auto hide or show call controls 2024-08-02 18:09:07 +08:00
c88fcc84da Show call participants 2024-08-02 17:14:23 +08:00
11fb79623e Attachment can link exists things
 Optimize upload progress
2024-08-02 15:49:32 +08:00
98cc313a91 💫 Optimize chat event list animation 2024-08-02 14:14:09 +08:00
bc3401a897 🐛 Fix post item color mismatch 2024-08-02 05:10:10 +08:00
5b6a5d9046 🐛 Fix post popup color mismatch 2024-08-02 05:04:31 +08:00
6cbd78e836 💫 Optimize post editor transition 2024-08-02 04:59:35 +08:00
aefcbad02f 💫 Better animated post list 2024-08-02 04:42:38 +08:00
70617be687 💫 Animated chat 2024-08-02 04:24:12 +08:00
cccb3d5c16 🐛 Fix post won't refresh after post 2024-08-02 01:00:31 +08:00
a0a3a8d182 DM message last preview 2024-08-02 00:54:19 +08:00
c6b2ef8459 💄 Better about 2024-08-02 00:41:12 +08:00
34a2fe3988 Move about page link from account to settings 2024-08-02 00:29:51 +08:00
0a5604d0ff Crop image in personalize 2024-08-02 00:12:16 +08:00
5e754ad233 💫 About page icon will rotate 2024-08-01 23:51:03 +08:00
5b9c92e4d3 Crop image 2024-08-01 23:44:07 +08:00
b2a6ca7244 Improve attachments queue performance 2024-08-01 23:10:19 +08:00
27c60fc8cb Block user action when attachments isn't ready 2024-08-01 22:36:00 +08:00
8b3c45ab29 Queued upload 2024-08-01 22:13:08 +08:00
adb415700a 💄 Optimized attachment edit action 2024-08-01 17:19:55 +08:00
1e4b44a78b 💄 Better attachment editor previewing 2024-08-01 16:45:18 +08:00
9765b200b9 🐛 Fix content previewing will show attachments 2024-08-01 16:28:48 +08:00
47d03ce1e5 🐛 Bug fixes 2024-08-01 16:09:09 +08:00
c41a71388d Post with publish at and until 2024-08-01 15:49:42 +08:00
7655dfdf37 Post publish zone 2024-08-01 15:21:43 +08:00
190bb34958 Markdown toolbar 2024-08-01 14:46:01 +08:00
d02ed68afa Mention user in chat 2024-08-01 14:01:12 +08:00
2bc4513bb6 🐛 Fix post tag input issue 2024-08-01 11:49:28 +08:00
f10393f6d0 Download attachment 2024-08-01 02:10:57 +08:00
ecef8dab0c Fix post list ui jank 2024-08-01 01:21:27 +08:00
52e58fce3d Make theme switcher easier to use 2024-07-31 22:48:22 +08:00
31d50bfb1f 🐛 Fix web url issue 2024-07-31 21:01:32 +08:00
ca8ad12d93 🍱 Update font 2024-07-31 20:45:36 +08:00
f799900450 🐛 Fix crash on ratio 1 in attachment 2024-07-31 20:45:16 +08:00
dfdf7b23c8 🐛 Fix theme switching 2024-07-31 13:29:26 +08:00
771b2029b0 🍱 Add fonts 2024-07-31 13:29:17 +08:00
cc9c99f375 Global theme color 2024-07-31 02:44:49 +08:00
b70d3795d1 Better tags input 2024-07-31 02:00:03 +08:00
a16ff1b9a1 🍱 Update app icon 2024-07-30 21:24:30 +08:00
19751617cb Able to edit visibility 2024-07-30 20:49:01 +08:00
bb77b74356 Able to post article 2024-07-30 16:53:13 +08:00
fc77c8693f Post editor able to edit article 2024-07-30 16:44:04 +08:00
58bb549217 Post content local cache 2024-07-30 16:29:30 +08:00
6590062dcb Post overview w/ content length limit indicator 2024-07-30 14:49:26 +08:00
6ace977bf6 💄 Better fullscreen attachment viewer 2024-07-30 12:22:57 +08:00
387f0d14ac ⬆️ Upgrade packages 2024-07-30 12:21:39 +08:00
18bb0d3db2 🍱 Update app icon for v1.2.0 2024-07-30 11:50:26 +08:00
8ab3ca5633 ⬆️ Support latest Paperclip 2024-07-29 18:06:38 +08:00
3db6850d89 🐛 Fix attachment displaying according the latest server 2024-07-29 17:56:36 +08:00
3ca98fa58c 🐛 Fix share link issue 2024-07-27 20:37:04 +08:00
425c79d6fc 🚀 Launch the last version of 1.1.0 2024-07-27 20:34:02 +08:00
7e98edfbc9 🐛 Fix web issue 2024-07-27 20:27:29 +08:00
056b98db07 🐛 Fix web 404 issue 2024-07-27 19:58:44 +08:00
7bfbd37b76 🐛 Fix attachment fullscreen in dark mode 2024-07-27 19:52:22 +08:00
7800a70ef2 Deep link 2024-07-27 19:20:53 +08:00
74b6ccd5c7 🐛 Fix share 2024-07-27 14:32:31 +08:00
6ca4aad1c4 💄 Better bootstrapping 2024-07-27 14:16:49 +08:00
102df2ef1c Share 2024-07-27 02:11:59 +08:00
f08c9903b4 Bootstrapper 2024-07-27 01:39:20 +08:00
0d279842cf 💄 Better full screen attachment display 2024-07-27 00:20:11 +08:00
33d69908a6 Social credit points & quick send friend request 2024-07-26 22:37:08 +08:00
4552dfd3f3 Pinned post & Total vote counts 2024-07-26 18:23:51 +08:00
ae87e9ad31 💄 Optimized album page 2024-07-26 17:35:54 +08:00
277ba69513 Account profile page 2024-07-26 16:53:05 +08:00
6e3d0f9787 💄 Better attachments in posts 2024-07-26 14:21:00 +08:00
0237409d27 🐛 Fix search with tag won't work 2024-07-26 01:31:45 +08:00
a5b6ace79b 💄 Better attachments list styles 2024-07-26 01:16:32 +08:00
156 changed files with 6340 additions and 2441 deletions

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

BIN
assets/icon-fit.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 537 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

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

View File

@@ -38,19 +38,19 @@ PODS:
- file_picker (0.0.1): - file_picker (0.0.1):
- DKImagePickerController/PhotoGallery - DKImagePickerController/PhotoGallery
- Flutter - Flutter
- Firebase/CoreOnly (10.28.0): - Firebase/CoreOnly (10.29.0):
- FirebaseCore (= 10.28.0) - FirebaseCore (= 10.29.0)
- Firebase/Messaging (10.28.0): - Firebase/Messaging (10.29.0):
- Firebase/CoreOnly - Firebase/CoreOnly
- FirebaseMessaging (~> 10.28.0) - FirebaseMessaging (~> 10.29.0)
- firebase_core (3.2.0): - firebase_core (3.3.0):
- Firebase/CoreOnly (= 10.28.0) - Firebase/CoreOnly (= 10.29.0)
- Flutter - Flutter
- firebase_messaging (15.0.3): - firebase_messaging (15.0.4):
- Firebase/Messaging (= 10.28.0) - Firebase/Messaging (= 10.29.0)
- firebase_core - firebase_core
- Flutter - Flutter
- FirebaseCore (10.28.0): - FirebaseCore (10.29.0):
- FirebaseCoreInternal (~> 10.0) - FirebaseCoreInternal (~> 10.0)
- GoogleUtilities/Environment (~> 7.12) - GoogleUtilities/Environment (~> 7.12)
- GoogleUtilities/Logger (~> 7.12) - GoogleUtilities/Logger (~> 7.12)
@@ -61,7 +61,7 @@ PODS:
- GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/UserDefaults (~> 7.8) - GoogleUtilities/UserDefaults (~> 7.8)
- PromisesObjC (~> 2.1) - PromisesObjC (~> 2.1)
- FirebaseMessaging (10.28.0): - FirebaseMessaging (10.29.0):
- FirebaseCore (~> 10.0) - FirebaseCore (~> 10.0)
- FirebaseInstallations (~> 10.0) - FirebaseInstallations (~> 10.0)
- GoogleDataTransport (~> 9.3) - GoogleDataTransport (~> 9.3)
@@ -76,6 +76,9 @@ PODS:
- flutter_webrtc (0.11.3): - flutter_webrtc (0.11.3):
- Flutter - Flutter
- WebRTC-SDK (= 125.6422.04) - WebRTC-SDK (= 125.6422.04)
- gal (1.0.0):
- Flutter
- FlutterMacOS
- GoogleDataTransport (9.4.1): - GoogleDataTransport (9.4.1):
- GoogleUtilities/Environment (~> 7.7) - GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30911.0, >= 2.30908.0) - nanopb (< 2.30911.0, >= 2.30908.0)
@@ -105,9 +108,12 @@ PODS:
- GoogleUtilities/UserDefaults (7.13.3): - GoogleUtilities/UserDefaults (7.13.3):
- GoogleUtilities/Logger - GoogleUtilities/Logger
- GoogleUtilities/Privacy - GoogleUtilities/Privacy
- image_cropper (0.0.4):
- Flutter
- TOCropViewController (~> 2.7.4)
- image_picker_ios (0.0.1): - image_picker_ios (0.0.1):
- Flutter - Flutter
- livekit_client (2.2.1): - livekit_client (2.2.3):
- Flutter - Flutter
- WebRTC-SDK (= 125.6422.04) - WebRTC-SDK (= 125.6422.04)
- media_kit_libs_ios_video (1.0.4): - media_kit_libs_ios_video (1.0.4):
@@ -138,15 +144,21 @@ PODS:
- SDWebImage (5.19.4): - SDWebImage (5.19.4):
- SDWebImage/Core (= 5.19.4) - SDWebImage/Core (= 5.19.4)
- SDWebImage/Core (5.19.4) - SDWebImage/Core (5.19.4)
- Sentry/HybridSDK (8.30.1) - Sentry/HybridSDK (8.32.0)
- sentry_flutter (8.4.0): - sentry_flutter (8.6.0):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.32.0)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- Sentry/HybridSDK (= 8.30.1)
- sqflite (0.0.3): - sqflite (0.0.3):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- SwiftyGif (5.4.5) - SwiftyGif (5.4.5)
- TOCropViewController (2.7.4)
- url_launcher_ios (0.0.1): - url_launcher_ios (0.0.1):
- Flutter - Flutter
- volume_controller (0.0.1): - volume_controller (0.0.1):
@@ -164,6 +176,8 @@ DEPENDENCIES:
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
- gal (from `.symlinks/plugins/gal/darwin`)
- image_cropper (from `.symlinks/plugins/image_cropper/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- livekit_client (from `.symlinks/plugins/livekit_client/ios`) - livekit_client (from `.symlinks/plugins/livekit_client/ios`)
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
@@ -176,6 +190,8 @@ DEPENDENCIES:
- protocol_handler_ios (from `.symlinks/plugins/protocol_handler_ios/ios`) - protocol_handler_ios (from `.symlinks/plugins/protocol_handler_ios/ios`)
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`) - screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`) - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`) - sqflite (from `.symlinks/plugins/sqflite/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- volume_controller (from `.symlinks/plugins/volume_controller/ios`) - volume_controller (from `.symlinks/plugins/volume_controller/ios`)
@@ -197,6 +213,7 @@ SPEC REPOS:
- SDWebImage - SDWebImage
- Sentry - Sentry
- SwiftyGif - SwiftyGif
- TOCropViewController
- WebRTC-SDK - WebRTC-SDK
EXTERNAL SOURCES: EXTERNAL SOURCES:
@@ -216,6 +233,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_secure_storage/ios" :path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_webrtc: flutter_webrtc:
:path: ".symlinks/plugins/flutter_webrtc/ios" :path: ".symlinks/plugins/flutter_webrtc/ios"
gal:
:path: ".symlinks/plugins/gal/darwin"
image_cropper:
:path: ".symlinks/plugins/image_cropper/ios"
image_picker_ios: image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios" :path: ".symlinks/plugins/image_picker_ios/ios"
livekit_client: livekit_client:
@@ -240,6 +261,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/screen_brightness_ios/ios" :path: ".symlinks/plugins/screen_brightness_ios/ios"
sentry_flutter: sentry_flutter:
:path: ".symlinks/plugins/sentry_flutter/ios" :path: ".symlinks/plugins/sentry_flutter/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite: sqflite:
:path: ".symlinks/plugins/sqflite/darwin" :path: ".symlinks/plugins/sqflite/darwin"
url_launcher_ios: url_launcher_ios:
@@ -255,20 +280,22 @@ SPEC CHECKSUMS:
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
Firebase: 5121c624121af81cbc81df3bda414b3c28c4f3c3 Firebase: cec914dab6fd7b1bd8ab56ea07ce4e03dd251c2d
firebase_core: a9d0180d5285527884d07a41eb4a9ec9ed12cdb6 firebase_core: 57aeb91680e5d5e6df6b888064be7c785f146efb
firebase_messaging: ccc82a143a74de75f440a4e413dbbb37ec3fddbc firebase_messaging: c862b3d2b973ecc769194dc8de09bd22c77ae757
FirebaseCore: 857dc1c6dd1255675047404d8466f7dfaac5d779 FirebaseCore: 30e9c1cbe3d38f5f5e75f48bfcea87d7c358ec16
FirebaseCoreInternal: df84dd300b561c27d5571684f389bf60b0a5c934 FirebaseCoreInternal: df84dd300b561c27d5571684f389bf60b0a5c934
FirebaseInstallations: 913cf60d0400ebd5d6b63a28b290372ab44590dd FirebaseInstallations: 913cf60d0400ebd5d6b63a28b290372ab44590dd
FirebaseMessaging: 087a7c7cadef7b9239f005bc4db823894844f323 FirebaseMessaging: 7b5d8033e183ab59eb5b852a53201559e976d366
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
flutter_webrtc: 75b868e4f9e817c7a9a42ca4b6169063de4eec9f flutter_webrtc: 75b868e4f9e817c7a9a42ca4b6169063de4eec9f
gal: 61e868295d28fe67ffa297fae6dacebf56fd53e1
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
image_cropper: 37d40f62177c101ff4c164906d259ea2c3aa70cf
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
livekit_client: 411d387fd6f993851081069afbe7f04a8e974f1b livekit_client: bad83a7776a41abc42e1f26d903eeac9164c8a9f
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1 media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
@@ -281,10 +308,13 @@ SPEC CHECKSUMS:
protocol_handler_ios: a5db8abc38526ee326988b808be621e5fd568990 protocol_handler_ios: a5db8abc38526ee326988b808be621e5fd568990
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625 screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d
Sentry: 514a3ea653886e9a48c6287d8b7bf05ec24bf3be Sentry: 96ae1dcdf01a644bc3a3b1dc279cecaf48a833fb
sentry_flutter: edc037f7af0dc1512d6c33a5c2c7c838bd0d6806 sentry_flutter: 090351ce1ff5f96a4b33ef9455b7e3b28185387d
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9 volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1 wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 575 B

After

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 868 B

After

Width:  |  Height:  |  Size: 815 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

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

252
lib/bootstrapper.dart Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,12 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
import 'package:solian/screens/about.dart'; import 'package:solian/screens/about.dart';
import 'package:solian/screens/account.dart'; import 'package:solian/screens/account.dart';
import 'package:solian/screens/account/friend.dart'; import 'package:solian/screens/account/friend.dart';
import 'package:solian/screens/account/personalize.dart'; import 'package:solian/screens/account/personalize.dart';
import 'package:solian/screens/account/profile_page.dart';
import 'package:solian/screens/channel/channel_chat.dart'; import 'package:solian/screens/channel/channel_chat.dart';
import 'package:solian/screens/channel/channel_detail.dart'; import 'package:solian/screens/channel/channel_detail.dart';
import 'package:solian/screens/channel/channel_organize.dart'; import 'package:solian/screens/channel/channel_organize.dart';
@@ -17,6 +20,7 @@ import 'package:solian/screens/realms/realm_organize.dart';
import 'package:solian/screens/realms/realm_view.dart'; import 'package:solian/screens/realms/realm_view.dart';
import 'package:solian/screens/home.dart'; import 'package:solian/screens/home.dart';
import 'package:solian/screens/posts/post_editor.dart'; import 'package:solian/screens/posts/post_editor.dart';
import 'package:solian/screens/settings.dart';
import 'package:solian/shells/root_shell.dart'; import 'package:solian/shells/root_shell.dart';
import 'package:solian/shells/title_shell.dart'; import 'package:solian/shells/title_shell.dart';
@@ -33,6 +37,22 @@ abstract class AppRouter {
_chatRoute, _chatRoute,
_realmRoute, _realmRoute,
_accountRoute, _accountRoute,
GoRoute(
path: '/about',
name: 'about',
builder: (context, state) => TitleShell(
state: state,
child: const AboutScreen(),
),
),
GoRoute(
path: '/settings',
name: 'settings',
builder: (context, state) => TitleShell(
state: state,
child: const SettingScreen(),
),
),
], ],
), ),
], ],
@@ -75,13 +95,26 @@ abstract class AppRouter {
GoRoute( GoRoute(
path: '/posts/editor', path: '/posts/editor',
name: 'postEditor', name: 'postEditor',
builder: (context, state) { pageBuilder: (context, state) {
final arguments = state.extra as PostPublishArguments?; final arguments = state.extra as PostPublishArguments?;
return PostPublishScreen( return CustomTransitionPage(
edit: arguments?.edit, child: PostPublishScreen(
reply: arguments?.reply, edit: arguments?.edit,
repost: arguments?.repost, reply: arguments?.reply,
realm: arguments?.realm, repost: arguments?.repost,
realm: arguments?.realm,
postListController: arguments?.postListController,
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,
);
},
); );
}, },
), ),
@@ -202,11 +235,10 @@ abstract class AppRouter {
), ),
), ),
GoRoute( GoRoute(
path: '/about', path: '/account/view/:name',
name: 'about', name: 'accountProfilePage',
builder: (context, state) => TitleShell( builder: (context, state) => AccountProfilePage(
state: state, name: state.pathParameters['name']!,
child: const AboutScreen(),
), ),
), ),
], ],

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
@@ -17,7 +18,9 @@ class AboutScreen extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Image.asset('assets/logo.png', width: 64, height: 64), Image.asset('assets/logo.png', width: 64, height: 64)
.animate(onPlay: (c) => c.repeat())
.rotate(duration: 1000.ms),
Text( Text(
'Solian', 'Solian',
style: Theme.of(context).textTheme.headlineMedium, style: Theme.of(context).textTheme.headlineMedium,
@@ -44,17 +47,29 @@ class AboutScreen extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
TextButton( TextButton(
style: denseButtonStyle, style: denseButtonStyle,
child: const Text('More Information'), child: const Text('App Details'),
onPressed: () { onPressed: () async {
launchUrlString('https://solsynth.dev/products/solar-network'); final info = await PackageInfo.fromPlatform();
showAboutDialog(
context: context,
applicationVersion: '${info.version} (${info.buildNumber})',
applicationLegalese:
'The Solar Network App is an intuitive and self-hostable social network and computing platform. Experience the freedom of a user-friendly design that empowers you to create and connect with communities on your own terms. Embrace the future of social networking with a platform that prioritizes your independence and privacy.',
applicationIcon: Image.asset(
'assets/logo.png',
width: 56,
height: 56,
),
);
}, },
), ),
TextButton( TextButton(
style: denseButtonStyle, style: denseButtonStyle,
child: const Text('Project Website'),
onPressed: () { onPressed: () {
launchUrlString('https://solsynth.dev'); launchUrlString('https://solsynth.dev/products/solar-network');
}, },
child: const Text('Official Website'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( const Text(

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,7 +52,7 @@ class _SignInPopupState extends State<SignInPopup> with ProtocolListener {
} }
void performAction() async { void performAction() async {
final AuthProvider provider = Get.find(); final AuthProvider auth = Get.find();
final username = _usernameController.value.text; final username = _usernameController.value.text;
final password = _passwordController.value.text; final password = _passwordController.value.text;
@@ -61,7 +61,11 @@ class _SignInPopupState extends State<SignInPopup> with ProtocolListener {
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
await provider.signin(context, username, password); await auth.signin(context, username, password);
await Future.delayed(const Duration(milliseconds: 250), () async {
await auth.refreshAuthorizeStatus();
await auth.refreshUserProfile();
});
} on RiskyAuthenticateException catch (e) { } on RiskyAuthenticateException catch (e) {
showDialog( showDialog(
context: context, context: context,
@@ -75,7 +79,8 @@ class _SignInPopupState extends State<SignInPopup> with ProtocolListener {
onPressed: () { onPressed: () {
const redirect = 'solink://auth?status=done'; const redirect = 'solink://auth?status=done';
launchUrlString( launchUrlString(
ServiceFinder.buildUrl('passport', '/mfa?redirect_uri=$redirect&ticketId=${e.ticketId}'), ServiceFinder.buildUrl('passport',
'/mfa?redirect_uri=$redirect&ticketId=${e.ticketId}'),
mode: LaunchMode.inAppWebView, mode: LaunchMode.inAppWebView,
); );
Navigator.pop(context); Navigator.pop(context);

View File

@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:async/async.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/providers/call.dart'; import 'package:solian/providers/call.dart';
@@ -16,11 +17,26 @@ class CallScreen extends StatefulWidget {
State<CallScreen> createState() => _CallScreenState(); State<CallScreen> createState() => _CallScreenState();
} }
class _CallScreenState extends State<CallScreen> { class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
Timer? timer; Timer? _timer;
String currentDuration = '00:00:00'; String _currentDuration = '00:00:00';
String parseDuration() { int _layoutMode = 0;
bool _showControls = true;
CancelableOperation? _hideControlsOperation;
late final AnimationController _controlsAnimationController =
AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
late final Animation<double> _controlsAnimation = CurvedAnimation(
parent: _controlsAnimationController,
curve: Curves.fastOutSlowIn,
);
String _parseDuration() {
final ChatCallProvider provider = Get.find(); final ChatCallProvider provider = Get.find();
if (provider.current.value == null) return '00:00:00'; if (provider.current.value == null) return '00:00:00';
Duration duration = Duration duration =
@@ -34,9 +50,142 @@ class _CallScreenState extends State<CallScreen> {
return formattedTime; return formattedTime;
} }
void updateDuration() { void _updateDuration() {
setState(() { setState(() {
currentDuration = parseDuration(); _currentDuration = _parseDuration();
});
}
void _switchLayout() {
if (_layoutMode < 1) {
setState(() => _layoutMode++);
} else {
setState(() => _layoutMode = 0);
}
}
void _toggleControls() {
if (_showControls) {
setState(() => _showControls = false);
_controlsAnimationController.animateTo(0);
_hideControlsOperation?.cancel();
} else {
setState(() => _showControls = true);
_controlsAnimationController.animateTo(1);
_planAutoHideControls();
}
}
void _planAutoHideControls() {
_hideControlsOperation = CancelableOperation.fromFuture(
Future.delayed(const Duration(seconds: 3), () {
if (!mounted) return;
if (_showControls) _toggleControls();
}),
);
}
Widget _buildListLayout() {
final ChatCallProvider call = Get.find();
return Obx(
() => Stack(
children: [
Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: call.focusTrack.value != null
? InteractiveParticipantWidget(
isFixedAvatar: false,
participant: call.focusTrack.value!,
onTap: () {},
)
: const SizedBox(),
),
Positioned(
left: 0,
right: 0,
top: 0,
child: SizedBox(
height: 128,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: math.max(0, call.participantTracks.length),
itemBuilder: (BuildContext context, int index) {
final track = call.participantTracks[index];
if (track.participant.sid ==
call.focusTrack.value?.participant.sid) {
return Container();
}
return Padding(
padding: const EdgeInsets.only(top: 8, left: 8),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: InteractiveParticipantWidget(
isFixedAvatar: true,
width: 120,
height: 120,
color: Theme.of(context).cardColor,
participant: track,
onTap: () {
if (track.participant.sid !=
call.focusTrack.value?.participant.sid) {
call.changeFocusTrack(track);
}
},
),
),
);
},
),
),
),
],
),
);
}
Widget _buildGridLayout() {
final ChatCallProvider call = Get.find();
return LayoutBuilder(builder: (context, constraints) {
double screenWidth = constraints.maxWidth;
double screenHeight = constraints.maxHeight;
int columns = (math.sqrt(call.participantTracks.length)).ceil();
int rows = (call.participantTracks.length / columns).ceil();
double tileWidth = screenWidth / columns;
double tileHeight = screenHeight / rows;
return Obx(
() => GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
childAspectRatio: tileWidth / tileHeight,
),
itemCount: math.max(0, call.participantTracks.length),
itemBuilder: (BuildContext context, int index) {
final track = call.participantTracks[index];
return Padding(
padding: const EdgeInsets.all(16),
child: 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);
}
},
),
),
),
);
},
),
);
}); });
} }
@@ -45,7 +194,18 @@ class _CallScreenState extends State<CallScreen> {
Get.find<ChatCallProvider>().setupRoom(); Get.find<ChatCallProvider>().setupRoom();
super.initState(); super.initState();
timer = Timer.periodic(const Duration(seconds: 1), (_) => updateDuration()); _updateDuration();
_planAutoHideControls();
_timer = Timer.periodic(
const Duration(seconds: 1),
(_) => _updateDuration(),
);
}
@override
void dispose() {
_controlsAnimationController.dispose();
super.dispose();
} }
@override @override
@@ -68,80 +228,71 @@ class _CallScreenState extends State<CallScreen> {
), ),
const TextSpan(text: '\n'), const TextSpan(text: '\n'),
TextSpan( TextSpan(
text: currentDuration, text: _currentDuration,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
]), ]),
), ),
), ),
body: SafeArea( body: SafeArea(
child: Obx( child: GestureDetector(
() => Stack( behavior: HitTestBehavior.translucent,
child: Column(
children: [ children: [
Column( SizeTransition(
children: [ sizeFactor: _controlsAnimation,
Expanded( axis: Axis.vertical,
child: Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: provider.focusTrack.value != null
? InteractiveParticipantWidget(
isFixed: false,
participant: provider.focusTrack.value!,
onTap: () {},
)
: const SizedBox(),
),
),
if (provider.room.localParticipant != null)
ControlsWidget(
provider.room,
provider.room.localParticipant!,
),
],
),
Positioned(
left: 0,
right: 0,
top: 0,
child: SizedBox( child: SizedBox(
height: 128, width: MediaQuery.of(context).size.width,
child: ListView.builder( height: 64,
scrollDirection: Axis.horizontal, child: Row(
itemCount: math.max(0, provider.participantTracks.length), children: [
itemBuilder: (BuildContext context, int index) { const Expanded(child: SizedBox()),
final track = provider.participantTracks[index]; IconButton(
if (track.participant.sid == icon: _layoutMode == 0
provider.focusTrack.value?.participant.sid) { ? const Icon(Icons.view_list)
return Container(); : const Icon(Icons.grid_view),
onPressed: () {
_switchLayout();
},
),
],
).paddingSymmetric(horizontal: 10),
),
),
Expanded(
child: Material(
color: Theme.of(context).colorScheme.surfaceContainerLow,
elevation: 2,
child: Builder(
builder: (context) {
switch (_layoutMode) {
case 1:
return _buildGridLayout();
default:
return _buildListLayout();
} }
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);
}
},
),
),
);
}, },
), ),
), ),
), ),
if (provider.room.localParticipant != null)
SizeTransition(
sizeFactor: _controlsAnimation,
axis: Axis.vertical,
child: SizedBox(
width: MediaQuery.of(context).size.width,
child: ControlsWidget(
provider.room,
provider.room.localParticipant!,
),
),
),
], ],
), ),
onTap: () {
_toggleControls();
},
), ),
), ),
), ),
@@ -150,16 +301,16 @@ class _CallScreenState extends State<CallScreen> {
@override @override
void deactivate() { void deactivate() {
timer?.cancel(); _timer?.cancel();
timer = null; _timer = null;
super.deactivate(); super.deactivate();
} }
@override @override
void activate() { void activate() {
timer ??= Timer.periodic( _timer ??= Timer.periodic(
const Duration(seconds: 1), const Duration(seconds: 1),
(_) => updateDuration(), (_) => _updateDuration(),
); );
super.activate(); super.activate();
} }

View File

@@ -11,7 +11,6 @@ import 'package:solian/models/channel.dart';
import 'package:solian/models/event.dart'; import 'package:solian/models/event.dart';
import 'package:solian/models/packet.dart'; import 'package:solian/models/packet.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/call.dart';
import 'package:solian/providers/content/channel.dart'; import 'package:solian/providers/content/channel.dart';
import 'package:solian/providers/websocket.dart'; import 'package:solian/providers/websocket.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
@@ -19,7 +18,7 @@ import 'package:solian/screens/channel/channel_detail.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/chat/call/call_prejoin.dart'; import 'package:solian/widgets/channel/channel_call_indicator.dart';
import 'package:solian/widgets/chat/call/chat_call_action.dart'; import 'package:solian/widgets/chat/call/chat_call_action.dart';
import 'package:solian/widgets/chat/chat_event.dart'; import 'package:solian/widgets/chat/chat_event.dart';
import 'package:solian/widgets/chat/chat_event_list.dart'; import 'package:solian/widgets/chat/chat_event_list.dart';
@@ -53,7 +52,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
late final ChatEventController _chatController; late final ChatEventController _chatController;
getChannel({String? alias}) async { _getChannel({String? alias}) async {
final ChannelProvider provider = Get.find(); final ChannelProvider provider = Get.find();
setState(() => _isBusy = true); setState(() => _isBusy = true);
@@ -80,7 +79,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
getOngoingCall() async { _getOngoingCall() async {
final ChannelProvider provider = Get.find(); final ChannelProvider provider = Get.find();
setState(() => _isBusy = true); setState(() => _isBusy = true);
@@ -100,7 +99,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
void listenMessages() { void _listenMessages() {
final WebSocketProvider provider = Get.find(); final WebSocketProvider provider = Get.find();
_subscription = provider.stream.stream.listen((event) { _subscription = provider.stream.stream.listen((event) {
switch (event.method) { switch (event.method) {
@@ -110,26 +109,20 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
break; break;
case 'calls.new': case 'calls.new':
final payload = Call.fromJson(event.payload!); final payload = Call.fromJson(event.payload!);
setState(() => _ongoingCall = payload); if (payload.channel.id == _channel!.id) {
setState(() => _ongoingCall = payload);
}
break; break;
case 'calls.end': case 'calls.end':
setState(() => _ongoingCall = null); final payload = Call.fromJson(event.payload!);
if (payload.channel.id == _channel!.id) {
setState(() => _ongoingCall = null);
}
break; break;
} }
}); });
} }
void showCallPrejoin() {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => ChatCallPrejoinPopup(
ongoingCall: _ongoingCall!,
channel: _channel!,
),
);
}
Event? _messageToReplying; Event? _messageToReplying;
Event? _messageToEditing; Event? _messageToEditing;
@@ -149,13 +142,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_chatController = ChatEventController(); _chatController = ChatEventController();
_chatController.initialize(); _chatController.initialize();
getChannel().then((_) { _getOngoingCall();
_getChannel().then((_) {
_chatController.getEvents(_channel!, widget.realm); _chatController.getEvents(_channel!, widget.realm);
listenMessages(); _listenMessages();
}); });
getOngoingCall();
super.initState(); super.initState();
} }
@@ -183,8 +175,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
); );
} }
final ChatCallProvider call = Get.find();
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context), leading: AppBarLeadingButton.adaptive(context),
@@ -219,7 +209,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
if (value == false) AppRouter.instance.pop(); if (value == false) AppRouter.instance.pop();
if (value != null) { if (value != null) {
final resp = Channel.fromJson(value as Map<String, dynamic>); final resp = Channel.fromJson(value as Map<String, dynamic>);
getChannel(alias: resp.alias); _getChannel(alias: resp.alias);
} }
}); });
}, },
@@ -232,32 +222,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
body: Column( body: Column(
children: [ children: [
if (_ongoingCall != null) if (_ongoingCall != null)
MaterialBanner( ChannelCallIndicator(
padding: const EdgeInsets.only(left: 16, top: 4, bottom: 4), channel: _channel!,
leading: const Icon(Icons.call_received), ongoingCall: _ongoingCall!,
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( Expanded(
child: ChatEventList( child: ChatEventList(

View File

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

View File

@@ -4,12 +4,13 @@ import 'package:solian/controllers/post_list_controller.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/screens/account/notification.dart'; import 'package:solian/screens/account/notification.dart';
import 'package:solian/screens/posts/post_editor.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/current_state_action.dart'; import 'package:solian/widgets/current_state_action.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/feed/feed_list.dart';
import 'package:solian/widgets/posts/post_shuffle_swiper.dart'; import 'package:solian/widgets/posts/post_shuffle_swiper.dart';
import 'package:solian/widgets/posts/post_warped_list.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@@ -25,9 +26,8 @@ class _HomeScreenState extends State<HomeScreen>
@override @override
void initState() { void initState() {
Get.lazyPut(() => PostListController());
super.initState(); super.initState();
_postController = Get.find(); _postController = PostListController();
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 2, vsync: this);
_tabController.addListener(() { _tabController.addListener(() {
switch (_tabController.index) { switch (_tabController.index) {
@@ -44,70 +44,69 @@ class _HomeScreenState extends State<HomeScreen>
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return Material(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: SafeArea( child: Scaffold(
bottom: false, floatingActionButton: FloatingActionButton(
child: Scaffold( child: const Icon(Icons.add),
floatingActionButton: FloatingActionButton( onPressed: () {
child: const Icon(Icons.add), showModalBottomSheet(
onPressed: () { useRootNavigator: true,
showModalBottomSheet( isScrollControlled: true,
useRootNavigator: true, context: context,
isScrollControlled: true, builder: (context) => PostCreatePopup(
context: context, controller: _postController,
builder: (context) => const PostCreatePopup(), ),
); );
}, },
), ),
body: NestedScrollView( body: NestedScrollView(
headerSliverBuilder: headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
(BuildContext context, bool innerBoxIsScrolled) { return [
return [ SliverAppBar(
SliverAppBar( title: AppBarTitle('home'.tr),
title: AppBarTitle('home'.tr), centerTitle: false,
centerTitle: false, floating: true,
floating: true, toolbarHeight: SolianTheme.toolbarHeight(context),
toolbarHeight: SolianTheme.toolbarHeight(context), leading: AppBarLeadingButton.adaptive(context),
leading: AppBarLeadingButton.adaptive(context), actions: [
actions: [ const BackgroundStateWidget(),
const BackgroundStateWidget(), const NotificationButton(),
const NotificationButton(), SizedBox(
SizedBox( width: SolianTheme.isLargeScreen(context) ? 8 : 16,
width: SolianTheme.isLargeScreen(context) ? 8 : 16,
),
],
bottom: TabBar(
controller: _tabController,
tabs: [
Tab(text: 'postListNews'.tr),
Tab(text: 'postListShuffle'.tr),
],
), ),
)
];
},
body: Obx(() {
if (_postController.isPreparing.isTrue) {
return const Center(
child: CircularProgressIndicator(),
);
}
return TabBarView(
physics: const NeverScrollableScrollPhysics(),
controller: _tabController,
children: [
RefreshIndicator(
onRefresh: () => _postController.reloadAllOver(),
child: CustomScrollView(slivers: [
FeedListWidget(
controller: _postController.pagingController),
]),
),
PostShuffleSwiper(controller: _postController),
], ],
bottom: TabBar(
controller: _tabController,
tabs: [
Tab(text: 'postListNews'.tr),
Tab(text: 'postListShuffle'.tr),
],
),
)
];
},
body: Obx(() {
if (_postController.isPreparing.isTrue) {
return const Center(
child: CircularProgressIndicator(),
); );
}), }
),
return TabBarView(
physics: const NeverScrollableScrollPhysics(),
controller: _tabController,
children: [
RefreshIndicator(
onRefresh: () => _postController.reloadAllOver(),
child: CustomScrollView(slivers: [
PostWarpedListWidget(
controller: _postController.pagingController,
),
]),
),
PostShuffleSwiper(controller: _postController),
],
);
}),
), ),
), ),
); );
@@ -122,12 +121,12 @@ class _HomeScreenState extends State<HomeScreen>
class PostCreatePopup extends StatelessWidget { class PostCreatePopup extends StatelessWidget {
final bool hideDraftBox; final bool hideDraftBox;
final Function? onCreated; final PostListController controller;
const PostCreatePopup({ const PostCreatePopup({
super.key, super.key,
this.hideDraftBox = false, this.hideDraftBox = false,
this.onCreated, required this.controller,
}); });
@override @override
@@ -140,13 +139,31 @@ class PostCreatePopup extends StatelessWidget {
final List<dynamic> actionList = [ final List<dynamic> actionList = [
( (
icon: const Icon(Icons.edit_square), icon: const Icon(Icons.post_add),
label: 'postEditor'.tr, label: 'postEditorModeStory'.tr,
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
AppRouter.instance.pushNamed('postEditor').then((val) { AppRouter.instance.pushNamed(
if (val != null && onCreated != null) onCreated!(); 'postEditor',
}); extra: PostPublishArguments(postListController: controller),
queryParameters: {
'mode': 0.toString(),
},
);
},
),
(
icon: const Icon(Icons.description),
label: 'postEditorModeArticle'.tr,
onTap: () {
Navigator.pop(context);
AppRouter.instance.pushNamed(
'postEditor',
extra: PostPublishArguments(postListController: controller),
queryParameters: {
'mode': 1.toString(),
},
);
}, },
), ),
( (
@@ -160,7 +177,7 @@ class PostCreatePopup extends StatelessWidget {
]; ];
return SizedBox( return SizedBox(
height: MediaQuery.of(context).size.height * 0.35, height: MediaQuery.of(context).size.height * 0.38,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [

View File

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

View File

@@ -2,18 +2,20 @@ import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:markdown_toolbar/markdown_toolbar.dart';
import 'package:solian/controllers/post_editor_controller.dart';
import 'package:solian/controllers/post_list_controller.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
import 'package:solian/providers/attachment_uploader.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/attachments/attachment_publish.dart';
import 'package:solian/widgets/posts/post_item.dart'; import 'package:solian/widgets/posts/post_item.dart';
import 'package:solian/widgets/feed/feed_tags_field.dart';
import 'package:textfield_tags/textfield_tags.dart';
import 'package:badges/badges.dart' as badges; import 'package:badges/badges.dart' as badges;
class PostPublishArguments { class PostPublishArguments {
@@ -21,8 +23,15 @@ class PostPublishArguments {
final Post? reply; final Post? reply;
final Post? repost; final Post? repost;
final Realm? realm; final Realm? realm;
final PostListController? postListController;
PostPublishArguments({this.edit, this.reply, this.repost, this.realm}); PostPublishArguments({
this.edit,
this.reply,
this.repost,
this.realm,
this.postListController,
});
} }
class PostPublishScreen extends StatefulWidget { class PostPublishScreen extends StatefulWidget {
@@ -30,6 +39,8 @@ class PostPublishScreen extends StatefulWidget {
final Post? reply; final Post? reply;
final Post? repost; final Post? repost;
final Realm? realm; final Realm? realm;
final PostListController? postListController;
final int mode;
const PostPublishScreen({ const PostPublishScreen({
super.key, super.key,
@@ -37,6 +48,8 @@ class PostPublishScreen extends StatefulWidget {
this.reply, this.reply,
this.repost, this.repost,
this.realm, this.realm,
this.postListController,
required this.mode,
}); });
@override @override
@@ -44,87 +57,87 @@ class PostPublishScreen extends StatefulWidget {
} }
class _PostPublishScreenState extends State<PostPublishScreen> { class _PostPublishScreenState extends State<PostPublishScreen> {
final _contentController = TextEditingController(); final _editorController = PostEditorController();
final _tagsController = StringTagController(); final _contentFocusNode = FocusNode();
bool _isBusy = false; bool _isBusy = false;
List<int> _attachments = List.empty(); void _applyPost() async {
bool _isDraft = false;
void showAttachments() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => AttachmentPublishPopup(
usage: 'i.attachment',
current: _attachments,
onUpdate: (value) {
setState(() => _attachments = value);
},
),
);
}
void applyPost() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
if (_contentController.value.text.isEmpty) return; if (_editorController.isEmpty) return;
final AttachmentUploaderController uploader = Get.find();
if (uploader.queueOfUpload.any(
((x) => x.usage == 'i.attachment' && x.isUploading),
)) {
context.showErrorDialog('attachmentUploadInProgress'.tr);
return;
}
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = auth.configureClient('interactive'); final client = auth.configureClient('interactive');
final payload = {
'content': _contentController.value.text,
'tags': _tagsController.getTags?.map((x) => {'alias': x}).toList() ??
List.empty(),
'attachments': _attachments,
'is_draft': _isDraft,
if (widget.reply != null) 'reply_to': widget.reply!.id,
if (widget.repost != null) 'repost_to': widget.repost!.id,
if (widget.realm != null) 'realm': widget.realm!.alias,
};
Response resp; Response resp;
if (widget.edit != null) { if (widget.edit != null) {
resp = await client.put('/stories/${widget.edit!.id}', payload); resp = await client.put(
'/${_editorController.typeEndpoint}/${widget.edit!.id}',
_editorController.payload,
);
} else { } else {
resp = await client.post('/stories', payload); resp = await client.post(
'/${_editorController.typeEndpoint}',
_editorController.payload,
);
} }
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString); context.showErrorDialog(resp.bodyString);
} else { } else {
_editorController.localClear();
if (widget.postListController != null) {
widget.postListController!.reloadAllOver();
}
AppRouter.instance.pop(resp.body); AppRouter.instance.pop(resp.body);
} }
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
void syncWidget() { void _syncWidget() {
_editorController.mode.value = widget.mode;
if (widget.edit != null) { if (widget.edit != null) {
_contentController.text = widget.edit!.body['content']; _editorController.editTarget = widget.edit;
_attachments = widget.edit!.body['attachments']?.cast<int>() ?? List.empty(); }
_isDraft = widget.edit!.isDraft ?? false; if (widget.realm != null) {
_editorController.realmZone.value = widget.realm;
} }
} }
void cancelAction() { void _cancelAction() {
_editorController.localClear();
AppRouter.instance.pop(); AppRouter.instance.pop();
} }
Post? get _editTo => _editorController.editTo.value;
Post? get _replyTo => _editorController.replyTo.value;
Post? get _repostTo => _editorController.repostTo.value;
@override @override
void initState() { void initState() {
syncWidget();
super.initState(); super.initState();
if (widget.edit == null) _editorController.localRead();
_editorController.contentController.addListener(() => setState(() {}));
_syncWidget();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final notifyBannerActions = [ final notifyBannerActions = [
TextButton( TextButton(
onPressed: cancelAction, onPressed: _cancelAction,
child: Text('cancel'.tr), child: Text('cancel'.tr),
) )
]; ];
@@ -134,156 +147,337 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context), leading: AppBarLeadingButton.adaptive(context),
title: AppBarTitle('postPublish'.tr), title: Obx(
() => AppBarTitle(
_editorController.mode.value == 0
? 'postEditorModeStory'.tr
: 'postEditorModeArticle'.tr,
),
),
centerTitle: false, centerTitle: false,
toolbarHeight: SolianTheme.toolbarHeight(context), toolbarHeight: SolianTheme.toolbarHeight(context),
actions: [ actions: [
TextButton( TextButton(
onPressed: _isBusy ? null : () => applyPost(), onPressed: _isBusy ? null : () => _applyPost(),
child: Text( child: Obx(
_isDraft () => Text(
? 'draftSave'.tr.toUpperCase() _editorController.isDraft.isTrue
: 'postAction'.tr.toUpperCase(), ? 'draftSave'.tr.toUpperCase()
: 'postAction'.tr.toUpperCase(),
),
), ),
) )
], ],
), ),
body: Stack( body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ListView( ListTile(
children: [ tileColor: Theme.of(context).colorScheme.surfaceContainerLow,
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), title: Text(
if (widget.edit != null && widget.edit!.isDraft != true) _editorController.title ?? 'title'.tr,
MaterialBanner( maxLines: 1,
leading: const Icon(Icons.edit), overflow: TextOverflow.ellipsis,
leadingPadding: const EdgeInsets.only(left: 10, right: 20), ),
dividerColor: Colors.transparent, subtitle: Text(
content: Text('postEditingNotify'.tr), _editorController.description ?? 'description'.tr,
actions: notifyBannerActions, maxLines: 2,
), overflow: TextOverflow.ellipsis,
if (widget.reply != null) ),
ExpansionTile( contentPadding: const EdgeInsets.only(
leading: const FaIcon( left: 17,
FontAwesomeIcons.reply, right: 8,
size: 18, top: 0,
).paddingOnly(left: 2), bottom: 0,
title: Text('postReplyingNotify'.trParams( ),
{'username': '@${widget.reply!.author.name}'}, trailing: IconButton(
)), icon: const Icon(Icons.edit),
collapsedBackgroundColor: onPressed: () {
Theme.of(context).colorScheme.surfaceContainer, _editorController.editOverview(context).then((_) {
children: [ setState(() {});
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,
),
const Divider(thickness: 0.3, height: 0.3)
.paddingOnly(bottom: 8),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
maxLines: null,
autofocus: true,
autocorrect: true,
keyboardType: TextInputType.multiline,
controller: _contentController,
decoration: InputDecoration.collapsed(
hintText: 'postContentPlaceholder'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
const SizedBox(height: 120)
],
), ),
Positioned( if (_editTo != null && _editTo!.isDraft != true)
bottom: 0, MaterialBanner(
left: 0, leading: const Icon(Icons.edit),
right: 0, leadingPadding: const EdgeInsets.only(left: 10, right: 20),
child: Material( dividerColor: Colors.transparent,
elevation: 8, content: Text('postEditingNotify'.tr),
color: Theme.of(context).colorScheme.surface, actions: notifyBannerActions,
child: Column( ),
crossAxisAlignment: CrossAxisAlignment.start, if (_replyTo != null)
children: [ ExpansionTile(
TagsField( leading: const FaIcon(
initialTags: FontAwesomeIcons.reply,
widget.edit?.tags?.map((x) => x.alias).toList(), size: 18,
tagsController: _tagsController, ).paddingOnly(left: 2),
hintText: 'postTagsPlaceholder'.tr, title: Text('postReplyingNotify'.trParams(
{'username': '@${widget.reply!.author.name}'},
)),
collapsedBackgroundColor:
Theme.of(context).colorScheme.surfaceContainer,
children: [
PostItem(
item: _replyTo!,
isReactable: false,
).paddingOnly(bottom: 8),
],
),
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: [
PostItem(
item: _repostTo!,
isReactable: false,
).paddingOnly(bottom: 8),
],
),
Expanded(
child: ListView(
children: [
if (_isBusy)
const LinearProgressIndicator().animate().scaleX(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
), ),
const Divider(thickness: 0.3, height: 0.3), child: TextField(
SizedBox( maxLines: null,
height: 56, autofocus: true,
child: ListView( autocorrect: true,
scrollDirection: Axis.horizontal, keyboardType: TextInputType.multiline,
controller: _editorController.contentController,
focusNode: _contentFocusNode,
decoration: InputDecoration.collapsed(
hintText: 'postContentPlaceholder'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
const SizedBox(height: 120)
],
),
),
Material(
color: Theme.of(context).colorScheme.surface,
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: [ children: [
IconButton( if (showFactors[0])
icon: _isDraft Text('postRestoreFromLocal'.tr, style: textStyle)
? const Icon(Icons.drive_file_rename_outline) .paddingOnly(right: 4),
: const Icon(Icons.public), if (showFactors[0])
color: _isDraft InkWell(
? Colors.grey.shade600 child: Text('clear'.tr, style: textStyle),
: Colors.green.shade700, 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,
),
),
),
SizedBox(
height: 56,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
Obx(() {
final isDraft = _editorController.isDraft.value;
return IconButton(
icon: const Icon(
Icons.drive_file_rename_outline,
color: Colors.grey,
)
.animate(
target: isDraft ? 0 : 1,
)
.fadeOut(duration: 150.ms)
.swap(
duration: 150.ms,
builder: (_, __) => const Icon(
Icons.public,
color: Colors.green,
).animate().fadeIn(duration: 150.ms),
),
onPressed: () { onPressed: () {
setState(() => _isDraft = !_isDraft); _editorController.toggleDraftMode();
}, },
), );
IconButton( }),
icon: badges.Badge( IconButton(
icon: const Icon(Icons.disabled_visible),
color: Theme.of(context).colorScheme.primary,
onPressed: () {
_editorController.editVisibility(context);
},
),
IconButton(
icon: Obx(() {
return badges.Badge(
badgeContent: Text( badgeContent: Text(
_attachments.length.toString(), _editorController.attachments.length.toString(),
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
), ),
showBadge: _attachments.isNotEmpty, showBadge:
_editorController.attachments.isNotEmpty,
position: badges.BadgePosition.topEnd( position: badges.BadgePosition.topEnd(
top: -12, top: -12,
end: -8, end: -8,
), ),
child: const Icon(Icons.camera_alt), child: const Icon(Icons.file_present_rounded),
), );
color: Theme.of(context).colorScheme.primary, }),
onPressed: () => showAttachments(), color: Theme.of(context).colorScheme.primary,
), onPressed: () {
], _editorController.editAttachment(context);
).paddingSymmetric(horizontal: 6, vertical: 8), },
), ),
], IconButton(
).paddingOnly(bottom: MediaQuery.of(context).padding.bottom), 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.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: Colors.transparent,
iconColor: Theme.of(context).colorScheme.onSurface,
controller: _editorController.contentController,
focusNode: _contentFocusNode,
borderRadius:
const BorderRadius.all(Radius.circular(20)),
width: 40,
).paddingSymmetric(horizontal: 4),
],
).paddingSymmetric(horizontal: 6, vertical: 8),
),
],
).paddingOnly(bottom: MediaQuery.of(context).padding.bottom),
), ),
], ],
), ),
@@ -293,8 +487,8 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
@override @override
void dispose() { void dispose() {
_contentController.dispose(); _contentFocusNode.dispose();
_tagsController.dispose(); _editorController.dispose();
super.dispose(); super.dispose();
} }
} }

View File

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

View File

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

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

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

View File

@@ -15,7 +15,7 @@ abstract class ServiceFinder {
} else if (serviceName == 'passport') { } else if (serviceName == 'passport') {
return '$passportUrl$append'; return '$passportUrl$append';
} }
return '$dealerUrl/srv/$serviceName$append'; return '$dealerUrl/cgi/$serviceName$append';
} }
static GetConnect configureClient(String serviceName, static GetConnect configureClient(String serviceName,

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
const messagesEnglish = { const i18nEnglish = {
'done': 'Done', 'done': 'Done',
'hide': 'Hide', 'hide': 'Hide',
'okay': 'Okay', 'okay': 'Okay',
@@ -10,10 +10,16 @@ const messagesEnglish = {
'draft': 'Draft', 'draft': 'Draft',
'draftSave': 'Save', 'draftSave': 'Save',
'draftBox': 'Draft Box', 'draftBox': 'Draft Box',
'more': 'More',
'share': 'Share',
'feed': 'Feed', 'feed': 'Feed',
'unlink': 'Unlink',
'feedSearch': 'Search Feed', 'feedSearch': 'Search Feed',
'feedSearchWithTag': 'Searching with tag #@key', 'feedSearchWithTag': 'Searching with tag #@key',
'feedSearchWithCategory': 'Searching in category @category', 'feedSearchWithCategory': 'Searching in category @category',
'visitProfilePage': 'Visit Profile Page',
'profilePosts': 'Posts',
'profileAlbum': 'Album',
'chat': 'Chat', 'chat': 'Chat',
'apply': 'Apply', 'apply': 'Apply',
'cancel': 'Cancel', 'cancel': 'Cancel',
@@ -23,11 +29,13 @@ const messagesEnglish = {
'about': 'About', 'about': 'About',
'edit': 'Edit', 'edit': 'Edit',
'delete': 'Delete', 'delete': 'Delete',
'settings': 'Settings',
'search': 'Search', 'search': 'Search',
'post': 'Post', 'post': 'Post',
'article': 'Article', 'article': 'Article',
'reply': 'Reply', 'reply': 'Reply',
'repost': 'Repost', 'repost': 'Repost',
'openInAlbum': 'Open in album',
'openInBrowser': 'Open in browser', 'openInBrowser': 'Open in browser',
'notification': 'Notification', 'notification': 'Notification',
'errorHappened': 'An error occurred', 'errorHappened': 'An error occurred',
@@ -36,6 +44,7 @@ const messagesEnglish = {
'username': 'Username', 'username': 'Username',
'nickname': 'Nickname', 'nickname': 'Nickname',
'password': 'Password', 'password': 'Password',
'title': 'Title',
'description': 'Description', 'description': 'Description',
'birthday': 'Birthday', 'birthday': 'Birthday',
'firstName': 'First Name', 'firstName': 'First Name',
@@ -50,6 +59,7 @@ const messagesEnglish = {
'accountFriendPending': 'Friend requests', 'accountFriendPending': 'Friend requests',
'accountFriendBlocked': 'Friend blocklist', 'accountFriendBlocked': 'Friend blocklist',
'accountFriendListHint': 'Swipe left to decline, right to approve', 'accountFriendListHint': 'Swipe left to decline, right to approve',
'accountFriendRequestSent': 'Friend request sent, waiting for processing...',
'accountSuspended': 'Account was suspended', 'accountSuspended': 'Account was suspended',
'accountSuspendedAt': 'Account was suspended since @date', 'accountSuspendedAt': 'Account was suspended since @date',
'aspectRatio': 'Aspect Ratio', 'aspectRatio': 'Aspect Ratio',
@@ -83,9 +93,36 @@ const messagesEnglish = {
'notifyAllRead': 'Mark all as read', 'notifyAllRead': 'Mark all as read',
'notifyEmpty': 'All notifications read', 'notifyEmpty': 'All notifications read',
'notifyEmptyCaption': 'It seems like nothing happened recently', 'notifyEmptyCaption': 'It seems like nothing happened recently',
'totalSocialCreditPoints': 'Social Credit Points',
'totalPostCount': 'Posts',
'totalUpvote': 'Upvote',
'totalDownvote': 'Downvote',
'clear': 'Clear',
'pinPost': 'Pin this post',
'unpinPost': 'Unpin this post',
'postRestoreFromLocal': 'Restore from local',
'postAutoSaveAt': 'Auto saved at @date',
'postCategoriesAndTags': 'Categories n\' Tags',
'postPublishDate': 'Publish Date',
'postPublishAt': 'Publish At',
'postPublishedUntil': 'Publish Until',
'postPublishZone': 'Publish Zone',
'postPublishZoneNone': 'None',
'postVisibility': 'Visibility',
'postVisibilityAll': 'Everyone',
'postVisibilityFriends': 'Friends',
'postVisibilitySelected': 'Selected visible',
'postVisibilityFiltered': 'Selected invisible',
'postVisibilityNone': 'Only me',
'postVisibleUsers': 'Visible users',
'postInvisibleUsers': 'Invisible users',
'postOverview': 'Overview',
'postPinned': 'Pinned',
'postListNews': 'News', 'postListNews': 'News',
'postListShuffle': 'Random', 'postListShuffle': 'Random',
'postEditor': 'Create new post', 'postEditorModeStory': 'Post a post',
'postEditorModeArticle': 'Post an article',
'postEditor': 'Post editor',
'articleEditor': 'Create new article', 'articleEditor': 'Create new article',
'articleDetail': 'Article details', 'articleDetail': 'Article details',
'draftBoxOpen': 'Open draft box', 'draftBoxOpen': 'Open draft box',
@@ -94,7 +131,7 @@ const messagesEnglish = {
'postAction': 'Post', 'postAction': 'Post',
'postEdited': 'Edited at @date', 'postEdited': 'Edited at @date',
'postNewCreated': 'Created at @date', 'postNewCreated': 'Created at @date',
'postAttachmentTip': '@count attachment(s)', 'attachmentHint': '@count attachment(s)',
'postInRealm': 'In @realm', 'postInRealm': 'In @realm',
'postDetail': 'Post', 'postDetail': 'Post',
'postReplies': 'Replies', 'postReplies': 'Replies',
@@ -121,6 +158,13 @@ const messagesEnglish = {
'reactAdd': 'React', 'reactAdd': 'React',
'reactCompleted': 'Your reaction has been added', 'reactCompleted': 'Your reaction has been added',
'reactUncompleted': 'Your reaction has been removed', 'reactUncompleted': 'Your reaction has been removed',
'attachmentUploadBy': 'Upload by',
'attachmentAutoUpload': 'Auto Upload',
'attachmentUploadQueue': 'Upload Queue',
'attachmentUploadQueueStart': 'Start All',
'attachmentUploadInProgress': 'There are attachments being uploaded. Please wait until all attachments have been uploaded before proceeding...',
'attachmentAttached': 'Exists Files',
'attachmentUploadBlocked': 'Upload blocked, there is currently a task in progress...',
'attachmentAdd': 'Attach attachments', 'attachmentAdd': 'Attach attachments',
'attachmentAddGalleryPhoto': 'Gallery photo', 'attachmentAddGalleryPhoto': 'Gallery photo',
'attachmentAddGalleryVideo': 'Gallery video', 'attachmentAddGalleryVideo': 'Gallery video',
@@ -128,6 +172,9 @@ const messagesEnglish = {
'attachmentAddCameraVideo': 'Capture video', 'attachmentAddCameraVideo': 'Capture video',
'attachmentAddClipboard': 'Paste file', 'attachmentAddClipboard': 'Paste file',
'attachmentAddFile': 'Attach file', 'attachmentAddFile': 'Attach file',
'attachmentAddLink': 'Link attachments',
'attachmentAddLinkHint': 'Enter attachment serial number to link that attachment',
'attachmentAddLinkInput': 'Serial number',
'attachmentSetting': 'Adjust attachment', 'attachmentSetting': 'Adjust attachment',
'attachmentAlt': 'Alternative text', 'attachmentAlt': 'Alternative text',
'attachmentLoadFailed': 'Load Attachment Failed', 'attachmentLoadFailed': 'Load Attachment Failed',
@@ -210,6 +257,8 @@ const messagesEnglish = {
'Are your sure to delete message @id? This action cannot be undone!', 'Are your sure to delete message @id? This action cannot be undone!',
'call': 'Call', 'call': 'Call',
'callOngoing': 'A call is ongoing...', 'callOngoing': 'A call is ongoing...',
'callOngoingEmpty': 'A call is on hold...',
'callOngoingParticipants': '@count people are calling...',
'callJoin': 'Join', 'callJoin': 'Join',
'callResume': 'Resume', 'callResume': 'Resume',
'callMicrophone': 'Microphone', 'callMicrophone': 'Microphone',
@@ -264,4 +313,30 @@ const messagesEnglish = {
'accountStatusNegative': 'Negative', 'accountStatusNegative': 'Negative',
'accountStatusNeutral': 'Neutral', 'accountStatusNeutral': 'Neutral',
'accountStatusPositive': 'Positive', 'accountStatusPositive': 'Positive',
'bsLoadingTheme': 'Loading Theme',
'bsCheckForUpdate': 'Checking For Updates',
'bsCheckForUpdateFailed': 'Unable to Check Updates',
'bsCheckForUpdateNew': 'Found New Version',
'bsCheckForUpdateDescApple': 'Please head to TestFlight and update your app to latest version to prevent error happens and get latest functions.',
'bsCheckForUpdateDescCommon': 'Please head to our website download and install latest version of application to prevent error happens and get latest functions.',
'bsCheckingServer': 'Checking Server Status',
'bsCheckingServerFail':
'Unable connect to server, check your network connection',
'bsCheckingServerDown': 'Server currently unavailable, please retry later',
'bsAuthorizing': 'Authorizing',
'bsEstablishingConn': 'Establishing Connection',
'bsPreparingData': 'Preparing User Data',
'bsRegisteringPushNotify': 'Enabling Push Notifications',
'postShareContent':
'@content\n\n@username on the Solar Network\nCheck it out: @link',
'postShareSubject': '@username posted a post on the Solar Network',
'themeColor': 'Global Theme Color',
'themeColorRed': 'Modern Red',
'themeColorBlue': 'Classic Blue',
'themeColorMiku': 'Miku Blue',
'themeColorKagamine': 'Kagamine Yellow',
'themeColorLuka': 'Luka Pink',
'themeColorApplied': 'Global theme color has been applied.',
'attachmentSaved': 'Attachment saved to your system album.',
'cropImage': 'Crop Image',
}; };

View File

@@ -1,4 +1,4 @@
const simplifiedChineseMessages = { const i18nSimplifiedChinese = {
'done': '完成', 'done': '完成',
'hide': '隐藏', 'hide': '隐藏',
'okay': '确认', 'okay': '确认',
@@ -13,14 +13,21 @@ const simplifiedChineseMessages = {
'about': '关于', 'about': '关于',
'edit': '编辑', 'edit': '编辑',
'delete': '删除', 'delete': '删除',
'settings': '设置',
'page': '页面', 'page': '页面',
'draft': '草稿', 'draft': '草稿',
'draftSave': '存为草稿', 'draftSave': '存为草稿',
'draftBox': '草稿箱', 'draftBox': '草稿箱',
'more': '更多',
'share': '分享',
'feed': '资讯', 'feed': '资讯',
'unlink': '移除链接',
'feedSearch': '搜索资讯', 'feedSearch': '搜索资讯',
'feedSearchWithTag': '检索带有 #@key 标签的资讯', 'feedSearchWithTag': '检索带有 #@key 标签的资讯',
'feedSearchWithCategory': '检索位于分类 @category 的资讯', 'feedSearchWithCategory': '检索位于分类 @category 的资讯',
'visitProfilePage': '造访个人主页',
'profilePosts': '帖子',
'profileAlbum': '相簿',
'chat': '聊天', 'chat': '聊天',
'apply': '应用', 'apply': '应用',
'search': '搜索', 'search': '搜索',
@@ -28,6 +35,7 @@ const simplifiedChineseMessages = {
'article': '文章', 'article': '文章',
'reply': '回复', 'reply': '回复',
'repost': '转帖', 'repost': '转帖',
'openInAlbum': '在相簿中打开',
'openInBrowser': '在浏览器中打开', 'openInBrowser': '在浏览器中打开',
'notification': '通知', 'notification': '通知',
'errorHappened': '发生错误了', 'errorHappened': '发生错误了',
@@ -36,6 +44,7 @@ const simplifiedChineseMessages = {
'username': '用户名', 'username': '用户名',
'nickname': '显示名', 'nickname': '显示名',
'password': '密码', 'password': '密码',
'title': '标题',
'description': '简介', 'description': '简介',
'birthday': '生日', 'birthday': '生日',
'firstName': '名称', 'firstName': '名称',
@@ -49,6 +58,7 @@ const simplifiedChineseMessages = {
'accountFriendPending': '好友请求', 'accountFriendPending': '好友请求',
'accountFriendBlocked': '好友黑名单', 'accountFriendBlocked': '好友黑名单',
'accountFriendListHint': '左滑来拒绝,右滑来接受', 'accountFriendListHint': '左滑来拒绝,右滑来接受',
'accountFriendRequestSent': '好友请求已发送,等待处理对方中……',
'accountSuspended': '帐号被停用', 'accountSuspended': '帐号被停用',
'accountSuspendedAt': '该帐号自 @date 起被停用', 'accountSuspendedAt': '该帐号自 @date 起被停用',
'aspectRatio': '纵横比', 'aspectRatio': '纵横比',
@@ -77,7 +87,34 @@ const simplifiedChineseMessages = {
'notifyAllRead': '已读所有通知', 'notifyAllRead': '已读所有通知',
'notifyEmpty': '通知箱为空', 'notifyEmpty': '通知箱为空',
'notifyEmptyCaption': '看起来最近没发生什么呢', 'notifyEmptyCaption': '看起来最近没发生什么呢',
'postEditor': '发个帖子', 'totalSocialCreditPoints': '社会信用点',
'totalPostCount': '总帖数',
'totalUpvote': '获顶数',
'totalDownvote': '获踩数',
'clear': '清除',
'pinPost': '置顶本帖',
'unpinPost': '取消置顶本帖',
'postRestoreFromLocal': '内容从本地暂存回复',
'postAutoSaveAt': '已自动保存于 @date',
'postCategoriesAndTags': '分类与标签',
'postPublishDate': '发布时间',
'postPublishAt': '发布帖子于',
'postPublishedUntil': '取消发布于',
'postPublishZone': '帖子发布区',
'postPublishZoneNone': '无所属领域',
'postVisibility': '帖子可见性',
'postVisibilityAll': '所有人可见',
'postVisibilityFriends': '仅好友可见',
'postVisibilitySelected': '选中者可见',
'postVisibilityFiltered': '选中者不可见',
'postVisibilityNone': '仅自己可见',
'postVisibleUsers': '可见帖子者',
'postInvisibleUsers': '隐藏帖子者',
'postOverview': '帖子概览',
'postPinned': '已置顶',
'postEditorModeStory': '发个帖子',
'postEditorModeArticle': '撰写文章',
'postEditor': '帖子编辑器',
'articleEditor': '撰写文章', 'articleEditor': '撰写文章',
'articleDetail': '文章详情', 'articleDetail': '文章详情',
'draftBoxOpen': '打开草稿箱', 'draftBoxOpen': '打开草稿箱',
@@ -89,7 +126,7 @@ const simplifiedChineseMessages = {
'postEdited': '编辑于 @date', 'postEdited': '编辑于 @date',
'postNewCreated': '创建于 @date', 'postNewCreated': '创建于 @date',
'postInRealm': '发表于 @realm', 'postInRealm': '发表于 @realm',
'postAttachmentTip': '@count 个附件', 'attachmentHint': '@count 个附件',
'postDetail': '帖子详情', 'postDetail': '帖子详情',
'postReplies': '帖子回复', 'postReplies': '帖子回复',
'postPublish': '编辑帖子', 'postPublish': '编辑帖子',
@@ -110,6 +147,13 @@ const simplifiedChineseMessages = {
'reactAdd': '作出反应', 'reactAdd': '作出反应',
'reactCompleted': '你的反应已被添加', 'reactCompleted': '你的反应已被添加',
'reactUncompleted': '你的反应已被移除', 'reactUncompleted': '你的反应已被移除',
'attachmentUploadBy': '由上传',
'attachmentAutoUpload': '自动上传',
'attachmentUploadQueue': '上传队列',
'attachmentUploadQueueStart': '整队上传',
'attachmentUploadInProgress': '有附件正在上传,请等待所有附件上传完毕后再进行操作……',
'attachmentAttached': '已附附件',
'attachmentUploadBlocked': '上传受阻,当前已有任务进行中……',
'attachmentAdd': '附加附件', 'attachmentAdd': '附加附件',
'attachmentAddGalleryPhoto': '相册照片', 'attachmentAddGalleryPhoto': '相册照片',
'attachmentAddGalleryVideo': '相册视频', 'attachmentAddGalleryVideo': '相册视频',
@@ -117,6 +161,9 @@ const simplifiedChineseMessages = {
'attachmentAddCameraVideo': '拍摄视频', 'attachmentAddCameraVideo': '拍摄视频',
'attachmentAddClipboard': '粘贴文件', 'attachmentAddClipboard': '粘贴文件',
'attachmentAddFile': '附加文件', 'attachmentAddFile': '附加文件',
'attachmentAddLink': '链接附件',
'attachmentAddLinkHint': '输入附件的神秘代号来链接对应附件',
'attachmentAddLinkInput': '神秘代号',
'attachmentSetting': '调整附件', 'attachmentSetting': '调整附件',
'attachmentAlt': '替代文字', 'attachmentAlt': '替代文字',
'attachmentLoadFailed': '加载失败', 'attachmentLoadFailed': '加载失败',
@@ -191,6 +238,8 @@ const simplifiedChineseMessages = {
'messageDeletionConfirmCaption': '你确定要删除消息 @id 吗?该操作不可撤销。', 'messageDeletionConfirmCaption': '你确定要删除消息 @id 吗?该操作不可撤销。',
'call': '通话', 'call': '通话',
'callOngoing': '一则通话正在进行中…', 'callOngoing': '一则通话正在进行中…',
'callOngoingEmpty': '一则通话待机中…',
'callOngoingParticipants': '@count 人正在进行通话…',
'callJoin': '加入', 'callJoin': '加入',
'callResume': '恢复', 'callResume': '恢复',
'callMicrophone': '麦克风', 'callMicrophone': '麦克风',
@@ -243,4 +292,28 @@ const simplifiedChineseMessages = {
'accountStatusNegative': '负面', 'accountStatusNegative': '负面',
'accountStatusNeutral': '中性', 'accountStatusNeutral': '中性',
'accountStatusPositive': '积极', 'accountStatusPositive': '积极',
'bsLoadingTheme': '正在装载主题',
'bsCheckForUpdate': '正在检查更新',
'bsCheckForUpdateFailed': '无法检查更新',
'bsCheckForUpdateNew': '发现新版本',
'bsCheckForUpdateDescApple': '请前往 TestFlight 并将您的应用程序更新到最新版本,以防止出现错误并获取最新功能。',
'bsCheckForUpdateDescCommon': '请前往我们的网站下载并安装最新版本的应用程序,以防止出现错误并获取最新功能。',
'bsCheckingServer': '检查服务器状态中',
'bsCheckingServerFail': '无法连接至服务器,请检查你的网络连接状态',
'bsCheckingServerDown': '当前服务器不可用,请稍后重试',
'bsAuthorizing': '正在授权中',
'bsEstablishingConn': '部署连接中',
'bsPreparingData': '正在准备用户资料',
'bsRegisteringPushNotify': '正在启用推送通知',
'postShareContent': '@content\n\n@username 在 Solar Network\n原帖地址:@link',
'postShareSubject': '@username 在 Solar Network 上发布了一篇帖子',
'themeColor': '全局主题色',
'themeColorRed': '现代红',
'themeColorBlue': '经典蓝',
'themeColorMiku': '未来蓝',
'themeColorKagamine': '镜音黄',
'themeColorLuka': '流音粉',
'themeColorApplied': '全局主题颜色已应用',
'attachmentSaved': '附件已保存到系统相册',
'cropImage': '裁剪图片',
}; };

View File

@@ -18,6 +18,7 @@ class AccountHeadingWidget extends StatelessWidget {
final String? desc; final String? desc;
final Account? detail; final Account? detail;
final List<AccountBadge>? badges; final List<AccountBadge>? badges;
final List<Widget>? extraWidgets;
final Future<Response>? status; final Future<Response>? status;
final Function? onEditStatus; final Function? onEditStatus;
@@ -32,6 +33,7 @@ class AccountHeadingWidget extends StatelessWidget {
required this.badges, required this.badges,
this.detail, this.detail,
this.status, this.status,
this.extraWidgets,
this.onEditStatus, this.onEditStatus,
}); });
@@ -149,19 +151,6 @@ class AccountHeadingWidget extends StatelessWidget {
], ],
).paddingOnly(left: 116, top: 6), ).paddingOnly(left: 116, top: 6),
const SizedBox(height: 4), const SizedBox(height: 4),
if (detail?.suspendedAt != null)
SizedBox(
width: double.infinity,
child: Card(
child: ListTile(
title: Text('accountSuspended'.tr),
subtitle: Text('accountSuspendedAt'.trParams({
'date': DateFormat('y/M/d').format(detail!.suspendedAt!),
})),
trailing: const Icon(Icons.block),
),
),
).paddingOnly(left: 16, right: 16),
if (badges?.isNotEmpty ?? false) if (badges?.isNotEmpty ?? false)
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
@@ -177,7 +166,21 @@ class AccountHeadingWidget extends StatelessWidget {
vertical: PlatformInfo.isMobile ? 0 : 6, vertical: PlatformInfo.isMobile ? 0 : 6,
), ),
), ),
).paddingOnly(left: 16, right: 16), ).paddingSymmetric(horizontal: 16),
...?extraWidgets?.map((x) => x.paddingSymmetric(horizontal: 16)),
if (detail?.suspendedAt != null)
SizedBox(
width: double.infinity,
child: Card(
child: ListTile(
title: Text('accountSuspended'.tr),
subtitle: Text('accountSuspendedAt'.trParams({
'date': DateFormat('y/M/d').format(detail!.suspendedAt!),
})),
trailing: const Icon(Icons.block),
),
),
).paddingSymmetric(horizontal: 16),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: Card( child: Card(
@@ -188,7 +191,7 @@ class AccountHeadingWidget extends StatelessWidget {
), ),
), ),
), ),
).paddingOnly(left: 16, right: 16), ).paddingSymmetric(horizontal: 16),
], ],
), ),
); );

View File

@@ -3,6 +3,7 @@ import 'package:get/get.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/account.dart'; import 'package:solian/models/account.dart';
import 'package:solian/providers/account_status.dart'; import 'package:solian/providers/account_status.dart';
import 'package:solian/router.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:solian/widgets/account/account_heading.dart'; import 'package:solian/widgets/account/account_heading.dart';
@@ -23,9 +24,7 @@ class _AccountProfilePopupState extends State<AccountProfilePopup> {
void getUserinfo() async { void getUserinfo() async {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = GetConnect(); final client = ServiceFinder.configureClient('auth');
client.httpClient.baseUrl = ServiceFinder.buildUrl('auth', null);
final resp = await client.get('/users/${widget.account.name}'); final resp = await client.get('/users/${widget.account.name}');
if (resp.statusCode == 200) { if (resp.statusCode == 200) {
_userinfo = Account.fromJson(resp.body); _userinfo = Account.fromJson(resp.body);
@@ -39,14 +38,16 @@ class _AccountProfilePopupState extends State<AccountProfilePopup> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
getUserinfo(); getUserinfo();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_isBusy || _userinfo == null) { if (_isBusy || _userinfo == null) {
return const Center(child: CircularProgressIndicator()); return SizedBox(
height: MediaQuery.of(context).size.height * 0.75,
child: const Center(child: CircularProgressIndicator()),
);
} }
return SizedBox( return SizedBox(
@@ -62,7 +63,28 @@ class _AccountProfilePopupState extends State<AccountProfilePopup> {
desc: _userinfo!.description, desc: _userinfo!.description,
detail: _userinfo!, detail: _userinfo!,
badges: _userinfo!.badges, badges: _userinfo!.badges,
status: Get.find<StatusProvider>().getSomeoneStatus(_userinfo!.name), status:
Get.find<StatusProvider>().getSomeoneStatus(_userinfo!.name),
extraWidgets: [
Card(
child: ListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
title: Text('visitProfilePage'.tr),
visualDensity:
const VisualDensity(horizontal: -4, vertical: -2),
trailing: const Icon(Icons.chevron_right),
onTap: () {
AppRouter.instance.goNamed(
'accountProfilePage',
pathParameters: {'name': _userinfo!.name},
);
Navigator.pop(context);
},
),
),
],
).paddingOnly(top: 16), ).paddingOnly(top: 16),
], ],
), ),

View File

@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/relations.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/relation.dart';
import 'package:solian/services.dart';
import 'package:solian/widgets/account/account_avatar.dart';
class AccountSelector extends StatefulWidget {
final String title;
final Widget? Function(Account item)? trailingBuilder;
final List<int>? initialSelection;
final Function(List<Account>)? onMultipleSelect;
const AccountSelector({
super.key,
required this.title,
this.trailingBuilder,
this.initialSelection,
this.onMultipleSelect,
});
@override
State<AccountSelector> createState() => _AccountSelectorState();
}
class _AccountSelectorState extends State<AccountSelector> {
final TextEditingController _probeController = TextEditingController();
final List<Account> _relativeUsers = List.empty(growable: true);
final List<Account> _pendingUsers = List.empty(growable: true);
final List<Account> _selectedUsers = List.empty(growable: true);
int _accountId = 0;
_revertSelectedUsers() async {
if (widget.initialSelection?.isEmpty ?? true) return;
final client = ServiceFinder.configureClient('auth');
final idQuery = widget.initialSelection!.join(',');
final resp = await client.get('/users?id=$idQuery');
setState(() {
_selectedUsers.addAll(
resp.body
.map((e) => Account.fromJson(e))
.toList()
.cast<Account>(),
);
});
}
_getFriends() async {
final AuthProvider auth = Get.find();
_accountId = auth.userProfile.value!['id'];
final RelationshipProvider provider = Get.find();
final resp = await provider.listRelationWithStatus(1);
setState(() {
_relativeUsers.addAll(
resp.body
.map((e) => Relationship.fromJson(e).related)
.toList()
.cast<Account>(),
);
});
}
_searchAccount() async {
final AuthProvider auth = Get.find();
_accountId = auth.userProfile.value!['id'];
if (_probeController.text.isEmpty) return;
final client = auth.configureClient('auth');
final resp = await client.get(
'/users/search?probe=${_probeController.text}',
);
setState(() {
_pendingUsers.clear();
_pendingUsers.addAll(
resp.body.map((e) => Account.fromJson(e)).toList().cast<Account>(),
);
});
}
bool _checkSelected(Account item) {
return _selectedUsers.any((x) => x.id == item.id);
}
@override
void initState() {
super.initState();
_getFriends();
_revertSelectedUsers();
}
@override
void dispose() {
super.dispose();
_probeController.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: MediaQuery.of(context).size.height * 0.85,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
style: Theme.of(context).textTheme.headlineSmall,
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
Container(
color: Theme.of(context).colorScheme.secondaryContainer,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: TextField(
controller: _probeController,
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
hintText: 'search'.tr,
),
onSubmitted: (_) {
_searchAccount();
},
),
),
Expanded(
child: ListView.builder(
itemCount: _pendingUsers.isEmpty
? _relativeUsers.length
: _pendingUsers.length,
itemBuilder: (context, index) {
var element = _pendingUsers.isEmpty
? _relativeUsers[index]
: _pendingUsers[index];
return ListTile(
title: Text(element.nick),
subtitle: Text(element.name),
leading: AccountAvatar(content: element.avatar),
trailing: widget.trailingBuilder != null
? widget.trailingBuilder!(element)
: _checkSelected(element)
? const Icon(Icons.check)
: null,
onTap: element.id == _accountId
? null
: () {
if (widget.onMultipleSelect == null) {
Navigator.pop(context, element);
return;
}
setState(() {
final idx = _selectedUsers.indexWhere((x) => x.id == element.id);
if (idx != -1) {
_selectedUsers.removeAt(idx);
} else {
_selectedUsers.add(element);
}
});
widget.onMultipleSelect!(_selectedUsers);
},
);
},
),
),
],
),
);
}
}

View File

@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/models/account.dart'; import 'package:solian/models/account.dart';
import 'package:solian/models/relations.dart'; import 'package:solian/models/relations.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/relation.dart'; import 'package:solian/providers/relation.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
@@ -10,21 +9,17 @@ class RelativeSelector extends StatefulWidget {
final String title; final String title;
final Widget? Function(Account item)? trailingBuilder; final Widget? Function(Account item)? trailingBuilder;
const RelativeSelector({super.key, required this.title, this.trailingBuilder}); const RelativeSelector(
{super.key, required this.title, this.trailingBuilder});
@override @override
State<RelativeSelector> createState() => _RelativeSelectorState(); State<RelativeSelector> createState() => _RelativeSelectorState();
} }
class _RelativeSelectorState extends State<RelativeSelector> { class _RelativeSelectorState extends State<RelativeSelector> {
int _accountId = 0;
final List<Relationship> _friends = List.empty(growable: true); final List<Relationship> _friends = List.empty(growable: true);
getFriends() async { _getFriends() async {
final AuthProvider auth = Get.find();
_accountId = auth.userProfile.value!['id'];
final RelationshipProvider provider = Get.find(); final RelationshipProvider provider = Get.find();
final resp = await provider.listRelationWithStatus(1); final resp = await provider.listRelationWithStatus(1);
@@ -39,8 +34,7 @@ class _RelativeSelectorState extends State<RelativeSelector> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_getFriends();
getFriends();
} }
@override @override
@@ -58,7 +52,7 @@ class _RelativeSelectorState extends State<RelativeSelector> {
child: ListView.builder( child: ListView.builder(
itemCount: _friends.length, itemCount: _friends.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
var element = _friends[index].getOtherside(_accountId); var element = _friends[index].related;
return ListTile( return ListTile(
title: Text(element.nick), title: Text(element.nick),
subtitle: Text(element.name), subtitle: Text(element.name),

View File

@@ -0,0 +1,132 @@
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,
widget.item.usage,
isMature: _isMature,
);
Get.find<AttachmentProvider>().clearCache(id: widget.item.id);
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);
}
});
},
),
],
),
],
);
}
}

View File

@@ -0,0 +1,749 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:desktop_drop/desktop_drop.dart';
import 'package:dismissible_page/dismissible_page.dart';
import 'package:file_picker/file_picker.dart';
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:pasteboard/pasteboard.dart';
import 'package:path/path.dart' show basename, extension;
import 'package:solian/exts.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/attachment_uploader.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/attachment.dart';
import 'package:solian/widgets/attachments/attachment_attr_editor.dart';
import 'package:solian/widgets/attachments/attachment_fullscreen.dart';
class AttachmentEditorPopup extends StatefulWidget {
final String usage;
final List<int> initialAttachments;
final void Function(int) onAdd;
final void Function(int) onRemove;
const AttachmentEditorPopup({
super.key,
required this.usage,
required this.initialAttachments,
required this.onAdd,
required this.onRemove,
});
@override
State<AttachmentEditorPopup> createState() => _AttachmentEditorPopupState();
}
class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
final _imagePicker = ImagePicker();
final AttachmentUploaderController _uploadController = Get.find();
bool _isAutoUpload = false;
bool _isBusy = false;
bool _isFirstTimeBusy = true;
List<Attachment?> _attachments = List.empty(growable: true);
Future<void> _pickPhotoToUpload() async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
final medias = await _imagePicker.pickMultiImage();
if (medias.isEmpty) return;
_enqueueTaskBatch(medias.map((x) {
final file = File(x.path);
return AttachmentUploadTask(file: file, usage: widget.usage);
}));
}
Future<void> _pickVideoToUpload() async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
final media = await _imagePicker.pickVideo(source: ImageSource.gallery);
if (media == null) return;
final file = File(media.path);
_enqueueTask(
AttachmentUploadTask(file: file, usage: widget.usage),
);
}
Future<void> _pickFileToUpload() async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
FilePickerResult? result = await FilePicker.platform.pickFiles(
allowMultiple: true,
);
if (result == null) return;
List<File> files = result.paths.map((path) => File(path!)).toList();
_enqueueTaskBatch(files.map((x) {
return AttachmentUploadTask(file: x, usage: widget.usage);
}));
}
Future<void> _takeMediaToUpload(bool isVideo) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
XFile? media;
if (isVideo) {
media = await _imagePicker.pickVideo(source: ImageSource.camera);
} else {
media = await _imagePicker.pickImage(source: ImageSource.camera);
}
if (media == null) return;
final file = File(media.path);
_enqueueTask(
AttachmentUploadTask(file: file, usage: widget.usage),
);
}
Future<void> _linkAttachments() async {
final controller = TextEditingController();
final input = await showDialog<String?>(
context: context,
builder: (context) {
return AlertDialog(
title: Text('attachmentAddLink'.tr),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('attachmentAddLinkHint'.tr, textAlign: TextAlign.left),
const SizedBox(height: 18),
TextField(
controller: controller,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'attachmentAddLinkInput'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
],
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(
foregroundColor:
Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
),
onPressed: () => Navigator.pop(context),
child: Text('cancel'.tr),
),
TextButton(
child: Text('next'.tr),
onPressed: () {
Navigator.pop(context, controller.text);
},
),
],
);
},
);
WidgetsBinding.instance.addPostFrameCallback((_) => controller.dispose());
if (input == null || input.isEmpty) return;
final value = int.tryParse(input);
if (value == null) return;
final AttachmentProvider attach = Get.find();
final result = await attach.getMetadata(value);
if (result != null) {
widget.onAdd(result.id);
setState(() => _attachments.add(result));
}
}
void _pasteFileToUpload() async {
final data = await Pasteboard.image;
if (data == null) return;
if (_uploadController.isUploading.value) return;
_uploadController.uploadAttachmentWithCallback(
data,
'Pasted Image',
widget.usage,
null,
(item) {
widget.onAdd(item.id);
if (mounted) {
setState(() => _attachments.add(item));
}
},
);
}
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]}';
}
void _revertMetadataList() {
final AttachmentProvider attach = Get.find();
if (widget.initialAttachments.isEmpty) {
_isFirstTimeBusy = false;
return;
} else {
_attachments = List.filled(
widget.initialAttachments.length,
null,
growable: true,
);
}
setState(() => _isBusy = true);
attach.listMetadata(widget.initialAttachments).then((result) {
setState(() {
_attachments = result;
_isBusy = false;
_isFirstTimeBusy = false;
});
});
}
void _showAttachmentPreview(Attachment element) {
context.pushTransparentRoute(
AttachmentFullScreen(
parentId: 'attachment-editor-preview',
item: element,
),
rootNavigator: true,
);
}
void _showEdit(Attachment element, int index) {
showDialog(
context: context,
builder: (context) {
return AttachmentAttrEditorDialog(
item: element,
onUpdate: (item) {
setState(() => _attachments[index] = item);
},
);
},
);
}
Future<void> _cropAttachment(int queueIndex) async {
final task = _uploadController.queueOfUpload[queueIndex];
CroppedFile? croppedFile = await ImageCropper().cropImage(
sourcePath: task.file.path,
uiSettings: [
AndroidUiSettings(
toolbarTitle: 'cropImage'.tr,
toolbarColor: Theme.of(context).colorScheme.primary,
toolbarWidgetColor: Theme.of(context).colorScheme.onPrimary,
aspectRatioPresets: CropAspectRatioPreset.values,
),
IOSUiSettings(
title: 'cropImage'.tr,
aspectRatioPresets: CropAspectRatioPreset.values,
),
WebUiSettings(
context: context,
),
],
);
if (croppedFile == null) return;
_uploadController.queueOfUpload[queueIndex].file = File(croppedFile.path);
_uploadController.queueOfUpload.refresh();
}
Future<void> _deleteAttachment(Attachment element) async {
setState(() => _isBusy = true);
try {
final AttachmentProvider provider = Get.find();
await provider.deleteAttachment(element.id);
} catch (e) {
context.showErrorDialog(e);
} finally {
setState(() => _isBusy = false);
}
}
Widget _buildQueueEntry(AttachmentUploadTask element, int index) {
final extName = extension(element.file.path).substring(1);
final canBeCrop = ['png', 'jpg', 'jpeg', 'gif'].contains(extName);
return Container(
padding: const EdgeInsets.only(left: 16, right: 8, bottom: 16),
child: Card(
color: Theme.of(context).colorScheme.surface,
child: Column(
children: [
SizedBox(
height: 54,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
basename(element.file.path),
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
),
Text(
'In queue #${index + 1}',
style: const TextStyle(fontSize: 12),
),
],
),
),
if (element.isUploading)
SizedBox(
width: 40,
height: 38,
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.5,
value: element.progress,
),
),
),
),
if (element.isCompleted)
const SizedBox(
width: 40,
height: 38,
child: Center(
child: Icon(Icons.check),
),
),
if (!element.isCompleted && canBeCrop)
Obx(
() => IconButton(
color: Colors.teal,
icon: const Icon(Icons.crop),
visualDensity: const VisualDensity(horizontal: -4),
onPressed: _uploadController.isUploading.value
? null
: () {
_cropAttachment(index);
},
),
),
if (!element.isCompleted && !element.isUploading)
Obx(
() => IconButton(
color: Colors.green,
icon: const Icon(Icons.play_arrow),
visualDensity: const VisualDensity(horizontal: -4),
onPressed: _uploadController.isUploading.value
? null
: () {
_uploadController
.performSingleTask(index)
.then((r) {
widget.onAdd(r.id);
if (mounted) {
setState(() => _attachments.add(r));
}
});
},
),
),
if (!element.isCompleted && !element.isUploading)
IconButton(
color: Colors.red,
icon: const Icon(Icons.remove_circle),
visualDensity: const VisualDensity(horizontal: -4),
onPressed: () {
_uploadController.dequeueTask(element);
},
),
],
).paddingSymmetric(vertical: 8, horizontal: 16),
),
],
),
),
);
}
Widget _buildListEntry(Attachment element, int index) {
var fileType = element.mimetype.split('/').firstOrNull;
fileType ??= 'unknown';
final canBePreview = fileType.toLowerCase() == 'image';
return Container(
padding: const EdgeInsets.only(left: 16, right: 8, bottom: 16),
child: Card(
color: Theme.of(context).colorScheme.surface,
child: Column(
children: [
SizedBox(
height: 54,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
element.alt,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
),
Text(
'${fileType[0].toUpperCase()}${fileType.substring(1)} · ${_formatBytes(element.size)}',
style: const TextStyle(fontSize: 12),
),
],
),
),
IconButton(
color: Colors.teal,
icon: const Icon(Icons.preview),
visualDensity: const VisualDensity(horizontal: -4),
onPressed: canBePreview
? () => _showAttachmentPreview(element)
: null,
),
PopupMenuButton(
icon: const Icon(Icons.more_horiz),
iconColor: Theme.of(context).colorScheme.primary,
style: const ButtonStyle(
visualDensity: VisualDensity(horizontal: -4),
),
itemBuilder: (BuildContext context) => [
PopupMenuItem(
child: ListTile(
title: Text('edit'.tr),
leading: const Icon(Icons.edit),
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
),
),
onTap: () => _showEdit(element, index),
),
PopupMenuItem(
child: ListTile(
title: Text('delete'.tr),
leading: const Icon(Icons.delete),
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
),
),
onTap: () {
_deleteAttachment(element).then((_) {
widget.onRemove(element.id);
setState(() => _attachments.removeAt(index));
});
},
),
PopupMenuItem(
child: ListTile(
title: Text('unlink'.tr),
leading: const Icon(Icons.link_off),
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
),
),
onTap: () {
widget.onRemove(element.id);
setState(() => _attachments.removeAt(index));
},
),
],
),
],
).paddingSymmetric(vertical: 8, horizontal: 16),
),
],
),
),
);
}
void _enqueueTask(AttachmentUploadTask task) {
_uploadController.enqueueTask(task);
if (_isAutoUpload) {
_startUploading();
}
}
void _enqueueTaskBatch(Iterable<AttachmentUploadTask> tasks) {
_uploadController.enqueueTaskBatch(tasks);
if (_isAutoUpload) {
_startUploading();
}
}
void _startUploading() {
_uploadController.performUploadQueue(onData: (r) {
widget.onAdd(r.id);
if (mounted) {
setState(() => _attachments.add(r));
}
});
}
@override
void initState() {
super.initState();
_revertMetadataList();
}
@override
Widget build(BuildContext context) {
const density = VisualDensity(horizontal: 0, vertical: 0);
return SafeArea(
child: DropTarget(
onDragDone: (detail) async {
if (_uploadController.isUploading.value) return;
_enqueueTaskBatch(detail.files.map((x) {
final file = File(x.path);
return AttachmentUploadTask(file: file, usage: widget.usage);
}));
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'attachmentAdd'.tr,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(width: 10),
Obx(() {
if (_uploadController.isUploading.value) {
return SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2.5,
value: _uploadController.progressOfUpload.value,
),
);
}
return const SizedBox();
}),
],
),
),
Text('attachmentAutoUpload'.tr),
const SizedBox(width: 8),
Switch(
value: _isAutoUpload,
onChanged: (bool? value) {
if (value != null) {
setState(() => _isAutoUpload = value);
}
},
),
],
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(),
Expanded(
child: CustomScrollView(
slivers: [
Obx(() {
if (_uploadController.queueOfUpload.isNotEmpty) {
return SliverPadding(
padding: const EdgeInsets.only(bottom: 8),
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'attachmentUploadQueue'.tr,
style: Theme.of(context).textTheme.bodyLarge,
),
Obx(() {
if (_uploadController.isUploading.value) {
return const SizedBox();
}
return TextButton(
child: Text('attachmentUploadQueueStart'.tr),
onPressed: () {
_startUploading();
},
);
}),
],
).paddingOnly(left: 24, right: 24),
),
);
}
return const SliverToBoxAdapter(child: SizedBox());
}),
Obx(() {
if (_uploadController.queueOfUpload.isNotEmpty) {
return SliverPadding(
padding: const EdgeInsets.only(bottom: 8),
sliver: SliverList.builder(
itemCount: _uploadController.queueOfUpload.length,
itemBuilder: (context, index) {
final element =
_uploadController.queueOfUpload[index];
return _buildQueueEntry(element, index);
},
),
);
}
return const SliverToBoxAdapter(child: SizedBox());
}),
if (_attachments.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.only(bottom: 8),
sliver: SliverToBoxAdapter(
child: Text(
'attachmentAttached'.tr,
style: Theme.of(context).textTheme.bodyLarge,
).paddingOnly(left: 24, right: 24),
),
),
if (_attachments.isNotEmpty)
Builder(builder: (context) {
if (_isFirstTimeBusy && _isBusy) {
return const SliverFillRemaining(
child: Center(
child: CircularProgressIndicator(),
),
);
}
return SliverList.builder(
itemCount: _attachments.length,
itemBuilder: (context, index) {
final element = _attachments[index];
return _buildListEntry(element!, index);
},
);
}),
],
),
),
Obx(() {
return IgnorePointer(
ignoring: _uploadController.isUploading.value,
child: Container(
height: 64,
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
width: 0.3,
),
),
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap(
spacing: 8,
runSpacing: 0,
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
children: [
if (PlatformInfo.isDesktop ||
PlatformInfo.isIOS ||
PlatformInfo.isWeb)
ElevatedButton.icon(
icon: const Icon(Icons.paste),
label: Text('attachmentAddClipboard'.tr),
style: const ButtonStyle(visualDensity: density),
onPressed: () => _pasteFileToUpload(),
),
ElevatedButton.icon(
icon: const Icon(Icons.add_photo_alternate),
label: Text('attachmentAddGalleryPhoto'.tr),
style: const ButtonStyle(visualDensity: density),
onPressed: () => _pickPhotoToUpload(),
),
ElevatedButton.icon(
icon: const Icon(Icons.add_road),
label: Text('attachmentAddGalleryVideo'.tr),
style: const ButtonStyle(visualDensity: density),
onPressed: () => _pickVideoToUpload(),
),
ElevatedButton.icon(
icon: const Icon(Icons.photo_camera_back),
label: Text('attachmentAddCameraPhoto'.tr),
style: const ButtonStyle(visualDensity: density),
onPressed: () => _takeMediaToUpload(false),
),
ElevatedButton.icon(
icon: const Icon(Icons.video_camera_back_outlined),
label: Text('attachmentAddCameraVideo'.tr),
style: const ButtonStyle(visualDensity: density),
onPressed: () => _takeMediaToUpload(true),
),
ElevatedButton.icon(
icon: const Icon(Icons.file_present_rounded),
label: Text('attachmentAddFile'.tr),
style: const ButtonStyle(visualDensity: density),
onPressed: () => _pickFileToUpload(),
),
ElevatedButton.icon(
icon: const Icon(Icons.link),
label: Text('attachmentAddFile'.tr),
style: const ButtonStyle(visualDensity: density),
onPressed: () => _linkAttachments(),
),
],
).paddingSymmetric(horizontal: 12),
),
)
.animate(
target: _uploadController.isUploading.value ? 0 : 1,
)
.fade(duration: 100.ms),
);
}),
],
),
),
);
}
}

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