Compare commits

..

58 Commits

Author SHA1 Message Date
d46d584ff3 🐛 Bug fixes and optimization 2024-05-13 22:52:41 +08:00
f43f9e91f6 Better websocket reconnection and maintainer 2024-05-13 22:12:37 +08:00
b9461e5019 💄 Optimize connection experience 2024-05-12 22:29:38 +08:00
8e0e2dacfe 🐛 Fix connection issue 2024-05-12 22:17:32 +08:00
b4d1d62e9b 🚑 Fix services url issue 2024-05-12 21:27:55 +08:00
6f7ae4467c 🐛 Bug fixes and optimization 2024-05-12 20:59:33 +08:00
98547708af E2EE and Keypair 2024-05-12 20:15:12 +08:00
08d0a99b10 🐛 Bug fixes 2024-05-11 23:22:35 +08:00
5ce6543275 Message view source 2024-05-10 23:42:59 +08:00
40aa16e971 ⬆️ Support some new server stuff 2024-05-10 23:17:01 +08:00
c1d3bac0c8 🐛 Fix several known bugs 2024-05-08 22:01:06 +08:00
a4f8c65aa5 💄 Realm shortcut 2024-05-07 23:38:12 +08:00
3bcdc67285 ♻️ Better chat connection method 2024-05-07 23:07:51 +08:00
dffa0077de 💄 Better user send message experience 2024-05-07 21:49:24 +08:00
b3e266d564 🐛 Bug fixes on realm missing post editor 2024-05-07 13:06:26 +08:00
0c87bbbce1 🐛 Bug fixes on realm UI 2024-05-06 23:36:54 +08:00
ae4d9cf81a Complete the realm system 2024-05-06 23:30:30 +08:00
22c2a80650 Complete the realm system 2024-05-06 20:57:52 +08:00
0b9439262c Realm members & edit 2024-05-05 23:51:46 +08:00
384d861d56 Realm basis 2024-05-05 23:01:08 +08:00
cf0d473a40 ⬆️ Support newer version of server messaging 2024-05-05 01:53:31 +08:00
efc46dbbc5 🐛 Bug fixes 2024-05-03 23:38:51 +08:00
f96ca899b5 Personal page 2024-05-03 18:20:32 +08:00
083975bcd9 Batch mark notify as read 2024-05-03 16:35:28 +08:00
740c704fb8 Can edit avatar and banner 2024-05-03 16:16:42 +08:00
e742338d92 🐛 Bug fixes of scaffold 2024-05-03 14:04:34 +08:00
d179d907ad 💄 Better large screen optimization! 2024-05-03 13:52:41 +08:00
c0680a3134 🎨 Optimized code structure, rename a lot of widgets
💄 Better large screen support in chat
2024-05-03 13:39:52 +08:00
e080f49935 Cached network images 2024-05-03 12:15:54 +08:00
9df4aba56c 💄 Animated chat history 2024-05-03 01:55:12 +08:00
ec69b34877 Better attachments upload, and supports files 2024-05-03 00:09:33 +08:00
9f6942a8cb 🐛 Dispose current call when hang up 2024-05-02 21:36:23 +08:00
1a5faabf86 🐛 Bug fixes of infinite reloading 2024-05-02 21:19:37 +08:00
e5a4554bdd 🐛 Bug fixes of bug fixes 2024-05-02 20:52:20 +08:00
7e4fa47d00 🐛 Bug fixes 2024-05-02 20:45:24 +08:00
7633619edd Basis personalize 2024-05-02 18:56:40 +08:00
52c09151a6 Account page two pane 2024-05-02 12:51:16 +08:00
3089e1f8d2 Rewrite http client 2024-05-02 12:16:01 +08:00
d968169e42 🐛 Bug fixes 2024-05-02 01:38:45 +08:00
b39c8c770e Basic large screen support 2024-05-02 00:49:38 +08:00
fceb3edbc6 Dark mode 2024-05-01 22:35:58 +08:00
7c4427e84a 💄 Optimize UX 2024-05-01 19:39:48 +08:00
fd200105c0 Sign up screen 2024-05-01 18:10:57 +08:00
cd5cfedb2f 🎨 Formatted all the code 2024-05-01 17:37:34 +08:00
28c0094837 Better to solve multi-factor authenticate 2024-05-01 11:27:48 +08:00
5d79692766 🐛 Bug fixes of fixed avatar 2024-04-30 20:37:08 +08:00
7fb94eeafa Floating call widgets 2024-04-30 20:31:54 +08:00
5922d325e5 🐛 Bug fixes 2024-04-29 20:32:51 +08:00
e665b44507 Optimized UI of call actions 2024-04-28 23:05:59 +08:00
1e4fda7daa 🐛 Fix add reaction to non-reaction post cause infinite loading 2024-04-28 22:02:25 +08:00
ad10084850 Real-time local notify 2024-04-28 21:49:03 +08:00
db45764d42 🐛 Bug fixes of sort participants 2024-04-28 19:36:06 +08:00
0483e99a4c 🐛 Bug fixes of duplication 2024-04-28 00:21:16 +08:00
155f7c7999 🐛 Fix colors difference 2024-04-28 00:10:20 +08:00
541df5c3bc 💄 Optimized voice chat 2024-04-28 00:07:32 +08:00
34dee3773d 🐛 Bug fixes on macos 2024-04-27 20:10:15 +08:00
657b497370 💄 Hide stats layer in VC 2024-04-27 14:17:18 +08:00
ff089f26eb 🐛 Bug fixes of webrtc 2024-04-27 14:14:10 +08:00
123 changed files with 7127 additions and 2878 deletions

View File

@ -21,8 +21,9 @@ linter:
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
lines_longer_than_80_chars: false
avoid_print: false # Uncomment to disable the `avoid_print` rule
prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View File

@ -51,6 +51,10 @@ android {
buildTypes {
release {
signingConfig signingConfigs.debug
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}

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

@ -0,0 +1,12 @@
## Flutter wrapper
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.** { *; }
-keep class io.flutter.util.** { *; }
-keep class io.flutter.view.** { *; }
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }
-dontwarn io.flutter.embedding.**
## Flutter WebRTC
-keep class com.cloudwebrtc.webrtc.** { *; }
-keep class org.webrtc.** { *; }

View File

@ -15,19 +15,28 @@
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<application>
...
<service
android:name="de.julianassmann.flutter_background.IsolateHolderService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
</application>
<application
android:label="Solian"
android:name="${applicationName}"
android:icon="@mipmap/launcher_icon">
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
</intent-filter>
</receiver>
<service
android:name="de.julianassmann.flutter_background.IsolateHolderService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<activity
android:name=".MainActivity"
android:exported="true"

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@ -20,7 +20,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "7.3.0" apply false
id "org.jetbrains.kotlin.android" version "1.7.10" apply false
id "org.jetbrains.kotlin.android" version "1.9.23" apply false
}
include ":app"

View File

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

View File

@ -1,9 +1,56 @@
PODS:
- connectivity_plus (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.8):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.8)
- DKImagePickerController/PhotoGallery (4.3.8):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.8)
- DKPhotoGallery (0.0.17):
- DKPhotoGallery/Core (= 0.0.17)
- DKPhotoGallery/Model (= 0.0.17)
- DKPhotoGallery/Preview (= 0.0.17)
- DKPhotoGallery/Resource (= 0.0.17)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.17):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.17):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.17):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.17):
- SDWebImage
- SwiftyGif
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Flutter (1.0.0)
- flutter_local_notifications (0.0.1):
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- flutter_webrtc (0.9.36):
- Flutter
- WebRTC-SDK (= 114.5735.09)
- image_picker_ios (0.0.1):
- Flutter
- livekit_client (2.1.3):
- Flutter
- WebRTC-SDK (= 114.5735.09)
- media_kit_libs_ios_video (1.0.4):
- Flutter
- media_kit_native_event_loop (1.0.0):
@ -15,39 +62,77 @@ PODS:
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- screen_brightness_ios (0.1.0):
- Flutter
- SDWebImage (5.19.1):
- SDWebImage/Core (= 5.19.1)
- SDWebImage/Core (5.19.1)
- sqflite (0.0.3):
- Flutter
- FlutterMacOS
- SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1):
- Flutter
- volume_controller (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
- WebRTC-SDK (114.5735.09)
- webview_flutter_wkwebview (0.0.1):
- Flutter
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- livekit_client (from `.symlinks/plugins/livekit_client/ios`)
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
- media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- volume_controller (from `.symlinks/plugins/volume_controller/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
SPEC REPOS:
trunk:
- DKImagePickerController
- DKPhotoGallery
- SDWebImage
- SwiftyGif
- WebRTC-SDK
EXTERNAL SOURCES:
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/darwin"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
Flutter:
:path: Flutter
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_webrtc:
:path: ".symlinks/plugins/flutter_webrtc/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
livekit_client:
:path: ".symlinks/plugins/livekit_client/ios"
media_kit_libs_ios_video:
:path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
media_kit_native_event_loop:
@ -58,8 +143,12 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
screen_brightness_ios:
:path: ".symlinks/plugins/screen_brightness_ios/ios"
sqflite:
:path: ".symlinks/plugins/sqflite/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
volume_controller:
@ -70,20 +159,33 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
SPEC CHECKSUMS:
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
DKImagePickerController: a7836546cfdfe014171694f643a7d575bc8ace7f
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
flutter_webrtc: 9bc044b0b5bcaabd0fb7d52c90421fb540f8c35e
image_picker_ios: b545a5f16c0fa88e3ecbbce3ed4de45567a8ec18
livekit_client: 734af0e8cb97a610af9d83c208a5453d588d5797
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
SDWebImage: 40b0b4053e36c660a764958bff99eed16610acbb
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 6116280ddcfe98ab8820085d8d76ae7449447586
volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
WebRTC-SDK: efc3e67e0355b1ee14bfe3c91188cada6000cb94
webview_flutter_wkwebview: be0f0d33777f1bfd0c9fdcb594786704dbf65f36
PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796
PODFILE CHECKSUM: f9420bd595da8fbce156b547dcd3368afc5226ff
COCOAPODS: 1.15.1
COCOAPODS: 1.15.2

View File

@ -199,6 +199,7 @@
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
FBE5FC129D166C3891201349 /* [CP] Embed Pods Frameworks */,
BAE67882F3DAA8740C7E8FC1 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@ -345,6 +346,23 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
BAE67882F3DAA8740C7E8FC1 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
FBE5FC129D166C3891201349 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;

View File

@ -1,12 +1,22 @@
import UIKit
import Flutter
import flutter_local_notifications
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
FlutterLocalNotificationsPlugin.setPluginRegistrantCallback { (registry) in
GeneratedPluginRegistrant.register(with: registry)
}
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
}
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}

View File

@ -1,7 +1,8 @@
{
"solian": "Solian",
"appName": "Solar Network",
"explore": "Explore",
"chat": "Chat",
"realm": "Realm",
"account": "Account",
"riskDetection": "Risk Detection",
"signIn": "Sign In",
@ -9,29 +10,44 @@
"signInRequired": "Sign in required",
"signInRiskDetected": "Risk detected, click Next to open a webpage and signin through it to pass security check.",
"signUp": "Sign Up",
"signUpCaption": "Create an account on Solarpass and then get the access of entire Solar Networks!",
"signUpCaption": "Create an account on Solarpass and then get the access of entire Solar Network!",
"signUpDone": "Sign Up Successfully",
"signUpDoneCaption": "Welcome to join Solar Network! Now go to sign in!",
"signOut": "Sign Out",
"poweredBy": "Powered by Project Hydrogen",
"copyright": "Copyright © 2024 Solsynth LLC",
"confirmation": "Confirmation",
"confirmCancel": "Not sure",
"confirmOkay": "OK",
"email": "Email Address",
"nickname": "Nickname",
"username": "Username",
"firstName": "First Name",
"lastName": "Last Name",
"description": "Description",
"birthday": "Birthday",
"password": "Password",
"next": "Next",
"join": "Join",
"edit": "Edit",
"apply": "Apply",
"delete": "Delete",
"exit": "Exit",
"action": "Action",
"reset": "Reset",
"cancel": "Cancel",
"report": "Report",
"reply": "Reply",
"export": "Export",
"import": "Import",
"settings": "Settings",
"errorHappened": "An Error Occurred",
"notification": "Notification",
"notifyDone": "You're done!",
"notifyDoneCaption": "There are no notifications unread for you.",
"notifyListHint": "Pull to refresh, swipe to dismiss",
"notifyMarkAllRead": "Mark all as read",
"notifyMarkAllReadDone": "Marked all notifications as read",
"friend": "Friend",
"friendPending": "Pending",
"friendActive": "Active",
@ -40,6 +56,13 @@
"friendAdd": "Add friend",
"friendAddHint": "Use your their username to send a friend request to your best friend!",
"friendAddDone": "Friend request sent, go reach your friend!",
"personalize": "Personalize",
"personalizeApplied": "Your account information has been updated, some fields may take a while to fully applied.",
"keypair": "Keypair",
"keypairGenerated": "A new set of keypair has been generated. Automatically set it to active.",
"keypairSecretCode": "Secret Code",
"keypairImportHint": "You can paste the exported secret code here to import all keys in it.",
"keypairExportHint": "You can copy the exported secret code to other device to import all keys into it. Do not share it with anybody else!",
"reaction": "Reaction",
"reactVerb": "React",
"post": "Post",
@ -47,10 +70,10 @@
"comment": "Comment",
"attachment": "Attachment",
"attachmentAdd": "Add new attachment",
"pickPhoto": "Gallery photo",
"pickMedia": "Gallery media",
"pickFile": "Device file",
"takePhoto": "Capture photo",
"pickVideo": "Gallery video",
"takeVideo": "Record video",
"takeVideo": "Capture video",
"newMoment": "Record a moment",
"newComment": "Leave a comment",
"connectingServer": "Connecting to server...",
@ -60,12 +83,30 @@
"postEditNotify": "You are about editing a post that already published.",
"reactionAdded": "Your reaction has been added.",
"reactionRemoved": "Your reaction has been removed.",
"shortcutsEmpty": "Shortcuts are empty, looks like you didn't go anywhere recently...",
"realmNew": "New Realm",
"realmNewCreate": "Create a realm",
"realmNewJoin": "Join a exists realm",
"realmUsage": "Realm",
"realmUsageCaption": "Realm is a place to organize your posts, channels and more. It will be much easier if you build community with realm!",
"realmEstablish": "Establish a realm",
"realmEditNotify": "You are about editing a existing realm.",
"realmAliasLabel": "Realm Alias",
"realmNameLabel": "Realm Name",
"realmDescriptionLabel": "Realm Description",
"realmPublicLabel": "It's public",
"realmCommunityLabel": "It's community realm",
"realmMember": "Member",
"realmManage": "Realm Manage",
"chatNew": "New Chat",
"chatNewCreate": "Create a channel",
"chatNewJoin": "Join a exists channel",
"chatManage": "Manage Chat",
"chatDetail": "Chat Details",
"chatMember": "Member",
"chatNotifySetting": "Notify Settings",
"chatChannelUnavailable": "Channel Unavailable",
"chatChannelUnavailableCaptionWithRealm": "You didn't join the channel, but looks like you able to join to, would you want to have a try?",
"chatChannelUnavailableCaption": "You didn't join the channel, so you cannot access the information of this channel.",
"chatChannelUsage": "Channel",
"chatChannelUsageCaption": "Channel is place to talk with people, one or a lot.",
"chatChannelOrganize": "Organize a channel",
@ -73,13 +114,31 @@
"chatChannelAliasLabel": "Channel Alias",
"chatChannelNameLabel": "Channel Name",
"chatChannelDescriptionLabel": "Channel Description",
"chatChannelEncryptedLabel": "Encrypted Channel",
"chatChannelLeaveConfirm": "Are you sure you want to leave this channel? Your message will be stored, but if you rejoin this channel later, you will lose your control of your previous messages.",
"chatChannelDeleteConfirm": "Are you sure you want to delete this channel? All messages in this channel will be gone forever. This operation cannot be revert!",
"chatCall": "Call",
"chatCallOngoing": "A call is ongoing",
"chatCallOngoingShort": "Ongoing",
"chatCallJoin": "Join",
"chatCallMute": "Mute",
"chatCallUnMute": "Un-mute",
"chatCallVideoOff": "Turn Off Video",
"chatCallVideoOn": "Turn On Video",
"chatCallVideoFlip": "Flip Camera",
"chatCallScreenOn": "Start Screen Share",
"chatCallScreenOff": "Stop Screen Share",
"chatCallDisconnect": "Disconnect",
"chatCallDisconnectConfirm": "Are you sure you want to disconnect? You can reconnect after this if you want.",
"chatCallChangeSpeaker": "Change Speaker",
"chatMessagePlaceholder": "Write a message...",
"chatMessageEncryptedPlaceholder": "Write a encrypted message...",
"chatMessageSending": "Now delivering your messages...",
"chatMessageEditNotify": "You are about editing a message.",
"chatMessageReplyNotify": "You are about replying a message.",
"chatMessageDeleteConfirm": "Are you sure you want to delete this message? This operation cannot be revert and no local history is saved!"
"chatMessageDeleteConfirm": "Are you sure you want to delete this message? This operation cannot be revert and no local history is saved!",
"chatMessageViewSource": "View source",
"chatMessageUnableDecryptWaiting": "Waiting for encryption key...",
"chatMessageUnableDecryptUnsupported": "Unable to decrypt the message, encryption algorithm unsupported",
"chatMessageUnableDecryptMissing": "Unable to decrypt the message, missing encryption key"
}

View File

@ -1,7 +1,8 @@
{
"solian": "索链",
"appName": "Solar Network",
"explore": "探索",
"chat": "聊天",
"realm": "领域",
"account": "账号",
"riskDetection": "风险监测",
"signIn": "登录",
@ -9,29 +10,44 @@
"signInRequired": "请先登录",
"signInRiskDetected": "检测到风险,点击下一步按钮来打开一个网页,并通过在其上面登录来通过安全检查。",
"signUp": "注册",
"signUpCaption": "在 Solarpass 注册一个账号以获得整个 Solar Networks 的存取权!",
"signUpCaption": "在 Solarpass 注册一个账号以获得整个 Solar Network 的存取权!",
"signUpDone": "注册成功",
"signUpDoneCaption": "欢迎您加入 Solar Network现在去登陆吧",
"signOut": "登出",
"poweredBy": "由 Project Hydrogen 强力驱动",
"copyright": "2024 Solsynth LLC © 版权所有",
"confirmation": "确认",
"confirmCancel": "不太确定",
"confirmOkay": "确定",
"email": "邮箱地址",
"nickname": "显示名",
"username": "用户名",
"firstName": "姓氏",
"lastName": "名字",
"description": "简介",
"birthday": "生日",
"password": "密码",
"next": "下一步",
"join": "加入",
"edit": "编辑",
"delete": "删除",
"action": "操作",
"apply": "应用",
"reset": "重置",
"cancel": "取消",
"exit": "离开",
"report": "举报",
"reply": "回复",
"export": "导出",
"import": "导入",
"settings": "设置",
"errorHappened": "发生了错误",
"notification": "通知",
"notifyDone": "所有通知已读!",
"notifyDoneCaption": "这里没有什么东西可以给你看的了~",
"notifyListHint": "下拉以刷新,左滑来已读",
"notifyMarkAllRead": "将所有标记为已读",
"notifyMarkAllReadDone": "已将所有通知标记为已读",
"friend": "好友",
"friendPending": "请求中",
"friendActive": "活跃的好友",
@ -40,6 +56,13 @@
"friendAdd": "添加好友",
"friendAddHint": "使用用户名来给你的好朋友发一个好友请求吧!",
"friendAddDone": "好友请求已发送,快告诉你的朋友吧!",
"personalize": "个性化",
"personalizeApplied": "您的账号信息已被更新,部分信息可能需要一段时间来同步。",
"keypair": "密钥对",
"keypairGenerated": "已生成一套新的密钥对,并且设为活跃的密钥。",
"keypairSecretCode": "神秘代码",
"keypairImportHint": "你可以将别的设备导出的神秘代码粘贴到这里来导入其中的所有密钥。",
"keypairExportHint": "你可以将这个导出的神秘代码到你的别的设备来导入这个设备所包含的密钥,但绝对不要发送给其他人!",
"reaction": "反应",
"reactVerb": "作出反应",
"post": "帖子",
@ -47,9 +70,9 @@
"comment": "评论",
"attachment": "附件",
"attachmentAdd": "附加新附件",
"pickPhoto": "相册照片",
"pickMedia": "相册媒体",
"pickFile": "设备文件",
"takePhoto": "拍摄照片",
"pickVideo": "相册视频",
"takeVideo": "拍摄视频",
"newMoment": "记录时刻",
"newComment": "留下评论",
@ -60,10 +83,28 @@
"postEditNotify": "你正在修改一个已经发布了的帖子。",
"reactionAdded": "你的反应已被添加。",
"reactionRemoved": "你的反应已被移除。",
"shortcutsEmpty": "快捷方式空空如也,看起来最近你没去哪里呀~",
"realmNew": "新领域",
"realmNewCreate": "创建新领域",
"realmNewJoin": "加入现有领域",
"realmUsage": "领域",
"realmUsageCaption": "领域是一个地方给你来组织帖子、文章、聊天频道的,好好利用领域打造一个绝妙的专属于你的社区吧!",
"realmEstablish": "部署领域",
"realmEditNotify": "你正在修改一个现有领域……",
"realmAliasLabel": "领域别名",
"realmNameLabel": "领域名称",
"realmDescriptionLabel": "领域简介",
"realmPublicLabel": "公共领域",
"realmCommunityLabel": "社区领域(任何人均可加入)",
"realmMember": "成员",
"realmManage": "领域管理",
"chatNew": "新聊天",
"chatManage": "管理聊天",
"chatDetail": "聊天详情",
"chatMember": "成员",
"chatNotifySetting": "通知设定",
"chatChannelUnavailable": "频道不可用",
"chatChannelUnavailableCaptionWithRealm": "你没加入该频道,但是看起来你能加入本频道,你想加入吗?",
"chatChannelUnavailableCaption": "你没加入该频道,所以你无法读取本频道的信息。",
"chatNewCreate": "新建频道",
"chatNewJoin": "加入已有频道",
"chatChannelUsage": "频道",
@ -73,13 +114,31 @@
"chatChannelAliasLabel": "频道别名",
"chatChannelNameLabel": "频道名称",
"chatChannelDescriptionLabel": "频道简介",
"chatChannelEncryptedLabel": "加密频道",
"chatChannelLeaveConfirm": "你确定你要离开这个频道吗?你在这个频道里的消息将被存储下来,但是当你重新加入本频道后你将会失去对你之前消息的权限。",
"chatChannelDeleteConfirm": "你确定你要删除这个频道吗?这个频道里的所有消息都将消失,并且不可被反转!",
"chatCall": "通话",
"chatCallMute": "静音",
"chatCallUnMute": "取消静音",
"chatCallVideoOff": "关闭摄像头",
"chatCallVideoOn": "启动摄像头",
"chatCallVideoFlip": "翻转视频输出",
"chatCallScreenOn": "开启屏幕分享",
"chatCallScreenOff": "停止屏幕分享",
"chatCallChangeSpeaker": "切换扬声器",
"chatCallOngoing": "一则通话正在进行中",
"chatCallOngoingShort": "进行中",
"chatCallJoin": "加入",
"chatCallDisconnect": "断开连接",
"chatCallDisconnectConfirm": "你确定你要断开连接吗?你可以之后在任何时候重新连接。",
"chatMessagePlaceholder": "发条消息……",
"chatMessageEncryptedPlaceholder": "发条加密信息……",
"chatMessageSending": "正在送出你的信息……",
"chatMessageEditNotify": "你正在编辑信息中……",
"chatMessageReplyNotify": "你正在回复消息中……",
"chatMessageDeleteConfirm": "你确定要删除这条消息吗?这条消息将永远的从所有人的视图中消失,并且不会有本地消息记录保存!"
}
"chatMessageDeleteConfirm": "你确定要删除这条消息吗?这条消息将永远的从所有人的视图中消失,并且不会有本地消息记录保存!",
"chatMessageViewSource": "查看原始信息",
"chatMessageUnableDecryptWaiting": "正在等待解密密钥……",
"chatMessageUnableDecryptUnsupported": "无法解密信息,不支持加密的算法",
"chatMessageUnableDecryptMissing": "无法解密信息,缺失解密密钥"
}

View File

@ -3,15 +3,21 @@ import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/providers/friend.dart';
import 'package:solian/providers/keypair.dart';
import 'package:solian/providers/navigation.dart';
import 'package:solian/providers/notify.dart';
import 'package:solian/providers/realm.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/utils/timeago.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/utils/video_player.dart';
import 'package:solian/widgets/notification_notifier.dart';
import 'package:solian/widgets/chat/call/call_overlay.dart';
import 'package:solian/widgets/provider_init.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
initVideo();
initTimeAgo();
@ -21,34 +27,41 @@ void main() {
class SolianApp extends StatelessWidget {
const SolianApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Solian',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
theme: SolianTheme.build(Brightness.light),
darkTheme: SolianTheme.build(Brightness.dark),
themeMode: ThemeMode.system,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
routerConfig: router,
routerConfig: SolianRouter.router,
builder: (context, child) {
return Overlay(
initialEntries: [
OverlayEntry(builder: (context) {
return MultiProvider(
providers: [
Provider(create: (_) => NavigationProvider()),
Provider(create: (_) => AuthProvider()),
Provider(create: (_) => ChatProvider()),
ChangeNotifierProvider(create: (_) => NotifyProvider()),
ChangeNotifierProvider(create: (_) => FriendProvider()),
],
child: NotificationNotifier(child: child ?? Container()),
);
})
return MultiProvider(
providers: [
Provider(create: (_) => NavigationProvider()),
ChangeNotifierProvider(create: (_) => AuthProvider()),
ChangeNotifierProvider(create: (_) => ChatProvider()),
ChangeNotifierProvider(create: (_) => NotifyProvider()),
ChangeNotifierProvider(create: (_) => FriendProvider()),
ChangeNotifierProvider(create: (_) => RealmProvider()),
ChangeNotifierProvider(create: (_) => KeypairProvider()),
],
child: Overlay(
initialEntries: [
OverlayEntry(builder: (context) {
return ScaffoldMessenger(
child: Scaffold(
body: ProviderInitializer(
child: child ?? Container(),
),
),
);
}),
OverlayEntry(builder: (context) => const CallOverlay()),
],
),
);
},
);

View File

@ -28,32 +28,32 @@ class Account {
});
factory Account.fromJson(Map<String, dynamic> json) => Account(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
name: json["name"],
nick: json["nick"],
avatar: json["avatar"],
banner: json["banner"],
description: json["description"],
emailAddress: json["email_address"],
powerLevel: json["power_level"],
externalId: json["external_id"],
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
name: json['name'],
nick: json['nick'],
avatar: json['avatar'],
banner: json['banner'],
description: json['description'],
emailAddress: json['email_address'],
powerLevel: json['power_level'],
externalId: json['external_id'],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"name": name,
"nick": nick,
"avatar": avatar,
"banner": banner,
"description": description,
"email_address": emailAddress,
"power_level": powerLevel,
"external_id": externalId,
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'name': name,
'nick': nick,
'avatar': avatar,
'banner': banner,
'description': description,
'email_address': emailAddress,
'power_level': powerLevel,
'external_id': externalId,
};
}

View File

@ -28,32 +28,32 @@ class Author {
});
factory Author.fromJson(Map<String, dynamic> json) => Author(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
name: json["name"],
nick: json["nick"],
avatar: json["avatar"],
banner: json["banner"],
description: json["description"],
emailAddress: json["email_address"],
powerLevel: json["power_level"],
externalId: json["external_id"],
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
name: json['name'],
nick: json['nick'],
avatar: json['avatar'],
banner: json['banner'],
description: json['description'],
emailAddress: json['email_address'],
powerLevel: json['power_level'],
externalId: json['external_id'],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"name": name,
"nick": nick,
"avatar": avatar,
"banner": banner,
"description": description,
"email_address": emailAddress,
"power_level": powerLevel,
"external_id": externalId,
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'name': name,
'nick': nick,
'avatar': avatar,
'banner': banner,
'description': description,
'email_address': emailAddress,
'power_level': powerLevel,
'external_id': externalId,
};
}

View File

@ -25,28 +25,29 @@ class Call {
});
factory Call.fromJson(Map<String, dynamic> json) => Call(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
endedAt: json["ended_at"] != null ? DateTime.parse(json["ended_at"]) : null,
externalId: json["external_id"],
founderId: json["founder_id"],
channelId: json["channel_id"],
channel: Channel.fromJson(json["channel"]),
);
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
endedAt:
json['ended_at'] != null ? DateTime.parse(json['ended_at']) : null,
externalId: json['external_id'],
founderId: json['founder_id'],
channelId: json['channel_id'],
channel: Channel.fromJson(json['channel']),
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"ended_at": endedAt?.toIso8601String(),
"external_id": externalId,
"founder_id": founderId,
"channel_id": channelId,
"channel": channel.toJson(),
};
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'ended_at': endedAt?.toIso8601String(),
'external_id': externalId,
'founder_id': founderId,
'channel_id': channelId,
'channel': channel.toJson(),
};
}
enum ParticipantStatsType {
@ -60,9 +61,9 @@ enum ParticipantStatsType {
class ParticipantTrack {
ParticipantTrack(
{required this.participant,
required this.videoTrack,
required this.isScreenShare});
required this.videoTrack,
required this.isScreenShare});
VideoTrack? videoTrack;
Participant participant;
final bool isScreenShare;
}
bool isScreenShare;
}

View File

@ -8,12 +8,13 @@ class Channel {
String alias;
String name;
String description;
dynamic members;
dynamic calls;
int type;
Account account;
int accountId;
int realmId;
int? realmId;
bool isEncrypted;
bool isAvailable = false;
Channel({
required this.id,
@ -23,45 +24,42 @@ class Channel {
required this.alias,
required this.name,
required this.description,
this.members,
this.calls,
required this.type,
required this.account,
required this.accountId,
required this.realmId,
required this.isEncrypted,
this.realmId,
});
factory Channel.fromJson(Map<String, dynamic> json) => Channel(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
alias: json["alias"],
name: json["name"],
description: json["description"],
members: json["members"],
calls: json["calls"],
type: json["type"],
account: Account.fromJson(json["account"]),
accountId: json["account_id"],
realmId: json["realm_id"],
);
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
alias: json['alias'],
name: json['name'],
description: json['description'],
type: json['type'],
account: Account.fromJson(json['account']),
accountId: json['account_id'],
realmId: json['realm_id'],
isEncrypted: json['is_encrypted'],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"alias": alias,
"name": name,
"description": description,
"members": members,
"calls": calls,
"type": type,
"account": account,
"account_id": accountId,
"realm_id": realmId,
};
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'alias': alias,
'name': name,
'description': description,
'type': type,
'account': account,
'account_id': accountId,
'realm_id': realmId,
'is_encrypted': isEncrypted,
};
}
class ChannelMember {
@ -86,24 +84,24 @@ class ChannelMember {
});
factory ChannelMember.fromJson(Map<String, dynamic> json) => ChannelMember(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
channelId: json["channel_id"],
accountId: json["account_id"],
account: Account.fromJson(json["account"]),
notify: json["notify"],
);
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
channelId: json['channel_id'],
accountId: json['account_id'],
account: Account.fromJson(json['account']),
notify: json['notify'],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"channel_id": channelId,
"account_id": accountId,
"account": account.toJson(),
"notify": notify,
};
}
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'channel_id': channelId,
'account_id': accountId,
'account': account.toJson(),
'notify': notify,
};
}

View File

@ -26,30 +26,30 @@ class Friendship {
});
factory Friendship.fromJson(Map<String, dynamic> json) => Friendship(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
accountId: json["account_id"],
relatedId: json["related_id"],
blockedBy: json["blocked_by"],
account: Account.fromJson(json["account"]),
related: Account.fromJson(json["related"]),
status: json["status"],
);
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
accountId: json['account_id'],
relatedId: json['related_id'],
blockedBy: json['blocked_by'],
account: Account.fromJson(json['account']),
related: Account.fromJson(json['related']),
status: json['status'],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"account_id": accountId,
"related_id": relatedId,
"blocked_by": blockedBy,
"account": account.toJson(),
"related": related.toJson(),
"status": status,
};
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'account_id': accountId,
'related_id': relatedId,
'blocked_by': blockedBy,
'account': account.toJson(),
'related': related.toJson(),
'status': status,
};
Account getOtherside(int selfId) {
if (accountId != selfId) {
@ -58,4 +58,4 @@ class Friendship {
return related;
}
}
}
}

32
lib/models/keypair.dart Normal file
View File

@ -0,0 +1,32 @@
class Keypair {
final String id;
final String algorithm;
final String publicKey;
final String? privateKey;
final bool isOwned;
Keypair({
required this.id,
required this.algorithm,
required this.publicKey,
required this.privateKey,
this.isOwned = false,
});
factory Keypair.fromJson(Map<String, dynamic> json) => Keypair(
id: json['id'],
algorithm: json['algorithm'],
publicKey: json['public_key'],
privateKey: json['private_key'],
isOwned: json['is_owned'],
);
Map<String, dynamic> toJson() => {
'id': id,
'algorithm': algorithm,
'public_key': publicKey,
'private_key': privateKey,
'is_owned': isOwned,
};
}

View File

@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:solian/models/account.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/post.dart';
@ -7,9 +9,9 @@ class Message {
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String content;
dynamic metadata;
int type;
String rawContent;
Map<String, dynamic>? metadata;
String type;
List<Attachment>? attachments;
Channel? channel;
Sender sender;
@ -18,12 +20,18 @@ class Message {
int channelId;
int senderId;
bool isSending = false;
Map<String, dynamic> get decodedContent {
return jsonDecode(utf8.fuse(base64).decode(rawContent));
}
Message({
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.content,
required this.rawContent,
required this.metadata,
required this.type,
this.attachments,
@ -36,37 +44,37 @@ class Message {
});
factory Message.fromJson(Map<String, dynamic> json) => Message(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
content: json["content"],
metadata: json["metadata"],
type: json["type"],
attachments: List<Attachment>.from(json["attachments"]?.map((x) => Attachment.fromJson(x)) ?? List.empty()),
channel: Channel.fromJson(json["channel"]),
sender: Sender.fromJson(json["sender"]),
replyId: json["reply_id"],
replyTo: json["reply_to"] != null ? Message.fromJson(json["reply_to"]) : null,
channelId: json["channel_id"],
senderId: json["sender_id"],
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
rawContent: json['content'],
metadata: json['metadata'],
type: json['type'],
attachments: List<Attachment>.from(json['attachments']?.map((x) => Attachment.fromJson(x)) ?? List.empty()),
channel: Channel.fromJson(json['channel']),
sender: Sender.fromJson(json['sender']),
replyId: json['reply_id'],
replyTo: json['reply_to'] != null ? Message.fromJson(json['reply_to']) : null,
channelId: json['channel_id'],
senderId: json['sender_id'],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"content": content,
"metadata": metadata,
"type": type,
"attachments": List<dynamic>.from(attachments?.map((x) => x.toJson()) ?? List.empty()),
"channel": channel?.toJson(),
"sender": sender.toJson(),
"reply_id": replyId,
"reply_to": replyTo?.toJson(),
"channel_id": channelId,
"sender_id": senderId,
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'content': rawContent,
'metadata': metadata,
'type': type,
'attachments': List<dynamic>.from(attachments?.map((x) => x.toJson()) ?? List.empty()),
'channel': channel?.toJson(),
'sender': sender.toJson(),
'reply_id': replyId,
'reply_to': replyTo?.toJson(),
'channel_id': channelId,
'sender_id': senderId,
};
}
@ -92,24 +100,24 @@ class Sender {
});
factory Sender.fromJson(Map<String, dynamic> json) => Sender(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
account: Account.fromJson(json["account"]),
channelId: json["channel_id"],
accountId: json["account_id"],
notify: json["notify"],
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
account: Account.fromJson(json['account']),
channelId: json['channel_id'],
accountId: json['account_id'],
notify: json['notify'],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"account": account.toJson(),
"channel_id": channelId,
"account_id": accountId,
"notify": notify,
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'account': account.toJson(),
'channel_id': channelId,
'account_id': accountId,
'notify': notify,
};
}

View File

@ -9,7 +9,7 @@ class Notification {
bool isImportant;
bool isRealtime;
DateTime? readAt;
int senderId;
int? senderId;
int recipientId;
Notification({
@ -23,38 +23,38 @@ class Notification {
required this.isImportant,
required this.isRealtime,
this.readAt,
required this.senderId,
this.senderId,
required this.recipientId,
});
factory Notification.fromJson(Map<String, dynamic> json) => Notification(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
subject: json["subject"],
content: json["content"],
links: json["links"] != null ? List<Link>.from(json["links"].map((x) => Link.fromJson(x))) : List.empty(),
isImportant: json["is_important"],
isRealtime: json["is_realtime"],
readAt: json["read_at"],
senderId: json["sender_id"],
recipientId: json["recipient_id"],
id: json['id'] ?? 0,
createdAt: json['created_at'] == null ? DateTime.now() : DateTime.parse(json['created_at']),
updatedAt: json['updated_at'] == null ? DateTime.now() : DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
subject: json['subject'],
content: json['content'],
links: json['links'] != null ? List<Link>.from(json['links'].map((x) => Link.fromJson(x))) : List.empty(),
isImportant: json['is_important'],
isRealtime: json['is_realtime'],
readAt: json['read_at'],
senderId: json['sender_id'],
recipientId: json['recipient_id'],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"subject": subject,
"content": content,
"links": links != null ? List<dynamic>.from(links!.map((x) => x.toJson())) : List.empty(),
"is_important": isImportant,
"is_realtime": isRealtime,
"read_at": readAt,
"sender_id": senderId,
"recipient_id": recipientId,
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'subject': subject,
'content': content,
'links': links != null ? List<dynamic>.from(links!.map((x) => x.toJson())) : List.empty(),
'is_important': isImportant,
'is_realtime': isRealtime,
'read_at': readAt,
'sender_id': senderId,
'recipient_id': recipientId,
};
}
@ -68,12 +68,12 @@ class Link {
});
factory Link.fromJson(Map<String, dynamic> json) => Link(
label: json["label"],
url: json["url"],
label: json['label'],
url: json['url'],
);
Map<String, dynamic> toJson() => {
"label": label,
"url": url,
'label': label,
'url': url,
};
}

View File

@ -10,14 +10,14 @@ class NetworkPackage {
});
factory NetworkPackage.fromJson(Map<String, dynamic> json) => NetworkPackage(
method: json["w"],
message: json["m"],
payload: json["p"],
);
method: json['w'],
message: json['m'],
payload: json['p'],
);
Map<String, dynamic> toJson() => {
"w": method,
"m": message,
"p": payload,
};
}
'w': method,
'm': message,
'p': payload,
};
}

View File

@ -8,10 +8,10 @@ class PaginationResult {
});
factory PaginationResult.fromJson(Map<String, dynamic> json) =>
PaginationResult(count: json["count"], data: json["data"]);
PaginationResult(count: json['count'], data: json['data']);
Map<String, dynamic> toJson() => {
"count": count,
"data": data,
'count': count,
'data': data,
};
}

View File

@ -0,0 +1,47 @@
class PersonalPage {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String content;
String script;
String style;
Map<String, String>? links;
int accountId;
PersonalPage({
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.content,
required this.script,
required this.style,
this.links,
required this.accountId,
});
factory PersonalPage.fromJson(Map<String, dynamic> json) => PersonalPage(
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,
content: json['content'],
script: json['script'],
style: json['style'],
links: json['links'],
accountId: json['account_id'],
);
Map<String, dynamic> toJson() => {
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt?.toIso8601String(),
'content': content,
'script': script,
'style': style,
'links': links,
'account_id': accountId,
};
}

View File

@ -18,6 +18,8 @@ class Post {
List<Attachment>? attachments;
Map<String, dynamic>? reactionList;
String get dataset => '${modelType}s';
Post({
required this.id,
required this.createdAt,
@ -38,46 +40,46 @@ class Post {
});
factory Post.fromJson(Map<String, dynamic> json) => Post(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
alias: json["alias"],
title: json["title"],
description: json["description"],
content: json["content"],
modelType: json["model_type"],
commentCount: json["comment_count"],
reactionCount: json["reaction_count"],
authorId: json["author_id"],
realmId: json["realm_id"],
author: Author.fromJson(json["author"]),
attachments: json["attachments"] != null
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
alias: json['alias'],
title: json['title'],
description: json['description'],
content: json['content'],
modelType: json['model_type'],
commentCount: json['comment_count'],
reactionCount: json['reaction_count'],
authorId: json['author_id'],
realmId: json['realm_id'],
author: Author.fromJson(json['author']),
attachments: json['attachments'] != null
? List<Attachment>.from(
json["attachments"].map((x) => Attachment.fromJson(x)))
json['attachments'].map((x) => Attachment.fromJson(x)))
: List.empty(),
reactionList: json["reaction_list"],
reactionList: json['reaction_list'],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"alias": alias,
"title": title,
"description": description,
"content": content,
"model_type": modelType,
"comment_count": commentCount,
"reaction_count": reactionCount,
"author_id": authorId,
"realm_id": realmId,
"author": author.toJson(),
"attachments": attachments == null
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'alias': alias,
'title': title,
'description': description,
'content': content,
'model_type': modelType,
'comment_count': commentCount,
'reaction_count': reactionCount,
'author_id': authorId,
'realm_id': realmId,
'author': author.toJson(),
'attachments': attachments == null
? List.empty()
: List<dynamic>.from(attachments!.map((x) => x.toJson())),
"reaction_list": reactionList,
'reaction_list': reactionList,
};
}
@ -117,38 +119,38 @@ class Attachment {
});
factory Attachment.fromJson(Map<String, dynamic> json) => Attachment(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
fileId: json["file_id"],
filesize: json["filesize"],
filename: json["filename"],
mimetype: json["mimetype"],
type: json["type"],
externalUrl: json["external_url"],
author: Author.fromJson(json["author"]),
articleId: json["article_id"],
momentId: json["moment_id"],
commentId: json["comment_id"],
authorId: json["author_id"],
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
fileId: json['file_id'],
filesize: json['filesize'],
filename: json['filename'],
mimetype: json['mimetype'],
type: json['type'],
externalUrl: json['external_url'],
author: Author.fromJson(json['author']),
articleId: json['article_id'],
momentId: json['moment_id'],
commentId: json['comment_id'],
authorId: json['author_id'],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"file_id": fileId,
"filesize": filesize,
"filename": filename,
"mimetype": mimetype,
"type": type,
"external_url": externalUrl,
"author": author.toJson(),
"article_id": articleId,
"moment_id": momentId,
"comment_id": commentId,
"author_id": authorId,
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'file_id': fileId,
'filesize': filesize,
'filename': filename,
'mimetype': mimetype,
'type': type,
'external_url': externalUrl,
'author': author.toJson(),
'article_id': articleId,
'moment_id': momentId,
'comment_id': commentId,
'author_id': authorId,
};
}

97
lib/models/realm.dart Normal file
View File

@ -0,0 +1,97 @@
import 'package:solian/models/account.dart';
class Realm {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String alias;
String name;
String description;
bool isPublic;
bool isCommunity;
int accountId;
Realm({
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.alias,
required this.name,
required this.description,
required this.isPublic,
required this.isCommunity,
required this.accountId,
});
factory Realm.fromJson(Map<String, dynamic> json) => Realm(
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,
alias: json['alias'],
name: json['name'],
description: json['description'],
isPublic: json['is_public'],
isCommunity: json['is_community'],
accountId: json['account_id'],
);
Map<String, dynamic> toJson() => {
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'alias': alias,
'name': name,
'description': description,
'is_public': isPublic,
'is_community': isCommunity,
'account_id': accountId,
};
}
class RealmMember {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
int realmId;
int accountId;
Account account;
int powerLevel;
RealmMember({
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.realmId,
required this.accountId,
required this.account,
required this.powerLevel,
});
factory RealmMember.fromJson(Map<String, dynamic> json) => RealmMember(
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,
realmId: json['realm_id'],
accountId: json['account_id'],
account: Account.fromJson(json['account']),
powerLevel: json['power_level'],
);
Map<String, dynamic> toJson() => {
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'realm_id': realmId,
'account_id': accountId,
'account': account.toJson(),
'power_level': powerLevel,
};
}

View File

@ -3,34 +3,37 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:oauth2/oauth2.dart' as oauth2;
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/http.dart';
import 'package:solian/utils/services_url.dart';
class AuthProvider {
AuthProvider();
class AuthProvider extends ChangeNotifier {
AuthProvider() {
loadClient();
}
final deviceEndpoint = getRequestUri('passport', '/api/notifications/subscribe');
final tokenEndpoint = getRequestUri('passport', '/api/auth/token');
final userinfoEndpoint = getRequestUri('passport', '/api/users/me');
final redirectUrl = Uri.parse('solian://auth');
static const clientId = "solian";
static const clientSecret = "_F4%q2Eea3";
static const clientId = 'solian';
static const clientSecret = '_F4%q2Eea3';
static const storage = FlutterSecureStorage();
static const storageKey = "identity";
static const profileKey = "profiles";
static const storageKey = 'identity';
static const profileKey = 'profiles';
/// Before use this variable to make request
/// **MAKE SURE YOU HAVE CALL THE isAuthorized() METHOD**
oauth2.Client? client;
HttpClient? client;
DateTime? lastRefreshedAt;
Future<bool> pickClient() async {
Future<bool> loadClient() async {
if (await storage.containsKey(key: storageKey)) {
try {
final credentials = oauth2.Credentials.fromJson((await storage.read(key: storageKey))!);
client = oauth2.Client(credentials, identifier: clientId, secret: clientSecret);
client = HttpClient(
defaultToken: credentials.accessToken,
defaultRefreshToken: credentials.refreshToken,
onTokenRefreshed: setToken,
);
await fetchProfiles();
return true;
} catch (e) {
@ -38,23 +41,33 @@ class AuthProvider {
return false;
}
} else {
client = HttpClient(onTokenRefreshed: setToken);
return false;
}
}
Future<oauth2.Client> createClient(BuildContext context, String username, String password) async {
if (await pickClient()) {
Future<HttpClient> createClient(BuildContext context, String username, String password) async {
if (await loadClient()) {
return client!;
}
return await oauth2.resourceOwnerPasswordGrant(
final credentials = (await oauth2.resourceOwnerPasswordGrant(
tokenEndpoint,
username,
password,
identifier: clientId,
secret: clientSecret,
scopes: ["openid"],
scopes: ['openid'],
basicAuth: false,
))
.credentials;
setToken(credentials.accessToken, credentials.refreshToken!);
return HttpClient(
defaultToken: credentials.accessToken,
defaultRefreshToken: credentials.refreshToken,
onTokenRefreshed: setToken,
);
}
@ -63,20 +76,19 @@ class AuthProvider {
var userinfo = await client!.get(userinfoEndpoint);
storage.write(key: profileKey, value: utf8.decode(userinfo.bodyBytes));
}
notifyListeners();
}
Future<void> refreshToken() async {
Future<void> setToken(String atk, String rtk) async {
if (client != null) {
final credentials =
await client!.credentials.refresh(identifier: clientId, secret: clientSecret, basicAuth: false);
client = oauth2.Client(credentials, identifier: clientId, secret: clientSecret);
final credentials = oauth2.Credentials(atk, refreshToken: rtk, idToken: atk, scopes: ['openid']);
storage.write(key: storageKey, value: credentials.toJson());
}
notifyListeners();
}
Future<void> signin(BuildContext context, String username, String password) async {
client = await createClient(context, username, password);
storage.write(key: storageKey, value: client!.credentials.toJson());
await fetchProfiles();
}
@ -88,22 +100,11 @@ class AuthProvider {
Future<bool> isAuthorized() async {
const storage = FlutterSecureStorage();
if (await storage.containsKey(key: storageKey)) {
if (client == null) {
await pickClient();
}
if (lastRefreshedAt == null || DateTime.now().subtract(const Duration(minutes: 3)).isAfter(lastRefreshedAt!)) {
await refreshToken();
lastRefreshedAt = DateTime.now();
}
return true;
} else {
return false;
}
return await storage.containsKey(key: storageKey);
}
Future<dynamic> getProfiles() async {
const storage = FlutterSecureStorage();
return jsonDecode(await storage.read(key: profileKey) ?? "{}");
return jsonDecode(await storage.read(key: profileKey) ?? '{}');
}
}

View File

@ -1,29 +1,596 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/call.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/message.dart';
import 'package:solian/models/packet.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/chat/call/exts.dart';
import 'package:solian/widgets/exts.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/status.dart' as status;
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ChatProvider {
bool isOpened = false;
class ChatProvider extends ChangeNotifier {
bool isCallShown = false;
Future<WebSocketChannel?> connect(AuthProvider auth) async {
if (auth.client == null) await auth.pickClient();
Call? ongoingCall;
Channel? focusChannel;
String? focusChannelRealm;
ChatCallInstance? currentCall;
PagingController<int, Message>? historyPagingController;
IOWebSocketChannel? _channel;
Future<IOWebSocketChannel?> connect(
AuthProvider auth, {
Function(bool status)? onStateUpdated,
noRetry = false,
}) async {
if (auth.client == null) await auth.loadClient();
if (!await auth.isAuthorized()) return null;
await auth.refreshToken();
if (_channel != null && (_channel!.innerWebSocket?.readyState ?? 0) < 2) {
return _channel;
}
var ori = getRequestUri('messaging', '/api/ws');
var uri = Uri(
scheme: ori.scheme.replaceFirst('http', 'ws'),
host: ori.host,
port: ori.port,
path: ori.path,
queryParameters: {'tk': Uri.encodeComponent(auth.client!.credentials.accessToken)},
queryParameters: {'tk': Uri.encodeComponent(auth.client!.currentToken!)},
);
final channel = WebSocketChannel.connect(uri);
await channel.ready;
try {
_channel = IOWebSocketChannel.connect(uri);
if (onStateUpdated != null) onStateUpdated(true);
await _channel!.ready;
if (onStateUpdated != null) onStateUpdated(false);
} catch (e) {
if (!noRetry) {
await auth.client!.refreshToken(auth.client!.currentRefreshToken!);
connect(auth, noRetry: true);
} else {
rethrow;
}
}
return channel;
_channel!.stream.listen(
(event) {
final result = NetworkPackage.fromJson(jsonDecode(event));
if (focusChannel == null || historyPagingController == null) return;
switch (result.method) {
case 'messages.new':
final payload = Message.fromJson(result.payload!);
if (payload.channelId == focusChannel?.id) {
historyPagingController?.itemList?.insert(0, payload);
}
break;
case 'messages.update':
final payload = Message.fromJson(result.payload!);
if (payload.channelId == focusChannel?.id) {
historyPagingController?.itemList =
historyPagingController?.itemList?.map((x) => x.id == payload.id ? payload : x).toList();
}
break;
case 'messages.burnt':
final payload = Message.fromJson(result.payload!);
if (payload.channelId == focusChannel?.id) {
historyPagingController?.itemList =
historyPagingController?.itemList?.where((x) => x.id != payload.id).toList();
}
break;
case 'calls.new':
final payload = Call.fromJson(result.payload!);
if (payload.channelId == focusChannel?.id) {
setOngoingCall(payload);
}
break;
case 'calls.end':
final payload = Call.fromJson(result.payload!);
if (payload.channelId == focusChannel?.id) {
setOngoingCall(null);
}
break;
}
notifyListeners();
},
onError: (_, __) => Future.delayed(const Duration(seconds: 3), () => connect(auth)),
onDone: () => Future.delayed(const Duration(seconds: 1), () => connect(auth)),
);
return _channel!;
}
void disconnect() {
_channel?.sink.close(status.goingAway);
}
Future<void> fetchMessages(int pageKey, BuildContext context) async {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
if (focusChannel == null || focusChannelRealm == null) return;
final offset = pageKey;
const take = 10;
var uri = getRequestUri(
'messaging',
'/api/channels/$focusChannelRealm/${focusChannel!.alias}/messages?take=$take&offset=$offset',
);
var res = await auth.client!.get(uri);
if (res.statusCode == 200) {
final result = PaginationResult.fromJson(jsonDecode(utf8.decode(res.bodyBytes)));
final items = result.data?.map((x) => Message.fromJson(x)).toList() ?? List.empty();
final isLastPage = (result.count - pageKey) < take;
if (isLastPage || result.data == null) {
historyPagingController!.appendLastPage(items);
} else {
final nextPageKey = pageKey + items.length;
historyPagingController!.appendPage(items, nextPageKey);
}
} else if (res.statusCode == 403) {
historyPagingController!.appendLastPage([]);
} else {
historyPagingController!.error = utf8.decode(res.bodyBytes);
}
}
Future<Channel> fetchChannel(BuildContext context, AuthProvider auth, String alias, String realm) async {
if (focusChannel != null) {
unFocus();
}
var uri = getRequestUri('messaging', '/api/channels/$realm/$alias/availability');
var res = await auth.client!.get(uri);
if (res.statusCode == 200 || res.statusCode == 403) {
final result = jsonDecode(utf8.decode(res.bodyBytes));
focusChannel = Channel.fromJson(result);
focusChannel?.isAvailable = res.statusCode == 200;
focusChannelRealm = realm;
if (historyPagingController == null) {
historyPagingController = PagingController(firstPageKey: 0);
historyPagingController?.addPageRequestListener((pageKey) => fetchMessages(pageKey, context));
}
notifyListeners();
return focusChannel!;
} else {
var message = utf8.decode(res.bodyBytes);
throw Exception(message);
}
}
Future<Call?> fetchOngoingCall(String alias, String realm) async {
final Client client = Client();
var uri = getRequestUri('messaging', '/api/channels/$realm/$alias/calls/ongoing');
var res = await client.get(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes));
ongoingCall = Call.fromJson(result);
notifyListeners();
return ongoingCall;
} else if (res.statusCode != 404) {
var message = utf8.decode(res.bodyBytes);
throw Exception(message);
} else {
return null;
}
}
bool handleCallJoin(Call call, Channel channel, {Function? onUpdate, Function? onDispose}) {
if (currentCall != null) return false;
currentCall = ChatCallInstance(
onUpdate: () {
notifyListeners();
if (onUpdate != null) onUpdate();
},
onDispose: () {
currentCall = null;
notifyListeners();
if (onDispose != null) onDispose();
},
channel: channel,
info: call,
);
return true;
}
void setOngoingCall(Call? item) {
ongoingCall = item;
notifyListeners();
}
void setCallShown(bool state) {
isCallShown = state;
notifyListeners();
}
void unFocus() {
currentCall = null;
focusChannel = null;
historyPagingController?.dispose();
historyPagingController = null;
notifyListeners();
}
}
class ChatCallInstance {
final Function onUpdate;
final Function onDispose;
final Call info;
final Channel channel;
bool isMounted = false;
String? token;
String? endpoint;
StreamSubscription? subscription;
List<MediaDevice> audioInputs = [];
List<MediaDevice> videoInputs = [];
bool enableAudio = true;
bool enableVideo = false;
LocalAudioTrack? audioTrack;
LocalVideoTrack? videoTrack;
MediaDevice? videoDevice;
MediaDevice? audioDevice;
final VideoParameters videoParameters = VideoParametersPresets.h720_169;
late Room room;
late EventsListener<RoomEvent> listener;
List<ParticipantTrack> participantTracks = [];
ParticipantTrack? focusTrack;
ChatCallInstance({
required this.onUpdate,
required this.onDispose,
required this.channel,
required this.info,
});
void init() {
subscription = Hardware.instance.onDeviceChange.stream.listen(revertDevices);
room = Room();
listener = room.createListener();
Hardware.instance.enumerateDevices().then(revertDevices);
WakelockPlus.enable();
}
Future<void> checkPermissions() async {
if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) {
return;
}
await Permission.camera.request();
await Permission.microphone.request();
await Permission.bluetooth.request();
await Permission.bluetoothConnect.request();
}
Future<(String, String)> exchangeToken(BuildContext context) async {
await checkPermissions();
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
onDispose();
throw Exception('unauthorized');
}
var uri = getRequestUri('messaging', '/api/channels/global/${channel.alias}/calls/ongoing/token');
var res = await auth.client!.post(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes));
token = result['token'];
endpoint = 'wss://${result['endpoint']}';
joinRoom(context, endpoint!, token!);
return (token!, endpoint!);
} else {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
throw Exception(message);
}
}
void joinRoom(BuildContext context, String url, String token) async {
if (isMounted) {
return;
} else {
isMounted = true;
}
ScaffoldMessenger.of(context).clearSnackBars();
final notify = ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.connectingServer),
duration: const Duration(minutes: 1),
),
);
try {
await room.connect(
url,
token,
roomOptions: RoomOptions(
dynacast: true,
adaptiveStream: true,
defaultAudioPublishOptions: const AudioPublishOptions(
name: 'call_voice',
stream: 'call_stream',
),
defaultVideoPublishOptions: const VideoPublishOptions(
name: 'callvideo',
stream: 'call_stream',
simulcast: true,
backupVideoCodec: BackupVideoCodec(enabled: true),
),
defaultScreenShareCaptureOptions: const ScreenShareCaptureOptions(
useiOSBroadcastExtension: true,
params: VideoParameters(
dimensions: VideoDimensionsPresets.h1080_169,
encoding: VideoEncoding(maxBitrate: 3 * 1000 * 1000, maxFramerate: 30),
),
),
defaultCameraCaptureOptions: CameraCaptureOptions(maxFrameRate: 30, params: videoParameters),
),
fastConnectOptions: FastConnectOptions(
microphone: TrackOption(track: audioTrack),
camera: TrackOption(track: videoTrack),
),
);
setupRoom(context);
} catch (e) {
context.showErrorDialog(e);
} finally {
notify.close();
}
}
void autoPublish(BuildContext context) async {
try {
if (enableVideo) await room.localParticipant?.setCameraEnabled(true);
} catch (error) {
await context.showErrorDialog(error);
}
try {
if (enableAudio) await room.localParticipant?.setMicrophoneEnabled(true);
} catch (error) {
await context.showErrorDialog(error);
}
}
void setupRoom(BuildContext context) {
room.addListener(onRoomDidUpdate);
setupRoomListeners(context);
sortParticipants();
WidgetsBindingCompatible.instance?.addPostFrameCallback((_) => autoPublish(context));
if (lkPlatformIsMobile()) {
Hardware.instance.setSpeakerphoneOn(true);
}
}
void setupRoomListeners(BuildContext context) {
listener
..on<RoomDisconnectedEvent>((event) async {
if (event.reason != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Call disconnected... ${event.reason}'),
));
}
onDispose();
})
..on<ParticipantEvent>((event) => sortParticipants())
..on<LocalTrackPublishedEvent>((_) => sortParticipants())
..on<LocalTrackUnpublishedEvent>((_) => sortParticipants())
..on<TrackSubscribedEvent>((_) => sortParticipants())
..on<TrackUnsubscribedEvent>((_) => sortParticipants())
..on<ParticipantNameUpdatedEvent>((event) {
sortParticipants();
})
..on<AudioPlaybackStatusChanged>((event) async {
if (!room.canPlaybackAudio) {
bool? yesno = await context.showPlayAudioManuallyDialog();
if (yesno == true) {
await room.startAudio();
}
}
});
}
void sortParticipants() {
Map<String, ParticipantTrack> mediaTracks = {};
for (var participant in room.remoteParticipants.values) {
mediaTracks[participant.sid] = ParticipantTrack(
participant: participant,
videoTrack: null,
isScreenShare: false,
);
for (var t in participant.videoTrackPublications) {
mediaTracks[participant.sid]?.videoTrack = t.track;
mediaTracks[participant.sid]?.isScreenShare = t.isScreenShare;
}
}
final mediaTrackList = mediaTracks.values.toList();
mediaTrackList.sort((a, b) {
// Loudest people first
if (a.participant.isSpeaking && b.participant.isSpeaking) {
if (a.participant.audioLevel > b.participant.audioLevel) {
return -1;
} else {
return 1;
}
}
// Last spoke first
final aSpokeAt = a.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0;
final bSpokeAt = b.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0;
if (aSpokeAt != bSpokeAt) {
return aSpokeAt > bSpokeAt ? -1 : 1;
}
// Has video first
if (a.participant.hasVideo != b.participant.hasVideo) {
return a.participant.hasVideo ? -1 : 1;
}
// First joined people first
return a.participant.joinedAt.millisecondsSinceEpoch - b.participant.joinedAt.millisecondsSinceEpoch;
});
ParticipantTrack localTrack = ParticipantTrack(
participant: room.localParticipant!,
videoTrack: null,
isScreenShare: false,
);
if (room.localParticipant != null) {
final localParticipantTracks = room.localParticipant?.videoTrackPublications;
if (localParticipantTracks != null) {
for (var t in localParticipantTracks) {
localTrack.videoTrack = t.track;
localTrack.isScreenShare = t.isScreenShare;
}
}
}
participantTracks = [localTrack, ...mediaTrackList];
if (focusTrack == null) {
focusTrack = participantTracks.first;
} else {
final idx = participantTracks.indexWhere((x) => focusTrack!.participant.sid == x.participant.sid);
focusTrack = participantTracks[idx];
}
onUpdate();
}
void revertDevices(List<MediaDevice> devices) async {
audioInputs = devices.where((d) => d.kind == 'audioinput').toList();
videoInputs = devices.where((d) => d.kind == 'videoinput').toList();
if (audioInputs.isNotEmpty) {
if (audioDevice == null && enableAudio) {
audioDevice = audioInputs.first;
Future.delayed(const Duration(milliseconds: 100), () async {
await changeLocalAudioTrack();
onUpdate();
});
}
}
if (videoInputs.isNotEmpty) {
if (videoDevice == null && enableVideo) {
videoDevice = videoInputs.first;
Future.delayed(const Duration(milliseconds: 100), () async {
await changeLocalVideoTrack();
onUpdate();
});
}
}
onUpdate();
}
Future<void> setEnableVideo(value) async {
enableVideo = value;
if (!enableVideo) {
await videoTrack?.stop();
videoTrack = null;
} else {
await changeLocalVideoTrack();
}
onUpdate();
}
Future<void> setEnableAudio(value) async {
enableAudio = value;
if (!enableAudio) {
await audioTrack?.stop();
audioTrack = null;
} else {
await changeLocalAudioTrack();
}
onUpdate();
}
Future<void> changeLocalAudioTrack() async {
if (audioTrack != null) {
await audioTrack!.stop();
audioTrack = null;
}
if (audioDevice != null) {
audioTrack = await LocalAudioTrack.create(AudioCaptureOptions(
deviceId: audioDevice!.deviceId,
));
await audioTrack!.start();
}
}
Future<void> changeLocalVideoTrack() async {
if (videoTrack != null) {
await videoTrack!.stop();
videoTrack = null;
}
if (videoDevice != null) {
videoTrack = await LocalVideoTrack.createCameraTrack(CameraCaptureOptions(
deviceId: videoDevice!.deviceId,
params: videoParameters,
));
await videoTrack!.start();
}
}
void changeFocusTrack(ParticipantTrack track) {
focusTrack = track;
onUpdate();
}
void onRoomDidUpdate() => sortParticipants();
void deactivate() {
subscription?.cancel();
}
void dispose() {
room.removeListener(onRoomDidUpdate);
(() async {
await listener.dispose();
await room.disconnect();
await room.dispose();
})();
WakelockPlus.disable();
onDispose();
}
}

View File

@ -3,7 +3,7 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:solian/models/friendship.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
class FriendProvider extends ChangeNotifier {
List<Friendship> friends = List.empty();
@ -16,11 +16,11 @@ class FriendProvider extends ChangeNotifier {
var res = await auth.client!.get(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes)) as List<dynamic>;
friends = result.map((x) => Friendship.fromJson(x)).toList();
notifyListeners();
friends = result.map((x) => Friendship.fromJson(x)).toList();
notifyListeners();
} else {
var message = utf8.decode(res.bodyBytes);
throw Exception(message);
}
}
}
}

151
lib/providers/keypair.dart Normal file
View File

@ -0,0 +1,151 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:encrypt/encrypt.dart' as encrypt;
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:solian/models/keypair.dart';
import 'package:solian/models/packet.dart';
import 'package:uuid/uuid.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
class KeypairProvider extends ChangeNotifier {
static const storage = FlutterSecureStorage();
static const encryptIV = 'WT7s~><Ae?YrJd)D';
WebSocketChannel? channel;
String? activeKeyId;
Map<String, Keypair> keys = {};
List<String> requestingKeys = List.empty(growable: true);
KeypairProvider() {
loadKeys();
}
void loadKeys() async {
final result = await storage.read(key: 'keypair');
if (result != null) {
jsonDecode(result).values.forEach((x) {
keys[x['id']] = Keypair.fromJson(x);
});
activeKeyId = await storage.read(key: 'keypairActive');
}
notifyListeners();
}
void saveKeys() async {
await storage.write(key: 'keypair', value: jsonEncode(keys));
if (activeKeyId != null) {
await storage.write(key: 'keypairActive', value: activeKeyId);
}
}
void receiveKeypair(Keypair kp) {
print('received ${kp.id}');
keys[kp.id] = kp;
requestingKeys.remove(kp.id);
notifyListeners();
saveKeys();
}
Keypair? provideKeypair(String id) {
return keys[id];
}
void importKeys(String code) {
final result = jsonDecode(utf8.fuse(base64).decode(code)).map((x) => Keypair.fromJson(x)).toList();
for (final item in result) {
if (item is Keypair) {
keys[item.id] = item;
}
}
saveKeys();
notifyListeners();
}
void setActiveKey(String id) {
if (keys[id] == null) return;
activeKeyId = id;
saveKeys();
notifyListeners();
}
void clearKeys() {
keys = {};
storage.delete(key: 'keypairActive');
saveKeys();
}
void requestKey(String id, String algorithm, int uid) {
if (channel == null) return;
if (requestingKeys.contains(id)) return;
print('requested $id');
channel!.sink.add(jsonEncode(
NetworkPackage(method: 'kex.request', payload: {
'request_id': const Uuid().v4(),
'keypair_id': id,
'algorithm': algorithm,
'owner_id': uid,
'deadline': 3,
}).toJson(),
));
requestingKeys.add(id);
Future.delayed(const Duration(seconds: 3), () {
requestingKeys.remove(id);
notifyListeners();
});
notifyListeners();
}
String? encodeViaAESKey(String keypairId, String content) {
if (keys[keypairId] == null) {
return null;
} else if (keys[keypairId]?.algorithm != 'aes') {
throw Exception('invalid algorithm');
}
final kp = keys[keypairId]!;
final iv = encrypt.IV.fromUtf8(encryptIV);
final key = encrypt.Key.fromBase64(kp.publicKey);
final encryptor = encrypt.Encrypter(encrypt.AES(key, mode: encrypt.AESMode.sic, padding: null));
return encryptor.encryptBytes(utf8.encode(content), iv: iv).base64;
}
String? decodeViaAESKey(String keypairId, String encrypted) {
if (keys[keypairId] == null) {
return null;
} else if (keys[keypairId]?.algorithm != 'aes') {
throw Exception('invalid algorithm');
}
final kp = keys[keypairId]!;
final iv = encrypt.IV.fromUtf8(encryptIV);
final key = encrypt.Key.fromBase64(kp.publicKey);
final encryptor = encrypt.Encrypter(encrypt.AES(key, mode: encrypt.AESMode.sic, padding: null));
return utf8.decode(encryptor.decryptBytes(encrypt.Encrypted.fromBase64(encrypted), iv: iv));
}
Keypair generateAESKey() {
final random = Random.secure();
final values = List<int>.generate(32, (i) => random.nextInt(256));
final key = Uint8List.fromList(values);
final kp = Keypair(
id: const Uuid().v4(),
algorithm: 'aes',
publicKey: base64.encode(key),
privateKey: null,
isOwned: true,
);
keys[kp.id] = kp;
return kp;
}
}

View File

@ -1,3 +1,3 @@
class NavigationProvider {
int selectedIndex = 0;
}
}

View File

@ -1,17 +1,56 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:solian/models/keypair.dart';
import 'package:solian/models/packet.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/models/notification.dart' as model;
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/status.dart' as status;
import 'dart:math' as math;
class NotifyProvider extends ChangeNotifier {
bool isOpened = false;
int unreadAmount = 0;
List<model.Notification> notifications = List.empty(growable: true);
final FlutterLocalNotificationsPlugin localNotify = FlutterLocalNotificationsPlugin();
NotifyProvider() {
initNotify();
requestPermissions();
}
void initNotify() {
const androidSettings = AndroidInitializationSettings('app_icon');
const darwinSettings = DarwinInitializationSettings(
notificationCategories: [
DarwinNotificationCategory('general'),
],
);
const linuxSettings = LinuxInitializationSettings(defaultActionName: 'Open notification');
const InitializationSettings initializationSettings = InitializationSettings(
android: androidSettings,
iOS: darwinSettings,
macOS: darwinSettings,
linux: linuxSettings,
);
localNotify.initialize(initializationSettings);
}
Future<void> requestPermissions() async {
if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) {
return;
}
await Permission.notification.request();
}
Future<void> fetch(AuthProvider auth) async {
if (!await auth.isAuthorized()) return;
@ -25,29 +64,118 @@ class NotifyProvider extends ChangeNotifier {
notifyListeners();
}
Future<WebSocketChannel?> connect(AuthProvider auth) async {
if (auth.client == null) await auth.pickClient();
IOWebSocketChannel? _channel;
Future<IOWebSocketChannel?> connect(
AuthProvider auth, {
Keypair? Function(String id)? onKexRequest,
Function(Keypair kp)? onKexProvide,
Function(bool status)? onStateUpdated,
bool noRetry = false,
}) async {
if (auth.client == null) await auth.loadClient();
if (!await auth.isAuthorized()) return null;
await auth.refreshToken();
var ori = getRequestUri('passport', '/api/notifications/listen');
if (_channel != null && (_channel!.innerWebSocket?.readyState ?? 0) < 2) {
return _channel;
}
var ori = getRequestUri('passport', '/api/ws');
var uri = Uri(
scheme: ori.scheme.replaceFirst('http', 'ws'),
host: ori.host,
port: ori.port,
path: ori.path,
queryParameters: {'tk': Uri.encodeComponent(auth.client!.credentials.accessToken)},
queryParameters: {'tk': Uri.encodeComponent(auth.client!.currentToken!)},
);
final channel = WebSocketChannel.connect(uri);
await channel.ready;
try {
_channel = IOWebSocketChannel.connect(uri);
if (onStateUpdated != null) onStateUpdated(true);
await _channel!.ready;
if (onStateUpdated != null) onStateUpdated(false);
} catch (e) {
if (!noRetry) {
await auth.client!.refreshToken(auth.client!.currentRefreshToken!);
connect(auth, noRetry: true);
} else {
rethrow;
}
}
return channel;
_channel!.stream.listen(
(event) {
final result = NetworkPackage.fromJson(jsonDecode(event));
switch (result.method) {
case 'notifications.new':
if (result.payload == null) break;
final notification = model.Notification.fromJson(result.payload!);
unreadAmount++;
notifications.add(notification);
notifyListeners();
notifyMessage(notification.subject, notification.content);
break;
case 'kex.request':
if (onKexRequest == null || result.payload == null) break;
final resp = onKexRequest(result.payload!['keypair_id']);
if (resp == null) break;
_channel!.sink.add(jsonEncode(
NetworkPackage(method: 'kex.provide', payload: {
'request_id': result.payload!['request_id'],
'keypair_id': resp.id,
'public_key': resp.publicKey,
'algorithm': resp.algorithm,
}).toJson(),
));
break;
case 'kex.provide':
if (onKexProvide == null || result.payload == null) break;
onKexProvide(Keypair(
id: result.payload!['keypair_id'],
algorithm: result.payload?['algorithm'] ?? 'aes',
publicKey: result.payload!['public_key'],
privateKey: result.payload?['private_key'],
));
break;
}
},
onError: (_, __) => Future.delayed(const Duration(seconds: 3), () => connect(auth)),
onDone: () => Future.delayed(const Duration(seconds: 1), () => connect(auth)),
);
return _channel!;
}
void onRemoteMessage(model.Notification item) {
notifications.add(item);
notifyListeners();
void disconnect() {
_channel?.sink.close(status.goingAway);
}
void notifyMessage(String title, String body) {
const androidSettings = AndroidNotificationDetails(
'general',
'General',
importance: Importance.high,
priority: Priority.high,
silent: true,
);
const darwinSettings = DarwinNotificationDetails(
presentAlert: true,
presentBanner: true,
presentBadge: true,
presentSound: false,
);
const linuxSettings = LinuxNotificationDetails();
localNotify.show(
math.max(1, math.Random().nextInt(100000000)),
title,
body,
const NotificationDetails(
android: androidSettings,
iOS: darwinSettings,
macOS: darwinSettings,
linux: linuxSettings,
),
);
}
void clearAt(int index) {
@ -55,7 +183,13 @@ class NotifyProvider extends ChangeNotifier {
notifyListeners();
}
void clearNonRealtime() {
void clearRealtimeNotifications() {
notifications = notifications.where((x) => !x.isRealtime).toList();
notifyListeners();
}
void allRead() {
unreadAmount = 0;
notifyListeners();
}
}

47
lib/providers/realm.dart Normal file
View File

@ -0,0 +1,47 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/services_url.dart';
class RealmProvider with ChangeNotifier {
List<Realm> realms = List.empty();
Realm? focusRealm;
Future<void> fetch(AuthProvider auth) async {
if (!await auth.isAuthorized()) return;
var uri = getRequestUri('passport', '/api/realms/me/available');
var res = await auth.client!.get(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes)) as List<dynamic>;
realms = result.map((x) => Realm.fromJson(x)).toList();
notifyListeners();
} else {
var message = utf8.decode(res.bodyBytes);
throw Exception(message);
}
}
Future<Realm> fetchSingle(AuthProvider auth, String alias) async {
var uri = getRequestUri('passport', '/api/realms/$alias');
var res = await auth.client!.get(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes));
focusRealm = Realm.fromJson(result);
notifyListeners();
return focusRealm!;
} else {
var message = utf8.decode(res.bodyBytes);
throw Exception(message);
}
}
void clearFocus() {
focusRealm = null;
notifyListeners();
}
}

View File

@ -1,99 +1,281 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:solian/models/call.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/post.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/screens/account.dart';
import 'package:solian/screens/account/friend.dart';
import 'package:solian/screens/account/keypair.dart';
import 'package:solian/screens/account/personalize.dart';
import 'package:solian/screens/auth/signup.dart';
import 'package:solian/screens/chat/call.dart';
import 'package:solian/screens/chat/chat.dart';
import 'package:solian/screens/chat/index.dart';
import 'package:solian/screens/chat/manage.dart';
import 'package:solian/screens/chat/channel/editor.dart';
import 'package:solian/screens/chat/channel/member.dart';
import 'package:solian/screens/chat/chat_list.dart';
import 'package:solian/screens/chat/chat_detail.dart';
import 'package:solian/screens/chat/channel/channel_editor.dart';
import 'package:solian/screens/chat/channel/channel_member.dart';
import 'package:solian/screens/explore.dart';
import 'package:solian/screens/notification.dart';
import 'package:solian/screens/posts/comment_editor.dart';
import 'package:solian/screens/posts/moment_editor.dart';
import 'package:solian/screens/posts/screen.dart';
import 'package:solian/screens/signin.dart';
import 'package:solian/screens/auth/signin.dart';
import 'package:solian/screens/realms/realm.dart';
import 'package:solian/screens/realms/realm_manage.dart';
import 'package:solian/screens/realms/realm_editor.dart';
import 'package:solian/screens/realms/realm_list.dart';
import 'package:solian/screens/realms/realm_member.dart';
import 'package:solian/screens/users/userinfo.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/empty.dart';
import 'package:solian/widgets/layouts/two_column.dart';
final router = GoRouter(
routes: [
GoRoute(
path: '/',
name: 'explore',
builder: (context, state) => const ExploreScreen(),
),
GoRoute(
path: '/notification',
name: 'notification',
builder: (context, state) => const NotificationScreen(),
),
GoRoute(
path: '/chat',
name: 'chat',
builder: (context, state) => const ChatIndexScreen(),
),
GoRoute(
path: '/chat/create',
name: 'chat.channel.editor',
builder: (context, state) => ChannelEditorScreen(editing: state.extra as Channel?),
),
GoRoute(
path: '/chat/c/:channel',
name: 'chat.channel',
builder: (context, state) => ChatScreen(alias: state.pathParameters['channel'] as String),
),
GoRoute(
path: '/chat/c/:channel/call',
name: 'chat.channel.call',
builder: (context, state) => ChatCall(call: state.extra as Call),
),
GoRoute(
path: '/chat/c/:channel/manage',
name: 'chat.channel.manage',
builder: (context, state) => ChatManageScreen(channel: state.extra as Channel),
),
GoRoute(
path: '/chat/c/:channel/member',
name: 'chat.channel.member',
builder: (context, state) => ChatMemberScreen(channel: state.extra as Channel),
),
GoRoute(
path: '/account',
name: 'account',
builder: (context, state) => const AccountScreen(),
),
GoRoute(
path: '/posts/publish/moments',
name: 'posts.moments.editor',
builder: (context, state) => MomentEditorScreen(editing: state.extra as Post?),
),
GoRoute(
path: '/posts/publish/comments',
name: 'posts.comments.editor',
builder: (context, state) {
final args = state.extra as CommentPostArguments;
return CommentEditorScreen(editing: args.editing, related: args.related);
},
),
GoRoute(
path: '/posts/:dataset/:alias',
name: 'posts.screen',
builder: (context, state) => PostScreen(
alias: state.pathParameters['alias'] as String,
dataset: state.pathParameters['dataset'] as String,
abstract class SolianRouter {
static final router = GoRouter(
routes: [
GoRoute(
path: '/notification',
name: 'notification',
builder: (context, state) => const NotificationScreen(),
),
),
GoRoute(
path: '/auth/sign-in',
name: 'auth.sign-in',
builder: (context, state) => SignInScreen(),
),
GoRoute(
path: '/account/friend',
name: 'account.friend',
builder: (context, state) => const FriendScreen(),
),
],
);
ShellRoute(
pageBuilder: (context, state, child) => defaultPageBuilder(
context,
state,
SolianTheme.isLargeScreen(context)
? TwoColumnLayout(
sideChild: const ExplorePostScreen(),
mainChild: child,
)
: child,
),
routes: [
GoRoute(
path: '/',
name: 'explore',
builder: (context, state) =>
!SolianTheme.isLargeScreen(context) ? const ExplorePostScreen() : const PageEmptyWidget(),
),
GoRoute(
path: '/posts/publish/moments',
name: 'posts.moments.editor',
builder: (context, state) => MomentEditorScreen(editing: state.extra as Post?),
),
GoRoute(
path: '/posts/publish/comments',
name: 'posts.comments.editor',
builder: (context, state) {
final args = state.extra as CommentPostArguments;
return CommentEditorScreen(editing: args.editing, related: args.related);
},
),
GoRoute(
path: '/posts/:dataset/:alias',
name: 'posts.details',
builder: (context, state) => PostScreen(
alias: state.pathParameters['alias'] as String,
dataset: state.pathParameters['dataset'] as String,
),
),
],
),
ShellRoute(
pageBuilder: (context, state, child) => defaultPageBuilder(
context,
state,
SolianTheme.isLargeScreen(context)
? TwoColumnLayout(
sideChild: const RealmListScreen(),
mainChild: child,
)
: child,
),
routes: [
GoRoute(
path: '/realms',
name: 'realms',
builder: (context, state) =>
!SolianTheme.isLargeScreen(context) ? const RealmListScreen() : const PageEmptyWidget(),
),
GoRoute(
path: '/realms/editor',
name: 'realms.editor',
builder: (context, state) => RealmEditorScreen(
editing: state.extra as Realm?,
realm: state.uri.queryParameters['realm'],
),
),
GoRoute(
path: '/realms/:realm/manage',
name: 'realms.manage',
builder: (context, state) => RealmManageScreen(realm: state.extra as Realm),
),
GoRoute(
path: '/realms/:realm/member',
name: 'realms.member',
builder: (context, state) => RealmMemberScreen(realm: state.extra as Realm),
),
GoRoute(
path: '/realms/:realm/posts/publish/moments',
name: 'realms.posts.moments.editor',
builder: (context, state) => MomentEditorScreen(
editing: state.extra as Post?,
realm: state.pathParameters['realm']
),
),
GoRoute(
path: '/realms/:realm/posts/:dataset/:alias',
name: 'realms.posts.details',
builder: (context, state) => PostScreen(
alias: state.pathParameters['alias'] as String,
dataset: state.pathParameters['dataset'] as String,
),
),
GoRoute(
path: '/realms/:realm',
name: 'realms.details',
builder: (context, state) => !SolianTheme.isLargeScreen(context)
? RealmScreen(alias: state.pathParameters['realm'] as String)
: const PageEmptyWidget(),
),
GoRoute(
path: '/realms/:realm/chat/:channel',
name: 'realms.chat.channel',
builder: (context, state) => ChatScreen(
alias: state.pathParameters['channel'] as String,
realm: state.pathParameters['realm'] as String,
),
),
GoRoute(
path: '/realms/:realm/chat/:channel/manage',
name: 'realms.chat.channel.manage',
builder: (context, state) => ChatDetailScreen(
channel: state.extra as Channel,
realm: state.pathParameters['realm'] as String,
),
),
GoRoute(
path: '/realms/:realm/chat/:channel/member',
name: 'realms.chat.channel.member',
builder: (context, state) => ChatMemberScreen(
channel: state.extra as Channel,
realm: state.pathParameters['realm'] as String,
),
),
],
),
ShellRoute(
pageBuilder: (context, state, child) => defaultPageBuilder(
context,
state,
SolianTheme.isLargeScreen(context)
? TwoColumnLayout(
sideChild: const ChatListScreen(),
mainChild: child,
)
: child,
),
routes: [
GoRoute(
path: '/chat',
name: 'chat',
builder: (context, state) =>
!SolianTheme.isLargeScreen(context) ? const ChatListScreen() : const PageEmptyWidget(),
),
GoRoute(
path: '/chat/editor',
name: 'chat.channel.editor',
builder: (context, state) => ChannelEditorScreen(
editing: state.extra as Channel?,
realm: state.uri.queryParameters['realm'] ?? 'global',
),
),
GoRoute(
path: '/chat/:channel',
name: 'chat.channel',
builder: (context, state) => ChatScreen(alias: state.pathParameters['channel'] as String),
),
GoRoute(
path: '/chat/:channel/manage',
name: 'chat.channel.manage',
builder: (context, state) => ChatDetailScreen(channel: state.extra as Channel),
),
GoRoute(
path: '/chat/:channel/member',
name: 'chat.channel.member',
builder: (context, state) => ChatMemberScreen(channel: state.extra as Channel),
),
],
),
GoRoute(
path: '/chat/:channel/call',
name: 'chat.channel.call',
builder: (context, state) => ChatCall(call: state.extra as Call),
),
ShellRoute(
pageBuilder: (context, state, child) => defaultPageBuilder(
context,
state,
SolianTheme.isLargeScreen(context)
? TwoColumnLayout(
sideChild: const AccountScreen(),
mainChild: child,
)
: child,
),
routes: [
GoRoute(
path: '/account',
name: 'account',
builder: (context, state) =>
!SolianTheme.isLargeScreen(context) ? const AccountScreen() : const PageEmptyWidget(),
),
GoRoute(
path: '/auth/sign-in',
name: 'auth.sign-in',
builder: (context, state) => SignInScreen(),
),
GoRoute(
path: '/auth/sign-up',
name: 'auth.sign-up',
builder: (context, state) => SignUpScreen(),
),
GoRoute(
path: '/account/friend',
name: 'account.friend',
builder: (context, state) => const FriendScreen(),
),
GoRoute(
path: '/account/personalize',
name: 'account.personalize',
builder: (context, state) => const PersonalizeScreen(),
),
GoRoute(
path: '/account/keypair',
name: 'account.keypair',
builder: (context, state) => const KeypairScreen(),
),
],
),
GoRoute(
path: '/users/:user',
name: 'users.info',
builder: (context, state) => UserInfoScreen(name: state.pathParameters['user'] as String),
),
],
);
static GoRoute get currentRoute => SolianRouter.router.routerDelegate.currentConfiguration.last.route;
static Page defaultPageBuilder(
BuildContext context,
GoRouterState state,
Widget child,
) =>
MaterialPage(
key: state.pageKey,
restorationId: state.pageKey.value,
child: child,
);
}

View File

@ -1,28 +1,49 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/providers/keypair.dart';
import 'package:solian/providers/notify.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/account/avatar.dart';
import 'package:solian/widgets/common_wrapper.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/scaffold.dart';
class AccountScreen extends StatefulWidget {
class AccountScreen extends StatelessWidget {
const AccountScreen({super.key});
@override
State<AccountScreen> createState() => _AccountScreenState();
Widget build(BuildContext context) {
return IndentScaffold(
title: AppLocalizations.of(context)!.account,
fixedAppBarColor: SolianTheme.isLargeScreen(context),
body: AccountScreenWidget(
onSelect: (item) {
SolianRouter.router.pushNamed(item);
},
),
);
}
}
class _AccountScreenState extends State<AccountScreen> {
bool isAuthorized = false;
class AccountScreenWidget extends StatefulWidget {
final Function(String item) onSelect;
const AccountScreenWidget({super.key, required this.onSelect});
@override
State<AccountScreenWidget> createState() => _AccountScreenWidgetState();
}
class _AccountScreenWidgetState extends State<AccountScreenWidget> {
bool _isAuthorized = false;
@override
void initState() {
Future.delayed(Duration.zero, () async {
var authorized = await context.read<AuthProvider>().isAuthorized();
setState(() => isAuthorized = authorized);
setState(() => _isAuthorized = authorized);
});
super.initState();
@ -31,65 +52,76 @@ class _AccountScreenState extends State<AccountScreen> {
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthProvider>();
final keypair = context.read<KeypairProvider>();
return LayoutWrapper(
title: AppLocalizations.of(context)!.account,
child: isAuthorized
? Column(
children: [
const Padding(
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 24),
child: NameCard(),
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.diversity_1),
title: Text(AppLocalizations.of(context)!.friend),
onTap: () {
router.goNamed('account.friend');
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.logout),
title: Text(AppLocalizations.of(context)!.signOut),
onTap: () {
auth.signoff();
setState(() {
isAuthorized = false;
});
},
),
],
)
: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ActionCard(
icon: const Icon(Icons.login, color: Colors.white),
title: AppLocalizations.of(context)!.signIn,
caption: AppLocalizations.of(context)!.signInCaption,
onTap: () {
router.pushNamed('auth.sign-in').then((did) {
auth.isAuthorized().then((value) {
setState(() => isAuthorized = value);
});
});
},
),
ActionCard(
icon: const Icon(Icons.add, color: Colors.white),
title: AppLocalizations.of(context)!.signUp,
caption: AppLocalizations.of(context)!.signUpCaption,
onTap: () {
launchUrl(getRequestUri('passport', '/sign-up'));
},
),
],
),
final actionItems = [
(const Icon(Icons.color_lens), AppLocalizations.of(context)!.personalize, 'account.personalize'),
(const Icon(Icons.diversity_1), AppLocalizations.of(context)!.friend, 'account.friend'),
(const Icon(Icons.key), AppLocalizations.of(context)!.keypair, 'account.keypair'),
];
if (_isAuthorized) {
return Column(
children: [
const Padding(
padding: EdgeInsets.only(top: 16, bottom: 8, left: 24, right: 24),
child: NameCard(),
),
...(actionItems.map(
(x) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: x.$1,
title: Text(x.$2),
onTap: () {
widget.onSelect(x.$3);
},
),
);
)),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.logout),
title: Text(AppLocalizations.of(context)!.signOut),
onTap: () {
auth.signoff();
keypair.clearKeys();
context.read<NotifyProvider>().disconnect();
context.read<ChatProvider>().disconnect();
setState(() {
_isAuthorized = false;
});
},
),
],
);
} else {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ActionCard(
icon: const Icon(Icons.login, color: Colors.white),
title: AppLocalizations.of(context)!.signIn,
caption: AppLocalizations.of(context)!.signInCaption,
onTap: () {
SolianRouter.router.pushNamed('auth.sign-in').then((did) {
auth.isAuthorized().then((value) {
setState(() => _isAuthorized = value);
});
});
},
),
ActionCard(
icon: const Icon(Icons.add, color: Colors.white),
title: AppLocalizations.of(context)!.signUp,
caption: AppLocalizations.of(context)!.signUpCaption,
onTap: () {
SolianRouter.router.pushNamed('auth.sign-up');
},
),
],
),
);
}
}
}
@ -99,7 +131,7 @@ class NameCard extends StatelessWidget {
Future<Widget> renderAvatar(BuildContext context) async {
final auth = context.read<AuthProvider>();
final profiles = await auth.getProfiles();
return AccountAvatar(source: profiles["picture"], direct: true);
return AccountAvatar(source: profiles['picture'], direct: true);
}
Future<Column> renderLabel(BuildContext context) async {
@ -109,13 +141,19 @@ class NameCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
profiles["nick"],
profiles['nick'],
maxLines: 1,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
overflow: TextOverflow.ellipsis,
),
),
Text(profiles["email"])
Text(
profiles['email'],
maxLines: 1,
style: const TextStyle(overflow: TextOverflow.ellipsis),
)
],
);
}
@ -144,7 +182,7 @@ class NameCard extends StatelessWidget {
future: renderLabel(context),
builder: (BuildContext context, AsyncSnapshot<Column> snapshot) {
if (snapshot.hasData) {
return snapshot.data!;
return Expanded(child: snapshot.data!);
} else {
return const Column();
}
@ -164,7 +202,13 @@ class ActionCard extends StatelessWidget {
final String caption;
final Function onTap;
const ActionCard({super.key, required this.onTap, required this.title, required this.caption, required this.icon});
const ActionCard({
super.key,
required this.onTap,
required this.title,
required this.caption,
required this.icon,
});
@override
Widget build(BuildContext context) {
@ -191,9 +235,13 @@ class ActionCard extends StatelessWidget {
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
overflow: TextOverflow.clip,
),
),
Text(caption),
Text(
caption,
style: const TextStyle(overflow: TextOverflow.clip),
),
],
),
),

View File

@ -5,22 +5,37 @@ import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/friendship.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/account/avatar.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class FriendScreen extends StatefulWidget {
class FriendScreen extends StatelessWidget {
const FriendScreen({super.key});
@override
State<FriendScreen> createState() => _FriendScreenState();
Widget build(BuildContext context) {
return IndentScaffold(
title: AppLocalizations.of(context)!.friend,
hideDrawer: true,
body: const FriendScreenWidget(),
);
}
}
class _FriendScreenState extends State<FriendScreen> {
class FriendScreenWidget extends StatefulWidget {
const FriendScreenWidget({super.key});
@override
State<FriendScreenWidget> createState() => _FriendScreenWidgetState();
}
class _FriendScreenWidgetState extends State<FriendScreenWidget> {
bool _isSubmitting = false;
int _selfId = 0;
List<Friendship> _friendships = List.empty();
Future<void> fetchFriendships() async {
@ -40,9 +55,7 @@ class _FriendScreenState extends State<FriendScreen> {
});
} else {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
context.showErrorDialog(message);
}
}
@ -65,9 +78,7 @@ class _FriendScreenState extends State<FriendScreen> {
await fetchFriendships();
} else {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
context.showErrorDialog(message);
}
setState(() => _isSubmitting = false);
@ -97,9 +108,7 @@ class _FriendScreenState extends State<FriendScreen> {
await fetchFriendships();
} else {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
context.showErrorDialog(message);
}
setState(() => _isSubmitting = false);
@ -160,7 +169,9 @@ class _FriendScreenState extends State<FriendScreen> {
DismissDirection getDismissDirection(Friendship relation) {
if (relation.status == 2) return DismissDirection.endToStart;
if (relation.status == 1) return DismissDirection.startToEnd;
if (relation.status == 0 && relation.relatedId != _selfId) return DismissDirection.startToEnd;
if (relation.status == 0 && relation.relatedId != _selfId) {
return DismissDirection.startToEnd;
}
return DismissDirection.horizontal;
}
@ -212,15 +223,12 @@ class _FriendScreenState extends State<FriendScreen> {
);
}
return IndentWrapper(
title: AppLocalizations.of(context)!.friend,
appBarActions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => promptAddFriend(),
),
],
child: RefreshIndicator(
return Scaffold(
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () => promptAddFriend(),
),
body: RefreshIndicator(
onRefresh: () => fetchFriendships(),
child: CustomScrollView(
slivers: [

View File

@ -0,0 +1,191 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/keypair.dart';
import 'package:solian/providers/keypair.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class KeypairScreen extends StatelessWidget {
const KeypairScreen({super.key});
Widget getIcon(KeypairProvider provider, Keypair item) {
if (item.id == provider.activeKeyId) {
return const Icon(Icons.check_box);
} else if (item.isOwned) {
return const Icon(Icons.check_box_outlined);
} else {
return const Icon(Icons.key);
}
}
void importKeys(BuildContext context) async {
final controller = TextEditingController();
final input = await showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(AppLocalizations.of(context)!.import),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(AppLocalizations.of(context)!.keypairImportHint),
const SizedBox(height: 18),
TextField(
controller: controller,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.keypairSecretCode,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
],
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
),
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
child: Text(AppLocalizations.of(context)!.next),
onPressed: () {
Navigator.pop(context, controller.text);
},
),
],
);
},
);
WidgetsBinding.instance.addPostFrameCallback((_) => controller.dispose());
if (input == null || input.isEmpty) return;
context.read<KeypairProvider>().importKeys(input);
}
void exportKeys(BuildContext context) {
showModalBottomSheet(context: context, builder: (context) => const KeypairExportWidget());
}
@override
Widget build(BuildContext context) {
final keypair = context.watch<KeypairProvider>();
final keys = keypair.keys.values.toList();
return IndentScaffold(
title: AppLocalizations.of(context)!.keypair,
hideDrawer: true,
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.generating_tokens),
onPressed: () {
final result = keypair.generateAESKey();
keypair.setActiveKey(result.id);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.keypairGenerated),
));
},
),
appBarActions: [
IconButton(
icon: const Icon(Icons.upload),
tooltip: AppLocalizations.of(context)!.import,
onPressed: () => importKeys(context),
),
IconButton(
icon: const Icon(Icons.download),
tooltip: AppLocalizations.of(context)!.export,
onPressed: () => exportKeys(context),
),
],
body: ListView.builder(
itemCount: keys.length,
itemBuilder: (context, index) {
final element = keys[index];
final randomId = DateTime.now().microsecondsSinceEpoch >> 10;
return Dismissible(
key: Key(randomId.toString()),
background: Container(
color: Colors.teal,
padding: const EdgeInsets.symmetric(horizontal: 20),
alignment: Alignment.centerLeft,
child: const Icon(Icons.check, color: Colors.white),
),
direction: keypair.activeKeyId != element.id && element.isOwned
? DismissDirection.horizontal
: DismissDirection.none,
child: ListTile(
leading: getIcon(keypair, element),
title: Text('${element.algorithm.toUpperCase()} Key'),
subtitle: Text(element.id.toUpperCase()),
),
onDismissed: (_) {
keypair.setActiveKey(element.id);
},
);
},
),
);
}
}
class KeypairExportWidget extends StatelessWidget {
const KeypairExportWidget({super.key});
String getEncodedContent(BuildContext context) {
final keypair = context.read<KeypairProvider>();
return utf8.fuse(base64).encode(jsonEncode(
keypair.keys.values.map((x) => x.toJson()).toList(),
));
}
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(32)),
child: SizedBox(
width: double.infinity,
height: 640,
child: ListView(
children: [
Container(
padding: const EdgeInsets.only(left: 20, top: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.export,
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
),
Padding(
padding: const EdgeInsets.only(left: 20, right: 20, top: 20, bottom: 20),
child: Text(
AppLocalizations.of(context)!.keypairExportHint,
style: Theme.of(context).textTheme.bodyMedium,
),
),
Markdown(
selectable: true,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
data: '```\n${getEncodedContent(context)}\n```',
padding: const EdgeInsets.all(0),
styleSheet: MarkdownStyleSheet(
codeblockPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 8),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,324 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:http/http.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class PersonalizeScreen extends StatelessWidget {
const PersonalizeScreen({super.key});
@override
Widget build(BuildContext context) {
return IndentScaffold(
title: AppLocalizations.of(context)!.personalize,
hideDrawer: true,
body: const PersonalizeScreenWidget(),
);
}
}
class PersonalizeScreenWidget extends StatefulWidget {
const PersonalizeScreenWidget({super.key});
@override
State<PersonalizeScreenWidget> createState() => _PersonalizeScreenWidgetState();
}
class _PersonalizeScreenWidgetState extends State<PersonalizeScreenWidget> {
final _imagePicker = ImagePicker();
final _usernameController = TextEditingController();
final _nicknameController = TextEditingController();
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _descriptionController = TextEditingController();
final _birthdayController = TextEditingController();
String? _avatar;
String? _banner;
DateTime? _birthday;
bool _isSubmitting = false;
void editBirthday() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _birthday,
firstDate: DateTime(DateTime.now().year - 200),
lastDate: DateTime(DateTime.now().year + 200),
);
if (picked != null && picked != _birthday) {
setState(() {
_birthday = picked;
_birthdayController.text = DateFormat('yyyy-MM-dd hh:mm').format(_birthday!);
});
}
}
void resetInputs() async {
final auth = context.read<AuthProvider>();
final prof = await auth.getProfiles();
setState(() {
_usernameController.text = prof['name'];
_nicknameController.text = prof['nick'];
_descriptionController.text = prof['description'];
_firstNameController.text = prof['profile']['first_name'];
_lastNameController.text = prof['profile']['last_name'];
if (prof['avatar'] != null && prof['avatar'].isNotEmpty) {
_avatar = getRequestUri('passport', '/api/avatar/${prof['avatar']}').toString();
}
if (prof['banner'] != null && prof['banner'].isNotEmpty) {
_banner = getRequestUri('passport', '/api/avatar/${prof['banner']}').toString();
}
if (prof['profile']['birthday'] != null) {
_birthday = DateTime.parse(prof['profile']['birthday']);
_birthdayController.text = DateFormat('yyyy-MM-dd hh:mm').format(_birthday!);
}
});
}
void applyChanges() async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
final res = await auth.client!.put(
getRequestUri('passport', '/api/users/me'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'nick': _nicknameController.value.text,
'description': _descriptionController.value.text,
'first_name': _firstNameController.value.text,
'last_name': _lastNameController.value.text,
'birthday': _birthday?.toIso8601String(),
}),
);
if (res.statusCode == 200) {
await auth.fetchProfiles();
resetInputs();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.personalizeApplied),
));
} else {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
}
setState(() => _isSubmitting = false);
}
Future<void> applyImage(String position) async {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
final image = await _imagePicker.pickImage(source: ImageSource.gallery);
if (image == null) return;
setState(() => _isSubmitting = true);
final file = File(image.path);
try {
final req = MultipartRequest('PUT', getRequestUri('passport', '/api/users/me/$position'));
req.files.add(await MultipartFile.fromPath(position, file.path));
var res = await auth.client!.send(req);
if (res.statusCode == 200) {
await auth.fetchProfiles();
resetInputs();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.personalizeApplied),
));
} else {
throw Exception(utf8.decode(await res.stream.toBytes()));
}
} catch (err) {
context.showErrorDialog(err);
}
setState(() => _isSubmitting = false);
}
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () {
resetInputs();
});
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: ListView(
children: [
_isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
const SizedBox(height: 24),
Stack(
children: [
AccountAvatar(source: _avatar ?? '', radius: 40, direct: true),
Positioned(
bottom: 0,
left: 40,
child: FloatingActionButton.small(
heroTag: const Key('avatar-editor'),
onPressed: () => applyImage('avatar'),
child: const Icon(
Icons.camera,
),
),
),
],
),
const SizedBox(height: 16),
Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Container(
color: Theme.of(context).colorScheme.surfaceVariant,
child: _banner != null
? Image.network(
_banner!,
fit: BoxFit.cover,
loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
: null,
),
);
},
)
: Container(),
),
),
),
Positioned(
bottom: 16,
right: 16,
child: FloatingActionButton(
heroTag: const Key('banner-editor'),
onPressed: () => applyImage('banner'),
child: const Icon(
Icons.camera_alt,
),
),
),
],
),
const SizedBox(height: 24),
Row(
children: [
Flexible(
flex: 1,
child: TextField(
readOnly: true,
controller: _usernameController,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.username,
prefixText: '@',
),
),
),
const SizedBox(width: 16),
Flexible(
flex: 1,
child: TextField(
controller: _nicknameController,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.nickname,
),
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Flexible(
flex: 1,
child: TextField(
controller: _firstNameController,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.firstName,
),
),
),
const SizedBox(width: 16),
Flexible(
flex: 1,
child: TextField(
controller: _lastNameController,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.lastName,
),
),
),
],
),
const SizedBox(height: 16),
TextField(
controller: _descriptionController,
keyboardType: TextInputType.multiline,
maxLines: null,
minLines: 3,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.description,
),
),
const SizedBox(height: 16),
TextField(
controller: _birthdayController,
readOnly: true,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.birthday,
),
onTap: editBirthday,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isSubmitting ? null : () => resetInputs(),
child: Text(AppLocalizations.of(context)!.reset),
),
ElevatedButton(
onPressed: _isSubmitting ? null : () => applyChanges(),
child: Text(AppLocalizations.of(context)!.apply),
),
],
),
],
),
);
}
}

View File

@ -1,41 +0,0 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class AuthorizationScreen extends StatelessWidget {
final Uri authorizationUrl;
const AuthorizationScreen(this.authorizationUrl, {super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context)!.signIn),
),
body: Stack(children: [
WebViewWidget(
controller: WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.white)
..setNavigationDelegate(NavigationDelegate(
onNavigationRequest: (NavigationRequest request) {
if (request.url.startsWith('solian')) {
Navigator.of(context).pop(request.url);
WebViewCookieManager().clearCookies();
return NavigationDecision.prevent;
} else if (request.url.contains("sign-up")) {
launchUrl(Uri.parse(request.url));
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
))
..loadRequest(authorizationUrl)
..clearCache(),
),
]),
);
}
}

View File

@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/providers/notify.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:url_launcher/url_launcher_string.dart';
class SignInScreen extends StatelessWidget {
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
SignInScreen({super.key});
void performSignIn(BuildContext context) {
final auth = context.read<AuthProvider>();
final username = _usernameController.value.text;
final password = _passwordController.value.text;
if (username.isEmpty || password.isEmpty) return;
auth.signin(context, username, password).then((_) {
context.read<ChatProvider>().connect(auth);
context.read<NotifyProvider>().connect(auth);
SolianRouter.router.pop(true);
}).catchError((e) {
List<String> messages = e.toString().split('\n');
if (messages.last.contains('risk')) {
final ticketId = RegExp(r'ticketId=(\d+)').firstMatch(messages.last);
if (ticketId == null) {
context.showErrorDialog('requested to multi-factor authenticate, but the ticket id was not found');
}
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(AppLocalizations.of(context)!.riskDetection),
content: Text(AppLocalizations.of(context)!.signInRiskDetected),
actions: [
TextButton(
child: Text(AppLocalizations.of(context)!.next),
onPressed: () {
launchUrlString(
getRequestUri('passport', '/mfa?ticket=${ticketId!.group(1)}').toString(),
);
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
},
)
],
);
},
);
} else {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(messages.last),
));
}
});
}
@override
Widget build(BuildContext context) {
return IndentScaffold(
title: AppLocalizations.of(context)!.signIn,
hideDrawer: true,
body: Center(
child: Container(
width: MediaQuery.of(context).size.width * 0.6,
constraints: const BoxConstraints(maxWidth: 360),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Image.asset('assets/logo.png', width: 72, height: 72),
),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _usernameController,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.username,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 12),
TextField(
obscureText: true,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.password],
controller: _passwordController,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.password,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (_) => performSignIn(context),
),
const SizedBox(height: 16),
ElevatedButton(
child: Text(AppLocalizations.of(context)!.signIn),
onPressed: () => performSignIn(context),
)
],
),
),
),
);
}
}

View File

@ -0,0 +1,154 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:http/http.dart' as http;
class SignUpScreen extends StatelessWidget {
final _emailController = TextEditingController();
final _usernameController = TextEditingController();
final _nicknameController = TextEditingController();
final _passwordController = TextEditingController();
final http.Client _client = http.Client();
SignUpScreen({super.key});
void performSignIn(BuildContext context) async {
final email = _emailController.value.text;
final username = _usernameController.value.text;
final nickname = _passwordController.value.text;
final password = _passwordController.value.text;
if (email.isEmpty ||
username.isEmpty ||
nickname.isEmpty ||
password.isEmpty) return;
final uri = getRequestUri('passport', '/api/users');
final res = await _client.post(
uri,
headers: {
'Content-Type': 'application/json',
},
body: jsonEncode({
'name': username,
'nick': nickname,
'email': email,
'password': password,
}),
);
if (res.statusCode == 200) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(AppLocalizations.of(context)!.signUpDone),
content: Text(AppLocalizations.of(context)!.signUpDoneCaption),
actions: [
TextButton(
child: Text(AppLocalizations.of(context)!.confirmOkay),
onPressed: () => Navigator.pop(context),
),
],
);
},
).then((_) {
SolianRouter.router.replaceNamed('auth.sign-in');
});
} else {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
}
}
@override
Widget build(BuildContext context) {
return IndentScaffold(
title: AppLocalizations.of(context)!.signUp,
hideDrawer: true,
body: Center(
child: Container(
width: MediaQuery.of(context).size.width * 0.6,
constraints: const BoxConstraints(maxWidth: 360),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Image.asset('assets/logo.png', width: 72, height: 72),
),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _usernameController,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.username,
prefixText: '@',
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 12),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _nicknameController,
autofillHints: const [AutofillHints.nickname],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.nickname,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 12),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _emailController,
autofillHints: const [AutofillHints.email],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.email,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 12),
TextField(
obscureText: true,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.password],
controller: _passwordController,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.password,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (_) => performSignIn(context),
),
const SizedBox(height: 16),
ElevatedButton(
child: Text(AppLocalizations.of(context)!.signUp),
onPressed: () => performSignIn(context),
)
],
),
),
),
);
}
}

View File

@ -1,22 +1,16 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/call.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/chat/call/exts.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/chat/call/call_controls.dart';
import 'package:solian/widgets/chat/call/participant.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:solian/widgets/chat/call/participant_menu.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'dart:math' as math;
import '../../widgets/chat/call/controls.dart';
class ChatCall extends StatefulWidget {
final Call call;
@ -27,351 +21,39 @@ class ChatCall extends StatefulWidget {
}
class _ChatCallState extends State<ChatCall> {
String? _token;
String? _endpoint;
bool _isHandled = false;
bool _isMounted = false;
late ChatProvider _chat;
StreamSubscription? _subscription;
List<MediaDevice> _audioInputs = [];
List<MediaDevice> _videoInputs = [];
bool _enableAudio = true;
bool _enableVideo = false;
LocalAudioTrack? _audioTrack;
LocalVideoTrack? _videoTrack;
MediaDevice? _videoDevice;
MediaDevice? _audioDevice;
final VideoParameters _videoParameters = VideoParametersPresets.h720_169;
late Room _callRoom;
late EventsListener<RoomEvent> _callListener;
List<ParticipantTrack> _participantTracks = [];
bool get _fastConnection => _callRoom.engine.fastConnectOptions != null;
Future<void> checkPermissions() async {
await Permission.camera.request();
await Permission.microphone.request();
await Permission.bluetooth.request();
await Permission.bluetoothConnect.request();
}
Future<(String, String)> exchangeToken() async {
await checkPermissions();
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
router.pop();
throw Error();
}
var uri = getRequestUri('messaging', '/api/channels/${widget.call.channel.alias}/calls/ongoing/token');
var res = await auth.client!.post(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes));
_token = result['token'];
_endpoint = 'wss://${result['endpoint']}';
joinRoom(_endpoint!, _token!);
return (_token!, _endpoint!);
} else {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
throw Exception(message);
}
}
void joinRoom(String url, String token) async {
if(_isMounted) {
return;
} else {
_isMounted = true;
}
ScaffoldMessenger.of(context).clearSnackBars();
final notify = ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.connectingServer),
duration: const Duration(minutes: 1),
),
);
try {
await _callRoom.connect(
url,
token,
roomOptions: RoomOptions(
dynacast: true,
adaptiveStream: true,
defaultAudioPublishOptions: const AudioPublishOptions(
name: 'call_voice',
stream: 'call_stream',
),
defaultVideoPublishOptions: const VideoPublishOptions(
name: 'call_video',
stream: 'call_stream',
simulcast: true,
backupVideoCodec: BackupVideoCodec(enabled: true),
),
defaultScreenShareCaptureOptions: const ScreenShareCaptureOptions(
useiOSBroadcastExtension: true,
params: VideoParameters(
dimensions: VideoDimensionsPresets.h1080_169,
encoding: VideoEncoding(maxBitrate: 3 * 1000 * 1000, maxFramerate: 30),
),
),
defaultCameraCaptureOptions: CameraCaptureOptions(maxFrameRate: 30, params: _videoParameters),
),
fastConnectOptions: FastConnectOptions(
microphone: TrackOption(track: _audioTrack),
camera: TrackOption(track: _videoTrack),
),
);
setupRoom();
} catch (e) {
final message = e.toString();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
} finally {
notify.close();
}
}
void askPublish() async {
final result = await context.showPublishDialog();
if (result != true) return;
try {
await _callRoom.localParticipant?.setCameraEnabled(true);
} catch (error) {
await context.showErrorDialog(error);
}
try {
await _callRoom.localParticipant?.setMicrophoneEnabled(true);
} catch (error) {
await context.showErrorDialog(error);
}
}
void setupRoom() {
_callRoom.addListener(onRoomDidUpdate);
setupRoomListeners();
sortParticipants();
WidgetsBindingCompatible.instance?.addPostFrameCallback((_) {
if (!_fastConnection) {
askPublish();
}
});
if (lkPlatformIsMobile()) {
Hardware.instance.setSpeakerphoneOn(true);
}
}
void setupRoomListeners() {
_callListener
..on<RoomDisconnectedEvent>((event) async {
if (event.reason != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Call disconnected... ${event.reason}'),
));
}
if (router.canPop()) router.pop();
})
..on<ParticipantEvent>((event) => sortParticipants())
..on<RoomRecordingStatusChanged>((event) {
context.showRecordingStatusChangedDialog(event.activeRecording);
})
..on<LocalTrackPublishedEvent>((_) => sortParticipants())
..on<LocalTrackUnpublishedEvent>((_) => sortParticipants())
..on<TrackSubscribedEvent>((_) => sortParticipants())
..on<TrackUnsubscribedEvent>((_) => sortParticipants())
..on<ParticipantNameUpdatedEvent>((event) {
sortParticipants();
})
..on<AudioPlaybackStatusChanged>((event) async {
if (!_callRoom.canPlaybackAudio) {
bool? yesno = await context.showPlayAudioManuallyDialog();
if (yesno == true) {
await _callRoom.startAudio();
}
}
});
}
void sortParticipants() {
List<ParticipantTrack> userMediaTracks = [];
List<ParticipantTrack> screenTracks = [];
for (var participant in _callRoom.remoteParticipants.values) {
for (var t in participant.videoTrackPublications) {
if (t.isScreenShare) {
screenTracks.add(ParticipantTrack(
participant: participant,
videoTrack: t.track,
isScreenShare: true,
));
} else {
userMediaTracks.add(ParticipantTrack(
participant: participant,
videoTrack: t.track,
isScreenShare: false,
));
}
}
}
userMediaTracks.sort((a, b) {
// Loudest people first
if (a.participant.isSpeaking && b.participant.isSpeaking) {
if (a.participant.audioLevel > b.participant.audioLevel) {
return -1;
} else {
return 1;
}
}
// Last spoke first
final aSpokeAt = a.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0;
final bSpokeAt = b.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0;
if (aSpokeAt != bSpokeAt) {
return aSpokeAt > bSpokeAt ? -1 : 1;
}
// Has video first
if (a.participant.hasVideo != b.participant.hasVideo) {
return a.participant.hasVideo ? -1 : 1;
}
// First joined people first
return a.participant.joinedAt.millisecondsSinceEpoch - b.participant.joinedAt.millisecondsSinceEpoch;
});
final localParticipantTracks = _callRoom.localParticipant?.videoTrackPublications;
if (localParticipantTracks != null) {
for (var t in localParticipantTracks) {
if (t.isScreenShare) {
screenTracks.add(ParticipantTrack(
participant: _callRoom.localParticipant!,
videoTrack: t.track,
isScreenShare: true,
));
} else {
userMediaTracks.add(ParticipantTrack(
participant: _callRoom.localParticipant!,
videoTrack: t.track,
isScreenShare: false,
));
}
}
}
setState(() {
_participantTracks = [...screenTracks, ...userMediaTracks];
});
}
void onRoomDidUpdate() => sortParticipants();
void revertDevices(List<MediaDevice> devices) async {
_audioInputs = devices.where((d) => d.kind == 'audioinput').toList();
_videoInputs = devices.where((d) => d.kind == 'videoinput').toList();
if (_audioInputs.isNotEmpty) {
if (_audioDevice == null && _enableAudio) {
_audioDevice = _audioInputs.first;
Future.delayed(const Duration(milliseconds: 100), () async {
await changeLocalAudioTrack();
setState(() {});
});
}
}
if (_videoInputs.isNotEmpty) {
if (_videoDevice == null && _enableVideo) {
_videoDevice = _videoInputs.first;
Future.delayed(const Duration(milliseconds: 100), () async {
await changeLocalVideoTrack();
setState(() {});
});
}
}
setState(() {});
}
Future<void> setEnableVideo(value) async {
_enableVideo = value;
if (!_enableVideo) {
await _videoTrack?.stop();
_videoTrack = null;
} else {
await changeLocalVideoTrack();
}
setState(() {});
}
Future<void> setEnableAudio(value) async {
_enableAudio = value;
if (!_enableAudio) {
await _audioTrack?.stop();
_audioTrack = null;
} else {
await changeLocalAudioTrack();
}
setState(() {});
}
Future<void> changeLocalAudioTrack() async {
if (_audioTrack != null) {
await _audioTrack!.stop();
_audioTrack = null;
}
if (_audioDevice != null) {
_audioTrack = await LocalAudioTrack.create(AudioCaptureOptions(
deviceId: _audioDevice!.deviceId,
));
await _audioTrack!.start();
}
}
Future<void> changeLocalVideoTrack() async {
if (_videoTrack != null) {
await _videoTrack!.stop();
_videoTrack = null;
}
if (_videoDevice != null) {
_videoTrack = await LocalVideoTrack.createCameraTrack(CameraCaptureOptions(
deviceId: _videoDevice!.deviceId,
params: _videoParameters,
));
await _videoTrack!.start();
}
}
ChatCallInstance get _call => _chat.currentCall!;
@override
void initState() {
super.initState();
_subscription = Hardware.instance.onDeviceChange.stream.listen(revertDevices);
_callRoom = Room();
_callListener = _callRoom.createListener();
Hardware.instance.enumerateDevices().then(revertDevices);
WidgetsBinding.instance.addPostFrameCallback((_) {
_chat.setCallShown(true);
});
}
@override
Widget build(BuildContext context) {
return IndentWrapper(
title: AppLocalizations.of(context)!.chatCall,
noSafeArea: true,
hideDrawer: true,
child: FutureBuilder(
future: exchangeToken(),
_chat = context.watch<ChatProvider>();
if (!_isHandled) {
_isHandled = true;
if (_chat.handleCallJoin(widget.call, widget.call.channel)) {
_chat.currentCall?.init();
}
}
Widget content;
if (_chat.currentCall == null) {
content = const Center(
child: CircularProgressIndicator(),
);
} else {
content = FutureBuilder(
future: _call.exchangeToken(context),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data == null) {
return const Center(child: CircularProgressIndicator());
@ -382,15 +64,22 @@ class _ChatCallState extends State<ChatCall> {
Column(
children: [
Expanded(
child: _participantTracks.isNotEmpty
? ParticipantWidget.widgetFor(_participantTracks.first, showStatsLayer: true)
: Container(),
child: Container(
color: Theme.of(context).colorScheme.surfaceVariant,
child: _call.focusTrack != null
? InteractiveParticipantWidget(
isFixed: false,
participant: _call.focusTrack!,
onTap: () {},
)
: Container(),
),
),
if (_callRoom.localParticipant != null)
SafeArea(
top: false,
child: ControlsWidget(_callRoom, _callRoom.localParticipant!),
)
if (_call.room.localParticipant != null)
ControlsWidget(
_call.room,
_call.room.localParticipant!,
),
],
),
Positioned(
@ -398,39 +87,101 @@ class _ChatCallState extends State<ChatCall> {
right: 0,
top: 0,
child: SizedBox(
height: 120,
height: 128,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: math.max(0, _participantTracks.length - 1),
itemBuilder: (BuildContext context, int index) => SizedBox(
width: 180,
height: 120,
child: ParticipantWidget.widgetFor(_participantTracks[index + 1]),
),
itemCount: math.max(0, _call.participantTracks.length),
itemBuilder: (BuildContext context, int index) {
final track = _call.participantTracks[index];
if (track.participant.sid ==
_call.focusTrack?.participant.sid) {
return Container();
}
return Padding(
padding: const EdgeInsets.only(top: 8, left: 8),
child: ClipRRect(
borderRadius:
const BorderRadius.all(Radius.circular(8)),
child: InteractiveParticipantWidget(
isFixed: true,
width: 120,
height: 120,
color: Theme.of(context).cardColor,
participant: track,
onTap: () {
if (track.participant.sid !=
_call.focusTrack?.participant.sid) {
_call.changeFocusTrack(track);
}
},
),
),
);
},
),
),
),
],
);
},
),
);
}
return IndentScaffold(
title: AppLocalizations.of(context)!.chatCall,
fixedAppBarColor: SolianTheme.isLargeScreen(context),
hideDrawer: true,
body: content,
);
}
@override
void deactivate() {
_subscription?.cancel();
WidgetsBinding.instance.addPostFrameCallback((_) => _chat.setCallShown(false));
super.deactivate();
}
}
class InteractiveParticipantWidget extends StatelessWidget {
final double? width;
final double? height;
final Color? color;
final bool isFixed;
final ParticipantTrack participant;
final Function() onTap;
const InteractiveParticipantWidget({
super.key,
this.width,
this.height,
this.color,
this.isFixed = false,
required this.participant,
required this.onTap,
});
@override
void dispose() {
(() async {
_callRoom.removeListener(onRoomDidUpdate);
await _callRoom.disconnect();
await _callListener.dispose();
await _callRoom.dispose();
})();
super.dispose();
Widget build(BuildContext context) {
return InkWell(
child: Container(
width: width,
height: height,
color: color,
child: ParticipantWidget.widgetFor(participant, isFixed: isFixed),
),
onTap: () => onTap(),
onLongPress: () {
if (participant.participant is LocalParticipant) return;
showModalBottomSheet(
context: context,
builder: (context) => ParticipantMenu(
participant: participant.participant as RemoteParticipant,
videoTrack: participant.videoTrack,
isScreenShare: participant.isScreenShare,
),
);
},
);
}
}

View File

@ -0,0 +1,198 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:http/http.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:uuid/uuid.dart';
class ChannelEditorScreen extends StatefulWidget {
final Channel? editing;
final String realm;
const ChannelEditorScreen({super.key, this.editing, this.realm = 'global'});
@override
State<ChannelEditorScreen> createState() => _ChannelEditorScreenState();
}
class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
final _aliasController = TextEditingController();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
bool _isEncrypted = false;
bool _isSubmitting = false;
Future<void> applyChannel(BuildContext context) async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
final scope = widget.realm.isNotEmpty ? widget.realm : 'global';
final uri = widget.editing == null
? getRequestUri('messaging', '/api/channels/$scope')
: getRequestUri('messaging', '/api/channels/$scope/${widget.editing!.id}');
final req = Request(widget.editing == null ? 'POST' : 'PUT', uri);
req.headers['Content-Type'] = 'application/json';
req.body = jsonEncode(<String, dynamic>{
'alias': _aliasController.value.text.toLowerCase(),
'name': _nameController.value.text,
'description': _descriptionController.value.text,
'is_encrypted': _isEncrypted,
});
var res = await Response.fromStream(await auth.client!.send(req));
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
} else {
if (SolianRouter.router.canPop()) {
SolianRouter.router.pop(_aliasController.value.text.toLowerCase());
}
}
setState(() => _isSubmitting = false);
}
void randomizeAlias() {
_aliasController.text = const Uuid().v4().replaceAll('-', '');
}
void cancelEditing() {
if (SolianRouter.router.canPop()) {
SolianRouter.router.pop(false);
}
}
@override
void initState() {
if (widget.editing != null) {
_aliasController.text = widget.editing!.alias;
_nameController.text = widget.editing!.name;
_descriptionController.text = widget.editing!.description;
_isEncrypted = widget.editing!.isEncrypted;
}
super.initState();
}
@override
Widget build(BuildContext context) {
final editingBanner = MaterialBanner(
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20),
leading: const Icon(Icons.edit_note),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.9),
dividerColor: const Color.fromARGB(1, 0, 0, 0),
content: Text(AppLocalizations.of(context)!.chatChannelEditNotify),
actions: [
TextButton(
child: Text(AppLocalizations.of(context)!.cancel),
onPressed: () => cancelEditing(),
),
],
);
return IndentScaffold(
hideDrawer: true,
showSafeArea: true,
title: AppLocalizations.of(context)!.chatChannelOrganize,
appBarActions: <Widget>[
TextButton(
onPressed: !_isSubmitting ? () => applyChannel(context) : null,
child: Text(AppLocalizations.of(context)!.apply.toUpperCase()),
),
],
body: Column(
children: [
_isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
widget.editing != null ? editingBanner : Container(),
ListTile(
title: Text(AppLocalizations.of(context)!.chatChannelUsage),
subtitle: Text(AppLocalizations.of(context)!.chatChannelUsageCaption),
leading: const CircleAvatar(
backgroundColor: Colors.teal,
child: Icon(Icons.tag, color: Colors.white),
),
),
const Divider(thickness: 0.3),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
child: Row(
children: [
Expanded(
child: TextField(
autofocus: true,
controller: _aliasController,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!.chatChannelAliasLabel,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
TextButton(
style: TextButton.styleFrom(
shape: const CircleBorder(),
visualDensity: const VisualDensity(horizontal: -2, vertical: -2),
),
onPressed: () => randomizeAlias(),
child: const Icon(Icons.refresh),
)
],
),
),
const Divider(thickness: 0.3),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
autocorrect: true,
controller: _nameController,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!.chatChannelNameLabel,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
const Divider(thickness: 0.3),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: TextField(
minLines: 5,
maxLines: null,
autocorrect: true,
keyboardType: TextInputType.multiline,
controller: _descriptionController,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!.chatChannelDescriptionLabel,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
),
const Divider(thickness: 0.3),
CheckboxListTile(
title: Text(AppLocalizations.of(context)!.chatChannelEncryptedLabel),
value: _isEncrypted,
onChanged: (widget.editing?.isEncrypted ?? false) ? null : (newValue) {
setState(() => _isEncrypted = newValue ?? false);
},
controlAffinity: ListTileControlAffinity.leading,
),
],
),
);
}
}

View File

@ -6,16 +6,18 @@ import 'package:provider/provider.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/account/avatar.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/friend_picker.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ChatMemberScreen extends StatefulWidget {
final Channel channel;
final String realm;
const ChatMemberScreen({super.key, required this.channel});
const ChatMemberScreen({super.key, required this.channel, this.realm = 'global'});
@override
State<ChatMemberScreen> createState() => _ChatMemberScreenState();
@ -35,7 +37,7 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
_selfId = prof['id'];
var uri = getRequestUri('messaging', '/api/channels/${widget.channel.alias}/members');
var uri = getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel.alias}/members');
var res = await auth.client!.get(uri);
if (res.statusCode == 200) {
@ -45,13 +47,11 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
});
} else {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
context.showErrorDialog(message);
}
}
Future<void> kickMember(ChannelMember item) async {
Future<void> removeMember(ChannelMember item) async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
@ -60,30 +60,28 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
return;
}
var uri = getRequestUri('messaging', '/api/channels/${widget.channel.alias}/kick');
var uri = getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel.alias}/members');
var res = await auth.client!.post(
var res = await auth.client!.delete(
uri,
headers: {
'Content-Type': 'application/json',
},
body: jsonEncode({
'account_name': item.account.name,
'target': item.account.name,
}),
);
if (res.statusCode == 200) {
await fetchMemberships();
} else {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
context.showErrorDialog(message);
}
setState(() => _isSubmitting = false);
}
Future<void> inviteMember(String username) async {
Future<void> addMember(String username) async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
@ -92,7 +90,7 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
return;
}
var uri = getRequestUri('messaging', '/api/channels/${widget.channel.alias}/invite');
var uri = getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel.alias}/members');
var res = await auth.client!.post(
uri,
@ -100,22 +98,20 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
'Content-Type': 'application/json',
},
body: jsonEncode({
'account_name': username,
'target': username,
}),
);
if (res.statusCode == 200) {
await fetchMemberships();
} else {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
context.showErrorDialog(message);
}
setState(() => _isSubmitting = false);
}
void promptInviteMember() async {
void promptAddMember() async {
final input = await showModalBottomSheet(
context: context,
builder: (context) {
@ -124,10 +120,10 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
);
if (input == null) return;
await inviteMember((input as Account).name);
await addMember((input as Account).name);
}
bool getKickable(ChannelMember item) {
bool getRemovable(ChannelMember item) {
if (_selfId != widget.channel.account.externalId) return false;
if (item.accountId == widget.channel.accountId) return false;
if (item.account.externalId == _selfId) return false;
@ -143,17 +139,16 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
@override
Widget build(BuildContext context) {
return IndentWrapper(
return IndentScaffold(
title: AppLocalizations.of(context)!.chatMember,
noSafeArea: true,
hideDrawer: true,
appBarActions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => promptInviteMember(),
onPressed: () => promptAddMember(),
),
],
child: RefreshIndicator(
body: RefreshIndicator(
onRefresh: () => fetchMemberships(),
child: CustomScrollView(
slivers: [
@ -169,7 +164,7 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
return Dismissible(
key: Key(randomId.toString()),
direction: getKickable(element) ? DismissDirection.startToEnd : DismissDirection.none,
direction: getRemovable(element) ? DismissDirection.startToEnd : DismissDirection.none,
background: Container(
color: Colors.red,
padding: const EdgeInsets.symmetric(horizontal: 20),
@ -182,7 +177,7 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
subtitle: Text(element.account.name),
),
onDismissed: (_) {
kickMember(element);
removeMember(element);
},
);
},

View File

@ -1,188 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:http/http.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:uuid/uuid.dart';
class ChannelEditorScreen extends StatefulWidget {
final Channel? editing;
const ChannelEditorScreen({super.key, this.editing});
@override
State<ChannelEditorScreen> createState() => _ChannelEditorScreenState();
}
class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
final _aliasController = TextEditingController();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
bool _isSubmitting = false;
Future<void> applyChannel(BuildContext context) async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
final uri = widget.editing == null
? getRequestUri('messaging', '/api/channels')
: getRequestUri('messaging', '/api/channels/${widget.editing!.id}');
final req = Request(widget.editing == null ? "POST" : "PUT", uri);
req.headers['Content-Type'] = 'application/json';
req.body = jsonEncode(<String, dynamic>{
'alias': _aliasController.value.text.toLowerCase(),
'name': _nameController.value.text,
'description': _descriptionController.value.text,
});
var res = await Response.fromStream(await auth.client!.send(req));
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
} else {
if (router.canPop()) {
router.pop(true);
}
}
setState(() => _isSubmitting = false);
}
void randomizeAlias() {
_aliasController.text = const Uuid().v4().replaceAll('-', '');
}
void cancelEditing() {
if (router.canPop()) {
router.pop(false);
}
}
@override
void initState() {
if (widget.editing != null) {
_aliasController.text = widget.editing!.alias;
_nameController.text = widget.editing!.name;
_descriptionController.text = widget.editing!.description;
}
super.initState();
}
@override
Widget build(BuildContext context) {
final editingBanner = MaterialBanner(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 20),
leading: const Icon(Icons.edit_note),
backgroundColor: const Color(0xFFE0E0E0),
dividerColor: const Color.fromARGB(1, 0, 0, 0),
content: Text(AppLocalizations.of(context)!.chatChannelEditNotify),
actions: [
TextButton(
child: Text(AppLocalizations.of(context)!.cancel),
onPressed: () => cancelEditing(),
),
],
);
return IndentWrapper(
hideDrawer: true,
title: AppLocalizations.of(context)!.chatChannelOrganize,
appBarActions: <Widget>[
TextButton(
onPressed: !_isSubmitting ? () => applyChannel(context) : null,
child: Text(AppLocalizations.of(context)!.apply.toUpperCase()),
),
],
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 640),
child: Column(
children: [
_isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
ListTile(
title: Text(AppLocalizations.of(context)!.chatChannelUsage),
subtitle: Text(AppLocalizations.of(context)!.chatChannelUsageCaption),
leading: const CircleAvatar(
backgroundColor: Colors.teal,
child: Icon(Icons.tag, color: Colors.white),
),
),
const Divider(thickness: 0.3),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
child: Row(
children: [
Expanded(
child: TextField(
autofocus: true,
controller: _aliasController,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!.chatChannelAliasLabel,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
TextButton(
style: TextButton.styleFrom(
shape: const CircleBorder(),
visualDensity: const VisualDensity(horizontal: -2, vertical: -2),
),
onPressed: () => randomizeAlias(),
child: const Icon(Icons.refresh),
)
],
),
),
const Divider(thickness: 0.3),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
autocorrect: true,
controller: _nameController,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!.chatChannelNameLabel,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
const Divider(thickness: 0.3),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: TextField(
minLines: 5,
maxLines: null,
autocorrect: true,
keyboardType: TextInputType.multiline,
controller: _descriptionController,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!.chatChannelDescriptionLabel,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
),
widget.editing != null ? editingBanner : Container(),
],
),
),
),
);
}
}

View File

@ -4,124 +4,100 @@ import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/call.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/message.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/chat/channel_action.dart';
import 'package:solian/widgets/chat/maintainer.dart';
import 'package:solian/widgets/chat/message.dart';
import 'package:solian/widgets/chat/message_action.dart';
import 'package:solian/widgets/chat/message_editor.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:http/http.dart' as http;
class ChatScreen extends StatefulWidget {
class ChatScreen extends StatelessWidget {
final String alias;
final String realm;
const ChatScreen({super.key, required this.alias});
const ChatScreen({super.key, required this.alias, this.realm = 'global'});
@override
State<ChatScreen> createState() => _ChatScreenState();
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
final chat = context.watch<ChatProvider>();
return IndentScaffold(
title: chat.focusChannel?.name ?? 'Loading...',
hideDrawer: true,
showSafeArea: true,
fixedAppBarColor: SolianTheme.isLargeScreen(context),
appBarActions: chat.focusChannel != null
? [
ChannelCallAction(
call: chat.ongoingCall,
channel: chat.focusChannel!,
realm: realm,
onUpdate: () => chat.fetchChannel(context, auth, chat.focusChannel!.alias, realm),
),
ChannelManageAction(
channel: chat.focusChannel!,
realm: realm,
onUpdate: () => chat.fetchChannel(context, auth, chat.focusChannel!.alias, realm),
),
]
: [],
body: ChatWidget(
alias: alias,
realm: realm,
),
);
}
}
class _ChatScreenState extends State<ChatScreen> {
Call? _ongoingCall;
Channel? _channelMeta;
class ChatWidget extends StatefulWidget {
final String alias;
final String realm;
final PagingController<int, Message> _pagingController = PagingController(firstPageKey: 0);
const ChatWidget({super.key, required this.alias, required this.realm});
final http.Client _client = http.Client();
@override
State<ChatWidget> createState() => _ChatWidgetState();
}
Future<Channel> fetchMetadata() async {
var uri = getRequestUri('messaging', '/api/channels/${widget.alias}');
var res = await _client.get(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes));
setState(() => _channelMeta = Channel.fromJson(result));
return _channelMeta!;
} else {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
throw Exception(message);
}
}
class _ChatWidgetState extends State<ChatWidget> {
bool _isReady = false;
Future<Call?> fetchCall() async {
var uri = getRequestUri('messaging', '/api/channels/${widget.alias}/calls/ongoing');
var res = await _client.get(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes));
setState(() => _ongoingCall = Call.fromJson(result));
return _ongoingCall;
} else if (res.statusCode != 404) {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
throw Exception(message);
} else {
return null;
}
}
late final ChatProvider _chat;
Future<void> fetchMessages(int pageKey, BuildContext context) async {
Future<void> joinChannel() async {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
final offset = pageKey;
const take = 10;
var uri = getRequestUri(
'messaging',
'/api/channels/${widget.alias}/messages?take=$take&offset=$offset',
'/api/channels/${widget.realm}/${widget.alias}/members/me',
);
var res = await auth.client!.get(uri);
var res = await auth.client!.post(uri);
if (res.statusCode == 200) {
final result = PaginationResult.fromJson(jsonDecode(utf8.decode(res.bodyBytes)));
final items = result.data?.map((x) => Message.fromJson(x)).toList() ?? List.empty();
final isLastPage = (result.count - pageKey) < take;
if (isLastPage || result.data == null) {
_pagingController.appendLastPage(items);
} else {
final nextPageKey = pageKey + items.length;
_pagingController.appendPage(items, nextPageKey);
}
setState(() {});
_chat.historyPagingController?.refresh();
} else {
_pagingController.error = utf8.decode(res.bodyBytes);
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message).then((_) {
SolianRouter.router.pop();
});
}
}
bool getMessageMergeable(Message? a, Message? b) {
if (a?.replyTo != null || b?.replyTo != null) return false;
if (a?.replyTo != null) return false;
if (a == null || b == null) return false;
if (a.senderId != b.senderId) return false;
return a.createdAt.difference(b.createdAt).inMinutes <= 5;
}
void addMessage(Message item) {
setState(() {
_pagingController.itemList?.insert(0, item);
});
}
void updateMessage(Message item) {
setState(() {
_pagingController.itemList = _pagingController.itemList?.map((x) => x.id == item.id ? item : x).toList();
});
}
void deleteMessage(Message item) {
setState(() {
_pagingController.itemList = _pagingController.itemList?.where((x) => x.id != item.id).toList();
});
return a.createdAt.difference(b.createdAt).inMinutes <= 3;
}
Message? _editingItem;
@ -132,6 +108,7 @@ class _ChatScreenState extends State<ChatScreen> {
context: context,
builder: (context) => ChatMessageAction(
channel: widget.alias,
realm: widget.realm,
item: item,
onEdit: () => setState(() {
_editingItem = item;
@ -143,16 +120,57 @@ class _ChatScreenState extends State<ChatScreen> {
);
}
void showUnavailableDialog() {
final content = widget.realm == 'global'
? AppLocalizations.of(context)!.chatChannelUnavailableCaption
: AppLocalizations.of(context)!.chatChannelUnavailableCaptionWithRealm;
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.chatChannelUnavailable),
content: Text(content),
actions: <Widget>[
TextButton(
child: Text(AppLocalizations.of(context)!.cancel),
onPressed: () {
Navigator.of(context).pop();
SolianRouter.router.pop();
},
),
...(widget.realm != 'global'
? [
TextButton(
child: Text(AppLocalizations.of(context)!.join),
onPressed: () {
Navigator.of(context).pop();
joinChannel();
},
),
]
: [])
],
),
);
}
@override
void initState() {
Future.delayed(Duration.zero, () {
fetchMetadata();
fetchCall();
});
_pagingController.addPageRequestListener((pageKey) => fetchMessages(pageKey, context));
super.initState();
Future.delayed(Duration.zero, () async {
final auth = context.read<AuthProvider>();
await _chat.connect(auth);
_chat.fetchOngoingCall(widget.alias, widget.realm);
_chat.fetchChannel(context, auth, widget.alias, widget.realm).then((result) {
if (result.isAvailable == false) {
showUnavailableDialog();
}
});
});
}
@override
@ -160,10 +178,10 @@ class _ChatScreenState extends State<ChatScreen> {
Widget chatHistoryBuilder(context, item, index) {
bool isMerged = false, hasMerged = false;
if (index > 0) {
hasMerged = getMessageMergeable(_pagingController.itemList?[index - 1], item);
hasMerged = getMessageMergeable(_chat.historyPagingController?.itemList?[index - 1], item);
}
if (index + 1 < (_pagingController.itemList?.length ?? 0)) {
isMerged = getMessageMergeable(item, _pagingController.itemList?[index + 1]);
if (index + 1 < (_chat.historyPagingController?.itemList?.length ?? 0)) {
isMerged = getMessageMergeable(item, _chat.historyPagingController?.itemList?[index + 1]);
}
return InkWell(
child: Container(
@ -174,13 +192,22 @@ class _ChatScreenState extends State<ChatScreen> {
right: 12,
),
child: ChatMessage(
key: Key('m${item.id}'),
item: item,
underMerged: isMerged,
),
),
onLongPress: () => viewActions(item),
);
).animate(key: Key('m${item.id}'), autoPlay: true).slideY(
curve: Curves.fastEaseInToSlowEaseOut,
duration: 350.ms,
begin: 0.25,
end: 0,
);
}
if (!_isReady) {
_isReady = true;
_chat = context.watch<ChatProvider>();
}
final callBanner = MaterialBanner(
@ -193,9 +220,9 @@ class _ChatScreenState extends State<ChatScreen> {
TextButton(
child: Text(AppLocalizations.of(context)!.chatCallJoin),
onPressed: () {
router.pushNamed(
SolianRouter.router.pushNamed(
'chat.channel.call',
extra: _ongoingCall,
extra: _chat.ongoingCall,
pathParameters: {'channel': widget.alias},
);
},
@ -203,60 +230,41 @@ class _ChatScreenState extends State<ChatScreen> {
],
);
return IndentWrapper(
hideDrawer: true,
title: _channelMeta?.name ?? "Loading...",
appBarActions: _channelMeta != null
? [
ChannelCallAction(call: _ongoingCall, channel: _channelMeta!, onUpdate: () => fetchMetadata()),
ChannelManageAction(channel: _channelMeta!, onUpdate: () => fetchMetadata()),
]
: [],
child: FutureBuilder(
future: fetchMetadata(),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data == null) {
return const Center(child: CircularProgressIndicator());
}
if (_chat.focusChannel == null || _chat.historyPagingController == null) {
return const Center(child: CircularProgressIndicator());
}
return ChatMaintainer(
channel: snapshot.data!,
child: Stack(
children: [
Column(
children: [
Expanded(
child: PagedListView<int, Message>(
reverse: true,
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<Message>(
noItemsFoundIndicatorBuilder: (_) => Container(),
itemBuilder: chatHistoryBuilder,
),
),
),
ChatMessageEditor(
channel: widget.alias,
editing: _editingItem,
replying: _replyingItem,
onReset: () => setState(() {
_editingItem = null;
_replyingItem = null;
}),
),
],
return Stack(
children: [
Column(
children: [
Expanded(
child: PagedListView<int, Message>(
reverse: true,
pagingController: _chat.historyPagingController!,
builderDelegate: PagedChildBuilderDelegate<Message>(
animateTransitions: true,
transitionDuration: 350.ms,
itemBuilder: chatHistoryBuilder,
noItemsFoundIndicatorBuilder: (_) => Container(),
),
_ongoingCall != null ? callBanner.animate().slideY() : Container(),
],
),
),
onInsertMessage: (message) => addMessage(message),
onUpdateMessage: (message) => updateMessage(message),
onDeleteMessage: (message) => deleteMessage(message),
onCallStarted: (call) => setState(() => _ongoingCall = call),
onCallEnded: () => setState(() => _ongoingCall = null),
);
},
),
ChatMessageEditor(
realm: widget.realm,
channel: widget.alias,
editing: _editingItem,
replying: _replyingItem,
isEncrypted: _chat.focusChannel?.isEncrypted ?? false,
onReset: () => setState(() {
_editingItem = null;
_replyingItem = null;
}),
),
],
),
_chat.ongoingCall != null ? callBanner.animate().slideY() : Container(),
],
);
}
}

View File

@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/router.dart';
import 'package:solian/widgets/chat/channel_deletion.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ChatDetailScreen extends StatefulWidget {
final Channel channel;
final String realm;
const ChatDetailScreen({super.key, required this.channel, this.realm = 'global'});
@override
State<ChatDetailScreen> createState() => _ChatDetailScreenState();
}
class _ChatDetailScreenState extends State<ChatDetailScreen> {
bool _isOwned = false;
void promptLeaveChannel() async {
final did = await showDialog(
context: context,
builder: (context) =>
ChannelDeletion(
channel: widget.channel,
realm: widget.realm,
isOwned: _isOwned,
),
);
if (did == true && SolianRouter.router.canPop()) {
SolianRouter.router.pop('disposed');
}
}
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () async {
final auth = context.read<AuthProvider>();
final prof = await auth.getProfiles();
setState(() {
_isOwned = prof['id'] == widget.channel.account.externalId;
});
});
}
@override
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
final chat = context.read<ChatProvider>();
final authorizedItems = [
ListTile(
leading: const Icon(Icons.settings),
title: Text(AppLocalizations.of(context)!.settings),
onTap: () async {
SolianRouter.router
.pushNamed(
'chat.channel.editor',
extra: widget.channel,
queryParameters: widget.realm != 'global' ? {'realm': widget.realm} : {},
)
.then((resp) {
if (resp != null) {
chat.fetchChannel(context, auth, resp as String, widget.realm);
}
});
},
),
];
return IndentScaffold(
title: AppLocalizations.of(context)!.chatDetail,
hideDrawer: true,
body: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
const CircleAvatar(
radius: 24,
backgroundColor: Colors.teal,
child: Icon(Icons.tag, color: Colors.white),
),
const SizedBox(width: 16),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(widget.channel.name, style: Theme
.of(context)
.textTheme
.bodyLarge),
Text(widget.channel.description, style: Theme
.of(context)
.textTheme
.bodySmall),
]),
)
],
),
),
const Divider(thickness: 0.3),
Expanded(
child: ListView(
children: [
ListTile(
leading: const Icon(Icons.edit_notifications),
title: Text(AppLocalizations.of(context)!.chatNotifySetting),
onTap: () {},
),
ListTile(
leading: const Icon(Icons.supervisor_account),
title: Text(AppLocalizations.of(context)!.chatMember),
onTap: () {
SolianRouter.router.pushNamed(
widget.realm == 'global' ? 'chat.channel.member' : 'realms.chat.channel.member',
extra: widget.channel,
pathParameters: {
'channel': widget.channel.alias,
...(widget.realm == 'global' ? {} : {'realm': widget.realm}),
},
);
},
),
...(_isOwned ? authorizedItems : List.empty()),
const Divider(thickness: 0.3),
ListTile(
leading: _isOwned ? const Icon(Icons.delete) : const Icon(Icons.exit_to_app),
title: Text(_isOwned ? AppLocalizations.of(context)!.delete : AppLocalizations.of(context)!.exit),
onTap: () => promptLeaveChannel(),
),
],
),
),
],
),
);
}
}

View File

@ -0,0 +1,147 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/chat/chat_new.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/notification_notifier.dart';
import 'package:solian/widgets/signin_required.dart';
class ChatListScreen extends StatelessWidget {
const ChatListScreen({super.key});
@override
Widget build(BuildContext context) {
return IndentScaffold(
title: AppLocalizations.of(context)!.chat,
appBarActions: const [NotificationButton()],
fixedAppBarColor: SolianTheme.isLargeScreen(context),
body: const ChatListWidget(),
);
}
}
class ChatListWidget extends StatefulWidget {
final String? realm;
const ChatListWidget({super.key, this.realm});
@override
State<ChatListWidget> createState() => _ChatListWidgetState();
}
class _ChatListWidgetState extends State<ChatListWidget> {
List<Channel> _channels = List.empty();
Future<void> fetchChannels() async {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
Uri uri;
if (widget.realm == null) {
uri = getRequestUri('messaging', '/api/channels/global/me/available');
} else {
uri = getRequestUri('messaging', '/api/channels/${widget.realm}');
}
var res = await auth.client!.get(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes)) as List<dynamic>;
setState(() {
_channels = result.map((x) => Channel.fromJson(x)).toList();
});
} else {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
}
}
void viewNewChatAction() {
showModalBottomSheet(
context: context,
builder: (context) => ChatNewAction(
onUpdate: () => fetchChannels(),
realm: widget.realm,
),
);
}
@override
void initState() {
Future.delayed(Duration.zero, () {
fetchChannels();
});
super.initState();
}
@override
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
final chat = context.watch<ChatProvider>();
return Scaffold(
floatingActionButton: FutureBuilder(
future: auth.isAuthorized(),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data!) {
return FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: Text(AppLocalizations.of(context)!.chatNew),
onPressed: () => viewNewChatAction(),
);
} else {
return Container();
}
},
),
body: FutureBuilder(
future: auth.isAuthorized(),
builder: (context, snapshot) {
if (!snapshot.hasData || !snapshot.data!) {
return const SignInRequiredScreen();
}
return RefreshIndicator(
onRefresh: () => fetchChannels(),
child: ListView.builder(
itemCount: _channels.length,
itemBuilder: (context, index) {
final element = _channels[index];
return ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.indigo,
child: Icon(Icons.tag, color: Colors.white),
),
title: Text(element.name),
subtitle: Text(element.description),
onTap: () async {
if (['chat.channel', 'realms.chat.channel'].contains(SolianRouter.currentRoute.name)) {
chat.fetchChannel(context, auth, element.alias, widget.realm!);
} else {
SolianRouter.router.pushNamed(
widget.realm == null ? 'chat.channel' : 'realms.chat.channel',
pathParameters: {
'channel': element.alias,
...(widget.realm == null ? {} : {'realm': widget.realm!}),
},
);
}
},
);
},
),
);
},
),
);
}
}

View File

@ -1,121 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/chat/chat_new.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/notification_notifier.dart';
import 'package:solian/widgets/signin_required.dart';
class ChatIndexScreen extends StatefulWidget {
const ChatIndexScreen({super.key});
@override
State<ChatIndexScreen> createState() => _ChatIndexScreenState();
}
class _ChatIndexScreenState extends State<ChatIndexScreen> {
List<Channel> _channels = List.empty();
Future<void> fetchChannels() async {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
var uri = getRequestUri('messaging', '/api/channels/me/available');
var res = await auth.client!.get(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes)) as List<dynamic>;
setState(() {
_channels = result.map((x) => Channel.fromJson(x)).toList();
});
} else {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
}
}
void viewNewChatAction() {
showModalBottomSheet(
context: context,
builder: (context) => ChatNewAction(onUpdate: () => fetchChannels()),
);
}
@override
void initState() {
Future.delayed(Duration.zero, () {
fetchChannels();
});
super.initState();
}
@override
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
return IndentWrapper(
title: AppLocalizations.of(context)!.chat,
appBarActions: const [NotificationButton()],
floatingActionButton: FutureBuilder(
future: auth.isAuthorized(),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data!) {
return FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: Text(AppLocalizations.of(context)!.chatNew),
onPressed: () => viewNewChatAction(),
);
} else {
return Container();
}
},
),
child: FutureBuilder(
future: auth.isAuthorized(),
builder: (context, snapshot) {
if (!snapshot.hasData || !snapshot.data!) {
return const SignInRequiredScreen();
}
return RefreshIndicator(
onRefresh: () => fetchChannels(),
child: ListView.builder(
itemCount: _channels.length,
itemBuilder: (context, index) {
final element = _channels[index];
return ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.indigo,
child: Icon(Icons.tag, color: Colors.white),
),
title: Text(element.name),
subtitle: Text(element.description),
onTap: () async {
final result = await router.pushNamed(
'chat.channel',
pathParameters: {
'channel': element.alias,
},
);
switch(result) {
case 'refresh':
fetchChannels();
}
},
);
},
),
);
}),
);
}
}

View File

@ -1,27 +1,52 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/realm.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:http/http.dart' as http;
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/realms/realm_shortcuts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:solian/widgets/notification_notifier.dart';
import 'package:solian/widgets/posts/item.dart';
import 'package:solian/widgets/posts/post.dart';
class ExploreScreen extends StatefulWidget {
const ExploreScreen({super.key});
class ExplorePostScreen extends StatelessWidget {
const ExplorePostScreen({super.key});
@override
State<ExploreScreen> createState() => _ExploreScreenState();
Widget build(BuildContext context) {
return IndentScaffold(
fixedAppBarColor: SolianTheme.isLargeScreen(context),
appBarActions: const [NotificationButton()],
title: AppLocalizations.of(context)!.explore,
body: const ExplorePostWidget(showRealmShortcuts: true),
);
}
}
class _ExploreScreenState extends State<ExploreScreen> {
class ExplorePostWidget extends StatefulWidget {
final String? realm;
final bool showRealmShortcuts;
const ExplorePostWidget({
super.key,
this.realm,
this.showRealmShortcuts = false,
});
@override
State<ExplorePostWidget> createState() => _ExplorePostWidgetState();
}
class _ExplorePostWidgetState extends State<ExplorePostWidget> {
final PagingController<int, Post> _pagingController = PagingController(firstPageKey: 0);
final http.Client _client = http.Client();
@ -30,7 +55,12 @@ class _ExploreScreenState extends State<ExploreScreen> {
final offset = pageKey;
const take = 5;
var uri = getRequestUri('interactive', '/api/feed?take=$take&offset=$offset');
Uri uri;
if (widget.realm == null) {
uri = getRequestUri('interactive', '/api/feed?take=$take&offset=$offset');
} else {
uri = getRequestUri('interactive', '/api/feed?realm=${widget.realm}&take=$take&offset=$offset');
}
var res = await _client.get(uri);
if (res.statusCode == 200) {
@ -52,6 +82,16 @@ class _ExploreScreenState extends State<ExploreScreen> {
void initState() {
super.initState();
Future.delayed(Duration.zero, () async {
if (widget.showRealmShortcuts) {
final auth = context.read<AuthProvider>();
if (auth.client == null) {
await auth.loadClient();
}
context.read<RealmProvider>().fetch(auth);
}
});
_pagingController.addPageRequestListener((pageKey) => fetchFeed(pageKey));
}
@ -59,8 +99,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
return IndentWrapper(
noSafeArea: true,
return Scaffold(
floatingActionButton: FutureBuilder(
future: auth.isAuthorized(),
builder: (context, snapshot) {
@ -68,7 +107,10 @@ class _ExploreScreenState extends State<ExploreScreen> {
return FloatingActionButton(
child: const Icon(Icons.edit),
onPressed: () async {
final did = await router.pushNamed("posts.moments.editor");
final did = await SolianRouter.router.pushNamed(
widget.realm == null ? 'posts.moments.editor' : 'realms.posts.moments.editor',
pathParameters: widget.realm == null ? {} : {'realm': widget.realm!},
);
if (did == true) _pagingController.refresh();
},
);
@ -77,29 +119,52 @@ class _ExploreScreenState extends State<ExploreScreen> {
}
},
),
appBarActions: const [NotificationButton()],
title: AppLocalizations.of(context)!.explore,
child: RefreshIndicator(
body: RefreshIndicator(
onRefresh: () => Future.sync(
() => _pagingController.refresh(),
),
child: PagedListView<int, Post>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<Post>(
itemBuilder: (context, item, index) => PostItem(
item: item,
onUpdate: () => _pagingController.refresh(),
onTap: () {
router.pushNamed(
'posts.screen',
pathParameters: {
'alias': item.alias,
'dataset': '${item.modelType}s',
child: CustomScrollView(
slivers: [
widget.showRealmShortcuts
? SliverToBoxAdapter(
child: FutureBuilder(
future: auth.isAuthorized(),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data != true) {
return Container();
}
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: const Material(
elevation: 8,
child: SizedBox(height: 120, child: RealmShortcuts()),
).animate().fade().slideY(begin: -1, end: 0, curve: Curves.fastEaseInToSlowEaseOut),
);
},
),
)
: SliverToBoxAdapter(child: Container()),
PagedSliverList<int, Post>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<Post>(
itemBuilder: (context, item, index) => PostItem(
item: item,
onUpdate: () => _pagingController.refresh(),
onTap: () {
SolianRouter.router.pushNamed(
widget.realm == null ? 'posts.details' : 'realms.posts.details',
pathParameters: {
'alias': item.alias,
'dataset': item.dataset,
...(widget.realm == null ? {} : {'realm': widget.realm!}),
},
);
},
);
},
),
),
),
),
],
),
),
);

View File

@ -1,9 +1,12 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/notify.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:solian/models/notification.dart' as model;
@ -16,19 +19,79 @@ class NotificationScreen extends StatefulWidget {
}
class _NotificationScreenState extends State<NotificationScreen> {
bool _isSubmitting = false;
Future<void> markAllRead() async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
final nty = context.read<NotifyProvider>();
List<int> markList = List.empty(growable: true);
for (final element in nty.notifications) {
if (element.isRealtime) continue;
markList.add(element.id);
}
nty.clearRealtimeNotifications();
if(markList.isNotEmpty) {
var uri = getRequestUri('passport', '/api/notifications/batch/read');
await auth.client!.put(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'messages': markList}),
);
}
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.notifyMarkAllReadDone),
));
await nty.fetch(auth);
setState(() => _isSubmitting = false);
}
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () {
final nty = context.read<NotifyProvider>();
nty.allRead();
});
}
@override
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
final nty = context.watch<NotifyProvider>();
return IndentWrapper(
noSafeArea: true,
return IndentScaffold(
hideDrawer: true,
title: AppLocalizations.of(context)!.notification,
child: RefreshIndicator(
body: RefreshIndicator(
onRefresh: () => nty.fetch(auth),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
),
if (nty.notifications.isNotEmpty)
SliverToBoxAdapter(
child: ListTile(
tileColor: Theme.of(context).colorScheme.secondaryContainer,
leading: const Icon(Icons.checklist),
title: Text(AppLocalizations.of(context)!.notifyMarkAllRead),
contentPadding: const EdgeInsets.symmetric(horizontal: 28),
onTap: _isSubmitting ? null : markAllRead,
),
),
nty.notifications.isEmpty
? SliverToBoxAdapter(
child: Container(
@ -48,9 +111,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
return NotificationItem(
index: index,
item: element,
onDismiss: () => setState(() {
nty.clearAt(index);
}),
onDismiss: () => nty.clearAt(index),
);
},
),
@ -78,10 +139,10 @@ class NotificationItem extends StatelessWidget {
const NotificationItem({super.key, required this.index, required this.item, this.onDismiss});
bool hasLinks() => item.links != null && item.links!.isNotEmpty;
bool get hasLinks => item.links != null && item.links!.isNotEmpty;
void showLinks(BuildContext context) {
if (!hasLinks()) return;
if (!hasLinks) return;
showModalBottomSheet<void>(
context: context,
@ -92,7 +153,7 @@ class NotificationItem extends StatelessWidget {
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 34, bottom: 12),
child: Text(
"Links",
'Links',
style: Theme.of(context).textTheme.headlineSmall,
),
),
@ -133,23 +194,11 @@ class NotificationItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Dismissible(
key: Key('n$index'),
key: Key('n${item.id}'),
onDismissed: (direction) {
markAsRead(item, context).then((value) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: RichText(
text: TextSpan(
children: [
TextSpan(
text: item.subject,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const TextSpan(text: " is marked as read")
],
),
),
),
SnackBar(content: Text('${item.subject} is marked as read')),
);
});
if (onDismiss != null) {
@ -164,7 +213,7 @@ class NotificationItem extends StatelessWidget {
child: ListTile(
title: Text(item.subject),
subtitle: Text(item.content),
trailing: hasLinks()
trailing: hasLinks
? TextButton(
onPressed: () => showLinks(context),
style: TextButton.styleFrom(shape: const CircleBorder()),

View File

@ -7,10 +7,11 @@ import 'package:provider/provider.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/account/avatar.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:solian/widgets/posts/attachment_editor.dart';
class CommentPostArguments {
@ -64,7 +65,7 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> {
? getRequestUri('interactive', '/api/p/$relatedDataset/$alias/comments')
: getRequestUri('interactive', '/api/p/comments/${widget.editing!.id}');
final req = Request(widget.editing == null ? "POST" : "PUT", uri);
final req = Request(widget.editing == null ? 'POST' : 'PUT', uri);
req.headers['Content-Type'] = 'application/json';
req.body = jsonEncode(<String, dynamic>{
'alias': _alias,
@ -75,20 +76,18 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> {
var res = await Response.fromStream(await auth.client!.send(req));
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
context.showErrorDialog(message);
} else {
if (router.canPop()) {
router.pop(true);
if (SolianRouter.router.canPop()) {
SolianRouter.router.pop(true);
}
}
setState(() => _isSubmitting = false);
}
void cancelEditing() {
if (router.canPop()) {
router.pop(false);
if (SolianRouter.router.canPop()) {
SolianRouter.router.pop(false);
}
}
@ -108,9 +107,9 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> {
final auth = context.read<AuthProvider>();
final editingBanner = MaterialBanner(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 20),
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20),
leading: const Icon(Icons.edit_note),
backgroundColor: const Color(0xFFE0E0E0),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.9),
dividerColor: const Color.fromARGB(1, 0, 0, 0),
content: Text(AppLocalizations.of(context)!.postEditNotify),
actions: [
@ -121,8 +120,9 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> {
],
);
return IndentWrapper(
return IndentScaffold(
hideDrawer: true,
showSafeArea: true,
title: AppLocalizations.of(context)!.newComment,
appBarActions: <Widget>[
TextButton(
@ -130,69 +130,69 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> {
child: Text(AppLocalizations.of(context)!.postVerb.toUpperCase()),
),
],
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 640),
child: Column(
children: [
_isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
FutureBuilder(
future: auth.getProfiles(),
builder: (context, snapshot) {
if (snapshot.hasData) {
var userinfo = snapshot.data;
return ListTile(
title: Text(userinfo["nick"]),
subtitle: Text(
AppLocalizations.of(context)!.postIdentityNotify,
),
leading: AccountAvatar(
source: userinfo["picture"],
direct: true,
),
);
} else {
return Container();
}
},
),
const Divider(thickness: 0.3),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
maxLines: null,
autofocus: true,
autocorrect: true,
keyboardType: TextInputType.multiline,
controller: _textController,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!.postContentPlaceholder,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
body: Column(
children: [
_isSubmitting
? const LinearProgressIndicator().animate().scaleX()
: Container(),
FutureBuilder(
future: auth.getProfiles(),
builder: (context, snapshot) {
if (snapshot.hasData) {
var userinfo = snapshot.data;
return ListTile(
title: Text(userinfo['nick']),
subtitle: Text(
AppLocalizations.of(context)!.postIdentityNotify,
),
),
),
widget.editing != null ? editingBanner : Container(),
Container(
decoration: const BoxDecoration(
border: Border(
top: BorderSide(width: 0.3, color: Color(0xffdedede)),
leading: AccountAvatar(
source: userinfo['picture'],
direct: true,
),
),
child: Row(
children: [
TextButton(
style: TextButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.camera_alt),
onPressed: () => viewAttachments(context),
)
],
),
),
],
);
} else {
return Container();
}
},
),
),
const Divider(thickness: 0.3),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
maxLines: null,
autofocus: true,
autocorrect: true,
keyboardType: TextInputType.multiline,
controller: _textController,
decoration: InputDecoration.collapsed(
hintText:
AppLocalizations.of(context)!.postContentPlaceholder,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
),
widget.editing != null ? editingBanner : Container(),
Container(
constraints: const BoxConstraints(minHeight: 56),
decoration: BoxDecoration(
border: Border(
top: BorderSide(width: 0.3, color: Theme.of(context).dividerColor),
),
),
child: Row(
children: [
TextButton(
style: TextButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.camera_alt),
onPressed: () => viewAttachments(context),
)
],
),
),
],
),
);
}

View File

@ -7,16 +7,18 @@ import 'package:provider/provider.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/account/avatar.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:solian/widgets/posts/attachment_editor.dart';
class MomentEditorScreen extends StatefulWidget {
final Post? editing;
final String? realm;
const MomentEditorScreen({super.key, this.editing});
const MomentEditorScreen({super.key, this.editing, this.realm});
@override
State<MomentEditorScreen> createState() => _MomentEditorScreenState();
@ -54,31 +56,30 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> {
? getRequestUri('interactive', '/api/p/moments')
: getRequestUri('interactive', '/api/p/moments/${widget.editing!.id}');
final req = Request(widget.editing == null ? "POST" : "PUT", uri);
final req = Request(widget.editing == null ? 'POST' : 'PUT', uri);
req.headers['Content-Type'] = 'application/json';
req.body = jsonEncode(<String, dynamic>{
'alias': _alias,
'content': _textController.value.text,
'attachments': _attachments,
'realm': widget.realm,
});
var res = await Response.fromStream(await auth.client!.send(req));
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
context.showErrorDialog(message);
} else {
if (router.canPop()) {
router.pop(true);
if (SolianRouter.router.canPop()) {
SolianRouter.router.pop(true);
}
}
setState(() => _isSubmitting = false);
}
void cancelEditing() {
if (router.canPop()) {
router.pop(false);
if (SolianRouter.router.canPop()) {
SolianRouter.router.pop(false);
}
}
@ -98,9 +99,9 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> {
final auth = context.read<AuthProvider>();
final editingBanner = MaterialBanner(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 20),
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20),
leading: const Icon(Icons.edit_note),
backgroundColor: const Color(0xFFE0E0E0),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.9),
dividerColor: const Color.fromARGB(1, 0, 0, 0),
content: Text(AppLocalizations.of(context)!.postEditNotify),
actions: [
@ -111,7 +112,8 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> {
],
);
return IndentWrapper(
return IndentScaffold(
showSafeArea: true,
hideDrawer: true,
title: AppLocalizations.of(context)!.newMoment,
appBarActions: <Widget>[
@ -120,69 +122,65 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> {
child: Text(AppLocalizations.of(context)!.postVerb.toUpperCase()),
),
],
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 640),
child: Column(
children: [
_isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
FutureBuilder(
future: auth.getProfiles(),
builder: (context, snapshot) {
if (snapshot.hasData) {
var userinfo = snapshot.data;
return ListTile(
title: Text(userinfo["nick"]),
subtitle: Text(
AppLocalizations.of(context)!.postIdentityNotify,
),
leading: AccountAvatar(
source: userinfo["picture"],
direct: true,
),
);
} else {
return Container();
}
},
),
const Divider(thickness: 0.3),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
maxLines: null,
autofocus: true,
autocorrect: true,
keyboardType: TextInputType.multiline,
controller: _textController,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!.postContentPlaceholder,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
body: Column(
children: [
_isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
FutureBuilder(
future: auth.getProfiles(),
builder: (context, snapshot) {
if (snapshot.hasData) {
var userinfo = snapshot.data;
return ListTile(
title: Text(userinfo['nick']),
subtitle: Text(
AppLocalizations.of(context)!.postIdentityNotify,
),
),
),
widget.editing != null ? editingBanner : Container(),
Container(
decoration: const BoxDecoration(
border: Border(
top: BorderSide(width: 0.3, color: Color(0xffdedede)),
leading: AccountAvatar(
source: userinfo['picture'],
direct: true,
),
),
child: Row(
children: [
TextButton(
style: TextButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.camera_alt),
onPressed: () => viewAttachments(context),
)
],
),
),
],
);
} else {
return Container();
}
},
),
),
const Divider(thickness: 0.3),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
maxLines: null,
autofocus: true,
autocorrect: true,
keyboardType: TextInputType.multiline,
controller: _textController,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!.postContentPlaceholder,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
),
widget.editing != null ? editingBanner : Container(),
Container(
constraints: const BoxConstraints(minHeight: 56),
decoration: BoxDecoration(
border: Border(
top: BorderSide(width: 0.3, color: Theme.of(context).dividerColor),
),
),
child: Row(
children: [
TextButton(
style: TextButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.camera_alt),
onPressed: () => viewAttachments(context),
)
],
),
),
],
),
);
}

View File

@ -4,23 +4,43 @@ import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/post.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:solian/widgets/posts/comment_list.dart';
import 'package:solian/widgets/posts/item.dart';
import 'package:solian/widgets/posts/post.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class PostScreen extends StatefulWidget {
class PostScreen extends StatelessWidget {
final String dataset;
final String alias;
const PostScreen({super.key, required this.alias, required this.dataset});
@override
State<PostScreen> createState() => _PostScreenState();
Widget build(BuildContext context) {
return IndentScaffold(
title: AppLocalizations.of(context)!.post,
hideDrawer: true,
body: PostScreenWidget(
dataset: dataset,
alias: alias,
),
);
}
}
class _PostScreenState extends State<PostScreen> {
class PostScreenWidget extends StatefulWidget {
final String dataset;
final String alias;
const PostScreenWidget({super.key, required this.dataset, required this.alias});
@override
State<PostScreenWidget> createState() => _PostScreenWidgetState();
}
class _PostScreenWidgetState extends State<PostScreenWidget> {
final _client = http.Client();
final PagingController<int, Post> _commentPagingController = PagingController(firstPageKey: 0);
@ -30,9 +50,7 @@ class _PostScreenState extends State<PostScreen> {
final res = await _client.get(uri);
if (res.statusCode != 200) {
final err = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $err")),
);
context.showErrorDialog(err);
return null;
} else {
return Post.fromJson(jsonDecode(utf8.decode(res.bodyBytes)));
@ -41,43 +59,38 @@ class _PostScreenState extends State<PostScreen> {
@override
Widget build(BuildContext context) {
return IndentWrapper(
noSafeArea: true,
hideDrawer: true,
title: AppLocalizations.of(context)!.post,
child: FutureBuilder(
future: fetchPost(context),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: PostItem(
item: snapshot.data!,
brief: false,
ripple: false,
),
return FutureBuilder(
future: fetchPost(context),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: PostItem(
item: snapshot.data!,
brief: false,
ripple: false,
),
SliverToBoxAdapter(
child: CommentListHeader(
related: snapshot.data!,
paging: _commentPagingController,
),
),
CommentList(
),
SliverToBoxAdapter(
child: CommentListHeader(
related: snapshot.data!,
dataset: widget.dataset,
paging: _commentPagingController,
),
],
);
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
),
),
CommentList(
related: snapshot.data!,
dataset: widget.dataset,
paging: _commentPagingController,
),
],
);
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
);
}

View File

@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/realm.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/chat/chat_list.dart';
import 'package:solian/screens/explore.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/scaffold.dart';
class RealmScreen extends StatelessWidget {
final String alias;
const RealmScreen({super.key, required this.alias});
@override
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
final realm = context.watch<RealmProvider>();
return IndentScaffold(
title: realm.focusRealm?.name ?? 'Loading...',
hideDrawer: true,
fixedAppBarColor: SolianTheme.isLargeScreen(context),
appBarActions: realm.focusRealm != null
? [
RealmManageAction(
realm: realm.focusRealm!,
onUpdate: () => realm.fetchSingle(auth, alias),
),
]
: [],
appBarLeading: SolianTheme.isLargeScreen(context)
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
realm.clearFocus();
},
)
: null,
body: RealmWidget(
alias: alias,
),
);
}
}
class RealmWidget extends StatefulWidget {
final String alias;
const RealmWidget({super.key, required this.alias});
@override
State<RealmWidget> createState() => _RealmWidgetState();
}
class _RealmWidgetState extends State<RealmWidget> {
bool _isReady = false;
late RealmProvider _realm;
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () {
final auth = context.read<AuthProvider>();
if (_realm.focusRealm?.alias != widget.alias) {
_realm.fetchSingle(auth, widget.alias);
}
});
}
@override
Widget build(BuildContext context) {
if (!_isReady) {
_realm = context.watch<RealmProvider>();
_isReady = true;
}
return DefaultTabController(
length: 2,
child: Column(
children: [
TabBar(
isScrollable: !SolianTheme.isLargeScreen(context),
tabs: const [
Tab(icon: Icon(Icons.newspaper)),
Tab(icon: Icon(Icons.message)),
],
),
Expanded(
child: TabBarView(
children: [
ExplorePostWidget(realm: widget.alias),
ChatListWidget(realm: widget.alias),
],
),
)
],
),
);
}
}
class RealmManageAction extends StatelessWidget {
final Realm realm;
final Function onUpdate;
const RealmManageAction({
super.key,
required this.realm,
required this.onUpdate,
});
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () async {
final did = await SolianRouter.router.pushNamed(
'realms.manage',
extra: realm,
pathParameters: {'realm': realm.alias},
);
if (did == true) onUpdate();
},
icon: const Icon(Icons.settings),
);
}
}

View File

@ -0,0 +1,211 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:http/http.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:uuid/uuid.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class RealmEditorScreen extends StatefulWidget {
final Realm? editing;
final String? realm;
const RealmEditorScreen({super.key, this.editing, this.realm});
@override
State<RealmEditorScreen> createState() => _RealmEditorScreenState();
}
class _RealmEditorScreenState extends State<RealmEditorScreen> {
final _aliasController = TextEditingController();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
bool _isPublic = false;
bool _isCommunity = false;
bool _isSubmitting = false;
Future<void> applyChannel(BuildContext context) async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
final uri = widget.editing == null
? getRequestUri('passport', '/api/realms')
: getRequestUri('passport', '/api/realms/${widget.editing!.id}');
final req = Request(widget.editing == null ? 'POST' : 'PUT', uri);
req.headers['Content-Type'] = 'application/json';
req.body = jsonEncode(<String, dynamic>{
'alias': _aliasController.value.text.toLowerCase(),
'name': _nameController.value.text,
'description': _descriptionController.value.text,
'is_public': _isPublic,
'is_community': _isCommunity,
'realm': widget.realm,
});
var res = await Response.fromStream(await auth.client!.send(req));
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
} else {
if (SolianRouter.router.canPop()) {
SolianRouter.router.pop(true);
}
}
setState(() => _isSubmitting = false);
}
void randomizeAlias() {
_aliasController.text = const Uuid().v4().replaceAll('-', '').substring(0, 16);
}
void cancelEditing() {
if (SolianRouter.router.canPop()) {
SolianRouter.router.pop(false);
}
}
@override
void initState() {
if (widget.editing != null) {
_aliasController.text = widget.editing!.alias;
_nameController.text = widget.editing!.name;
_descriptionController.text = widget.editing!.description;
_isPublic = widget.editing!.isPublic;
_isCommunity = widget.editing!.isCommunity;
}
super.initState();
}
@override
Widget build(BuildContext context) {
final editingBanner = MaterialBanner(
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20),
leading: const Icon(Icons.edit_note),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.9),
dividerColor: const Color.fromARGB(1, 0, 0, 0),
content: Text(AppLocalizations.of(context)!.realmEditNotify),
actions: [
TextButton(
child: Text(AppLocalizations.of(context)!.cancel),
onPressed: () => cancelEditing(),
),
],
);
return IndentScaffold(
hideDrawer: true,
showSafeArea: true,
title: AppLocalizations.of(context)!.realmEstablish,
appBarActions: <Widget>[
TextButton(
onPressed: !_isSubmitting ? () => applyChannel(context) : null,
child: Text(AppLocalizations.of(context)!.apply.toUpperCase()),
),
],
fixedAppBarColor: SolianTheme.isLargeScreen(context),
body: Column(
children: [
_isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
widget.editing != null ? editingBanner : Container(),
ListTile(
title: Text(AppLocalizations.of(context)!.realmUsage),
subtitle: Text(AppLocalizations.of(context)!.realmUsageCaption),
leading: const CircleAvatar(
backgroundColor: Colors.teal,
child: Icon(Icons.supervised_user_circle, color: Colors.white),
),
),
const Divider(thickness: 0.3),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
child: Row(
children: [
Expanded(
child: TextField(
autofocus: true,
controller: _aliasController,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!.realmAliasLabel,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
TextButton(
style: TextButton.styleFrom(
shape: const CircleBorder(),
visualDensity: const VisualDensity(horizontal: -2, vertical: -2),
),
onPressed: () => randomizeAlias(),
child: const Icon(Icons.refresh),
)
],
),
),
const Divider(thickness: 0.3),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
autocorrect: true,
controller: _nameController,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!.realmNameLabel,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
const Divider(thickness: 0.3),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: TextField(
minLines: 5,
maxLines: null,
autocorrect: true,
keyboardType: TextInputType.multiline,
controller: _descriptionController,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!.realmDescriptionLabel,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
),
const Divider(thickness: 0.3),
CheckboxListTile(
title: Text(AppLocalizations.of(context)!.realmPublicLabel),
value: _isPublic,
onChanged: (newValue) {
setState(() => _isPublic = newValue ?? false);
},
controlAffinity: ListTileControlAffinity.leading,
),
CheckboxListTile(
title: Text(AppLocalizations.of(context)!.realmCommunityLabel),
value: _isCommunity,
onChanged: (newValue) {
setState(() => _isCommunity = newValue ?? false);
},
controlAffinity: ListTileControlAffinity.leading,
)
],
),
);
}
}

View File

@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/realm.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/realms/realm.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/notification_notifier.dart';
import 'package:solian/widgets/realms/realm_new.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/signin_required.dart';
class RealmListScreen extends StatelessWidget {
const RealmListScreen({super.key});
@override
Widget build(BuildContext context) {
final realm = context.watch<RealmProvider>();
return realm.focusRealm == null || !SolianTheme.isLargeScreen(context)
? IndentScaffold(
title: AppLocalizations.of(context)!.realm,
appBarActions: const [NotificationButton()],
fixedAppBarColor: SolianTheme.isLargeScreen(context),
body: const RealmListWidget(),
)
: RealmScreen(alias: realm.focusRealm!.alias);
}
}
class RealmListWidget extends StatefulWidget {
const RealmListWidget({super.key});
@override
State<RealmListWidget> createState() => _RealmListWidgetState();
}
class _RealmListWidgetState extends State<RealmListWidget> {
void viewNewRealmAction() {
final auth = context.read<AuthProvider>();
final realms = context.read<RealmProvider>();
showModalBottomSheet(
context: context,
builder: (context) => RealmNewAction(onUpdate: () => realms.fetch(auth)),
);
}
@override
void initState() {
Future.delayed(Duration.zero, () {
final auth = context.read<AuthProvider>();
final realms = context.read<RealmProvider>();
realms.fetch(auth);
});
super.initState();
}
@override
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
final realms = context.watch<RealmProvider>();
return Scaffold(
floatingActionButton: FutureBuilder(
future: auth.isAuthorized(),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data!) {
return FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: Text(AppLocalizations.of(context)!.realmNew),
onPressed: () => viewNewRealmAction(),
);
} else {
return Container();
}
},
),
body: FutureBuilder(
future: auth.isAuthorized(),
builder: (context, snapshot) {
if (!snapshot.hasData || !snapshot.data!) {
return const SignInRequiredScreen();
}
return RefreshIndicator(
onRefresh: () => realms.fetch(auth),
child: ListView.builder(
itemCount: realms.realms.length,
itemBuilder: (context, index) {
final element = realms.realms[index];
return ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.indigo,
child: Icon(Icons.supervisor_account, color: Colors.white),
),
title: Text(element.name),
subtitle: Text(element.description),
onTap: () async {
realms.fetchSingle(auth, element.alias);
String? result;
if (SolianRouter.currentRoute.name == 'chat.channel') {
result = await SolianRouter.router.pushReplacementNamed(
'realms.details',
pathParameters: {
'realm': element.alias,
},
);
} else {
result = await SolianRouter.router.pushNamed(
'realms.details',
pathParameters: {
'realm': element.alias,
},
);
}
switch (result) {
case 'refresh':
realms.fetch(auth);
}
},
);
},
),
);
},
),
);
}
}

View File

@ -1,34 +1,34 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/widgets/chat/channel_deletion.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/widgets/realms/realm_deletion.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ChatManageScreen extends StatefulWidget {
final Channel channel;
class RealmManageScreen extends StatefulWidget {
final Realm realm;
const ChatManageScreen({super.key, required this.channel});
const RealmManageScreen({super.key, required this.realm});
@override
State<ChatManageScreen> createState() => _ChatManageScreenState();
State<RealmManageScreen> createState() => _RealmManageScreenState();
}
class _ChatManageScreenState extends State<ChatManageScreen> {
class _RealmManageScreenState extends State<RealmManageScreen> {
bool _isOwned = false;
void promptLeaveChannel() async {
final did = await showDialog(
context: context,
builder: (context) => ChannelDeletion(
channel: widget.channel,
builder: (context) => RealmDeletion(
realm: widget.realm,
isOwned: _isOwned,
),
);
if (did == true && router.canPop()) {
router.pop('disposed');
if (did == true && SolianRouter.router.canPop()) {
SolianRouter.router.pop('disposed');
}
}
@ -41,7 +41,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
final prof = await auth.getProfiles();
setState(() {
_isOwned = prof['id'] == widget.channel.account.externalId;
_isOwned = prof['id'] == widget.realm.accountId;
});
});
}
@ -53,20 +53,19 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
leading: const Icon(Icons.settings),
title: Text(AppLocalizations.of(context)!.settings),
onTap: () async {
router.pushNamed('chat.channel.editor', extra: widget.channel).then((did) {
SolianRouter.router.pushNamed('realms.editor', extra: widget.realm).then((did) {
if (did == true) {
if (router.canPop()) router.pop('refresh');
if (SolianRouter.router.canPop()) SolianRouter.router.pop('refresh');
}
});
},
),
];
return IndentWrapper(
title: AppLocalizations.of(context)!.chatManage,
return IndentScaffold(
title: AppLocalizations.of(context)!.realmManage,
hideDrawer: true,
noSafeArea: true,
child: Column(
body: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
@ -80,8 +79,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
const SizedBox(width: 16),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(widget.channel.name, style: Theme.of(context).textTheme.bodyLarge),
Text(widget.channel.description, style: Theme.of(context).textTheme.bodySmall),
Text(widget.realm.name, style: Theme.of(context).textTheme.bodyLarge),
Text(widget.realm.description, style: Theme.of(context).textTheme.bodySmall),
]),
)
],
@ -91,19 +90,14 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
Expanded(
child: ListView(
children: [
ListTile(
leading: const Icon(Icons.edit_notifications),
title: Text(AppLocalizations.of(context)!.chatNotifySetting),
onTap: () {},
),
ListTile(
leading: const Icon(Icons.supervisor_account),
title: Text(AppLocalizations.of(context)!.chatMember),
onTap: () {
router.pushNamed(
'chat.channel.member',
extra: widget.channel,
pathParameters: {'channel': widget.channel.alias},
SolianRouter.router.pushNamed(
'realms.member',
extra: widget.realm,
pathParameters: {'realm': widget.realm.alias},
);
},
),

View File

@ -0,0 +1,191 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/friend_picker.dart';
import 'package:solian/widgets/exts.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/scaffold.dart';
class RealmMemberScreen extends StatefulWidget {
final Realm realm;
const RealmMemberScreen({super.key, required this.realm});
@override
State<RealmMemberScreen> createState() => _RealmMemberScreenState();
}
class _RealmMemberScreenState extends State<RealmMemberScreen> {
bool _isSubmitting = false;
List<RealmMember> _members = List.empty();
int _selfId = 0;
Future<void> fetchMemberships() async {
final auth = context.read<AuthProvider>();
final prof = await auth.getProfiles();
if (!await auth.isAuthorized()) return;
_selfId = prof['id'];
var uri = getRequestUri('passport', '/api/realms/${widget.realm.alias}/members');
var res = await auth.client!.get(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes)) as List<dynamic>;
setState(() {
_members = result.map((x) => RealmMember.fromJson(x)).toList();
});
} else {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
}
}
Future<void> removeMember(RealmMember item) async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
var uri = getRequestUri('passport', '/api/realms/${widget.realm.alias}/members');
var res = await auth.client!.delete(
uri,
headers: {
'Content-Type': 'application/json',
},
body: jsonEncode({
'target': item.account.name,
}),
);
if (res.statusCode == 200) {
await fetchMemberships();
} else {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
}
setState(() => _isSubmitting = false);
}
Future<void> addMember(String username) async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
var uri = getRequestUri('passport', '/api/realms/${widget.realm.alias}/members');
var res = await auth.client!.post(
uri,
headers: {
'Content-Type': 'application/json',
},
body: jsonEncode({
'target': username,
}),
);
if (res.statusCode == 200) {
await fetchMemberships();
} else {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
}
setState(() => _isSubmitting = false);
}
void promptAddMember() async {
final input = await showModalBottomSheet(
context: context,
builder: (context) {
return const FriendPicker();
},
);
if (input == null) return;
await addMember((input as Account).name);
}
bool getRemovable(RealmMember item) {
if (_selfId != widget.realm.accountId) return false;
if (item.accountId == widget.realm.accountId) return false;
if (item.account.id == _selfId) return false;
return true;
}
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () => fetchMemberships());
}
@override
Widget build(BuildContext context) {
return IndentScaffold(
title: AppLocalizations.of(context)!.realmMember,
fixedAppBarColor: SolianTheme.isLargeScreen(context),
hideDrawer: true,
appBarActions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => promptAddMember(),
),
],
body: RefreshIndicator(
onRefresh: () => fetchMemberships(),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
),
SliverList.builder(
itemCount: _members.length,
itemBuilder: (context, index) {
final element = _members[index];
final randomId = DateTime.now().microsecondsSinceEpoch >> 10;
return Dismissible(
key: Key(randomId.toString()),
direction: getRemovable(element) ? DismissDirection.startToEnd : DismissDirection.none,
background: Container(
color: Colors.red,
padding: const EdgeInsets.symmetric(horizontal: 20),
alignment: Alignment.centerLeft,
child: const Icon(Icons.remove, color: Colors.white),
),
child: ListTile(
leading: AccountAvatar(source: element.account.avatar),
title: Text(element.account.nick),
subtitle: Text(element.account.name),
),
onDismissed: (_) {
removeMember(element);
},
);
},
)
],
),
),
);
}
}

View File

@ -1,100 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:url_launcher/url_launcher_string.dart';
class SignInScreen extends StatelessWidget {
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
SignInScreen({super.key});
@override
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
return IndentWrapper(
title: AppLocalizations.of(context)!.signIn,
hideDrawer: true,
child: Center(
child: Container(
width: MediaQuery.of(context).size.width * 0.6,
constraints: const BoxConstraints(maxWidth: 360),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _usernameController,
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: AppLocalizations.of(context)!.username,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 12),
TextField(
obscureText: true,
autocorrect: false,
enableSuggestions: false,
controller: _passwordController,
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: AppLocalizations.of(context)!.password,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextButton(
child: Text(AppLocalizations.of(context)!.next),
onPressed: () {
final username = _usernameController.value.text;
final password = _passwordController.value.text;
if (username.isEmpty || password.isEmpty) return;
auth.signin(context, username, password).then((_) {
router.pop(true);
}).catchError((e) {
List<String> messages = e.toString().split('\n');
if (messages.last.contains("risk")) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(AppLocalizations.of(context)!.riskDetection),
content: Text(AppLocalizations.of(context)!.signInRiskDetected),
actions: [
TextButton(
child: Text(AppLocalizations.of(context)!.next),
onPressed: () {
launchUrlString(getRequestUri('passport', '/sign-in').toString());
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
},
)
],
);
},
);
} else {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(messages.last),
));
}
});
},
)
],
),
),
),
);
}
}

View File

@ -0,0 +1,170 @@
import 'dart:convert';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/personal_page.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/personal_page_content.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
class UserInfoScreen extends StatefulWidget {
final String name;
const UserInfoScreen({super.key, required this.name});
@override
State<UserInfoScreen> createState() => _UserInfoScreenState();
}
class _UserInfoScreenState extends State<UserInfoScreen> {
final _client = Client();
Account? _userinfo;
PersonalPage? _page;
Future<Account> fetchUserinfo() async {
final res = await Future.wait([
_client.get(getRequestUri('passport', '/api/users/${widget.name}')),
_client.get(getRequestUri('passport', '/api/users/${widget.name}/page'))
], eagerError: true);
final mistakeRes = res.indexWhere((x) => x.statusCode != 200 && x.statusCode != 400);
if (mistakeRes > -1) {
var message = utf8.decode(res[mistakeRes].bodyBytes);
context.showErrorDialog(message);
throw Exception(message);
} else {
final info = Account.fromJson(jsonDecode(utf8.decode(res[0].bodyBytes)));
final page = res[1].statusCode == 200 ? PersonalPage.fromJson(jsonDecode(utf8.decode(res[1].bodyBytes))) : null;
setState(() {
_userinfo = info;
_page = page ??
PersonalPage(
id: 0,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
content: '',
script: '',
style: '',
accountId: info.id,
);
});
return info;
}
}
String getAuthorDescribe() => _userinfo!.description.isNotEmpty ? _userinfo!.description : 'No description yet.';
@override
Widget build(BuildContext context) {
return IndentScaffold(
title: _userinfo?.nick ?? 'Loading...',
fixedAppBarColor: SolianTheme.isLargeScreen(context),
hideDrawer: true,
body: FutureBuilder(
future: fetchUserinfo(),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data == null) {
return const Center(child: CircularProgressIndicator());
}
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 24),
children: [
const SizedBox(height: 8),
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio(
aspectRatio: 16 / 5,
child: Container(
color: Theme.of(context).colorScheme.surfaceVariant,
child: _userinfo?.banner != null
? CachedNetworkImage(
imageUrl: getRequestUri('passport', '/api/avatar/${_userinfo!.banner}').toString(),
fit: BoxFit.cover,
progressIndicatorBuilder: (_, __, DownloadProgress loadingProgress) {
return Center(
child: CircularProgressIndicator(
value: loadingProgress.totalSize != null
? loadingProgress.downloaded / loadingProgress.totalSize!
: null,
),
);
},
)
: Container(),
),
),
),
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 20),
child: Row(
children: [
AccountAvatar(source: _userinfo!.avatar, radius: 32),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
textBaseline: TextBaseline.alphabetic,
crossAxisAlignment: CrossAxisAlignment.baseline,
children: [
Text(
_userinfo!.nick,
maxLines: 1,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
Text(
'@${_userinfo!.name}',
maxLines: 1,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
overflow: TextOverflow.ellipsis,
),
),
],
),
Text(
_userinfo!.description,
maxLines: 3,
style: const TextStyle(
fontSize: 14,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Divider(thickness: 0.3, indent: 4, endIndent: 4),
),
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: PersonalPageContent(item: _page!),
),
),
],
);
},
),
);
}
}

9
lib/utils/file.dart Normal file
View File

@ -0,0 +1,9 @@
import 'dart:io';
import 'package:crypto/crypto.dart';
Future<String> calculateFileSha256(File file) async {
final bytes = await file.readAsBytes();
final digest = sha256.convert(bytes);
return digest.toString();
}

86
lib/utils/http.dart Normal file
View File

@ -0,0 +1,86 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:solian/utils/services_url.dart';
class HttpClient extends http.BaseClient {
final bool isUnauthorizedRetry;
final Future<String> Function()? onUnauthorizedRetry;
final Function(String atk, String rtk)? onTokenRefreshed;
final _client = http.Client();
HttpClient({
this.isUnauthorizedRetry = true,
this.onUnauthorizedRetry,
this.onTokenRefreshed,
String? defaultToken,
String? defaultRefreshToken,
}) {
currentToken = defaultToken;
currentRefreshToken = defaultRefreshToken;
}
String? currentToken;
String? currentRefreshToken;
Future<String> refreshToken(String token) async {
final res = await _client.post(
getRequestUri('passport', '/api/auth/token'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'refresh_token': token, 'grant_type': 'refresh_token'}),
);
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
throw Exception('An error occurred when trying refresh token: $message');
}
final result = jsonDecode(utf8.decode(res.bodyBytes));
currentToken = result['access_token'];
currentRefreshToken = result['refresh_token'];
if (onTokenRefreshed != null) {
onTokenRefreshed!(currentToken!, currentRefreshToken!);
}
return currentToken!;
}
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
request.headers['Authorization'] = 'Bearer $currentToken';
final res = await _client.send(request);
if (res.statusCode == 401 && currentToken != null && isUnauthorizedRetry) {
if (onUnauthorizedRetry != null) {
currentToken = await onUnauthorizedRetry!();
} else if (currentRefreshToken != null) {
currentToken = await refreshToken(currentRefreshToken!);
} else {
final result = await http.Response.fromStream(res);
throw Exception(utf8.decode(result.bodyBytes));
}
http.BaseRequest newRequest;
if (request is http.Request) {
newRequest = http.Request(request.method, request.url)
..encoding = request.encoding
..bodyBytes = request.bodyBytes;
} else if (request is http.MultipartRequest) {
newRequest = http.MultipartRequest(request.method, request.url)
..fields.addAll(request.fields)
..files.addAll(request.files);
} else {
throw Exception('unsupported request type to auto retry');
}
newRequest
..persistentConnection = request.persistentConnection
..followRedirects = request.followRedirects
..maxRedirects = request.maxRedirects
..headers.addAll(request.headers)
..headers['Authorization'] = 'Bearer $currentToken';
return await _client.send(newRequest);
}
return res;
}
}

39
lib/utils/platform.dart Normal file
View File

@ -0,0 +1,39 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
abstract class PlatformInfo {
static bool get isWeb => kIsWeb;
static bool get isLinux => !kIsWeb && Platform.isLinux;
static bool get isWindows => !kIsWeb && Platform.isWindows;
static bool get isMacOS => !kIsWeb && Platform.isMacOS;
static bool get isIOS => !kIsWeb && Platform.isIOS;
static bool get isAndroid => !kIsWeb && Platform.isAndroid;
static bool get isMobile => isAndroid || isIOS;
// Not first tier supported platform
static bool get isBetaDesktop => isWindows || isLinux;
static bool get isDesktop => isLinux || isWindows || isMacOS;
static bool get useTouchscreen => !isMobile;
static bool get canCacheImage => isAndroid || isIOS || isMacOS;
static bool get canRecord => (isMobile || isMacOS);
static Future<String> getVersion() async {
var version = kIsWeb ? 'Web' : 'Unknown';
try {
version = (await PackageInfo.fromPlatform()).version;
} catch (_) {}
return version;
}
}

14
lib/utils/theme.dart Normal file
View File

@ -0,0 +1,14 @@
import 'package:flutter/material.dart';
abstract class SolianTheme {
static bool isLargeScreen(BuildContext context) =>
MediaQuery.of(context).size.width > 640;
static ThemeData build(Brightness brightness) {
return ThemeData(
brightness: brightness,
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(brightness: brightness, seedColor: Colors.indigo),
);
}
}

View File

@ -4,4 +4,4 @@ import 'package:media_kit/media_kit.dart';
void initVideo() {
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
}
}

View File

@ -1,5 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/platform.dart';
import 'package:solian/utils/services_url.dart';
class AccountAvatar extends StatelessWidget {
final String source;
@ -27,17 +29,19 @@ class AccountAvatar extends StatelessWidget {
);
}
if (direct == true) {
final image = PlatformInfo.canCacheImage ? CachedNetworkImageProvider(source) : NetworkImage(source);
return CircleAvatar(
radius: radius,
backgroundColor: backgroundColor,
backgroundImage: NetworkImage(source),
backgroundImage: image as ImageProvider,
);
} else {
final url = getRequestUri('passport', '/api/avatar/$source').toString();
final image = PlatformInfo.canCacheImage ? CachedNetworkImageProvider(url) : NetworkImage(url);
return CircleAvatar(
radius: radius,
backgroundColor: backgroundColor,
backgroundImage: NetworkImage(url),
backgroundImage: image as ImageProvider,
);
}
}

View File

@ -3,7 +3,7 @@ import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/friend.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/account/avatar.dart';
import 'package:solian/widgets/account/account_avatar.dart';
class FriendPicker extends StatefulWidget {
const FriendPicker({super.key});
@ -37,7 +37,8 @@ class _FriendPickerState extends State<FriendPicker> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.only(left: 16, right: 16, top: 32, bottom: 12),
padding:
const EdgeInsets.only(left: 16, right: 16, top: 32, bottom: 12),
child: Text(
AppLocalizations.of(context)!.friend,
style: Theme.of(context).textTheme.headlineSmall,

View File

@ -1,16 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:solian/models/message.dart';
import 'package:solian/models/personal_page.dart';
import 'package:url_launcher/url_launcher_string.dart';
class ChatMessageContent extends StatelessWidget {
final Message item;
class PersonalPageContent extends StatelessWidget {
final PersonalPage item;
const ChatMessageContent({super.key, required this.item});
const PersonalPageContent({super.key, required this.item});
@override
Widget build(BuildContext context) {
return Markdown(
selectable: true,
data: item.content,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),

View File

@ -5,7 +5,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_background/flutter_background.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/router.dart';
import 'package:solian/widgets/chat/call/exts.dart';
import 'package:solian/widgets/exts.dart';
class ControlsWidget extends StatefulWidget {
final Room room;
@ -36,7 +41,8 @@ class _ControlsWidgetState extends State<ControlsWidget> {
void initState() {
super.initState();
participant.addListener(onChange);
_subscription = Hardware.instance.onDeviceChange.stream.listen((List<MediaDevice> devices) {
_subscription = Hardware.instance.onDeviceChange.stream
.listen((List<MediaDevice> devices) {
revertDevices(devices);
});
Hardware.instance.enumerateDevices().then(revertDevices);
@ -61,18 +67,24 @@ class _ControlsWidgetState extends State<ControlsWidget> {
void onChange() => setState(() {});
void unpublishAll() async {
final result = await context.showUnPublishDialog();
if (result == true) await participant.unpublishAllTracks();
}
bool get isMuted => participant.isMuted;
void disconnect() async {
if (await context.showDisconnectDialog() != true) return;
final chat = context.read<ChatProvider>();
if (chat.currentCall != null) {
chat.currentCall!.deactivate();
chat.currentCall!.dispose();
SolianRouter.router.pop();
}
}
void disableAudio() async {
await participant.setMicrophoneEnabled(false);
}
Future<void> enableAudio() async {
void enableAudio() async {
await participant.setMicrophoneEnabled(true);
}
@ -106,7 +118,6 @@ class _ControlsWidgetState extends State<ControlsWidget> {
}
void toggleCamera() async {
//
final track = participant.videoTrackPublications.firstOrNull?.track;
if (track == null) return;
@ -140,9 +151,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
await participant.publishVideoTrack(track);
} catch (e) {
final message = e.toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Something went wrong... $message'),
));
context.showErrorDialog(message);
}
return;
}
@ -153,18 +162,23 @@ class _ControlsWidgetState extends State<ControlsWidget> {
if (!isRetry) {
const androidConfig = FlutterBackgroundAndroidConfig(
notificationTitle: 'Screen Sharing',
notificationText: 'A Solar Messager\'s Call is sharing your screen',
notificationText:
'A Solar Messager\'s Call is sharing your screen',
notificationImportance: AndroidNotificationImportance.Default,
notificationIcon: AndroidResource(name: 'launcher_icon', defType: 'mipmap'),
notificationIcon:
AndroidResource(name: 'launcher_icon', defType: 'mipmap'),
);
hasPermissions = await FlutterBackground.initialize(androidConfig: androidConfig);
hasPermissions = await FlutterBackground.initialize(
androidConfig: androidConfig);
}
if (hasPermissions && !FlutterBackground.isBackgroundExecutionEnabled) {
if (hasPermissions &&
!FlutterBackground.isBackgroundExecutionEnabled) {
await FlutterBackground.enableBackgroundExecution();
}
} catch (e) {
if (!isRetry) {
return await Future<void>.delayed(const Duration(seconds: 1), () => requestBackgroundPermission(true));
return await Future<void>.delayed(const Duration(seconds: 1),
() => requestBackgroundPermission(true));
}
}
}
@ -202,22 +216,6 @@ class _ControlsWidgetState extends State<ControlsWidget> {
}
}
void onTapUpdateSubscribePermission() async {
final result = await context.showSubscribePermissionDialog();
if (result != null) {
try {
widget.room.localParticipant?.setTrackSubscriptionPermissions(
allParticipantsAllowed: result,
);
} catch (e) {
final message = e.toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Something went wrong... $message'),
));
}
}
}
@override
Widget build(BuildContext context) {
return Padding(
@ -231,16 +229,18 @@ class _ControlsWidgetState extends State<ControlsWidget> {
runSpacing: 5,
children: [
IconButton(
onPressed: unpublishAll,
icon: const Icon(Icons.cancel),
tooltip: 'Unpublish all',
icon: Transform.flip(
flipX: true, child: const Icon(Icons.exit_to_app)),
color: Theme.of(context).colorScheme.onSurface,
onPressed: disconnect,
),
if (participant.isMicrophoneEnabled())
if (lkPlatformIs(PlatformType.android))
IconButton(
onPressed: disableAudio,
icon: const Icon(Icons.mic),
tooltip: 'mute audio',
color: Theme.of(context).colorScheme.onSurface,
tooltip: AppLocalizations.of(context)!.chatCallMute,
)
else
PopupMenuButton<MediaDevice>(
@ -250,9 +250,9 @@ class _ControlsWidgetState extends State<ControlsWidget> {
PopupMenuItem<MediaDevice>(
value: null,
onTap: isMuted ? enableAudio : disableAudio,
child: const ListTile(
leading: Icon(Icons.mic_off),
title: Text('Mute Microphone'),
child: ListTile(
leading: const Icon(Icons.mic_off),
title: Text(AppLocalizations.of(context)!.chatCallMute),
),
),
if (_audioInputs != null)
@ -260,7 +260,8 @@ class _ControlsWidgetState extends State<ControlsWidget> {
return PopupMenuItem<MediaDevice>(
value: device,
child: ListTile(
leading: (device.deviceId == widget.room.selectedAudioInputDeviceId)
leading: (device.deviceId ==
widget.room.selectedAudioInputDeviceId)
? const Icon(Icons.check_box_outlined)
: const Icon(Icons.check_box_outline_blank),
title: Text(device.label),
@ -275,8 +276,55 @@ class _ControlsWidgetState extends State<ControlsWidget> {
IconButton(
onPressed: enableAudio,
icon: const Icon(Icons.mic_off),
tooltip: 'un-mute audio',
color: Theme.of(context).colorScheme.onSurface,
tooltip: AppLocalizations.of(context)!.chatCallUnMute,
),
if (participant.isCameraEnabled())
PopupMenuButton<MediaDevice>(
icon: const Icon(Icons.videocam_sharp),
itemBuilder: (BuildContext context) {
return [
PopupMenuItem<MediaDevice>(
value: null,
onTap: disableVideo,
child: ListTile(
leading: const Icon(Icons.videocam_off),
title:
Text(AppLocalizations.of(context)!.chatCallVideoOff),
),
),
if (_videoInputs != null)
..._videoInputs!.map((device) {
return PopupMenuItem<MediaDevice>(
value: device,
child: ListTile(
leading: (device.deviceId ==
widget.room.selectedVideoInputDeviceId)
? const Icon(Icons.check_box_outlined)
: const Icon(Icons.check_box_outline_blank),
title: Text(device.label),
),
onTap: () => selectVideoInput(device),
);
})
];
},
)
else
IconButton(
onPressed: enableVideo,
icon: const Icon(Icons.videocam_off),
color: Theme.of(context).colorScheme.onSurface,
tooltip: AppLocalizations.of(context)!.chatCallVideoOn,
),
IconButton(
icon: Icon(position == CameraPosition.back
? Icons.video_camera_back
: Icons.video_camera_front),
color: Theme.of(context).colorScheme.onSurface,
onPressed: () => toggleCamera(),
tooltip: AppLocalizations.of(context)!.chatCallVideoFlip,
),
if (!lkPlatformIs(PlatformType.iOS))
PopupMenuButton<MediaDevice>(
icon: const Icon(Icons.volume_up),
@ -294,7 +342,8 @@ class _ControlsWidgetState extends State<ControlsWidget> {
return PopupMenuItem<MediaDevice>(
value: device,
child: ListTile(
leading: (device.deviceId == widget.room.selectedAudioOutputDeviceId)
leading: (device.deviceId ==
widget.room.selectedAudioOutputDeviceId)
? const Icon(Icons.check_box_outlined)
: const Icon(Icons.check_box_outline_blank),
title: Text(device.label),
@ -307,71 +356,28 @@ class _ControlsWidgetState extends State<ControlsWidget> {
),
if (!kIsWeb && lkPlatformIs(PlatformType.iOS))
IconButton(
disabledColor: Colors.grey,
onPressed: Hardware.instance.canSwitchSpeakerphone ? setSpeakerphoneOn : null,
icon: Icon(_speakerphoneOn ? Icons.speaker_phone : Icons.phone_android),
tooltip: 'Switch SpeakerPhone',
onPressed: Hardware.instance.canSwitchSpeakerphone
? setSpeakerphoneOn
: null,
color: Theme.of(context).colorScheme.onSurface,
icon: Icon(
_speakerphoneOn ? Icons.speaker_phone : Icons.phone_android),
tooltip: AppLocalizations.of(context)!.chatCallChangeSpeaker,
),
if (participant.isCameraEnabled())
PopupMenuButton<MediaDevice>(
icon: const Icon(Icons.videocam_sharp),
itemBuilder: (BuildContext context) {
return [
PopupMenuItem<MediaDevice>(
value: null,
onTap: disableVideo,
child: const ListTile(
leading: Icon(
Icons.videocam_off,
color: Colors.white,
),
title: Text('Disable Camera'),
),
),
if (_videoInputs != null)
..._videoInputs!.map((device) {
return PopupMenuItem<MediaDevice>(
value: device,
child: ListTile(
leading: (device.deviceId == widget.room.selectedVideoInputDeviceId)
? const Icon(Icons.check_box_outlined)
: const Icon(Icons.check_box_outline_blank),
title: Text(device.label),
),
onTap: () => selectVideoInput(device),
);
})
];
},
)
else
IconButton(
onPressed: enableVideo,
icon: const Icon(Icons.videocam_off),
tooltip: 'un-mute video',
),
IconButton(
icon: Icon(position == CameraPosition.back ? Icons.video_camera_back : Icons.video_camera_front),
onPressed: () => toggleCamera(),
tooltip: 'toggle camera',
),
if (participant.isScreenShareEnabled())
IconButton(
icon: const Icon(Icons.monitor_outlined),
color: Theme.of(context).colorScheme.onSurface,
onPressed: () => disableScreenShare(),
tooltip: 'unshare screen (experimental)',
tooltip: AppLocalizations.of(context)!.chatCallScreenOff,
)
else
IconButton(
icon: const Icon(Icons.monitor),
color: Theme.of(context).colorScheme.onSurface,
onPressed: () => enableScreenShare(),
tooltip: 'share screen (experimental)',
tooltip: AppLocalizations.of(context)!.chatCallScreenOn,
),
IconButton(
onPressed: onTapUpdateSubscribePermission,
icon: const Icon(Icons.settings),
tooltip: 'Subscribe permission',
),
],
),
);

View File

@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:draggable_float_widget/draggable_float_widget.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/router.dart';
class CallOverlay extends StatelessWidget {
const CallOverlay({super.key});
@override
Widget build(BuildContext context) {
const radius = BorderRadius.all(Radius.circular(8));
final chat = context.watch<ChatProvider>();
if (chat.isCallShown || chat.currentCall == null) {
return Container();
}
return DraggableFloatWidget(
config: const DraggableFloatWidgetBaseConfig(
initPositionYInTop: false,
initPositionYMarginBorder: 50,
borderTopContainTopBar: true,
borderBottom: defaultBorderWidth,
borderLeft: 8,
),
child: Material(
elevation: 6,
color: Colors.transparent,
borderRadius: radius,
child: ClipRRect(
borderRadius: radius,
child: Container(
height: 80,
width: 80,
color: Theme.of(context).colorScheme.secondaryContainer,
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.call, size: 18),
const SizedBox(height: 4),
Text(
AppLocalizations.of(context)!.chatCallOngoingShort,
style: const TextStyle(fontSize: 12),
)
],
),
),
),
),
onTap: () {
SolianRouter.router.pushNamed(
'chat.channel.call',
extra: chat.currentCall!.info,
pathParameters: {'channel': chat.currentCall!.channel.alias},
);
},
);
}
}

View File

@ -1,206 +1,74 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
extension SolianCallExt on BuildContext {
Future<bool?> showPublishDialog() => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Publish'),
content: const Text('Would you like to publish your Camera & Mic ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('NO'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('YES'),
),
],
),
);
Future<bool?> showPlayAudioManuallyDialog() => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Play Audio'),
content: const Text(
'You need to manually activate audio PlayBack for iOS Safari !'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Ignore'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Play Audio'),
),
],
),
);
Future<bool?> showUnPublishDialog() => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('UnPublish'),
content:
const Text('Would you like to un-publish your Camera & Mic ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('NO'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('YES'),
),
],
),
);
Future<void> showErrorDialog(dynamic exception) => showDialog<void>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Error'),
content: Text(exception.toString()),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('OK'),
)
],
),
);
Future<bool?> showDisconnectDialog() => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Disconnect'),
content: const Text('Are you sure to disconnect?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Disconnect'),
),
],
),
);
Future<bool?> showReconnectDialog() => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Reconnect'),
content: const Text('This will force a reconnection'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Reconnect'),
),
],
),
);
Future<void> showReconnectSuccessDialog() => showDialog<void>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Reconnect'),
content: const Text('Reconnection was successful.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('OK'),
),
],
),
);
Future<bool?> showSendDataDialog() => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Send data'),
content: const Text(
'This will send a sample data to all participants in the room'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Send'),
),
],
),
);
Future<bool?> showDataReceivedDialog(String data) => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Received data'),
content: Text(data),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('OK'),
),
],
),
);
Future<bool?> showRecordingStatusChangedDialog(bool isActiveRecording) =>
showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Room recording reminder'),
content: Text(isActiveRecording
? 'Room recording is active.'
: 'Room recording is stoped.'),
title: const Text('Play Audio'),
content: const Text(
'You need to manually activate audio PlayBack for iOS Safari!',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Ignore'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('OK'),
child: const Text('Play Audio'),
),
],
),
);
Future<bool?> showSubscribePermissionDialog() => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Allow subscription'),
content: const Text(
'Allow all participants to subscribe tracks published by local participant?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('NO'),
Future<bool?> showDisconnectDialog() => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: Text(AppLocalizations.of(this)!.chatCallDisconnect),
content: Text(AppLocalizations.of(this)!.chatCallDisconnectConfirm),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(AppLocalizations.of(this)!.confirmCancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(AppLocalizations.of(this)!.confirmOkay),
),
],
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('YES'),
),
],
),
);
}
);
enum SimulateScenarioResult {
signalReconnect,
fullReconnect,
speakerUpdate,
nodeFailure,
migration,
serverLeave,
switchCandidate,
e2eeKeyRatchet,
participantName,
participantMetadata,
clear,
}
Future<bool?> showReconnectDialog() => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Reconnect'),
content: const Text('This will force a reconnection'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Reconnect'),
),
],
),
);
Future<void> showReconnectSuccessDialog() => showDialog<void>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Reconnect'),
content: const Text('Reconnection was successful.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('OK'),
),
],
),
);
}

View File

@ -1,18 +0,0 @@
import 'package:flutter/material.dart';
import 'dart:math' as math;
class NoVideoWidget extends StatelessWidget {
const NoVideoWidget({super.key});
@override
Widget build(BuildContext context) => Container(
alignment: Alignment.center,
child: LayoutBuilder(
builder: (ctx, constraints) => Icon(
Icons.videocam_off_outlined,
color: Theme.of(context).colorScheme.primary,
size: math.min(constraints.maxHeight, constraints.maxWidth) * 0.3,
),
),
);
}

View File

@ -1,19 +1,33 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/call.dart';
import 'package:solian/widgets/chat/call/no_video.dart';
import 'package:solian/widgets/chat/call/participant_no_content.dart';
import 'package:solian/widgets/chat/call/participant_info.dart';
import 'package:solian/widgets/chat/call/participant_stats.dart';
abstract class ParticipantWidget extends StatefulWidget {
static ParticipantWidget widgetFor(ParticipantTrack participantTrack, {bool showStatsLayer = false}) {
static ParticipantWidget widgetFor(ParticipantTrack participantTrack,
{bool isFixed = false, bool showStatsLayer = false}) {
if (participantTrack.participant is LocalParticipant) {
return LocalParticipantWidget(participantTrack.participant as LocalParticipant, participantTrack.videoTrack,
participantTrack.isScreenShare, showStatsLayer);
return LocalParticipantWidget(
participantTrack.participant as LocalParticipant,
participantTrack.videoTrack,
isFixed,
participantTrack.isScreenShare,
showStatsLayer,
);
} else if (participantTrack.participant is RemoteParticipant) {
return RemoteParticipantWidget(participantTrack.participant as RemoteParticipant, participantTrack.videoTrack,
participantTrack.isScreenShare, showStatsLayer);
return RemoteParticipantWidget(
participantTrack.participant as RemoteParticipant,
participantTrack.videoTrack,
isFixed,
participantTrack.isScreenShare,
showStatsLayer,
);
}
throw UnimplementedError('Unknown participant type');
}
@ -21,6 +35,7 @@ abstract class ParticipantWidget extends StatefulWidget {
abstract final Participant participant;
abstract final VideoTrack? videoTrack;
abstract final bool isScreenShare;
abstract final bool isFixed;
abstract final bool showStatsLayer;
final VideoQuality quality;
@ -36,6 +51,8 @@ class LocalParticipantWidget extends ParticipantWidget {
@override
final VideoTrack? videoTrack;
@override
final bool isFixed;
@override
final bool isScreenShare;
@override
final bool showStatsLayer;
@ -43,6 +60,7 @@ class LocalParticipantWidget extends ParticipantWidget {
const LocalParticipantWidget(
this.participant,
this.videoTrack,
this.isFixed,
this.isScreenShare,
this.showStatsLayer, {
super.key,
@ -58,6 +76,8 @@ class RemoteParticipantWidget extends ParticipantWidget {
@override
final VideoTrack? videoTrack;
@override
final bool isFixed;
@override
final bool isScreenShare;
@override
final bool showStatsLayer;
@ -65,6 +85,7 @@ class RemoteParticipantWidget extends ParticipantWidget {
const RemoteParticipantWidget(
this.participant,
this.videoTrack,
this.isFixed,
this.isScreenShare,
this.showStatsLayer, {
super.key,
@ -74,15 +95,14 @@ class RemoteParticipantWidget extends ParticipantWidget {
State<StatefulWidget> createState() => _RemoteParticipantWidgetState();
}
abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends State<T> {
bool _visible = true;
abstract class _ParticipantWidgetState<T extends ParticipantWidget>
extends State<T> {
VideoTrack? get _activeVideoTrack;
TrackPublication? get _videoPublication;
TrackPublication? get _firstAudioPublication;
Account? _userinfoMetadata;
@override
void initState() {
super.initState();
@ -104,73 +124,60 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat
super.didUpdateWidget(oldWidget);
}
void onParticipantChanged() => setState(() {});
List<Widget> extraWidgets(bool isScreenShare) => [];
void onParticipantChanged() {
setState(() {
if (widget.participant.metadata != null) {
_userinfoMetadata =
Account.fromJson(jsonDecode(widget.participant.metadata!));
}
});
}
@override
Widget build(BuildContext ctx) => Container(
foregroundDecoration: BoxDecoration(
border: widget.participant.isSpeaking && !widget.isScreenShare
? Border.all(
width: 5,
color: Theme.of(context).colorScheme.primary,
)
: null,
),
decoration: BoxDecoration(
color: Theme.of(ctx).cardColor,
),
child: Stack(
children: [
// Video
InkWell(
onTap: () => setState(() => _visible = !_visible),
child: _activeVideoTrack != null && !_activeVideoTrack!.muted
? VideoTrackRenderer(
_activeVideoTrack!,
fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
)
: const NoVideoWidget(),
),
if (widget.showStatsLayer)
Positioned(
top: 30,
right: 30,
child: ParticipantStatsWidget(
participant: widget.participant,
)),
// Bottom bar
Align(
alignment: Alignment.bottomCenter,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
...extraWidgets(widget.isScreenShare),
ParticipantInfoWidget(
title: widget.participant.name.isNotEmpty
? '${widget.participant.name} (${widget.participant.identity})'
: widget.participant.identity,
audioAvailable:
_firstAudioPublication?.muted == false && _firstAudioPublication?.subscribed == true,
connectionQuality: widget.participant.connectionQuality,
isScreenShare: widget.isScreenShare,
enabledE2EE: widget.participant.isEncrypted,
),
],
Widget build(BuildContext ctx) {
return Stack(
children: [
_activeVideoTrack != null && !_activeVideoTrack!.muted
? VideoTrackRenderer(
_activeVideoTrack!,
fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
)
: NoContentWidget(
userinfo: _userinfoMetadata,
isFixed: widget.isFixed,
isSpeaking: widget.participant.isSpeaking,
),
),
],
if (widget.showStatsLayer)
Positioned(
top: 30,
right: 30,
child: ParticipantStatsWidget(participant: widget.participant),
),
Align(
alignment: Alignment.bottomCenter,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
ParticipantInfoWidget(
title: widget.participant.name.isNotEmpty
? widget.participant.name
: widget.participant.identity,
audioAvailable: _firstAudioPublication?.muted == false &&
_firstAudioPublication?.subscribed == true,
connectionQuality: widget.participant.connectionQuality,
isScreenShare: widget.isScreenShare,
),
],
),
),
);
],
);
}
}
class _LocalParticipantWidgetState extends _ParticipantWidgetState<LocalParticipantWidget> {
@override
LocalTrackPublication<LocalVideoTrack>? get _videoPublication =>
widget.participant.videoTrackPublications.where((element) => element.sid == widget.videoTrack?.sid).firstOrNull;
class _LocalParticipantWidgetState
extends _ParticipantWidgetState<LocalParticipantWidget> {
@override
LocalTrackPublication<LocalAudioTrack>? get _firstAudioPublication =>
widget.participant.audioTrackPublications.firstOrNull;
@ -179,155 +186,12 @@ class _LocalParticipantWidgetState extends _ParticipantWidgetState<LocalParticip
VideoTrack? get _activeVideoTrack => widget.videoTrack;
}
class _RemoteParticipantWidgetState extends _ParticipantWidgetState<RemoteParticipantWidget> {
@override
RemoteTrackPublication<RemoteVideoTrack>? get _videoPublication =>
widget.participant.videoTrackPublications.where((element) => element.sid == widget.videoTrack?.sid).firstOrNull;
class _RemoteParticipantWidgetState
extends _ParticipantWidgetState<RemoteParticipantWidget> {
@override
RemoteTrackPublication<RemoteAudioTrack>? get _firstAudioPublication =>
widget.participant.audioTrackPublications.firstOrNull;
@override
VideoTrack? get _activeVideoTrack => widget.videoTrack;
@override
List<Widget> extraWidgets(bool isScreenShare) => [
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Menu for RemoteTrackPublication<RemoteAudioTrack>
if (_firstAudioPublication != null && !isScreenShare)
RemoteTrackPublicationMenuWidget(
pub: _firstAudioPublication!,
icon: Icons.volume_up,
),
// Menu for RemoteTrackPublication<RemoteVideoTrack>
if (_videoPublication != null)
RemoteTrackPublicationMenuWidget(
pub: _videoPublication!,
icon: isScreenShare ? Icons.monitor : Icons.videocam,
),
if (_videoPublication != null)
RemoteTrackFPSMenuWidget(
pub: _videoPublication!,
icon: Icons.menu,
),
if (_videoPublication != null)
RemoteTrackQualityMenuWidget(
pub: _videoPublication!,
icon: Icons.monitor_outlined,
),
],
),
];
}
class RemoteTrackPublicationMenuWidget extends StatelessWidget {
final IconData icon;
final RemoteTrackPublication pub;
const RemoteTrackPublicationMenuWidget({
required this.pub,
required this.icon,
super.key,
});
@override
Widget build(BuildContext context) => Material(
color: Colors.black.withOpacity(0.3),
child: PopupMenuButton<Function>(
tooltip: 'Subscribe menu',
icon: Icon(icon,
color: {
TrackSubscriptionState.notAllowed: Colors.red,
TrackSubscriptionState.unsubscribed: Colors.grey,
TrackSubscriptionState.subscribed: Colors.green,
}[pub.subscriptionState]),
onSelected: (value) => value(),
itemBuilder: (BuildContext context) => <PopupMenuEntry<Function>>[
if (pub.subscribed == false)
PopupMenuItem(
child: const Text('Subscribe'),
value: () => pub.subscribe(),
)
else if (pub.subscribed == true)
PopupMenuItem(
child: const Text('Un-subscribe'),
value: () => pub.unsubscribe(),
),
],
),
);
}
class RemoteTrackFPSMenuWidget extends StatelessWidget {
final IconData icon;
final RemoteTrackPublication pub;
const RemoteTrackFPSMenuWidget({
required this.pub,
required this.icon,
super.key,
});
@override
Widget build(BuildContext context) => Material(
color: Colors.black.withOpacity(0.3),
child: PopupMenuButton<Function>(
tooltip: 'Preferred FPS',
icon: Icon(icon, color: Colors.white),
onSelected: (value) => value(),
itemBuilder: (BuildContext context) => <PopupMenuEntry<Function>>[
PopupMenuItem(
child: const Text('30'),
value: () => pub.setVideoFPS(30),
),
PopupMenuItem(
child: const Text('15'),
value: () => pub.setVideoFPS(15),
),
PopupMenuItem(
child: const Text('8'),
value: () => pub.setVideoFPS(8),
),
],
),
);
}
class RemoteTrackQualityMenuWidget extends StatelessWidget {
final IconData icon;
final RemoteTrackPublication pub;
const RemoteTrackQualityMenuWidget({
required this.pub,
required this.icon,
super.key,
});
@override
Widget build(BuildContext context) => Material(
color: Colors.black.withOpacity(0.3),
child: PopupMenuButton<Function>(
tooltip: 'Preferred Quality',
icon: Icon(icon, color: Colors.white),
onSelected: (value) => value(),
itemBuilder: (BuildContext context) => <PopupMenuEntry<Function>>[
PopupMenuItem(
child: const Text('HIGH'),
value: () => pub.setVideoQuality(VideoQuality.HIGH),
),
PopupMenuItem(
child: const Text('MEDIUM'),
value: () => pub.setVideoQuality(VideoQuality.MEDIUM),
),
PopupMenuItem(
child: const Text('LOW'),
value: () => pub.setVideoQuality(VideoQuality.LOW),
),
],
),
);
}

View File

@ -6,7 +6,6 @@ class ParticipantInfoWidget extends StatelessWidget {
final bool audioAvailable;
final ConnectionQuality connectionQuality;
final bool isScreenShare;
final bool enabledE2EE;
const ParticipantInfoWidget({
super.key,
@ -14,12 +13,11 @@ class ParticipantInfoWidget extends StatelessWidget {
this.audioAvailable = true,
this.connectionQuality = ConnectionQuality.unknown,
this.isScreenShare = false,
this.enabledE2EE = false,
});
@override
Widget build(BuildContext context) => Container(
color: Colors.black.withOpacity(0.3),
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
padding: const EdgeInsets.symmetric(
vertical: 7,
horizontal: 10,
@ -33,6 +31,7 @@ class ParticipantInfoWidget extends StatelessWidget {
child: Text(
title!,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white),
),
),
isScreenShare
@ -56,7 +55,9 @@ class ParticipantInfoWidget extends StatelessWidget {
Padding(
padding: const EdgeInsets.only(left: 5),
child: Icon(
connectionQuality == ConnectionQuality.poor ? Icons.wifi_off_outlined : Icons.wifi,
connectionQuality == ConnectionQuality.poor
? Icons.wifi_off_outlined
: Icons.wifi,
color: {
ConnectionQuality.excellent: Colors.green,
ConnectionQuality.good: Colors.orange,
@ -65,14 +66,6 @@ class ParticipantInfoWidget extends StatelessWidget {
size: 16,
),
),
Padding(
padding: const EdgeInsets.only(left: 5),
child: Icon(
enabledE2EE ? Icons.lock : Icons.lock_open,
color: enabledE2EE ? Colors.green : Colors.red,
size: 16,
),
),
],
),
);

View File

@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ParticipantMenu extends StatefulWidget {
final RemoteParticipant participant;
final VideoTrack? videoTrack;
final bool isScreenShare;
final bool showStatsLayer;
const ParticipantMenu({
super.key,
required this.participant,
this.videoTrack,
this.isScreenShare = false,
this.showStatsLayer = false,
});
@override
State<ParticipantMenu> createState() => _ParticipantMenuState();
}
class _ParticipantMenuState extends State<ParticipantMenu> {
RemoteTrackPublication<RemoteVideoTrack>? get _videoPublication =>
widget.participant.videoTrackPublications
.where((element) => element.sid == widget.videoTrack?.sid)
.firstOrNull;
RemoteTrackPublication<RemoteAudioTrack>? get _firstAudioPublication =>
widget.participant.audioTrackPublications.firstOrNull;
void tookAction() {
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding:
const EdgeInsets.only(left: 8, right: 8, top: 20, bottom: 12),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 12,
),
child: Text(
AppLocalizations.of(context)!.action,
style: Theme.of(context).textTheme.headlineSmall,
),
),
),
Expanded(
child: ListView(
children: [
if (_firstAudioPublication != null && !widget.isScreenShare)
ListTile(
leading: Icon(
Icons.volume_up,
color: {
TrackSubscriptionState.notAllowed:
Theme.of(context).colorScheme.error,
TrackSubscriptionState.unsubscribed: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.6),
TrackSubscriptionState.subscribed:
Theme.of(context).colorScheme.primary,
}[_firstAudioPublication!.subscriptionState],
),
title: Text(
_firstAudioPublication!.subscribed
? AppLocalizations.of(context)!.chatCallMute
: AppLocalizations.of(context)!.chatCallUnMute,
),
onTap: () {
if (_firstAudioPublication!.subscribed) {
_firstAudioPublication!.unsubscribe();
} else {
_firstAudioPublication!.subscribe();
}
tookAction();
},
),
if (_videoPublication != null)
ListTile(
leading: Icon(
widget.isScreenShare ? Icons.monitor : Icons.videocam,
color: {
TrackSubscriptionState.notAllowed:
Theme.of(context).colorScheme.error,
TrackSubscriptionState.unsubscribed: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.6),
TrackSubscriptionState.subscribed:
Theme.of(context).colorScheme.primary,
}[_videoPublication!.subscriptionState],
),
title: Text(
_videoPublication!.subscribed
? AppLocalizations.of(context)!.chatCallVideoOff
: AppLocalizations.of(context)!.chatCallVideoOn,
),
onTap: () {
if (_videoPublication!.subscribed) {
_videoPublication!.unsubscribe();
} else {
_videoPublication!.subscribe();
}
tookAction();
},
),
if (_videoPublication != null) const Divider(thickness: 0.3),
if (_videoPublication != null)
...[30, 15, 8].map(
(x) => ListTile(
leading: Icon(
_videoPublication?.fps == x
? Icons.check_box_outlined
: Icons.check_box_outline_blank,
),
title: Text('Set preferred frame-per-second to $x'),
onTap: () {
_videoPublication!.setVideoFPS(x);
tookAction();
},
),
),
if (_videoPublication != null) const Divider(thickness: 0.3),
if (_videoPublication != null)
...[
('High', VideoQuality.HIGH),
('Medium', VideoQuality.MEDIUM),
('Low', VideoQuality.LOW),
].map(
(x) => ListTile(
leading: Icon(
_videoPublication?.videoQuality == x.$2
? Icons.check_box_outlined
: Icons.check_box_outline_blank,
),
title: Text('Set preferred quality to ${x.$1}'),
onTap: () {
_videoPublication!.setVideoQuality(x.$2);
tookAction();
},
),
),
],
),
),
],
);
}
}

View File

@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:solian/models/account.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'dart:math' as math;
class NoContentWidget extends StatefulWidget {
final Account? userinfo;
final bool isSpeaking;
final bool isFixed;
const NoContentWidget({
super.key,
this.userinfo,
this.isFixed = false,
required this.isSpeaking,
});
@override
State<NoContentWidget> createState() => _NoContentWidgetState();
}
class _NoContentWidgetState extends State<NoContentWidget>
with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
@override
void initState() {
super.initState();
_animationController = AnimationController(vsync: this);
}
@override
void didUpdateWidget(NoContentWidget old) {
super.didUpdateWidget(old);
if (widget.isSpeaking) {
_animationController.repeat(reverse: true);
} else {
_animationController
.animateTo(0, duration: 300.ms)
.then((_) => _animationController.reset());
}
}
@override
Widget build(BuildContext context) {
final double radius = widget.isFixed
? 32
: math.min(
MediaQuery.of(context).size.width * 0.1,
MediaQuery.of(context).size.height * 0.1,
);
return Container(
alignment: Alignment.center,
child: Center(
child: Animate(
autoPlay: false,
controller: _animationController,
effects: [
CustomEffect(
begin: widget.isSpeaking ? 2 : 0,
end: 8,
curve: Curves.easeInOut,
duration: 1250.ms,
builder: (context, value, child) => Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(radius + 8)),
border: value > 0
? Border.all(color: Colors.green, width: value)
: null,
),
child: child,
),
)
],
child: AccountAvatar(
source: widget.userinfo!.avatar,
backgroundColor: Colors.transparent,
radius: radius,
direct: true,
),
),
),
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
}

View File

@ -28,11 +28,14 @@ class _ParticipantStatsWidgetState extends State<ParticipantStatsWidget> {
stats['layer-$key'] =
'${value.frameWidth ?? 0}x${value.frameHeight ?? 0} ${value.framesPerSecond?.toDouble() ?? 0} fps, ${event.bitrateForLayers[key] ?? 0} kbps';
});
var firstStats = event.stats['f'] ?? event.stats['h'] ?? event.stats['q'];
var firstStats =
event.stats['f'] ?? event.stats['h'] ?? event.stats['q'];
if (firstStats != null) {
stats['encoder'] = firstStats.encoderImplementation ?? '';
stats['video codec'] = '${firstStats.mimeType}, ${firstStats.clockRate}hz, pt: ${firstStats.payloadType}';
stats['qualityLimitationReason'] = firstStats.qualityLimitationReason ?? '';
stats['video codec'] =
'${firstStats.mimeType}, ${firstStats.clockRate}hz, pt: ${firstStats.payloadType}';
stats['qualityLimitationReason'] =
firstStats.qualityLimitationReason ?? '';
}
});
});
@ -41,7 +44,8 @@ class _ParticipantStatsWidgetState extends State<ParticipantStatsWidget> {
listener.on<VideoReceiverStatsEvent>((event) {
setState(() {
stats['video rx'] = '${event.currentBitrate.toInt()} kpbs';
stats['video codec'] = '${event.stats.mimeType}, ${event.stats.clockRate}hz, pt: ${event.stats.payloadType}';
stats['video codec'] =
'${event.stats.mimeType}, ${event.stats.clockRate}hz, pt: ${event.stats.payloadType}';
stats['video size'] =
'${event.stats.frameWidth}x${event.stats.frameHeight} ${event.stats.framesPerSecond?.toDouble()}fps';
stats['video jitter'] = '${event.stats.jitter} s';
@ -70,7 +74,8 @@ class _ParticipantStatsWidgetState extends State<ParticipantStatsWidget> {
stats['audio codec'] =
'${event.stats.mimeType}, ${event.stats.clockRate}hz, ${event.stats.channels}ch, pt: ${event.stats.payloadType}';
stats['audio jitter'] = '${event.stats.jitter} s';
stats['audio concealed samples'] = '${event.stats.concealedSamples} / ${event.stats.concealmentEvents}';
stats['audio concealed samples'] =
'${event.stats.concealedSamples} / ${event.stats.concealmentEvents}';
stats['audio packets lost'] = '${event.stats.packetsLost}';
stats['audio packets received'] = '${event.stats.packetsReceived}';
});
@ -83,7 +88,10 @@ class _ParticipantStatsWidgetState extends State<ParticipantStatsWidget> {
element.dispose();
}
listeners.clear();
for (var track in [...widget.participant.videoTrackPublications, ...widget.participant.audioTrackPublications]) {
for (var track in [
...widget.participant.videoTrackPublications,
...widget.participant.audioTrackPublications
]) {
if (track.track != null) {
_setUpListener(track.track!);
}
@ -111,13 +119,14 @@ class _ParticipantStatsWidgetState extends State<ParticipantStatsWidget> {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black.withOpacity(0.3),
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 8,
),
child: Column(
children: stats.entries.map((e) => Text('${e.key}: ${e.value}')).toList(),
children:
stats.entries.map((e) => Text('${e.key}: ${e.value}')).toList(),
),
);
}

View File

@ -5,15 +5,24 @@ import 'package:provider/provider.dart';
import 'package:solian/models/call.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/exts.dart';
class ChannelCallAction extends StatefulWidget {
final Call? call;
final Channel channel;
final String realm;
final Function onUpdate;
const ChannelCallAction({super.key, this.call, required this.channel, required this.onUpdate});
const ChannelCallAction({
super.key,
this.call,
required this.channel,
required this.onUpdate,
this.realm = 'global',
});
@override
State<ChannelCallAction> createState() => _ChannelCallActionState();
@ -31,14 +40,12 @@ class _ChannelCallActionState extends State<ChannelCallAction> {
return;
}
var uri = getRequestUri('messaging', '/api/channels/${widget.channel.alias}/calls');
var uri = getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel.alias}/calls');
var res = await auth.client!.post(uri);
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
context.showErrorDialog(message);
}
setState(() => _isSubmitting = false);
@ -47,20 +54,24 @@ class _ChannelCallActionState extends State<ChannelCallAction> {
Future<void> endsCall() async {
setState(() => _isSubmitting = true);
final chat = context.read<ChatProvider>();
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
var uri = getRequestUri('messaging', '/api/channels/${widget.channel.alias}/calls/ongoing');
var uri = getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel.alias}/calls/ongoing');
var res = await auth.client!.delete(uri);
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
context.showErrorDialog(message);
} else {
if (chat.currentCall != null && chat.currentCall?.info.channelId == widget.channel.id) {
chat.currentCall!.deactivate();
chat.currentCall!.dispose();
}
}
setState(() => _isSubmitting = false);
@ -86,21 +97,30 @@ class _ChannelCallActionState extends State<ChannelCallAction> {
class ChannelManageAction extends StatelessWidget {
final Channel channel;
final Function onUpdate;
final String realm;
const ChannelManageAction({super.key, required this.channel, required this.onUpdate});
const ChannelManageAction({
super.key,
required this.channel,
required this.onUpdate,
this.realm = 'global',
});
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () async {
final result = await router.pushNamed(
'chat.channel.manage',
final result = await SolianRouter.router.pushNamed(
realm == 'global' ? 'chat.channel.manage' : 'realms.chat.channel.manage',
extra: channel,
pathParameters: {'channel': channel.alias},
pathParameters: {
'channel': channel.alias,
...(realm == 'global' ? {} : {'realm': realm}),
},
);
switch (result) {
case 'disposed':
if (router.canPop()) router.pop('refresh');
if (SolianRouter.router.canPop()) SolianRouter.router.pop('refresh');
case 'refresh':
onUpdate();
}

View File

@ -5,13 +5,20 @@ import 'package:provider/provider.dart';
import 'package:solian/models/channel.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/exts.dart';
class ChannelDeletion extends StatefulWidget {
final Channel channel;
final String realm;
final bool isOwned;
const ChannelDeletion({super.key, required this.channel, required this.isOwned});
const ChannelDeletion({
super.key,
required this.channel,
required this.realm,
required this.isOwned,
});
@override
State<ChannelDeletion> createState() => _ChannelDeletionState();
@ -30,13 +37,11 @@ class _ChannelDeletionState extends State<ChannelDeletion> {
}
var res = await auth.client!.delete(
getRequestUri('messaging', '/api/channels/${widget.channel.id}'),
getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel.id}'),
);
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
context.showErrorDialog(message);
} else if (Navigator.canPop(context)) {
Navigator.pop(context, true);
}
@ -53,14 +58,12 @@ class _ChannelDeletionState extends State<ChannelDeletion> {
return;
}
var res = await auth.client!.post(
getRequestUri('messaging', '/api/channels/${widget.channel.alias}/leave'),
var res = await auth.client!.delete(
getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel.alias}/members/me'),
);
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
context.showErrorDialog(message);
} else if (Navigator.canPop(context)) {
Navigator.pop(context, true);
}

View File

@ -4,8 +4,9 @@ import 'package:solian/router.dart';
class ChatNewAction extends StatelessWidget {
final Function onUpdate;
final String? realm;
const ChatNewAction({super.key, required this.onUpdate});
const ChatNewAction({super.key, required this.onUpdate, this.realm});
@override
Widget build(BuildContext context) {
@ -29,7 +30,10 @@ class ChatNewAction extends StatelessWidget {
leading: const Icon(Icons.add),
title: Text(AppLocalizations.of(context)!.chatNewCreate),
onTap: () {
router.pushNamed('chat.channel.editor').then((did) {
SolianRouter.router.pushNamed(
'chat.channel.editor',
queryParameters: {'realm': realm},
).then((did) {
if (did == true) {
onUpdate();
if (Navigator.canPop(context)) {

View File

@ -1,99 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/call.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/message.dart';
import 'package:solian/models/packet.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ChatMaintainer extends StatefulWidget {
final Widget child;
final Channel channel;
final Function(Message val) onInsertMessage;
final Function(Message val) onUpdateMessage;
final Function(Message val) onDeleteMessage;
final Function(Call val) onCallStarted;
final Function() onCallEnded;
const ChatMaintainer({
super.key,
required this.child,
required this.channel,
required this.onInsertMessage,
required this.onUpdateMessage,
required this.onDeleteMessage,
required this.onCallStarted,
required this.onCallEnded,
});
@override
State<ChatMaintainer> createState() => _ChatMaintainerState();
}
class _ChatMaintainerState extends State<ChatMaintainer> {
void connect() {
ScaffoldMessenger.of(context).clearSnackBars();
final notify = ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.connectingServer),
duration: const Duration(minutes: 1),
),
);
final auth = context.read<AuthProvider>();
final chat = context.read<ChatProvider>();
chat.connect(auth).then((snapshot) {
snapshot!.stream.listen(
(event) {
final result = NetworkPackage.fromJson(jsonDecode(event));
switch (result.method) {
case 'messages.new':
final payload = Message.fromJson(result.payload!);
if (payload.channelId == widget.channel.id) widget.onInsertMessage(payload);
break;
case 'messages.update':
final payload = Message.fromJson(result.payload!);
if (payload.channelId == widget.channel.id) widget.onUpdateMessage(payload);
break;
case 'messages.burnt':
final payload = Message.fromJson(result.payload!);
if (payload.channelId == widget.channel.id) widget.onDeleteMessage(payload);
break;
case 'calls.new':
final payload = Call.fromJson(result.payload!);
if (payload.channelId == widget.channel.id) widget.onCallStarted(payload);
break;
case 'calls.end':
final payload = Call.fromJson(result.payload!);
if (payload.channelId == widget.channel.id) widget.onCallEnded();
break;
}
},
onError: (_, __) => connect(),
onDone: () => connect(),
);
notify.close();
});
}
@override
void initState() {
Future.delayed(Duration.zero, () {
connect();
});
super.initState();
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:solian/models/message.dart';
import 'package:solian/widgets/account/avatar.dart';
import 'package:solian/widgets/chat/content.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/chat/message_content.dart';
import 'package:solian/widgets/posts/content/attachment.dart';
import 'package:timeago/timeago.dart' as timeago;
import 'dart:math' as math;

View File

@ -1,4 +1,8 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/message.dart';
import 'package:solian/providers/auth.dart';
@ -7,6 +11,7 @@ import 'package:solian/widgets/chat/message_deletion.dart';
class ChatMessageAction extends StatelessWidget {
final String channel;
final String realm;
final Message item;
final Function? onEdit;
final Function? onReply;
@ -15,6 +20,7 @@ class ChatMessageAction extends StatelessWidget {
super.key,
required this.channel,
required this.item,
this.realm = 'global',
this.onEdit,
this.onReply,
});
@ -24,7 +30,7 @@ class ChatMessageAction extends StatelessWidget {
final auth = context.read<AuthProvider>();
return SizedBox(
height: 320,
height: 400,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -38,7 +44,7 @@ class ChatMessageAction extends StatelessWidget {
style: Theme.of(context).textTheme.headlineSmall,
),
Text(
'Message ID #${item.id.toString().padLeft(8, '0')}',
'#${item.id.toString().padLeft(8, '0')}',
style: Theme.of(context).textTheme.bodySmall,
),
],
@ -67,6 +73,7 @@ class ChatMessageAction extends StatelessWidget {
builder: (context) => ChatMessageDeletionDialog(
item: item,
channel: channel,
realm: realm,
),
).then((did) {
if (did == true && Navigator.canPop(context)) {
@ -87,6 +94,17 @@ class ChatMessageAction extends StatelessWidget {
if (onReply != null) onReply!();
if (Navigator.canPop(context)) Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.code),
title: Text(AppLocalizations.of(context)!.chatMessageViewSource),
onTap: () {
if (Navigator.canPop(context)) Navigator.pop(context);
showModalBottomSheet(
context: context,
builder: (context) => ChatMessageSourceWidget(item: item),
);
},
)
],
);
@ -103,3 +121,90 @@ class ChatMessageAction extends StatelessWidget {
);
}
}
class ChatMessageSourceWidget extends StatelessWidget {
final Message item;
const ChatMessageSourceWidget({super.key, required this.item});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(32)),
child: SizedBox(
width: double.infinity,
height: 640,
child: ListView(
children: [
Container(
padding: const EdgeInsets.only(left: 20, top: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.chatMessageViewSource,
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
),
Padding(
padding: const EdgeInsets.only(left: 20, top: 20, bottom: 8),
child: Text(
'Raw content',
style: Theme.of(context).textTheme.titleMedium,
),
),
Markdown(
selectable: true,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
data: '```\n${item.rawContent}\n```',
padding: const EdgeInsets.all(0),
),
Padding(
padding: const EdgeInsets.only(left: 20, top: 20, bottom: 8),
child: Text(
'Decoded content',
style: Theme.of(context).textTheme.titleMedium,
),
),
Markdown(
selectable: true,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
data: '```\n${const JsonEncoder.withIndent(' ').convert(item.decodedContent)}\n```',
padding: const EdgeInsets.all(0),
styleSheet: MarkdownStyleSheet(
code: GoogleFonts.robotoMono(
backgroundColor: Theme.of(context).cardTheme.color ?? Theme.of(context).cardColor,
fontSize: Theme.of(context).textTheme.bodyMedium!.fontSize! * 0.85,
),
),
),
Padding(
padding: const EdgeInsets.only(left: 20, top: 20, bottom: 8),
child: Text(
'Entire message',
style: Theme.of(context).textTheme.titleMedium,
),
),
Markdown(
selectable: true,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
data: '```\n${const JsonEncoder.withIndent(' ').convert(item)}\n```',
padding: const EdgeInsets.all(0),
styleSheet: MarkdownStyleSheet(
code: GoogleFonts.robotoMono(
backgroundColor: Theme.of(context).cardTheme.color ?? Theme.of(context).cardColor,
fontSize: Theme.of(context).textTheme.bodyMedium!.fontSize! * 0.85,
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/message.dart';
import 'package:solian/providers/keypair.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ChatMessageContent extends StatefulWidget {
final Message item;
const ChatMessageContent({super.key, required this.item});
@override
State<ChatMessageContent> createState() => _ChatMessageContentState();
}
class _ChatMessageContentState extends State<ChatMessageContent> {
@override
Widget build(BuildContext context) {
final feColor = Theme.of(context).colorScheme.onSurface.withOpacity(0.65);
final waitingKeyHint = Row(
children: [
Icon(Icons.key, color: feColor, size: 16),
const SizedBox(width: 4),
Expanded(
child: Text(
AppLocalizations.of(context)!.chatMessageUnableDecryptWaiting,
style: TextStyle(color: feColor),
),
),
],
);
final missingKeyHint = Row(
children: [
Icon(Icons.key_off_outlined, color: feColor, size: 16),
const SizedBox(width: 4),
Expanded(
child: Text(
AppLocalizations.of(context)!.chatMessageUnableDecryptMissing,
style: TextStyle(color: feColor),
),
),
],
);
if (widget.item.type == 'm.text') {
String? content;
switch (widget.item.decodedContent['algorithm']) {
case 'plain':
content = widget.item.decodedContent['value'];
case 'aes':
final keypair = context.watch<KeypairProvider>();
if (keypair.keys[widget.item.decodedContent['keypair_id']] == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
keypair.requestKey(
widget.item.decodedContent['keypair_id'],
widget.item.decodedContent['algorithm'],
widget.item.sender.account.externalId!,
);
});
} else {
content = keypair.decodeViaAESKey(
widget.item.decodedContent['keypair_id'],
widget.item.decodedContent['value'],
)!;
break;
}
if (keypair.requestingKeys.contains(widget.item.decodedContent['keypair_id'])) {
return waitingKeyHint.animate().swap(builder: (context, _) {
return missingKeyHint;
}, delay: 3000.ms);
}
}
if (content == null) {
return Row(
children: [
Icon(Icons.key_off, color: feColor, size: 16),
const SizedBox(width: 4),
Text(
AppLocalizations.of(context)!.chatMessageUnableDecryptUnsupported,
style: TextStyle(color: feColor),
),
],
);
}
return Markdown(
data: content,
shrinkWrap: true,
selectable: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.all(0),
onTapLink: (text, href, title) async {
if (href == null) return;
await launchUrlString(
href,
mode: LaunchMode.externalApplication,
);
},
);
}
return Container();
}
}

View File

@ -5,16 +5,19 @@ import 'package:provider/provider.dart';
import 'package:solian/models/message.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/exts.dart';
class ChatMessageDeletionDialog extends StatefulWidget {
final String channel;
final String realm;
final Message item;
const ChatMessageDeletionDialog({
super.key,
required this.item,
required this.channel,
this.realm = 'global'
});
@override
@ -28,15 +31,14 @@ class _ChatMessageDeletionDialogState extends State<ChatMessageDeletionDialog> {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
final uri = getRequestUri('messaging', '/api/channels/${widget.channel}/messages/${widget.item.id}');
final uri =
getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel}/messages/${widget.item.id}');
setState(() => _isSubmitting = true);
final res = await auth.client!.delete(uri);
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
context.showErrorDialog(message);
setState(() => _isSubmitting = false);
} else {
Navigator.pop(context, true);

View File

@ -1,23 +1,37 @@
import 'dart:convert';
import 'package:easy_debounce/easy_debounce.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:http/http.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/message.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/providers/keypair.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/posts/attachment_editor.dart';
import 'package:badges/badges.dart' as badge;
class ChatMessageEditor extends StatefulWidget {
final String channel;
final String realm;
final Message? editing;
final Message? replying;
final bool isEncrypted;
final Function? onReset;
const ChatMessageEditor({super.key, required this.channel, this.editing, this.replying, this.onReset});
const ChatMessageEditor({
super.key,
required this.channel,
required this.isEncrypted,
this.realm = 'global',
this.editing,
this.replying,
this.onReset,
});
@override
State<ChatMessageEditor> createState() => _ChatMessageEditorState();
@ -25,8 +39,11 @@ class ChatMessageEditor extends StatefulWidget {
class _ChatMessageEditorState extends State<ChatMessageEditor> {
final _textController = TextEditingController();
final _focusNode = FocusNode();
bool _isSubmitting = false;
final List<int> _pendingMessages = List.empty(growable: true);
int? _prevEditingId;
List<Attachment> _attachments = List.empty(growable: true);
@ -41,35 +58,59 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
);
}
Map<String, dynamic> buildContentBody(String content) {
final keypair = context.read<KeypairProvider>();
if (keypair.activeKeyId == null || keypair.keys[keypair.activeKeyId] == null) {
final kp = keypair.generateAESKey();
keypair.setActiveKey(kp.id);
return buildContentBody(content);
}
return {
'value': widget.isEncrypted ? keypair.encodeViaAESKey(keypair.activeKeyId!, content) : content,
'keypair_id': widget.isEncrypted ? keypair.activeKeyId : null,
'algorithm': widget.isEncrypted ? 'aes' : 'plain',
};
}
Future<void> sendMessage(BuildContext context) async {
if (_isSubmitting) return;
_focusNode.requestFocus();
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
final uri = widget.editing == null
? getRequestUri('messaging', '/api/channels/${widget.channel}/messages')
: getRequestUri('messaging', '/api/channels/${widget.channel}/messages/${widget.editing!.id}');
? getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel}/messages')
: getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel}/messages/${widget.editing!.id}');
final req = Request(widget.editing == null ? "POST" : "PUT", uri);
final req = Request(widget.editing == null ? 'POST' : 'PUT', uri);
req.headers['Content-Type'] = 'application/json';
req.body = jsonEncode(<String, dynamic>{
'content': _textController.value.text,
'type': 'm.text',
'content': buildContentBody(_textController.value.text),
'attachments': _attachments,
'reply_to': widget.replying?.id,
});
setState(() => _isSubmitting = true);
reset();
final messageMarkId = DateTime.now().microsecondsSinceEpoch >> 10;
final messageDebounceId = 'm-pending$messageMarkId';
EasyDebounce.debounce(messageDebounceId, 350.ms, () {
setState(() => _pendingMessages.add(messageMarkId));
});
var res = await Response.fromStream(await auth.client!.send(req));
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
} else {
reset();
context.showErrorDialog(message);
}
EasyDebounce.cancel(messageDebounceId);
if (_pendingMessages.isNotEmpty) {
setState(() => _pendingMessages.remove(messageMarkId));
}
setState(() => _isSubmitting = false);
}
void reset() {
@ -80,10 +121,14 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
}
void syncWidget() {
if (widget.editing != null) {
if (widget.editing != null && _prevEditingId != widget.editing!.id) {
setState(() {
_textController.text = widget.editing!.content;
_prevEditingId = widget.editing!.id;
_attachments = widget.editing!.attachments ?? List.empty(growable: true);
if (widget.editing!.type == 'm.text') {
_textController.text = widget.editing!.decodedContent['value'];
}
});
}
}
@ -101,10 +146,19 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
@override
Widget build(BuildContext context) {
final sendingBanner = MaterialBanner(
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20),
leading: const Icon(Icons.schedule_send),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.9),
dividerColor: const Color.fromARGB(1, 0, 0, 0),
content: Text('${AppLocalizations.of(context)!.chatMessageSending} (${_pendingMessages.length})'),
actions: const [SizedBox()],
);
final editingBanner = MaterialBanner(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 20),
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20),
leading: const Icon(Icons.edit_note),
backgroundColor: const Color(0xFFE0E0E0),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.9),
dividerColor: const Color.fromARGB(1, 0, 0, 0),
content: Text(AppLocalizations.of(context)!.chatMessageEditNotify),
actions: [
@ -116,9 +170,9 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
);
final replyingBanner = MaterialBanner(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 20),
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20),
leading: const Icon(Icons.reply),
backgroundColor: const Color(0xFFE0E0E0),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.9),
dividerColor: const Color.fromARGB(1, 0, 0, 0),
content: Text(AppLocalizations.of(context)!.chatMessageReplyNotify),
actions: [
@ -131,14 +185,26 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
return Column(
children: [
_pendingMessages.isNotEmpty
? sendingBanner
.animate()
.scaleY(
begin: 0,
curve: Curves.fastEaseInToSlowEaseOut,
)
.slideY(
begin: 1,
curve: Curves.fastEaseInToSlowEaseOut,
)
: Container(),
widget.editing != null ? editingBanner : Container(),
widget.replying != null ? replyingBanner : Container(),
Container(
height: 56,
padding: const EdgeInsets.only(top: 4, bottom: 4, right: 8),
decoration: const BoxDecoration(
decoration: BoxDecoration(
border: Border(
top: BorderSide(width: 0.3, color: Color(0xffdedede)),
top: BorderSide(width: 0.3, color: Theme.of(context).dividerColor),
),
),
child: Row(
@ -150,19 +216,21 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
position: badge.BadgePosition.custom(top: -2, end: 8),
child: TextButton(
style: TextButton.styleFrom(shape: const CircleBorder(), padding: const EdgeInsets.all(4)),
onPressed: !_isSubmitting ? () => viewAttachments(context) : null,
onPressed: () => viewAttachments(context),
child: const Icon(Icons.attach_file),
),
),
Expanded(
child: TextField(
focusNode: _focusNode,
controller: _textController,
maxLines: null,
autofocus: true,
autocorrect: true,
keyboardType: TextInputType.text,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!.chatMessagePlaceholder,
hintText: widget.isEncrypted
? AppLocalizations.of(context)!.chatMessageEncryptedPlaceholder
: AppLocalizations.of(context)!.chatMessagePlaceholder,
),
onSubmitted: (_) => sendMessage(context),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
@ -170,7 +238,7 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
),
TextButton(
style: TextButton.styleFrom(shape: const CircleBorder(), padding: const EdgeInsets.all(4)),
onPressed: !_isSubmitting ? () => sendMessage(context) : null,
onPressed: () => sendMessage(context),
child: const Icon(Icons.send),
)
],

View File

@ -1,31 +0,0 @@
import 'package:flutter/material.dart';
import 'package:solian/widgets/navigation_drawer.dart';
class LayoutWrapper extends StatelessWidget {
final Widget? child;
final Widget? floatingActionButton;
final List<Widget>? appBarActions;
final bool? noSafeArea;
final String title;
const LayoutWrapper({
super.key,
this.child,
required this.title,
this.floatingActionButton,
this.appBarActions,
this.noSafeArea,
});
@override
Widget build(BuildContext context) {
final content = child ?? Container();
return Scaffold(
appBar: AppBar(title: Text(title), actions: appBarActions),
floatingActionButton: floatingActionButton,
drawer: const SolianNavigationDrawer(),
body: (noSafeArea ?? false) ? content : SafeArea(child: content),
);
}
}

25
lib/widgets/empty.dart Normal file
View File

@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class PageEmptyWidget extends StatelessWidget {
const PageEmptyWidget({super.key});
@override
Widget build(BuildContext context) {
return Material(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset('assets/logo.png', width: 64, height: 64),
const SizedBox(height: 2),
Text(
AppLocalizations.of(context)!.appName,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900),
),
],
),
),
);
}
}

30
lib/widgets/exts.dart Normal file
View File

@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
extension SolianCommonExtensions on BuildContext {
Future<void> showErrorDialog(dynamic exception) {
String formatMessage(dynamic exception) {
final message = exception.toString();
if (message.trim().isEmpty) return '';
return message
.split(' ')
.map((element) =>
'${element[0].toUpperCase()}${element.substring(1).toLowerCase()}')
.join(' ');
}
return showDialog<void>(
context: this,
builder: (ctx) => AlertDialog(
title: Text(AppLocalizations.of(this)!.errorHappened),
content: Text(formatMessage(exception)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(AppLocalizations.of(this)!.confirmOkay),
)
],
),
);
}
}

View File

@ -1,29 +0,0 @@
import 'package:flutter/material.dart';
import 'package:solian/widgets/common_wrapper.dart';
import 'package:solian/widgets/navigation_drawer.dart';
class IndentWrapper extends LayoutWrapper {
final bool? hideDrawer;
const IndentWrapper({
super.key,
super.child,
required super.title,
super.floatingActionButton,
super.appBarActions,
this.hideDrawer,
super.noSafeArea,
}) : super();
@override
Widget build(BuildContext context) {
final content = child ?? Container();
return Scaffold(
appBar: AppBar(title: Text(title), actions: appBarActions),
floatingActionButton: floatingActionButton,
drawer: (hideDrawer ?? false) ? null : const SolianNavigationDrawer(),
body: (noSafeArea ?? false) ? content : SafeArea(child: content),
);
}
}

View File

@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:solian/widgets/empty.dart';
class TwoColumnLayout extends StatelessWidget {
final Widget sideChild;
final Widget? mainChild;
const TwoColumnLayout({
super.key,
required this.sideChild,
required this.mainChild,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(
width: 400,
child: sideChild,
),
const VerticalDivider(width: 0.3, thickness: 0.3),
Expanded(child: mainChild ?? const PageEmptyWidget()),
],
);
}
}

View File

@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'package:solian/providers/navigation.dart';
import 'package:solian/router.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/utils/theme.dart';
class SolianNavigationDrawer extends StatefulWidget {
const SolianNavigationDrawer({super.key});
@ -17,7 +18,7 @@ class _SolianNavigationDrawerState extends State<SolianNavigationDrawer> {
void _onSelect(String name, int idx) {
setState(() => _selectedIndex = idx);
context.read<NavigationProvider>().selectedIndex = idx;
router.goNamed(name);
SolianRouter.router.goNamed(name);
}
@override
@ -39,26 +40,36 @@ class _SolianNavigationDrawerState extends State<SolianNavigationDrawer> {
icon: const Icon(Icons.explore),
label: Text(AppLocalizations.of(context)!.explore),
),
"explore",
'explore',
),
(
NavigationDrawerDestination(
icon: const Icon(Icons.supervised_user_circle),
label: Text(AppLocalizations.of(context)!.realm),
),
'realms',
),
(
NavigationDrawerDestination(
icon: const Icon(Icons.send),
label: Text(AppLocalizations.of(context)!.chat),
),
"chat",
'chat',
),
(
NavigationDrawerDestination(
icon: const Icon(Icons.account_circle),
label: Text(AppLocalizations.of(context)!.account),
),
"account",
'account',
),
];
return NavigationDrawer(
selectedIndex: _selectedIndex,
elevation: SolianTheme.isLargeScreen(context) ? 20 : 0,
shadowColor: SolianTheme.isLargeScreen(context) ? Theme.of(context).shadowColor : null,
surfaceTintColor: Theme.of(context).colorScheme.background,
onDestinationSelected: (int idx) {
final element = navigationItems[idx];
_onSelect(element.$2, idx);
@ -69,10 +80,10 @@ class _SolianNavigationDrawerState extends State<SolianNavigationDrawer> {
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Image.asset("assets/logo.png", width: 26, height: 26),
Image.asset('assets/logo.png', width: 26, height: 26),
const SizedBox(width: 10),
Text(
AppLocalizations.of(context)!.solian,
AppLocalizations.of(context)!.appName,
style: const TextStyle(fontWeight: FontWeight.w900),
),
],

View File

@ -1,90 +1,27 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/notify.dart';
import 'package:solian/router.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/models/notification.dart' as model;
import 'package:badges/badges.dart' as badge;
class NotificationNotifier extends StatefulWidget {
final Widget child;
const NotificationNotifier({super.key, required this.child});
@override
State<NotificationNotifier> createState() => _NotificationNotifierState();
}
class _NotificationNotifierState extends State<NotificationNotifier> {
void connect() async {
final notify = ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.connectingServer),
duration: const Duration(minutes: 1),
),
);
final auth = context.read<AuthProvider>();
final nty = context.read<NotifyProvider>();
if (await auth.isAuthorized()) {
nty.fetch(auth);
nty.connect(auth).then((snapshot) {
snapshot!.stream.listen(
(event) {
final result = model.Notification.fromJson(jsonDecode(event));
nty.onRemoteMessage(result);
},
onError: (_, __) => connect(),
onDone: () => connect(),
);
});
}
notify.close();
}
@override
void initState() {
Future.delayed(Duration.zero, () {
connect();
});
super.initState();
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}
class NotificationButton extends StatefulWidget {
class NotificationButton extends StatelessWidget {
const NotificationButton({super.key});
@override
State<NotificationButton> createState() => _NotificationButtonState();
}
class _NotificationButtonState extends State<NotificationButton> {
@override
Widget build(BuildContext context) {
final nty = context.watch<NotifyProvider>();
return badge.Badge(
showBadge: nty.notifications.isNotEmpty,
showBadge: nty.unreadAmount > 0,
position: badge.BadgePosition.custom(top: -2, end: 8),
badgeContent: Text(
nty.notifications.length.toString(),
nty.unreadAmount.toString(),
style: const TextStyle(color: Colors.white),
),
child: IconButton(
icon: const Icon(Icons.notifications),
onPressed: () {
router.pushNamed("notification");
SolianRouter.router.pushNamed('notification');
},
),
);

View File

@ -2,7 +2,7 @@ import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:crypto/crypto.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:http/http.dart';
@ -10,8 +10,10 @@ import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/file.dart';
import 'package:solian/utils/services_url.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/exts.dart';
class AttachmentEditor extends StatefulWidget {
final String provider;
@ -40,52 +42,85 @@ class _AttachmentEditorState extends State<AttachmentEditor> {
showModalBottomSheet(
context: context,
builder: (context) => AttachmentEditorMethodPopup(
pickImage: () => pickImageToUpload(context, ImageSource.gallery),
takeImage: () => pickImageToUpload(context, ImageSource.camera),
pickVideo: () => pickVideoToUpload(context, ImageSource.gallery),
takeVideo: () => pickVideoToUpload(context, ImageSource.camera),
pickMedia: () => pickMediaToUpload(context),
pickFile: () => pickFileToUpload(context),
takeImage: () => takeMediaToUpload(context, false),
takeVideo: () => takeMediaToUpload(context, true),
),
);
}
Future<void> pickImageToUpload(BuildContext context, ImageSource source) async {
Future<void> pickMediaToUpload(BuildContext context) async {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
final image = await _imagePicker.pickImage(source: source);
if (image == null) return;
final medias = await _imagePicker.pickMultipleMedia();
if (medias.isEmpty) return;
setState(() => _isSubmitting = true);
final file = File(image.path);
final hashcode = await calculateSha256(file);
if (Navigator.canPop(context)) {
Navigator.pop(context);
bool isPopped = false;
for (final media in medias) {
final file = File(media.path);
final hashcode = await calculateFileSha256(file);
try {
await uploadAttachment(file, hashcode);
} catch (err) {
context.showErrorDialog(err);
}
if (!isPopped && Navigator.canPop(context)) {
Navigator.pop(context);
isPopped = true;
}
}
try {
await uploadAttachment(file, hashcode);
} catch (err) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $err")),
);
} finally {
setState(() => _isSubmitting = false);
}
setState(() => _isSubmitting = false);
}
Future<void> pickVideoToUpload(BuildContext context, ImageSource source) async {
Future<void> pickFileToUpload(BuildContext context) async {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
final image = await _imagePicker.pickVideo(source: source);
if (image == null) return;
FilePickerResult? result = await FilePicker.platform.pickFiles(allowMultiple: true);
if (result == null) return;
List<File> files = result.paths.map((path) => File(path!)).toList();
setState(() => _isSubmitting = true);
final file = File(image.path);
final hashcode = await calculateSha256(file);
bool isPopped = false;
for (final file in files) {
final hashcode = await calculateFileSha256(file);
try {
await uploadAttachment(file, hashcode);
} catch (err) {
context.showErrorDialog(err);
}
if (!isPopped && Navigator.canPop(context)) {
Navigator.pop(context);
isPopped = true;
}
}
setState(() => _isSubmitting = false);
}
Future<void> takeMediaToUpload(BuildContext context, bool isVideo) async {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
XFile? media;
if (isVideo) {
media = await _imagePicker.pickVideo(source: ImageSource.camera);
} else {
media = await _imagePicker.pickImage(source: ImageSource.camera);
}
if (media == null) return;
setState(() => _isSubmitting = true);
final file = File(media.path);
final hashcode = await calculateFileSha256(file);
if (Navigator.canPop(context)) {
Navigator.pop(context);
@ -94,12 +129,10 @@ class _AttachmentEditorState extends State<AttachmentEditor> {
try {
await uploadAttachment(file, hashcode);
} catch (err) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $err")),
);
} finally {
setState(() => _isSubmitting = false);
context.showErrorDialog(err);
}
setState(() => _isSubmitting = false);
}
Future<void> uploadAttachment(File file, String hashcode) async {
@ -112,7 +145,7 @@ class _AttachmentEditorState extends State<AttachmentEditor> {
var res = await auth.client!.send(req);
if (res.statusCode == 200) {
var result = Attachment.fromJson(
jsonDecode(utf8.decode(await res.stream.toBytes()))["info"],
jsonDecode(utf8.decode(await res.stream.toBytes()))['info'],
);
setState(() => _attachments.add(result));
widget.onUpdate(_attachments);
@ -133,19 +166,11 @@ class _AttachmentEditorState extends State<AttachmentEditor> {
widget.onUpdate(_attachments);
} else {
final err = utf8.decode(await res.stream.toBytes());
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $err")),
);
context.showErrorDialog(err);
}
setState(() => _isSubmitting = false);
}
Future<String> calculateSha256(File file) async {
final bytes = await file.readAsBytes();
final digest = sha256.convert(bytes);
return digest.toString();
}
String getFileName(Attachment item) {
return item.filename.replaceAll(RegExp(r'\.[^/.]+$'), '');
}
@ -218,12 +243,12 @@ class _AttachmentEditorState extends State<AttachmentEditor> {
),
_isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
Expanded(
child: ListView.separated(
child: ListView.builder(
itemCount: _attachments.length,
itemBuilder: (context, index) {
var element = _attachments[index];
return Container(
padding: const EdgeInsets.only(left: 16, right: 8),
padding: const EdgeInsets.only(left: 16, right: 8, bottom: 16),
child: Row(
children: [
Expanded(
@ -237,7 +262,7 @@ class _AttachmentEditorState extends State<AttachmentEditor> {
style: Theme.of(context).textTheme.titleMedium,
),
Text(
"${getFileType(element)} · ${formatBytes(element.filesize)}",
'${getFileType(element)} · ${formatBytes(element.filesize)}',
),
],
),
@ -254,7 +279,6 @@ class _AttachmentEditorState extends State<AttachmentEditor> {
),
);
},
separatorBuilder: (context, index) => const Divider(thickness: 0.3),
),
),
],
@ -263,16 +287,16 @@ class _AttachmentEditorState extends State<AttachmentEditor> {
}
class AttachmentEditorMethodPopup extends StatelessWidget {
final Function pickImage;
final Function pickMedia;
final Function pickFile;
final Function takeImage;
final Function pickVideo;
final Function takeVideo;
const AttachmentEditorMethodPopup({
super.key,
required this.pickImage,
required this.pickMedia,
required this.pickFile,
required this.takeImage,
required this.pickVideo,
required this.takeVideo,
});
@ -303,14 +327,28 @@ class AttachmentEditorMethodPopup extends StatelessWidget {
children: [
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => pickImage(),
onTap: () => pickMedia(),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add_photo_alternate, color: Colors.indigo),
const Icon(Icons.photo_library, color: Colors.indigo),
const SizedBox(height: 8),
Text(AppLocalizations.of(context)!.pickPhoto),
Text(AppLocalizations.of(context)!.pickMedia),
],
),
),
),
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => pickFile(),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.file_present, color: Colors.amber),
const SizedBox(height: 8),
Text(AppLocalizations.of(context)!.pickFile),
],
),
),
@ -322,27 +360,13 @@ class AttachmentEditorMethodPopup extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.camera_alt, color: Colors.indigo),
const Icon(Icons.camera_alt, color: Colors.teal),
const SizedBox(height: 8),
Text(AppLocalizations.of(context)!.takePhoto),
],
),
),
),
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => pickVideo(),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.camera, color: Colors.teal),
const SizedBox(height: 8),
Text(AppLocalizations.of(context)!.pickVideo),
],
),
),
),
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => takeVideo(),
@ -350,7 +374,7 @@ class AttachmentEditorMethodPopup extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.video_call, color: Colors.teal),
const Icon(Icons.movie, color: Colors.blue),
const SizedBox(height: 8),
Text(AppLocalizations.of(context)!.takeVideo),
],

View File

@ -1,4 +1,6 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:solian/utils/platform.dart';
class AttachmentScreen extends StatelessWidget {
final String url;
@ -14,8 +16,10 @@ class AttachmentScreen extends StatelessWidget {
child: InteractiveViewer(
boundaryMargin: const EdgeInsets.all(128),
minScale: 0.1,
maxScale: 16.0,
child: Image.network(url, fit: BoxFit.contain),
maxScale: 16,
panEnabled: true,
scaleEnabled: true,
child: PlatformInfo.canCacheImage ? CachedNetworkImage(imageUrl: url, fit: BoxFit.contain) : Image.network(url),
),
);

View File

@ -9,8 +9,8 @@ import 'package:http/http.dart' as http;
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/posts/comment_editor.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/posts/item.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/posts/post.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class CommentList extends StatefulWidget {
@ -120,8 +120,8 @@ class CommentListHeader extends StatelessWidget {
if (snapshot.hasData && snapshot.data == true) {
return TextButton(
onPressed: () async {
final did = await router.pushNamed(
"posts.comments.editor",
final did = await SolianRouter.router.pushNamed(
'posts.comments.editor',
extra: CommentPostArguments(related: related),
);
if (did == true) paging.refresh();

View File

@ -1,9 +1,10 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:solian/models/post.dart';
import 'package:markdown/markdown.dart' as markdown;
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/posts/content/attachment.dart';
import 'package:solian/utils/platform.dart';
import 'package:solian/utils/services_url.dart';
import 'package:url_launcher/url_launcher_string.dart';
class ArticleContent extends StatelessWidget {
@ -55,16 +56,15 @@ class ArticleContent extends StatelessWidget {
},
imageBuilder: (url, _, __) {
Uri uri;
if (url.toString().startsWith("/api/attachments")) {
if (url.toString().startsWith('/api/attachments')) {
uri = getRequestUri('interactive', url.toString());
} else {
uri = url;
}
return AttachmentItem(
type: 1,
url: uri.toString(),
);
return PlatformInfo.canCacheImage
? CachedNetworkImage(imageUrl: uri.toString())
: Image.network(uri.toString());
},
),
],

View File

@ -1,8 +1,10 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:solian/models/post.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/platform.dart';
import 'package:solian/utils/services_url.dart';
import 'package:flutter_carousel_widget/flutter_carousel_widget.dart';
import 'package:solian/widgets/posts/attachment_screen.dart';
import 'package:uuid/uuid.dart';
@ -12,6 +14,7 @@ class AttachmentItem extends StatefulWidget {
final String url;
final String? tag;
final String? badge;
final bool noTag;
const AttachmentItem({
super.key,
@ -19,6 +22,7 @@ class AttachmentItem extends StatefulWidget {
required this.url,
this.tag,
this.badge,
this.noTag = false,
});
@override
@ -30,20 +34,32 @@ class _AttachmentItemState extends State<AttachmentItem> {
late final _videoPlayer = Player(
configuration: PlayerConfiguration(
title: "Attachment #${getTag()}",
title: 'Attachment #${getTag()}',
logLevel: MPVLogLevel.error,
),
);
late final _videoController = VideoController(_videoPlayer);
@override
void initState() {
super.initState();
if (widget.type != 1) {
_videoPlayer.open(
Media(widget.url),
play: false,
);
}
}
@override
Widget build(BuildContext context) {
const borderRadius = Radius.circular(8);
final tag = getTag();
final tag = widget.noTag ? const Uuid().v4() : getTag();
Widget content;
if (widget.type == 1) {
final image = PlatformInfo.canCacheImage ? CachedNetworkImageProvider(widget.url) : NetworkImage(widget.url);
content = GestureDetector(
child: ClipRRect(
borderRadius: const BorderRadius.all(borderRadius),
@ -51,8 +67,9 @@ class _AttachmentItemState extends State<AttachmentItem> {
tag: tag,
child: Stack(
children: [
Image.network(
widget.url,
Image(
image: image as ImageProvider,
key: Key(getTag()),
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
@ -62,7 +79,10 @@ class _AttachmentItemState extends State<AttachmentItem> {
: Positioned(
right: 12,
bottom: 8,
child: Chip(label: Text(widget.badge!)),
child: Material(
color: Colors.transparent,
child: Chip(label: Text(widget.badge!)),
),
)
],
),
@ -81,11 +101,6 @@ class _AttachmentItemState extends State<AttachmentItem> {
},
);
} else {
_videoPlayer.open(
Media(widget.url),
play: false,
);
content = ClipRRect(
borderRadius: const BorderRadius.all(borderRadius),
child: Video(
@ -118,8 +133,14 @@ class _AttachmentItemState extends State<AttachmentItem> {
class AttachmentList extends StatelessWidget {
final List<Attachment> items;
final String provider;
final bool noTag;
const AttachmentList({super.key, required this.items, required this.provider});
const AttachmentList({
super.key,
required this.items,
required this.provider,
this.noTag = false,
});
Uri getFileUri(String fileId) => getRequestUri(provider, '/api/attachments/o/$fileId');
@ -144,6 +165,7 @@ class AttachmentList extends StatelessWidget {
tag: item.fileId,
url: getFileUri(item.fileId).toString(),
badge: items.length <= 1 ? null : badge,
noTag: noTag,
),
);
},

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