Compare commits

...

60 Commits

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

View File

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

View File

@@ -23,7 +23,7 @@ if (flutterVersionName == null) {
} }
android { android {
namespace "com.example.solian" namespace "dev.solsynth.solian"
compileSdk flutter.compileSdkVersion compileSdk flutter.compileSdkVersion
ndkVersion flutter.ndkVersion ndkVersion flutter.ndkVersion
@@ -41,11 +41,8 @@ android {
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "dev.solsynth.solian"
applicationId "com.example.solian" minSdkVersion 21
// 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
targetSdkVersion flutter.targetSdkVersion targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
@@ -53,9 +50,11 @@ android {
buildTypes { buildTypes {
release { 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 signingConfig signingConfigs.debug
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
} }
} }
} }

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

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

View File

@@ -1,11 +1,42 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <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 <application
android:label="Solian" android:label="Solian"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/launcher_icon"> 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 <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@@ -1,4 +1,4 @@
package com.example.solian package dev.solsynth.solian
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@@ -20,7 +20,7 @@ pluginManagement {
plugins { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "7.3.0" apply false 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" include ":app"

View File

@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project # 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. # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

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

View File

@@ -199,6 +199,7 @@
9705A1C41CF9048500538489 /* Embed Frameworks */, 9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
FBE5FC129D166C3891201349 /* [CP] Embed Pods Frameworks */, FBE5FC129D166C3891201349 /* [CP] Embed Pods Frameworks */,
BAE67882F3DAA8740C7E8FC1 /* [CP] Copy Pods Resources */,
); );
buildRules = ( buildRules = (
); );
@@ -345,6 +346,23 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
}; };
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 */ = { FBE5FC129D166C3891201349 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -496,7 +514,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.solian.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -514,7 +532,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.solian.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -530,7 +548,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.solian.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";

View File

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

View File

@@ -58,5 +58,9 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
</dict> </dict>
</plist> </plist>

View File

@@ -1,7 +1,8 @@
{ {
"solian": "Solian", "appName": "Solar Network",
"explore": "Explore", "explore": "Explore",
"chat": "Chat", "chat": "Chat",
"realm": "Realm",
"account": "Account", "account": "Account",
"riskDetection": "Risk Detection", "riskDetection": "Risk Detection",
"signIn": "Sign In", "signIn": "Sign In",
@@ -9,29 +10,44 @@
"signInRequired": "Sign in required", "signInRequired": "Sign in required",
"signInRiskDetected": "Risk detected, click Next to open a webpage and signin through it to pass security check.", "signInRiskDetected": "Risk detected, click Next to open a webpage and signin through it to pass security check.",
"signUp": "Sign Up", "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", "signOut": "Sign Out",
"poweredBy": "Powered by Project Hydrogen", "poweredBy": "Powered by Project Hydrogen",
"copyright": "Copyright © 2024 Solsynth LLC", "copyright": "Copyright © 2024 Solsynth LLC",
"confirmation": "Confirmation", "confirmation": "Confirmation",
"confirmCancel": "Not sure", "confirmCancel": "Not sure",
"confirmOkay": "OK", "confirmOkay": "OK",
"email": "Email Address",
"nickname": "Nickname",
"username": "Username", "username": "Username",
"firstName": "First Name",
"lastName": "Last Name",
"description": "Description",
"birthday": "Birthday",
"password": "Password", "password": "Password",
"next": "Next", "next": "Next",
"join": "Join",
"edit": "Edit", "edit": "Edit",
"apply": "Apply", "apply": "Apply",
"delete": "Delete", "delete": "Delete",
"exit": "Exit", "exit": "Exit",
"action": "Action", "action": "Action",
"reset": "Reset",
"cancel": "Cancel", "cancel": "Cancel",
"report": "Report", "report": "Report",
"reply": "Reply", "reply": "Reply",
"export": "Export",
"import": "Import",
"settings": "Settings", "settings": "Settings",
"errorHappened": "An Error Occurred",
"notification": "Notification", "notification": "Notification",
"notifyDone": "You're done!", "notifyDone": "You're done!",
"notifyDoneCaption": "There are no notifications unread for you.", "notifyDoneCaption": "There are no notifications unread for you.",
"notifyListHint": "Pull to refresh, swipe to dismiss", "notifyListHint": "Pull to refresh, swipe to dismiss",
"notifyMarkAllRead": "Mark all as read",
"notifyMarkAllReadDone": "Marked all notifications as read",
"friend": "Friend", "friend": "Friend",
"friendPending": "Pending", "friendPending": "Pending",
"friendActive": "Active", "friendActive": "Active",
@@ -40,6 +56,13 @@
"friendAdd": "Add friend", "friendAdd": "Add friend",
"friendAddHint": "Use your their username to send a friend request to your best friend!", "friendAddHint": "Use your their username to send a friend request to your best friend!",
"friendAddDone": "Friend request sent, go reach your 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", "reaction": "Reaction",
"reactVerb": "React", "reactVerb": "React",
"post": "Post", "post": "Post",
@@ -47,10 +70,10 @@
"comment": "Comment", "comment": "Comment",
"attachment": "Attachment", "attachment": "Attachment",
"attachmentAdd": "Add new attachment", "attachmentAdd": "Add new attachment",
"pickPhoto": "Gallery photo", "pickMedia": "Gallery media",
"pickFile": "Device file",
"takePhoto": "Capture photo", "takePhoto": "Capture photo",
"pickVideo": "Gallery video", "takeVideo": "Capture video",
"takeVideo": "Record video",
"newMoment": "Record a moment", "newMoment": "Record a moment",
"newComment": "Leave a comment", "newComment": "Leave a comment",
"connectingServer": "Connecting to server...", "connectingServer": "Connecting to server...",
@@ -60,12 +83,30 @@
"postEditNotify": "You are about editing a post that already published.", "postEditNotify": "You are about editing a post that already published.",
"reactionAdded": "Your reaction has been added.", "reactionAdded": "Your reaction has been added.",
"reactionRemoved": "Your reaction has been removed.", "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", "chatNew": "New Chat",
"chatNewCreate": "Create a channel", "chatNewCreate": "Create a channel",
"chatNewJoin": "Join a exists channel", "chatNewJoin": "Join a exists channel",
"chatManage": "Manage Chat", "chatDetail": "Chat Details",
"chatMember": "Member", "chatMember": "Member",
"chatNotifySetting": "Notify Settings", "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", "chatChannelUsage": "Channel",
"chatChannelUsageCaption": "Channel is place to talk with people, one or a lot.", "chatChannelUsageCaption": "Channel is place to talk with people, one or a lot.",
"chatChannelOrganize": "Organize a channel", "chatChannelOrganize": "Organize a channel",
@@ -73,10 +114,31 @@
"chatChannelAliasLabel": "Channel Alias", "chatChannelAliasLabel": "Channel Alias",
"chatChannelNameLabel": "Channel Name", "chatChannelNameLabel": "Channel Name",
"chatChannelDescriptionLabel": "Channel Description", "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.", "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!", "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...", "chatMessagePlaceholder": "Write a message...",
"chatMessageEncryptedPlaceholder": "Write a encrypted message...",
"chatMessageSending": "Now delivering your messages...",
"chatMessageEditNotify": "You are about editing a message.", "chatMessageEditNotify": "You are about editing a message.",
"chatMessageReplyNotify": "You are about replying a message.", "chatMessageReplyNotify": "You are about replying a message.",
"chatMessageDeleteConfirm": "Are you sure you want to delete this message? This operation cannot be revert and no local history is saved!" "chatMessageDeleteConfirm": "Are you sure you want to delete this message? This operation cannot be revert and no local history is saved!",
"chatMessageViewSource": "View source",
"chatMessageUnableDecryptWaiting": "Waiting for encryption key...",
"chatMessageUnableDecryptUnsupported": "Unable to decrypt the message, encryption algorithm unsupported",
"chatMessageUnableDecryptMissing": "Unable to decrypt the message, missing encryption key"
} }

View File

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

View File

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

View File

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

View File

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

69
lib/models/call.dart Normal file
View 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;
}

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

@@ -1,17 +1,56 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; 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/models/pagination.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart'; import 'package:solian/utils/services_url.dart';
import 'package:solian/models/notification.dart' as model; import 'package:solian/models/notification.dart' as model;
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/status.dart' as status;
import 'dart:math' as math;
class NotifyProvider extends ChangeNotifier { class NotifyProvider extends ChangeNotifier {
bool isOpened = false; int unreadAmount = 0;
List<model.Notification> notifications = List.empty(growable: true); 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 { Future<void> fetch(AuthProvider auth) async {
if (!await auth.isAuthorized()) return; if (!await auth.isAuthorized()) return;
@@ -25,29 +64,118 @@ class NotifyProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<WebSocketChannel?> connect(AuthProvider auth) async { IOWebSocketChannel? _channel;
if (auth.client == null) await auth.pickClient();
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 (!await auth.isAuthorized()) return null;
await auth.refreshToken(); if (_channel != null && (_channel!.innerWebSocket?.readyState ?? 0) < 2) {
return _channel;
var ori = getRequestUri('passport', '/api/notifications/listen'); }
var ori = getRequestUri('passport', '/api/ws');
var uri = Uri( var uri = Uri(
scheme: ori.scheme.replaceFirst('http', 'ws'), scheme: ori.scheme.replaceFirst('http', 'ws'),
host: ori.host, host: ori.host,
port: ori.port,
path: ori.path, path: ori.path,
queryParameters: {'tk': Uri.encodeComponent(auth.client!.credentials.accessToken)}, queryParameters: {'tk': Uri.encodeComponent(auth.client!.currentToken!)},
); );
final channel = WebSocketChannel.connect(uri); try {
await channel.ready; _channel = IOWebSocketChannel.connect(uri);
if (onStateUpdated != null) onStateUpdated(true);
await _channel!.ready;
if (onStateUpdated != null) onStateUpdated(false);
} catch (e) {
if (!noRetry) {
await auth.client!.refreshToken(auth.client!.currentRefreshToken!);
connect(auth, noRetry: true);
} else {
rethrow;
}
}
return channel; _channel!.stream.listen(
(event) {
final result = NetworkPackage.fromJson(jsonDecode(event));
switch (result.method) {
case 'notifications.new':
if (result.payload == null) break;
final notification = model.Notification.fromJson(result.payload!);
unreadAmount++;
notifications.add(notification);
notifyListeners();
notifyMessage(notification.subject, notification.content);
break;
case 'kex.request':
if (onKexRequest == null || result.payload == null) break;
final resp = onKexRequest(result.payload!['keypair_id']);
if (resp == null) break;
_channel!.sink.add(jsonEncode(
NetworkPackage(method: 'kex.provide', payload: {
'request_id': result.payload!['request_id'],
'keypair_id': resp.id,
'public_key': resp.publicKey,
'algorithm': resp.algorithm,
}).toJson(),
));
break;
case 'kex.provide':
if (onKexProvide == null || result.payload == null) break;
onKexProvide(Keypair(
id: result.payload!['keypair_id'],
algorithm: result.payload?['algorithm'] ?? 'aes',
publicKey: result.payload!['public_key'],
privateKey: result.payload?['private_key'],
));
break;
}
},
onError: (_, __) => Future.delayed(const Duration(seconds: 3), () => connect(auth)),
onDone: () => Future.delayed(const Duration(seconds: 1), () => connect(auth)),
);
return _channel!;
} }
void onRemoteMessage(model.Notification item) { void disconnect() {
notifications.add(item); _channel?.sink.close(status.goingAway);
notifyListeners(); }
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) { void clearAt(int index) {
@@ -55,7 +183,13 @@ class NotifyProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void clearNonRealtime() { void clearRealtimeNotifications() {
notifications = notifications.where((x) => !x.isRealtime).toList(); notifications = notifications.where((x) => !x.isRealtime).toList();
notifyListeners();
}
void allRead() {
unreadAmount = 0;
notifyListeners();
} }
} }

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

187
lib/screens/chat/call.dart Normal file
View 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,
),
);
},
);
}
}

View File

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

View File

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

View File

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

View File

@@ -1,104 +1,103 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/message.dart'; import 'package:solian/models/message.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/auth.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/channel_action.dart';
import 'package:solian/widgets/chat/maintainer.dart';
import 'package:solian/widgets/chat/message.dart'; import 'package:solian/widgets/chat/message.dart';
import 'package:solian/widgets/chat/message_action.dart'; import 'package:solian/widgets/chat/message_action.dart';
import 'package:solian/widgets/chat/message_editor.dart'; import 'package:solian/widgets/chat/message_editor.dart';
import 'package:solian/widgets/indent_wrapper.dart'; import 'package:solian/widgets/exts.dart';
import 'package:http/http.dart' as http; 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 alias;
final String realm;
const ChatScreen({super.key, required this.alias}); const ChatScreen({super.key, required this.alias, this.realm = 'global'});
@override @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> { class ChatWidget extends StatefulWidget {
Channel? _channelMeta; final String alias;
final String realm;
final PagingController<int, Message> _pagingController = PagingController(firstPageKey: 0); const ChatWidget({super.key, required this.alias, required this.realm});
final http.Client _client = http.Client(); @override
State<ChatWidget> createState() => _ChatWidgetState();
}
Future<Channel> fetchMetadata() async { class _ChatWidgetState extends State<ChatWidget> {
var uri = getRequestUri('messaging', '/api/channels/${widget.alias}'); bool _isReady = false;
var res = await _client.get(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes));
setState(() => _channelMeta = Channel.fromJson(result));
return _channelMeta!;
} else {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
throw Exception(message);
}
}
Future<void> fetchMessages(int pageKey, BuildContext context) async { late final ChatProvider _chat;
Future<void> joinChannel() async {
final auth = context.read<AuthProvider>(); final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return; if (!await auth.isAuthorized()) return;
final offset = pageKey;
const take = 10;
var uri = getRequestUri( var uri = getRequestUri(
'messaging', '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) { if (res.statusCode == 200) {
final result = PaginationResult.fromJson(jsonDecode(utf8.decode(res.bodyBytes))); setState(() {});
final items = result.data?.map((x) => Message.fromJson(x)).toList() ?? List.empty(); _chat.historyPagingController?.refresh();
final isLastPage = (result.count - pageKey) < take;
if (isLastPage || result.data == null) {
_pagingController.appendLastPage(items);
} else {
final nextPageKey = pageKey + items.length;
_pagingController.appendPage(items, nextPageKey);
}
} else { } 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) { 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 == null || b == null) return false;
if (a.senderId != b.senderId) return false; if (a.senderId != b.senderId) return false;
return a.createdAt.difference(b.createdAt).inMinutes <= 5; return a.createdAt.difference(b.createdAt).inMinutes <= 3;
}
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();
});
} }
Message? _editingItem; Message? _editingItem;
@@ -109,6 +108,7 @@ class _ChatScreenState extends State<ChatScreen> {
context: context, context: context,
builder: (context) => ChatMessageAction( builder: (context) => ChatMessageAction(
channel: widget.alias, channel: widget.alias,
realm: widget.realm,
item: item, item: item,
onEdit: () => setState(() { onEdit: () => setState(() {
_editingItem = item; _editingItem = item;
@@ -120,87 +120,151 @@ 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 @override
void initState() { void initState() {
Future.delayed(Duration.zero, () {
fetchMetadata();
});
_pagingController.addPageRequestListener((pageKey) => fetchMessages(pageKey, context));
super.initState(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IndentWrapper( Widget chatHistoryBuilder(context, item, index) {
hideDrawer: true, bool isMerged = false, hasMerged = false;
title: _channelMeta?.name ?? "Loading...", if (index > 0) {
appBarActions: [ hasMerged = getMessageMergeable(_chat.historyPagingController?.itemList?[index - 1], item);
_channelMeta != null ? ChannelAction(channel: _channelMeta!, onUpdate: () => fetchMetadata()) : Container(), }
], if (index + 1 < (_chat.historyPagingController?.itemList?.length ?? 0)) {
child: FutureBuilder( isMerged = getMessageMergeable(item, _chat.historyPagingController?.itemList?[index + 1]);
future: fetchMetadata(), }
builder: (context, snapshot) { return InkWell(
if (!snapshot.hasData || snapshot.data == null) { child: Container(
return const Center(child: CircularProgressIndicator()); padding: EdgeInsets.only(
} top: !isMerged ? 8 : 0,
bottom: !hasMerged ? 8 : 0,
return ChatMaintainer( left: 12,
channel: snapshot.data!, right: 12,
child: Column( ),
children: [ child: ChatMessage(
Expanded( item: item,
child: PagedListView<int, Message>( underMerged: isMerged,
reverse: true, ),
pagingController: _pagingController, ),
builderDelegate: PagedChildBuilderDelegate<Message>( onLongPress: () => viewActions(item),
noItemsFoundIndicatorBuilder: (_) => Container(), ).animate(key: Key('m${item.id}'), autoPlay: true).slideY(
itemBuilder: (context, item, index) { curve: Curves.fastEaseInToSlowEaseOut,
bool isMerged = false, hasMerged = false; duration: 350.ms,
if (index > 0) { begin: 0.25,
hasMerged = getMessageMergeable(_pagingController.itemList?[index - 1], item); end: 0,
}
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(
channel: widget.alias,
editing: _editingItem,
replying: _replyingItem,
onReset: () => setState(() {
_editingItem = null;
_replyingItem = null;
}),
),
],
),
onInsertMessage: (message) => addMessage(message),
onUpdateMessage: (message) => updateMessage(message),
onDeleteMessage: (message) => deleteMessage(message),
); );
}, }
),
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},
);
},
),
],
);
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: _chat.historyPagingController!,
builderDelegate: PagedChildBuilderDelegate<Message>(
animateTransitions: true,
transitionDuration: 350.ms,
itemBuilder: chatHistoryBuilder,
noItemsFoundIndicatorBuilder: (_) => Container(),
),
),
),
ChatMessageEditor(
realm: widget.realm,
channel: widget.alias,
editing: _editingItem,
replying: _replyingItem,
isEncrypted: _chat.focusChannel?.isEncrypted ?? false,
onReset: () => setState(() {
_editingItem = null;
_replyingItem = null;
}),
),
],
),
_chat.ongoingCall != null ? callBanner.animate().slideY() : Container(),
],
); );
} }
} }

View File

@@ -1,35 +1,38 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/widgets/chat/channel_deletion.dart'; import 'package:solian/widgets/chat/channel_deletion.dart';
import 'package:solian/widgets/indent_wrapper.dart'; import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ChatManageScreen extends StatefulWidget { class ChatDetailScreen extends StatefulWidget {
final Channel channel; final Channel channel;
final String realm;
const ChatManageScreen({super.key, required this.channel}); const ChatDetailScreen({super.key, required this.channel, this.realm = 'global'});
@override @override
State<ChatManageScreen> createState() => _ChatManageScreenState(); State<ChatDetailScreen> createState() => _ChatDetailScreenState();
} }
class _ChatManageScreenState extends State<ChatManageScreen> { class _ChatDetailScreenState extends State<ChatDetailScreen> {
bool _isOwned = false; bool _isOwned = false;
void promptLeaveChannel() async { void promptLeaveChannel() async {
final did = await showDialog( final did = await showDialog(
context: context, context: context,
builder: (context) => ChannelDeletion( builder: (context) =>
channel: widget.channel, ChannelDeletion(
isOwned: _isOwned, channel: widget.channel,
), realm: widget.realm,
isOwned: _isOwned,
),
); );
if (did == true && router.canPop()) { if (did == true && SolianRouter.router.canPop()) {
router.pop('disposed'); SolianRouter.router.pop('disposed');
} }
} }
@@ -49,25 +52,33 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
final chat = context.read<ChatProvider>();
final authorizedItems = [ final authorizedItems = [
ListTile( ListTile(
leading: const Icon(Icons.settings), leading: const Icon(Icons.settings),
title: Text(AppLocalizations.of(context)!.settings), title: Text(AppLocalizations.of(context)!.settings),
onTap: () async { onTap: () async {
router.pushNamed('chat.channel.editor', extra: widget.channel).then((did) { SolianRouter.router
if (did == true) { .pushNamed(
if (router.canPop()) router.pop('refresh'); '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 IndentWrapper( return IndentScaffold(
title: AppLocalizations.of(context)!.chatManage, title: AppLocalizations.of(context)!.chatDetail,
hideDrawer: true, hideDrawer: true,
noSafeArea: true, body: Column(
child: Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
@@ -81,8 +92,14 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(widget.channel.name, style: Theme.of(context).textTheme.bodyLarge), Text(widget.channel.name, style: Theme
Text(widget.channel.description, style: Theme.of(context).textTheme.bodySmall), .of(context)
.textTheme
.bodyLarge),
Text(widget.channel.description, style: Theme
.of(context)
.textTheme
.bodySmall),
]), ]),
) )
], ],
@@ -101,10 +118,13 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
leading: const Icon(Icons.supervisor_account), leading: const Icon(Icons.supervisor_account),
title: Text(AppLocalizations.of(context)!.chatMember), title: Text(AppLocalizations.of(context)!.chatMember),
onTap: () { onTap: () {
router.pushNamed( SolianRouter.router.pushNamed(
'chat.channel.member', widget.realm == 'global' ? 'chat.channel.member' : 'realms.chat.channel.member',
extra: widget.channel, extra: widget.channel,
pathParameters: {'channel': widget.channel.alias}, pathParameters: {
'channel': widget.channel.alias,
...(widget.realm == 'global' ? {} : {'realm': widget.realm}),
},
); );
}, },
), ),

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,12 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/notify.dart'; import 'package:solian/providers/notify.dart';
import 'package:solian/utils/service_url.dart'; import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/indent_wrapper.dart'; import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:solian/models/notification.dart' as model; import 'package:solian/models/notification.dart' as model;
@@ -16,19 +19,79 @@ class NotificationScreen extends StatefulWidget {
} }
class _NotificationScreenState extends State<NotificationScreen> { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final auth = context.read<AuthProvider>(); final auth = context.read<AuthProvider>();
final nty = context.watch<NotifyProvider>(); final nty = context.watch<NotifyProvider>();
return IndentWrapper( return IndentScaffold(
noSafeArea: true,
hideDrawer: true, hideDrawer: true,
title: AppLocalizations.of(context)!.notification, title: AppLocalizations.of(context)!.notification,
child: RefreshIndicator( body: RefreshIndicator(
onRefresh: () => nty.fetch(auth), onRefresh: () => nty.fetch(auth),
child: CustomScrollView( child: CustomScrollView(
slivers: [ 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 nty.notifications.isEmpty
? SliverToBoxAdapter( ? SliverToBoxAdapter(
child: Container( child: Container(
@@ -48,9 +111,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
return NotificationItem( return NotificationItem(
index: index, index: index,
item: element, item: element,
onDismiss: () => setState(() { onDismiss: () => nty.clearAt(index),
nty.clearAt(index);
}),
); );
}, },
), ),
@@ -78,10 +139,10 @@ class NotificationItem extends StatelessWidget {
const NotificationItem({super.key, required this.index, required this.item, this.onDismiss}); const NotificationItem({super.key, required this.index, required this.item, this.onDismiss});
bool hasLinks() => item.links != null && item.links!.isNotEmpty; bool get hasLinks => item.links != null && item.links!.isNotEmpty;
void showLinks(BuildContext context) { void showLinks(BuildContext context) {
if (!hasLinks()) return; if (!hasLinks) return;
showModalBottomSheet<void>( showModalBottomSheet<void>(
context: context, context: context,
@@ -92,7 +153,7 @@ class NotificationItem extends StatelessWidget {
Padding( Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 34, bottom: 12), padding: const EdgeInsets.only(left: 16, right: 16, top: 34, bottom: 12),
child: Text( child: Text(
"Links", 'Links',
style: Theme.of(context).textTheme.headlineSmall, style: Theme.of(context).textTheme.headlineSmall,
), ),
), ),
@@ -133,23 +194,11 @@ class NotificationItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Dismissible( return Dismissible(
key: Key('n$index'), key: Key('n${item.id}'),
onDismissed: (direction) { onDismissed: (direction) {
markAsRead(item, context).then((value) { markAsRead(item, context).then((value) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(content: Text('${item.subject} is marked as read')),
content: RichText(
text: TextSpan(
children: [
TextSpan(
text: item.subject,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const TextSpan(text: " is marked as read")
],
),
),
),
); );
}); });
if (onDismiss != null) { if (onDismiss != null) {
@@ -164,7 +213,7 @@ class NotificationItem extends StatelessWidget {
child: ListTile( child: ListTile(
title: Text(item.subject), title: Text(item.subject),
subtitle: Text(item.content), subtitle: Text(item.content),
trailing: hasLinks() trailing: hasLinks
? TextButton( ? TextButton(
onPressed: () => showLinks(context), onPressed: () => showLinks(context),
style: TextButton.styleFrom(shape: const CircleBorder()), style: TextButton.styleFrom(shape: const CircleBorder()),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),
),
],
),
),
],
),
);
}
}

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View 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,
),
],
),
);
}
}

View File

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

View File

@@ -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'),
),
],
),
);
}

View 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;
}

View 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,
),
),
],
),
);
}

View File

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

View File

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

View File

@@ -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(),
),
);
}
}

View File

@@ -1,34 +1,131 @@
import 'package:flutter/material.dart'; import 'dart:convert';
import 'package:solian/models/channel.dart';
import 'package:solian/router.dart';
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 Channel channel;
final String realm;
final Function onUpdate; 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 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IconButton( return IconButton(
onPressed: () async { onPressed: () async {
final result = await router.pushNamed( final result = await SolianRouter.router.pushNamed(
'chat.channel.manage', realm == 'global' ? 'chat.channel.manage' : 'realms.chat.channel.manage',
extra: channel, extra: channel,
pathParameters: {'channel': channel.alias}, pathParameters: {
'channel': channel.alias,
...(realm == 'global' ? {} : {'realm': realm}),
},
); );
switch(result) { switch (result) {
case 'disposed': case 'disposed':
if(router.canPop()) router.pop('refresh'); if (SolianRouter.router.canPop()) SolianRouter.router.pop('refresh');
case 'refresh': case 'refresh':
onUpdate(); onUpdate();
} }
}, },
focusNode: _focusNode, icon: const Icon(Icons.settings),
style: TextButton.styleFrom(shape: const CircleBorder()),
icon: const Icon(Icons.more_horiz),
); );
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/widgets/account/avatar.dart'; import 'package:solian/router.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/posts/comment_list.dart'; import 'package:solian/widgets/posts/comment_list.dart';
import 'package:solian/widgets/posts/content/article.dart'; import 'package:solian/widgets/posts/content/article.dart';
import 'package:solian/widgets/posts/content/attachment.dart'; import 'package:solian/widgets/posts/content/attachment.dart';
import 'package:solian/widgets/posts/content/moment.dart'; import 'package:solian/widgets/posts/content/moment.dart';
import 'package:solian/widgets/posts/item_action.dart'; import 'package:solian/widgets/posts/post_action.dart';
import 'package:solian/widgets/posts/reaction_list.dart'; import 'package:solian/widgets/posts/reaction_list.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:timeago/timeago.dart' as timeago; import 'package:timeago/timeago.dart' as timeago;
class PostItem extends StatefulWidget { class PostItem extends StatefulWidget {
final Post item; final Post item;
final bool? brief; final bool brief;
final bool? ripple; final bool ripple;
final Function? onUpdate; final Function? onUpdate;
final Function? onDelete; final Function? onDelete;
final Function? onTap; final Function? onTap;
@@ -22,8 +24,8 @@ class PostItem extends StatefulWidget {
const PostItem({ const PostItem({
super.key, super.key,
required this.item, required this.item,
this.brief, this.brief = true,
this.ripple, this.ripple = true,
this.onUpdate, this.onUpdate,
this.onDelete, this.onDelete,
this.onTap, this.onTap,
@@ -78,9 +80,9 @@ class _PostItemState extends State<PostItem> {
Widget renderContent() { Widget renderContent() {
switch (widget.item.modelType) { switch (widget.item.modelType) {
case 'article': case 'article':
return ArticleContent(item: widget.item, brief: widget.brief ?? true); return ArticleContent(item: widget.item, brief: widget.brief);
default: default:
return MomentContent(item: widget.item, brief: widget.brief ?? true); return MomentContent(item: widget.item, brief: widget.brief);
} }
} }
@@ -90,7 +92,11 @@ class _PostItemState extends State<PostItem> {
if (widget.item.attachments != null && widget.item.attachments!.isNotEmpty) { if (widget.item.attachments != null && widget.item.attachments!.isNotEmpty) {
return Padding( return Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: AttachmentList(items: widget.item.attachments!, provider: 'interactive'), child: AttachmentList(
items: widget.item.attachments!,
provider: 'interactive',
noTag: SolianTheme.isLargeScreen(context) && widget.brief,
),
); );
} else { } else {
return Container(); return Container();
@@ -135,7 +141,7 @@ class _PostItemState extends State<PostItem> {
@override @override
void initState() { void initState() {
reactionList = widget.item.reactionList; reactionList = widget.item.reactionList ?? {};
super.initState(); super.initState();
} }
@@ -159,7 +165,7 @@ class _PostItemState extends State<PostItem> {
Widget content; Widget content;
if (widget.brief ?? true) { if (widget.brief) {
content = Padding( content = Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: Column( child: Column(
@@ -167,9 +173,17 @@ class _PostItemState extends State<PostItem> {
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
AccountAvatar( GestureDetector(
source: widget.item.author.avatar, child: AccountAvatar(
direct: true, source: widget.item.author.avatar,
direct: true,
),
onTap: () {
SolianRouter.router.pushNamed(
'users.info',
pathParameters: {'user': widget.item.author.name},
);
},
), ),
Expanded( Expanded(
child: Column( child: Column(
@@ -194,13 +208,21 @@ class _PostItemState extends State<PostItem> {
content = Column( content = Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.only(left: 12, right: 12, top: 16), padding: const EdgeInsets.only(left: 20, right: 20, top: 16),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
AccountAvatar( GestureDetector(
source: widget.item.author.avatar, child: AccountAvatar(
direct: true, source: widget.item.author.avatar,
direct: true,
),
onTap: () {
SolianRouter.router.pushNamed(
'users.info',
pathParameters: {'user': widget.item.author.name},
);
},
), ),
Expanded( Expanded(
child: Column( child: Column(
@@ -225,17 +247,17 @@ class _PostItemState extends State<PostItem> {
child: Divider(thickness: 0.3), child: Divider(thickness: 0.3),
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: renderContent(), child: renderContent(),
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 16),
child: renderAttachments(), child: renderAttachments(),
), ),
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
child: renderReactions(), child: renderReactions(),
), ),
), ),
@@ -243,9 +265,7 @@ class _PostItemState extends State<PostItem> {
); );
} }
final ripple = widget.ripple ?? true; if (widget.ripple) {
if (ripple) {
return InkWell( return InkWell(
child: content, child: content,
onTap: () { onTap: () {

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