Compare commits
68 Commits
9fa666d0b8
...
archived/v
Author | SHA1 | Date | |
---|---|---|---|
|
d46d584ff3 | ||
|
f43f9e91f6 | ||
|
b9461e5019 | ||
|
8e0e2dacfe | ||
|
b4d1d62e9b | ||
|
6f7ae4467c | ||
|
98547708af | ||
|
08d0a99b10 | ||
|
5ce6543275 | ||
|
40aa16e971 | ||
|
c1d3bac0c8 | ||
|
a4f8c65aa5 | ||
|
3bcdc67285 | ||
|
dffa0077de | ||
|
b3e266d564 | ||
|
0c87bbbce1 | ||
|
ae4d9cf81a | ||
|
22c2a80650 | ||
|
0b9439262c | ||
|
384d861d56 | ||
|
cf0d473a40 | ||
|
efc46dbbc5 | ||
|
f96ca899b5 | ||
|
083975bcd9 | ||
|
740c704fb8 | ||
|
e742338d92 | ||
|
d179d907ad | ||
|
c0680a3134 | ||
|
e080f49935 | ||
|
9df4aba56c | ||
|
ec69b34877 | ||
|
9f6942a8cb | ||
|
1a5faabf86 | ||
|
e5a4554bdd | ||
|
7e4fa47d00 | ||
|
7633619edd | ||
|
52c09151a6 | ||
|
3089e1f8d2 | ||
|
d968169e42 | ||
|
b39c8c770e | ||
|
fceb3edbc6 | ||
|
7c4427e84a | ||
|
fd200105c0 | ||
|
cd5cfedb2f | ||
|
28c0094837 | ||
|
5d79692766 | ||
|
7fb94eeafa | ||
|
5922d325e5 | ||
|
e665b44507 | ||
|
1e4fda7daa | ||
|
ad10084850 | ||
|
db45764d42 | ||
|
0483e99a4c | ||
|
155f7c7999 | ||
|
541df5c3bc | ||
|
34dee3773d | ||
|
657b497370 | ||
|
ff089f26eb | ||
|
7ac5c651aa | ||
|
15c8c0fe8f | ||
|
a761b80499 | ||
|
a02831644c | ||
|
3a661b67c7 | ||
|
47c10a180a | ||
|
5346224f1e | ||
|
0230ea5c79 | ||
|
3a2894b533 | ||
|
0d96a6f9ac |
@@ -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
|
||||
|
@@ -23,7 +23,7 @@ if (flutterVersionName == null) {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.example.solian"
|
||||
namespace "dev.solsynth.solian"
|
||||
compileSdk flutter.compileSdkVersion
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
@@ -41,11 +41,8 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId "com.example.solian"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
applicationId "dev.solsynth.solian"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion flutter.targetSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
@@ -53,9 +50,11 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig signingConfigs.debug
|
||||
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
12
android/app/proguard-rules.pro
vendored
Normal file
12
android/app/proguard-rules.pro
vendored
Normal 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.** { *; }
|
@@ -1,8 +1,42 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-feature android:name="android.hardware.camera" />
|
||||
<uses-feature android:name="android.hardware.camera.autofocus" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
|
||||
<application
|
||||
android:label="solian"
|
||||
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"
|
||||
@@ -24,6 +58,7 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
@@ -1,4 +1,4 @@
|
||||
package com.example.solian
|
||||
package dev.solsynth.solian
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
|
BIN
android/app/src/main/res/drawable/app_icon.png
Executable file
BIN
android/app/src/main/res/drawable/app_icon.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 70 KiB |
@@ -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"
|
||||
|
BIN
assets/icon-macos.png
Normal file
BIN
assets/icon-macos.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 90 KiB |
@@ -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'
|
||||
|
106
ios/Podfile.lock
106
ios/Podfile.lock
@@ -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
|
||||
|
@@ -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;
|
||||
@@ -496,7 +514,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.solian.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -514,7 +532,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.solian.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -530,7 +548,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.solian.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -49,6 +49,8 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
@@ -56,5 +58,9 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"solian": "Solian",
|
||||
"appName": "Solar Network",
|
||||
"explore": "Explore",
|
||||
"chat": "Chat",
|
||||
"realm": "Realm",
|
||||
"account": "Account",
|
||||
"riskDetection": "Risk Detection",
|
||||
"signIn": "Sign In",
|
||||
@@ -9,23 +10,59 @@
|
||||
"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",
|
||||
"friendBlocked": "Blocked",
|
||||
"friendListHint": "Swipe left to decline, swipe right to approve",
|
||||
"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",
|
||||
@@ -33,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...",
|
||||
@@ -46,9 +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",
|
||||
"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",
|
||||
@@ -56,8 +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"
|
||||
}
|
||||
|
@@ -1,31 +1,68 @@
|
||||
{
|
||||
"solian": "索链",
|
||||
"appName": "Solar Network",
|
||||
"explore": "探索",
|
||||
"chat": "聊天",
|
||||
"realm": "领域",
|
||||
"account": "账号",
|
||||
"riskDetection": "风险监测",
|
||||
"signIn": "登陆",
|
||||
"signInCaption": "登陆以发表帖子、文章、创建领域、和你的朋友聊天,以及获取更多功能!",
|
||||
"signInRequired": "请先登陆",
|
||||
"signInRiskDetected": "检测到风险,点击下一步按钮来打开一个网页,并通过在其上面登陆来通过安全检查。",
|
||||
"signIn": "登录",
|
||||
"signInCaption": "登录以发表帖子、文章、创建领域、和你的朋友聊天,以及获取更多功能!",
|
||||
"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": "活跃的好友",
|
||||
"friendBlocked": "封锁中",
|
||||
"friendListHint": "左滑来拒绝,右滑来接受",
|
||||
"friendAdd": "添加好友",
|
||||
"friendAddHint": "使用用户名来给你的好朋友发一个好友请求吧!",
|
||||
"friendAddDone": "好友请求已发送,快告诉你的朋友吧!",
|
||||
"personalize": "个性化",
|
||||
"personalizeApplied": "您的账号信息已被更新,部分信息可能需要一段时间来同步。",
|
||||
"keypair": "密钥对",
|
||||
"keypairGenerated": "已生成一套新的密钥对,并且设为活跃的密钥。",
|
||||
"keypairSecretCode": "神秘代码",
|
||||
"keypairImportHint": "你可以将别的设备导出的神秘代码粘贴到这里来导入其中的所有密钥。",
|
||||
"keypairExportHint": "你可以将这个导出的神秘代码到你的别的设备来导入这个设备所包含的密钥,但绝对不要发送给其他人!",
|
||||
"reaction": "反应",
|
||||
"reactVerb": "作出反应",
|
||||
"post": "帖子",
|
||||
@@ -33,9 +70,9 @@
|
||||
"comment": "评论",
|
||||
"attachment": "附件",
|
||||
"attachmentAdd": "附加新附件",
|
||||
"pickPhoto": "相册照片",
|
||||
"pickMedia": "相册媒体",
|
||||
"pickFile": "设备文件",
|
||||
"takePhoto": "拍摄照片",
|
||||
"pickVideo": "相册视频",
|
||||
"takeVideo": "拍摄视频",
|
||||
"newMoment": "记录时刻",
|
||||
"newComment": "留下评论",
|
||||
@@ -46,11 +83,62 @@
|
||||
"postEditNotify": "你正在修改一个已经发布了的帖子。",
|
||||
"reactionAdded": "你的反应已被添加。",
|
||||
"reactionRemoved": "你的反应已被移除。",
|
||||
"shortcutsEmpty": "快捷方式空空如也,看起来最近你没去哪里呀~",
|
||||
"realmNew": "新领域",
|
||||
"realmNewCreate": "创建新领域",
|
||||
"realmNewJoin": "加入现有领域",
|
||||
"realmUsage": "领域",
|
||||
"realmUsageCaption": "领域是一个地方给你来组织帖子、文章、聊天频道的,好好利用领域打造一个绝妙的专属于你的社区吧!",
|
||||
"realmEstablish": "部署领域",
|
||||
"realmEditNotify": "你正在修改一个现有领域……",
|
||||
"realmAliasLabel": "领域别名",
|
||||
"realmNameLabel": "领域名称",
|
||||
"realmDescriptionLabel": "领域简介",
|
||||
"realmPublicLabel": "公共领域",
|
||||
"realmCommunityLabel": "社区领域(任何人均可加入)",
|
||||
"realmMember": "成员",
|
||||
"realmManage": "领域管理",
|
||||
"chatNew": "新聊天",
|
||||
"chatDetail": "聊天详情",
|
||||
"chatMember": "成员",
|
||||
"chatNotifySetting": "通知设定",
|
||||
"chatChannelUnavailable": "频道不可用",
|
||||
"chatChannelUnavailableCaptionWithRealm": "你没加入该频道,但是看起来你能加入本频道,你想加入吗?",
|
||||
"chatChannelUnavailableCaption": "你没加入该频道,所以你无法读取本频道的信息。",
|
||||
"chatNewCreate": "新建频道",
|
||||
"chatNewJoin": "加入已有频道",
|
||||
"chatChannelUsage": "频道",
|
||||
"chatChannelUsageCaption": "频道是一个地方供你聊天,跟一个人,或者一堆人",
|
||||
"chatChannelOrganize": "组织频道",
|
||||
"chatChannelEditNotify": "你正在编辑一个已经存在的频道……",
|
||||
"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": "无法解密信息,缺失解密密钥"
|
||||
}
|
||||
|
@@ -2,13 +2,22 @@ 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/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/chat/call/call_overlay.dart';
|
||||
import 'package:solian/widgets/provider_init.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
initVideo();
|
||||
initTimeAgo();
|
||||
|
||||
@@ -18,32 +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()),
|
||||
],
|
||||
child: child,
|
||||
);
|
||||
})
|
||||
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()),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@@ -8,9 +8,9 @@ class Account {
|
||||
String avatar;
|
||||
String banner;
|
||||
String description;
|
||||
String emailAddress;
|
||||
String? emailAddress;
|
||||
int powerLevel;
|
||||
int externalId;
|
||||
int? externalId;
|
||||
|
||||
Account({
|
||||
required this.id,
|
||||
@@ -22,38 +22,38 @@ class Account {
|
||||
required this.avatar,
|
||||
required this.banner,
|
||||
required this.description,
|
||||
required this.emailAddress,
|
||||
this.emailAddress,
|
||||
required this.powerLevel,
|
||||
required this.externalId,
|
||||
this.externalId,
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
@@ -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,
|
||||
};
|
||||
}
|
||||
|
69
lib/models/call.dart
Normal file
69
lib/models/call.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:solian/models/channel.dart';
|
||||
|
||||
class Call {
|
||||
int id;
|
||||
DateTime createdAt;
|
||||
DateTime updatedAt;
|
||||
DateTime? deletedAt;
|
||||
DateTime? endedAt;
|
||||
String externalId;
|
||||
int founderId;
|
||||
int channelId;
|
||||
Channel channel;
|
||||
|
||||
Call({
|
||||
required this.id,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.deletedAt,
|
||||
this.endedAt,
|
||||
required this.externalId,
|
||||
required this.founderId,
|
||||
required this.channelId,
|
||||
required this.channel,
|
||||
});
|
||||
|
||||
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']),
|
||||
);
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
enum ParticipantStatsType {
|
||||
unknown,
|
||||
localAudioSender,
|
||||
localVideoSender,
|
||||
remoteAudioReceiver,
|
||||
remoteVideoReceiver,
|
||||
}
|
||||
|
||||
class ParticipantTrack {
|
||||
ParticipantTrack(
|
||||
{required this.participant,
|
||||
required this.videoTrack,
|
||||
required this.isScreenShare});
|
||||
VideoTrack? videoTrack;
|
||||
Participant participant;
|
||||
bool isScreenShare;
|
||||
}
|
@@ -1,3 +1,5 @@
|
||||
import 'package:solian/models/account.dart';
|
||||
|
||||
class Channel {
|
||||
int id;
|
||||
DateTime createdAt;
|
||||
@@ -6,12 +8,13 @@ class Channel {
|
||||
String alias;
|
||||
String name;
|
||||
String description;
|
||||
dynamic members;
|
||||
dynamic messages;
|
||||
dynamic calls;
|
||||
int type;
|
||||
Account account;
|
||||
int accountId;
|
||||
int realmId;
|
||||
int? realmId;
|
||||
bool isEncrypted;
|
||||
|
||||
bool isAvailable = false;
|
||||
|
||||
Channel({
|
||||
required this.id,
|
||||
@@ -21,43 +24,84 @@ class Channel {
|
||||
required this.alias,
|
||||
required this.name,
|
||||
required this.description,
|
||||
this.members,
|
||||
this.messages,
|
||||
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"],
|
||||
messages: json["messages"],
|
||||
calls: json["calls"],
|
||||
type: json["type"],
|
||||
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,
|
||||
"messages": messages,
|
||||
"calls": calls,
|
||||
"type": type,
|
||||
"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 {
|
||||
int id;
|
||||
DateTime createdAt;
|
||||
DateTime updatedAt;
|
||||
DateTime? deletedAt;
|
||||
int channelId;
|
||||
int accountId;
|
||||
Account account;
|
||||
int notify;
|
||||
|
||||
ChannelMember({
|
||||
required this.id,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.deletedAt,
|
||||
required this.channelId,
|
||||
required this.accountId,
|
||||
required this.account,
|
||||
required this.notify,
|
||||
});
|
||||
|
||||
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'],
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
61
lib/models/friendship.dart
Normal file
61
lib/models/friendship.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
import 'package:solian/models/account.dart';
|
||||
|
||||
class Friendship {
|
||||
int id;
|
||||
DateTime createdAt;
|
||||
DateTime updatedAt;
|
||||
DateTime? deletedAt;
|
||||
int accountId;
|
||||
int relatedId;
|
||||
int? blockedBy;
|
||||
Account account;
|
||||
Account related;
|
||||
int status;
|
||||
|
||||
Friendship({
|
||||
required this.id,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.deletedAt,
|
||||
required this.accountId,
|
||||
required this.relatedId,
|
||||
this.blockedBy,
|
||||
required this.account,
|
||||
required this.related,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
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'],
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
Account getOtherside(int selfId) {
|
||||
if (accountId != selfId) {
|
||||
return account;
|
||||
} else {
|
||||
return related;
|
||||
}
|
||||
}
|
||||
}
|
32
lib/models/keypair.dart
Normal file
32
lib/models/keypair.dart
Normal 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,
|
||||
};
|
||||
}
|
@@ -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,
|
||||
};
|
||||
}
|
||||
|
@@ -7,8 +7,9 @@ class Notification {
|
||||
String content;
|
||||
List<Link>? links;
|
||||
bool isImportant;
|
||||
bool isRealtime;
|
||||
DateTime? readAt;
|
||||
int senderId;
|
||||
int? senderId;
|
||||
int recipientId;
|
||||
|
||||
Notification({
|
||||
@@ -20,41 +21,40 @@ class Notification {
|
||||
required this.content,
|
||||
this.links,
|
||||
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"],
|
||||
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,
|
||||
"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,
|
||||
};
|
||||
}
|
||||
|
@@ -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,
|
||||
};
|
||||
}
|
||||
|
@@ -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,
|
||||
};
|
||||
}
|
||||
|
47
lib/models/personal_page.dart
Normal file
47
lib/models/personal_page.dart
Normal 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,
|
||||
};
|
||||
}
|
@@ -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
97
lib/models/realm.dart
Normal 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,
|
||||
};
|
||||
}
|
@@ -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) ?? '{}');
|
||||
}
|
||||
}
|
||||
|
@@ -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/unified');
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
26
lib/providers/friend.dart
Normal file
26
lib/providers/friend.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:solian/models/friendship.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
|
||||
class FriendProvider extends ChangeNotifier {
|
||||
List<Friendship> friends = List.empty();
|
||||
|
||||
Future<void> fetch(AuthProvider auth) async {
|
||||
if (!await auth.isAuthorized()) return;
|
||||
|
||||
var uri = getRequestUri('passport', '/api/users/me/friends?status=1');
|
||||
|
||||
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();
|
||||
} else {
|
||||
var message = utf8.decode(res.bodyBytes);
|
||||
throw Exception(message);
|
||||
}
|
||||
}
|
||||
}
|
151
lib/providers/keypair.dart
Normal file
151
lib/providers/keypair.dart
Normal 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;
|
||||
}
|
||||
}
|
@@ -1,3 +1,3 @@
|
||||
class NavigationProvider {
|
||||
int selectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
195
lib/providers/notify.dart
Normal file
195
lib/providers/notify.dart
Normal file
@@ -0,0 +1,195 @@
|
||||
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/services_url.dart';
|
||||
import 'package:solian/models/notification.dart' as model;
|
||||
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 {
|
||||
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;
|
||||
|
||||
var uri = getRequestUri('passport', '/api/notifications?skip=0&take=25');
|
||||
var res = await auth.client!.get(uri);
|
||||
if (res.statusCode == 200) {
|
||||
final result = PaginationResult.fromJson(jsonDecode(utf8.decode(res.bodyBytes)));
|
||||
notifications = result.data?.map((x) => model.Notification.fromJson(x)).toList() ?? List.empty(growable: true);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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!.currentToken!)},
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
_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 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) {
|
||||
notifications.removeAt(index);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearRealtimeNotifications() {
|
||||
notifications = notifications.where((x) => !x.isRealtime).toList();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void allRead() {
|
||||
unreadAmount = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
47
lib/providers/realm.dart
Normal file
47
lib/providers/realm.dart
Normal 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();
|
||||
}
|
||||
}
|
327
lib/router.dart
327
lib/router.dart
@@ -1,68 +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/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/widgets/chat/channel_editor.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: '/chat',
|
||||
name: 'chat',
|
||||
builder: (context, state) => const ChatIndexScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/chat/create',
|
||||
name: 'chat.channel.editor',
|
||||
builder: (context, state) => ChannelEditor(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: '/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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
@@ -1,27 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.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/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();
|
||||
@@ -30,61 +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(),
|
||||
),
|
||||
InkWell(
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 18),
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.logout),
|
||||
title: Text("Sign out"),
|
||||
),
|
||||
),
|
||||
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');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +131,7 @@ class NameCard extends StatelessWidget {
|
||||
Future<Widget> renderAvatar(BuildContext context) async {
|
||||
final auth = context.read<AuthProvider>();
|
||||
final profiles = await auth.getProfiles();
|
||||
return CircleAvatar(backgroundImage: NetworkImage(profiles["picture"]));
|
||||
return AccountAvatar(source: profiles['picture'], direct: true);
|
||||
}
|
||||
|
||||
Future<Column> renderLabel(BuildContext context) async {
|
||||
@@ -104,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),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -126,8 +169,7 @@ class NameCard extends StatelessWidget {
|
||||
children: [
|
||||
FutureBuilder(
|
||||
future: renderAvatar(context),
|
||||
builder:
|
||||
(BuildContext context, AsyncSnapshot<Widget> snapshot) {
|
||||
builder: (BuildContext context, AsyncSnapshot<Widget> snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return snapshot.data!;
|
||||
} else {
|
||||
@@ -138,10 +180,9 @@ class NameCard extends StatelessWidget {
|
||||
const SizedBox(width: 20),
|
||||
FutureBuilder(
|
||||
future: renderLabel(context),
|
||||
builder:
|
||||
(BuildContext context, AsyncSnapshot<Column> snapshot) {
|
||||
builder: (BuildContext context, AsyncSnapshot<Column> snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return snapshot.data!;
|
||||
return Expanded(child: snapshot.data!);
|
||||
} else {
|
||||
return const Column();
|
||||
}
|
||||
@@ -161,12 +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) {
|
||||
@@ -193,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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
293
lib/screens/account/friend.dart
Normal file
293
lib/screens/account/friend.dart
Normal file
@@ -0,0 +1,293 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
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/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 StatelessWidget {
|
||||
const FriendScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IndentScaffold(
|
||||
title: AppLocalizations.of(context)!.friend,
|
||||
hideDrawer: true,
|
||||
body: const FriendScreenWidget(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
final auth = context.read<AuthProvider>();
|
||||
final prof = await auth.getProfiles();
|
||||
if (!await auth.isAuthorized()) return;
|
||||
|
||||
_selfId = prof['id'];
|
||||
|
||||
var uri = getRequestUri('passport', '/api/users/me/friends');
|
||||
|
||||
var res = await auth.client!.get(uri);
|
||||
if (res.statusCode == 200) {
|
||||
final result = jsonDecode(utf8.decode(res.bodyBytes)) as List<dynamic>;
|
||||
setState(() {
|
||||
_friendships = result.map((x) => Friendship.fromJson(x)).toList();
|
||||
});
|
||||
} else {
|
||||
var message = utf8.decode(res.bodyBytes);
|
||||
context.showErrorDialog(message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createFriendship(String username) async {
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
final auth = context.read<AuthProvider>();
|
||||
if (!await auth.isAuthorized()) {
|
||||
setState(() => _isSubmitting = false);
|
||||
return;
|
||||
}
|
||||
|
||||
var res = await auth.client!.post(
|
||||
getRequestUri('passport', '/api/users/me/friends?related=$username'),
|
||||
);
|
||||
if (res.statusCode == 200) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context)!.friendAddDone)),
|
||||
);
|
||||
await fetchFriendships();
|
||||
} else {
|
||||
var message = utf8.decode(res.bodyBytes);
|
||||
context.showErrorDialog(message);
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
|
||||
Future<void> updateFriendship(Friendship relation, int status) async {
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
final otherside = relation.getOtherside(_selfId);
|
||||
|
||||
final auth = context.read<AuthProvider>();
|
||||
if (!await auth.isAuthorized()) {
|
||||
setState(() => _isSubmitting = false);
|
||||
return;
|
||||
}
|
||||
|
||||
var res = await auth.client!.put(
|
||||
getRequestUri('passport', '/api/users/me/friends/${otherside.id}'),
|
||||
headers: <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode(<String, dynamic>{
|
||||
'status': status,
|
||||
}),
|
||||
);
|
||||
if (res.statusCode == 200) {
|
||||
await fetchFriendships();
|
||||
} else {
|
||||
var message = utf8.decode(res.bodyBytes);
|
||||
context.showErrorDialog(message);
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
|
||||
void promptAddFriend() async {
|
||||
final controller = TextEditingController();
|
||||
final input = await showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.friendAdd),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(AppLocalizations.of(context)!.friendAddHint),
|
||||
const SizedBox(height: 18),
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
prefixIcon: const Icon(Icons.account_circle),
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: AppLocalizations.of(context)!.username,
|
||||
),
|
||||
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());
|
||||
|
||||
await createFriendship(input);
|
||||
}
|
||||
|
||||
List<Friendship> filterWithStatus(int status) {
|
||||
return _friendships.where((x) => x.status == status).toList();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
return DismissDirection.horizontal;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
Future.delayed(Duration.zero, () {
|
||||
fetchFriendships();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget friendshipTileBuilder(context, index, status) {
|
||||
final element = filterWithStatus(status)[index];
|
||||
final otherside = element.getOtherside(_selfId);
|
||||
|
||||
final randomId = DateTime.now().microsecondsSinceEpoch >> 10;
|
||||
|
||||
return Dismissible(
|
||||
key: Key(randomId.toString()),
|
||||
background: Container(
|
||||
color: Colors.red,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: const Icon(Icons.close, color: Colors.white),
|
||||
),
|
||||
secondaryBackground: Container(
|
||||
color: Colors.green,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
alignment: Alignment.centerRight,
|
||||
child: const Icon(Icons.check, color: Colors.white),
|
||||
),
|
||||
direction: getDismissDirection(element),
|
||||
child: ListTile(
|
||||
title: Text(otherside.nick),
|
||||
subtitle: Text(otherside.name),
|
||||
leading: AccountAvatar(source: otherside.avatar),
|
||||
),
|
||||
onDismissed: (direction) {
|
||||
if (direction == DismissDirection.startToEnd) {
|
||||
updateFriendship(element, 2);
|
||||
}
|
||||
if (direction == DismissDirection.endToStart) {
|
||||
updateFriendship(element, 1);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.add),
|
||||
onPressed: () => promptAddFriend(),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () => fetchFriendships(),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: _isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.8),
|
||||
child: Text(AppLocalizations.of(context)!.friendPending),
|
||||
),
|
||||
),
|
||||
SliverList.builder(
|
||||
itemCount: filterWithStatus(0).length,
|
||||
itemBuilder: (_, __) => friendshipTileBuilder(_, __, 0),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.8),
|
||||
child: Text(AppLocalizations.of(context)!.friendActive),
|
||||
),
|
||||
),
|
||||
SliverList.builder(
|
||||
itemCount: filterWithStatus(1).length,
|
||||
itemBuilder: (_, __) => friendshipTileBuilder(_, __, 1),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.8),
|
||||
child: Text(AppLocalizations.of(context)!.friendBlocked),
|
||||
),
|
||||
),
|
||||
SliverList.builder(
|
||||
itemCount: filterWithStatus(2).length,
|
||||
itemBuilder: (_, __) => friendshipTileBuilder(_, __, 2),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.8),
|
||||
width: 0.3,
|
||||
)),
|
||||
),
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.friendListHint,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
191
lib/screens/account/keypair.dart
Normal file
191
lib/screens/account/keypair.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
324
lib/screens/account/personalize.dart
Normal file
324
lib/screens/account/personalize.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@@ -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(),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
120
lib/screens/auth/signin.dart
Normal file
120
lib/screens/auth/signin.dart
Normal 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),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
154
lib/screens/auth/signup.dart
Normal file
154
lib/screens/auth/signup.dart
Normal 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),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
187
lib/screens/chat/call.dart
Normal file
187
lib/screens/chat/call.dart
Normal file
@@ -0,0 +1,187 @@
|
||||
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/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/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;
|
||||
|
||||
class ChatCall extends StatefulWidget {
|
||||
final Call call;
|
||||
|
||||
const ChatCall({super.key, required this.call});
|
||||
|
||||
@override
|
||||
State<ChatCall> createState() => _ChatCallState();
|
||||
}
|
||||
|
||||
class _ChatCallState extends State<ChatCall> {
|
||||
bool _isHandled = false;
|
||||
|
||||
late ChatProvider _chat;
|
||||
|
||||
ChatCallInstance get _call => _chat.currentCall!;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_chat.setCallShown(true);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_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());
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||
child: _call.focusTrack != null
|
||||
? InteractiveParticipantWidget(
|
||||
isFixed: false,
|
||||
participant: _call.focusTrack!,
|
||||
onTap: () {},
|
||||
)
|
||||
: Container(),
|
||||
),
|
||||
),
|
||||
if (_call.room.localParticipant != null)
|
||||
ControlsWidget(
|
||||
_call.room,
|
||||
_call.room.localParticipant!,
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: SizedBox(
|
||||
height: 128,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: math.max(0, _call.participantTracks.length),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final track = _call.participantTracks[index];
|
||||
if (track.participant.sid ==
|
||||
_call.focusTrack?.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() {
|
||||
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
|
||||
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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
198
lib/screens/chat/channel/channel_editor.dart
Normal file
198
lib/screens/chat/channel/channel_editor.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
190
lib/screens/chat/channel/channel_member.dart
Normal file
190
lib/screens/chat/channel/channel_member.dart
Normal file
@@ -0,0 +1,190 @@
|
||||
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/channel.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/account/friend_picker.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, this.realm = 'global'});
|
||||
|
||||
@override
|
||||
State<ChatMemberScreen> createState() => _ChatMemberScreenState();
|
||||
}
|
||||
|
||||
class _ChatMemberScreenState extends State<ChatMemberScreen> {
|
||||
bool _isSubmitting = false;
|
||||
|
||||
List<ChannelMember> _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('messaging', '/api/channels/${widget.realm}/${widget.channel.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) => ChannelMember.fromJson(x)).toList();
|
||||
});
|
||||
} else {
|
||||
var message = utf8.decode(res.bodyBytes);
|
||||
context.showErrorDialog(message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeMember(ChannelMember item) async {
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
final auth = context.read<AuthProvider>();
|
||||
if (!await auth.isAuthorized()) {
|
||||
setState(() => _isSubmitting = false);
|
||||
return;
|
||||
}
|
||||
|
||||
var uri = getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel.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('messaging', '/api/channels/${widget.realm}/${widget.channel.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(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;
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
Future.delayed(Duration.zero, () => fetchMemberships());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IndentScaffold(
|
||||
title: AppLocalizations.of(context)!.chatMember,
|
||||
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, direct: true),
|
||||
title: Text(element.account.nick),
|
||||
subtitle: Text(element.account.name),
|
||||
),
|
||||
onDismissed: (_) {
|
||||
removeMember(element);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,102 +1,103 @@
|
||||
import 'dart:convert';
|
||||
|
||||
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/channel.dart';
|
||||
import 'package:solian/models/message.dart';
|
||||
import 'package:solian/models/pagination.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/utils/service_url.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/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:http/http.dart' as http;
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
import 'package:solian/widgets/scaffold.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
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> {
|
||||
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<void> 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));
|
||||
} else {
|
||||
var message = utf8.decode(res.bodyBytes);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("Something went wrong... $message")),
|
||||
);
|
||||
}
|
||||
}
|
||||
class _ChatWidgetState extends State<ChatWidget> {
|
||||
bool _isReady = false;
|
||||
|
||||
Future<void> fetchMessages(int pageKey, BuildContext context) async {
|
||||
late final ChatProvider _chat;
|
||||
|
||||
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;
|
||||
@@ -107,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;
|
||||
@@ -118,66 +120,142 @@ 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();
|
||||
});
|
||||
|
||||
_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
|
||||
Widget build(BuildContext context) {
|
||||
return IndentWrapper(
|
||||
hideDrawer: true,
|
||||
title: _channelMeta?.name ?? "Loading...",
|
||||
appBarActions: [
|
||||
_channelMeta != null ? ChannelAction(channel: _channelMeta!, onUpdate: () => fetchMetadata()) : Container(),
|
||||
Widget chatHistoryBuilder(context, item, index) {
|
||||
bool isMerged = false, hasMerged = false;
|
||||
if (index > 0) {
|
||||
hasMerged = getMessageMergeable(_chat.historyPagingController?.itemList?[index - 1], item);
|
||||
}
|
||||
if (index + 1 < (_chat.historyPagingController?.itemList?.length ?? 0)) {
|
||||
isMerged = getMessageMergeable(item, _chat.historyPagingController?.itemList?[index + 1]);
|
||||
}
|
||||
return InkWell(
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(
|
||||
top: !isMerged ? 8 : 0,
|
||||
bottom: !hasMerged ? 8 : 0,
|
||||
left: 12,
|
||||
right: 12,
|
||||
),
|
||||
child: ChatMessage(
|
||||
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(
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20),
|
||||
leading: const Icon(Icons.call_received),
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.9),
|
||||
dividerColor: const Color.fromARGB(1, 0, 0, 0),
|
||||
content: Text(AppLocalizations.of(context)!.chatCallOngoing),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: Text(AppLocalizations.of(context)!.chatCallJoin),
|
||||
onPressed: () {
|
||||
SolianRouter.router.pushNamed(
|
||||
'chat.channel.call',
|
||||
extra: _chat.ongoingCall,
|
||||
pathParameters: {'channel': widget.alias},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
child: ChatMaintainer(
|
||||
child: Column(
|
||||
);
|
||||
|
||||
if (_chat.focusChannel == null || _chat.historyPagingController == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: PagedListView<int, Message>(
|
||||
reverse: true,
|
||||
pagingController: _pagingController,
|
||||
pagingController: _chat.historyPagingController!,
|
||||
builderDelegate: PagedChildBuilderDelegate<Message>(
|
||||
animateTransitions: true,
|
||||
transitionDuration: 350.ms,
|
||||
itemBuilder: chatHistoryBuilder,
|
||||
noItemsFoundIndicatorBuilder: (_) => Container(),
|
||||
itemBuilder: (context, item, index) {
|
||||
bool isMerged = false, hasMerged = false;
|
||||
if (index > 0) {
|
||||
hasMerged = getMessageMergeable(_pagingController.itemList?[index - 1], item);
|
||||
}
|
||||
if (index + 1 < (_pagingController.itemList?.length ?? 0)) {
|
||||
isMerged = getMessageMergeable(item, _pagingController.itemList?[index + 1]);
|
||||
}
|
||||
return InkWell(
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(
|
||||
top: !isMerged ? 8 : 0,
|
||||
bottom: !hasMerged ? 8 : 0,
|
||||
left: 12,
|
||||
right: 12,
|
||||
),
|
||||
child: ChatMessage(
|
||||
key: Key('m${item.id}'),
|
||||
item: item,
|
||||
underMerged: isMerged,
|
||||
),
|
||||
),
|
||||
onLongPress: () => viewActions(item),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
ChatMessageEditor(
|
||||
realm: widget.realm,
|
||||
channel: widget.alias,
|
||||
editing: _editingItem,
|
||||
replying: _replyingItem,
|
||||
isEncrypted: _chat.focusChannel?.isEncrypted ?? false,
|
||||
onReset: () => setState(() {
|
||||
_editingItem = null;
|
||||
_replyingItem = null;
|
||||
@@ -185,10 +263,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
onInsertMessage: (message) => addMessage(message),
|
||||
onUpdateMessage: (message) => updateMessage(message),
|
||||
onDeleteMessage: (message) => deleteMessage(message),
|
||||
),
|
||||
_chat.ongoingCall != null ? callBanner.animate().slideY() : Container(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
145
lib/screens/chat/chat_detail.dart
Normal file
145
lib/screens/chat/chat_detail.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
147
lib/screens/chat/chat_list.dart
Normal file
147
lib/screens/chat/chat_list.dart
Normal 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!}),
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,115 +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/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,
|
||||
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: () {
|
||||
router.pushNamed(
|
||||
'chat.channel',
|
||||
pathParameters: {
|
||||
'channel': element.alias,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,26 +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/widgets/posts/item.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/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();
|
||||
@@ -29,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) {
|
||||
@@ -51,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));
|
||||
}
|
||||
|
||||
@@ -58,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) {
|
||||
@@ -67,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();
|
||||
},
|
||||
);
|
||||
@@ -76,33 +119,52 @@ class _ExploreScreenState extends State<ExploreScreen> {
|
||||
}
|
||||
},
|
||||
),
|
||||
title: AppLocalizations.of(context)!.explore,
|
||||
child: RefreshIndicator(
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () => Future.sync(
|
||||
() => _pagingController.refresh(),
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 640),
|
||||
child: PagedListView<int, Post>(
|
||||
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: () {
|
||||
router.pushNamed(
|
||||
'posts.screen',
|
||||
SolianRouter.router.pushNamed(
|
||||
widget.realm == null ? 'posts.details' : 'realms.posts.details',
|
||||
pathParameters: {
|
||||
'alias': item.alias,
|
||||
'dataset': '${item.modelType}s',
|
||||
'dataset': item.dataset,
|
||||
...(widget.realm == null ? {} : {'realm': widget.realm!}),
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
227
lib/screens/notification.dart
Normal file
227
lib/screens/notification.dart
Normal file
@@ -0,0 +1,227 @@
|
||||
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/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;
|
||||
|
||||
class NotificationScreen extends StatefulWidget {
|
||||
const NotificationScreen({super.key});
|
||||
|
||||
@override
|
||||
State<NotificationScreen> createState() => _NotificationScreenState();
|
||||
}
|
||||
|
||||
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 IndentScaffold(
|
||||
hideDrawer: true,
|
||||
title: AppLocalizations.of(context)!.notification,
|
||||
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(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.check),
|
||||
title: Text(AppLocalizations.of(context)!.notifyDone),
|
||||
subtitle: Text(AppLocalizations.of(context)!.notifyDoneCaption),
|
||||
),
|
||||
),
|
||||
)
|
||||
: SliverList.builder(
|
||||
itemCount: nty.notifications.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
var element = nty.notifications[index];
|
||||
return NotificationItem(
|
||||
index: index,
|
||||
item: element,
|
||||
onDismiss: () => nty.clearAt(index),
|
||||
);
|
||||
},
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.notifyListHint,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationItem extends StatelessWidget {
|
||||
final int index;
|
||||
final model.Notification item;
|
||||
final void Function()? onDismiss;
|
||||
|
||||
const NotificationItem({super.key, required this.index, required this.item, this.onDismiss});
|
||||
|
||||
bool get hasLinks => item.links != null && item.links!.isNotEmpty;
|
||||
|
||||
void showLinks(BuildContext context) {
|
||||
if (!hasLinks) return;
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, top: 34, bottom: 12),
|
||||
child: Text(
|
||||
'Links',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: item.links!.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
var element = item.links![index];
|
||||
return ListTile(
|
||||
title: Text(element.label),
|
||||
onTap: () async {
|
||||
await launchUrlString(element.url);
|
||||
if (Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> markAsRead(model.Notification element, BuildContext context) async {
|
||||
if (element.isRealtime) return;
|
||||
|
||||
final auth = context.read<AuthProvider>();
|
||||
if (!await auth.isAuthorized()) return;
|
||||
|
||||
var id = element.id;
|
||||
var uri = getRequestUri('passport', '/api/notifications/$id/read');
|
||||
await auth.client!.put(uri);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dismissible(
|
||||
key: Key('n${item.id}'),
|
||||
onDismissed: (direction) {
|
||||
markAsRead(item, context).then((value) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${item.subject} is marked as read')),
|
||||
);
|
||||
});
|
||||
if (onDismiss != null) {
|
||||
onDismiss!();
|
||||
}
|
||||
},
|
||||
background: Container(
|
||||
color: Colors.lightBlue,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(left: 10),
|
||||
child: ListTile(
|
||||
title: Text(item.subject),
|
||||
subtitle: Text(item.content),
|
||||
trailing: hasLinks
|
||||
? TextButton(
|
||||
onPressed: () => showLinks(context),
|
||||
style: TextButton.styleFrom(shape: const CircleBorder()),
|
||||
child: const Icon(Icons.more_vert),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@@ -7,9 +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/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 {
|
||||
@@ -63,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,
|
||||
@@ -74,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,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: [
|
||||
@@ -120,8 +120,9 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> {
|
||||
],
|
||||
);
|
||||
|
||||
return IndentWrapper(
|
||||
return IndentScaffold(
|
||||
hideDrawer: true,
|
||||
showSafeArea: true,
|
||||
title: AppLocalizations.of(context)!.newComment,
|
||||
appBarActions: <Widget>[
|
||||
TextButton(
|
||||
@@ -129,68 +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: CircleAvatar(
|
||||
backgroundImage: NetworkImage(userinfo["picture"]),
|
||||
),
|
||||
);
|
||||
} 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),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@@ -7,15 +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/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();
|
||||
@@ -53,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,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: [
|
||||
@@ -110,7 +112,8 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> {
|
||||
],
|
||||
);
|
||||
|
||||
return IndentWrapper(
|
||||
return IndentScaffold(
|
||||
showSafeArea: true,
|
||||
hideDrawer: true,
|
||||
title: AppLocalizations.of(context)!.newMoment,
|
||||
appBarActions: <Widget>[
|
||||
@@ -119,68 +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: CircleAvatar(
|
||||
backgroundImage: NetworkImage(userinfo["picture"]),
|
||||
),
|
||||
);
|
||||
} 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),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@@ -4,37 +4,53 @@ 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);
|
||||
final PagingController<int, Post> _commentPagingController = PagingController(firstPageKey: 0);
|
||||
|
||||
Future<Post?> fetchPost(BuildContext context) async {
|
||||
final uri = getRequestUri(
|
||||
'interactive', '/api/p/${widget.dataset}/${widget.alias}');
|
||||
final uri = getRequestUri('interactive', '/api/p/${widget.dataset}/${widget.alias}');
|
||||
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)));
|
||||
@@ -43,48 +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 Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 640),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: PostItem(
|
||||
item: snapshot.data!,
|
||||
brief: false,
|
||||
ripple: false,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: CommentListHeader(
|
||||
related: snapshot.data!,
|
||||
paging: _commentPagingController,
|
||||
),
|
||||
),
|
||||
CommentList(
|
||||
related: snapshot.data!,
|
||||
dataset: widget.dataset,
|
||||
paging: _commentPagingController,
|
||||
),
|
||||
],
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: CommentListHeader(
|
||||
related: snapshot.data!,
|
||||
paging: _commentPagingController,
|
||||
),
|
||||
),
|
||||
CommentList(
|
||||
related: snapshot.data!,
|
||||
dataset: widget.dataset,
|
||||
paging: _commentPagingController,
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
131
lib/screens/realms/realm.dart
Normal file
131
lib/screens/realms/realm.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
211
lib/screens/realms/realm_editor.dart
Normal file
211
lib/screens/realms/realm_editor.dart
Normal 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,
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
132
lib/screens/realms/realm_list.dart
Normal file
132
lib/screens/realms/realm_list.dart
Normal 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
118
lib/screens/realms/realm_manage.dart
Normal file
118
lib/screens/realms/realm_manage.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
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/router.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 RealmManageScreen extends StatefulWidget {
|
||||
final Realm realm;
|
||||
|
||||
const RealmManageScreen({super.key, required this.realm});
|
||||
|
||||
@override
|
||||
State<RealmManageScreen> createState() => _RealmManageScreenState();
|
||||
}
|
||||
|
||||
class _RealmManageScreenState extends State<RealmManageScreen> {
|
||||
bool _isOwned = false;
|
||||
|
||||
void promptLeaveChannel() async {
|
||||
final did = await showDialog(
|
||||
context: context,
|
||||
builder: (context) => RealmDeletion(
|
||||
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.realm.accountId;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authorizedItems = [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings),
|
||||
title: Text(AppLocalizations.of(context)!.settings),
|
||||
onTap: () async {
|
||||
SolianRouter.router.pushNamed('realms.editor', extra: widget.realm).then((did) {
|
||||
if (did == true) {
|
||||
if (SolianRouter.router.canPop()) SolianRouter.router.pop('refresh');
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
return IndentScaffold(
|
||||
title: AppLocalizations.of(context)!.realmManage,
|
||||
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.realm.name, style: Theme.of(context).textTheme.bodyLarge),
|
||||
Text(widget.realm.description, style: Theme.of(context).textTheme.bodySmall),
|
||||
]),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(thickness: 0.3),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.supervisor_account),
|
||||
title: Text(AppLocalizations.of(context)!.chatMember),
|
||||
onTap: () {
|
||||
SolianRouter.router.pushNamed(
|
||||
'realms.member',
|
||||
extra: widget.realm,
|
||||
pathParameters: {'realm': widget.realm.alias},
|
||||
);
|
||||
},
|
||||
),
|
||||
...(_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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
191
lib/screens/realms/realm_member.dart
Normal file
191
lib/screens/realms/realm_member.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@@ -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(),
|
||||
hintText: 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(),
|
||||
hintText: 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),
|
||||
));
|
||||
}
|
||||
});
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
170
lib/screens/users/userinfo.dart
Normal file
170
lib/screens/users/userinfo.dart
Normal 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
9
lib/utils/file.dart
Normal 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
86
lib/utils/http.dart
Normal 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
39
lib/utils/platform.dart
Normal 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
14
lib/utils/theme.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
@@ -4,4 +4,4 @@ import 'package:media_kit/media_kit.dart';
|
||||
void initVideo() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
MediaKit.ensureInitialized();
|
||||
}
|
||||
}
|
||||
|
48
lib/widgets/account/account_avatar.dart
Normal file
48
lib/widgets/account/account_avatar.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:solian/utils/platform.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
|
||||
class AccountAvatar extends StatelessWidget {
|
||||
final String source;
|
||||
final double? radius;
|
||||
final bool? direct;
|
||||
final Color? backgroundColor;
|
||||
|
||||
const AccountAvatar({
|
||||
super.key,
|
||||
required this.source,
|
||||
this.radius,
|
||||
this.direct,
|
||||
this.backgroundColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final detectRegex = RegExp(r'https://.*/api/avatar/');
|
||||
|
||||
if (source.isEmpty || source.replaceAll(detectRegex, '').isEmpty) {
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: backgroundColor,
|
||||
child: const Icon(Icons.account_circle),
|
||||
);
|
||||
}
|
||||
if (direct == true) {
|
||||
final image = PlatformInfo.canCacheImage ? CachedNetworkImageProvider(source) : NetworkImage(source);
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: backgroundColor,
|
||||
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: image as ImageProvider,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
66
lib/widgets/account/friend_picker.dart
Normal file
66
lib/widgets/account/friend_picker.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
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/account_avatar.dart';
|
||||
|
||||
class FriendPicker extends StatefulWidget {
|
||||
const FriendPicker({super.key});
|
||||
|
||||
@override
|
||||
State<FriendPicker> createState() => _FriendPickerState();
|
||||
}
|
||||
|
||||
class _FriendPickerState extends State<FriendPicker> {
|
||||
int _selfId = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
Future.delayed(Duration.zero, () async {
|
||||
final auth = context.read<AuthProvider>();
|
||||
final friends = context.read<FriendProvider>();
|
||||
friends.fetch(auth);
|
||||
|
||||
final prof = await auth.getProfiles();
|
||||
_selfId = prof['id'];
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dict = context.watch<FriendProvider>();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.only(left: 16, right: 16, top: 32, bottom: 12),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.friend,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: dict.friends.length,
|
||||
itemBuilder: (context, index) {
|
||||
var element = dict.friends[index].getOtherside(_selfId);
|
||||
return ListTile(
|
||||
title: Text(element.nick),
|
||||
subtitle: Text(element.name),
|
||||
leading: AccountAvatar(source: element.avatar),
|
||||
onTap: () {
|
||||
Navigator.pop(context, element);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@@ -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(),
|
385
lib/widgets/chat/call/call_controls.dart
Normal file
385
lib/widgets/chat/call/call_controls.dart
Normal file
@@ -0,0 +1,385 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
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;
|
||||
final LocalParticipant participant;
|
||||
|
||||
const ControlsWidget(
|
||||
this.room,
|
||||
this.participant, {
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _ControlsWidgetState();
|
||||
}
|
||||
|
||||
class _ControlsWidgetState extends State<ControlsWidget> {
|
||||
CameraPosition position = CameraPosition.front;
|
||||
|
||||
List<MediaDevice>? _audioInputs;
|
||||
List<MediaDevice>? _audioOutputs;
|
||||
List<MediaDevice>? _videoInputs;
|
||||
|
||||
StreamSubscription? _subscription;
|
||||
|
||||
bool _speakerphoneOn = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
participant.addListener(onChange);
|
||||
_subscription = Hardware.instance.onDeviceChange.stream
|
||||
.listen((List<MediaDevice> devices) {
|
||||
revertDevices(devices);
|
||||
});
|
||||
Hardware.instance.enumerateDevices().then(revertDevices);
|
||||
_speakerphoneOn = Hardware.instance.speakerOn ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
participant.removeListener(onChange);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
LocalParticipant get participant => widget.participant;
|
||||
|
||||
void revertDevices(List<MediaDevice> devices) async {
|
||||
_audioInputs = devices.where((d) => d.kind == 'audioinput').toList();
|
||||
_audioOutputs = devices.where((d) => d.kind == 'audiooutput').toList();
|
||||
_videoInputs = devices.where((d) => d.kind == 'videoinput').toList();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void onChange() => setState(() {});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
void enableAudio() async {
|
||||
await participant.setMicrophoneEnabled(true);
|
||||
}
|
||||
|
||||
void disableVideo() async {
|
||||
await participant.setCameraEnabled(false);
|
||||
}
|
||||
|
||||
void enableVideo() async {
|
||||
await participant.setCameraEnabled(true);
|
||||
}
|
||||
|
||||
void selectAudioOutput(MediaDevice device) async {
|
||||
await widget.room.setAudioOutputDevice(device);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void selectAudioInput(MediaDevice device) async {
|
||||
await widget.room.setAudioInputDevice(device);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void selectVideoInput(MediaDevice device) async {
|
||||
await widget.room.setVideoInputDevice(device);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void setSpeakerphoneOn() {
|
||||
_speakerphoneOn = !_speakerphoneOn;
|
||||
Hardware.instance.setSpeakerphoneOn(_speakerphoneOn);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void toggleCamera() async {
|
||||
final track = participant.videoTrackPublications.firstOrNull?.track;
|
||||
if (track == null) return;
|
||||
|
||||
try {
|
||||
final newPosition = position.switched();
|
||||
await track.setCameraPosition(newPosition);
|
||||
setState(() {
|
||||
position = newPosition;
|
||||
});
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void enableScreenShare() async {
|
||||
if (lkPlatformIsDesktop()) {
|
||||
try {
|
||||
final source = await showDialog<DesktopCapturerSource>(
|
||||
context: context,
|
||||
builder: (context) => ScreenSelectDialog(),
|
||||
);
|
||||
if (source == null) {
|
||||
return;
|
||||
}
|
||||
var track = await LocalVideoTrack.createScreenShareTrack(
|
||||
ScreenShareCaptureOptions(
|
||||
sourceId: source.id,
|
||||
maxFrameRate: 15.0,
|
||||
),
|
||||
);
|
||||
await participant.publishVideoTrack(track);
|
||||
} catch (e) {
|
||||
final message = e.toString();
|
||||
context.showErrorDialog(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (lkPlatformIs(PlatformType.android)) {
|
||||
requestBackgroundPermission([bool isRetry = false]) async {
|
||||
try {
|
||||
bool hasPermissions = await FlutterBackground.hasPermissions;
|
||||
if (!isRetry) {
|
||||
const androidConfig = FlutterBackgroundAndroidConfig(
|
||||
notificationTitle: 'Screen Sharing',
|
||||
notificationText:
|
||||
'A Solar Messager\'s Call is sharing your screen',
|
||||
notificationImportance: AndroidNotificationImportance.Default,
|
||||
notificationIcon:
|
||||
AndroidResource(name: 'launcher_icon', defType: 'mipmap'),
|
||||
);
|
||||
hasPermissions = await FlutterBackground.initialize(
|
||||
androidConfig: androidConfig);
|
||||
}
|
||||
if (hasPermissions &&
|
||||
!FlutterBackground.isBackgroundExecutionEnabled) {
|
||||
await FlutterBackground.enableBackgroundExecution();
|
||||
}
|
||||
} catch (e) {
|
||||
if (!isRetry) {
|
||||
return await Future<void>.delayed(const Duration(seconds: 1),
|
||||
() => requestBackgroundPermission(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await requestBackgroundPermission();
|
||||
}
|
||||
if (lkPlatformIs(PlatformType.iOS)) {
|
||||
var track = await LocalVideoTrack.createScreenShareTrack(
|
||||
const ScreenShareCaptureOptions(
|
||||
useiOSBroadcastExtension: true,
|
||||
maxFrameRate: 30.0,
|
||||
),
|
||||
);
|
||||
await participant.publishVideoTrack(track);
|
||||
return;
|
||||
}
|
||||
|
||||
if (lkPlatformIsWebMobile()) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('Screen share is not supported mobile platform.'),
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
await participant.setScreenShareEnabled(true, captureScreenAudio: true);
|
||||
}
|
||||
|
||||
void disableScreenShare() async {
|
||||
await participant.setScreenShareEnabled(false);
|
||||
if (lkPlatformIs(PlatformType.android)) {
|
||||
// Android specific
|
||||
try {
|
||||
await FlutterBackground.disableBackgroundExecution();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 15,
|
||||
horizontal: 15,
|
||||
),
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 5,
|
||||
runSpacing: 5,
|
||||
children: [
|
||||
IconButton(
|
||||
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),
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
tooltip: AppLocalizations.of(context)!.chatCallMute,
|
||||
)
|
||||
else
|
||||
PopupMenuButton<MediaDevice>(
|
||||
icon: const Icon(Icons.settings_voice),
|
||||
itemBuilder: (BuildContext context) {
|
||||
return [
|
||||
PopupMenuItem<MediaDevice>(
|
||||
value: null,
|
||||
onTap: isMuted ? enableAudio : disableAudio,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.mic_off),
|
||||
title: Text(AppLocalizations.of(context)!.chatCallMute),
|
||||
),
|
||||
),
|
||||
if (_audioInputs != null)
|
||||
..._audioInputs!.map((device) {
|
||||
return PopupMenuItem<MediaDevice>(
|
||||
value: device,
|
||||
child: ListTile(
|
||||
leading: (device.deviceId ==
|
||||
widget.room.selectedAudioInputDeviceId)
|
||||
? const Icon(Icons.check_box_outlined)
|
||||
: const Icon(Icons.check_box_outline_blank),
|
||||
title: Text(device.label),
|
||||
),
|
||||
onTap: () => selectAudioInput(device),
|
||||
);
|
||||
})
|
||||
];
|
||||
},
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
onPressed: enableAudio,
|
||||
icon: const Icon(Icons.mic_off),
|
||||
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),
|
||||
itemBuilder: (BuildContext context) {
|
||||
return [
|
||||
const PopupMenuItem<MediaDevice>(
|
||||
value: null,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.speaker),
|
||||
title: Text('Select Audio Output'),
|
||||
),
|
||||
),
|
||||
if (_audioOutputs != null)
|
||||
..._audioOutputs!.map((device) {
|
||||
return PopupMenuItem<MediaDevice>(
|
||||
value: device,
|
||||
child: ListTile(
|
||||
leading: (device.deviceId ==
|
||||
widget.room.selectedAudioOutputDeviceId)
|
||||
? const Icon(Icons.check_box_outlined)
|
||||
: const Icon(Icons.check_box_outline_blank),
|
||||
title: Text(device.label),
|
||||
),
|
||||
onTap: () => selectAudioOutput(device),
|
||||
);
|
||||
})
|
||||
];
|
||||
},
|
||||
),
|
||||
if (!kIsWeb && lkPlatformIs(PlatformType.iOS))
|
||||
IconButton(
|
||||
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.isScreenShareEnabled())
|
||||
IconButton(
|
||||
icon: const Icon(Icons.monitor_outlined),
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
onPressed: () => disableScreenShare(),
|
||||
tooltip: AppLocalizations.of(context)!.chatCallScreenOff,
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.monitor),
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
onPressed: () => enableScreenShare(),
|
||||
tooltip: AppLocalizations.of(context)!.chatCallScreenOn,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
63
lib/widgets/chat/call/call_overlay.dart
Normal file
63
lib/widgets/chat/call/call_overlay.dart
Normal 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},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
74
lib/widgets/chat/call/exts.dart
Normal file
74
lib/widgets/chat/call/exts.dart
Normal file
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
extension SolianCallExt on BuildContext {
|
||||
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?> 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
197
lib/widgets/chat/call/participant.dart
Normal file
197
lib/widgets/chat/call/participant.dart
Normal file
@@ -0,0 +1,197 @@
|
||||
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/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 isFixed = false, bool showStatsLayer = false}) {
|
||||
if (participantTrack.participant is LocalParticipant) {
|
||||
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,
|
||||
isFixed,
|
||||
participantTrack.isScreenShare,
|
||||
showStatsLayer,
|
||||
);
|
||||
}
|
||||
throw UnimplementedError('Unknown participant type');
|
||||
}
|
||||
|
||||
abstract final Participant participant;
|
||||
abstract final VideoTrack? videoTrack;
|
||||
abstract final bool isScreenShare;
|
||||
abstract final bool isFixed;
|
||||
abstract final bool showStatsLayer;
|
||||
final VideoQuality quality;
|
||||
|
||||
const ParticipantWidget({
|
||||
super.key,
|
||||
this.quality = VideoQuality.MEDIUM,
|
||||
});
|
||||
}
|
||||
|
||||
class LocalParticipantWidget extends ParticipantWidget {
|
||||
@override
|
||||
final LocalParticipant participant;
|
||||
@override
|
||||
final VideoTrack? videoTrack;
|
||||
@override
|
||||
final bool isFixed;
|
||||
@override
|
||||
final bool isScreenShare;
|
||||
@override
|
||||
final bool showStatsLayer;
|
||||
|
||||
const LocalParticipantWidget(
|
||||
this.participant,
|
||||
this.videoTrack,
|
||||
this.isFixed,
|
||||
this.isScreenShare,
|
||||
this.showStatsLayer, {
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _LocalParticipantWidgetState();
|
||||
}
|
||||
|
||||
class RemoteParticipantWidget extends ParticipantWidget {
|
||||
@override
|
||||
final RemoteParticipant participant;
|
||||
@override
|
||||
final VideoTrack? videoTrack;
|
||||
@override
|
||||
final bool isFixed;
|
||||
@override
|
||||
final bool isScreenShare;
|
||||
@override
|
||||
final bool showStatsLayer;
|
||||
|
||||
const RemoteParticipantWidget(
|
||||
this.participant,
|
||||
this.videoTrack,
|
||||
this.isFixed,
|
||||
this.isScreenShare,
|
||||
this.showStatsLayer, {
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _RemoteParticipantWidgetState();
|
||||
}
|
||||
|
||||
abstract class _ParticipantWidgetState<T extends ParticipantWidget>
|
||||
extends State<T> {
|
||||
VideoTrack? get _activeVideoTrack;
|
||||
|
||||
TrackPublication? get _firstAudioPublication;
|
||||
|
||||
Account? _userinfoMetadata;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.participant.addListener(onParticipantChanged);
|
||||
onParticipantChanged();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.participant.removeListener(onParticipantChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant T oldWidget) {
|
||||
oldWidget.participant.removeListener(onParticipantChanged);
|
||||
widget.participant.addListener(onParticipantChanged);
|
||||
onParticipantChanged();
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
void onParticipantChanged() {
|
||||
setState(() {
|
||||
if (widget.participant.metadata != null) {
|
||||
_userinfoMetadata =
|
||||
Account.fromJson(jsonDecode(widget.participant.metadata!));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
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<LocalAudioTrack>? get _firstAudioPublication =>
|
||||
widget.participant.audioTrackPublications.firstOrNull;
|
||||
|
||||
@override
|
||||
VideoTrack? get _activeVideoTrack => widget.videoTrack;
|
||||
}
|
||||
|
||||
class _RemoteParticipantWidgetState
|
||||
extends _ParticipantWidgetState<RemoteParticipantWidget> {
|
||||
@override
|
||||
RemoteTrackPublication<RemoteAudioTrack>? get _firstAudioPublication =>
|
||||
widget.participant.audioTrackPublications.firstOrNull;
|
||||
|
||||
@override
|
||||
VideoTrack? get _activeVideoTrack => widget.videoTrack;
|
||||
}
|
72
lib/widgets/chat/call/participant_info.dart
Normal file
72
lib/widgets/chat/call/participant_info.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
|
||||
class ParticipantInfoWidget extends StatelessWidget {
|
||||
final String? title;
|
||||
final bool audioAvailable;
|
||||
final ConnectionQuality connectionQuality;
|
||||
final bool isScreenShare;
|
||||
|
||||
const ParticipantInfoWidget({
|
||||
super.key,
|
||||
this.title,
|
||||
this.audioAvailable = true,
|
||||
this.connectionQuality = ConnectionQuality.unknown,
|
||||
this.isScreenShare = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 7,
|
||||
horizontal: 10,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (title != null)
|
||||
Flexible(
|
||||
child: Text(
|
||||
title!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
isScreenShare
|
||||
? const Padding(
|
||||
padding: EdgeInsets.only(left: 5),
|
||||
child: Icon(
|
||||
Icons.monitor,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(left: 5),
|
||||
child: Icon(
|
||||
audioAvailable ? Icons.mic : Icons.mic_off,
|
||||
color: audioAvailable ? Colors.white : Colors.red,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
if (connectionQuality != ConnectionQuality.unknown)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 5),
|
||||
child: Icon(
|
||||
connectionQuality == ConnectionQuality.poor
|
||||
? Icons.wifi_off_outlined
|
||||
: Icons.wifi,
|
||||
color: {
|
||||
ConnectionQuality.excellent: Colors.green,
|
||||
ConnectionQuality.good: Colors.orange,
|
||||
ConnectionQuality.poor: Colors.red,
|
||||
}[connectionQuality],
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
160
lib/widgets/chat/call/participant_menu.dart
Normal file
160
lib/widgets/chat/call/participant_menu.dart
Normal 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();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
93
lib/widgets/chat/call/participant_no_content.dart
Normal file
93
lib/widgets/chat/call/participant_no_content.dart
Normal 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();
|
||||
}
|
||||
}
|
133
lib/widgets/chat/call/participant_stats.dart
Normal file
133
lib/widgets/chat/call/participant_stats.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:solian/models/call.dart';
|
||||
|
||||
class ParticipantStatsWidget extends StatefulWidget {
|
||||
const ParticipantStatsWidget({super.key, required this.participant});
|
||||
|
||||
final Participant participant;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _ParticipantStatsWidgetState();
|
||||
}
|
||||
|
||||
class _ParticipantStatsWidgetState extends State<ParticipantStatsWidget> {
|
||||
List<EventsListener<TrackEvent>> listeners = [];
|
||||
ParticipantStatsType statsType = ParticipantStatsType.unknown;
|
||||
Map<String, String> stats = {};
|
||||
|
||||
void _setUpListener(Track track) {
|
||||
var listener = track.createListener();
|
||||
listeners.add(listener);
|
||||
if (track is LocalVideoTrack) {
|
||||
statsType = ParticipantStatsType.localVideoSender;
|
||||
listener.on<VideoSenderStatsEvent>((event) {
|
||||
setState(() {
|
||||
stats['video tx'] = 'total sent ${event.currentBitrate.toInt()} kpbs';
|
||||
event.stats.forEach((key, value) {
|
||||
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'];
|
||||
if (firstStats != null) {
|
||||
stats['encoder'] = firstStats.encoderImplementation ?? '';
|
||||
stats['video codec'] =
|
||||
'${firstStats.mimeType}, ${firstStats.clockRate}hz, pt: ${firstStats.payloadType}';
|
||||
stats['qualityLimitationReason'] =
|
||||
firstStats.qualityLimitationReason ?? '';
|
||||
}
|
||||
});
|
||||
});
|
||||
} else if (track is RemoteVideoTrack) {
|
||||
statsType = ParticipantStatsType.remoteVideoReceiver;
|
||||
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 size'] =
|
||||
'${event.stats.frameWidth}x${event.stats.frameHeight} ${event.stats.framesPerSecond?.toDouble()}fps';
|
||||
stats['video jitter'] = '${event.stats.jitter} s';
|
||||
stats['video decoder'] = '${event.stats.decoderImplementation}';
|
||||
stats['video packets lost'] = '${event.stats.packetsLost}';
|
||||
stats['video packets received'] = '${event.stats.packetsReceived}';
|
||||
stats['video frames received'] = '${event.stats.framesReceived}';
|
||||
stats['video frames decoded'] = '${event.stats.framesDecoded}';
|
||||
stats['video frames dropped'] = '${event.stats.framesDropped}';
|
||||
});
|
||||
});
|
||||
} else if (track is LocalAudioTrack) {
|
||||
statsType = ParticipantStatsType.localAudioSender;
|
||||
listener.on<AudioSenderStatsEvent>((event) {
|
||||
setState(() {
|
||||
stats['audio tx'] = '${event.currentBitrate.toInt()} kpbs';
|
||||
stats['audio codec'] =
|
||||
'${event.stats.mimeType}, ${event.stats.clockRate}hz, ${event.stats.channels}ch, pt: ${event.stats.payloadType}';
|
||||
});
|
||||
});
|
||||
} else if (track is RemoteAudioTrack) {
|
||||
statsType = ParticipantStatsType.remoteAudioReceiver;
|
||||
listener.on<AudioReceiverStatsEvent>((event) {
|
||||
setState(() {
|
||||
stats['audio rx'] = '${event.currentBitrate.toInt()} kpbs';
|
||||
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 packets lost'] = '${event.stats.packetsLost}';
|
||||
stats['audio packets received'] = '${event.stats.packetsReceived}';
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onParticipantChanged() {
|
||||
for (var element in listeners) {
|
||||
element.dispose();
|
||||
}
|
||||
listeners.clear();
|
||||
for (var track in [
|
||||
...widget.participant.videoTrackPublications,
|
||||
...widget.participant.audioTrackPublications
|
||||
]) {
|
||||
if (track.track != null) {
|
||||
_setUpListener(track.track!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.participant.addListener(onParticipantChanged);
|
||||
onParticipantChanged();
|
||||
}
|
||||
|
||||
@override
|
||||
void deactivate() {
|
||||
for (var element in listeners) {
|
||||
element.dispose();
|
||||
}
|
||||
widget.participant.removeListener(onParticipantChanged);
|
||||
super.deactivate();
|
||||
}
|
||||
|
||||
num sendBitrate = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,53 +1,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:solian/models/channel.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
class ChannelAction extends StatelessWidget {
|
||||
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/providers/auth.dart';
|
||||
import 'package:solian/providers/chat.dart';
|
||||
import 'package:solian/router.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;
|
||||
|
||||
ChannelAction({super.key, required this.channel, required this.onUpdate});
|
||||
const ChannelCallAction({
|
||||
super.key,
|
||||
this.call,
|
||||
required this.channel,
|
||||
required this.onUpdate,
|
||||
this.realm = 'global',
|
||||
});
|
||||
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
@override
|
||||
State<ChannelCallAction> createState() => _ChannelCallActionState();
|
||||
}
|
||||
|
||||
class _ChannelCallActionState extends State<ChannelCallAction> {
|
||||
bool _isSubmitting = false;
|
||||
|
||||
Future<void> makeCall() async {
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
final auth = context.read<AuthProvider>();
|
||||
if (!await auth.isAuthorized()) {
|
||||
setState(() => _isSubmitting = false);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
context.showErrorDialog(message);
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
|
||||
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.realm}/${widget.channel.alias}/calls/ongoing');
|
||||
|
||||
var res = await auth.client!.delete(uri);
|
||||
if (res.statusCode != 200) {
|
||||
var message = utf8.decode(res.bodyBytes);
|
||||
context.showErrorDialog(message);
|
||||
} else {
|
||||
if (chat.currentCall != null && chat.currentCall?.info.channelId == widget.channel.id) {
|
||||
chat.currentCall!.deactivate();
|
||||
chat.currentCall!.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
MenuAnchor(
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.settings),
|
||||
const SizedBox(width: 12),
|
||||
Text(AppLocalizations.of(context)!.settings),
|
||||
],
|
||||
),
|
||||
onPressed: () {
|
||||
router.pushNamed('chat.channel.editor', extra: channel).then((did) {
|
||||
if(did == true) onUpdate();
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
builder: (BuildContext context, MenuController controller, Widget? child) {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
if (controller.isOpen) {
|
||||
controller.close();
|
||||
} else {
|
||||
controller.open();
|
||||
}
|
||||
},
|
||||
focusNode: _focusNode,
|
||||
style: TextButton.styleFrom(shape: const CircleBorder()),
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
return IconButton(
|
||||
onPressed: _isSubmitting
|
||||
? null
|
||||
: () {
|
||||
if (widget.call == null) {
|
||||
makeCall();
|
||||
} else {
|
||||
endsCall();
|
||||
}
|
||||
},
|
||||
icon: widget.call == null ? const Icon(Icons.call) : const Icon(Icons.call_end),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChannelManageAction extends StatelessWidget {
|
||||
final Channel channel;
|
||||
final Function onUpdate;
|
||||
final String realm;
|
||||
|
||||
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 SolianRouter.router.pushNamed(
|
||||
realm == 'global' ? 'chat.channel.manage' : 'realms.chat.channel.manage',
|
||||
extra: channel,
|
||||
pathParameters: {
|
||||
'channel': channel.alias,
|
||||
...(realm == 'global' ? {} : {'realm': realm}),
|
||||
},
|
||||
);
|
||||
switch (result) {
|
||||
case 'disposed':
|
||||
if (SolianRouter.router.canPop()) SolianRouter.router.pop('refresh');
|
||||
case 'refresh':
|
||||
onUpdate();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.settings),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
103
lib/widgets/chat/channel_deletion.dart
Normal file
103
lib/widgets/chat/channel_deletion.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
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/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.realm,
|
||||
required this.isOwned,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChannelDeletion> createState() => _ChannelDeletionState();
|
||||
}
|
||||
|
||||
class _ChannelDeletionState extends State<ChannelDeletion> {
|
||||
bool _isSubmitting = false;
|
||||
|
||||
Future<void> deleteChannel() async {
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
final auth = context.read<AuthProvider>();
|
||||
if (!await auth.isAuthorized()) {
|
||||
setState(() => _isSubmitting = false);
|
||||
return;
|
||||
}
|
||||
|
||||
var res = await auth.client!.delete(
|
||||
getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel.id}'),
|
||||
);
|
||||
if (res.statusCode != 200) {
|
||||
var message = utf8.decode(res.bodyBytes);
|
||||
context.showErrorDialog(message);
|
||||
} else if (Navigator.canPop(context)) {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
|
||||
Future<void> leaveChannel() async {
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
final auth = context.read<AuthProvider>();
|
||||
if (!await auth.isAuthorized()) {
|
||||
setState(() => _isSubmitting = false);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
context.showErrorDialog(message);
|
||||
} else if (Navigator.canPop(context)) {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final content = widget.isOwned
|
||||
? AppLocalizations.of(context)!.chatChannelDeleteConfirm
|
||||
: AppLocalizations.of(context)!.chatChannelLeaveConfirm;
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.confirmation),
|
||||
content: Text(content),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: _isSubmitting ? null : () => Navigator.pop(context),
|
||||
child: Text(AppLocalizations.of(context)!.confirmCancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _isSubmitting
|
||||
? null
|
||||
: () {
|
||||
if (widget.isOwned) {
|
||||
deleteChannel();
|
||||
} else {
|
||||
leaveChannel();
|
||||
}
|
||||
},
|
||||
child: Text(AppLocalizations.of(context)!.confirmOkay),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@@ -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 ChannelEditor extends StatefulWidget {
|
||||
final Channel? editing;
|
||||
|
||||
const ChannelEditor({super.key, this.editing});
|
||||
|
||||
@override
|
||||
State<ChannelEditor> createState() => _ChannelEditorState();
|
||||
}
|
||||
|
||||
class _ChannelEditorState extends State<ChannelEditor> {
|
||||
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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@@ -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)) {
|
||||
|
@@ -1,77 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.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 Function(Message val) onInsertMessage;
|
||||
final Function(Message val) onUpdateMessage;
|
||||
final Function(Message val) onDeleteMessage;
|
||||
|
||||
const ChatMaintainer({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.onInsertMessage,
|
||||
required this.onUpdateMessage,
|
||||
required this.onDeleteMessage,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatMaintainer> createState() => _ChatMaintainerState();
|
||||
}
|
||||
|
||||
class _ChatMaintainerState extends State<ChatMaintainer> {
|
||||
void connect() {
|
||||
final notify = ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.connectingServer),
|
||||
duration: const Duration(days: 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':
|
||||
widget.onInsertMessage(Message.fromJson(result.payload!));
|
||||
break;
|
||||
case 'messages.update':
|
||||
widget.onUpdateMessage(Message.fromJson(result.payload!));
|
||||
break;
|
||||
case 'messages.burnt':
|
||||
widget.onDeleteMessage(Message.fromJson(result.payload!));
|
||||
break;
|
||||
}
|
||||
},
|
||||
onError: (_, __) => connect(),
|
||||
);
|
||||
|
||||
notify.close();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
Future.delayed(Duration.zero, () {
|
||||
connect();
|
||||
});
|
||||
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.child;
|
||||
}
|
||||
}
|
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:solian/models/message.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;
|
||||
@@ -38,9 +39,10 @@ class ChatMessage extends StatelessWidget {
|
||||
child: const Icon(Icons.reply, size: 16),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
CircleAvatar(
|
||||
AccountAvatar(
|
||||
radius: 10,
|
||||
backgroundImage: NetworkImage(item.replyTo!.sender.account.avatar),
|
||||
source: item.replyTo!.sender.account.avatar,
|
||||
direct: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -102,8 +104,9 @@ class ChatMessage extends StatelessWidget {
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundImage: NetworkImage(item.sender.account.avatar),
|
||||
AccountAvatar(
|
||||
source: item.sender.account.avatar,
|
||||
direct: true,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
|
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
110
lib/widgets/chat/message_content.dart
Normal file
110
lib/widgets/chat/message_content.dart
Normal 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();
|
||||
}
|
||||
}
|
@@ -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);
|
||||
|
@@ -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);
|
||||
|
||||
@@ -36,40 +53,64 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
|
||||
builder: (context) => AttachmentEditor(
|
||||
provider: 'messaging',
|
||||
current: _attachments,
|
||||
onUpdate: (value) => _attachments = value,
|
||||
onUpdate: (value) => setState(() => _attachments = value),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
],
|
||||
|
@@ -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
25
lib/widgets/empty.dart
Normal 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
30
lib/widgets/exts.dart
Normal 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),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
27
lib/widgets/layouts/two_column.dart
Normal file
27
lib/widgets/layouts/two_column.dart
Normal 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()),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@@ -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),
|
||||
),
|
||||
],
|
||||
|
29
lib/widgets/notification_notifier.dart
Normal file
29
lib/widgets/notification_notifier.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/providers/notify.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:badges/badges.dart' as badge;
|
||||
|
||||
class NotificationButton extends StatelessWidget {
|
||||
const NotificationButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final nty = context.watch<NotifyProvider>();
|
||||
|
||||
return badge.Badge(
|
||||
showBadge: nty.unreadAmount > 0,
|
||||
position: badge.BadgePosition.custom(top: -2, end: 8),
|
||||
badgeContent: Text(
|
||||
nty.unreadAmount.toString(),
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.notifications),
|
||||
onPressed: () {
|
||||
SolianRouter.router.pushNamed('notification');
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@@ -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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -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),
|
||||
],
|
||||
|
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
|
@@ -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();
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user