Compare commits

...

39 Commits

Author SHA1 Message Date
48ca885a2c 🚀 Launch 1.2.1+22 2024-08-22 01:06:10 +08:00
09cb340a9d 🐛 Fix personalize page issue 2024-08-22 00:42:17 +08:00
b6ebd6bef6 🐛 Drawer will expand on mobile device 2024-08-21 20:55:22 +08:00
2ec25fd1a2 Drawer tooltip on collapse mode 2024-08-21 19:35:29 +08:00
bc99865ba8 💫 Animated collapsible sidebar 2024-08-21 19:11:27 +08:00
f834351ce2 Basis collapse sidebar 2024-08-21 17:00:59 +08:00
0f1a02f65b 🐛 Try to fix protocol handler issue on android 2024-08-21 16:02:00 +08:00
6ad0a34645 Call on large screen able to full screen 2024-08-21 15:57:45 +08:00
fdc71475fc 💄 Optimize message hint 2024-08-21 15:45:55 +08:00
047defebd1 🥅 Better request failed exceptions 2024-08-21 15:39:29 +08:00
6148e889aa 🥅 Better unauthorized exceptions 2024-08-21 15:25:50 +08:00
1d7affcd84 🐛 Bug fixes 2024-08-21 13:14:40 +08:00
cc1e0599aa 🐛 Fix link expand match markdown link 2024-08-21 10:06:05 +08:00
221b97901f 💄 Optimize uploader 2024-08-21 10:01:09 +08:00
498bb0e5fb Run upload chunks at the same time (max 3) 2024-08-21 09:33:34 +08:00
aa94dfcfe0 Multipart upload 2024-08-21 01:53:16 +08:00
65d9253876 🐛 Fix svg site icon cause invalid image data 2024-08-21 00:48:51 +08:00
3ac510c4b1 🐛 Bug fixes 2024-08-20 01:19:18 +08:00
253cd1ecbd Call in same screen on large screen 2024-08-20 01:10:15 +08:00
c82c48dfec 🐛 Fix attachments padding 2024-08-19 22:45:22 +08:00
433beec2dd 💄 Optimize large screen ux 2024-08-19 22:38:36 +08:00
3a1e7537dd 🐛 Fix alignment issue 2024-08-19 22:25:49 +08:00
9170ae6be7 💄 Line up attachments & expansion of link 2024-08-19 22:25:17 +08:00
a5ee5b7f09 💄 Better attachment layout 2024-08-19 22:13:25 +08:00
32e6658f3d Better link expand layout on large screen 2024-08-19 20:13:08 +08:00
e45d9b39d5 Post link expand
 Cache link expansion image
2024-08-19 19:56:44 +08:00
cf1cfecb08 Link expand 2024-08-19 19:36:01 +08:00
95ea3e558f 🚀 Launch 1.2.1+18 2024-08-19 09:43:25 +08:00
0006a94632 🐛 Fix local db old data cause crash 2024-08-19 09:19:29 +08:00
7ea18dbe12 💄 Update styles 2024-08-19 01:54:32 +08:00
6004b74724 🚀 Launch 1.2.1+17 2024-08-19 01:35:57 +08:00
4d82ae8058 🐛 Bug fixes
⬆️ Add firebase performance
2024-08-19 01:35:38 +08:00
7fe26d0df0 🚀 Launch 1.2.1+16 2024-08-19 00:33:20 +08:00
80bade0e03 View posts posted by friends 2024-08-19 00:33:03 +08:00
b63db7fe76 👽 Support use realm alias instead of id 2024-08-19 00:14:09 +08:00
49f73f5f04 ⬆️ Support new attachments system 2024-08-18 22:51:52 +08:00
98749f42c0 ⬆️ Upgrade deps 2024-08-17 19:18:51 +08:00
f0e6bd64f4 ♻️ Refactor video player 2024-08-17 19:02:57 +08:00
3bea3a114a Post alias 2024-08-17 18:44:20 +08:00
63 changed files with 1954 additions and 887 deletions

View File

@ -1,17 +1,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.camera"/> <uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus"/> <uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/> <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" /> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
@ -24,14 +24,14 @@
android:icon="@mipmap/ic_launcher" 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" />
<receiver android:exported="false" <receiver android:exported="false"
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver"> android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/> <action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/> <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON"/> <action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/> <action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
</intent-filter> </intent-filter>
</receiver> </receiver>
@ -58,6 +58,13 @@
<data android:host="sn.solsynth.dev" /> <data android:host="sn.solsynth.dev" />
<data android:scheme="https" /> <data android:scheme="https" />
<data android:scheme="https" /> <data android:scheme="https" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="solink" /> <data android:scheme="solink" />
</intent-filter> </intent-filter>
@ -66,21 +73,21 @@
android:resource="@style/NormalTheme" android:resource="@style/NormalTheme"
/> />
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity <activity
android:name="com.yalantis.ucrop.UCropActivity" android:name="com.yalantis.ucrop.UCropActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:theme="@style/Theme.AppCompat.Light.NoActionBar"/> 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
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2"/> android:value="2" />
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and
@ -89,8 +96,8 @@
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. --> In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain" />
</intent> </intent>
</queries> </queries>
</manifest> </manifest>

View File

@ -51,6 +51,9 @@ PODS:
- Firebase/Messaging (10.29.0): - Firebase/Messaging (10.29.0):
- Firebase/CoreOnly - Firebase/CoreOnly
- FirebaseMessaging (~> 10.29.0) - FirebaseMessaging (~> 10.29.0)
- Firebase/Performance (10.29.0):
- Firebase/CoreOnly
- FirebasePerformance (~> 10.29.0)
- firebase_analytics (11.2.1): - firebase_analytics (11.2.1):
- Firebase/Analytics (= 10.29.0) - Firebase/Analytics (= 10.29.0)
- firebase_core - firebase_core
@ -66,6 +69,12 @@ PODS:
- Firebase/Messaging (= 10.29.0) - Firebase/Messaging (= 10.29.0)
- firebase_core - firebase_core
- Flutter - Flutter
- firebase_performance (0.10.0-4):
- Firebase/Performance (= 10.29.0)
- firebase_core
- Flutter
- FirebaseABTesting (10.29.0):
- FirebaseCore (~> 10.0)
- FirebaseAnalytics (10.29.0): - FirebaseAnalytics (10.29.0):
- FirebaseAnalytics/AdIdSupport (= 10.29.0) - FirebaseAnalytics/AdIdSupport (= 10.29.0)
- FirebaseCore (~> 10.0) - FirebaseCore (~> 10.0)
@ -115,6 +124,25 @@ PODS:
- GoogleUtilities/Reachability (~> 7.8) - GoogleUtilities/Reachability (~> 7.8)
- GoogleUtilities/UserDefaults (~> 7.8) - GoogleUtilities/UserDefaults (~> 7.8)
- nanopb (< 2.30911.0, >= 2.30908.0) - nanopb (< 2.30911.0, >= 2.30908.0)
- FirebasePerformance (10.29.0):
- FirebaseCore (~> 10.5)
- FirebaseInstallations (~> 10.0)
- FirebaseRemoteConfig (~> 10.0)
- FirebaseSessions (~> 10.5)
- GoogleDataTransport (~> 9.2)
- GoogleUtilities/Environment (~> 7.13)
- GoogleUtilities/ISASwizzler (~> 7.13)
- GoogleUtilities/MethodSwizzler (~> 7.13)
- GoogleUtilities/UserDefaults (~> 7.13)
- nanopb (< 2.30911.0, >= 2.30908.0)
- FirebaseRemoteConfig (10.29.0):
- FirebaseABTesting (~> 10.0)
- FirebaseCore (~> 10.0)
- FirebaseInstallations (~> 10.0)
- FirebaseRemoteConfigInterop (~> 10.23)
- FirebaseSharedSwift (~> 10.0)
- GoogleUtilities/Environment (~> 7.8)
- "GoogleUtilities/NSData+zlib (~> 7.8)"
- FirebaseRemoteConfigInterop (10.29.0) - FirebaseRemoteConfigInterop (10.29.0)
- FirebaseSessions (10.29.0): - FirebaseSessions (10.29.0):
- FirebaseCore (~> 10.5) - FirebaseCore (~> 10.5)
@ -125,6 +153,7 @@ PODS:
- GoogleUtilities/UserDefaults (~> 7.13) - GoogleUtilities/UserDefaults (~> 7.13)
- nanopb (< 2.30911.0, >= 2.30908.0) - nanopb (< 2.30911.0, >= 2.30908.0)
- PromisesSwift (~> 2.1) - PromisesSwift (~> 2.1)
- FirebaseSharedSwift (10.29.0)
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_keyboard_visibility (0.0.1): - flutter_keyboard_visibility (0.0.1):
- Flutter - Flutter
@ -168,6 +197,8 @@ PODS:
- GoogleUtilities/Environment (7.13.3): - GoogleUtilities/Environment (7.13.3):
- GoogleUtilities/Privacy - GoogleUtilities/Privacy
- PromisesObjC (< 3.0, >= 1.2) - PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/ISASwizzler (7.13.3):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (7.13.3): - GoogleUtilities/Logger (7.13.3):
- GoogleUtilities/Environment - GoogleUtilities/Environment
- GoogleUtilities/Privacy - GoogleUtilities/Privacy
@ -254,6 +285,7 @@ DEPENDENCIES:
- firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`) - firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- firebase_performance (from `.symlinks/plugins/firebase_performance/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
@ -284,6 +316,7 @@ SPEC REPOS:
- DKImagePickerController - DKImagePickerController
- DKPhotoGallery - DKPhotoGallery
- Firebase - Firebase
- FirebaseABTesting
- FirebaseAnalytics - FirebaseAnalytics
- FirebaseCore - FirebaseCore
- FirebaseCoreExtension - FirebaseCoreExtension
@ -291,8 +324,11 @@ SPEC REPOS:
- FirebaseCrashlytics - FirebaseCrashlytics
- FirebaseInstallations - FirebaseInstallations
- FirebaseMessaging - FirebaseMessaging
- FirebasePerformance
- FirebaseRemoteConfig
- FirebaseRemoteConfigInterop - FirebaseRemoteConfigInterop
- FirebaseSessions - FirebaseSessions
- FirebaseSharedSwift
- GoogleAppMeasurement - GoogleAppMeasurement
- GoogleDataTransport - GoogleDataTransport
- GoogleUtilities - GoogleUtilities
@ -319,6 +355,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/firebase_crashlytics/ios" :path: ".symlinks/plugins/firebase_crashlytics/ios"
firebase_messaging: firebase_messaging:
:path: ".symlinks/plugins/firebase_messaging/ios" :path: ".symlinks/plugins/firebase_messaging/ios"
firebase_performance:
:path: ".symlinks/plugins/firebase_performance/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
flutter_keyboard_visibility: flutter_keyboard_visibility:
@ -379,6 +417,8 @@ SPEC CHECKSUMS:
firebase_core: 57aeb91680e5d5e6df6b888064be7c785f146efb firebase_core: 57aeb91680e5d5e6df6b888064be7c785f146efb
firebase_crashlytics: e3d3e0c99bad5aaab5908385133dea8ec344693f firebase_crashlytics: e3d3e0c99bad5aaab5908385133dea8ec344693f
firebase_messaging: c862b3d2b973ecc769194dc8de09bd22c77ae757 firebase_messaging: c862b3d2b973ecc769194dc8de09bd22c77ae757
firebase_performance: 8643e815a354ee94da1192cd69335a48a7b625a4
FirebaseABTesting: d87f56707159bae64e269757a6e963d490f2eebe
FirebaseAnalytics: 23717de130b779aa506e757edb9713d24b6ffeda FirebaseAnalytics: 23717de130b779aa506e757edb9713d24b6ffeda
FirebaseCore: 30e9c1cbe3d38f5f5e75f48bfcea87d7c358ec16 FirebaseCore: 30e9c1cbe3d38f5f5e75f48bfcea87d7c358ec16
FirebaseCoreExtension: 705ca5b14bf71d2564a0ddc677df1fc86ffa600f FirebaseCoreExtension: 705ca5b14bf71d2564a0ddc677df1fc86ffa600f
@ -386,8 +426,11 @@ SPEC CHECKSUMS:
FirebaseCrashlytics: 34647b41e18de773717fdd348a22206f2f9bc774 FirebaseCrashlytics: 34647b41e18de773717fdd348a22206f2f9bc774
FirebaseInstallations: 913cf60d0400ebd5d6b63a28b290372ab44590dd FirebaseInstallations: 913cf60d0400ebd5d6b63a28b290372ab44590dd
FirebaseMessaging: 7b5d8033e183ab59eb5b852a53201559e976d366 FirebaseMessaging: 7b5d8033e183ab59eb5b852a53201559e976d366
FirebasePerformance: d0ac4aa90f8c1aedeb8d0329a56e2d77d8d9e004
FirebaseRemoteConfig: 48ef3f243742a8d72422ccfc9f986e19d7de53fd
FirebaseRemoteConfigInterop: 6efda51fb5e2f15b16585197e26eaa09574e8a4d FirebaseRemoteConfigInterop: 6efda51fb5e2f15b16585197e26eaa09574e8a4d
FirebaseSessions: dbd14adac65ce996228652c1fc3a3f576bdf3ecc FirebaseSessions: dbd14adac65ce996228652c1fc3a3f576bdf3ecc
FirebaseSharedSwift: 20530f495084b8d840f78a100d8c5ee613375f6e
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12

View File

@ -254,6 +254,7 @@
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = ( buildPhases = (
B1CDA9DD5B638A2BB88053CB /* [CP] Check Pods Manifest.lock */, B1CDA9DD5B638A2BB88053CB /* [CP] Check Pods Manifest.lock */,
7356FAC42C72724B0051A465 /* [Crashlytics] Clear dSYM */,
9740EEB61CF901F6004384FC /* Run Script */, 9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */, 97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */, 97C146EB1CF9000F007C117D /* Frameworks */,
@ -263,7 +264,7 @@
3B06AD1E1E4923F5004D2608 /* Thin Binary */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
287A33C298CA352A7E7F32A4 /* [CP] Embed Pods Frameworks */, 287A33C298CA352A7E7F32A4 /* [CP] Embed Pods Frameworks */,
0818E8E4321C0D7433E07576 /* [CP] Copy Pods Resources */, 0818E8E4321C0D7433E07576 /* [CP] Copy Pods Resources */,
1A9FD6BE5DEE99CDA7399504 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */, 1A9FD6BE5DEE99CDA7399504 /* [Crashlytics] Upload dSYM */,
); );
buildRules = ( buildRules = (
); );
@ -366,7 +367,7 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0; showEnvVarsInLog = 0;
}; };
1A9FD6BE5DEE99CDA7399504 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */ = { 1A9FD6BE5DEE99CDA7399504 /* [Crashlytics] Upload dSYM */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@ -375,14 +376,14 @@
); );
inputPaths = ( inputPaths = (
); );
name = "FlutterFire: \"flutterfire upload-crashlytics-symbols\""; name = "[Crashlytics] Upload dSYM";
outputFileListPaths = ( outputFileListPaths = (
); );
outputPaths = ( outputPaths = (
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\n#!/bin/bash\nPATH=${PATH}:$FLUTTER_ROOT/bin:$HOME/.pub-cache/bin\nflutterfire upload-crashlytics-symbols --upload-symbols-script-path=$PODS_ROOT/FirebaseCrashlytics/upload-symbols --platform=ios --apple-project-path=${SRCROOT} --env-platform-name=${PLATFORM_NAME} --env-configuration=${CONFIGURATION} --env-project-dir=${PROJECT_DIR} --env-built-products-dir=${BUILT_PRODUCTS_DIR} --env-dwarf-dsym-folder-path=${DWARF_DSYM_FOLDER_PATH} --env-dwarf-dsym-file-name=${DWARF_DSYM_FILE_NAME} --env-infoplist-path=${INFOPLIST_PATH} --default-config=default\n"; shellScript = "\n#!/bin/bash\nsleep 1 # Without this, there seems a chance that the script runs before dSYM generation is finished \n$PODS_ROOT/FirebaseCrashlytics/upload-symbols -gsp $PROJECT_DIR/Runner/GoogleService-Info.plist -p ios $DWARF_DSYM_FOLDER_PATH/$DWARF_DSYM_FILE_NAME\n";
}; };
259653AE41D478F4C6BAE9B2 /* [CP] Check Pods Manifest.lock */ = { 259653AE41D478F4C6BAE9B2 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
@ -439,6 +440,24 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
}; };
7356FAC42C72724B0051A465 /* [Crashlytics] Clear dSYM */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "[Crashlytics] Clear dSYM";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\n#!/bin/bash\nrm -rf $DWARF_DSYM_FOLDER_PATH/$DWARF_DSYM_FILE_NAME\n";
};
9740EEB61CF901F6004384FC /* Run Script */ = { 9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;
@ -452,7 +471,7 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n";
}; };
B1CDA9DD5B638A2BB88053CB /* [CP] Check Pods Manifest.lock */ = { B1CDA9DD5B638A2BB88053CB /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;

View File

@ -112,6 +112,7 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
label: 'bsPreparingData', label: 'bsPreparingData',
action: () async { action: () async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
try {
await Future.wait([ await Future.wait([
Get.find<StickerProvider>().refreshAvailableStickers(), Get.find<StickerProvider>().refreshAvailableStickers(),
if (auth.isAuthorized.isTrue) if (auth.isAuthorized.isTrue)
@ -121,6 +122,9 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
if (auth.isAuthorized.isTrue) if (auth.isAuthorized.isTrue)
Get.find<RealmProvider>().refreshAvailableRealms(), Get.find<RealmProvider>().refreshAvailableRealms(),
]); ]);
} catch (e) {
context.showErrorDialog(e);
}
}, },
), ),
( (

View File

@ -17,6 +17,7 @@ import 'package:shared_preferences/shared_preferences.dart';
class PostEditorController extends GetxController { class PostEditorController extends GetxController {
late final SharedPreferences _prefs; late final SharedPreferences _prefs;
final aliasController = TextEditingController();
final titleController = TextEditingController(); final titleController = TextEditingController();
final descriptionController = TextEditingController(); final descriptionController = TextEditingController();
final contentController = TextEditingController(); final contentController = TextEditingController();
@ -30,9 +31,9 @@ class PostEditorController extends GetxController {
Rx<Realm?> realmZone = Rx(null); Rx<Realm?> realmZone = Rx(null);
Rx<DateTime?> publishedAt = Rx(null); Rx<DateTime?> publishedAt = Rx(null);
Rx<DateTime?> publishedUntil = Rx(null); Rx<DateTime?> publishedUntil = Rx(null);
RxList<int> attachments = RxList<int>.empty(growable: true); RxList<String> attachments = RxList<String>.empty(growable: true);
RxList<String> tags = RxList<String>.empty(growable: true); RxList<String> tags = RxList<String>.empty(growable: true);
Rx<int?> thumbnail = Rx(null); Rx<String?> thumbnail = Rx(null);
RxList<int> visibleUsers = RxList.empty(growable: true); RxList<int> visibleUsers = RxList.empty(growable: true);
RxList<int> invisibleUsers = RxList.empty(growable: true); RxList<int> invisibleUsers = RxList.empty(growable: true);
@ -115,12 +116,12 @@ class PostEditorController extends GetxController {
return showModalBottomSheet( return showModalBottomSheet(
context: context, context: context,
builder: (context) => AttachmentEditorPopup( builder: (context) => AttachmentEditorPopup(
usage: 'i.attachment', pool: 'interactive',
initialAttachments: attachments, initialAttachments: attachments,
onAdd: (int value) { onAdd: (String value) {
attachments.add(value); attachments.add(value);
}, },
onRemove: (int value) { onRemove: (String value) {
attachments.remove(value); attachments.remove(value);
}, },
), ),
@ -168,6 +169,7 @@ class PostEditorController extends GetxController {
} }
void currentClear() { void currentClear() {
aliasController.clear();
titleController.clear(); titleController.clear();
descriptionController.clear(); descriptionController.clear();
contentController.clear(); contentController.clear();
@ -197,16 +199,23 @@ class PostEditorController extends GetxController {
type = value.type; type = value.type;
editTo.value = value; editTo.value = value;
realmZone.value = value.realm;
isDraft.value = value.isDraft ?? false; isDraft.value = value.isDraft ?? false;
aliasController.text = value.alias ?? '';
titleController.text = value.body['title'] ?? ''; titleController.text = value.body['title'] ?? '';
descriptionController.text = value.body['description'] ?? ''; descriptionController.text = value.body['description'] ?? '';
contentController.text = value.body['content'] ?? ''; contentController.text = value.body['content'] ?? '';
publishedAt.value = value.publishedAt; publishedAt.value = value.publishedAt;
publishedUntil.value = value.publishedUntil; publishedUntil.value = value.publishedUntil;
tags.value = tags.value = List.from(
value.body['tags']?.map((x) => x['alias']).toList() ?? List.empty(); value.body['tags']?.map((x) => x['alias']).toList() ?? List.empty(),
growable: true,
);
tags.refresh(); tags.refresh();
attachments.value = value.body['attachments']?.cast<int>() ?? List.empty(); attachments.value = List.from(
value.body['attachments'] ?? List.empty(),
growable: true,
);
attachments.refresh(); attachments.refresh();
thumbnail.value = value.body['thumbnail']; thumbnail.value = value.body['thumbnail'];
@ -256,6 +265,7 @@ class PostEditorController extends GetxController {
Map<String, dynamic> get payload { Map<String, dynamic> get payload {
return { return {
'alias': aliasController.text,
'title': title, 'title': title,
'description': description, 'description': description,
'content': contentController.text, 'content': contentController.text,
@ -277,20 +287,33 @@ class PostEditorController extends GetxController {
set payload(Map<String, dynamic> value) { set payload(Map<String, dynamic> value) {
type = value['type']; type = value['type'];
tags.value = value['tags'].map((x) => x['alias']).toList().cast<String>(); tags.value = List.from(
value['tags'].map((x) => x['alias']).toList(),
growable: true,
);
aliasController.text = value['alias'] ?? '';
titleController.text = value['title'] ?? ''; titleController.text = value['title'] ?? '';
descriptionController.text = value['description'] ?? ''; descriptionController.text = value['description'] ?? '';
contentController.text = value['content'] ?? ''; contentController.text = value['content'] ?? '';
attachments.value = value['attachments'].cast<int>() ?? List.empty(); attachments.value = List.from(
value['attachments'] ?? List.empty(),
growable: true,
);
attachments.refresh(); attachments.refresh();
thumbnail.value = value['thumbnail']; thumbnail.value = value['thumbnail'];
visibility.value = value['visibility']; visibility.value = value['visibility'];
isDraft.value = value['is_draft']; isDraft.value = value['is_draft'];
if (value['visible_users'] != null) { if (value['visible_users'] != null) {
visibleUsers.value = value['visible_users'].cast<int>(); visibleUsers.value = List.from(
value['visible_users'],
growable: true,
);
} }
if (value['invisible_users'] != null) { if (value['invisible_users'] != null) {
invisibleUsers.value = value['invisible_users'].cast<int>(); invisibleUsers.value = List.from(
value['invisible_users'],
growable: true,
);
} }
if (value['published_at'] != null) { if (value['published_at'] != null) {
publishedAt.value = DateTime.parse(value['published_at']).toLocal(); publishedAt.value = DateTime.parse(value['published_at']).toLocal();
@ -319,6 +342,7 @@ class PostEditorController extends GetxController {
bool get isNotEmpty { bool get isNotEmpty {
return [ return [
aliasController.text.isNotEmpty,
titleController.text.isNotEmpty, titleController.text.isNotEmpty,
descriptionController.text.isNotEmpty, descriptionController.text.isNotEmpty,
contentController.text.isNotEmpty, contentController.text.isNotEmpty,

View File

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

View File

@ -0,0 +1,10 @@
import 'package:get/get.dart';
class RequestException implements Exception {
final Response data;
const RequestException(this.data);
@override
String toString() => 'Request failed ${data.statusCode}: ${data.bodyString}';
}

View File

@ -0,0 +1,6 @@
class UnauthorizedException implements Exception {
const UnauthorizedException();
@override
String toString() => 'Unauthorized';
}

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart';
extension SolianExtenions on BuildContext { extension SolianExtenions on BuildContext {
void showSnackbar(String content, {SnackBarAction? action}) { void showSnackbar(String content, {SnackBarAction? action}) {
@ -48,15 +50,48 @@ extension SolianExtenions on BuildContext {
} }
Future<void> showErrorDialog(dynamic exception) { Future<void> showErrorDialog(dynamic exception) {
var stack = StackTrace.current; Widget content = Text(exception.toString().capitalize!);
var stackTrace = '$stack'; if (exception is UnauthorizedException) {
content = Text('errorHappenedUnauthorized'.tr);
}
if (exception is RequestException) {
String overall;
switch (exception.data.statusCode) {
case 400:
overall = 'errorHappenedRequestBad'.tr;
break;
case 401:
overall = 'errorHappenedUnauthorized'.tr;
break;
case 403:
overall = 'errorHappenedRequestForbidden'.tr;
break;
case 404:
overall = 'errorHappenedRequestNotFound'.tr;
break;
case null:
overall = 'errorHappenedRequestConnection'.tr;
break;
default:
overall = 'errorHappenedRequestUnknown'.tr;
break;
}
if (exception.data.statusCode != null) {
content = Text(
'$overall\n\n(${exception.data.statusCode}) ${exception.data.bodyString}',
);
} else {
content = Text(overall);
}
}
return showDialog<void>( return showDialog<void>(
useRootNavigator: true, useRootNavigator: true,
context: this, context: this,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: Text('errorHappened'.tr), title: Text('errorHappened'.tr),
content: Text('${exception.toString().capitalize!}\n\nStack Trace: $stackTrace'), content: content,
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx), onPressed: () => Navigator.pop(ctx),

View File

@ -13,6 +13,7 @@ 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/attachment_uploader.dart';
import 'package:solian/providers/link_expander.dart';
import 'package:solian/providers/stickers.dart'; import 'package:solian/providers/stickers.dart';
import 'package:solian/providers/theme_switcher.dart'; import 'package:solian/providers/theme_switcher.dart';
import 'package:solian/providers/websocket.dart'; import 'package:solian/providers/websocket.dart';
@ -128,5 +129,6 @@ class SolianApp extends StatelessWidget {
Get.lazyPut(() => RealmProvider()); Get.lazyPut(() => RealmProvider());
Get.lazyPut(() => ChatCallProvider()); Get.lazyPut(() => ChatCallProvider());
Get.lazyPut(() => AttachmentUploaderController()); Get.lazyPut(() => AttachmentUploaderController());
Get.lazyPut(() => LinkExpandController());
} }
} }

View File

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

View File

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

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

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

View File

@ -8,6 +8,8 @@ class Post {
DateTime updatedAt; DateTime updatedAt;
DateTime? editedAt; DateTime? editedAt;
DateTime? deletedAt; DateTime? deletedAt;
String? alias;
String? areaAlias;
dynamic body; dynamic body;
List<Tag>? tags; List<Tag>? tags;
List<Category>? categories; List<Category>? categories;
@ -33,6 +35,8 @@ class Post {
required this.updatedAt, required this.updatedAt,
required this.editedAt, required this.editedAt,
required this.deletedAt, required this.deletedAt,
required this.alias,
required this.areaAlias,
required this.type, required this.type,
required this.body, required this.body,
required this.tags, required this.tags,
@ -60,6 +64,8 @@ class Post {
deletedAt: json['deleted_at'] != null deletedAt: json['deleted_at'] != null
? DateTime.parse(json['deleted_at']) ? DateTime.parse(json['deleted_at'])
: null, : null,
alias: json['alias'],
areaAlias: json['area_alias'],
type: json['type'], 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>(),
@ -101,6 +107,8 @@ class Post {
'updated_at': updatedAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(),
'edited_at': editedAt?.toIso8601String(), 'edited_at': editedAt?.toIso8601String(),
'deleted_at': deletedAt?.toIso8601String(), 'deleted_at': deletedAt?.toIso8601String(),
'alias': alias,
'area_alias': areaAlias,
'type': type, 'type': type,
'body': body, 'body': body,
'tags': tags, 'tags': tags,

View File

@ -36,7 +36,7 @@ class Sticker {
String get imageUrl => ServiceFinder.buildUrl( String get imageUrl => ServiceFinder.buildUrl(
'files', 'files',
'/attachments/$attachmentId', '/attachments/${attachment.rid}',
); );
factory Sticker.fromJson(Map<String, dynamic> json) => Sticker( factory Sticker.fromJson(Map<String, dynamic> json) => Sticker(

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart';
import 'package:solian/models/account_status.dart'; import 'package:solian/models/account_status.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
@ -33,15 +35,14 @@ class StatusProvider extends GetConnect {
Future<Response> getCurrentStatus() async { Future<Response> getCurrentStatus() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('auth'); final client = auth.configureClient('auth');
return await client.get('/users/me/status'); return await client.get('/users/me/status');
} }
Future<Response> getSomeoneStatus(String name) => Future<Response> getSomeoneStatus(String name) => get('/users/$name/status');
get('/users/$name/status');
Future<Response> setStatus( Future<Response> setStatus(
String type, String type,
@ -53,7 +54,7 @@ class StatusProvider extends GetConnect {
DateTime? clearAt, DateTime? clearAt,
}) async { }) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('auth'); final client = auth.configureClient('auth');
@ -74,7 +75,7 @@ class StatusProvider extends GetConnect {
} }
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -82,13 +83,13 @@ class StatusProvider extends GetConnect {
Future<Response> clearStatus() async { Future<Response> clearStatus() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('auth'); final client = auth.configureClient('auth');
final resp = await client.delete('/users/me/status'); final resp = await client.delete('/users/me/status');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;

View File

@ -1,24 +1,27 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:collection';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:cross_file/cross_file.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:path/path.dart' show basename;
import 'package:solian/models/attachment.dart'; import 'package:solian/models/attachment.dart';
import 'package:solian/providers/content/attachment.dart'; import 'package:solian/providers/content/attachment.dart';
class AttachmentUploadTask { class AttachmentUploadTask {
File file; XFile file;
String usage; String pool;
Map<String, dynamic>? metadata; Map<String, dynamic>? metadata;
Map<String, int>? chunkFiles;
double progress = 0; double? progress;
bool isUploading = false; bool isUploading = false;
bool isCompleted = false; bool isCompleted = false;
dynamic error; dynamic error;
AttachmentUploadTask({ AttachmentUploadTask({
required this.file, required this.file,
required this.usage, required this.pool,
this.metadata, this.metadata,
}); });
} }
@ -73,23 +76,26 @@ class AttachmentUploaderController extends GetxController {
_startProgressSyncTimer(); _startProgressSyncTimer();
queueOfUpload[queueIndex].isUploading = true; queueOfUpload[queueIndex].isUploading = true;
queueOfUpload[queueIndex].progress = 0;
final task = queueOfUpload[queueIndex]; final task = queueOfUpload[queueIndex];
final result = await _rawUploadAttachment( try {
await task.file.readAsBytes(), final result = await _chunkedUploadAttachment(
task.file.path, task.file,
task.usage, task.pool,
null, null,
onProgress: (value) { onData: (_) {},
queueOfUpload[queueIndex].progress = value; onProgress: (progress) {
_progressOfUpload = value; queueOfUpload[queueIndex].progress = progress;
}, _progressOfUpload = progress;
onError: (err) {
queueOfUpload[queueIndex].error = err;
queueOfUpload[queueIndex].isUploading = false;
}, },
); );
return result;
} catch (err) {
queueOfUpload[queueIndex].error = err;
queueOfUpload[queueIndex].isUploading = false;
} finally {
_progressOfUpload = 1;
if (queueOfUpload[queueIndex].error == null) { if (queueOfUpload[queueIndex].error == null) {
queueOfUpload.removeAt(queueIndex); queueOfUpload.removeAt(queueIndex);
} }
@ -97,8 +103,9 @@ class AttachmentUploaderController extends GetxController {
_syncProgress(); _syncProgress();
isUploading.value = false; isUploading.value = false;
}
return result; return null;
} }
Future<void> performUploadQueue({ Future<void> performUploadQueue({
@ -115,24 +122,26 @@ class AttachmentUploaderController extends GetxController {
} }
queueOfUpload[idx].isUploading = true; queueOfUpload[idx].isUploading = true;
queueOfUpload[idx].progress = 0;
final task = queueOfUpload[idx]; final task = queueOfUpload[idx];
final result = await _rawUploadAttachment( try {
await task.file.readAsBytes(), final result = await _chunkedUploadAttachment(
task.file.path, task.file,
task.usage, task.pool,
null, null,
onProgress: (value) { onData: (_) {},
queueOfUpload[idx].progress = value; onProgress: (progress) {
_progressOfUpload = (idx + value) / queueOfUpload.length; queueOfUpload[idx].progress = progress;
},
onError: (err) {
queueOfUpload[idx].error = err;
queueOfUpload[idx].isUploading = false;
}, },
); );
_progressOfUpload = (idx + 1) / queueOfUpload.length;
if (result != null) onData(result); if (result != null) onData(result);
} catch (err) {
queueOfUpload[idx].error = err;
queueOfUpload[idx].isUploading = false;
} finally {
_progressOfUpload = (idx + 1) / queueOfUpload.length;
}
queueOfUpload[idx].isUploading = false; queueOfUpload[idx].isUploading = false;
queueOfUpload[idx].isCompleted = true; queueOfUpload[idx].isCompleted = true;
@ -145,69 +154,94 @@ class AttachmentUploaderController extends GetxController {
isUploading.value = false; isUploading.value = false;
} }
Future<void> uploadAttachmentWithCallback( Future<Attachment?> uploadAttachmentFromData(
Uint8List data, Uint8List data,
String path, String path,
String usage, String pool,
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, Map<String, dynamic>? metadata,
) async { ) async {
if (isUploading.value) throw Exception('uploading blocked'); if (isUploading.value) throw Exception('uploading blocked');
isUploading.value = true; isUploading.value = true;
final result = await _rawUploadAttachment(
data,
path,
usage,
metadata,
onProgress: (progress) {
progressOfUpload.value = progress;
},
);
isUploading.value = false;
return result;
}
Future<Attachment?> _rawUploadAttachment( final AttachmentProvider attach = Get.find();
Uint8List data, String path, String usage, Map<String, dynamic>? metadata,
{Function(double)? onProgress, Function(dynamic err)? onError}) async {
final AttachmentProvider provider = Get.find();
try { try {
final result = await provider.createAttachment( final result = await attach.createAttachmentDirectly(
data, data,
path, path,
usage, pool,
metadata, metadata,
onProgress: onProgress,
); );
return result; return result;
} catch (err) { } catch (_) {
if (onError != null) {
onError(err);
}
return null; return null;
} finally {
isUploading.value = false;
} }
} }
Future<Attachment?> _chunkedUploadAttachment(
XFile file,
String pool,
Map<String, dynamic>? metadata, {
required Function(AttachmentPlaceholder) onData,
required Function(double) onProgress,
}) async {
final AttachmentProvider attach = Get.find();
final holder = await attach.createAttachmentMultipartPlaceholder(
await file.length(),
file.path,
pool,
metadata,
);
onData(holder);
onProgress(0);
final filename = basename(file.path);
final chunks = holder.meta.fileChunks ?? {};
var currentTask = 0;
final queue = Queue<Future<void>>();
final activeTasks = <Future<void>>[];
for (final entry in chunks.entries) {
queue.add(() async {
final beginCursor = entry.value * holder.chunkSize;
final endCursor = (entry.value + 1) * holder.chunkSize;
final data = Uint8List.fromList(await file
.openRead(beginCursor, endCursor)
.expand((chunk) => chunk)
.toList());
final out = await attach.uploadAttachmentMultipartChunk(
data,
filename,
holder.meta.rid,
entry.key,
);
holder.meta = out;
currentTask++;
onProgress(currentTask / chunks.length);
onData(holder);
}());
}
while (queue.isNotEmpty || activeTasks.isNotEmpty) {
while (activeTasks.length < 3 && queue.isNotEmpty) {
final task = queue.removeFirst();
activeTasks.add(task);
task.then((_) => activeTasks.remove(task));
}
if (activeTasks.isNotEmpty) {
await Future.any(activeTasks);
}
}
return holder.meta;
}
} }

View File

@ -7,6 +7,8 @@ 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:solian/controllers/chat_events_controller.dart'; import 'package:solian/controllers/chat_events_controller.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart';
import 'package:solian/providers/websocket.dart'; import 'package:solian/providers/websocket.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
@ -81,7 +83,7 @@ class AuthProvider extends GetConnect {
'grant_type': 'refresh_token', 'grant_type': 'refresh_token',
}); });
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
credentials = TokenSet( credentials = TokenSet(
accessToken: resp.body['access_token'], accessToken: resp.body['access_token'],
@ -128,7 +130,7 @@ class AuthProvider extends GetConnect {
} }
Future<void> ensureCredentials() async { Future<void> ensureCredentials() async {
if (isAuthorized.isFalse) throw Exception('unauthorized'); if (isAuthorized.isFalse) throw const UnauthorizedException();
if (credentials == null) await loadCredentials(); if (credentials == null) await loadCredentials();
if (credentials!.isExpired) { if (credentials!.isExpired) {
@ -158,7 +160,7 @@ class AuthProvider extends GetConnect {
'password': password, 'password': password,
}); });
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.body); throw RequestException(resp);
} else if (resp.body['is_finished'] == false) { } else if (resp.body['is_finished'] == false) {
throw RiskyAuthenticateException(resp.body['ticket']['id']); throw RiskyAuthenticateException(resp.body['ticket']['id']);
} }
@ -218,7 +220,7 @@ class AuthProvider extends GetConnect {
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 RequestException(resp);
} }
userProfile.value = resp.body; userProfile.value = resp.body;

View File

@ -2,6 +2,8 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart';
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:solian/models/call.dart'; import 'package:solian/models/call.dart';
@ -17,6 +19,10 @@ class ChatCallProvider extends GetxController {
RxBool isReady = false.obs; RxBool isReady = false.obs;
RxBool isMounted = false.obs; RxBool isMounted = false.obs;
RxBool isInitialized = false.obs; RxBool isInitialized = false.obs;
RxBool isBusy = false.obs;
RxString lastDuration = '00:00:00'.obs;
Timer? lastDurationUpdateTimer;
String? token; String? token;
String? endpoint; String? endpoint;
@ -38,6 +44,34 @@ class ChatCallProvider extends GetxController {
RxList<ParticipantTrack> participantTracks = RxList.empty(growable: true); RxList<ParticipantTrack> participantTracks = RxList.empty(growable: true);
Rx<ParticipantTrack?> focusTrack = Rx(null); Rx<ParticipantTrack?> focusTrack = Rx(null);
void _updateDuration() {
if (current.value == null) {
lastDuration.value = '00:00:00';
return;
}
Duration duration = DateTime.now().difference(current.value!.createdAt);
String twoDigits(int n) => n.toString().padLeft(2, '0');
String formattedTime = '${twoDigits(duration.inHours)}:'
'${twoDigits(duration.inMinutes.remainder(60))}:'
'${twoDigits(duration.inSeconds.remainder(60))}';
lastDuration.value = formattedTime;
}
void enableDurationUpdater() {
_updateDuration();
lastDurationUpdateTimer = Timer.periodic(
const Duration(seconds: 1),
(_) => _updateDuration(),
);
}
void disableDurationUpdater() {
lastDurationUpdateTimer?.cancel();
lastDurationUpdateTimer = null;
}
Future<void> checkPermissions() async { Future<void> checkPermissions() async {
if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) { if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) {
return; return;
@ -56,7 +90,7 @@ class ChatCallProvider extends GetxController {
Future<(String, String)> getRoomToken() async { Future<(String, String)> getRoomToken() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = auth.configureClient('messaging');
@ -69,7 +103,7 @@ class ChatCallProvider extends GetxController {
endpoint = 'wss://${resp.body['endpoint']}'; endpoint = 'wss://${resp.body['endpoint']}';
return (token!, endpoint!); return (token!, endpoint!);
} else { } else {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
} }
@ -88,22 +122,7 @@ class ChatCallProvider extends GetxController {
void initRoom() { void initRoom() {
initHardware(); initHardware();
room = Room(); room = Room(
listener = room.createListener();
WakelockPlus.enable();
}
void joinRoom(String url, String token) async {
if (isMounted.value) {
return;
} else {
isMounted.value = true;
}
try {
await room.connect(
url,
token,
roomOptions: const RoomOptions( roomOptions: const RoomOptions(
dynacast: true, dynacast: true,
adaptiveStream: true, adaptiveStream: true,
@ -126,6 +145,20 @@ class ChatCallProvider extends GetxController {
params: VideoParametersPresets.h1080_169, params: VideoParametersPresets.h1080_169,
), ),
), ),
);
listener = room.createListener();
WakelockPlus.enable();
}
void joinRoom(String url, String token) async {
if (isMounted.value) {
return;
}
try {
await room.connect(
url,
token,
fastConnectOptions: FastConnectOptions( fastConnectOptions: FastConnectOptions(
microphone: TrackOption(track: audioTrack.value), microphone: TrackOption(track: audioTrack.value),
camera: TrackOption(track: videoTrack.value), camera: TrackOption(track: videoTrack.value),
@ -133,6 +166,8 @@ class ChatCallProvider extends GetxController {
); );
} catch (e) { } catch (e) {
rethrow; rethrow;
} finally {
isMounted.value = true;
} }
} }
@ -152,7 +187,7 @@ class ChatCallProvider extends GetxController {
void onRoomDidUpdate() => sortParticipants(); void onRoomDidUpdate() => sortParticipants();
void setupRoom() { void setupRoom() {
if(isInitialized.value) return; if (isInitialized.value) return;
sortParticipants(); sortParticipants();
room.addListener(onRoomDidUpdate); room.addListener(onRoomDidUpdate);
@ -164,6 +199,7 @@ class ChatCallProvider extends GetxController {
Hardware.instance.setSpeakerphoneOn(true); Hardware.instance.setSpeakerphoneOn(true);
} }
isBusy.value = false;
isInitialized.value = true; isInitialized.value = true;
} }
@ -366,6 +402,7 @@ class ChatCallProvider extends GetxController {
} }
void disposeRoom() { void disposeRoom() {
isBusy.value = false;
isMounted.value = false; isMounted.value = false;
isInitialized.value = false; isInitialized.value = false;
current.value = null; current.value = null;

View File

@ -2,12 +2,13 @@ import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:solian/models/attachment.dart'; import 'package:solian/models/attachment.dart';
import 'package:solian/models/pagination.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:dio/dio.dart' as dio;
class AttachmentProvider extends GetConnect { class AttachmentProvider extends GetConnect {
static Map<String, String> mimetypeOverrides = { static Map<String, String> mimetypeOverrides = {
@ -20,22 +21,22 @@ class AttachmentProvider extends GetConnect {
httpClient.baseUrl = ServiceFinder.buildUrl('files', null); httpClient.baseUrl = ServiceFinder.buildUrl('files', null);
} }
final Map<int, Attachment> _cachedResponses = {}; final Map<String, Attachment> _cachedResponses = {};
Future<List<Attachment?>> listMetadata( Future<List<Attachment?>> listMetadata(
List<int> id, { List<String> rid, {
noCache = false, noCache = false,
}) async { }) async {
if (id.isEmpty) return List.empty(); if (rid.isEmpty) return List.empty();
List<Attachment?> result = List.filled(id.length, null); List<Attachment?> result = List.filled(rid.length, null);
List<int> pendingQuery = List.empty(growable: true); List<String> pendingQuery = List.empty(growable: true);
if (!noCache) { if (!noCache) {
for (var idx = 0; idx < id.length; idx++) { for (var idx = 0; idx < rid.length; idx++) {
if (_cachedResponses.containsKey(id[idx])) { if (_cachedResponses.containsKey(rid[idx])) {
result[idx] = _cachedResponses[id[idx]]; result[idx] = _cachedResponses[rid[idx]];
} else { } else {
pendingQuery.add(id[idx]); pendingQuery.add(rid[idx]);
} }
} }
} }
@ -52,12 +53,12 @@ class AttachmentProvider extends GetConnect {
rawOut.data!.map((x) => Attachment.fromJson(x)).toList(); rawOut.data!.map((x) => Attachment.fromJson(x)).toList();
for (final item in out) { for (final item in out) {
if (item.destination != 0 && item.isAnalyzed) { if (item.destination != 0 && item.isAnalyzed) {
_cachedResponses[item.id] = item; _cachedResponses[item.rid] = item;
} }
} }
for (var i = 0; i < out.length; i++) { for (var i = 0; i < out.length; i++) {
for (var j = 0; j < id.length; j++) { for (var j = 0; j < rid.length; j++) {
if (out[i].id == id[j]) { if (out[i].rid == rid[j]) {
result[j] = out[i]; result[j] = out[i];
} }
} }
@ -66,16 +67,16 @@ class AttachmentProvider extends GetConnect {
return result; return result;
} }
Future<Attachment?> getMetadata(int id, {noCache = false}) async { Future<Attachment?> getMetadata(String rid, {noCache = false}) async {
if (!noCache && _cachedResponses.containsKey(id)) { if (!noCache && _cachedResponses.containsKey(rid)) {
return _cachedResponses[id]!; return _cachedResponses[rid]!;
} }
final resp = await get('/attachments/$id/meta'); final resp = await get('/attachments/$rid/meta');
if (resp.statusCode == 200) { if (resp.statusCode == 200) {
final result = Attachment.fromJson(resp.body); final result = Attachment.fromJson(resp.body);
if (result.destination != 0 && result.isAnalyzed) { if (result.destination != 0 && result.isAnalyzed) {
_cachedResponses[id] = result; _cachedResponses[rid] = result;
} }
return result; return result;
} }
@ -83,16 +84,21 @@ class AttachmentProvider extends GetConnect {
return null; return null;
} }
Future<Attachment> createAttachment( Future<Attachment> createAttachmentDirectly(
Uint8List data, String path, String usage, Map<String, dynamic>? metadata, Uint8List data,
{Function(double)? onProgress}) async { String path,
String pool,
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 const UnauthorizedException();
await auth.ensureCredentials(); final client = auth.configureClient(
'uc',
timeout: const Duration(minutes: 3),
);
final filePayload = final filePayload = MultipartFile(data, filename: basename(path));
dio.MultipartFile.fromBytes(data, filename: basename(path));
final fileAlt = basename(path).contains('.') final fileAlt = basename(path).contains('.')
? basename(path).substring(0, basename(path).lastIndexOf('.')) ? basename(path).substring(0, basename(path).lastIndexOf('.'))
: basename(path); : basename(path);
@ -105,51 +111,101 @@ class AttachmentProvider extends GetConnect {
if (mimetypeOverrides.keys.contains(fileExt)) { if (mimetypeOverrides.keys.contains(fileExt)) {
mimetypeOverride = mimetypeOverrides[fileExt]; mimetypeOverride = mimetypeOverrides[fileExt];
} }
final payload = dio.FormData.fromMap({ final payload = FormData({
'alt': fileAlt, 'alt': fileAlt,
'file': filePayload, 'file': filePayload,
'usage': usage, 'pool': pool,
if (mimetypeOverride != null) 'mimetype': mimetypeOverride, if (mimetypeOverride != null) 'mimetype': mimetypeOverride,
'metadata': jsonEncode(metadata), 'metadata': jsonEncode(metadata),
}); });
final resp = await dio.Dio( final resp = await client.post('/attachments', payload);
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.data); throw RequestException(resp);
} }
return Attachment.fromJson(resp.data); return Attachment.fromJson(resp.body);
}
Future<AttachmentPlaceholder> createAttachmentMultipartPlaceholder(
int size,
String path,
String pool,
Map<String, dynamic>? metadata,
) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('uc');
final fileAlt = basename(path).contains('.')
? basename(path).substring(0, basename(path).lastIndexOf('.'))
: basename(path);
final fileExt = basename(path)
.substring(basename(path).lastIndexOf('.') + 1)
.toLowerCase();
// Override for some files cannot be detected mimetype by server-side
String? mimetypeOverride;
if (mimetypeOverrides.keys.contains(fileExt)) {
mimetypeOverride = mimetypeOverrides[fileExt];
}
final resp = await client.post('/attachments/multipart', {
'alt': fileAlt,
'name': basename(path),
'size': size,
'pool': pool,
if (mimetypeOverride != null) 'mimetype': mimetypeOverride,
'metadata': metadata,
});
if (resp.statusCode != 200) {
throw RequestException(resp);
}
return AttachmentPlaceholder.fromJson(resp.body);
}
Future<Attachment> uploadAttachmentMultipartChunk(
Uint8List data,
String name,
String rid,
String cid,
) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient(
'uc',
timeout: const Duration(minutes: 3),
);
final payload = FormData({
'file': MultipartFile(data, filename: name),
});
final resp = await client.post('/attachments/multipart/$rid/$cid', payload);
if (resp.statusCode != 200) {
throw RequestException(resp);
}
return Attachment.fromJson(resp.body);
} }
Future<Response> updateAttachment( Future<Response> updateAttachment(
int id, int id,
String alt, String alt, {
String usage, {
bool isMature = false, bool isMature = false,
}) async { }) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('files'); final client = auth.configureClient('files');
var resp = await client.put('/attachments/$id', { var resp = await client.put('/attachments/$id', {
'alt': alt, 'alt': alt,
'usage': usage,
'is_mature': isMature, 'is_mature': isMature,
}); });
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -157,19 +213,19 @@ class AttachmentProvider extends GetConnect {
Future<Response> deleteAttachment(int id) async { Future<Response> deleteAttachment(int id) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('files'); final client = auth.configureClient('files');
var resp = await client.delete('/attachments/$id'); var resp = await client.delete('/attachments/$id');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
} }
void clearCache({int? id}) { void clearCache({String? id}) {
if (id != null) { if (id != null) {
_cachedResponses.remove(id); _cachedResponses.remove(id);
} else { } else {

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/widgets/account/relative_select.dart'; import 'package:solian/widgets/account/relative_select.dart';
@ -16,7 +18,7 @@ class ChannelProvider extends GetxController {
Future<void> refreshAvailableChannel() async { Future<void> refreshAvailableChannel() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
isLoading.value = true; isLoading.value = true;
final resp = await listAvailableChannel(); final resp = await listAvailableChannel();
@ -29,13 +31,13 @@ class ChannelProvider extends GetxController {
Future<Response> getChannel(String alias, {String realm = 'global'}) async { Future<Response> getChannel(String alias, {String realm = 'global'}) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = auth.configureClient('messaging');
final resp = await client.get('/channels/$realm/$alias'); final resp = await client.get('/channels/$realm/$alias');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -44,13 +46,13 @@ class ChannelProvider extends GetxController {
Future<Response> getMyChannelProfile(String alias, Future<Response> getMyChannelProfile(String alias,
{String realm = 'global'}) async { {String realm = 'global'}) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = auth.configureClient('messaging');
final resp = await client.get('/channels/$realm/$alias/me'); final resp = await client.get('/channels/$realm/$alias/me');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -59,7 +61,7 @@ class ChannelProvider extends GetxController {
Future<Response?> getChannelOngoingCall(String alias, Future<Response?> getChannelOngoingCall(String alias,
{String realm = 'global'}) async { {String realm = 'global'}) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = auth.configureClient('messaging');
@ -67,7 +69,7 @@ class ChannelProvider extends GetxController {
if (resp.statusCode == 404) { if (resp.statusCode == 404) {
return null; return null;
} else if (resp.statusCode != 200) { } else if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -75,13 +77,13 @@ class ChannelProvider extends GetxController {
Future<Response> listChannel({String scope = 'global'}) async { Future<Response> listChannel({String scope = 'global'}) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = auth.configureClient('messaging');
final resp = await client.get('/channels/$scope'); final resp = await client.get('/channels/$scope');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -89,13 +91,13 @@ class ChannelProvider extends GetxController {
Future<Response> listAvailableChannel({String realm = 'global'}) async { Future<Response> listAvailableChannel({String realm = 'global'}) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = auth.configureClient('messaging');
final resp = await client.get('/channels/$realm/me/available'); final resp = await client.get('/channels/$realm/me/available');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -103,13 +105,13 @@ class ChannelProvider extends GetxController {
Future<Response> createChannel(String scope, dynamic payload) async { Future<Response> createChannel(String scope, dynamic payload) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = auth.configureClient('messaging');
final resp = await client.post('/channels/$scope', payload); final resp = await client.post('/channels/$scope', payload);
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -118,7 +120,7 @@ class ChannelProvider extends GetxController {
Future<Response?> createDirectChannel( Future<Response?> createDirectChannel(
BuildContext context, String scope) async { BuildContext context, String scope) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final related = await showModalBottomSheet( final related = await showModalBottomSheet(
useRootNavigator: true, useRootNavigator: true,
@ -141,7 +143,7 @@ class ChannelProvider extends GetxController {
'is_encrypted': false, 'is_encrypted': false,
}); });
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -149,13 +151,13 @@ class ChannelProvider extends GetxController {
Future<Response> updateChannel(String scope, int id, dynamic payload) async { Future<Response> updateChannel(String scope, int id, dynamic payload) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = auth.configureClient('messaging');
final resp = await client.put('/channels/$scope/$id', payload); final resp = await client.put('/channels/$scope/$id', payload);
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;

View File

@ -1,4 +1,6 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
@ -9,19 +11,26 @@ class PostProvider extends GetConnect {
} }
Future<Response> listRecommendations(int page, Future<Response> listRecommendations(int page,
{int? realm, String? channel}) async { {String? realm, String? channel}) async {
GetConnect client;
final AuthProvider auth = Get.find();
final queries = [ final queries = [
'take=${10}', 'take=${10}',
'offset=$page', 'offset=$page',
if (realm != null) 'realmId=$realm', if (realm != null) 'realm=$realm',
]; ];
final resp = await get( if (auth.isAuthorized.value) {
client = auth.configureClient('co');
} else {
client = ServiceFinder.configureClient('co');
}
final resp = await client.get(
channel == null channel == null
? '/recommendations?${queries.join('&')}' ? '/recommendations?${queries.join('&')}'
: '/recommendations/$channel?${queries.join('&')}', : '/recommendations/$channel?${queries.join('&')}',
); );
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.body); throw RequestException(resp);
} }
return resp; return resp;
@ -29,7 +38,7 @@ class PostProvider extends GetConnect {
Future<Response> listDraft(int page) async { Future<Response> listDraft(int page) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final queries = [ final queries = [
'take=${10}', 'take=${10}',
@ -38,25 +47,25 @@ class PostProvider extends GetConnect {
final client = auth.configureClient('interactive'); final client = auth.configureClient('interactive');
final resp = await client.get('/posts/drafts?${queries.join('&')}'); final resp = await client.get('/posts/drafts?${queries.join('&')}');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.body); throw RequestException(resp);
} }
return resp; return resp;
} }
Future<Response> listPost(int page, Future<Response> listPost(int page,
{int? realm, String? author, tag, category}) async { {String? realm, String? author, tag, category}) async {
final queries = [ final queries = [
'take=${10}', 'take=${10}',
'offset=$page', 'offset=$page',
if (tag != null) 'tag=$tag', if (tag != null) 'tag=$tag',
if (category != null) 'category=$category', if (category != null) 'category=$category',
if (author != null) 'author=$author', if (author != null) 'author=$author',
if (realm != null) 'realmId=$realm', if (realm != null) 'realm=$realm',
]; ];
final resp = await get('/posts?${queries.join('&')}'); final resp = await get('/posts?${queries.join('&')}');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.body); throw RequestException(resp);
} }
return resp; return resp;
@ -65,7 +74,7 @@ class PostProvider extends GetConnect {
Future<Response> listPostReplies(String alias, int page) async { Future<Response> listPostReplies(String alias, int page) async {
final resp = await get('/posts/$alias/replies?take=${10}&offset=$page'); final resp = await get('/posts/$alias/replies?take=${10}&offset=$page');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.body); throw RequestException(resp);
} }
return resp; return resp;
@ -74,7 +83,7 @@ class PostProvider extends GetConnect {
Future<Response> getPost(String alias) async { Future<Response> getPost(String alias) async {
final resp = await get('/posts/$alias'); final resp = await get('/posts/$alias');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.body); throw RequestException(resp);
} }
return resp; return resp;
@ -83,7 +92,7 @@ class PostProvider extends GetConnect {
Future<Response> getArticle(String alias) async { Future<Response> getArticle(String alias) async {
final resp = await get('/articles/$alias'); final resp = await get('/articles/$alias');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.body); throw RequestException(resp);
} }
return resp; return resp;

View File

@ -1,4 +1,6 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
@ -8,7 +10,7 @@ class RealmProvider extends GetxController {
Future<void> refreshAvailableRealms() async { Future<void> refreshAvailableRealms() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
isLoading.value = true; isLoading.value = true;
final resp = await listAvailableRealm(); final resp = await listAvailableRealm();
@ -21,13 +23,13 @@ class RealmProvider extends GetxController {
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 const UnauthorizedException();
final client = auth.configureClient('auth'); final client = auth.configureClient('auth');
final resp = await client.get('/realms/$alias'); final resp = await client.get('/realms/$alias');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -35,13 +37,13 @@ class RealmProvider extends GetxController {
Future<Response> listAvailableRealm() async { Future<Response> listAvailableRealm() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('auth'); final client = auth.configureClient('auth');
final resp = await client.get('/realms/me/available'); final resp = await client.get('/realms/me/available');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;

View File

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

View File

@ -1,5 +1,6 @@
import 'package:floor/floor.dart'; import 'package:floor/floor.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/models/event.dart'; import 'package:solian/models/event.dart';
import 'package:solian/models/pagination.dart'; import 'package:solian/models/pagination.dart';
@ -29,7 +30,7 @@ Future<Event?> getRemoteEvent(int id, Channel channel, String scope) async {
if (resp.statusCode == 404) { if (resp.statusCode == 404) {
return null; return null;
} else if (resp.statusCode != 200) { } else if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return Event.fromJson(resp.body); return Event.fromJson(resp.body);
@ -42,7 +43,7 @@ Future<(List<Event>, int)?> getRemoteEvents(
bool Function(List<Event> items)? onBrake, bool Function(List<Event> items)? onBrake,
take = 10, take = 10,
offset = 0, offset = 0,
}) async { }) async {
if (remainDepth <= 0) { if (remainDepth <= 0) {
return null; return null;
} }
@ -57,7 +58,7 @@ Future<(List<Event>, int)?> getRemoteEvents(
); );
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
final PaginationResult response = PaginationResult.fromJson(resp.body); final PaginationResult response = PaginationResult.fromJson(resp.body);

View File

@ -1,4 +1,5 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.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/auth.dart';
@ -42,7 +43,7 @@ class RelationshipProvider extends GetxController {
final client = auth.configureClient('auth'); final client = auth.configureClient('auth');
final resp = await client.post('/users/me/relations?related=$username', {}); final resp = await client.post('/users/me/relations?related=$username', {});
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -57,7 +58,7 @@ class RelationshipProvider extends GetxController {
{}, {},
); );
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -71,7 +72,7 @@ class RelationshipProvider extends GetxController {
{'status': status}, {'status': status},
); );
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;

View File

@ -6,6 +6,7 @@ import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/models/notification.dart'; import 'package:solian/models/notification.dart';
import 'package:solian/models/packet.dart'; import 'package:solian/models/packet.dart';
import 'package:solian/models/pagination.dart'; import 'package:solian/models/pagination.dart';
@ -50,9 +51,9 @@ class WebSocketProvider extends GetxController {
} }
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
await auth.ensureCredentials();
if (auth.credentials == null) await auth.loadCredentials(); try {
await auth.ensureCredentials();
final uri = Uri.parse(ServiceFinder.buildUrl( final uri = Uri.parse(ServiceFinder.buildUrl(
'dealer', 'dealer',
@ -61,21 +62,21 @@ class WebSocketProvider extends GetxController {
isConnecting.value = true; isConnecting.value = true;
try {
websocket = WebSocketChannel.connect(uri); websocket = WebSocketChannel.connect(uri);
await websocket?.ready; await websocket?.ready;
} catch (e) { listen();
isConnected.value = true;
} catch (err) {
log('Unable connect dealer via websocket... $err');
if (!noRetry) { if (!noRetry) {
await auth.refreshCredentials(); await auth.refreshCredentials();
return connect(noRetry: true); return connect(noRetry: true);
} }
} } finally {
listen();
isConnected.value = true;
isConnecting.value = false; isConnecting.value = false;
} }
}
void disconnect() { void disconnect() {
websocket?.sink.close(WebSocketStatus.normalClosure); websocket?.sink.close(WebSocketStatus.normalClosure);
@ -148,7 +149,7 @@ class WebSocketProvider extends GetxController {
'device_id': deviceUuid, 'device_id': deviceUuid,
}); });
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
} }

View File

@ -30,8 +30,8 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
final _descriptionController = TextEditingController(); final _descriptionController = TextEditingController();
final _birthdayController = TextEditingController(); final _birthdayController = TextEditingController();
int? _avatar; String? _avatar;
int? _banner; String? _banner;
DateTime? _birthday; DateTime? _birthday;
bool _isBusy = false; bool _isBusy = false;
@ -109,14 +109,14 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final AttachmentProvider provider = Get.find(); final AttachmentProvider attach = Get.find();
Attachment? attachResult; Attachment? attachResult;
try { try {
attachResult = await provider.createAttachment( attachResult = await attach.createAttachmentDirectly(
await file.readAsBytes(), await file.readAsBytes(),
file.path, file.path,
'p.$position', 'avatar',
null, null,
); );
} catch (e) { } catch (e) {
@ -129,7 +129,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
final resp = await client.put( final resp = await client.put(
'/users/me/$position', '/users/me/$position',
{'attachment': attachResult.id}, {'attachment': attachResult.rid},
); );
if (resp.statusCode == 200) { if (resp.statusCode == 200) {
_syncWidget(); _syncWidget();

View File

@ -66,7 +66,7 @@ class _StickerScreenState extends State<StickerScreen> {
Widget _buildEmoteEntry(Sticker item, String prefix) { Widget _buildEmoteEntry(Sticker item, String prefix) {
final imageUrl = ServiceFinder.buildUrl( final imageUrl = ServiceFinder.buildUrl(
'files', 'files',
'/attachments/${item.attachmentId}', '/attachments/${item.attachment.rid}',
); );
return ListTile( return ListTile(
title: Text(item.name), title: Text(item.name),

View File

@ -12,16 +12,20 @@ import 'package:solian/widgets/chat/call/call_participant.dart';
import 'package:livekit_client/livekit_client.dart' as livekit; import 'package:livekit_client/livekit_client.dart' as livekit;
class CallScreen extends StatefulWidget { class CallScreen extends StatefulWidget {
const CallScreen({super.key}); final bool hideAppBar;
final bool isExpandable;
const CallScreen({
super.key,
this.hideAppBar = false,
this.isExpandable = false,
});
@override @override
State<CallScreen> createState() => _CallScreenState(); State<CallScreen> createState() => _CallScreenState();
} }
class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin { class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
Timer? _timer;
String _currentDuration = '00:00:00';
int _layoutMode = 0; int _layoutMode = 0;
bool _showControls = true; bool _showControls = true;
@ -37,26 +41,6 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
curve: Curves.fastOutSlowIn, curve: Curves.fastOutSlowIn,
); );
String _parseDuration() {
final ChatCallProvider provider = Get.find();
if (provider.current.value == null) return '00:00:00';
Duration duration =
DateTime.now().difference(provider.current.value!.createdAt);
String twoDigits(int n) => n.toString().padLeft(2, '0');
String formattedTime = '${twoDigits(duration.inHours)}:'
'${twoDigits(duration.inMinutes.remainder(60))}:'
'${twoDigits(duration.inSeconds.remainder(60))}';
return formattedTime;
}
void _updateDuration() {
setState(() {
_currentDuration = _parseDuration();
});
}
void _switchLayout() { void _switchLayout() {
if (_layoutMode < 1) { if (_layoutMode < 1) {
setState(() => _layoutMode++); setState(() => _layoutMode++);
@ -191,15 +175,15 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
@override @override
void initState() { void initState() {
Get.find<ChatCallProvider>().setupRoom();
super.initState(); super.initState();
_updateDuration(); Future.delayed(Duration.zero, () {
Get.find<ChatCallProvider>()
..setupRoom()
..enableDurationUpdater();
_planAutoHideControls(); _planAutoHideControls();
_timer = Timer.periodic( });
const Duration(seconds: 1),
(_) => _updateDuration(),
);
} }
@override @override
@ -210,16 +194,19 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ChatCallProvider provider = Get.find(); final ChatCallProvider ctrl = Get.find();
return Material( return Material(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: widget.hideAppBar
? null
: AppBar(
leading: AppBarLeadingButton.adaptive(context), leading: AppBarLeadingButton.adaptive(context),
centerTitle: true, centerTitle: true,
toolbarHeight: SolianTheme.toolbarHeight(context), toolbarHeight: SolianTheme.toolbarHeight(context),
title: RichText( title: Obx(
() => RichText(
textAlign: TextAlign.center, textAlign: TextAlign.center,
text: TextSpan(children: [ text: TextSpan(children: [
TextSpan( TextSpan(
@ -228,12 +215,13 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
), ),
const TextSpan(text: '\n'), const TextSpan(text: '\n'),
TextSpan( TextSpan(
text: _currentDuration, text: ctrl.lastDuration.value,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
]), ]),
), ),
), ),
),
body: SafeArea( body: SafeArea(
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
@ -259,13 +247,21 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Obx(() {
return Row(
children: [ children: [
Text(call.room.serverRegion ?? 'unknown'), Text(
const SizedBox(width: 6), call.channel.value?.name ??
Text(call.room.serverVersion ?? 'unknown') 'unknown'.tr,
], style: const TextStyle(
fontWeight: FontWeight.bold,
), ),
),
const SizedBox(width: 6),
Text(call.lastDuration.value)
],
);
}),
Row( Row(
children: [ children: [
Text( Text(
@ -317,6 +313,15 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
), ),
); );
}), }),
Row(
children: [
if (widget.isExpandable)
IconButton(
icon: const Icon(Icons.fullscreen),
onPressed: () {
ctrl.gotoScreen(context);
},
),
IconButton( IconButton(
icon: _layoutMode == 0 icon: _layoutMode == 0
? const Icon(Icons.view_list) ? const Icon(Icons.view_list)
@ -326,13 +331,14 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
}, },
), ),
], ],
),
],
).paddingOnly(left: 20, right: 16), ).paddingOnly(left: 20, right: 16),
), ),
), ),
Expanded( Expanded(
child: Material( child: Material(
color: Theme.of(context).colorScheme.surfaceContainerLow, color: Theme.of(context).colorScheme.surfaceContainerLow,
elevation: 2,
child: Builder( child: Builder(
builder: (context) { builder: (context) {
switch (_layoutMode) { switch (_layoutMode) {
@ -345,15 +351,15 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
), ),
), ),
), ),
if (provider.room.localParticipant != null) if (ctrl.room.localParticipant != null)
SizeTransition( SizeTransition(
sizeFactor: _controlsAnimation, sizeFactor: _controlsAnimation,
axis: Axis.vertical, axis: Axis.vertical,
child: SizedBox( child: SizedBox(
width: MediaQuery.of(context).size.width, width: MediaQuery.of(context).size.width,
child: ControlsWidget( child: ControlsWidget(
provider.room, ctrl.room,
provider.room.localParticipant!, ctrl.room.localParticipant!,
), ),
), ),
), ),
@ -370,17 +376,13 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
@override @override
void deactivate() { void deactivate() {
_timer?.cancel(); Get.find<ChatCallProvider>().disableDurationUpdater();
_timer = null;
super.deactivate(); super.deactivate();
} }
@override @override
void activate() { void activate() {
_timer ??= Timer.periodic( Get.find<ChatCallProvider>().enableDurationUpdater();
const Duration(seconds: 1),
(_) => _updateDuration(),
);
super.activate(); super.activate();
} }
} }

View File

@ -11,9 +11,11 @@ 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';
import 'package:solian/screens/channel/call/call.dart';
import 'package:solian/screens/channel/channel_detail.dart'; 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';
@ -39,7 +41,7 @@ class ChannelChatScreen extends StatefulWidget {
} }
class _ChannelChatScreenState extends State<ChannelChatScreen> class _ChannelChatScreenState extends State<ChannelChatScreen>
with WidgetsBindingObserver { with WidgetsBindingObserver, TickerProviderStateMixin {
DateTime? _isOutOfSyncSince; DateTime? _isOutOfSyncSince;
bool _isBusy = false; bool _isBusy = false;
@ -139,7 +141,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
switch (state) { switch (state) {
case AppLifecycleState.resumed: case AppLifecycleState.resumed:
if (_isOutOfSyncSince == null) break; if (_isOutOfSyncSince == null) break;
if (DateTime.now().difference(_isOutOfSyncSince!).inSeconds < 60) break; if (DateTime.now().difference(_isOutOfSyncSince!).inSeconds < 30) break;
_keepUpdateWithServer(); _keepUpdateWithServer();
break; break;
case AppLifecycleState.paused: case AppLifecycleState.paused:
@ -238,12 +240,21 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
); );
} }
return Column( return Row(
children: [
Expanded(
child: Column(
children: [ children: [
if (_ongoingCall != null) if (_ongoingCall != null)
ChannelCallIndicator( ChannelCallIndicator(
channel: _channel!, channel: _channel!,
ongoingCall: _ongoingCall!, ongoingCall: _ongoingCall!,
onJoin: () {
if (!SolianTheme.isLargeScreen(context)) {
final ChatCallProvider call = Get.find();
call.gotoScreen(context);
}
},
), ),
Expanded( Expanded(
child: ChatEventList( child: ChatEventList(
@ -258,25 +269,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
}, },
), ),
), ),
if (_isOutOfSyncSince != null)
ListTile(
contentPadding: const EdgeInsets.only(left: 16, right: 8),
tileColor: Theme.of(context).colorScheme.surfaceContainerLow,
leading: const Icon(Icons.history_toggle_off),
title: Text('messageOutOfSync'.tr),
subtitle: Text('messageOutOfSyncCaption'.tr),
trailing: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
setState(() => _isOutOfSyncSince = null);
},
),
onTap: _isBusy
? null
: () {
_keepUpdateWithServer();
},
),
Obx(() { Obx(() {
if (_chatController.isLoading.isTrue) { if (_chatController.isLoading.isTrue) {
return const LinearProgressIndicator().animate().slideY(); return const LinearProgressIndicator().animate().slideY();
@ -310,6 +302,26 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
), ),
), ),
], ],
),
),
Obx(() {
final ChatCallProvider call = Get.find();
if (call.isMounted.value && SolianTheme.isLargeScreen(context)) {
return const Expanded(
child: Row(children: [
VerticalDivider(width: 0.3, thickness: 0.3),
Expanded(
child: CallScreen(
hideAppBar: true,
isExpandable: true,
),
),
]),
);
}
return const SizedBox();
}),
],
); );
}), }),
); );

View File

@ -80,13 +80,15 @@ class _ChatScreenState extends State<ChatScreen> {
contentPadding: const EdgeInsets.symmetric(horizontal: 8), contentPadding: const EdgeInsets.symmetric(horizontal: 8),
), ),
onTap: () { onTap: () {
final ChannelProvider provider = Get.find(); final ChannelProvider channels = Get.find();
provider channels
.createDirectChannel(context, 'global') .createDirectChannel(context, 'global')
.then((resp) { .then((resp) {
if (resp != null) { if (resp != null) {
_channels.refreshAvailableChannel(); _channels.refreshAvailableChannel();
} }
}).catchError((e) {
context.showErrorDialog(e);
}); });
}, },
), ),
@ -125,6 +127,7 @@ class _ChatScreenState extends State<ChatScreen> {
noCategory: true, noCategory: true,
channels: _channels.directChannels, channels: _channels.directChannels,
selfId: selfId, selfId: selfId,
useReplace: true,
), ),
), ),
), ),

View File

@ -5,6 +5,7 @@ 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/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/account/signin_required_overlay.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/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';
@ -27,20 +28,18 @@ class _HomeScreenState extends State<HomeScreen>
void initState() { void initState() {
super.initState(); super.initState();
_postController = PostListController(); _postController = PostListController();
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() { _tabController.addListener(() {
switch (_tabController.index) {
case 0:
case 1:
if (_postController.mode.value == _tabController.index) return; if (_postController.mode.value == _tabController.index) return;
_postController.mode.value = _tabController.index; _postController.mode.value = _tabController.index;
_postController.reloadAllOver(); _postController.reloadAllOver();
}
}); });
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final AuthProvider auth = Get.find();
return Material( return Material(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: Scaffold( child: Scaffold(
@ -82,6 +81,7 @@ class _HomeScreenState extends State<HomeScreen>
controller: _tabController, controller: _tabController,
tabs: [ tabs: [
Tab(text: 'postListNews'.tr), Tab(text: 'postListNews'.tr),
Tab(text: 'postListFriends'.tr),
Tab(text: 'postListShuffle'.tr), Tab(text: 'postListShuffle'.tr),
], ],
), ),
@ -108,6 +108,23 @@ class _HomeScreenState extends State<HomeScreen>
), ),
]), ]),
), ),
Obx(() {
if (auth.isAuthorized.value) {
return RefreshIndicator(
onRefresh: () => _postController.reloadAllOver(),
child: CustomScrollView(slivers: [
PostWarpedListWidget(
controller: _postController.pagingController,
onUpdate: () => _postController.reloadAllOver(),
),
]),
);
} else {
return SigninRequiredOverlay(
onSignedIn: () => _postController.reloadAllOver(),
);
}
}),
PostShuffleSwiper(controller: _postController), PostShuffleSwiper(controller: _postController),
], ],
); );

View File

@ -65,7 +65,7 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
final AttachmentUploaderController uploader = Get.find(); final AttachmentUploaderController uploader = Get.find();
if (uploader.queueOfUpload.any( if (uploader.queueOfUpload.any(
((x) => x.usage == 'i.attachment' && x.isUploading), ((x) => x.isUploading),
)) { )) {
context.showErrorDialog('attachmentUploadInProgress'.tr); context.showErrorDialog('attachmentUploadInProgress'.tr);
return; return;
@ -90,8 +90,8 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString); context.showErrorDialog(resp.bodyString);
} else { } else {
_editorController.localClear();
_editorController.currentClear(); _editorController.currentClear();
_editorController.localClear();
AppRouter.instance.pop(resp.body); AppRouter.instance.pop(resp.body);
} }
@ -176,11 +176,20 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
children: [ children: [
ListTile( ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainerLow, tileColor: Theme.of(context).colorScheme.surfaceContainerLow,
title: Text( title: Row(
children: [
Text(
_editorController.title ?? 'title'.tr, _editorController.title ?? 'title'.tr,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(width: 6),
if (_editorController.aliasController.text.isNotEmpty)
Badge(
label: Text('#${_editorController.aliasController.text}'),
),
],
),
subtitle: Text( subtitle: Text(
_editorController.description ?? 'description'.tr, _editorController.description ?? 'description'.tr,
maxLines: 2, maxLines: 2,
@ -255,6 +264,7 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
), ),
], ],
), ),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(),
Expanded( Expanded(
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -265,10 +275,6 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
Expanded( Expanded(
child: ListView( child: ListView(
children: [ children: [
if (_isBusy)
const LinearProgressIndicator()
.animate()
.scaleX(),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 16, horizontal: 16,

View File

@ -171,7 +171,7 @@ class _RealmPostListWidgetState extends State<RealmPostListWidget> {
Response resp; Response resp;
try { try {
resp = await provider.listPost(pageKey, realm: widget.realm.id); resp = await provider.listPost(pageKey, realm: widget.realm.alias);
} catch (e) { } catch (e) {
_pagingController.error = e; _pagingController.error = e;
return; return;

View File

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

View File

@ -13,6 +13,7 @@ const i18nEnglish = {
'more': 'More', 'more': 'More',
'share': 'Share', 'share': 'Share',
'shareNoUri': 'Share text content', 'shareNoUri': 'Share text content',
'alias': 'Alias',
'feed': 'Feed', 'feed': 'Feed',
'unlink': 'Unlink', 'unlink': 'Unlink',
'feedSearch': 'Search Feed', 'feedSearch': 'Search Feed',
@ -40,6 +41,8 @@ const i18nEnglish = {
'openInBrowser': 'Open in browser', 'openInBrowser': 'Open in browser',
'notification': 'Notification', 'notification': 'Notification',
'errorHappened': 'An error occurred', 'errorHappened': 'An error occurred',
'errorHappenedUnauthorized':
'Unauthorized request, please sign in or try resign in.',
'forgotPassword': 'Forgot password', 'forgotPassword': 'Forgot password',
'email': 'Email', 'email': 'Email',
'username': 'Username', 'username': 'Username',
@ -124,6 +127,7 @@ const i18nEnglish = {
'postThumbnailAttachment': 'Attachment serial number', 'postThumbnailAttachment': 'Attachment serial number',
'postPinned': 'Pinned', 'postPinned': 'Pinned',
'postListNews': 'News', 'postListNews': 'News',
'postListFriends': 'Friends',
'postListShuffle': 'Random', 'postListShuffle': 'Random',
'postEditorModeStory': 'Post a post', 'postEditorModeStory': 'Post a post',
'postEditorModeArticle': 'Post an article', 'postEditorModeArticle': 'Post an article',
@ -172,7 +176,7 @@ const i18nEnglish = {
'attachmentAttached': 'Exists Files', 'attachmentAttached': 'Exists Files',
'attachmentUploadBlocked': 'attachmentUploadBlocked':
'Upload blocked, there is currently a task in progress...', 'Upload blocked, there is currently a task in progress...',
'attachmentAdd': 'Attach attachments', 'attachmentAdd': 'Attach file',
'attachmentAddGalleryPhoto': 'Gallery photo', 'attachmentAddGalleryPhoto': 'Gallery photo',
'attachmentAddGalleryVideo': 'Gallery video', 'attachmentAddGalleryVideo': 'Gallery video',
'attachmentAddCameraPhoto': 'Capture photo', 'attachmentAddCameraPhoto': 'Capture photo',
@ -267,6 +271,7 @@ const i18nEnglish = {
'callOngoing': 'A call is ongoing...', 'callOngoing': 'A call is ongoing...',
'callOngoingEmpty': 'A call is on hold...', 'callOngoingEmpty': 'A call is on hold...',
'callOngoingParticipants': '@count people are calling...', 'callOngoingParticipants': '@count people are calling...',
'callOngoingJoined': 'Call last @duration',
'callJoin': 'Join', 'callJoin': 'Join',
'callResume': 'Resume', 'callResume': 'Resume',
'callMicrophone': 'Microphone', 'callMicrophone': 'Microphone',
@ -377,4 +382,7 @@ const i18nEnglish = {
'messageOutOfSyncCaption': 'messageOutOfSyncCaption':
'Since the App has entered the background, there may be a time difference between the message list and the server. Click to Refresh.', 'Since the App has entered the background, there may be a time difference between the message list and the server. Click to Refresh.',
'messageHistoryWipe': 'Wipe local message history', 'messageHistoryWipe': 'Wipe local message history',
'unknown': 'Unknown',
'collapse': 'Collapse',
'expand': 'Expand',
}; };

View File

@ -21,6 +21,7 @@ const i18nSimplifiedChinese = {
'more': '更多', 'more': '更多',
'share': '分享', 'share': '分享',
'shareNoUri': '分享文字内容', 'shareNoUri': '分享文字内容',
'alias': '别名',
'feed': '资讯', 'feed': '资讯',
'unlink': '移除链接', 'unlink': '移除链接',
'feedSearch': '搜索资讯', 'feedSearch': '搜索资讯',
@ -40,6 +41,12 @@ const i18nSimplifiedChinese = {
'openInBrowser': '在浏览器中打开', 'openInBrowser': '在浏览器中打开',
'notification': '通知', 'notification': '通知',
'errorHappened': '发生错误了', 'errorHappened': '发生错误了',
'errorHappenedUnauthorized': '未经授权的请求,请登录或尝试重新登录。',
'errorHappenedRequestBad': '请求错误,服务器拒绝处理该请求,请检查您的请求数据。',
'errorHappenedRequestForbidden': '请求错误,权限不足。',
'errorHappenedRequestNotFound': '请求错误,请求的数据不存在。',
'errorHappenedRequestConnection': '网络请求失败,请检查连接状态与服务状态后再试。',
'errorHappenedRequestUnknown': '请求错误,类型未知,请将本提示完整截图提交反馈。',
'forgotPassword': '忘记密码', 'forgotPassword': '忘记密码',
'email': '邮件地址', 'email': '邮件地址',
'username': '用户名', 'username': '用户名',
@ -124,6 +131,7 @@ const i18nSimplifiedChinese = {
'articleDetail': '文章详情', 'articleDetail': '文章详情',
'draftBoxOpen': '打开草稿箱', 'draftBoxOpen': '打开草稿箱',
'postListNews': '新鲜事', 'postListNews': '新鲜事',
'postListFriends': '好友圈',
'postListShuffle': '打乱看', 'postListShuffle': '打乱看',
'postNew': '创建新帖子', 'postNew': '创建新帖子',
'postNewInRealmHint': '在领域 @realm 里发表新帖子', 'postNewInRealmHint': '在领域 @realm 里发表新帖子',
@ -245,6 +253,7 @@ const i18nSimplifiedChinese = {
'callOngoing': '一则通话正在进行中…', 'callOngoing': '一则通话正在进行中…',
'callOngoingEmpty': '一则通话待机中…', 'callOngoingEmpty': '一则通话待机中…',
'callOngoingParticipants': '@count 人正在进行通话…', 'callOngoingParticipants': '@count 人正在进行通话…',
'callOngoingJoined': '通话进行 @duration',
'callJoin': '加入', 'callJoin': '加入',
'callResume': '恢复', 'callResume': '恢复',
'callMicrophone': '麦克风', 'callMicrophone': '麦克风',
@ -343,4 +352,7 @@ const i18nSimplifiedChinese = {
'messageOutOfSync': '消息可能与服务器脱节', 'messageOutOfSync': '消息可能与服务器脱节',
'messageOutOfSyncCaption': '由于 App 进入后台,消息列表可能与服务器存在时差,点击刷新。', 'messageOutOfSyncCaption': '由于 App 进入后台,消息列表可能与服务器存在时差,点击刷新。',
'messageHistoryWipe': '清除消息记录', 'messageHistoryWipe': '清除消息记录',
'unknown': '未知',
'collapse': '折叠',
'expand': '展开',
}; };

View File

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

View File

@ -16,28 +16,29 @@ class AttachmentAttrEditorDialog extends StatefulWidget {
}); });
@override @override
State<AttachmentAttrEditorDialog> createState() => _AttachmentAttrEditorDialogState(); State<AttachmentAttrEditorDialog> createState() =>
_AttachmentAttrEditorDialogState();
} }
class _AttachmentAttrEditorDialogState extends State<AttachmentAttrEditorDialog> { class _AttachmentAttrEditorDialogState
extends State<AttachmentAttrEditorDialog> {
final _altController = TextEditingController(); final _altController = TextEditingController();
bool _isBusy = false; bool _isBusy = false;
bool _isMature = false; bool _isMature = false;
Future<Attachment?> _updateAttachment() async { Future<Attachment?> _updateAttachment() async {
final AttachmentProvider provider = Get.find(); final AttachmentProvider attach = Get.find();
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
final resp = await provider.updateAttachment( final resp = await attach.updateAttachment(
widget.item.id, widget.item.id,
_altController.value.text, _altController.value.text,
widget.item.usage,
isMature: _isMature, isMature: _isMature,
); );
Get.find<AttachmentProvider>().clearCache(id: widget.item.id); Get.find<AttachmentProvider>().clearCache(id: widget.item.rid);
setState(() => _isBusy = false); setState(() => _isBusy = false);
return Attachment.fromJson(resp.body); return Attachment.fromJson(resp.body);

View File

@ -22,19 +22,19 @@ import 'package:solian/widgets/attachments/attachment_attr_editor.dart';
import 'package:solian/widgets/attachments/attachment_fullscreen.dart'; import 'package:solian/widgets/attachments/attachment_fullscreen.dart';
class AttachmentEditorPopup extends StatefulWidget { class AttachmentEditorPopup extends StatefulWidget {
final String usage; final String pool;
final bool singleMode; final bool singleMode;
final bool imageOnly; final bool imageOnly;
final bool autoUpload; final bool autoUpload;
final double? imageMaxWidth; final double? imageMaxWidth;
final double? imageMaxHeight; final double? imageMaxHeight;
final List<int>? initialAttachments; final List<String>? initialAttachments;
final void Function(int) onAdd; final void Function(String) onAdd;
final void Function(int) onRemove; final void Function(String) onRemove;
const AttachmentEditorPopup({ const AttachmentEditorPopup({
super.key, super.key,
required this.usage, required this.pool,
required this.onAdd, required this.onAdd,
required this.onRemove, required this.onRemove,
this.singleMode = false, this.singleMode = false,
@ -64,7 +64,7 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
if (widget.singleMode) { if (!widget.singleMode) {
final medias = await _imagePicker.pickMultiImage( final medias = await _imagePicker.pickMultiImage(
maxWidth: widget.imageMaxWidth, maxWidth: widget.imageMaxWidth,
maxHeight: widget.imageMaxHeight, maxHeight: widget.imageMaxHeight,
@ -72,8 +72,8 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
if (medias.isEmpty) return; if (medias.isEmpty) return;
_enqueueTaskBatch(medias.map((x) { _enqueueTaskBatch(medias.map((x) {
final file = File(x.path); final file = XFile(x.path);
return AttachmentUploadTask(file: file, usage: widget.usage); return AttachmentUploadTask(file: file, pool: widget.pool);
})); }));
} else { } else {
final media = await _imagePicker.pickMedia( final media = await _imagePicker.pickMedia(
@ -83,7 +83,7 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
if (media == null) return; if (media == null) return;
_enqueueTask( _enqueueTask(
AttachmentUploadTask(file: File(media.path), usage: widget.usage), AttachmentUploadTask(file: XFile(media.path), pool: widget.pool),
); );
} }
} }
@ -95,9 +95,8 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
final media = await _imagePicker.pickVideo(source: ImageSource.gallery); final media = await _imagePicker.pickVideo(source: ImageSource.gallery);
if (media == null) return; if (media == null) return;
final file = File(media.path);
_enqueueTask( _enqueueTask(
AttachmentUploadTask(file: file, usage: widget.usage), AttachmentUploadTask(file: XFile(media.path), pool: widget.pool),
); );
} }
@ -113,7 +112,7 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
List<File> files = result.paths.map((path) => File(path!)).toList(); List<File> files = result.paths.map((path) => File(path!)).toList();
_enqueueTaskBatch(files.map((x) { _enqueueTaskBatch(files.map((x) {
return AttachmentUploadTask(file: x, usage: widget.usage); return AttachmentUploadTask(file: XFile(x.path), pool: widget.pool);
})); }));
} }
@ -129,9 +128,8 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
} }
if (media == null) return; if (media == null) return;
final file = File(media.path);
_enqueueTask( _enqueueTask(
AttachmentUploadTask(file: file, usage: widget.usage), AttachmentUploadTask(file: XFile(media.path), pool: widget.pool),
); );
} }
@ -181,13 +179,11 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
WidgetsBinding.instance.addPostFrameCallback((_) => controller.dispose()); WidgetsBinding.instance.addPostFrameCallback((_) => controller.dispose());
if (input == null || input.isEmpty) return; if (input == null || input.isEmpty) return;
final value = int.tryParse(input);
if (value == null) return;
final AttachmentProvider attach = Get.find(); final AttachmentProvider attach = Get.find();
final result = await attach.getMetadata(value); final result = await attach.getMetadata(input);
if (result != null) { if (result != null) {
widget.onAdd(result.id); widget.onAdd(result.rid);
setState(() => _attachments.add(result)); setState(() => _attachments.add(result));
if (widget.singleMode) Navigator.pop(context); if (widget.singleMode) Navigator.pop(context);
} }
@ -199,20 +195,16 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
if (_uploadController.isUploading.value) return; if (_uploadController.isUploading.value) return;
_uploadController.uploadAttachmentWithCallback( _uploadController
data, .uploadAttachmentFromData(data, 'Pasted Image', widget.pool, null)
'Pasted Image', .then((item) {
widget.usage,
null,
(item) {
if (item == null) return; if (item == null) return;
widget.onAdd(item.id); widget.onAdd(item.rid);
if (mounted) { if (mounted) {
setState(() => _attachments.add(item)); setState(() => _attachments.add(item));
if (widget.singleMode) Navigator.pop(context); if (widget.singleMode) Navigator.pop(context);
} }
}, });
);
} }
String _formatBytes(int bytes, {int decimals = 2}) { String _formatBytes(int bytes, {int decimals = 2}) {
@ -254,7 +246,7 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
.listMetadata(widget.initialAttachments ?? List.empty()) .listMetadata(widget.initialAttachments ?? List.empty())
.then((result) { .then((result) {
setState(() { setState(() {
_attachments = result; _attachments = List.from(result, growable: true);
_isBusy = false; _isBusy = false;
_isFirstTimeBusy = false; _isFirstTimeBusy = false;
}); });
@ -306,7 +298,7 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
], ],
); );
if (croppedFile == null) return; if (croppedFile == null) return;
_uploadController.queueOfUpload[queueIndex].file = File(croppedFile.path); _uploadController.queueOfUpload[queueIndex].file = XFile(croppedFile.path);
_uploadController.queueOfUpload.refresh(); _uploadController.queueOfUpload.refresh();
} }
@ -349,9 +341,25 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
fontFamily: 'monospace', fontFamily: 'monospace',
), ),
), ),
Row(
children: [
FutureBuilder(
future: element.file.length(),
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox();
return Text(
_formatBytes(snapshot.data!),
style: Theme.of(context).textTheme.bodySmall,
);
},
),
const SizedBox(width: 6),
if (element.progress != null)
Text( Text(
'In queue #${index + 1}', '${(element.progress! * 100).toStringAsFixed(2)}%',
style: const TextStyle(fontSize: 12), style: Theme.of(context).textTheme.bodySmall,
),
],
), ),
], ],
), ),
@ -413,11 +421,11 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
: () { : () {
_uploadController _uploadController
.performSingleTask(index) .performSingleTask(index)
.then((r) { .then((out) {
if (r == null) return; if (out == null) return;
widget.onAdd(r.id); widget.onAdd(out.rid);
if (mounted) { if (mounted) {
setState(() => _attachments.add(r)); setState(() => _attachments.add(out));
if (widget.singleMode) { if (widget.singleMode) {
Navigator.pop(context); Navigator.pop(context);
} }
@ -515,7 +523,7 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
), ),
onTap: () { onTap: () {
_deleteAttachment(element).then((_) { _deleteAttachment(element).then((_) {
widget.onRemove(element.id); widget.onRemove(element.rid);
setState(() => _attachments.removeAt(index)); setState(() => _attachments.removeAt(index));
}); });
}, },
@ -529,7 +537,7 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
), ),
), ),
onTap: () { onTap: () {
widget.onRemove(element.id); widget.onRemove(element.rid);
setState(() => _attachments.removeAt(index)); setState(() => _attachments.removeAt(index));
}, },
), ),
@ -560,7 +568,7 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
void _startUploading() { void _startUploading() {
_uploadController.performUploadQueue(onData: (r) { _uploadController.performUploadQueue(onData: (r) {
widget.onAdd(r.id); widget.onAdd(r.rid);
if (mounted) { if (mounted) {
setState(() => _attachments.add(r)); setState(() => _attachments.add(r));
if (widget.singleMode) Navigator.pop(context); if (widget.singleMode) Navigator.pop(context);
@ -583,8 +591,8 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
onDragDone: (detail) async { onDragDone: (detail) async {
if (_uploadController.isUploading.value) return; if (_uploadController.isUploading.value) return;
_enqueueTaskBatch(detail.files.map((x) { _enqueueTaskBatch(detail.files.map((x) {
final file = File(x.path); final file = XFile(x.path);
return AttachmentUploadTask(file: file, usage: widget.usage); return AttachmentUploadTask(file: file, pool: widget.pool);
})); }));
}, },
child: Column( child: Column(
@ -598,16 +606,14 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
children: [ children: [
Expanded( Expanded(
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Expanded( Text(
child: Text(
'attachmentAdd'.tr, 'attachmentAdd'.tr,
style: style: Theme.of(context).textTheme.headlineSmall,
Theme.of(context).textTheme.headlineSmall,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
),
const SizedBox(width: 10), const SizedBox(width: 10),
Obx(() { Obx(() {
if (_uploadController.isUploading.value) { if (_uploadController.isUploading.value) {

View File

@ -67,7 +67,7 @@ class _AttachmentFullScreenState extends State<AttachmentFullScreen> {
Future<void> _saveToAlbum() async { Future<void> _saveToAlbum() async {
final url = ServiceFinder.buildUrl( final url = ServiceFinder.buildUrl(
'files', 'files',
'/attachments/${widget.item.id}', '/attachments/${widget.item.rid}',
); );
if (PlatformInfo.isWeb || PlatformInfo.isDesktop) { if (PlatformInfo.isWeb || PlatformInfo.isDesktop) {
@ -258,7 +258,7 @@ class _AttachmentFullScreenState extends State<AttachmentFullScreen> {
spacing: 6, spacing: 6,
children: [ children: [
Text( Text(
'#${widget.item.id}', '#${widget.item.rid}',
style: metaTextStyle, style: metaTextStyle,
), ),
if (widget.item.metadata?['width'] != null && if (widget.item.metadata?['width'] != null &&

View File

@ -91,7 +91,7 @@ class _AttachmentItemState extends State<AttachmentItem> {
launchUrlString( launchUrlString(
ServiceFinder.buildUrl( ServiceFinder.buildUrl(
'files', 'files',
'/attachments/${widget.item.id}', '/attachments/${widget.item.rid}',
), ),
); );
}, },
@ -135,7 +135,7 @@ class _AttachmentItemImage extends StatelessWidget {
fit: fit, fit: fit,
imageUrl: ServiceFinder.buildUrl( imageUrl: ServiceFinder.buildUrl(
'files', 'files',
'/attachments/${item.id}', '/attachments/${item.rid}',
), ),
progressIndicatorBuilder: (context, url, downloadProgress) { progressIndicatorBuilder: (context, url, downloadProgress) {
return Center( return Center(
@ -232,15 +232,18 @@ class _AttachmentItemVideo extends StatefulWidget {
class _AttachmentItemVideoState extends State<_AttachmentItemVideo> { class _AttachmentItemVideoState extends State<_AttachmentItemVideo> {
late final _player = Player( late final _player = Player(
configuration: const PlayerConfiguration(logLevel: MPVLogLevel.error), configuration: const PlayerConfiguration(
logLevel: MPVLogLevel.error,
),
); );
late final _controller = VideoController(_player); late final _controller = VideoController(_player);
bool _showContent = false; bool _showContent = false;
void _startLoad() { Future<void> _startLoad() async {
_player.open( await _player.open(
Media(ServiceFinder.buildUrl('files', '/attachments/${widget.item.id}')), Media(ServiceFinder.buildUrl('files', '/attachments/${widget.item.rid}')),
play: false, play: false,
); );
setState(() => _showContent = true); setState(() => _showContent = true);
@ -249,7 +252,9 @@ class _AttachmentItemVideoState extends State<_AttachmentItemVideo> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_showContent = widget.autoload; if (widget.autoload) {
_startLoad();
}
} }
@override @override

View File

@ -1,4 +1,4 @@
import 'dart:math' show min; import 'dart:math' as math;
import 'dart:ui'; import 'dart:ui';
import 'package:carousel_slider/carousel_slider.dart'; import 'package:carousel_slider/carousel_slider.dart';
@ -14,11 +14,13 @@ import 'package:solian/widgets/sized_container.dart';
class AttachmentList extends StatefulWidget { class AttachmentList extends StatefulWidget {
final String parentId; final String parentId;
final List<int> attachmentsId; final List<String> attachmentsId;
final bool isGrid; final bool isGrid;
final bool isColumn;
final bool isForceGrid; final bool isForceGrid;
final bool autoload; final bool autoload;
final double flatMaxHeight; final double flatMaxHeight;
final double columnMaxWidth;
final double? width; final double? width;
final double? viewport; final double? viewport;
@ -28,9 +30,11 @@ class AttachmentList extends StatefulWidget {
required this.parentId, required this.parentId,
required this.attachmentsId, required this.attachmentsId,
this.isGrid = false, this.isGrid = false,
this.isColumn = false,
this.isForceGrid = false, this.isForceGrid = false,
this.autoload = false, this.autoload = false,
this.flatMaxHeight = 720, this.flatMaxHeight = 720,
this.columnMaxWidth = 480,
this.width, this.width,
this.viewport, this.viewport,
}); });
@ -57,10 +61,12 @@ class _AttachmentListState extends State<AttachmentList> {
} }
attach.listMetadata(widget.attachmentsId).then((result) { attach.listMetadata(widget.attachmentsId).then((result) {
if (mounted) {
setState(() { setState(() {
_attachmentsMeta = result; _attachmentsMeta = result;
_isLoading = false; _isLoading = false;
}); });
}
_calculateAspectRatio(); _calculateAspectRatio();
}); });
} }
@ -70,7 +76,8 @@ class _AttachmentListState extends State<AttachmentList> {
double? consistentValue; double? consistentValue;
int portrait = 0, square = 0, landscape = 0; int portrait = 0, square = 0, landscape = 0;
for (var entry in _attachmentsMeta) { for (var entry in _attachmentsMeta) {
if (entry!.metadata?['ratio'] != null) { if (entry == null) continue;
if (entry.metadata?['ratio'] != null) {
if (entry.metadata?['ratio'] is int) { if (entry.metadata?['ratio'] is int) {
consistentValue ??= entry.metadata?['ratio'].toDouble(); consistentValue ??= entry.metadata?['ratio'].toDouble();
} else { } else {
@ -102,15 +109,17 @@ class _AttachmentListState extends State<AttachmentList> {
} }
} }
Widget _buildEntry(Attachment? element, int idx) { Widget _buildEntry(Attachment? element, int idx, {double? width}) {
return AttachmentListEntry( return AttachmentListEntry(
item: element, item: element,
parentId: widget.parentId, parentId: widget.parentId,
width: widget.width, width: width ?? widget.width,
badgeContent: '${idx + 1}/${_attachmentsMeta.length}', badgeContent: '${idx + 1}/${_attachmentsMeta.length}',
showBadge: _attachmentsMeta.length > 1 && !widget.isGrid, showBadge:
_attachmentsMeta.length > 1 && !widget.isGrid && !widget.isColumn,
showBorder: widget.attachmentsId.length > 1, showBorder: widget.attachmentsId.length > 1,
showMature: _showMature, showMature: _showMature,
autoload: widget.autoload,
onReveal: (value) { onReveal: (value) {
setState(() => _showMature = value); setState(() => _showMature = value);
}, },
@ -138,8 +147,46 @@ class _AttachmentListState extends State<AttachmentList> {
); );
} }
final isNotPureImage = _attachmentsMeta if (widget.isColumn) {
.any((x) => x?.mimetype.split('/').firstOrNull != 'image'); var idx = 0;
const radius = BorderRadius.all(Radius.circular(8));
return Wrap(
spacing: 8,
runSpacing: 8,
children: widget.attachmentsId.map((x) {
final element = _attachmentsMeta[idx];
idx++;
if (element == null) return const SizedBox();
double ratio = element.metadata?['ratio']?.toDouble() ?? 16 / 9;
return Container(
constraints: BoxConstraints(
maxWidth: widget.columnMaxWidth,
maxHeight: 640,
),
child: AspectRatio(
aspectRatio: ratio,
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
borderRadius: radius,
),
child: ClipRRect(
borderRadius: radius,
child: _buildEntry(element, idx),
),
),
),
);
}).toList(),
);
}
final isNotPureImage = _attachmentsMeta.any(
(x) => x?.mimetype.split('/').firstOrNull != 'image',
);
if (widget.isGrid && (widget.isForceGrid || !isNotPureImage)) { if (widget.isGrid && (widget.isForceGrid || !isNotPureImage)) {
const radius = BorderRadius.all(Radius.circular(8)); const radius = BorderRadius.all(Radius.circular(8));
return GridView.builder( return GridView.builder(
@ -148,7 +195,7 @@ class _AttachmentListState extends State<AttachmentList> {
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true, shrinkWrap: true,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: min(3, widget.attachmentsId.length), crossAxisCount: math.min(3, widget.attachmentsId.length),
mainAxisSpacing: 8.0, mainAxisSpacing: 8.0,
crossAxisSpacing: 8.0, crossAxisSpacing: 8.0,
), ),
@ -157,8 +204,10 @@ class _AttachmentListState extends State<AttachmentList> {
final element = _attachmentsMeta[idx]; final element = _attachmentsMeta[idx];
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
border: border: Border.all(
Border.all(color: Theme.of(context).dividerColor, width: 1), color: Theme.of(context).dividerColor,
width: 1,
),
borderRadius: radius, borderRadius: radius,
), ),
child: ClipRRect( child: ClipRRect(
@ -206,6 +255,7 @@ class AttachmentListEntry extends StatelessWidget {
final Attachment? item; final Attachment? item;
final String? badgeContent; final String? badgeContent;
final double? width; final double? width;
final double? height;
final bool showBorder; final bool showBorder;
final bool showBadge; final bool showBadge;
final bool showMature; final bool showMature;
@ -220,6 +270,7 @@ class AttachmentListEntry extends StatelessWidget {
this.item, this.item,
this.badgeContent, this.badgeContent,
this.width, this.width,
this.height,
this.showBorder = false, this.showBorder = false,
this.showBadge = false, this.showBadge = false,
this.showMature = false, this.showMature = false,
@ -244,6 +295,7 @@ class AttachmentListEntry extends StatelessWidget {
return GestureDetector( return GestureDetector(
child: Container( child: Container(
width: width ?? MediaQuery.of(context).size.width, width: width ?? MediaQuery.of(context).size.width,
height: height,
decoration: BoxDecoration( decoration: BoxDecoration(
border: showBorder border: showBorder
? Border.symmetric( ? Border.symmetric(
@ -328,13 +380,13 @@ class AttachmentListEntry extends StatelessWidget {
} }
class AttachmentSelfContainedEntry extends StatefulWidget { class AttachmentSelfContainedEntry extends StatefulWidget {
final int id; final String rid;
final String parentId; final String parentId;
final bool isDense; final bool isDense;
const AttachmentSelfContainedEntry({ const AttachmentSelfContainedEntry({
super.key, super.key,
required this.id, required this.rid,
required this.parentId, required this.parentId,
this.isDense = false, this.isDense = false,
}); });
@ -353,10 +405,12 @@ class _AttachmentSelfContainedEntryState
final AttachmentProvider attachments = Get.find(); final AttachmentProvider attachments = Get.find();
return FutureBuilder( return FutureBuilder(
future: attachments.getMetadata(widget.id), future: attachments.getMetadata(widget.rid),
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData) { if (!snapshot.hasData) {
return const Text('Loading...'); return const Center(
child: CircularProgressIndicator(),
);
} }
return AttachmentListEntry( return AttachmentListEntry(
@ -364,7 +418,6 @@ class _AttachmentSelfContainedEntryState
isDense: widget.isDense, isDense: widget.isDense,
parentId: widget.parentId, parentId: widget.parentId,
showMature: _showMature, showMature: _showMature,
showBorder: true,
onReveal: (value) { onReveal: (value) {
setState(() => _showMature = value); setState(() => _showMature = value);
}, },

View File

@ -9,14 +9,20 @@ import 'package:solian/models/call.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/platform.dart'; import 'package:solian/platform.dart';
import 'package:solian/providers/call.dart'; import 'package:solian/providers/call.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/chat/call/call_prejoin.dart'; import 'package:solian/widgets/chat/call/call_prejoin.dart';
class ChannelCallIndicator extends StatelessWidget { class ChannelCallIndicator extends StatelessWidget {
final Channel channel; final Channel channel;
final Call ongoingCall; final Call ongoingCall;
final Function onJoin;
const ChannelCallIndicator( const ChannelCallIndicator({
{super.key, required this.channel, required this.ongoingCall}); super.key,
required this.channel,
required this.ongoingCall,
required this.onJoin,
});
void _showCallPrejoin(BuildContext context) { void _showCallPrejoin(BuildContext context) {
showModalBottomSheet( showModalBottomSheet(
@ -25,6 +31,7 @@ class ChannelCallIndicator extends StatelessWidget {
builder: (context) => ChatCallPrejoinPopup( builder: (context) => ChatCallPrejoinPopup(
ongoingCall: ongoingCall, ongoingCall: ongoingCall,
channel: channel, channel: channel,
onJoin: onJoin,
), ),
); );
} }
@ -40,48 +47,72 @@ class ChannelCallIndicator extends StatelessWidget {
dividerColor: Colors.transparent, dividerColor: Colors.transparent,
content: Row( content: Row(
children: [ children: [
if (ongoingCall.participants.isEmpty) Text('callOngoingEmpty'.tr), Obx(() {
if (ongoingCall.participants.isNotEmpty) if (call.isInitialized.value) {
Text('callOngoingParticipants'.trParams({ return Text('callOngoingJoined'.trParams({
'duration': call.lastDuration.value,
}));
} else if (ongoingCall.participants.isEmpty) {
return Text('callOngoingEmpty'.tr);
} else {
return Text('callOngoingParticipants'.trParams({
'count': ongoingCall.participants.length.toString(), 'count': ongoingCall.participants.length.toString(),
})), }));
}
}),
const SizedBox(width: 6), const SizedBox(width: 6),
if (ongoingCall.participants.isNotEmpty) Obx(() {
Container( if (call.isInitialized.value) {
return const SizedBox();
}
if (ongoingCall.participants.isNotEmpty) {
return Container(
height: 28, height: 28,
constraints: const BoxConstraints(maxWidth: 120), constraints: const BoxConstraints(maxWidth: 120),
child: AvatarStack( child: AvatarStack(
height: 28, height: 28,
borderWidth: 0, borderWidth: 0,
avatars: ongoingCall.participants.map((x) { avatars: ongoingCall.participants.map((x) {
final userinfo = Account.fromJson(jsonDecode(x['metadata'])); final userinfo =
Account.fromJson(jsonDecode(x['metadata']));
return PlatformInfo.canCacheImage return PlatformInfo.canCacheImage
? CachedNetworkImageProvider(userinfo.avatar) ? CachedNetworkImageProvider(userinfo.avatar)
as ImageProvider as ImageProvider
: NetworkImage(userinfo.avatar) as ImageProvider; : NetworkImage(userinfo.avatar) as ImageProvider;
}).toList(), }).toList(),
), ),
), );
}
return const SizedBox();
})
], ],
), ),
actions: [ actions: [
Obx(() { Obx(() {
if (call.current.value == null) { if (call.isBusy.value) {
return const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 3),
).paddingAll(16);
} else if (call.current.value == null) {
return TextButton( return TextButton(
onPressed: () => _showCallPrejoin(context), onPressed: () => _showCallPrejoin(context),
child: Text('callJoin'.tr), child: Text('callJoin'.tr),
); );
} else if (call.channel.value?.id == channel.id) { } else if (call.channel.value?.id == channel.id &&
!SolianTheme.isLargeScreen(context)) {
return TextButton( return TextButton(
onPressed: () => call.gotoScreen(context), onPressed: () => onJoin(),
child: Text('callResume'.tr), child: Text('callResume'.tr),
); );
} else { } else if (!SolianTheme.isLargeScreen(context)) {
return TextButton( return TextButton(
onPressed: null, onPressed: null,
child: Text('callJoin'.tr), child: Text('callJoin'.tr),
); );
} }
return const SizedBox();
}) })
], ],
); );

View File

@ -88,12 +88,14 @@ class _ControlsWidgetState extends State<ControlsWidget> {
void _disconnect() async { void _disconnect() async {
if (await showDisconnectDialog() != true) return; if (await showDisconnectDialog() != true) return;
final ChatCallProvider provider = Get.find(); final ChatCallProvider call = Get.find();
if (provider.current.value != null) { if (call.current.value != null) {
provider.disposeRoom(); call.disposeRoom();
if (Navigator.canPop(context)) {
Navigator.pop(context); Navigator.pop(context);
} }
} }
}
void _disableAudio() async { void _disableAudio() async {
await _participant.setMicrophoneEnabled(false); await _participant.setMicrophoneEnabled(false);
@ -209,8 +211,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
runSpacing: 5, runSpacing: 5,
children: [ children: [
IconButton( IconButton(
icon: Transform.flip( icon: const Icon(Icons.exit_to_app),
flipX: true, child: const Icon(Icons.exit_to_app)),
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
onPressed: _disconnect, onPressed: _disconnect,
), ),

View File

@ -11,11 +11,13 @@ import 'package:solian/providers/call.dart';
class ChatCallPrejoinPopup extends StatefulWidget { class ChatCallPrejoinPopup extends StatefulWidget {
final Call ongoingCall; final Call ongoingCall;
final Channel channel; final Channel channel;
final Function onJoin;
const ChatCallPrejoinPopup({ const ChatCallPrejoinPopup({
super.key, super.key,
required this.ongoingCall, required this.ongoingCall,
required this.channel, required this.channel,
required this.onJoin,
}); });
@override @override
@ -25,22 +27,23 @@ class ChatCallPrejoinPopup extends StatefulWidget {
class _ChatCallPrejoinPopupState extends State<ChatCallPrejoinPopup> { class _ChatCallPrejoinPopupState extends State<ChatCallPrejoinPopup> {
bool _isBusy = false; bool _isBusy = false;
void performJoin() async { void _performJoin() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
final ChatCallProvider provider = Get.find(); final ChatCallProvider call = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
setState(() => _isBusy = true); setState(() => _isBusy = true);
provider.setCall(widget.ongoingCall, widget.channel); call.setCall(widget.ongoingCall, widget.channel);
call.isBusy.value = true;
try { try {
final resp = await provider.getRoomToken(); final resp = await call.getRoomToken();
final token = resp.$1; final token = resp.$1;
final endpoint = resp.$2; final endpoint = resp.$2;
provider.initRoom(); call.initRoom();
provider.setupRoomListeners( call.setupRoomListeners(
onDisconnected: (reason) { onDisconnected: (reason) {
context.showSnackbar( context.showSnackbar(
'callDisconnected'.trParams({'reason': reason.toString()}), 'callDisconnected'.trParams({'reason': reason.toString()}),
@ -48,23 +51,22 @@ class _ChatCallPrejoinPopupState extends State<ChatCallPrejoinPopup> {
}, },
); );
provider.joinRoom(endpoint, token); call.joinRoom(endpoint, token);
provider.gotoScreen(context).then((_) {
Navigator.pop(context); Navigator.pop(context);
});
} catch (e) { } catch (e) {
context.showErrorDialog(e); context.showErrorDialog(e);
} }
widget.onJoin();
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
@override @override
void initState() { void initState() {
final ChatCallProvider provider = Get.find(); final ChatCallProvider call = Get.find();
provider.checkPermissions().then((_) { call.checkPermissions().then((_) {
provider.initHardware(); call.initHardware();
}); });
super.initState(); super.initState();
@ -169,7 +171,7 @@ class _ChatCallPrejoinPopupState extends State<ChatCallPrejoinPopup> {
backgroundColor: backgroundColor:
Theme.of(context).colorScheme.primaryContainer, Theme.of(context).colorScheme.primaryContainer,
), ),
onPressed: _isBusy ? null : performJoin, onPressed: _isBusy ? null : _performJoin,
child: Text('callJoin'.tr), child: Text('callJoin'.tr),
), ),
], ],

View File

@ -7,10 +7,10 @@ class ChatCallCurrentIndicator extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ChatCallProvider provider = Get.find(); final ChatCallProvider call = Get.find();
return Obx(() { return Obx(() {
if (provider.current.value == null || provider.channel.value == null) { if (call.current.value == null || call.channel.value == null) {
return const SizedBox(); return const SizedBox();
} }
@ -18,11 +18,8 @@ class ChatCallCurrentIndicator extends StatelessWidget {
tileColor: Theme.of(context).colorScheme.surfaceContainerHigh, tileColor: Theme.of(context).colorScheme.surfaceContainerHigh,
contentPadding: const EdgeInsets.symmetric(horizontal: 32), contentPadding: const EdgeInsets.symmetric(horizontal: 32),
leading: const Icon(Icons.call), leading: const Icon(Icons.call),
title: Text(provider.channel.value!.name), title: Text(call.channel.value!.name),
subtitle: Text('callAlreadyOngoing'.tr), subtitle: Text('callAlreadyOngoing'.tr),
onTap: () {
provider.gotoScreen(context);
},
); );
}); });
} }

View File

@ -8,6 +8,7 @@ import 'package:solian/widgets/account/account_profile_popup.dart';
import 'package:solian/widgets/attachments/attachment_list.dart'; import 'package:solian/widgets/attachments/attachment_list.dart';
import 'package:solian/widgets/chat/chat_event_action_log.dart'; import 'package:solian/widgets/chat/chat_event_action_log.dart';
import 'package:solian/widgets/chat/chat_event_message.dart'; import 'package:solian/widgets/chat/chat_event_message.dart';
import 'package:solian/widgets/link_expansion.dart';
import 'package:timeago/timeago.dart' show format; import 'package:timeago/timeago.dart' show format;
class ChatEvent extends StatelessWidget { class ChatEvent extends StatelessWidget {
@ -37,10 +38,15 @@ class ChatEvent extends StatelessWidget {
return '$negativeSign${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds'; return '$negativeSign${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds';
} }
Widget _buildLinkExpansion() {
if (item.body['text'] == null) return const SizedBox();
return LinkExpansion(content: item.body['text']);
}
Widget _buildAttachment(BuildContext context, {bool isMinimal = false}) { Widget _buildAttachment(BuildContext context, {bool isMinimal = false}) {
final attachments = item.body['attachments'] != null final attachments = item.body['attachments'] != null
? List<int>.from(item.body['attachments'].map((x) => x)) ? List<String>.from(item.body['attachments']?.whereType<String>())
: List<int>.empty(); : List<String>.empty();
if (attachments.isEmpty) return const SizedBox(); if (attachments.isEmpty) return const SizedBox();
@ -67,7 +73,7 @@ class ChatEvent extends StatelessWidget {
return Container( return Container(
key: Key('m${item.uuid}attachments-box'), key: Key('m${item.uuid}attachments-box'),
width: MediaQuery.of(context).size.width, width: MediaQuery.of(context).size.width,
padding: EdgeInsets.only(top: isMerged ? 0 : 4), padding: EdgeInsets.only(top: isMerged ? 0 : 4, bottom: 4),
constraints: const BoxConstraints( constraints: const BoxConstraints(
maxHeight: 720, maxHeight: 720,
), ),
@ -75,7 +81,7 @@ class ChatEvent extends StatelessWidget {
key: Key('m${item.uuid}attachments'), key: Key('m${item.uuid}attachments'),
parentId: item.uuid, parentId: item.uuid,
attachmentsId: attachments, attachmentsId: attachments,
viewport: 1, isColumn: true,
), ),
); );
} }
@ -90,10 +96,13 @@ class ChatEvent extends StatelessWidget {
return const SizedBox(); return const SizedBox();
} }
return ChatEvent( return Container(
constraints: const BoxConstraints(maxWidth: 480),
child: ChatEvent(
item: snapshot.data!.data, item: snapshot.data!.data,
isMerged: false, isMerged: false,
isQuote: true, isQuote: true,
),
).paddingOnly(left: isMerged ? 52 : 0); ).paddingOnly(left: isMerged ? 52 : 0);
}, },
); );
@ -188,8 +197,9 @@ class ChatEvent extends StatelessWidget {
), ),
], ],
).paddingOnly(right: 12), ).paddingOnly(right: 12),
_buildAttachment(context, isMinimal: isContentPreviewing) _buildAttachment(context, isMinimal: isContentPreviewing).paddingOnly(
.paddingOnly(left: isContentPreviewing ? 12 : 0), left: isContentPreviewing ? 12 : 56,
),
], ],
); );
} else if (isQuote) { } else if (isQuote) {
@ -210,7 +220,9 @@ class ChatEvent extends StatelessWidget {
Row( Row(
children: [ children: [
AccountAvatar( AccountAvatar(
content: item.sender.account.avatar, radius: 9), content: item.sender.account.avatar,
radius: 9,
),
const SizedBox(width: 5), const SizedBox(width: 5),
Text( Text(
item.sender.account.nick, item.sender.account.nick,
@ -221,8 +233,7 @@ class ChatEvent extends StatelessWidget {
], ],
), ),
_buildContent().paddingOnly(left: 0.5), _buildContent().paddingOnly(left: 0.5),
_buildAttachment(context, isMinimal: true) _buildAttachment(context, isMinimal: true),
.paddingOnly(left: 0),
], ],
), ),
), ),
@ -231,6 +242,7 @@ class ChatEvent extends StatelessWidget {
).paddingOnly(left: isMerged ? 52 : 0, right: 4); ).paddingOnly(left: isMerged ? 52 : 0, right: 4);
} else { } else {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start,
key: Key('m${item.uuid}'), key: Key('m${item.uuid}'),
children: [ children: [
Row( Row(
@ -284,7 +296,8 @@ class ChatEvent extends StatelessWidget {
), ),
], ],
).paddingSymmetric(horizontal: 12), ).paddingSymmetric(horizontal: 12),
_buildAttachment(context), _buildLinkExpansion().paddingOnly(left: 52, right: 8),
_buildAttachment(context).paddingOnly(left: 56, right: 8),
], ],
); );
} }

View File

@ -1,5 +1,4 @@
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/controllers/chat_events_controller.dart'; import 'package:solian/controllers/chat_events_controller.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
@ -83,11 +82,6 @@ class ChatEventList extends StatelessWidget {
), ),
); );
}, },
).animate(key: Key('m-animation${item.uuid}')).slideY(
duration: 250.ms,
curve: Curves.fastEaseInToSlowEaseOut,
end: 0,
begin: 0.5,
); );
}, },
); );

View File

@ -59,7 +59,7 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
final TextEditingController _textController = TextEditingController(); final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
final List<int> _attachments = List.empty(growable: true); final List<String> _attachments = List.empty(growable: true);
Event? _editTo; Event? _editTo;
Event? _replyTo; Event? _replyTo;
@ -68,7 +68,7 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => AttachmentEditorPopup( builder: (context) => AttachmentEditorPopup(
usage: 'm.attachment', pool: 'messaging',
initialAttachments: _attachments, initialAttachments: _attachments,
onAdd: (value) { onAdd: (value) {
setState(() { setState(() {
@ -103,7 +103,7 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
final AttachmentUploaderController uploader = Get.find(); final AttachmentUploaderController uploader = Get.find();
if (uploader.queueOfUpload.any( if (uploader.queueOfUpload.any(
((x) => x.usage == 'm.attachment' && x.isUploading), ((x) => x.isUploading),
)) { )) {
context.showErrorDialog('attachmentUploadInProgress'.tr); context.showErrorDialog('attachmentUploadInProgress'.tr);
return; return;
@ -239,7 +239,7 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
var insertText = ''; var insertText = '';
if (suggestion.type == 'emotes') { if (suggestion.type == 'emotes') {
insertText = suggestion.content; insertText = '${suggestion.content} ';
startText = replaceText.replaceFirstMapped( startText = replaceText.replaceFirstMapped(
RegExp(r':(?:([-\w]+)~)?([-\w]+)$'), RegExp(r':(?:([-\w]+)~)?([-\w]+)$'),
(Match m) => insertText, (Match m) => insertText,
@ -247,7 +247,7 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
} }
if (suggestion.type == 'users') { if (suggestion.type == 'users') {
insertText = suggestion.content; insertText = '${suggestion.content} ';
startText = replaceText.replaceFirstMapped( startText = replaceText.replaceFirstMapped(
RegExp(r'(?:\s|^)@([-\w]+)$'), RegExp(r'(?:\s|^)@([-\w]+)$'),
(Match m) => insertText, (Match m) => insertText,

View File

@ -0,0 +1,124 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_svg/svg.dart';
import 'package:get/get.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/link_expander.dart';
import 'package:url_launcher/url_launcher_string.dart';
class LinkExpansion extends StatelessWidget {
final String content;
const LinkExpansion({super.key, required this.content});
Widget _buildImage(String url, {double? width, double? height}) {
if (url.endsWith('svg')) {
return SvgPicture.network(url, width: width, height: height);
}
return PlatformInfo.canCacheImage
? CachedNetworkImage(imageUrl: url, width: width, height: height)
: Image.network(url, width: width, height: height);
}
@override
Widget build(BuildContext context) {
final linkRegex = RegExp(
r'(?<!\()(?:(?:https?):\/\/|www\.)(?:[-_a-z0-9]+\.)*(?:[-a-z0-9]+\.[-a-z0-9]+)[^\s<]*[^\s<?!.,:*_~]',
);
final matches = linkRegex.allMatches(content);
if (matches.isEmpty) {
return const SizedBox();
}
final LinkExpandController expandController = Get.find();
return Wrap(
children: matches.map((x) {
return Container(
constraints: BoxConstraints(
maxWidth: matches.length == 1 ? 480 : 340,
),
child: FutureBuilder(
future: expandController.expandLink(x.group(0)!),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox();
}
final isRichDescription = [
'solsynth.dev',
].contains(Uri.parse(snapshot.data!.url).host);
return GestureDetector(
child: Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if ([
(snapshot.data!.icon?.isNotEmpty ?? false),
snapshot.data!.siteName != null
].any((x) => x))
Row(
children: [
if (snapshot.data!.icon?.isNotEmpty ?? false)
ClipRRect(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
child: _buildImage(
snapshot.data!.icon!,
width: 32,
height: 32,
),
).paddingOnly(right: 8),
if (snapshot.data!.siteName != null)
Text(
snapshot.data!.siteName!,
style: Theme.of(context).textTheme.labelLarge,
),
],
).paddingOnly(
bottom: (snapshot.data!.icon?.isNotEmpty ?? false)
? 8
: 4,
),
if (snapshot.data!.image != null &&
(snapshot.data!.image?.startsWith('http') ?? false))
ClipRRect(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
child: _buildImage(
snapshot.data!.image!,
),
).paddingOnly(bottom: 8),
Text(
snapshot.data!.title ?? 'No Title',
maxLines: 1,
overflow: TextOverflow.fade,
style: Theme.of(context).textTheme.bodyLarge,
),
if (snapshot.data!.description != null &&
isRichDescription)
MarkdownBody(data: snapshot.data!.description!)
else if (snapshot.data!.description != null)
Text(
snapshot.data!.description!,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
).paddingAll(12),
),
onTap: () {
launchUrlString(x.group(0)!);
},
);
},
),
);
}).toList(),
);
}
}

View File

@ -131,7 +131,7 @@ class MarkdownTextContent extends StatelessWidget {
child: AttachmentSelfContainedEntry( child: AttachmentSelfContainedEntry(
isDense: true, isDense: true,
parentId: parentId, parentId: parentId,
id: int.parse(segments[1]), rid: segments[1],
), ),
), ),
).paddingSymmetric(vertical: 4); ).paddingSymmetric(vertical: 4);

View File

@ -13,7 +13,7 @@ import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/account_status_action.dart'; import 'package:solian/widgets/account/account_status_action.dart';
import 'package:solian/widgets/navigation/app_navigation.dart'; import 'package:solian/widgets/navigation/app_navigation.dart';
import 'package:badges/badges.dart' as badges; import 'package:badges/badges.dart' as badges;
import 'package:solian/widgets/navigation/app_navigation_regions.dart'; import 'package:solian/widgets/navigation/app_navigation_region.dart';
class AppNavigationDrawer extends StatefulWidget { class AppNavigationDrawer extends StatefulWidget {
final String? routeName; final String? routeName;
@ -24,7 +24,23 @@ class AppNavigationDrawer extends StatefulWidget {
State<AppNavigationDrawer> createState() => _AppNavigationDrawerState(); State<AppNavigationDrawer> createState() => _AppNavigationDrawerState();
} }
class _AppNavigationDrawerState extends State<AppNavigationDrawer> { class _AppNavigationDrawerState extends State<AppNavigationDrawer>
with TickerProviderStateMixin {
bool _isCollapsed = false;
late final AnimationController _drawerAnimationController =
AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
late final Animation<double> _drawerAnimation = Tween<double>(
begin: 80.0,
end: 304.0,
).animate(CurvedAnimation(
parent: _drawerAnimationController,
curve: Curves.easeInOut,
));
AccountStatus? _accountStatus; AccountStatus? _accountStatus;
Future<void> _getStatus() async { Future<void> _getStatus() async {
@ -33,39 +49,26 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
final resp = await provider.getCurrentStatus(); final resp = await provider.getCurrentStatus();
final status = AccountStatus.fromJson(resp.body); final status = AccountStatus.fromJson(resp.body);
if (mounted) {
setState(() { setState(() {
_accountStatus = status; _accountStatus = status;
}); });
} }
void _closeDrawer() {
rootScaffoldKey.currentState!.closeDrawer();
} }
@override Color get _unFocusColor =>
void initState() { Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
super.initState();
_getStatus();
}
@override Widget _buildUserInfo() {
Widget build(BuildContext context) { return Obx(() {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
return Drawer(
backgroundColor:
SolianTheme.isLargeScreen(context) ? Colors.transparent : null,
child: SafeArea(
bottom: false,
child: Column(
children: [
Obx(() {
if (auth.isAuthorized.isFalse || auth.userProfile.value == null) { if (auth.isAuthorized.isFalse || auth.userProfile.value == null) {
return ListTile( if (_isCollapsed) {
contentPadding: const EdgeInsets.symmetric(horizontal: 28), return InkWell(
leading: const Icon(Icons.account_circle), child: const Icon(Icons.account_circle).paddingSymmetric(
title: Text('guest'.tr), horizontal: 28,
subtitle: Text('unsignedIn'.tr), vertical: 20,
),
onTap: () { onTap: () {
AppRouter.instance.goNamed('account'); AppRouter.instance.goNamed('account');
_closeDrawer(); _closeDrawer();
@ -74,37 +77,24 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
} }
return ListTile( return ListTile(
contentPadding: const EdgeInsets.only(left: 20, right: 20), contentPadding: const EdgeInsets.symmetric(horizontal: 28),
title: Text( leading: const Icon(Icons.account_circle),
auth.userProfile.value!['nick'], title: !_isCollapsed ? Text('guest'.tr) : null,
maxLines: 1, subtitle: !_isCollapsed ? Text('unsignedIn'.tr) : null,
overflow: TextOverflow.fade, onTap: () {
), AppRouter.instance.goNamed('account');
subtitle: Builder( _closeDrawer();
builder: (context) {
if (_accountStatus == null) {
return Text('loading'.tr);
}
final info = StatusProvider.determineStatus(
_accountStatus!,
);
return Text(
info.$3,
maxLines: 1,
overflow: TextOverflow.fade,
);
}, },
), );
leading: Obx(() { }
final leading = Obx(() {
final statusBadgeColor = _accountStatus != null final statusBadgeColor = _accountStatus != null
? StatusProvider.determineStatus( ? StatusProvider.determineStatus(_accountStatus!).$2
_accountStatus!,
).$2
: Colors.grey; : Colors.grey;
final RelationshipProvider relations = Get.find(); final RelationshipProvider relations = Get.find();
final accountNotifications = final accountNotifications = relations.friendRequestCount.value;
relations.friendRequestCount.value;
return badges.Badge( return badges.Badge(
badgeContent: Text( badgeContent: Text(
@ -118,8 +108,7 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
), ),
child: badges.Badge( child: badges.Badge(
showBadge: _accountStatus != null, showBadge: _accountStatus != null,
badgeStyle: badgeStyle: badges.BadgeStyle(badgeColor: statusBadgeColor),
badges.BadgeStyle(badgeColor: statusBadgeColor),
position: badges.BadgePosition.bottomEnd( position: badges.BadgePosition.bottomEnd(
bottom: 0, bottom: 0,
end: -2, end: -2,
@ -129,7 +118,47 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
), ),
), ),
); );
}), });
return InkWell(
child: !_isCollapsed
? Row(
children: [
leading,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
auth.userProfile.value!['nick'],
maxLines: 1,
overflow: TextOverflow.fade,
style: Theme.of(context).textTheme.bodyLarge,
).paddingOnly(left: 16),
Builder(
builder: (context) {
if (_accountStatus == null) {
return Text('loading'.tr).paddingOnly(left: 16);
}
final info = StatusProvider.determineStatus(
_accountStatus!,
);
return Text(
info.$3,
maxLines: 1,
overflow: TextOverflow.fade,
style: TextStyle(
color: _unFocusColor,
),
).paddingOnly(left: 16);
},
),
],
),
),
],
).paddingSymmetric(horizontal: 20, vertical: 16)
: leading.paddingSymmetric(horizontal: 20, vertical: 16),
onTap: () { onTap: () {
AppRouter.instance.goNamed('account'); AppRouter.instance.goNamed('account');
_closeDrawer(); _closeDrawer();
@ -146,17 +175,96 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
}); });
}, },
); );
}).paddingSymmetric(vertical: 8), });
}
void _expandDrawer() {
_drawerAnimationController.animateTo(1);
}
void _collapseDrawer() {
_drawerAnimationController.animateTo(0);
}
void _closeDrawer() {
_autoResize();
rootScaffoldKey.currentState!.closeDrawer();
}
void _autoResize() {
if (SolianTheme.isExtraLargeScreen(context)) {
_expandDrawer();
} else if (SolianTheme.isLargeScreen(context)) {
_collapseDrawer();
} else {
_drawerAnimationController.animateTo(
1,
duration: const Duration(milliseconds: 100),
);
}
}
@override
void initState() {
super.initState();
_getStatus();
Future.delayed(Duration.zero, () => _autoResize());
_drawerAnimationController.addListener(() {
if (_drawerAnimation.value > 180 && _isCollapsed) {
setState(() => _isCollapsed = false);
} else if (_drawerAnimation.value < 180 && !_isCollapsed) {
setState(() => _isCollapsed = true);
}
});
}
@override
void dispose() {
_drawerAnimationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _drawerAnimation,
builder: (context, child) {
return Drawer(
width: _drawerAnimation.value,
backgroundColor:
SolianTheme.isLargeScreen(context) ? Colors.transparent : null,
child: child,
);
},
child: SafeArea(
bottom: false,
child: Column(
children: [
_buildUserInfo().paddingSymmetric(vertical: 8),
const Divider(thickness: 0.3, height: 1), const Divider(thickness: 0.3, height: 1),
Column( Column(
children: AppNavigation.destinations children: AppNavigation.destinations
.map( .map(
(e) => ListTile( (e) => _isCollapsed
? Tooltip(
message: e.label,
child: InkWell(
child: Icon(e.icon, size: 20).paddingSymmetric(
horizontal: 28,
vertical: 16,
),
onTap: () {
AppRouter.instance.goNamed(e.page);
_closeDrawer();
},
),
)
: ListTile(
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 20, horizontal: 20,
), ),
leading: Icon(e.icon, size: 20).paddingAll(2), leading: Icon(e.icon, size: 20).paddingAll(2),
title: Text(e.label), title: !_isCollapsed ? Text(e.label) : null,
enabled: true, enabled: true,
onTap: () { onTap: () {
AppRouter.instance.goNamed(e.page); AppRouter.instance.goNamed(e.page);
@ -165,10 +273,11 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
), ),
) )
.toList(), .toList(),
).paddingSymmetric(vertical: 8), ),
const Divider(thickness: 0.3, height: 1), const Divider(thickness: 0.3, height: 1),
Expanded( Expanded(
child: AppNavigationRegions( child: AppNavigationRegion(
isCollapsed: _isCollapsed,
onSelected: (item) { onSelected: (item) {
_closeDrawer(); _closeDrawer();
}, },
@ -177,6 +286,24 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
const Divider(thickness: 0.3, height: 1), const Divider(thickness: 0.3, height: 1),
Column( Column(
children: [ children: [
if (_isCollapsed)
Tooltip(
message: 'settings'.tr,
child: InkWell(
child: const Icon(
Icons.settings,
size: 20,
).paddingSymmetric(
horizontal: 28,
vertical: 10,
),
onTap: () {
AppRouter.instance.pushNamed('settings');
_closeDrawer();
},
),
)
else
ListTile( ListTile(
minTileHeight: 0, minTileHeight: 0,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
@ -189,6 +316,33 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
_closeDrawer(); _closeDrawer();
}, },
), ),
if (_isCollapsed)
Tooltip(
message: 'expand'.tr,
child: InkWell(
child: const Icon(Icons.chevron_right, size: 20)
.paddingSymmetric(
horizontal: 28,
vertical: 10,
),
onTap: () {
_expandDrawer();
},
),
)
else
ListTile(
minTileHeight: 0,
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
),
leading:
const Icon(Icons.chevron_left, size: 20).paddingAll(2),
title: Text('collapse'.tr),
onTap: () {
_collapseDrawer();
},
),
], ],
).paddingOnly( ).paddingOnly(
top: 8, top: 8,

View File

@ -5,10 +5,15 @@ import 'package:solian/providers/content/channel.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
class AppNavigationRegions extends StatelessWidget { class AppNavigationRegion extends StatelessWidget {
final bool isCollapsed;
final Function(Channel item) onSelected; final Function(Channel item) onSelected;
const AppNavigationRegions({super.key, required this.onSelected}); const AppNavigationRegion({
super.key,
required this.onSelected,
this.isCollapsed = false,
});
void _gotoChannel(Channel item) { void _gotoChannel(Channel item) {
AppRouter.instance.pushReplacementNamed( AppRouter.instance.pushReplacementNamed(
@ -25,6 +30,16 @@ class AppNavigationRegions extends StatelessWidget {
Widget _buildEntry(BuildContext context, Channel item) { Widget _buildEntry(BuildContext context, Channel item) {
const padding = EdgeInsets.symmetric(horizontal: 20); const padding = EdgeInsets.symmetric(horizontal: 20);
if (isCollapsed) {
return InkWell(
child: const Icon(Icons.tag_outlined, size: 20).paddingSymmetric(
horizontal: 20,
vertical: 16,
),
onTap: () => _gotoChannel(item),
);
}
return ListTile( return ListTile(
minTileHeight: 0, minTileHeight: 0,
leading: const Icon(Icons.tag_outlined), leading: const Icon(Icons.tag_outlined),
@ -51,6 +66,27 @@ class AppNavigationRegions extends StatelessWidget {
.where((x) => x.type == 0 && x.realmId != null) .where((x) => x.type == 0 && x.realmId != null)
.toList(); .toList();
if (isCollapsed) {
return CustomScrollView(
slivers: [
const SliverPadding(padding: EdgeInsets.only(top: 8)),
SliverList.builder(
itemCount:
noRealmGroupChannels.length + hasRealmGroupChannels.length,
itemBuilder: (context, index) {
final element = index >= noRealmGroupChannels.length
? hasRealmGroupChannels[index - noRealmGroupChannels.length]
: noRealmGroupChannels[index];
return Tooltip(
message: element.name,
child: _buildEntry(context, element),
);
},
),
],
);
}
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
const SliverPadding(padding: EdgeInsets.only(top: 8)), const SliverPadding(padding: EdgeInsets.only(top: 8)),

View File

@ -19,7 +19,7 @@ class PostEditorCategoriesDialog extends StatelessWidget {
initialTags: controller.tags, initialTags: controller.tags,
hintText: 'postTagsPlaceholder'.tr, hintText: 'postTagsPlaceholder'.tr,
onUpdate: (value) { onUpdate: (value) {
controller.tags.value = value; controller.tags.value = List.from(value, growable: true);
controller.tags.refresh(); controller.tags.refresh();
}, },
), ),

View File

@ -14,12 +14,25 @@ class PostEditorOverviewDialog extends StatelessWidget {
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextField(
autofocus: true,
autocorrect: true,
controller: controller.aliasController,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
hintText: 'alias'.tr,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextField( TextField(
autofocus: true, autofocus: true,
autocorrect: true, autocorrect: true,
controller: controller.titleController, controller: controller.titleController,
decoration: InputDecoration( decoration: InputDecoration(
border: const UnderlineInputBorder(), isDense: true,
border: const OutlineInputBorder(),
hintText: 'title'.tr, hintText: 'title'.tr,
), ),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
@ -33,7 +46,8 @@ class PostEditorOverviewDialog extends StatelessWidget {
keyboardType: TextInputType.multiline, keyboardType: TextInputType.multiline,
controller: controller.descriptionController, controller: controller.descriptionController,
decoration: InputDecoration( decoration: InputDecoration(
border: const UnderlineInputBorder(), isDense: true,
border: const OutlineInputBorder(),
hintText: 'description'.tr, hintText: 'description'.tr,
), ),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),

View File

@ -20,7 +20,7 @@ class _PostEditorThumbnailDialogState extends State<PostEditorThumbnailDialog> {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => AttachmentEditorPopup( builder: (context) => AttachmentEditorPopup(
usage: 'i.attachment', pool: 'interactive',
singleMode: true, singleMode: true,
imageOnly: true, imageOnly: true,
autoUpload: true, autoUpload: true,
@ -84,8 +84,7 @@ class _PostEditorThumbnailDialogState extends State<PostEditorThumbnailDialog> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
widget.controller.thumbnail.value = widget.controller.thumbnail.value = _attachmentController.text;
int.tryParse(_attachmentController.text);
Navigator.pop(context); Navigator.pop(context);
}, },
child: Text('confirm'.tr), child: Text('confirm'.tr),

View File

@ -42,24 +42,30 @@ class _PostActionState extends State<PostAction> {
Future<void> _doShare({bool noUri = false}) async { Future<void> _doShare({bool noUri = false}) async {
ShareResult result; ShareResult result;
String id;
final box = context.findRenderObject() as RenderBox?; final box = context.findRenderObject() as RenderBox?;
if (widget.item.alias?.isNotEmpty ?? false) {
id = '${widget.item.areaAlias}/${widget.item.alias}';
} else {
id = '${widget.item.id}';
}
if ((PlatformInfo.isAndroid || PlatformInfo.isIOS) && !noUri) { if ((PlatformInfo.isAndroid || PlatformInfo.isIOS) && !noUri) {
result = await Share.shareUri( result = await Share.shareUri(
Uri.parse('https://solsynth.dev/posts/${widget.item.id}'), Uri.parse('https://solsynth.dev/posts/$id'),
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
); );
} else { } else {
final extraContent = [ final extraContent = <String?>[
widget.item.body['title'], widget.item.body['title'],
widget.item.body['description'], widget.item.body['description'],
]; ].where((x) => x != null && x.isNotEmpty).toList();
final isExtraNotEmpty = extraContent.any((x) => x != null); final isExtraNotEmpty = extraContent.any((x) => x != null);
result = await Share.share( result = await Share.share(
'postShareContent'.trParams({ 'postShareContent'.trParams({
'username': widget.item.author.nick, 'username': widget.item.author.nick,
'content': 'content':
'${extraContent.join('\n')}${isExtraNotEmpty ? '\n\n' : ''}${widget.item.body['content'] ?? 'no content'}', '${extraContent.join('\n')}${isExtraNotEmpty ? '\n\n' : ''}${widget.item.body['content'] ?? 'no content'}',
'link': 'https://solsynth.dev/posts/${widget.item.id}', 'link': 'https://solsynth.dev/posts/$id',
}), }),
subject: 'postShareSubject'.trParams({ subject: 'postShareSubject'.trParams({
'username': widget.item.author.nick, 'username': widget.item.author.nick,
@ -96,10 +102,28 @@ class _PostActionState extends State<PostAction> {
'postActionList'.tr, 'postActionList'.tr,
style: Theme.of(context).textTheme.headlineSmall, style: Theme.of(context).textTheme.headlineSmall,
), ),
Row(
children: [
Text( Text(
'#${widget.item.id.toString().padLeft(8, '0')}', '#${widget.item.id.toString().padLeft(8, '0')}',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
if (widget.item.alias?.isNotEmpty ?? false)
Text(
'·',
style: Theme.of(context).textTheme.bodySmall,
).paddingSymmetric(horizontal: 6),
if (widget.item.alias?.isNotEmpty ?? false)
Expanded(
child: Text(
'${widget.item.areaAlias}:${widget.item.alias}',
style: Theme.of(context).textTheme.bodySmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
], ],
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), if (_isBusy) const LinearProgressIndicator().animate().scaleX(),

View File

@ -10,6 +10,7 @@ import 'package:solian/shells/title_shell.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/account_profile_popup.dart'; import 'package:solian/widgets/account/account_profile_popup.dart';
import 'package:solian/widgets/attachments/attachment_list.dart'; import 'package:solian/widgets/attachments/attachment_list.dart';
import 'package:solian/widgets/link_expansion.dart';
import 'package:solian/widgets/markdown_text_content.dart'; import 'package:solian/widgets/markdown_text_content.dart';
import 'package:solian/widgets/posts/post_tags.dart'; import 'package:solian/widgets/posts/post_tags.dart';
import 'package:solian/widgets/posts/post_quick_action.dart'; import 'package:solian/widgets/posts/post_quick_action.dart';
@ -78,25 +79,19 @@ class _PostItemState extends State<PostItem> {
Widget _buildThumbnail() { Widget _buildThumbnail() {
if (widget.item.body['thumbnail'] == null) return const SizedBox(); if (widget.item.body['thumbnail'] == null) return const SizedBox();
const radius = BorderRadius.all(Radius.circular(8)); final border = BorderSide(
return AspectRatio(
aspectRatio: 16 / 9,
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor, color: Theme.of(context).dividerColor,
width: 0.3, width: 0.3,
), );
borderRadius: radius, return Container(
), decoration: BoxDecoration(border: Border(top: border, bottom: border)),
child: ClipRRect( child: AspectRatio(
borderRadius: radius, aspectRatio: 16 / 9,
child: AttachmentSelfContainedEntry( child: AttachmentSelfContainedEntry(
id: widget.item.body['thumbnail'], rid: widget.item.body['thumbnail'],
parentId: 'p${item.id}-thumbnail', parentId: 'p${item.id}-thumbnail',
), ),
), ),
),
); );
} }
@ -294,12 +289,41 @@ class _PostItemState extends State<PostItem> {
); );
} }
Widget _buildAttachments() {
final List<String> attachments = item.body['attachments'] is List
? List.from(item.body['attachments']?.whereType<String>())
: List.empty();
if (attachments.length > 3) {
return AttachmentList(
parentId: widget.item.id.toString(),
attachmentsId: attachments,
autoload: true,
isGrid: true,
).paddingOnly(left: 36, top: 4, bottom: 4);
} else if (attachments.length > 1) {
return AttachmentList(
parentId: widget.item.id.toString(),
attachmentsId: attachments,
autoload: true,
isColumn: true,
).paddingOnly(left: 60, right: 24);
} else {
return AttachmentList(
flatMaxHeight: MediaQuery.of(context).size.width,
parentId: widget.item.id.toString(),
attachmentsId: attachments,
autoload: true,
);
}
}
double _contentHeight = 0; double _contentHeight = 0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final List<int> attachments = item.body['attachments'] is List final List<String> attachments = item.body['attachments'] is List
? item.body['attachments']?.cast<int>() ? List.from(item.body['attachments']?.whereType<String>())
: List.empty(); : List.empty();
final hasAttachment = attachments.isNotEmpty; final hasAttachment = attachments.isNotEmpty;
@ -307,7 +331,7 @@ class _PostItemState extends State<PostItem> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildThumbnail().paddingSymmetric(horizontal: 12, vertical: 4), _buildThumbnail().paddingOnly(bottom: 8),
_buildHeader().paddingSymmetric(horizontal: 12), _buildHeader().paddingSymmetric(horizontal: 12),
_buildHeaderDivider().paddingSymmetric(horizontal: 12), _buildHeaderDivider().paddingSymmetric(horizontal: 12),
Stack( Stack(
@ -355,6 +379,11 @@ class _PostItemState extends State<PostItem> {
), ),
], ],
), ),
LinkExpansion(content: item.body['content']).paddingOnly(
left: 8,
right: 8,
top: 4,
),
_buildFooter().paddingOnly(left: 16), _buildFooter().paddingOnly(left: 16),
if (attachments.isNotEmpty) if (attachments.isNotEmpty)
Row( Row(
@ -381,7 +410,7 @@ class _PostItemState extends State<PostItem> {
closedBuilder: (_, openContainer) => Column( closedBuilder: (_, openContainer) => Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildThumbnail().paddingSymmetric(horizontal: 12, vertical: 4), _buildThumbnail().paddingOnly(bottom: 4),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -447,27 +476,31 @@ class _PostItemState extends State<PostItem> {
], ],
), ),
if (widget.item.replyTo != null && widget.isShowEmbed) if (widget.item.replyTo != null && widget.isShowEmbed)
_buildReply(context).paddingOnly(top: 4), Container(
constraints: const BoxConstraints(maxWidth: 480),
padding: const EdgeInsets.only(top: 4),
child: _buildReply(context),
),
if (widget.item.repostTo != null && widget.isShowEmbed) if (widget.item.repostTo != null && widget.isShowEmbed)
_buildRepost(context).paddingOnly(top: 4), Container(
constraints: const BoxConstraints(maxWidth: 480),
padding: const EdgeInsets.only(top: 4),
child: _buildRepost(context),
),
_buildFooter().paddingOnly(left: 12), _buildFooter().paddingOnly(left: 12),
LinkExpansion(content: item.body['content'])
.paddingOnly(top: 4),
], ],
), ),
), ),
], ],
).paddingOnly( ).paddingOnly(
top: 10, top: 10,
bottom: hasAttachment ? 10 : 0, bottom: attachments.length == 1 ? 10 : 0,
right: 16, right: 16,
left: 16, left: 16,
), ),
AttachmentList( _buildAttachments(),
flatMaxHeight: MediaQuery.of(context).size.width,
parentId: widget.item.id.toString(),
attachmentsId: attachments,
autoload: true,
isGrid: attachments.length > 1,
),
if (widget.isShowReply || widget.isReactable) if (widget.isShowReply || widget.isReactable)
PostQuickAction( PostQuickAction(
isShowReply: widget.isShowReply, isShowReply: widget.isShowReply,
@ -480,8 +513,8 @@ class _PostItemState extends State<PostItem> {
}); });
}, },
).paddingOnly( ).paddingOnly(
top: hasAttachment ? 10 : 6, top: attachments.length == 1 ? 10 : 6,
left: hasAttachment ? 24 : 60, left: attachments.length == 1 ? 24 : 60,
right: 16, right: 16,
bottom: 10, bottom: 10,
) )

View File

@ -29,7 +29,7 @@ class _StickerUploadDialogState extends State<StickerUploadDialog> {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => AttachmentEditorPopup( builder: (context) => AttachmentEditorPopup(
usage: 'sticker', pool: 'sticker',
singleMode: true, singleMode: true,
imageOnly: true, imageOnly: true,
autoUpload: true, autoUpload: true,

View File

@ -263,7 +263,7 @@ packages:
source: hosted source: hosted
version: "3.1.1" version: "3.1.1"
cross_file: cross_file:
dependency: transitive dependency: "direct main"
description: description:
name: cross_file name: cross_file
sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670"
@ -534,6 +534,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.8.12" version: "3.8.12"
firebase_performance:
dependency: "direct main"
description:
name: firebase_performance
sha256: "6d17133458b9627f15f278d6f71bebbbce885d393f3462b690e55deeb5c36b90"
url: "https://pub.dev"
source: hosted
version: "0.10.0+4"
firebase_performance_platform_interface:
dependency: transitive
description:
name: firebase_performance_platform_interface
sha256: "28dc0a70a3459fe51d1c1be5754803a9a0db0e210322ec7526f6ce42bf6ad83e"
url: "https://pub.dev"
source: hosted
version: "0.1.4+40"
firebase_performance_web:
dependency: transitive
description:
name: firebase_performance_web
sha256: db91d86b34280f5253d2913945fdd51d7114486584a298a7bedf1c4b2ab08f79
url: "https://pub.dev"
source: hosted
version: "0.1.6+12"
fixnum: fixnum:
dependency: transitive dependency: transitive
description: description:
@ -763,6 +787,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.0" version: "0.7.0"
flutter_svg:
dependency: "direct main"
description:
name: flutter_svg
sha256: "7b4ca6cf3304575fe9c8ec64813c8d02ee41d2afe60bcfe0678bcb5375d596a2"
url: "https://pub.dev"
source: hosted
version: "2.0.10+1"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -1269,6 +1301,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.0" version: "1.9.0"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: e3e67b1629e6f7e8100b367d3db6ba6af4b1f0bb80f64db18ef1fbabd2fa9ccf
url: "https://pub.dev"
source: hosted
version: "1.0.1"
path_provider: path_provider:
dependency: transitive dependency: transitive
description: description:
@ -1734,10 +1774,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: sqflite_common name: sqflite_common
sha256: c5e5b2a142a893a752cb36ae5888680248686725a54afceff31f9a3a76bc53c2 sha256: "7b41b6c3507854a159e24ae90a8e3e9cc01eb26a477c118d6dca065b5f55453e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.4+1" version: "2.5.4+2"
sqflite_common_ffi: sqflite_common_ffi:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -1954,6 +1994,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.4.2" version: "4.4.2"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "32c3c684e02f9bc0afb0ae0aa653337a2fe022e8ab064bcd7ffda27a74e288e3"
url: "https://pub.dev"
source: hosted
version: "1.1.11+1"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: c86987475f162fadff579e7320c7ddda04cd2fdeffbe1129227a85d9ac9e03da
url: "https://pub.dev"
source: hosted
version: "1.1.11+1"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "12faff3f73b1741a36ca7e31b292ddeb629af819ca9efe9953b70bd63fc8cd81"
url: "https://pub.dev"
source: hosted
version: "1.1.11+1"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:

View File

@ -2,7 +2,7 @@ name: solian
description: "The Solar Network App" description: "The Solar Network App"
publish_to: "none" publish_to: "none"
version: 1.2.1+14 version: 1.2.1+22
environment: environment:
sdk: ">=3.3.4 <4.0.0" sdk: ">=3.3.4 <4.0.0"
@ -45,9 +45,6 @@ dependencies:
sqflite: ^2.3.3+1 sqflite: ^2.3.3+1
protocol_handler: ^0.2.0 protocol_handler: ^0.2.0
markdown: ^7.2.2 markdown: ^7.2.2
media_kit: ^1.1.10+1
media_kit_video: ^1.2.4
media_kit_libs_video: ^1.0.4
pasteboard: ^0.2.0 pasteboard: ^0.2.0
desktop_drop: ^0.4.4 desktop_drop: ^0.4.4
badges: ^3.1.2 badges: ^3.1.2
@ -70,6 +67,12 @@ dependencies:
collection: ^1.18.0 collection: ^1.18.0
firebase_crashlytics: ^4.0.4 firebase_crashlytics: ^4.0.4
firebase_analytics: ^11.2.1 firebase_analytics: ^11.2.1
firebase_performance: ^0.10.0+4
media_kit: ^1.1.10+1
media_kit_video: ^1.2.4
media_kit_libs_video: ^1.0.4
flutter_svg: ^2.0.10+1
cross_file: ^0.3.4+2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: