Compare commits

...

89 Commits

Author SHA1 Message Date
d46d584ff3 🐛 Bug fixes and optimization 2024-05-13 22:52:41 +08:00
f43f9e91f6 Better websocket reconnection and maintainer 2024-05-13 22:12:37 +08:00
b9461e5019 💄 Optimize connection experience 2024-05-12 22:29:38 +08:00
8e0e2dacfe 🐛 Fix connection issue 2024-05-12 22:17:32 +08:00
b4d1d62e9b 🚑 Fix services url issue 2024-05-12 21:27:55 +08:00
6f7ae4467c 🐛 Bug fixes and optimization 2024-05-12 20:59:33 +08:00
98547708af E2EE and Keypair 2024-05-12 20:15:12 +08:00
08d0a99b10 🐛 Bug fixes 2024-05-11 23:22:35 +08:00
5ce6543275 Message view source 2024-05-10 23:42:59 +08:00
40aa16e971 ⬆️ Support some new server stuff 2024-05-10 23:17:01 +08:00
c1d3bac0c8 🐛 Fix several known bugs 2024-05-08 22:01:06 +08:00
a4f8c65aa5 💄 Realm shortcut 2024-05-07 23:38:12 +08:00
3bcdc67285 ♻️ Better chat connection method 2024-05-07 23:07:51 +08:00
dffa0077de 💄 Better user send message experience 2024-05-07 21:49:24 +08:00
b3e266d564 🐛 Bug fixes on realm missing post editor 2024-05-07 13:06:26 +08:00
0c87bbbce1 🐛 Bug fixes on realm UI 2024-05-06 23:36:54 +08:00
ae4d9cf81a Complete the realm system 2024-05-06 23:30:30 +08:00
22c2a80650 Complete the realm system 2024-05-06 20:57:52 +08:00
0b9439262c Realm members & edit 2024-05-05 23:51:46 +08:00
384d861d56 Realm basis 2024-05-05 23:01:08 +08:00
cf0d473a40 ⬆️ Support newer version of server messaging 2024-05-05 01:53:31 +08:00
efc46dbbc5 🐛 Bug fixes 2024-05-03 23:38:51 +08:00
f96ca899b5 Personal page 2024-05-03 18:20:32 +08:00
083975bcd9 Batch mark notify as read 2024-05-03 16:35:28 +08:00
740c704fb8 Can edit avatar and banner 2024-05-03 16:16:42 +08:00
e742338d92 🐛 Bug fixes of scaffold 2024-05-03 14:04:34 +08:00
d179d907ad 💄 Better large screen optimization! 2024-05-03 13:52:41 +08:00
c0680a3134 🎨 Optimized code structure, rename a lot of widgets
💄 Better large screen support in chat
2024-05-03 13:39:52 +08:00
e080f49935 Cached network images 2024-05-03 12:15:54 +08:00
9df4aba56c 💄 Animated chat history 2024-05-03 01:55:12 +08:00
ec69b34877 Better attachments upload, and supports files 2024-05-03 00:09:33 +08:00
9f6942a8cb 🐛 Dispose current call when hang up 2024-05-02 21:36:23 +08:00
1a5faabf86 🐛 Bug fixes of infinite reloading 2024-05-02 21:19:37 +08:00
e5a4554bdd 🐛 Bug fixes of bug fixes 2024-05-02 20:52:20 +08:00
7e4fa47d00 🐛 Bug fixes 2024-05-02 20:45:24 +08:00
7633619edd Basis personalize 2024-05-02 18:56:40 +08:00
52c09151a6 Account page two pane 2024-05-02 12:51:16 +08:00
3089e1f8d2 Rewrite http client 2024-05-02 12:16:01 +08:00
d968169e42 🐛 Bug fixes 2024-05-02 01:38:45 +08:00
b39c8c770e Basic large screen support 2024-05-02 00:49:38 +08:00
fceb3edbc6 Dark mode 2024-05-01 22:35:58 +08:00
7c4427e84a 💄 Optimize UX 2024-05-01 19:39:48 +08:00
fd200105c0 Sign up screen 2024-05-01 18:10:57 +08:00
cd5cfedb2f 🎨 Formatted all the code 2024-05-01 17:37:34 +08:00
28c0094837 Better to solve multi-factor authenticate 2024-05-01 11:27:48 +08:00
5d79692766 🐛 Bug fixes of fixed avatar 2024-04-30 20:37:08 +08:00
7fb94eeafa Floating call widgets 2024-04-30 20:31:54 +08:00
5922d325e5 🐛 Bug fixes 2024-04-29 20:32:51 +08:00
e665b44507 Optimized UI of call actions 2024-04-28 23:05:59 +08:00
1e4fda7daa 🐛 Fix add reaction to non-reaction post cause infinite loading 2024-04-28 22:02:25 +08:00
ad10084850 Real-time local notify 2024-04-28 21:49:03 +08:00
db45764d42 🐛 Bug fixes of sort participants 2024-04-28 19:36:06 +08:00
0483e99a4c 🐛 Bug fixes of duplication 2024-04-28 00:21:16 +08:00
155f7c7999 🐛 Fix colors difference 2024-04-28 00:10:20 +08:00
541df5c3bc 💄 Optimized voice chat 2024-04-28 00:07:32 +08:00
34dee3773d 🐛 Bug fixes on macos 2024-04-27 20:10:15 +08:00
657b497370 💄 Hide stats layer in VC 2024-04-27 14:17:18 +08:00
ff089f26eb 🐛 Bug fixes of webrtc 2024-04-27 14:14:10 +08:00
7ac5c651aa Call join 2024-04-27 13:12:26 +08:00
15c8c0fe8f Chat call basis 2024-04-27 01:36:54 +08:00
a761b80499 Leave & delete channel 2024-04-26 23:25:56 +08:00
a02831644c Channel invite friends 2024-04-26 21:38:31 +08:00
3a661b67c7 Channel kick members 2024-04-26 20:49:21 +08:00
47c10a180a New friendship 2024-04-25 23:32:03 +08:00
5346224f1e Existing friends management 2024-04-25 23:03:16 +08:00
0230ea5c79 💄 Improve UX 2024-04-25 21:33:53 +08:00
3a2894b533 Notifications 2024-04-24 23:19:26 +08:00
0d96a6f9ac MacOS, Android platform support 2024-04-22 22:28:22 +08:00
9fa666d0b8 Handle risk detection 2024-04-21 21:49:05 +08:00
bef3221e2f New sign in page 2024-04-21 20:55:17 +08:00
eb9a24582b 🐛 Fix some permission check issue 2024-04-20 12:06:16 +08:00
846febb82a Channel creation and management 2024-04-20 00:01:48 +08:00
9ac7357e49 New chat menu 2024-04-19 19:48:31 +08:00
ac280f2d40 Chat message attachments 2024-04-19 19:36:03 +08:00
82fc191bad Sign in check 2024-04-19 18:02:53 +08:00
fa7b18fa98 Render replied message! 2024-04-19 17:51:00 +08:00
b257378a82 Edit, delete and reply messages! 2024-04-18 23:39:48 +08:00
1a1a3a5155 Handle more chat server events 2024-04-18 22:43:14 +08:00
94f9076abb Maintain basic messaging websocket 2024-04-18 22:33:59 +08:00
8943f089f2 Basic message editor 2024-04-17 23:24:09 +08:00
ba405770ed Message showing 2024-04-17 23:00:53 +08:00
c25ae591b9 ⬆️ Use new video player 2024-04-17 20:57:25 +08:00
da5f3a24e7 More attachment source 2024-04-16 23:23:34 +08:00
718c715cae Editing notify 2024-04-16 22:45:22 +08:00
964210cbe4 Chat channels index 2024-04-16 22:29:58 +08:00
bb5a10c4c4 Popup comments 2024-04-16 20:36:47 +08:00
0814c17407 More and more reactions 2024-04-15 23:47:44 +08:00
d2ae4f3292 🐛 Bug fixes and translation 2024-04-15 23:40:36 +08:00
7e42d95904 Reactions 2024-04-15 23:08:32 +08:00
172 changed files with 11241 additions and 1212 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,8 +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.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/ic_launcher"> 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"
@@ -24,6 +58,7 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 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"

BIN
assets/icon-macos.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
assets/icon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

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,60 @@
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):
- Flutter
- media_kit_native_event_loop (1.0.0):
- Flutter
- media_kit_video (0.0.1): - media_kit_video (0.0.1):
- Flutter - Flutter
- package_info_plus (0.4.5): - package_info_plus (0.4.5):
@@ -11,53 +62,95 @@ 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
- url_launcher_ios (0.0.1): - SDWebImage (5.19.1):
- Flutter - SDWebImage/Core (= 5.19.1)
- video_player_avfoundation (0.0.1): - SDWebImage/Core (5.19.1)
- sqflite (0.0.3):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1):
- 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_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`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- 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:
:path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
media_kit_native_event_loop:
:path: ".symlinks/plugins/media_kit_native_event_loop/ios"
media_kit_video: media_kit_video:
:path: ".symlinks/plugins/media_kit_video/ios" :path: ".symlinks/plugins/media_kit_video/ios"
package_info_plus: package_info_plus:
: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"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
volume_controller: volume_controller:
:path: ".symlinks/plugins/volume_controller/ios" :path: ".symlinks/plugins/volume_controller/ios"
wakelock_plus: wakelock_plus:
@@ -66,19 +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
media_kit_video: 26c5b265a4094a2df3e8d41e6724d9b964c13151 livekit_client: 734af0e8cb97a610af9d83c208a5453d588d5797
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
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
video_player_avfoundation: 2b4384f3b157206b5e150a0083cdc0c905d260d3
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";
@@ -541,7 +559,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
@@ -598,7 +616,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";

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)
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 625 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -20,7 +20,7 @@
<string>zh</string> <string>zh</string>
</array> </array>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>solian</string> <string>Solian</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
@@ -49,6 +49,8 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UISupportedInterfaceOrientations~ipad</key> <key>UISupportedInterfaceOrientations~ipad</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
@@ -56,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,27 +1,144 @@
{ {
"solian": "Solian", "appName": "Solar Network",
"explore": "Explore", "explore": "Explore",
"chat": "Chat",
"realm": "Realm",
"account": "Account", "account": "Account",
"riskDetection": "Risk Detection",
"signIn": "Sign In", "signIn": "Sign In",
"signInCaption": "Sign in to create post, start a realm, message your friend and more!", "signInCaption": "Sign in to create post, start a realm, message your friend and more!",
"signInRequired": "Sign in required",
"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",
"poweredBy": "Powered by Project Hydrogen",
"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",
"firstName": "First Name",
"lastName": "Last Name",
"description": "Description",
"birthday": "Birthday",
"password": "Password",
"next": "Next",
"join": "Join",
"edit": "Edit", "edit": "Edit",
"apply": "Apply",
"delete": "Delete", "delete": "Delete",
"exit": "Exit",
"action": "Action", "action": "Action",
"reset": "Reset",
"cancel": "Cancel",
"report": "Report", "report": "Report",
"reply": "Reply",
"export": "Export",
"import": "Import",
"settings": "Settings",
"errorHappened": "An Error Occurred",
"notification": "Notification",
"notifyDone": "You're done!",
"notifyDoneCaption": "There are no notifications unread for you.",
"notifyListHint": "Pull to refresh, swipe to dismiss",
"notifyMarkAllRead": "Mark all as read",
"notifyMarkAllReadDone": "Marked all notifications as read",
"friend": "Friend",
"friendPending": "Pending",
"friendActive": "Active",
"friendBlocked": "Blocked",
"friendListHint": "Swipe left to decline, swipe right to approve",
"friendAdd": "Add friend",
"friendAddHint": "Use your their username to send a friend request to your best friend!",
"friendAddDone": "Friend request sent, go reach your friend!",
"personalize": "Personalize",
"personalizeApplied": "Your account information has been updated, some fields may take a while to fully applied.",
"keypair": "Keypair",
"keypairGenerated": "A new set of keypair has been generated. Automatically set it to active.",
"keypairSecretCode": "Secret Code",
"keypairImportHint": "You can paste the exported secret code here to import all keys in it.",
"keypairExportHint": "You can copy the exported secret code to other device to import all keys into it. Do not share it with anybody else!",
"reaction": "Reaction",
"reactVerb": "React",
"post": "Post", "post": "Post",
"postVerb": "Post", "postVerb": "Post",
"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",
"takeVideo": "Capture video",
"newMoment": "Record a moment", "newMoment": "Record a moment",
"newComment": "Leave a comment", "newComment": "Leave a comment",
"connectingServer": "Connecting to server...",
"postIdentityNotify": "You will create this post as", "postIdentityNotify": "You will create this post as",
"postContentPlaceholder": "What's happened?!", "postContentPlaceholder": "What's happened?!",
"postDeleteConfirm": "Are you sure you want to delete this post? This operation cannot be revert!" "postDeleteConfirm": "Are you sure you want to delete this post? This operation cannot be revert!",
"postEditNotify": "You are about editing a post that already published.",
"reactionAdded": "Your reaction has been added.",
"reactionRemoved": "Your reaction has been removed.",
"shortcutsEmpty": "Shortcuts are empty, looks like you didn't go anywhere recently...",
"realmNew": "New Realm",
"realmNewCreate": "Create a realm",
"realmNewJoin": "Join a exists realm",
"realmUsage": "Realm",
"realmUsageCaption": "Realm is a place to organize your posts, channels and more. It will be much easier if you build community with realm!",
"realmEstablish": "Establish a realm",
"realmEditNotify": "You are about editing a existing realm.",
"realmAliasLabel": "Realm Alias",
"realmNameLabel": "Realm Name",
"realmDescriptionLabel": "Realm Description",
"realmPublicLabel": "It's public",
"realmCommunityLabel": "It's community realm",
"realmMember": "Member",
"realmManage": "Realm Manage",
"chatNew": "New Chat",
"chatNewCreate": "Create a channel",
"chatNewJoin": "Join a exists channel",
"chatDetail": "Chat Details",
"chatMember": "Member",
"chatNotifySetting": "Notify Settings",
"chatChannelUnavailable": "Channel Unavailable",
"chatChannelUnavailableCaptionWithRealm": "You didn't join the channel, but looks like you able to join to, would you want to have a try?",
"chatChannelUnavailableCaption": "You didn't join the channel, so you cannot access the information of this channel.",
"chatChannelUsage": "Channel",
"chatChannelUsageCaption": "Channel is place to talk with people, one or a lot.",
"chatChannelOrganize": "Organize a channel",
"chatChannelEditNotify": "You are about editing a existing channel.",
"chatChannelAliasLabel": "Channel Alias",
"chatChannelNameLabel": "Channel Name",
"chatChannelDescriptionLabel": "Channel Description",
"chatChannelEncryptedLabel": "Encrypted Channel",
"chatChannelLeaveConfirm": "Are you sure you want to leave this channel? Your message will be stored, but if you rejoin this channel later, you will lose your control of your previous messages.",
"chatChannelDeleteConfirm": "Are you sure you want to delete this channel? All messages in this channel will be gone forever. This operation cannot be revert!",
"chatCall": "Call",
"chatCallOngoing": "A call is ongoing",
"chatCallOngoingShort": "Ongoing",
"chatCallJoin": "Join",
"chatCallMute": "Mute",
"chatCallUnMute": "Un-mute",
"chatCallVideoOff": "Turn Off Video",
"chatCallVideoOn": "Turn On Video",
"chatCallVideoFlip": "Flip Camera",
"chatCallScreenOn": "Start Screen Share",
"chatCallScreenOff": "Stop Screen Share",
"chatCallDisconnect": "Disconnect",
"chatCallDisconnectConfirm": "Are you sure you want to disconnect? You can reconnect after this if you want.",
"chatCallChangeSpeaker": "Change Speaker",
"chatMessagePlaceholder": "Write a message...",
"chatMessageEncryptedPlaceholder": "Write a encrypted message...",
"chatMessageSending": "Now delivering your messages...",
"chatMessageEditNotify": "You are about editing a message.",
"chatMessageReplyNotify": "You are about replying a message.",
"chatMessageDeleteConfirm": "Are you sure you want to delete this message? This operation cannot be revert and no local history is saved!",
"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,27 +1,144 @@
{ {
"solian": "索链", "appName": "Solar Network",
"explore": "探索", "explore": "探索",
"chat": "聊天",
"realm": "领域",
"account": "账号", "account": "账号",
"signIn": "登陆", "riskDetection": "风险监测",
"signInCaption": "登陆以发表帖子、文章、创建领域、和你的朋友聊天,以及获取更多功能!", "signIn": "登录",
"signInCaption": "登录以发表帖子、文章、创建领域、和你的朋友聊天,以及获取更多功能!",
"signInRequired": "请先登录",
"signInRiskDetected": "检测到风险,点击下一步按钮来打开一个网页,并通过在其上面登录来通过安全检查。",
"signUp": "注册", "signUp": "注册",
"signUpCaption": "在 Solarpass 注册一个账号以获得整个 Solar Networks 的存取权!", "signUpCaption": "在 Solarpass 注册一个账号以获得整个 Solar Network 的存取权!",
"signUpDone": "注册成功",
"signUpDoneCaption": "欢迎您加入 Solar Network现在去登陆吧",
"signOut": "登出",
"poweredBy": "由 Project Hydrogen 强力驱动",
"copyright": "2024 Solsynth LLC © 版权所有",
"confirmation": "确认", "confirmation": "确认",
"confirmCancel": "不太确定", "confirmCancel": "不太确定",
"confirmOkay": "确定", "confirmOkay": "确定",
"email": "邮箱地址",
"nickname": "显示名",
"username": "用户名",
"firstName": "姓氏",
"lastName": "名字",
"description": "简介",
"birthday": "生日",
"password": "密码",
"next": "下一步",
"join": "加入",
"edit": "编辑", "edit": "编辑",
"delete": "删除", "delete": "删除",
"action": "操作", "action": "操作",
"apply": "应用",
"reset": "重置",
"cancel": "取消",
"exit": "离开",
"report": "举报", "report": "举报",
"reply": "回复",
"export": "导出",
"import": "导入",
"settings": "设置",
"errorHappened": "发生了错误",
"notification": "通知",
"notifyDone": "所有通知已读!",
"notifyDoneCaption": "这里没有什么东西可以给你看的了~",
"notifyListHint": "下拉以刷新,左滑来已读",
"notifyMarkAllRead": "将所有标记为已读",
"notifyMarkAllReadDone": "已将所有通知标记为已读",
"friend": "好友",
"friendPending": "请求中",
"friendActive": "活跃的好友",
"friendBlocked": "封锁中",
"friendListHint": "左滑来拒绝,右滑来接受",
"friendAdd": "添加好友",
"friendAddHint": "使用用户名来给你的好朋友发一个好友请求吧!",
"friendAddDone": "好友请求已发送,快告诉你的朋友吧!",
"personalize": "个性化",
"personalizeApplied": "您的账号信息已被更新,部分信息可能需要一段时间来同步。",
"keypair": "密钥对",
"keypairGenerated": "已生成一套新的密钥对,并且设为活跃的密钥。",
"keypairSecretCode": "神秘代码",
"keypairImportHint": "你可以将别的设备导出的神秘代码粘贴到这里来导入其中的所有密钥。",
"keypairExportHint": "你可以将这个导出的神秘代码到你的别的设备来导入这个设备所包含的密钥,但绝对不要发送给其他人!",
"reaction": "反应",
"reactVerb": "作出反应",
"post": "帖子", "post": "帖子",
"postVerb": "发表", "postVerb": "发表",
"comment": "评论", "comment": "评论",
"attachment": "附件", "attachment": "附件",
"attachmentAdd": "附加新附件", "attachmentAdd": "附加新附件",
"pickPhoto": "相册照片", "pickMedia": "相册媒体",
"pickFile": "设备文件",
"takePhoto": "拍摄照片",
"takeVideo": "拍摄视频",
"newMoment": "记录时刻", "newMoment": "记录时刻",
"newComment": "留下评论", "newComment": "留下评论",
"connectingServer": "正在连接至服务器……",
"postIdentityNotify": "你将会以该身份发表本帖子", "postIdentityNotify": "你将会以该身份发表本帖子",
"postContentPlaceholder": "发生什么事了?!", "postContentPlaceholder": "发生什么事了?!",
"postDeleteConfirm": "你确定要删除这篇帖子吗?这意味着这个帖子将永远被我们丢弃在硬盘海中!该操作不可被反转!" "postDeleteConfirm": "你确定要删除这篇帖子吗?这意味着这个帖子将永远被我们丢弃在硬盘海中!该操作不可被反转!",
} "postEditNotify": "你正在修改一个已经发布了的帖子。",
"reactionAdded": "你的反应已被添加。",
"reactionRemoved": "你的反应已被移除。",
"shortcutsEmpty": "快捷方式空空如也,看起来最近你没去哪里呀~",
"realmNew": "新领域",
"realmNewCreate": "创建新领域",
"realmNewJoin": "加入现有领域",
"realmUsage": "领域",
"realmUsageCaption": "领域是一个地方给你来组织帖子、文章、聊天频道的,好好利用领域打造一个绝妙的专属于你的社区吧!",
"realmEstablish": "部署领域",
"realmEditNotify": "你正在修改一个现有领域……",
"realmAliasLabel": "领域别名",
"realmNameLabel": "领域名称",
"realmDescriptionLabel": "领域简介",
"realmPublicLabel": "公共领域",
"realmCommunityLabel": "社区领域(任何人均可加入)",
"realmMember": "成员",
"realmManage": "领域管理",
"chatNew": "新聊天",
"chatDetail": "聊天详情",
"chatMember": "成员",
"chatNotifySetting": "通知设定",
"chatChannelUnavailable": "频道不可用",
"chatChannelUnavailableCaptionWithRealm": "你没加入该频道,但是看起来你能加入本频道,你想加入吗?",
"chatChannelUnavailableCaption": "你没加入该频道,所以你无法读取本频道的信息。",
"chatNewCreate": "新建频道",
"chatNewJoin": "加入已有频道",
"chatChannelUsage": "频道",
"chatChannelUsageCaption": "频道是一个地方供你聊天,跟一个人,或者一堆人",
"chatChannelOrganize": "组织频道",
"chatChannelEditNotify": "你正在编辑一个已经存在的频道……",
"chatChannelAliasLabel": "频道别名",
"chatChannelNameLabel": "频道名称",
"chatChannelDescriptionLabel": "频道简介",
"chatChannelEncryptedLabel": "加密频道",
"chatChannelLeaveConfirm": "你确定你要离开这个频道吗?你在这个频道里的消息将被存储下来,但是当你重新加入本频道后你将会失去对你之前消息的权限。",
"chatChannelDeleteConfirm": "你确定你要删除这个频道吗?这个频道里的所有消息都将消失,并且不可被反转!",
"chatCall": "通话",
"chatCallMute": "静音",
"chatCallUnMute": "取消静音",
"chatCallVideoOff": "关闭摄像头",
"chatCallVideoOn": "启动摄像头",
"chatCallVideoFlip": "翻转视频输出",
"chatCallScreenOn": "开启屏幕分享",
"chatCallScreenOff": "停止屏幕分享",
"chatCallChangeSpeaker": "切换扬声器",
"chatCallOngoing": "一则通话正在进行中",
"chatCallOngoingShort": "进行中",
"chatCallJoin": "加入",
"chatCallDisconnect": "断开连接",
"chatCallDisconnectConfirm": "你确定你要断开连接吗?你可以之后在任何时候重新连接。",
"chatMessagePlaceholder": "发条消息……",
"chatMessageEncryptedPlaceholder": "发条加密信息……",
"chatMessageSending": "正在送出你的信息……",
"chatMessageEditNotify": "你正在编辑信息中……",
"chatMessageReplyNotify": "你正在回复消息中……",
"chatMessageDeleteConfirm": "你确定要删除这条消息吗?这条消息将永远的从所有人的视图中消失,并且不会有本地消息记录保存!",
"chatMessageViewSource": "查看原始信息",
"chatMessageUnableDecryptWaiting": "正在等待解密密钥……",
"chatMessageUnableDecryptUnsupported": "无法解密信息,不支持加密的算法",
"chatMessageUnableDecryptMissing": "无法解密信息,缺失解密密钥"
}

View File

@@ -1,12 +1,24 @@
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/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/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/widgets/chat/call/call_overlay.dart';
import 'package:solian/widgets/provider_init.dart';
void main() { void main() {
WidgetsFlutterBinding.ensureInitialized();
initVideo();
initTimeAgo(); initTimeAgo();
runApp(const SolianApp()); runApp(const SolianApp());
@@ -15,31 +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()),
], ChangeNotifierProvider(create: (_) => RealmProvider()),
child: child, ChangeNotifierProvider(create: (_) => KeypairProvider()),
);
})
], ],
child: Overlay(
initialEntries: [
OverlayEntry(builder: (context) {
return ScaffoldMessenger(
child: Scaffold(
body: ProviderInitializer(
child: child ?? Container(),
),
),
);
}),
OverlayEntry(builder: (context) => const CallOverlay()),
],
),
); );
}, },
); );

59
lib/models/account.dart Normal file
View File

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

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

107
lib/models/channel.dart Normal file
View File

@@ -0,0 +1,107 @@
import 'package:solian/models/account.dart';
class Channel {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String alias;
String name;
String description;
int type;
Account account;
int accountId;
int? realmId;
bool isEncrypted;
bool isAvailable = false;
Channel({
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.alias,
required this.name,
required this.description,
required this.type,
required this.account,
required this.accountId,
required this.isEncrypted,
this.realmId,
});
factory Channel.fromJson(Map<String, dynamic> json) => Channel(
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
alias: json['alias'],
name: json['name'],
description: json['description'],
type: json['type'],
account: Account.fromJson(json['account']),
accountId: json['account_id'],
realmId: json['realm_id'],
isEncrypted: json['is_encrypted'],
);
Map<String, dynamic> toJson() => {
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'alias': alias,
'name': name,
'description': description,
'type': type,
'account': account,
'account_id': accountId,
'realm_id': realmId,
'is_encrypted': isEncrypted,
};
}
class ChannelMember {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
int channelId;
int accountId;
Account account;
int notify;
ChannelMember({
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.channelId,
required this.accountId,
required this.account,
required this.notify,
});
factory ChannelMember.fromJson(Map<String, dynamic> json) => ChannelMember(
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
channelId: json['channel_id'],
accountId: json['account_id'],
account: Account.fromJson(json['account']),
notify: json['notify'],
);
Map<String, dynamic> toJson() => {
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'channel_id': channelId,
'account_id': accountId,
'account': account.toJson(),
'notify': notify,
};
}

View File

@@ -0,0 +1,61 @@
import 'package:solian/models/account.dart';
class Friendship {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
int accountId;
int relatedId;
int? blockedBy;
Account account;
Account related;
int status;
Friendship({
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.accountId,
required this.relatedId,
this.blockedBy,
required this.account,
required this.related,
required this.status,
});
factory Friendship.fromJson(Map<String, dynamic> json) => Friendship(
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
accountId: json['account_id'],
relatedId: json['related_id'],
blockedBy: json['blocked_by'],
account: Account.fromJson(json['account']),
related: Account.fromJson(json['related']),
status: json['status'],
);
Map<String, dynamic> toJson() => {
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'account_id': accountId,
'related_id': relatedId,
'blocked_by': blockedBy,
'account': account.toJson(),
'related': related.toJson(),
'status': status,
};
Account getOtherside(int selfId) {
if (accountId != selfId) {
return account;
} else {
return related;
}
}
}

32
lib/models/keypair.dart Normal file
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,
};
}

123
lib/models/message.dart Normal file
View File

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

View File

@@ -7,8 +7,9 @@ class Notification {
String content; String content;
List<Link>? links; List<Link>? links;
bool isImportant; bool isImportant;
bool isRealtime;
DateTime? readAt; DateTime? readAt;
int senderId; int? senderId;
int recipientId; int recipientId;
Notification({ Notification({
@@ -20,41 +21,40 @@ class Notification {
required this.content, required this.content,
this.links, this.links,
required this.isImportant, required this.isImportant,
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 links: json['links'] != null ? List<Link>.from(json['links'].map((x) => Link.fromJson(x))) : List.empty(),
? List<Link>.from(json["links"].map((x) => Link.fromJson(x))) isImportant: json['is_important'],
: List.empty(), isRealtime: json['is_realtime'],
isImportant: json["is_important"], 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 'links': links != null ? List<dynamic>.from(links!.map((x) => x.toJson())) : List.empty(),
? List<dynamic>.from(links!.map((x) => x.toJson())) 'is_important': isImportant,
: List.empty(), 'is_realtime': isRealtime,
"is_important": isImportant, '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,
}; };
} }

23
lib/models/packet.dart Normal file
View File

@@ -0,0 +1,23 @@
class NetworkPackage {
String method;
String? message;
Map<String, dynamic>? payload;
NetworkPackage({
required this.method,
this.message,
this.payload,
});
factory NetworkPackage.fromJson(Map<String, dynamic> json) => NetworkPackage(
method: json['w'],
message: json['m'],
payload: json['p'],
);
Map<String, dynamic> toJson() => {
'w': method,
'm': message,
'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,
}; };
} }

16
lib/models/reaction.dart Normal file
View File

@@ -0,0 +1,16 @@
class ReactInfo {
final String icon;
final int attitude;
ReactInfo({required this.icon, required this.attitude});
}
final Map<String, ReactInfo> reactions = {
'thumb_up': ReactInfo(icon: '👍', attitude: 1),
'thumb_down': ReactInfo(icon: '👎', attitude: 2),
'just_okay': ReactInfo(icon: '😅', attitude: 0),
'cry': ReactInfo(icon: '😭', attitude: 0),
'confuse': ReactInfo(icon: '🧐', attitude: 0),
'retard': ReactInfo(icon: '🤪', attitude: 0),
'clap': ReactInfo(icon: '👏', attitude: 1),
};

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

@@ -1,77 +1,74 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
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:solian/screens/auth.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() {
pickClient(); loadClient();
} }
final deviceEndpoint = final deviceEndpoint = getRequestUri('passport', '/api/notifications/subscribe');
getRequestUri('passport', '/api/notifications/subscribe');
final authorizationEndpoint = getRequestUri('passport', '/auth/o/connect');
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';
oauth2.Client? client; HttpClient? client;
DateTime? lastRefreshedAt;
Future<bool> pickClient() async { Future<bool> loadClient() async {
if (await storage.containsKey(key: storageKey)) { if (await storage.containsKey(key: storageKey)) {
try { try {
final credentials = final credentials = oauth2.Credentials.fromJson((await storage.read(key: storageKey))!);
oauth2.Credentials.fromJson((await storage.read(key: storageKey))!); client = HttpClient(
client = oauth2.Client(credentials, defaultToken: credentials.accessToken,
identifier: clientId, secret: clientSecret); defaultRefreshToken: credentials.refreshToken,
onTokenRefreshed: setToken,
);
await fetchProfiles(); await fetchProfiles();
return true; return true;
} catch (e) { } catch (e) {
signOff(); signoff();
return false; return false;
} }
} else { } else {
client = HttpClient(onTokenRefreshed: setToken);
return false; return false;
} }
} }
Future<oauth2.Client> createClient(BuildContext context) async { Future<HttpClient> createClient(BuildContext context, String username, String password) async {
// If logged in if (await loadClient()) {
if (await pickClient()) {
return client!; return client!;
} }
var grant = oauth2.AuthorizationCodeGrant( final credentials = (await oauth2.resourceOwnerPasswordGrant(
clientId,
authorizationEndpoint,
tokenEndpoint, tokenEndpoint,
username,
password,
identifier: clientId,
secret: clientSecret, secret: clientSecret,
scopes: ['openid'],
basicAuth: false, basicAuth: false,
))
.credentials;
setToken(credentials.accessToken, credentials.refreshToken!);
return HttpClient(
defaultToken: credentials.accessToken,
defaultRefreshToken: credentials.refreshToken,
onTokenRefreshed: setToken,
); );
var authorizationUrl =
grant.getAuthorizationUrl(redirectUrl, scopes: ["openid"]);
var responseUrl = await Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (context) => AuthorizationScreen(authorizationUrl),
),
);
var responseUri = Uri.parse(responseUrl);
return await grant.handleAuthorizationResponse(responseUri.queryParameters);
} }
Future<void> fetchProfiles() async { Future<void> fetchProfiles() async {
@@ -79,50 +76,35 @@ 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 = await client?.credentials.refresh( final credentials = oauth2.Credentials(atk, refreshToken: rtk, idToken: atk, scopes: ['openid']);
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) async { Future<void> signin(BuildContext context, String username, String password) async {
client = await createClient(context); client = await createClient(context, username, password);
storage.write(key: storageKey, value: client!.credentials.toJson());
await fetchProfiles(); await fetchProfiles();
} }
void signOff() { void signoff() {
storage.delete(key: profileKey); storage.delete(key: profileKey);
storage.delete(key: storageKey); storage.delete(key: storageKey);
} }
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) {
if (lastRefreshedAt == null ||
lastRefreshedAt!
.add(const Duration(minutes: 3))
.isBefore(DateTime.now())) {
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) ?? '{}');
} }
} }

596
lib/providers/chat.dart Normal file
View File

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

26
lib/providers/friend.dart Normal file
View File

@@ -0,0 +1,26 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:solian/models/friendship.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/services_url.dart';
class FriendProvider extends ChangeNotifier {
List<Friendship> friends = List.empty();
Future<void> fetch(AuthProvider auth) async {
if (!await auth.isAuthorized()) return;
var uri = getRequestUri('passport', '/api/users/me/friends?status=1');
var res = await auth.client!.get(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes)) as List<dynamic>;
friends = result.map((x) => Friendship.fromJson(x)).toList();
notifyListeners();
} else {
var message = utf8.decode(res.bodyBytes);
throw Exception(message);
}
}
}

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

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

View File

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

195
lib/providers/notify.dart Normal file
View File

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

47
lib/providers/realm.dart Normal file
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,45 +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/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/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_list.dart';
import 'package:solian/screens/chat/chat_detail.dart';
import 'package:solian/screens/chat/channel/channel_editor.dart';
import 'package:solian/screens/chat/channel/channel_member.dart';
import 'package:solian/screens/explore.dart'; import 'package:solian/screens/explore.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/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: '/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(
], pageBuilder: (context, state, child) => defaultPageBuilder(
); context,
state,
SolianTheme.isLargeScreen(context)
? TwoColumnLayout(
sideChild: const ExplorePostScreen(),
mainChild: child,
)
: child,
),
routes: [
GoRoute(
path: '/',
name: 'explore',
builder: (context, state) =>
!SolianTheme.isLargeScreen(context) ? const ExplorePostScreen() : const PageEmptyWidget(),
),
GoRoute(
path: '/posts/publish/moments',
name: 'posts.moments.editor',
builder: (context, state) => MomentEditorScreen(editing: state.extra as Post?),
),
GoRoute(
path: '/posts/publish/comments',
name: 'posts.comments.editor',
builder: (context, state) {
final args = state.extra as CommentPostArguments;
return CommentEditorScreen(editing: args.editing, related: args.related);
},
),
GoRoute(
path: '/posts/:dataset/:alias',
name: 'posts.details',
builder: (context, state) => PostScreen(
alias: state.pathParameters['alias'] as String,
dataset: state.pathParameters['dataset'] as String,
),
),
],
),
ShellRoute(
pageBuilder: (context, state, child) => defaultPageBuilder(
context,
state,
SolianTheme.isLargeScreen(context)
? TwoColumnLayout(
sideChild: const RealmListScreen(),
mainChild: child,
)
: child,
),
routes: [
GoRoute(
path: '/realms',
name: 'realms',
builder: (context, state) =>
!SolianTheme.isLargeScreen(context) ? const RealmListScreen() : const PageEmptyWidget(),
),
GoRoute(
path: '/realms/editor',
name: 'realms.editor',
builder: (context, state) => RealmEditorScreen(
editing: state.extra as Realm?,
realm: state.uri.queryParameters['realm'],
),
),
GoRoute(
path: '/realms/:realm/manage',
name: 'realms.manage',
builder: (context, state) => RealmManageScreen(realm: state.extra as Realm),
),
GoRoute(
path: '/realms/:realm/member',
name: 'realms.member',
builder: (context, state) => RealmMemberScreen(realm: state.extra as Realm),
),
GoRoute(
path: '/realms/:realm/posts/publish/moments',
name: 'realms.posts.moments.editor',
builder: (context, state) => MomentEditorScreen(
editing: state.extra as Post?,
realm: state.pathParameters['realm']
),
),
GoRoute(
path: '/realms/:realm/posts/:dataset/:alias',
name: 'realms.posts.details',
builder: (context, state) => PostScreen(
alias: state.pathParameters['alias'] as String,
dataset: state.pathParameters['dataset'] as String,
),
),
GoRoute(
path: '/realms/:realm',
name: 'realms.details',
builder: (context, state) => !SolianTheme.isLargeScreen(context)
? RealmScreen(alias: state.pathParameters['realm'] as String)
: const PageEmptyWidget(),
),
GoRoute(
path: '/realms/:realm/chat/:channel',
name: 'realms.chat.channel',
builder: (context, state) => ChatScreen(
alias: state.pathParameters['channel'] as String,
realm: state.pathParameters['realm'] as String,
),
),
GoRoute(
path: '/realms/:realm/chat/:channel/manage',
name: 'realms.chat.channel.manage',
builder: (context, state) => ChatDetailScreen(
channel: state.extra as Channel,
realm: state.pathParameters['realm'] as String,
),
),
GoRoute(
path: '/realms/:realm/chat/:channel/member',
name: 'realms.chat.channel.member',
builder: (context, state) => ChatMemberScreen(
channel: state.extra as Channel,
realm: state.pathParameters['realm'] as String,
),
),
],
),
ShellRoute(
pageBuilder: (context, state, child) => defaultPageBuilder(
context,
state,
SolianTheme.isLargeScreen(context)
? TwoColumnLayout(
sideChild: const ChatListScreen(),
mainChild: child,
)
: child,
),
routes: [
GoRoute(
path: '/chat',
name: 'chat',
builder: (context, state) =>
!SolianTheme.isLargeScreen(context) ? const ChatListScreen() : const PageEmptyWidget(),
),
GoRoute(
path: '/chat/editor',
name: 'chat.channel.editor',
builder: (context, state) => ChannelEditorScreen(
editing: state.extra as Channel?,
realm: state.uri.queryParameters['realm'] ?? 'global',
),
),
GoRoute(
path: '/chat/:channel',
name: 'chat.channel',
builder: (context, state) => ChatScreen(alias: state.pathParameters['channel'] as String),
),
GoRoute(
path: '/chat/:channel/manage',
name: 'chat.channel.manage',
builder: (context, state) => ChatDetailScreen(channel: state.extra as Channel),
),
GoRoute(
path: '/chat/:channel/member',
name: 'chat.channel.member',
builder: (context, state) => ChatMemberScreen(channel: state.extra as Channel),
),
],
),
GoRoute(
path: '/chat/:channel/call',
name: 'chat.channel.call',
builder: (context, state) => ChatCall(call: state.extra as Call),
),
ShellRoute(
pageBuilder: (context, state, child) => defaultPageBuilder(
context,
state,
SolianTheme.isLargeScreen(context)
? TwoColumnLayout(
sideChild: const AccountScreen(),
mainChild: child,
)
: child,
),
routes: [
GoRoute(
path: '/account',
name: 'account',
builder: (context, state) =>
!SolianTheme.isLargeScreen(context) ? const AccountScreen() : const PageEmptyWidget(),
),
GoRoute(
path: '/auth/sign-in',
name: 'auth.sign-in',
builder: (context, state) => SignInScreen(),
),
GoRoute(
path: '/auth/sign-up',
name: 'auth.sign-up',
builder: (context, state) => SignUpScreen(),
),
GoRoute(
path: '/account/friend',
name: 'account.friend',
builder: (context, state) => const FriendScreen(),
),
GoRoute(
path: '/account/personalize',
name: 'account.personalize',
builder: (context, state) => const PersonalizeScreen(),
),
GoRoute(
path: '/account/keypair',
name: 'account.keypair',
builder: (context, state) => const KeypairScreen(),
),
],
),
GoRoute(
path: '/users/:user',
name: 'users.info',
builder: (context, state) => UserInfoScreen(name: state.pathParameters['user'] as String),
),
],
);
static GoRoute get currentRoute => SolianRouter.router.routerDelegate.currentConfiguration.last.route;
static Page defaultPageBuilder(
BuildContext context,
GoRouterState state,
Widget child,
) =>
MaterialPage(
key: state.pageKey,
restorationId: state.pageKey.value,
child: child,
);
}

View File

@@ -1,26 +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/utils/theme.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/utils/service_url.dart'; import 'package:solian/widgets/scaffold.dart';
import 'package:solian/widgets/common_wrapper.dart';
import 'package:url_launcher/url_launcher.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();
@@ -29,61 +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: [
InkWell( const Padding(
child: const Padding( padding: EdgeInsets.only(top: 16, bottom: 8, left: 24, right: 24),
padding: EdgeInsets.symmetric(horizontal: 18), child: NameCard(),
child: ListTile( ),
leading: Icon(Icons.logout), ...(actionItems.map(
title: Text("Sign out"), (x) => ListTile(
), contentPadding: const EdgeInsets.symmetric(horizontal: 34),
), leading: x.$1,
onTap: () { title: Text(x.$2),
auth.signOff(); onTap: () {
setState(() { widget.onSelect(x.$3);
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: () {
auth.signIn(context).then((_) {
auth.isAuthorized().then((val) {
setState(() => isAuthorized = val);
});
});
},
),
ActionCard(
icon: const Icon(Icons.plus_one, color: Colors.white),
title: AppLocalizations.of(context)!.signUp,
caption: AppLocalizations.of(context)!.signUpCaption,
onTap: () {
launchUrl(getRequestUri('passport', '/auth/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');
},
),
],
),
);
}
} }
} }
@@ -93,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 CircleAvatar(backgroundImage: NetworkImage(profiles["picture"])); return AccountAvatar(source: profiles['picture'], direct: true);
} }
Future<Column> renderLabel(BuildContext context) async { Future<Column> renderLabel(BuildContext context) async {
@@ -103,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),
)
], ],
); );
} }
@@ -125,8 +169,7 @@ class NameCard extends StatelessWidget {
children: [ children: [
FutureBuilder( FutureBuilder(
future: renderAvatar(context), future: renderAvatar(context),
builder: builder: (BuildContext context, AsyncSnapshot<Widget> snapshot) {
(BuildContext context, AsyncSnapshot<Widget> snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
return snapshot.data!; return snapshot.data!;
} else { } else {
@@ -137,10 +180,9 @@ class NameCard extends StatelessWidget {
const SizedBox(width: 20), const SizedBox(width: 20),
FutureBuilder( FutureBuilder(
future: renderLabel(context), future: renderLabel(context),
builder: builder: (BuildContext context, AsyncSnapshot<Column> snapshot) {
(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();
} }
@@ -160,12 +202,13 @@ class ActionCard extends StatelessWidget {
final String caption; final String caption;
final Function onTap; final Function onTap;
const ActionCard( const ActionCard({
{super.key, super.key,
required this.onTap, required this.onTap,
required this.title, required this.title,
required this.caption, required this.caption,
required this.icon}); required this.icon,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -192,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

@@ -0,0 +1,293 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/friendship.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class FriendScreen extends StatelessWidget {
const FriendScreen({super.key});
@override
Widget build(BuildContext context) {
return IndentScaffold(
title: AppLocalizations.of(context)!.friend,
hideDrawer: true,
body: const FriendScreenWidget(),
);
}
}
class FriendScreenWidget extends StatefulWidget {
const FriendScreenWidget({super.key});
@override
State<FriendScreenWidget> createState() => _FriendScreenWidgetState();
}
class _FriendScreenWidgetState extends State<FriendScreenWidget> {
bool _isSubmitting = false;
int _selfId = 0;
List<Friendship> _friendships = List.empty();
Future<void> fetchFriendships() async {
final auth = context.read<AuthProvider>();
final prof = await auth.getProfiles();
if (!await auth.isAuthorized()) return;
_selfId = prof['id'];
var uri = getRequestUri('passport', '/api/users/me/friends');
var res = await auth.client!.get(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes)) as List<dynamic>;
setState(() {
_friendships = result.map((x) => Friendship.fromJson(x)).toList();
});
} else {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
}
}
Future<void> createFriendship(String username) async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
var res = await auth.client!.post(
getRequestUri('passport', '/api/users/me/friends?related=$username'),
);
if (res.statusCode == 200) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.friendAddDone)),
);
await fetchFriendships();
} else {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
}
setState(() => _isSubmitting = false);
}
Future<void> updateFriendship(Friendship relation, int status) async {
setState(() => _isSubmitting = true);
final otherside = relation.getOtherside(_selfId);
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
var res = await auth.client!.put(
getRequestUri('passport', '/api/users/me/friends/${otherside.id}'),
headers: <String, String>{
'Content-Type': 'application/json',
},
body: jsonEncode(<String, dynamic>{
'status': status,
}),
);
if (res.statusCode == 200) {
await fetchFriendships();
} else {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
}
setState(() => _isSubmitting = false);
}
void promptAddFriend() async {
final controller = TextEditingController();
final input = await showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(AppLocalizations.of(context)!.friendAdd),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(AppLocalizations.of(context)!.friendAddHint),
const SizedBox(height: 18),
TextField(
controller: controller,
decoration: InputDecoration(
isDense: true,
prefixIcon: const Icon(Icons.account_circle),
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.username,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
],
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
),
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
child: Text(AppLocalizations.of(context)!.next),
onPressed: () {
Navigator.pop(context, controller.text);
},
),
],
);
},
);
WidgetsBinding.instance.addPostFrameCallback((_) => controller.dispose());
await createFriendship(input);
}
List<Friendship> filterWithStatus(int status) {
return _friendships.where((x) => x.status == status).toList();
}
DismissDirection getDismissDirection(Friendship relation) {
if (relation.status == 2) return DismissDirection.endToStart;
if (relation.status == 1) return DismissDirection.startToEnd;
if (relation.status == 0 && relation.relatedId != _selfId) {
return DismissDirection.startToEnd;
}
return DismissDirection.horizontal;
}
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () {
fetchFriendships();
});
}
@override
Widget build(BuildContext context) {
Widget friendshipTileBuilder(context, index, status) {
final element = filterWithStatus(status)[index];
final otherside = element.getOtherside(_selfId);
final randomId = DateTime.now().microsecondsSinceEpoch >> 10;
return Dismissible(
key: Key(randomId.toString()),
background: Container(
color: Colors.red,
padding: const EdgeInsets.symmetric(horizontal: 20),
alignment: Alignment.centerLeft,
child: const Icon(Icons.close, color: Colors.white),
),
secondaryBackground: Container(
color: Colors.green,
padding: const EdgeInsets.symmetric(horizontal: 20),
alignment: Alignment.centerRight,
child: const Icon(Icons.check, color: Colors.white),
),
direction: getDismissDirection(element),
child: ListTile(
title: Text(otherside.nick),
subtitle: Text(otherside.name),
leading: AccountAvatar(source: otherside.avatar),
),
onDismissed: (direction) {
if (direction == DismissDirection.startToEnd) {
updateFriendship(element, 2);
}
if (direction == DismissDirection.endToStart) {
updateFriendship(element, 1);
}
},
);
}
return Scaffold(
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () => promptAddFriend(),
),
body: RefreshIndicator(
onRefresh: () => fetchFriendships(),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
),
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.8),
child: Text(AppLocalizations.of(context)!.friendPending),
),
),
SliverList.builder(
itemCount: filterWithStatus(0).length,
itemBuilder: (_, __) => friendshipTileBuilder(_, __, 0),
),
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.8),
child: Text(AppLocalizations.of(context)!.friendActive),
),
),
SliverList.builder(
itemCount: filterWithStatus(1).length,
itemBuilder: (_, __) => friendshipTileBuilder(_, __, 1),
),
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.8),
child: Text(AppLocalizations.of(context)!.friendBlocked),
),
),
SliverList.builder(
itemCount: filterWithStatus(2).length,
itemBuilder: (_, __) => friendshipTileBuilder(_, __, 2),
),
SliverToBoxAdapter(
child: Container(
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.8),
width: 0.3,
)),
),
padding: const EdgeInsets.only(top: 16),
child: Text(
AppLocalizations.of(context)!.friendListHint,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
),
),
],
),
),
);
}
}

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

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

270
lib/screens/chat/chat.dart Normal file
View File

@@ -0,0 +1,270 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/message.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/channel_action.dart';
import 'package:solian/widgets/chat/message.dart';
import 'package:solian/widgets/chat/message_action.dart';
import 'package:solian/widgets/chat/message_editor.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ChatScreen extends StatelessWidget {
final String alias;
final String realm;
const ChatScreen({super.key, required this.alias, this.realm = 'global'});
@override
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 ChatWidget extends StatefulWidget {
final String alias;
final String realm;
const ChatWidget({super.key, required this.alias, required this.realm});
@override
State<ChatWidget> createState() => _ChatWidgetState();
}
class _ChatWidgetState extends State<ChatWidget> {
bool _isReady = false;
late final ChatProvider _chat;
Future<void> joinChannel() async {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
var uri = getRequestUri(
'messaging',
'/api/channels/${widget.realm}/${widget.alias}/members/me',
);
var res = await auth.client!.post(uri);
if (res.statusCode == 200) {
setState(() {});
_chat.historyPagingController?.refresh();
} else {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message).then((_) {
SolianRouter.router.pop();
});
}
}
bool getMessageMergeable(Message? a, Message? b) {
if (a?.replyTo != null) return false;
if (a == null || b == null) return false;
if (a.senderId != b.senderId) return false;
return a.createdAt.difference(b.createdAt).inMinutes <= 3;
}
Message? _editingItem;
Message? _replyingItem;
void viewActions(Message item) {
showModalBottomSheet(
context: context,
builder: (context) => ChatMessageAction(
channel: widget.alias,
realm: widget.realm,
item: item,
onEdit: () => setState(() {
_editingItem = item;
}),
onReply: () => setState(() {
_replyingItem = item;
}),
),
);
}
void showUnavailableDialog() {
final content = widget.realm == 'global'
? AppLocalizations.of(context)!.chatChannelUnavailableCaption
: AppLocalizations.of(context)!.chatChannelUnavailableCaptionWithRealm;
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.chatChannelUnavailable),
content: Text(content),
actions: <Widget>[
TextButton(
child: Text(AppLocalizations.of(context)!.cancel),
onPressed: () {
Navigator.of(context).pop();
SolianRouter.router.pop();
},
),
...(widget.realm != 'global'
? [
TextButton(
child: Text(AppLocalizations.of(context)!.join),
onPressed: () {
Navigator.of(context).pop();
joinChannel();
},
),
]
: [])
],
),
);
}
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () async {
final auth = context.read<AuthProvider>();
await _chat.connect(auth);
_chat.fetchOngoingCall(widget.alias, widget.realm);
_chat.fetchChannel(context, auth, widget.alias, widget.realm).then((result) {
if (result.isAvailable == false) {
showUnavailableDialog();
}
});
});
}
@override
Widget build(BuildContext context) {
Widget chatHistoryBuilder(context, item, index) {
bool isMerged = false, hasMerged = false;
if (index > 0) {
hasMerged = getMessageMergeable(_chat.historyPagingController?.itemList?[index - 1], item);
}
if (index + 1 < (_chat.historyPagingController?.itemList?.length ?? 0)) {
isMerged = getMessageMergeable(item, _chat.historyPagingController?.itemList?[index + 1]);
}
return InkWell(
child: Container(
padding: EdgeInsets.only(
top: !isMerged ? 8 : 0,
bottom: !hasMerged ? 8 : 0,
left: 12,
right: 12,
),
child: ChatMessage(
item: item,
underMerged: isMerged,
),
),
onLongPress: () => viewActions(item),
).animate(key: Key('m${item.id}'), autoPlay: true).slideY(
curve: Curves.fastEaseInToSlowEaseOut,
duration: 350.ms,
begin: 0.25,
end: 0,
);
}
if (!_isReady) {
_isReady = true;
_chat = context.watch<ChatProvider>();
}
final callBanner = MaterialBanner(
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20),
leading: const Icon(Icons.call_received),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.9),
dividerColor: const Color.fromARGB(1, 0, 0, 0),
content: Text(AppLocalizations.of(context)!.chatCallOngoing),
actions: [
TextButton(
child: Text(AppLocalizations.of(context)!.chatCallJoin),
onPressed: () {
SolianRouter.router.pushNamed(
'chat.channel.call',
extra: _chat.ongoingCall,
pathParameters: {'channel': widget.alias},
);
},
),
],
);
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

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

View File

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

View File

@@ -1,26 +1,53 @@
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: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/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/posts/item.dart'; import 'package:solian/widgets/realms/realm_shortcuts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:solian/widgets/notification_notifier.dart';
import 'package:solian/widgets/posts/post.dart';
class ExploreScreen extends StatefulWidget { 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 PagingController<int, Post> _pagingController = final String? realm;
PagingController(firstPageKey: 0); final bool showRealmShortcuts;
const ExplorePostWidget({
super.key,
this.realm,
this.showRealmShortcuts = false,
});
@override
State<ExplorePostWidget> createState() => _ExplorePostWidgetState();
}
class _ExplorePostWidgetState extends State<ExplorePostWidget> {
final PagingController<int, Post> _pagingController = PagingController(firstPageKey: 0);
final http.Client _client = http.Client(); final http.Client _client = http.Client();
@@ -28,15 +55,17 @@ class _ExploreScreenState extends State<ExploreScreen> {
final offset = pageKey; final offset = pageKey;
const take = 5; const take = 5;
var uri = Uri uri;
getRequestUri('interactive', '/api/feed?take=$take&offset=$offset'); 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) {
final result = final result = PaginationResult.fromJson(jsonDecode(utf8.decode(res.bodyBytes)));
PaginationResult.fromJson(jsonDecode(utf8.decode(res.bodyBytes))); final items = result.data?.map((x) => Post.fromJson(x)).toList() ?? List.empty();
final items =
result.data?.map((x) => Post.fromJson(x)).toList() ?? List.empty();
final isLastPage = (result.count - pageKey) < take; final isLastPage = (result.count - pageKey) < take;
if (isLastPage || result.data == null) { if (isLastPage || result.data == null) {
_pagingController.appendLastPage(items); _pagingController.appendLastPage(items);
@@ -53,51 +82,89 @@ 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));
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IndentWrapper( final auth = context.read<AuthProvider>();
noSafeArea: true,
floatingActionButton: FloatingActionButton( return Scaffold(
child: const Icon(Icons.edit), floatingActionButton: FutureBuilder(
onPressed: () async { future: auth.isAuthorized(),
final did = await router.pushNamed("posts.moments.editor"); builder: (context, snapshot) {
if (did == true) _pagingController.refresh(); if (snapshot.hasData && snapshot.data!) {
return FloatingActionButton(
child: const Icon(Icons.edit),
onPressed: () async {
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();
},
);
} else {
return Container();
}
}, },
), ),
title: AppLocalizations.of(context)!.explore, body: RefreshIndicator(
child: RefreshIndicator(
onRefresh: () => Future.sync( onRefresh: () => Future.sync(
() => _pagingController.refresh(), () => _pagingController.refresh(),
), ),
child: Center( child: CustomScrollView(
child: Container( slivers: [
constraints: const BoxConstraints(maxWidth: 640), widget.showRealmShortcuts
child: PagedListView<int, Post>.separated( ? SliverToBoxAdapter(
child: FutureBuilder(
future: auth.isAuthorized(),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data != true) {
return Container();
}
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: const Material(
elevation: 8,
child: SizedBox(height: 120, child: RealmShortcuts()),
).animate().fade().slideY(begin: -1, end: 0, curve: Curves.fastEaseInToSlowEaseOut),
);
},
),
)
: SliverToBoxAdapter(child: Container()),
PagedSliverList<int, Post>(
pagingController: _pagingController, pagingController: _pagingController,
separatorBuilder: (context, index) =>
const Divider(thickness: 0.3),
builderDelegate: PagedChildBuilderDelegate<Post>( builderDelegate: PagedChildBuilderDelegate<Post>(
itemBuilder: (context, item, index) => GestureDetector( itemBuilder: (context, item, index) => PostItem(
child: PostItem( item: item,
item: item, onUpdate: () => _pagingController.refresh(),
onUpdate: () => _pagingController.refresh(),
),
onTap: () { onTap: () {
router.pushNamed( SolianRouter.router.pushNamed(
'posts.screen', widget.realm == null ? 'posts.details' : 'realms.posts.details',
pathParameters: { pathParameters: {
'alias': item.alias, 'alias': item.alias,
'dataset': '${item.modelType}s', 'dataset': item.dataset,
...(widget.realm == null ? {} : {'realm': widget.realm!}),
}, },
); );
}, },
), ),
), ),
), ),
), ],
), ),
), ),
); );

View File

@@ -0,0 +1,227 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/notify.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:solian/models/notification.dart' as model;
class NotificationScreen extends StatefulWidget {
const NotificationScreen({super.key});
@override
State<NotificationScreen> createState() => _NotificationScreenState();
}
class _NotificationScreenState extends State<NotificationScreen> {
bool _isSubmitting = false;
Future<void> markAllRead() async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
final nty = context.read<NotifyProvider>();
List<int> markList = List.empty(growable: true);
for (final element in nty.notifications) {
if (element.isRealtime) continue;
markList.add(element.id);
}
nty.clearRealtimeNotifications();
if(markList.isNotEmpty) {
var uri = getRequestUri('passport', '/api/notifications/batch/read');
await auth.client!.put(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'messages': markList}),
);
}
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.notifyMarkAllReadDone),
));
await nty.fetch(auth);
setState(() => _isSubmitting = false);
}
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () {
final nty = context.read<NotifyProvider>();
nty.allRead();
});
}
@override
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
final nty = context.watch<NotifyProvider>();
return IndentScaffold(
hideDrawer: true,
title: AppLocalizations.of(context)!.notification,
body: RefreshIndicator(
onRefresh: () => nty.fetch(auth),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
),
if (nty.notifications.isNotEmpty)
SliverToBoxAdapter(
child: ListTile(
tileColor: Theme.of(context).colorScheme.secondaryContainer,
leading: const Icon(Icons.checklist),
title: Text(AppLocalizations.of(context)!.notifyMarkAllRead),
contentPadding: const EdgeInsets.symmetric(horizontal: 28),
onTap: _isSubmitting ? null : markAllRead,
),
),
nty.notifications.isEmpty
? SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
color: Theme.of(context).colorScheme.surfaceVariant,
child: ListTile(
leading: const Icon(Icons.check),
title: Text(AppLocalizations.of(context)!.notifyDone),
subtitle: Text(AppLocalizations.of(context)!.notifyDoneCaption),
),
),
)
: SliverList.builder(
itemCount: nty.notifications.length,
itemBuilder: (BuildContext context, int index) {
var element = nty.notifications[index];
return NotificationItem(
index: index,
item: element,
onDismiss: () => nty.clearAt(index),
);
},
),
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.only(top: 12),
child: Text(
AppLocalizations.of(context)!.notifyListHint,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
),
),
],
),
),
);
}
}
class NotificationItem extends StatelessWidget {
final int index;
final model.Notification item;
final void Function()? onDismiss;
const NotificationItem({super.key, required this.index, required this.item, this.onDismiss});
bool get hasLinks => item.links != null && item.links!.isNotEmpty;
void showLinks(BuildContext context) {
if (!hasLinks) return;
showModalBottomSheet<void>(
context: context,
builder: (BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 34, bottom: 12),
child: Text(
'Links',
style: Theme.of(context).textTheme.headlineSmall,
),
),
Expanded(
child: ListView.builder(
itemCount: item.links!.length,
itemBuilder: (BuildContext context, int index) {
var element = item.links![index];
return ListTile(
title: Text(element.label),
onTap: () async {
await launchUrlString(element.url);
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
},
);
},
),
),
],
);
},
);
}
Future<void> markAsRead(model.Notification element, BuildContext context) async {
if (element.isRealtime) return;
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
var id = element.id;
var uri = getRequestUri('passport', '/api/notifications/$id/read');
await auth.client!.put(uri);
}
@override
Widget build(BuildContext context) {
return Dismissible(
key: Key('n${item.id}'),
onDismissed: (direction) {
markAsRead(item, context).then((value) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${item.subject} is marked as read')),
);
});
if (onDismiss != null) {
onDismiss!();
}
},
background: Container(
color: Colors.lightBlue,
),
child: Container(
padding: const EdgeInsets.only(left: 10),
child: ListTile(
title: Text(item.subject),
subtitle: Text(item.content),
trailing: hasLinks
? TextButton(
onPressed: () => showLinks(context),
style: TextButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.more_vert),
)
: null,
),
),
);
}
}

View File

@@ -1,25 +1,28 @@
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:http/http.dart'; import 'package:http/http.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/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/indent_wrapper.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:solian/widgets/posts/attachment_editor.dart'; import 'package:solian/widgets/posts/attachment_editor.dart';
class CommentPostArguments { class CommentPostArguments {
final Post related; final Post? related;
final Post? editing; final Post? editing;
CommentPostArguments({required this.related, this.editing}); CommentPostArguments({this.related, this.editing});
} }
class CommentEditorScreen extends StatefulWidget { class CommentEditorScreen extends StatefulWidget {
final Post related; final Post? related;
final Post? editing; final Post? editing;
const CommentEditorScreen({super.key, required this.related, this.editing}); const CommentEditorScreen({super.key, required this.related, this.editing});
@@ -40,6 +43,7 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => AttachmentEditor( builder: (context) => AttachmentEditor(
provider: 'interactive',
current: _attachments, current: _attachments,
onUpdate: (value) => _attachments = value, onUpdate: (value) => _attachments = value,
), ),
@@ -47,15 +51,21 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> {
} }
Future<void> applyPost(BuildContext context) async { Future<void> applyPost(BuildContext context) async {
final auth = context.read<AuthProvider>(); setState(() => _isSubmitting = true);
if (!await auth.isAuthorized()) return;
final relatedDataset = '${widget.related.modelType}s'; final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
final alias = widget.related?.alias ?? 'not-found';
final relatedDataset = '${widget.related?.modelType ?? 'comment'}s';
final uri = widget.editing == null final uri = widget.editing == null
? getRequestUri('interactive', '/api/p/$relatedDataset/${widget.related.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,
@@ -63,21 +73,24 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> {
'attachments': _attachments, 'attachments': _attachments,
}); });
setState(() => _isSubmitting = true);
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() {
if (SolianRouter.router.canPop()) {
SolianRouter.router.pop(false);
}
}
@override @override
void initState() { void initState() {
if (widget.editing != null) { if (widget.editing != null) {
@@ -93,8 +106,23 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final auth = context.read<AuthProvider>(); final auth = context.read<AuthProvider>();
return IndentWrapper( 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)!.postEditNotify),
actions: [
TextButton(
child: Text(AppLocalizations.of(context)!.cancel),
onPressed: () => cancelEditing(),
),
],
);
return IndentScaffold(
hideDrawer: true, hideDrawer: true,
showSafeArea: true,
title: AppLocalizations.of(context)!.newComment, title: AppLocalizations.of(context)!.newComment,
appBarActions: <Widget>[ appBarActions: <Widget>[
TextButton( TextButton(
@@ -102,68 +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() : 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: CircleAvatar(
backgroundImage: NetworkImage(userinfo["picture"]),
),
);
} else {
return Container();
}
},
),
const Divider(thickness: 0.3),
Expanded(
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
maxLines: null,
autofocus: true,
autocorrect: true,
keyboardType: TextInputType.multiline,
controller: _textController,
decoration: InputDecoration.collapsed(
hintText:
AppLocalizations.of(context)!.postContentPlaceholder,
),
), ),
), leading: AccountAvatar(
), source: userinfo['picture'],
Container( direct: true,
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

@@ -1,20 +1,24 @@
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:http/http.dart'; import 'package:http/http.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/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/indent_wrapper.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:solian/widgets/posts/attachment_editor.dart'; 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();
@@ -32,6 +36,7 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => AttachmentEditor( builder: (context) => AttachmentEditor(
provider: 'interactive',
current: _attachments, current: _attachments,
onUpdate: (value) => _attachments = value, onUpdate: (value) => _attachments = value,
), ),
@@ -39,36 +44,45 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> {
} }
Future<void> applyPost(BuildContext context) async { Future<void> applyPost(BuildContext context) async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>(); final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return; if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
final uri = widget.editing == null final uri = widget.editing == null
? 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,
}); });
setState(() => _isSubmitting = true);
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() {
if (SolianRouter.router.canPop()) {
SolianRouter.router.pop(false);
}
}
@override @override
void initState() { void initState() {
if (widget.editing != null) { if (widget.editing != null) {
@@ -84,7 +98,22 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final auth = context.read<AuthProvider>(); final auth = context.read<AuthProvider>();
return IndentWrapper( 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)!.postEditNotify),
actions: [
TextButton(
child: Text(AppLocalizations.of(context)!.cancel),
onPressed: () => cancelEditing(),
),
],
);
return IndentScaffold(
showSafeArea: true,
hideDrawer: true, hideDrawer: true,
title: AppLocalizations.of(context)!.newMoment, title: AppLocalizations.of(context)!.newMoment,
appBarActions: <Widget>[ appBarActions: <Widget>[
@@ -93,68 +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() : 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: CircleAvatar(
backgroundImage: NetworkImage(userinfo["picture"]),
),
);
} else {
return Container();
}
},
),
const Divider(thickness: 0.3),
Expanded(
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
maxLines: null,
autofocus: true,
autocorrect: true,
keyboardType: TextInputType.multiline,
controller: _textController,
decoration: InputDecoration.collapsed(
hintText:
AppLocalizations.of(context)!.postContentPlaceholder,
),
), ),
), leading: AccountAvatar(
), source: userinfo['picture'],
Container( direct: true,
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,37 +4,53 @@ 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 = final PagingController<int, Post> _commentPagingController = PagingController(firstPageKey: 0);
PagingController(firstPageKey: 0);
Future<Post?> fetchPost(BuildContext context) async { Future<Post?> fetchPost(BuildContext context) async {
final uri = getRequestUri( final uri = getRequestUri('interactive', '/api/p/${widget.dataset}/${widget.alias}');
'interactive', '/api/p/${widget.dataset}/${widget.alias}');
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)));
@@ -43,46 +59,38 @@ class _PostScreenState extends State<PostScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IndentWrapper( return FutureBuilder(
hideDrawer: true, future: fetchPost(context),
title: AppLocalizations.of(context)!.post, builder: (context, snapshot) {
child: FutureBuilder( if (snapshot.hasData && snapshot.data != null) {
future: fetchPost(context), return CustomScrollView(
builder: (context, snapshot) { slivers: [
if (snapshot.hasData && snapshot.data != null) { SliverToBoxAdapter(
return Center( child: PostItem(
child: Container( item: snapshot.data!,
constraints: const BoxConstraints(maxWidth: 640), brief: false,
child: CustomScrollView( ripple: false,
slivers: [
SliverToBoxAdapter(
child: PostItem(
item: snapshot.data!,
brief: false,
),
),
SliverToBoxAdapter(
child: CommentListHeader(
related: snapshot.data!,
paging: _commentPagingController,
),
),
CommentList(
related: snapshot.data!,
dataset: widget.dataset,
paging: _commentPagingController,
),
],
), ),
), ),
); SliverToBoxAdapter(
} else { child: CommentListHeader(
return const Center( related: snapshot.data!,
child: CircularProgressIndicator(), paging: _commentPagingController,
); ),
} ),
}, CommentList(
), related: snapshot.data!,
dataset: widget.dataset,
paging: _commentPagingController,
),
],
);
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
); );
} }

View File

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

View File

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

View File

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

View File

@@ -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

@@ -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

@@ -0,0 +1,7 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
void initVideo() {
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
}

View File

@@ -0,0 +1,48 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:solian/utils/platform.dart';
import 'package:solian/utils/services_url.dart';
class AccountAvatar extends StatelessWidget {
final String source;
final double? radius;
final bool? direct;
final Color? backgroundColor;
const AccountAvatar({
super.key,
required this.source,
this.radius,
this.direct,
this.backgroundColor,
});
@override
Widget build(BuildContext context) {
final detectRegex = RegExp(r'https://.*/api/avatar/');
if (source.isEmpty || source.replaceAll(detectRegex, '').isEmpty) {
return CircleAvatar(
radius: radius,
backgroundColor: backgroundColor,
child: const Icon(Icons.account_circle),
);
}
if (direct == true) {
final image = PlatformInfo.canCacheImage ? CachedNetworkImageProvider(source) : NetworkImage(source);
return CircleAvatar(
radius: radius,
backgroundColor: backgroundColor,
backgroundImage: image as ImageProvider,
);
} else {
final url = getRequestUri('passport', '/api/avatar/$source').toString();
final image = PlatformInfo.canCacheImage ? CachedNetworkImageProvider(url) : NetworkImage(url);
return CircleAvatar(
radius: radius,
backgroundColor: backgroundColor,
backgroundImage: image as ImageProvider,
);
}
}
}

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/friend.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/account/account_avatar.dart';
class FriendPicker extends StatefulWidget {
const FriendPicker({super.key});
@override
State<FriendPicker> createState() => _FriendPickerState();
}
class _FriendPickerState extends State<FriendPicker> {
int _selfId = 0;
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () async {
final auth = context.read<AuthProvider>();
final friends = context.read<FriendProvider>();
friends.fetch(auth);
final prof = await auth.getProfiles();
_selfId = prof['id'];
});
}
@override
Widget build(BuildContext context) {
final dict = context.watch<FriendProvider>();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding:
const EdgeInsets.only(left: 16, right: 16, top: 32, bottom: 12),
child: Text(
AppLocalizations.of(context)!.friend,
style: Theme.of(context).textTheme.headlineSmall,
),
),
Expanded(
child: ListView.builder(
itemCount: dict.friends.length,
itemBuilder: (context, index) {
var element = dict.friends[index].getOtherside(_selfId);
return ListTile(
title: Text(element.nick),
subtitle: Text(element.name),
leading: AccountAvatar(source: element.avatar),
onTap: () {
Navigator.pop(context, element);
},
);
},
),
),
],
);
}
}

View File

@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:solian/models/personal_page.dart';
import 'package:url_launcher/url_launcher_string.dart';
class PersonalPageContent extends StatelessWidget {
final PersonalPage item;
const PersonalPageContent({super.key, required this.item});
@override
Widget build(BuildContext context) {
return Markdown(
selectable: true,
data: item.content,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.all(0),
onTapLink: (text, href, title) async {
if (href == null) return;
await launchUrlString(
href,
mode: LaunchMode.externalApplication,
);
},
);
}
}

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

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