Compare commits

...

67 Commits

Author SHA1 Message Date
a2a42f66a2 Editable channel notify level 2024-11-30 00:04:20 +08:00
51c7b03ff8 Editable channel profile 2024-11-29 23:48:39 +08:00
ddfbcc5e58 💄 Specialize details for 大吉 and 大凶 2024-11-29 23:30:40 +08:00
997562d174 🚀 Launch 2.0.0+11
 Album
2024-11-29 00:26:07 +08:00
df6f2af756 Leave channel 2024-11-29 00:01:41 +08:00
041be961c4 Delete account 2024-11-28 23:51:13 +08:00
36013a3a57 Editable channel 2024-11-28 23:35:25 +08:00
dc1ce94145 Delete post 2024-11-28 22:04:38 +08:00
2261528580 🐛 Bug fixes on daily check in 2024-11-28 13:15:15 +08:00
23301764ee 🚀 Launch 2.0.0+10
📱 Responsive home page
2024-11-28 00:29:53 +08:00
aa9724102b Birthday celebration 2024-11-28 00:04:45 +08:00
9395e081f0 Detailed daily check in fortune info 2024-11-27 23:37:40 +08:00
bd1d6b7be9 Basic daily sign in 2024-11-27 23:03:18 +08:00
dabb44635e 🍱 Use roboto as primary font 2024-11-27 21:18:02 +08:00
420588860a Post tags 2024-11-27 00:06:11 +08:00
312d68286e 🚀 Launch 2.0.0+9
⬆️ Upgrade deps
2024-11-26 22:06:05 +08:00
bedffbfad7 🐛 Fix notification screen error 2024-11-26 21:56:01 +08:00
6a3cd0a60d 🐛 Bug fixes on wrong push notification provider 2024-11-26 21:48:04 +08:00
356d3d4d3e :refactor: Central post fetching logic 2024-11-26 00:00:09 +08:00
41e2b08bcc Better attachment list 2024-11-25 22:41:15 +08:00
731ab97209 Post headline, and read est 2024-11-25 00:51:34 +08:00
a59de65130 💄 Optimization of UX in messages 2024-11-25 00:05:49 +08:00
9b6544df46 🚀 Launch 2.0.0+8
🐛 Bug fixes on background color
2024-11-24 20:54:01 +08:00
7221af75eb Call 2024-11-24 20:23:06 +08:00
66f41179ba Add livekit 2024-11-24 10:54:55 +08:00
ed32a31819 Add webrtc deps 2024-11-24 00:22:08 +08:00
33be7182d8 ♻️ Update platform specific code & resources 2024-11-23 22:04:21 +08:00
3cd08da3b6 ♻️ Replace storage token engine to prevent some platform specific issue 2024-11-23 19:54:38 +08:00
dfd80021b9 Search post 2024-11-23 19:06:09 +08:00
d64a24454d Set up sentry replay 2024-11-23 18:10:50 +08:00
0ed8c2373d Better reaction panel 2024-11-23 18:04:30 +08:00
b8a1e5b5c0 💫 Optimize transition of pages 2024-11-23 17:32:48 +08:00
5d6a52494e 🚀 Launch 2.0.0+7
 Add sentry
2024-11-23 17:13:28 +08:00
85a1dd3053 Notification screen 2024-11-23 16:55:23 +08:00
63499df99f 🐛 Bug fixes on notification push token register 2024-11-23 12:52:13 +08:00
e70041fefa 🚀 Launch 2.0.0+6 2024-11-22 00:46:55 +08:00
1af90cd9e7 Paste to add attachment 2024-11-22 00:28:29 +08:00
b52811d66e Ability to play video and audio
 Add media kit
2024-11-21 23:28:02 +08:00
7e63611416 Push token push (to server) 2024-11-21 22:55:00 +08:00
d41e358c6a ♻️ Optimized large screen display post effect
 Push notification
2024-11-21 22:10:12 +08:00
9fd30a1994 Add firebase 2024-11-21 00:18:11 +08:00
471d3deec5 ♻️ Optimize chat message display 2024-11-20 22:35:30 +08:00
c7f059b6d7 🐛 Fix bug render chat message on cannot find user 2024-11-20 00:13:36 +08:00
6af695d74e 🐛 Bug fixes on loading more messages 2024-11-19 22:17:17 +08:00
fd272ead37 👽 Update follow server side IM changes 2024-11-18 23:59:08 +08:00
6c5377d9fa 💥 Use quoteEventId column instead of quote_event in message body 2024-11-18 23:04:36 +08:00
ce414d92a2 Chat context menu (w.i.p) 2024-11-18 22:52:22 +08:00
5032cccf38 Chat quote and reply 2024-11-18 22:33:03 +08:00
9f7a3082cb Message with attachment 2024-11-18 21:38:15 +08:00
359cd94532 ♻️ Refactored attachment cache 2024-11-18 00:55:39 +08:00
432705c570 💄 Mergeable chat messages 2024-11-17 22:42:09 +08:00
2065350698 Chat message sending and receiving 2024-11-17 21:30:02 +08:00
285bb42b09 Basic message sending and listing 2024-11-17 01:16:54 +08:00
e9fbd0c65f Chat listing 2024-11-16 21:15:55 +08:00
835203706d Channel creation & alter 2024-11-16 16:55:31 +08:00
0e208cc320 Realm manage (CRUD) 2024-11-16 13:54:36 +08:00
ee2cb0c989 💫 Optimize nav transition 2024-11-15 23:08:29 +08:00
37c61a0406 Optimize nav transition performance 2024-11-15 22:46:12 +08:00
fa73a28324 🚀 Launch 2.0.0+4 (canary preview) 2024-11-15 00:55:49 +08:00
d945b103ca Websocket connection status indicator 2024-11-15 00:52:31 +08:00
8bc0da5188 Basic websocket connection 2024-11-15 00:24:46 +08:00
2e68d227a0 ⬆️ Upgrade deps 2024-11-14 23:25:02 +08:00
b8245b00b6 💄 Post item maxWidth 2024-11-14 22:49:17 +08:00
462e818078 💄 Optimize attachment list 2024-11-14 22:42:06 +08:00
e4582b7d25 ♻️ Optimized responsive navigation 2024-11-14 22:21:13 +08:00
00eef6e45a 🐛 Fix app drawer show on mobile 2024-11-14 13:02:42 +08:00
9498d428cd 🐛 Use only lang code on localization to fix not found bug 2024-11-14 00:40:58 +08:00
122 changed files with 15950 additions and 1585 deletions

View File

@ -9,6 +9,13 @@
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
errors:
invalid_annotation_target: ignore # Due to freezed + json_serializable issue, ref https://github.com/rrousselGit/freezed/issues/488#issuecomment-894358980
linter: linter:
# The lint rules applied to this project can be customized in the # The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml` # section below to disable rules from the `package:flutter_lints/flutter.yaml`

View File

@ -1,5 +1,9 @@
plugins { plugins {
id "com.android.application" id "com.android.application"
// START: FlutterFire Configuration
id 'com.google.gms.google-services'
id 'com.google.firebase.crashlytics'
// END: FlutterFire Configuration
id "kotlin-android" id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin" id "dev.flutter.flutter-gradle-plugin"
@ -8,15 +12,15 @@ plugins {
android { android {
namespace = "dev.solsynth.solian" namespace = "dev.solsynth.solian"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = "27.0.12077973"
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8 jvmTarget = JavaVersion.VERSION_17
} }
defaultConfig { defaultConfig {

View File

@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "961776991058",
"project_id": "solian-0x001",
"storage_bucket": "solian-0x001.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:961776991058:android:a8d3f7995b0b8e86f4188b",
"android_client_info": {
"package_name": "dev.solsynth.solian"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyDvFNudXYs29uDtcCv6pFR8h5tXBs90FYk"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@ -1,6 +1,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.INTERNET" />
<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_ADMIN" android:maxSdkVersion="30" />
<application <application
android:label="surface" android:label="Solian"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity
@ -17,12 +28,12 @@
while the Flutter UI initializes. After that, this theme continues while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. --> to determine the Window background behind the Flutter UI. -->
<meta-data <meta-data
android:name="io.flutter.embedding.android.NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" android:resource="@style/NormalTheme"
/> />
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
@ -38,8 +49,8 @@
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. --> In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain" />
</intent> </intent>
</queries> </queries>
</manifest> </manifest>

View File

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip

View File

@ -18,7 +18,11 @@ 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 "8.1.0" apply false id "com.android.application" version '8.7.2' apply false
// START: FlutterFire Configuration
id "com.google.gms.google-services" version "4.3.15" apply false
id "com.google.firebase.crashlytics" version "2.8.1" apply false
// END: FlutterFire Configuration
id "org.jetbrains.kotlin.android" version "1.8.22" apply false id "org.jetbrains.kotlin.android" version "1.8.22" apply false
} }

View File

@ -1,135 +0,0 @@
{
"nextVersionAlert": "Heavy Development Alert",
"nextVersionNotice": "You are using Solian 2.0 Preview, which is the first version of Solian 2.0. The current stable branch (sn.solsynth.dev) is 1.4. This version is still under heavy development, some features may not be stable, and not all features are supported. You can roll back to 1.4.X version via TestFlight, or continue to experience the new version (sn-next.solsynth.dev).",
"screen": "Screen",
"screenHome": "Home",
"screenExplore": "Explore",
"screenAccount": "Account",
"screenAuthLogin": "Login",
"screenAuthLoginSubtitle": "Login to Solar Network using Solarpass",
"screenAuthLoginGreeting": "Welcome back",
"screenAuthRegister": "Create an account",
"screenAuthRegisterSubtitle": "Create a Solarpass account",
"screenAccountPublishers": "Publishers",
"screenAccountPublisherNew": "New Publisher",
"screenAccountPublisherEdit": "Edit Publisher",
"screenAccountProfileEdit": "Edit Profile",
"screenSettings": "Settings",
"screenAlbum": "Album",
"screenChat": "Chat",
"dialogOkay": "Okay",
"dialogCancel": "Cancel",
"dialogConfirm": "Confirm",
"dialogDismiss": "Dismiss",
"dialogError": "Something went wrong",
"errorRequestBad": "Bad request, please check your input.",
"errorRequestUnauthorized": "Unauthorized request, please login or try re-login.",
"errorRequestForbidden": "Forbidden request, you have not enough permission to do that.",
"errorRequestNotFound": "The resource that you looking for is not found.",
"errorRequestConnection": "Network connection error, please check your network or the service status.",
"errorRequestUnknown": "Unknown request error, maybe you want to take screenshot and report it to us.",
"prev": "Previous",
"next": "Next",
"edit": "Edit",
"apply": "Apply",
"create": "Create",
"preview": "Preview",
"loading": "Loading...",
"delete": "Delete",
"unlink": "Unlink",
"crop": "Crop",
"compress": "Compress",
"report": "Report",
"repost": "Repost",
"reply": "Reply",
"unset": "Unset",
"untitled": "Untitled",
"postDetail": "Post detail",
"postNoun": "Post",
"fieldUsername": "Username",
"fieldNickname": "Nickname",
"fieldEmail": "Email address",
"fieldPassword": "Password",
"fieldDescription": "Description",
"fieldUsernameCannotEditHint": "Username cannot be edited after created",
"fieldUsernameLookupHint": "You can use username, phone number or email to login",
"fieldFirstName": "First name",
"fieldLastName": "Last name",
"fieldBirthday": "Birthday",
"fieldImageHint": "You can click those profile pictures to edit them.",
"forgotPassword": "Forgot password",
"loginPickFactor": "Pick a factor",
"loginMultiFactor": {
"one": "{} step left",
"other": "{} steps left"
},
"loginEnterPassword": "Enter the code",
"loginSuccess": "Logged in as {}",
"authFactorPassword": "Password",
"authFactorEmail": "Email verification code",
"accountIntroTitle": "Hello there!",
"accountIntroSubtitle": "Pick an option below to get started.",
"accountLogout": "Logout",
"accountLogoutSubtitle": "Log out of the current account.",
"accountLogoutConfirmTitle": "Are you sure you want to logout?",
"accountLogoutConfirm": "You will need to re-enter your account password, even if you have already done so. This is required to login again.",
"accountPublishers": "Your publishers",
"accountPublishersSubtitle": "Manage your publish identities.",
"accountProfileEdit": "Edit your profile",
"accountProfileEditSubtitle": "Make your Solarpass account more looks like you.",
"accountProfileEditApplied": "Profile modification applied.",
"publishersNew": "New Publisher",
"publisherNewSubtitle": "Create a new publisher identity.",
"publisherSyncWithAccount": "Sync with account",
"writePostTypeStory": "Post a story",
"writePostTypeArticle": "Write an article",
"fieldPostPublisher": "Post publisher",
"fieldPostContent": "What happened?!",
"fieldPostTitle": "Title",
"fieldPostDescription": "Description",
"postPublish": "Publish",
"postPosted": "Post has been posted.",
"postPublishedAt": "Published At",
"postPublishedUntil": "Published Until",
"postEditingNotice": "You're about to editing a post that posted {}.",
"postReplyingNotice": "You're about to reply to a post that posted {}.",
"postRepostingNotice": "You're about to repost a post that posted {}.",
"postReact": "React",
"postReactions": "Reactions of Post",
"postReactionPoints": {
"zero": "{} pt",
"one": "{} pt",
"other": "{} pts"
},
"postReactCompleted": "Reaction has been added.",
"postReactUncompleted": "Reaction has been removed.",
"postComments": {
"zero": "Comment",
"one": "{} comment",
"other": "{} comments"
},
"postCommentsDetailed": {
"zero": "No comments",
"one": "{} comment",
"other": "{} comments"
},
"settingsAppearance": "Appearance",
"settingsBackgroundImage": "Background Image",
"settingsBackgroundImageDescription": "Set the background image that will be applied globally.",
"settingsBackgroundImageClear": "Clear Existing Background Image",
"settingsBackgroundImageClearDescription": "Reset the background image to blank.",
"settingsThemeMaterial3": "Use Material You Design",
"settingsThemeMaterial3Description": "Set the application theme to Material 3 Design.",
"settingsNetwork": "Network",
"settingsNetworkServer": "HyperNet Server",
"settingsNetworkServerDescription": "Set the HyperNet server address, choose ours or build your own.",
"settingsNetworkServerReset": "Reset to Official Server",
"settingsNetworkServerResetDescription": "Reset to the official server address of Solar Network.",
"settingsNetworkServerPreset": "Present HyperNet Server",
"settingsNetworkServerPresetDescription": "You can choose one of our preset HyperNet server addresses from the list on the right.",
"settingsNetworkServerSaved": "Server address saved.",
"sensitiveContent": "Sensitive Content",
"sensitiveContentCollapsed": "Sensitive content has been collapsed.",
"sensitiveContentDescription": "This content has been marked as sensitive, and may not be suitable for all viewers.",
"sensitiveContentReveal": "Reveal"
}

300
assets/translations/en.json Normal file
View File

@ -0,0 +1,300 @@
{
"nextVersionAlert": "Heavy Development Alert",
"nextVersionNotice": "You are using Solian 2.0 Preview, which is the first version of Solian 2.0. The current stable branch (sn.solsynth.dev) is 1.4. This version is still under heavy development, some features may not be stable, and not all features are supported. You can roll back to 1.4.X version via TestFlight, or continue to experience the new version (sn-next.solsynth.dev).",
"screen": "Screen",
"screenHome": "Home",
"screenExplore": "Explore",
"screenAccount": "Account",
"screenAuthLogin": "Login",
"screenAuthLoginSubtitle": "Login to Solar Network using Solarpass",
"screenAuthLoginGreeting": "Welcome back",
"screenAuthRegister": "Create an account",
"screenAuthRegisterSubtitle": "Create a Solarpass account",
"screenAccountPublishers": "Publishers",
"screenAccountPublisherNew": "New Publisher",
"screenAccountPublisherEdit": "Edit Publisher",
"screenAccountProfileEdit": "Edit Profile",
"screenSettings": "Settings",
"screenAlbum": "Album",
"screenChat": "Chat",
"screenChatManage": "Edit Channel",
"screenChatNew": "New Channel",
"screenRealm": "Realm",
"screenRealmManage": "Edit Realm",
"screenRealmNew": "New Realm",
"screenNotification": "Notification",
"screenPostSearch": "Search Posts",
"dialogOkay": "Okay",
"dialogCancel": "Cancel",
"dialogConfirm": "Confirm",
"dialogDismiss": "Dismiss",
"dialogError": "Something went wrong",
"errorRequestBad": "Bad request, please check your input.",
"errorRequestUnauthorized": "Unauthorized request, please login or try re-login.",
"errorRequestForbidden": "Forbidden request, you have not enough permission to do that.",
"errorRequestNotFound": "The resource that you looking for is not found.",
"errorRequestConnection": "Network connection error, please check your network or the service status.",
"errorRequestUnknown": "Unknown request error, maybe you want to take screenshot and report it to us.",
"prev": "Previous",
"next": "Next",
"edit": "Edit",
"apply": "Apply",
"cancel": "Cancel",
"create": "Create",
"preview": "Preview",
"loading": "Loading...",
"delete": "Delete",
"unlink": "Unlink",
"crop": "Crop",
"compress": "Compress",
"report": "Report",
"repost": "Repost",
"replyPost": "Reply",
"reply": "Reply",
"unset": "Unset",
"untitled": "Untitled",
"postDetail": "Post detail",
"postNoun": "Post",
"postReadMore": "Read more",
"postReadEstimate": "Est read time {}",
"postTotalLength": {
"zero": "No character",
"one": "{} character",
"other": "{} characters"
},
"fieldUsername": "Username",
"fieldNickname": "Nickname",
"fieldEmail": "Email address",
"fieldPassword": "Password",
"fieldDescription": "Description",
"fieldUsernameCannotEditHint": "Username cannot be edited after created",
"fieldUsernameLookupHint": "You can use username, phone number or email to login",
"fieldFirstName": "First name",
"fieldLastName": "Last name",
"fieldBirthday": "Birthday",
"fieldImageHint": "You can click those profile pictures to edit them.",
"forgotPassword": "Forgot password",
"loginPickFactor": "Pick a factor",
"loginMultiFactor": {
"one": "{} step left",
"other": "{} steps left"
},
"loginEnterPassword": "Enter the code",
"loginSuccess": "Logged in as {}",
"authFactorPassword": "Password",
"authFactorEmail": "Email verification code",
"accountIntroTitle": "Hello there!",
"accountIntroSubtitle": "Pick an option below to get started.",
"accountLogout": "Logout",
"accountLogoutSubtitle": "Log out of the current account.",
"accountLogoutConfirmTitle": "Are you sure you want to logout?",
"accountLogoutConfirm": "You will need to re-enter your account password, even if you have already done so. This is required to login again.",
"accountPublishers": "Your publishers",
"accountPublishersSubtitle": "Manage your publish identities.",
"accountProfileEdit": "Edit your profile",
"accountProfileEditSubtitle": "Make your Solarpass account more looks like you.",
"accountProfileEditApplied": "Profile modification applied.",
"publishersNew": "New Publisher",
"publisherNewSubtitle": "Create a new publisher identity.",
"publisherSyncWithAccount": "Sync with account",
"writePostTypeStory": "Post a story",
"writePostTypeArticle": "Write an article",
"fieldPostPublisher": "Post publisher",
"fieldPostContent": "What happened?!",
"fieldPostTitle": "Title",
"fieldPostDescription": "Description",
"fieldPostTags": "Tags",
"postPublish": "Publish",
"postPosted": "Post has been posted.",
"postPublishedAt": "Published At",
"postPublishedUntil": "Published Until",
"postEditingNotice": "You're about to editing a post that posted {}.",
"postReplyingNotice": "You're about to reply to a post that posted {}.",
"postRepostingNotice": "You're about to repost a post that posted {}.",
"postReact": "React",
"postReactions": "Reactions of Post",
"postReactionUpvote": {
"zero": "0 upvote",
"one": "{} upvote",
"other": "{} upvotes"
},
"postReactionDownvote": {
"zero": "0 downvote",
"one": "{} downvote",
"other": "{} downvotes"
},
"postReactionSocialPoint": {
"zero": "0 point",
"one": "{} point",
"other": "{} points"
},
"postReactCompleted": "Reaction has been added.",
"postReactUncompleted": "Reaction has been removed.",
"postComments": {
"zero": "Comment",
"one": "{} comment",
"other": "{} comments"
},
"postCommentsDetailed": {
"zero": "No comments",
"one": "{} comment",
"other": "{} comments"
},
"settingsAppearance": "Appearance",
"settingsBackgroundImage": "Background Image",
"settingsBackgroundImageDescription": "Set the background image that will be applied globally.",
"settingsBackgroundImageClear": "Clear Existing Background Image",
"settingsBackgroundImageClearDescription": "Reset the background image to blank.",
"settingsThemeMaterial3": "Use Material You Design",
"settingsThemeMaterial3Description": "Set the application theme to Material 3 Design.",
"settingsNetwork": "Network",
"settingsNetworkServer": "HyperNet Server",
"settingsNetworkServerDescription": "Set the HyperNet server address, choose ours or build your own.",
"settingsNetworkServerReset": "Reset to Official Server",
"settingsNetworkServerResetDescription": "Reset to the official server address of Solar Network.",
"settingsNetworkServerPreset": "Present HyperNet Server",
"settingsNetworkServerPresetDescription": "You can choose one of our preset HyperNet server addresses from the list on the right.",
"settingsNetworkServerSaved": "Server address saved.",
"sensitiveContent": "Sensitive Content",
"sensitiveContentCollapsed": "Sensitive content has been collapsed.",
"sensitiveContentDescription": "This content has been marked as sensitive, and may not be suitable for all viewers.",
"sensitiveContentReveal": "Reveal",
"serverConnecting": "Connecting to server...",
"serverDisconnected": "Lost connection from server",
"fieldChatAlias": "Channel Alias",
"fieldChatAliasHint": "The unique channel alias within the site, used to represent the channel in URL, leave blank to auto generate. Should be URL-Safe.",
"fieldChatName": "Name",
"fieldChatDescription": "Description",
"fieldChatBelongToRealm": "Belongs to",
"fieldChatBelongToRealmUnset": "Unset Channel Belongs to Realm",
"channelEditingNotice": "You are editing channel {}",
"channelDeleted": "Chat channel {} has been deleted." ,
"channelDelete": "Delete channel {}",
"channelDeleteDescription": "Are you sure you want to delete this channel? This operation is irreversible, all messages in this channel will be permanently deleted.",
"channelDetailPersonalRegion": "Personal",
"channelDetailMemberRegion": "Members",
"channelDetailAdminRegion": "Administration",
"channelEditProfile": "Edit Channel Profile",
"channelEdit": "Edit Channel",
"channelEditDescription": "Change the basic information of the channel, metadata, etc.",
"channelProfileEdit": "Edit Channel Profile",
"channelActionDelete": "Delete Channel",
"channelActionDeleteDescription": "Delete the entire channel, and also delete messages in the channel.",
"channelLeave": "Leave Channel {}",
"channelLeaveDescription": "Leave this channel, but the messages in the channel will not be removed.",
"channelActionLeave": "Leave Channel",
"channelActionLeaveDescription": "Delete your profile in this channel.",
"channelNotifyLevel": "Notify Level",
"channelNotifyLevelDescription": "Decide to receive how much notifications from this channel.",
"channelNotifyLevelAll": "All",
"channelNotifyLevelMentioned": "Only Mentioned",
"channelNotifyLevelNone": "Muted",
"channelNotifyLevelApplied": "Channel notify level has been applied.",
"fieldChannelProfileNick": "In-Channel Display Name",
"fieldChannelProfileNickHint": "The nickname to display in the channel, leave blank to use the account display name.",
"fieldRealmAlias": "Realm Alias",
"fieldRealmAliasHint": "The unique realm alias within the site, used to represent the realm in URL, leave blank to auto generate. Should be URL-Safe.",
"fieldRealmName": "Name",
"fieldRealmDescription": "Description",
"realmEditingNotice": "You are editing realm {}",
"realmDeleted": "Realm {} has been deleted.",
"realmDelete": "Delete realm {}",
"realmDeleteDescription": "Are you sure you want to delete this realm? This operation is irreversible, all resources (posts, chat channels, publishers, etc) belonging to this realm will be permanently deleted. Be careful and think twice!",
"fieldChatMessage": "Message in {}",
"eventResourceTag": "Event {}",
"messageDelete": "Delete message {}",
"messageDeleteDescription": "Are you sure you want to delete this message? This operation is irreversible. You will leave a record of the deleted message.",
"messageDeleted": "Message {} has been deleted",
"messageEdited": "Message {} has been edited",
"messageEditedHint": "Edited",
"messageUnsupported": "Unsupported message {}",
"messageFileHint": {
"zero": "No attachments",
"one": "{} attachment",
"other": "{} attachments"
},
"addAttachmentFromAlbum": "Add from album",
"addAttachmentFromClipboard": "Paste file",
"attachmentPastedImage": "Pasted Image",
"notificationUnread": "未读",
"notificationRead": "已读",
"notificationMarkAllRead": "Mark all notifications as read",
"notificationMarkAllReadDescription": "Are you sure you want to mark all notifications as read? This operation is irreversible.",
"notificationMarkAllReadPrompt": {
"zero": "Marked 0 notification as read.",
"one": "Marked {} notification as read.",
"other": "Marked {} notifications as read."
},
"notificationMarkOneReadPrompt": "Marked notification {} as read.",
"postSearchResult": {
"zero": "No results",
"one": "{} result",
"other": "{} results"
},
"postSearchTook": "Took {}",
"postDelete": "Delete post {}",
"postDeleteDescription": "Are you sure you want to delete this post? This operation is irreversible.",
"postDeleted": "Post {} has been deleted.",
"call" : "Call",
"callOngoingNotice": "A call is ongoing",
"callJoin": "Join",
"callResume": "Resume",
"callMicrophone": "Microphone",
"callCamera": "Camera",
"callMicrophoneDisabled": "Microphone is disabled",
"callMicrophoneSelect": "Select a microphone",
"callCameraDisabled": "Camera is disabled",
"callCameraSelect": "Select a camera",
"callDisconnected": "Call has been disconnected",
"callEnded": "Call has been ended",
"callStatusConnected": "Connected",
"callStatusDisconnected": "Disconnected",
"callStatusConnecting": "Connecting",
"callStatusReconnecting": "Reconnecting",
"callDisconnect": "Disconnect",
"callDisconnectDescription": "Are you sure you want to disconnect from the call?",
"callMicrophoneOff": "Turn off microphone",
"callMicrophoneOn": "Turn on microphone",
"callCameraOff": "Turn off camera",
"callCameraOn": "Turn on camera",
"callVideoFlip": "Mirror video",
"callSpeakerphoneToggle": "Toggle speakerphone",
"callScreenOff": "Turn off screen share",
"callScreenOn": "Turn on screen share",
"callMessageEnded": "Call lasted {}",
"callMessageStarted": "Call started",
"dailyCheckIn": "Check In",
"dailyCheckInNone": "You haven't checked in today",
"dailyCheckAction": "Check in right now!",
"dailyCheckDetail": "Can't understand the symbol? Master, help me understand it!",
"dailyCheckDetailTitle": "{}'s fortune details",
"dailyCheckPositiveHint": "Good for {}",
"dailyCheckNegativeHint": "Bad for {}",
"dailyCheckEverythingIsPositive": "Everything going to be awesome!",
"dailyCheckEverythingIsNegative": "Everything may be wrong...",
"dailyCheckPositiveHint1": "Making friends",
"dailyCheckPositiveHint1Description": "Friendship lasts forever",
"dailyCheckPositiveHint2": "Drinking",
"dailyCheckPositiveHint2Description": "Drinking under the moonlight with an imaginary companion",
"dailyCheckPositiveHint3": "Traveling",
"dailyCheckPositiveHint3Description": "A journey of a thousand miles begins with a single step",
"dailyCheckPositiveHint4": "Exercising",
"dailyCheckPositiveHint4Description": "Life lies in movement",
"dailyCheckPositiveHint5": "Learning",
"dailyCheckPositiveHint5Description": "Knowledge knows no bounds; progress every day",
"dailyCheckPositiveHint6": "Planting",
"dailyCheckPositiveHint6Description": "Sow hope, reap the future",
"dailyCheckNegativeHint1": "Eating",
"dailyCheckNegativeHint1Description": "Biting your tongue while eating",
"dailyCheckNegativeHint2": "Taking exams",
"dailyCheckNegativeHint2Description": "The exam covered what you didn't review",
"dailyCheckNegativeHint3": "Catching a bus",
"dailyCheckNegativeHint3Description": "Just missed the bus",
"dailyCheckNegativeHint4": "Shopping",
"dailyCheckNegativeHint4Description": "Bought clothes that don't fit",
"dailyCheckNegativeHint5": "Gaming",
"dailyCheckNegativeHint5Description": "Lost connection at a crucial moment",
"dailyCheckNegativeHint6": "Going out",
"dailyCheckNegativeHint6Description": "Forgot your umbrella and got caught in the rain",
"happyBirthday": "Happy birthday, {}!"
}

View File

@ -1,135 +0,0 @@
{
"nextVersionAlert": "高强度开发提示",
"nextVersionNotice": "您正在使用的是 Solian 2.0 的抢先体验版本目前稳定分支sn.solsynth.dev版本为 1.4。该版本还在持续的开发中,部分功能可能不稳定,也并非所有功能都支持了。您可以通过 TestFlight 回滚到 1.4.X 或者继续体验新版本sn-next.solsynth.dev。",
"screen": "页面",
"screenHome": "首页",
"screenExplore": "探索",
"screenAccount": "您",
"screenAuthLogin": "登陆",
"screenAuthLoginSubtitle": "使用 Solarpass 登陆 Solar Network",
"screenAuthLoginGreeting": "欢迎回来",
"screenAuthRegister": "创建账号",
"screenAuthRegisterSubtitle": "创建一个 Solarpass 账号",
"screenAccountPublishers": "发布者",
"screenAccountPublisherNew": "新建发布者",
"screenAccountPublisherEdit": "编辑发布者",
"screenAccountProfileEdit": "编辑资料",
"screenSettings": "设置",
"screenAlbum": "相册",
"screenChat": "聊天",
"dialogOkay": "好的",
"dialogCancel": "取消",
"dialogConfirm": "确认",
"dialogDismiss": "忽略",
"dialogError": "出了点问题",
"errorRequestBad": "服务器拒绝了您的请求,请检查您的输入。",
"errorRequestUnauthorized": "未授权的请求,请登录或者尝试重新登陆。",
"errorRequestForbidden": "被禁止的请求,您没有足够的权限去做那件事。",
"errorRequestNotFound": "您正查找的资源无法被找到。",
"errorRequestConnection": "网络连接错误,请检查您的网络状态或者检查我们的服务状态。",
"errorRequestUnknown": "位置请求错误,您可能想将此对话框截图并发送给我们。",
"loading": "加载中…",
"prev": "上一步",
"next": "下一步",
"edit": "编辑",
"apply": "应用",
"create": "创建",
"preview": "预览",
"delete": "删除",
"unlink": "解除链接",
"crop": "裁剪",
"compress": "压缩",
"report": "检举",
"repost": "转帖",
"reply": "回贴",
"unset": "未设置",
"untitled": "无题",
"postDetail": "帖子详情",
"postNoun": "帖子",
"fieldUsername": "用户名",
"fieldNickname": "显示名",
"fieldEmail": "电子邮箱地址",
"fieldPassword": "密码",
"fieldUsernameCannotEditHint": "用户名在创建后无法修改",
"fieldUsernameLookupHint": "支持用户名、电话号码或邮箱地址",
"fieldFirstName": "名",
"fieldLastName": "姓",
"fieldBirthday": "生日",
"fieldImageHint": "你可以点击这些个人头像来编辑它们。",
"fieldDescription": "简介",
"forgotPassword": "忘记密码",
"loginPickFactor": "选择方式验证",
"loginMultiFactor": {
"one": "{} 步验证",
"other": "{} 步验证"
},
"loginEnterPassword": "验证代码",
"loginSuccess": "登录为 {}",
"authFactorPassword": "密码",
"authFactorEmail": "电邮一次性验证码",
"accountIntroTitle": "喜欢您来!",
"accountIntroSubtitle": "登陆以探索更广大的世界。",
"accountLogout": "退出登录",
"accountLogoutSubtitle": "注销当前账户的登陆状态。",
"accountLogoutConfirmTitle": "您确定要退出登录吗?",
"accountLogoutConfirm": "您需要重新输入账号密码,甚至可能需要多步验证来再次登陆。",
"accountPublishers": "你的发布者",
"accountPublishersSubtitle": "管理你的公共形象。",
"accountProfileEdit": "编辑资料",
"accountProfileEditSubtitle": "使你的 Solarpass 账户更像你。",
"accountProfileEditApplied": "个人资料修改已被应用。",
"publishersNew": "新发布者",
"publisherNewSubtitle": "创建一个新的公共身份。",
"publisherSyncWithAccount": "同步账户信息",
"writePostTypeStory": "发动态",
"writePostTypeArticle": "写文章",
"fieldPostPublisher": "帖子发布者",
"fieldPostContent": "发生什么事了?!",
"fieldPostTitle": "标题",
"fieldPostDescription": "描述",
"postPublish": "发布",
"postPublishedAt": "发布于",
"postPublishedUntil": "取消发布于",
"postEditingNotice": "你正在修改由 {} 发布的帖子。",
"postReplyingNotice": "你正在回复由 {} 发布的帖子。",
"postRepostingNotice": "你正在转发由 {} 发布的帖子。",
"postReact": "反应",
"postPosted": "帖子已经发表。",
"postReactions": "帖子的反应",
"postReactionPoints": {
"zero": "{} 点",
"one": "{} 点",
"other": "{} 点"
},
"postReactCompleted": "反应已被添加。",
"postReactUncompleted": "反应已被移除。",
"postComments": {
"zero": "评论",
"one": "{} 条评论",
"other": "{} 条评论"
},
"postCommentsDetailed": {
"zero": "没有评论",
"one": "{} 条评论",
"other": "{} 条评论"
},
"settingsAppearance": "外观",
"settingsBackgroundImage": "背景图片",
"settingsBackgroundImageDescription": "设置应用全局生效的的背景图片。",
"settingsBackgroundImageClear": "清除现存背景图",
"settingsBackgroundImageClearDescription": "将应用背景图重置为空白。",
"settingsThemeMaterial3": "使用 Material You 设计范式",
"settingsThemeMaterial3Description": "将应用主题设置为 Material 3 设计范式的主题。",
"settingsNetwork": "网络",
"settingsNetworkServer": "HyperNet 服务器",
"settingsNetworkServerDescription": "设置 HyperNet 服务器地址,选择我们提供的,或者自己搭建。",
"settingsNetworkServerReset": "重设为官方服务器",
"settingsNetworkServerResetDescription": "重设为 Solar Network 的服务器地址。",
"settingsNetworkServerPreset": "预设的 HyperNet 服务器",
"settingsNetworkServerPresetDescription": "你可以在旁边的列表中选择我们提供的预设 HyperNet 服务器地址。",
"settingsNetworkServerSaved": "服务器地址已保存。",
"sensitiveContent": "敏感内容",
"sensitiveContentCollapsed": "敏感内容已折叠。",
"sensitiveContentDescription": "此内容已被标记,可能不适合所有人查看。",
"sensitiveContentReveal": "显示内容"
}

300
assets/translations/zh.json Normal file
View File

@ -0,0 +1,300 @@
{
"nextVersionAlert": "高强度开发提示",
"nextVersionNotice": "您正在使用的是 Solian 2.0 的抢先体验版本目前稳定分支sn.solsynth.dev版本为 1.4。该版本还在持续的开发中,部分功能可能不稳定,也并非所有功能都支持了。您可以通过 TestFlight 回滚到 1.4.X 或者继续体验新版本sn-next.solsynth.dev。",
"screen": "页面",
"screenHome": "首页",
"screenExplore": "探索",
"screenAccount": "您",
"screenAuthLogin": "登陆",
"screenAuthLoginSubtitle": "使用 Solarpass 登陆 Solar Network",
"screenAuthLoginGreeting": "欢迎回来",
"screenAuthRegister": "创建账号",
"screenAuthRegisterSubtitle": "创建一个 Solarpass 账号",
"screenAccountPublishers": "发布者",
"screenAccountPublisherNew": "新建发布者",
"screenAccountPublisherEdit": "编辑发布者",
"screenAccountProfileEdit": "编辑资料",
"screenSettings": "设置",
"screenAlbum": "相册",
"screenChat": "聊天",
"screenChatManage": "编辑聊天频道",
"screenChatNew": "新建聊天频道",
"screenRealm": "领域",
"screenRealmManage": "编辑领域",
"screenRealmNew": "新建领域",
"screenNotification": "通知",
"screenPostSearch": "搜索帖子",
"dialogOkay": "好的",
"dialogCancel": "取消",
"dialogConfirm": "确认",
"dialogDismiss": "忽略",
"dialogError": "出了点问题",
"errorRequestBad": "服务器拒绝了您的请求,请检查您的输入。",
"errorRequestUnauthorized": "未授权的请求,请登录或者尝试重新登陆。",
"errorRequestForbidden": "被禁止的请求,您没有足够的权限去做那件事。",
"errorRequestNotFound": "您正查找的资源无法被找到。",
"errorRequestConnection": "网络连接错误,请检查您的网络状态或者检查我们的服务状态。",
"errorRequestUnknown": "位置请求错误,您可能想将此对话框截图并发送给我们。",
"loading": "加载中…",
"prev": "上一步",
"next": "下一步",
"edit": "编辑",
"apply": "应用",
"cancel": "取消",
"create": "创建",
"preview": "预览",
"delete": "删除",
"unlink": "解除链接",
"crop": "裁剪",
"compress": "压缩",
"report": "检举",
"repost": "转帖",
"replyPost": "回贴",
"reply": "回复",
"unset": "未设置",
"untitled": "无题",
"postDetail": "帖子详情",
"postNoun": "帖子",
"postReadMore": "阅读更多",
"postReadEstimate": "预计花费 {} 阅读",
"postTotalLength": {
"zero": "没有内容",
"one": "总计 {} 字",
"other": "总计 {} 字"
},
"fieldUsername": "用户名",
"fieldNickname": "显示名",
"fieldEmail": "电子邮箱地址",
"fieldPassword": "密码",
"fieldUsernameCannotEditHint": "用户名在创建后无法修改",
"fieldUsernameLookupHint": "支持用户名、电话号码或邮箱地址",
"fieldFirstName": "名",
"fieldLastName": "姓",
"fieldBirthday": "生日",
"fieldImageHint": "你可以点击这些个人头像来编辑它们。",
"fieldDescription": "简介",
"forgotPassword": "忘记密码",
"loginPickFactor": "选择方式验证",
"loginMultiFactor": {
"one": "{} 步验证",
"other": "{} 步验证"
},
"loginEnterPassword": "验证代码",
"loginSuccess": "登录为 {}",
"authFactorPassword": "密码",
"authFactorEmail": "电邮一次性验证码",
"accountIntroTitle": "喜欢您来!",
"accountIntroSubtitle": "登陆以探索更广大的世界。",
"accountLogout": "退出登录",
"accountLogoutSubtitle": "注销当前账户的登陆状态。",
"accountLogoutConfirmTitle": "您确定要退出登录吗?",
"accountLogoutConfirm": "您需要重新输入账号密码,甚至可能需要多步验证来再次登陆。",
"accountPublishers": "你的发布者",
"accountPublishersSubtitle": "管理你的公共形象。",
"accountProfileEdit": "编辑资料",
"accountProfileEditSubtitle": "使你的 Solarpass 账户更像你。",
"accountProfileEditApplied": "个人资料修改已被应用。",
"publishersNew": "新发布者",
"publisherNewSubtitle": "创建一个新的公共身份。",
"publisherSyncWithAccount": "同步账户信息",
"writePostTypeStory": "发动态",
"writePostTypeArticle": "写文章",
"fieldPostPublisher": "帖子发布者",
"fieldPostContent": "发生什么事了?!",
"fieldPostTitle": "标题",
"fieldPostDescription": "描述",
"fieldPostTags": "标签",
"postPublish": "发布",
"postPublishedAt": "发布于",
"postPublishedUntil": "取消发布于",
"postEditingNotice": "你正在修改由 {} 发布的帖子。",
"postReplyingNotice": "你正在回复由 {} 发布的帖子。",
"postRepostingNotice": "你正在转发由 {} 发布的帖子。",
"postReact": "反应",
"postPosted": "帖子已经发表。",
"postReactions": "帖子的反应",
"postReactionUpvote": {
"zero": "0 个顶",
"one": "{} 个顶",
"other": "{} 个顶"
},
"postReactionDownvote": {
"zero": "0 个踩",
"one": "{} 个踩",
"other": "{} 个踩"
},
"postReactionSocialPoint": {
"zero": "无社会信用点变更",
"one": "{} 点社会信用点变更",
"other": "{} 点社会信用点变更"
},
"postReactCompleted": "反应已被添加。",
"postReactUncompleted": "反应已被移除。",
"postComments": {
"zero": "评论",
"one": "{} 条评论",
"other": "{} 条评论"
},
"postCommentsDetailed": {
"zero": "没有评论",
"one": "{} 条评论",
"other": "{} 条评论"
},
"settingsAppearance": "外观",
"settingsBackgroundImage": "背景图片",
"settingsBackgroundImageDescription": "设置应用全局生效的的背景图片。",
"settingsBackgroundImageClear": "清除现存背景图",
"settingsBackgroundImageClearDescription": "将应用背景图重置为空白。",
"settingsThemeMaterial3": "使用 Material You 设计范式",
"settingsThemeMaterial3Description": "将应用主题设置为 Material 3 设计范式的主题。",
"settingsNetwork": "网络",
"settingsNetworkServer": "HyperNet 服务器",
"settingsNetworkServerDescription": "设置 HyperNet 服务器地址,选择我们提供的,或者自己搭建。",
"settingsNetworkServerReset": "重设为官方服务器",
"settingsNetworkServerResetDescription": "重设为 Solar Network 的服务器地址。",
"settingsNetworkServerPreset": "预设的 HyperNet 服务器",
"settingsNetworkServerPresetDescription": "你可以在旁边的列表中选择我们提供的预设 HyperNet 服务器地址。",
"settingsNetworkServerSaved": "服务器地址已保存。",
"sensitiveContent": "敏感内容",
"sensitiveContentCollapsed": "敏感内容已折叠。",
"sensitiveContentDescription": "此内容已被标记,可能不适合所有人查看。",
"sensitiveContentReveal": "显示内容",
"serverConnecting": "正在连接服务器…",
"serverDisconnected": "已与服务器断开连接",
"fieldChatAlias": "频道别名",
"fieldChatAliasHint": "全站范围内唯一的频道别名,用于在 URL 中表示该频道,留空则自动生成。应遵循 URL-Safe 的原则。",
"fieldChatName": "名称",
"fieldChatDescription": "描述",
"fieldChatBelongToRealm": "所属领域",
"fieldChatBelongToRealmUnset": "未设置频道所属领域",
"channelEditingNotice": "您正在编辑频道 {}",
"channelDeleted": "聊天频道 {} 已被删除",
"channelDelete": "删除聊天频道 {}",
"channelDeleteDescription": "你确定要删除这个聊天频道吗?该操作不可撤销,其频道内的所有消息将被永久删除。",
"channelDetailPersonalRegion": "个人区域",
"channelDetailMemberRegion": "成员管理",
"channelDetailAdminRegion": "管理区域",
"channelEditProfile": "更改频道身份",
"channelEdit": "编辑频道",
"channelEditDescription": "更改频道基本信息,元数据等。",
"channelProfileEdit": "编辑频道身份",
"channelActionDelete": "删除频道",
"channelActionDeleteDescription": "删除整个频道,并且删除频道里的所有信息。",
"channelLeave": "退出频道 {}",
"channelLeaveDescription": "退出该频道,但是你频道内的信息不会被移除。",
"channelActionLeave": "退出频道",
"channelActionLeaveDescription": "删除你在这个频道的身份。",
"channelNotifyLevel": "通知级别",
"channelNotifyLevelDescription": "有您决定要接受多少来自这个频道的消息。",
"channelNotifyLevelAll": "全部通知",
"channelNotifyLevelMentioned": "仅提及",
"channelNotifyLevelNone": "全部静音",
"channelNotifyLevelApplied": "已经保存并应用频道通知级别配置。",
"fieldChannelProfileNick": "频道内显示名",
"fieldChannelProfileNickHint": "在频道内显示的昵称,留空则使用账号显示名。",
"fieldRealmAlias": "领域别名",
"fieldRealmAliasHint": "全站范围内唯一的领域别名,用于在 URL 中表示该领域,留空则自动生成。应遵循 URL-Safe 的原则。",
"fieldRealmName": "名称",
"fieldRealmDescription": "描述",
"realmEditingNotice": "您正在编辑领域 {}",
"realmDeleted": "领域 {} 已被删除",
"realmDelete": "删除领域 {}",
"realmDeleteDescription": "你确定要删除这个领域吗?该操作不可撤销,其隶属于该领域的所有资源(帖子、聊天频道、发布者、制品等)都将被永久删除。三思而后行!",
"fieldChatMessage": "在 {} 中发消息",
"eventResourceTag": "消息 {}",
"messageDelete": "删除消息 {}",
"messageDeleteDescription": "你确定要删除这个消息吗?该操作不可撤销。同时您将留下一条删除消息的记录。",
"messageDeleted": "消息 {} 已被删除",
"messageEdited": "消息 {} 已被编辑",
"messageEditedHint": "已编辑",
"messageUnsupported": "不支持的消息 {}",
"messageFileHint": {
"zero": "没有附件",
"one": "{} 个附件",
"other": "{} 个附件"
},
"addAttachmentFromAlbum": "从相册中添加附件",
"addAttachmentFromClipboard": "粘贴附件",
"attachmentPastedImage": "粘贴的图片",
"notificationUnread": "未读",
"notificationRead": "已读",
"notificationMarkAllRead": "已读所有通知",
"notificationMarkAllReadDescription": "您确定要将所有通知设置为已读吗?该操作不可撤销。",
"notificationMarkAllReadPrompt": {
"zero": "已将 0 个通知标记为已读。",
"one": "已将 {} 个通知标记为已读。",
"other": "已将 {} 个通知标记为已读。"
},
"notificationMarkOneReadPrompt": "已将通知 {} 标记为已读。",
"postSearchResult": {
"zero": "没有搜索到结果",
"one": "搜索到 {} 个结果",
"other": "搜索到 {} 个结果"
},
"postSearchTook": "耗时 {}",
"postDelete": "删除帖子 {}",
"postDeleteDescription": "你确定要删除这个帖子吗?该操作不可撤销。",
"postDeleted": "帖子 {} 已被删除。",
"call": "通话",
"callOngoingNotice": "一则通话进行中",
"callJoin": "加入",
"callResume": "恢复",
"callMicrophone": "麦克风",
"callCamera": "摄像头",
"callMicrophoneDisabled": "麦克风已禁用",
"callMicrophoneSelect": "选择麦克风",
"callCameraDisabled": "摄像头已禁用",
"callCameraSelect": "选择摄像头",
"callDisconnected": "通话已断开",
"callEnded": "通话已结束",
"callStatusConnected": "已连接",
"callStatusDisconnected": "未连接",
"callStatusConnecting": "正在连接",
"callStatusReconnecting": "正在重连",
"callDisconnect": "断开连接",
"callDisconnectDescription": "您确定要与通话断开连接吗?",
"callMicrophoneOff": "关闭麦克风",
"callMicrophoneOn": "打开麦克风",
"callCameraOff": "关闭摄像头",
"callCameraOn": "打开摄像头",
"callVideoFlip": "镜像画面",
"callSpeakerphoneToggle": "切换扬声器",
"callScreenOff": "关闭屏幕共享",
"callScreenOn": "开启屏幕共享",
"callMessageEnded": "通话持续了 {}",
"callMessageStarted": "通话开始了",
"dailyCheckIn": "每日签到",
"dailyCheckInNone": "今日尚未签到",
"dailyCheckAction": "现在签到",
"dailyCheckDetail": "看不懂符?大师帮我解惑!",
"dailyCheckDetailTitle": "{} 的运势详情",
"dailyCheckPositiveHint": "宜 {}",
"dailyCheckNegativeHint": "忌 {}",
"dailyCheckEverythingIsPositive": "诸事皆宜",
"dailyCheckEverythingIsNegative": "诸事不宜",
"dailyCheckPositiveHint1": "交友",
"dailyCheckPositiveHint1Description": "友谊地久天长",
"dailyCheckPositiveHint2": "饮酒",
"dailyCheckPositiveHint2Description": "对影成三人",
"dailyCheckPositiveHint3": "旅行",
"dailyCheckPositiveHint3Description": "千里之行,始于足下",
"dailyCheckPositiveHint4": "运动",
"dailyCheckPositiveHint4Description": "生命在于运动",
"dailyCheckPositiveHint5": "学习",
"dailyCheckPositiveHint5Description": "学无止境,日有所进",
"dailyCheckPositiveHint6": "种植",
"dailyCheckPositiveHint6Description": "种下希望,收获未来",
"dailyCheckNegativeHint1": "吃饭",
"dailyCheckNegativeHint1Description": "吃饭咬到舌头",
"dailyCheckNegativeHint2": "考试",
"dailyCheckNegativeHint2Description": "考的东西刚好没复习",
"dailyCheckNegativeHint3": "坐公交",
"dailyCheckNegativeHint3Description": "赶车刚好错过一班",
"dailyCheckNegativeHint4": "购物",
"dailyCheckNegativeHint4Description": "买回来的衣服发现不合适",
"dailyCheckNegativeHint5": "打游戏",
"dailyCheckNegativeHint5Description": "关键时刻断网",
"dailyCheckNegativeHint6": "出门",
"dailyCheckNegativeHint6Description": "忘带伞遇上大雨",
"happyBirthday": "生日快乐,{}"
}

1
firebase.json Normal file
View File

@ -0,0 +1 @@
{"flutter":{"platforms":{"android":{"default":{"projectId":"solian-0x001","appId":"1:961776991058:android:a8d3f7995b0b8e86f4188b","fileOutput":"android/app/google-services.json"}},"ios":{"default":{"projectId":"solian-0x001","appId":"1:961776991058:ios:727229d368cc47e1f4188b","uploadDebugSymbols":false,"fileOutput":"ios/Runner/GoogleService-Info.plist"}},"macos":{"default":{"projectId":"solian-0x001","appId":"1:961776991058:ios:727229d368cc47e1f4188b","uploadDebugSymbols":false,"fileOutput":"macos/Runner/GoogleService-Info.plist"}},"dart":{"lib/firebase_options.dart":{"projectId":"solian-0x001","configurations":{"android":"1:961776991058:android:a8d3f7995b0b8e86f4188b","ios":"1:961776991058:ios:727229d368cc47e1f4188b","macos":"1:961776991058:ios:727229d368cc47e1f4188b","web":"1:961776991058:web:b91d12f2892a5609f4188b","windows":"1:961776991058:web:f152fd119699e13ef4188b"}}}}}}

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, '13.0'
# 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'
@ -40,5 +40,9 @@ end
post_install do |installer| post_install do |installer|
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target) flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
# Workaround for https://github.com/flutter/flutter/issues/64502
config.build_settings['ONLY_ACTIVE_ARCH'] = 'YES'
end
end end
end end

View File

@ -6,6 +6,8 @@ PODS:
- Flutter - Flutter
- cupertino_http (0.0.1): - cupertino_http (0.0.1):
- Flutter - Flutter
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9): - DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager - DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource - DKImagePickerController/Resource
@ -40,19 +42,161 @@ PODS:
- file_picker (0.0.1): - file_picker (0.0.1):
- DKImagePickerController/PhotoGallery - DKImagePickerController/PhotoGallery
- Flutter - Flutter
- Firebase/Analytics (11.4.0):
- Firebase/Core
- Firebase/Core (11.4.0):
- Firebase/CoreOnly
- FirebaseAnalytics (~> 11.4.0)
- Firebase/CoreOnly (11.4.0):
- FirebaseCore (= 11.4.0)
- Firebase/Messaging (11.4.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 11.4.0)
- firebase_analytics (11.3.5):
- Firebase/Analytics (= 11.4.0)
- firebase_core
- Flutter
- firebase_core (3.8.0):
- Firebase/CoreOnly (= 11.4.0)
- Flutter
- firebase_messaging (15.1.5):
- Firebase/Messaging (= 11.4.0)
- firebase_core
- Flutter
- FirebaseAnalytics (11.4.0):
- FirebaseAnalytics/AdIdSupport (= 11.4.0)
- FirebaseCore (~> 11.0)
- FirebaseInstallations (~> 11.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0)
- FirebaseAnalytics/AdIdSupport (11.4.0):
- FirebaseCore (~> 11.0)
- FirebaseInstallations (~> 11.0)
- GoogleAppMeasurement (= 11.4.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0)
- FirebaseCore (11.4.0):
- FirebaseCoreInternal (~> 11.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/Logger (~> 8.0)
- FirebaseCoreInternal (11.5.0):
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- FirebaseInstallations (11.4.0):
- FirebaseCore (~> 11.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- PromisesObjC (~> 2.4)
- FirebaseMessaging (11.4.0):
- FirebaseCore (~> 11.0)
- FirebaseInstallations (~> 11.0)
- GoogleDataTransport (~> 10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/Reachability (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- nanopb (~> 3.30910.0)
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_native_splash (0.0.1): - flutter_native_splash (2.4.3):
- Flutter - Flutter
- flutter_secure_storage (3.3.1): - flutter_udid (0.0.1):
- Flutter - Flutter
- SAMKeychain
- flutter_webrtc (0.12.2):
- Flutter
- WebRTC-SDK (= 125.6422.05)
- GoogleAppMeasurement (11.4.0):
- GoogleAppMeasurement/AdIdSupport (= 11.4.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/AdIdSupport (11.4.0):
- GoogleAppMeasurement/WithoutAdIdSupport (= 11.4.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/WithoutAdIdSupport (11.4.0):
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0)
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleUtilities/AppDelegateSwizzler (8.0.2):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Privacy
- GoogleUtilities/Environment (8.0.2):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.0.2):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/MethodSwizzler (8.0.2):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/Network (8.0.2):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Privacy
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (8.0.2)":
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.0.2)
- GoogleUtilities/Reachability (8.0.2):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/UserDefaults (8.0.2):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- image_picker_ios (0.0.1): - image_picker_ios (0.0.1):
- Flutter - Flutter
- livekit_client (2.3.0):
- Flutter
- WebRTC-SDK (= 125.6422.05)
- media_kit_libs_ios_video (1.0.4):
- Flutter
- media_kit_native_event_loop (1.0.0):
- Flutter
- media_kit_video (0.0.1):
- Flutter
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- package_info_plus (0.4.5):
- Flutter
- pasteboard (0.0.1):
- Flutter
- path_provider_foundation (0.0.1): - path_provider_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- PromisesObjC (2.4.0)
- SAMKeychain (1.5.3)
- screen_brightness_ios (0.1.0):
- Flutter
- SDWebImage (5.19.7): - SDWebImage (5.19.7):
- SDWebImage/Core (= 5.19.7) - SDWebImage/Core (= 5.19.7)
- SDWebImage/Core (5.19.7) - SDWebImage/Core (5.19.7)
- Sentry/HybridSDK (8.40.1)
- sentry_flutter (8.10.1):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.40.1)
- shared_preferences_foundation (0.0.1): - shared_preferences_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
@ -62,27 +206,62 @@ PODS:
- SwiftyGif (5.4.5) - SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1): - url_launcher_ios (0.0.1):
- Flutter - Flutter
- volume_controller (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
- WebRTC-SDK (125.6422.05)
DEPENDENCIES: DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
- croppy (from `.symlinks/plugins/croppy/ios`) - croppy (from `.symlinks/plugins/croppy/ios`)
- cupertino_http (from `.symlinks/plugins/cupertino_http/ios`) - cupertino_http (from `.symlinks/plugins/cupertino_http/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`)
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_udid (from `.symlinks/plugins/flutter_udid/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`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- pasteboard (from `.symlinks/plugins/pasteboard/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`)
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- volume_controller (from `.symlinks/plugins/volume_controller/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS: SPEC REPOS:
trunk: trunk:
- DKImagePickerController - DKImagePickerController
- DKPhotoGallery - DKPhotoGallery
- Firebase
- FirebaseAnalytics
- FirebaseCore
- FirebaseCoreInternal
- FirebaseInstallations
- FirebaseMessaging
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleUtilities
- nanopb
- PromisesObjC
- SAMKeychain
- SDWebImage - SDWebImage
- Sentry
- SwiftyGif - SwiftyGif
- WebRTC-SDK
EXTERNAL SOURCES: EXTERNAL SOURCES:
connectivity_plus: connectivity_plus:
@ -91,43 +270,105 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/croppy/ios" :path: ".symlinks/plugins/croppy/ios"
cupertino_http: cupertino_http:
:path: ".symlinks/plugins/cupertino_http/ios" :path: ".symlinks/plugins/cupertino_http/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker: file_picker:
:path: ".symlinks/plugins/file_picker/ios" :path: ".symlinks/plugins/file_picker/ios"
firebase_analytics:
:path: ".symlinks/plugins/firebase_analytics/ios"
firebase_core:
:path: ".symlinks/plugins/firebase_core/ios"
firebase_messaging:
:path: ".symlinks/plugins/firebase_messaging/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
flutter_native_splash: flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios" :path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage: flutter_udid:
:path: ".symlinks/plugins/flutter_secure_storage/ios" :path: ".symlinks/plugins/flutter_udid/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:
:path: ".symlinks/plugins/media_kit_video/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
pasteboard:
:path: ".symlinks/plugins/pasteboard/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:
:path: ".symlinks/plugins/screen_brightness_ios/ios"
sentry_flutter:
:path: ".symlinks/plugins/sentry_flutter/ios"
shared_preferences_foundation: shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin" :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin: sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin" :path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios: url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios" :path: ".symlinks/plugins/url_launcher_ios/ios"
volume_controller:
:path: ".symlinks/plugins/volume_controller/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
connectivity_plus: 4c41c08fc6d7c91f63bc7aec70ffe3730b04f563 connectivity_plus: 4c41c08fc6d7c91f63bc7aec70ffe3730b04f563
croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321 croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321
cupertino_http: 1a3a0f163c1b26e7f1a293b33d476e0fde7a64ec cupertino_http: 1a3a0f163c1b26e7f1a293b33d476e0fde7a64ec
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
Firebase: cf1b19f21410b029b6786a54e9764a0cacad3c99
firebase_analytics: fa7e5b20c2b58042e3301f5112a473d365bd490c
firebase_core: 9efc3ecf689cdbc90f13f4dc58108c83ea46b266
firebase_messaging: 6bf60adb4b33a848d135e16bc363fb4924f98fba
FirebaseAnalytics: 3feef9ae8733c567866342a1000691baaa7cad49
FirebaseCore: e0510f1523bc0eb21653cac00792e1e2bd6f1771
FirebaseCoreInternal: f47dd28ae7782e6a4738aad3106071a8fe0af604
FirebaseInstallations: 6ef4a1c7eb2a61ee1f74727d7f6ce2e72acf1414
FirebaseMessaging: f8a160d99c2c2e5babbbcc90c4a3e15db036aee2
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 flutter_native_splash: e8a1e01082d97a8099d973f919f57904c925008a
flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04
flutter_webrtc: 043d1b47e9795158dd97dc84f1c152cd0e98b93b
GoogleAppMeasurement: 987769c4ca6b968f2479fbcc9fe3ce34af454b8e
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
livekit_client: 5c31e13cd17dd0d545a074290c937dbdff1d809d
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3 SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
Sentry: e9215d7b17f7902692b4f8700e061e4f853e3521
sentry_flutter: 927eed60d66951d1b0f1db37fe94ff5cb7c80231
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
WebRTC-SDK: 1990a1a595bd0b59c17485ce13ff17f575732c12
PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796 PODFILE CHECKSUM: d2bdaa1cc7915e14cf47235c34a21fcb07b00390
COCOAPODS: 1.15.2 COCOAPODS: 1.15.2

View File

@ -12,6 +12,7 @@
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
8CD0929C27BC410DD5056EAB /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A2C24C5238FAC44EA2CCF738 /* GoogleService-Info.plist */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@ -53,6 +54,7 @@
4A2F84B6033057E3BD2C7CB8 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; }; 4A2F84B6033057E3BD2C7CB8 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
64FBE78F9C282712818D6D95 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; }; 64FBE78F9C282712818D6D95 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
72E9279EFA6DAC00BBAC493C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; 72E9279EFA6DAC00BBAC493C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
73111C212CEE3D5E004CF4B3 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
@ -64,6 +66,7 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A2C24C5238FAC44EA2CCF738 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; };
EDF483E994343CDFBF9BA347 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; EDF483E994343CDFBF9BA347 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -124,6 +127,7 @@
331C8082294A63A400263BE5 /* RunnerTests */, 331C8082294A63A400263BE5 /* RunnerTests */,
F5165E3BD1F2519F85CD4BE2 /* Pods */, F5165E3BD1F2519F85CD4BE2 /* Pods */,
09229EB4EB35A0678AB9738D /* Frameworks */, 09229EB4EB35A0678AB9738D /* Frameworks */,
A2C24C5238FAC44EA2CCF738 /* GoogleService-Info.plist */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -139,6 +143,7 @@
97C146F01CF9000F007C117D /* Runner */ = { 97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
73111C212CEE3D5E004CF4B3 /* Runner.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
@ -198,6 +203,8 @@
9705A1C41CF9048500538489 /* Embed Frameworks */, 9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
FC4815D44D909666EB1FA614 /* [CP] Embed Pods Frameworks */, FC4815D44D909666EB1FA614 /* [CP] Embed Pods Frameworks */,
244E364B35B507EB14F7681C /* FlutterFire: "flutterfire upload-crashlytics-symbols" */,
43B5CF57FD79BC21654EE037 /* [CP] Copy Pods Resources */,
); );
buildRules = ( buildRules = (
); );
@ -263,12 +270,31 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
8CD0929C27BC410DD5056EAB /* GoogleService-Info.plist in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */
244E364B35B507EB14F7681C /* FlutterFire: "flutterfire upload-crashlytics-symbols" */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "FlutterFire: \"flutterfire upload-crashlytics-symbols\"";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\n#!/bin/bash\nPATH=${PATH}:$FLUTTER_ROOT/bin:$HOME/.pub-cache/bin\nflutterfire upload-crashlytics-symbols --upload-symbols-script-path=$PODS_ROOT/FirebaseCrashlytics/upload-symbols --platform=ios --apple-project-path=${SRCROOT} --env-platform-name=${PLATFORM_NAME} --env-configuration=${CONFIGURATION} --env-project-dir=${PROJECT_DIR} --env-built-products-dir=${BUILT_PRODUCTS_DIR} --env-dwarf-dsym-folder-path=${DWARF_DSYM_FOLDER_PATH} --env-dwarf-dsym-file-name=${DWARF_DSYM_FILE_NAME} --env-infoplist-path=${INFOPLIST_PATH} --default-config=default\n";
};
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;
@ -285,6 +311,23 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
}; };
43B5CF57FD79BC21654EE037 /* [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;
};
9740EEB61CF901F6004384FC /* Run Script */ = { 9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;
@ -469,11 +512,13 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = W7HPZ53V6B; DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian; INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -653,11 +698,13 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = W7HPZ53V6B; DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian; INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -677,11 +724,13 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = W7HPZ53V6B; DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian; INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_KEY</key>
<string>AIzaSyCzQIyiYKoYHTpGXhN-IjgMML8z797WVD8</string>
<key>GCM_SENDER_ID</key>
<string>961776991058</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>dev.solsynth.solian</string>
<key>PROJECT_ID</key>
<string>solian-0x001</string>
<key>STORAGE_BUCKET</key>
<string>solian-0x001.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:961776991058:ios:727229d368cc47e1f4188b</string>
</dict>
</plist>

View File

@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@ -12,6 +14,11 @@
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>zh_CN</string>
</array>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>Solian</string> <string>Solian</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
@ -22,12 +29,31 @@
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSCameraUsageDescription</key>
<string>Grant access to Photo Library will allow Solian take photo or video for your post.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Grant access to Photo Library will allow Solian record audio for your post.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Grant access to Photo Library will allow Solian upload photo or video for your post.</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>remote-notification</string>
<string>audio</string>
<string>voip</string>
</array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
<string>Main</string> <string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
@ -41,24 +67,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>zh_CN</string>
</array>
<key>NSPhotoLibraryUsageDescription</key>
<string>Grant access to Photo Library will allow Solian upload photo or video for your post.</string>
<key>NSCameraUsageDescription</key>
<string>Grant access to Photo Library will allow Solian take photo or video for your post.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Grant access to Photo Library will allow Solian record audio for your post.</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIStatusBarHidden</key>
<false/>
</dict> </dict>
</plist> </plist>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,433 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/websocket.dart';
import 'package:surface/types/chat.dart';
import 'package:uuid/uuid.dart';
class ChatMessageController extends ChangeNotifier {
static const kChatMessageBoxPrefix = 'nex_chat_messages_';
static const kSingleBatchLoadLimit = 100;
late final SnNetworkProvider _sn;
late final UserDirectoryProvider _ud;
late final WebSocketProvider _ws;
late final SnAttachmentProvider _attach;
StreamSubscription? _wsSubscription;
ChatMessageController(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
_ud = context.read<UserDirectoryProvider>();
_ws = context.read<WebSocketProvider>();
_attach = context.read<SnAttachmentProvider>();
}
bool isPending = true;
bool isLoading = false;
int? messageTotal;
bool get isAllLoaded =>
messageTotal != null && messages.length >= messageTotal!;
String? _boxKey;
SnChannel? channel;
SnChannelMember? profile;
/// Messages are the all the messages that in the channel
final List<SnChatMessage> messages = List.empty(growable: true);
/// Unconfirmed messages are the messages that sent by client but did not receive the reply from websocket server.
/// Stored as a list of nonce to provide the loading state
final List<String> unconfirmedMessages = List.empty(growable: true);
Box<SnChatMessage>? get _box =>
(_boxKey == null || isPending) ? null : Hive.box<SnChatMessage>(_boxKey!);
Future<void> initialize(SnChannel chan) async {
channel = chan;
// Initialize local data
_boxKey = '$kChatMessageBoxPrefix${chan.id}';
await Hive.openBox<SnChatMessage>(_boxKey!);
// Fetch channel profile
final resp = await _sn.client.get(
'/cgi/im/channels/${chan.keyPath}/me',
);
profile = SnChannelMember.fromJson(
resp.data as Map<String, dynamic>,
);
_wsSubscription = _ws.stream.stream.listen((event) {
switch (event.method) {
case 'events.new':
final payload = SnChatMessage.fromJson(event.payload!);
_addMessage(payload);
break;
case 'status.typing':
if (event.payload?['channel_id'] != channel?.id) break;
final member = SnChannelMember.fromJson(event.payload!['member']);
if (member.id == profile?.id) break;
// TODO impl typing users
// if (!_typingUsers.any((x) => x.id == member.id)) {
// setState(() {
// _typingUsers.add(member);
// });
// }
// _typingInactiveTimer[member.id]?.cancel();
// _typingInactiveTimer[member.id] = Timer(
// const Duration(seconds: 3),
// () {
// setState(() {
// _typingUsers.removeWhere((x) => x.id == member.id);
// _typingInactiveTimer.remove(member.id);
// });
// },
// );
}
});
isPending = false;
notifyListeners();
}
Future<void> _saveMessageToLocal(Iterable<SnChatMessage> messages) async {
if (_box == null) return;
await _box!.putAll({
for (final message in messages) message.id: message,
});
}
Future<void> _addUnconfirmedMessage(SnChatMessage message) async {
SnChatMessage? quoteEvent;
if (message.quoteEventId != null) {
quoteEvent = await getMessage(message.quoteEventId as int);
}
final attachmentRid = List<String>.from(
message.body['attachments']?.cast<String>() ?? [],
);
final attachments = await _attach.getMultiple(attachmentRid);
message = message.copyWith(
preload: SnChatMessagePreload(
quoteEvent: quoteEvent,
attachments: attachments,
),
);
messages.insert(0, message);
unconfirmedMessages.add(message.uuid);
notifyListeners();
}
Future<void> _addMessage(SnChatMessage message) async {
SnChatMessage? quoteEvent;
if (message.quoteEventId != null) {
quoteEvent = await getMessage(message.quoteEventId as int);
}
final attachmentRid = List<String>.from(
message.body['attachments']?.cast<String>() ?? [],
);
final attachments = await _attach.getMultiple(attachmentRid);
message = message.copyWith(
preload: SnChatMessagePreload(
quoteEvent: quoteEvent,
attachments: attachments,
),
);
final idx = messages.indexWhere((e) => e.uuid == message.uuid);
if (idx != -1) {
unconfirmedMessages.remove(message.uuid);
messages[idx] = message;
} else {
messages.insert(0, message);
}
await _applyMessage(message);
notifyListeners();
if (_box == null) return;
await _box!.put(message.id, message);
}
Future<void> _applyMessage(SnChatMessage message) async {
if (message.channelId != channel?.id) return;
switch (message.type) {
case 'messages.edit':
if (message.relatedEventId != null) {
final idx =
messages.indexWhere((x) => x.id == message.relatedEventId);
if (idx != -1) {
final newBody = message.body;
newBody.remove('related_event');
messages[idx] = messages[idx].copyWith(
body: newBody,
updatedAt: message.updatedAt,
);
if (_box!.containsKey(message.relatedEventId)) {
await _box!.put(message.relatedEventId, messages[idx]);
}
}
}
case 'messages.delete':
if (message.relatedEventId != null) {
messages.removeWhere((x) => x.id == message.relatedEventId);
if (_box!.containsKey(message.relatedEventId)) {
await _box!.delete(message.relatedEventId);
}
}
}
}
Future<void> sendMessage(
String type,
String content, {
int? quoteId,
int? relatedId,
List<String>? attachments,
SnChatMessage? editingMessage,
}) async {
if (channel == null) return;
const uuid = Uuid();
final nonce = uuid.v4();
final body = {
'text': content,
'algorithm': 'plain',
if (quoteId != null) 'quote_event': quoteId,
if (relatedId != null) 'related_event': relatedId,
if (attachments != null && attachments.isNotEmpty)
'attachments': attachments,
};
// Mock the message locally
final createdAt = DateTime.now();
final message = SnChatMessage(
id: 0,
createdAt: createdAt,
updatedAt: createdAt,
deletedAt: null,
uuid: nonce,
body: body,
type: type,
channel: channel!,
channelId: channel!.id,
sender: profile!,
senderId: profile!.id,
quoteEventId: quoteId,
relatedEventId: relatedId,
);
_addUnconfirmedMessage(message);
// Send to server
try {
await _sn.client.request(
editingMessage != null
? '/cgi/im/channels/${channel!.keyPath}/messages/${editingMessage.id}'
: '/cgi/im/channels/${channel!.keyPath}/messages',
data: {
'type': type,
'uuid': nonce,
'body': body,
},
options: Options(
method: editingMessage != null ? 'PUT' : 'POST',
),
);
} catch (err) {
// ignore
}
}
Future<void> deleteMessage(SnChatMessage message) async {
if (message.channelId != channel?.id) return;
try {
await _sn.client.delete(
'/cgi/im/channels/${channel!.keyPath}/messages/${message.id}',
);
messages.removeWhere((x) => x.id == message.id);
} catch (err) {
// ignore
}
}
/// Check the local storage is up to date with the server.
/// If the local storage is not up to date, it will be updated.
Future<void> checkUpdate() async {
if (_box == null) return;
if (_box!.isEmpty) return;
isLoading = true;
notifyListeners();
try {
final resp = await _sn.client.get(
'/cgi/im/channels/${channel!.keyPath}/events/update',
queryParameters: {
'pivot': _box!.values.last.id,
},
);
if (resp.data['up_to_date'] == true) return;
// Only preload the first 100 messages to prevent first time check update cause load to server and waste local storage.
// FIXME If the local is missing more than 100 messages, it won't be fetched, this is a problem, we need to fix it.
final countToFetch = math.min(resp.data['count'] as int, 100);
for (int idx = 0; idx < countToFetch; idx += kSingleBatchLoadLimit) {
await getMessages(kSingleBatchLoadLimit, idx, forceRemote: true);
}
} catch (err) {
rethrow;
} finally {
await loadMessages();
isLoading = false;
notifyListeners();
}
}
/// Get a single event from the current channel
/// If it was not found in local storage we will look it up in remote
Future<SnChatMessage?> getMessage(int id) async {
SnChatMessage? out;
if (_box != null && _box!.containsKey(id)) {
out = _box!.get(id);
}
if (out == null) {
try {
final resp = await _sn.client
.get('/cgi/im/channels/${channel!.keyPath}/events/$id');
out = SnChatMessage.fromJson(resp.data);
_saveMessageToLocal([out]);
} catch (_) {
// ignore, maybe not found
}
}
// Preload some related things if found
if (out != null) {
await _ud.listAccount([out.sender.accountId]);
final attachments = await _attach.getMultiple(
out.body['attachments']?.cast<String>() ?? [],
);
out = out.copyWith(
preload: SnChatMessagePreload(
attachments: attachments,
),
);
}
return out;
}
/// Get message from local storage first, then from the server.
/// Will not check local storage is up to date with the server.
/// If you need to do the sync, do the `checkUpdate` instead.
Future<List<SnChatMessage>> getMessages(
int take,
int offset, {
bool forceLocal = false,
bool forceRemote = false,
}) async {
late List<SnChatMessage> out;
if (_box != null &&
(_box!.length >= take + offset || forceLocal) &&
!forceRemote) {
out = _box!.keys
.toList()
.cast<int>()
.sorted((a, b) => b.compareTo(a))
.skip(offset)
.take(take)
.map((key) => _box!.get(key)!)
.toList();
} else {
final resp = await _sn.client.get(
'/cgi/im/channels/${channel!.keyPath}/events',
queryParameters: {
'take': take,
'offset': offset,
},
);
messageTotal = resp.data['count'] as int?;
out = List<SnChatMessage>.from(
resp.data['data']?.map((e) => SnChatMessage.fromJson(e)) ?? [],
);
_saveMessageToLocal(out);
}
// Preload attachments
final attachmentRid = List<String>.from(
out.expand((e) => (e.body['attachments'] as List<dynamic>?) ?? []),
);
final attachments = await _attach.getMultiple(attachmentRid);
// Putting preload back to data
for (var i = 0; i < out.length; i++) {
// Preload related events (quoted)
SnChatMessage? quoteEvent;
if (out[i].quoteEventId != null) {
quoteEvent = await getMessage(out[i].quoteEventId as int);
}
out[i] = out[i].copyWith(
preload: SnChatMessagePreload(
quoteEvent: quoteEvent,
attachments: attachments
.where(
(ele) =>
out[i].body['attachments']?.contains(ele?.rid) ?? false,
)
.toList(),
),
);
}
// Preload sender accounts
final accountId = out
.where((ele) => ele.sender.accountId >= 0)
.map((ele) => ele.sender.accountId)
.toSet();
await _ud.listAccount(accountId);
return out;
}
/// The load messages method work as same as the `getMessages` method.
/// But it won't return the messages instead append them to the value that controller has.
/// At the same time, this method provide the `isLoading` state.
/// The `skip` parameter is no longer required since it will skip the messages count that already loaded.
Future<void> loadMessages({int take = 20}) async {
isLoading = true;
notifyListeners();
try {
final out = await getMessages(take, messages.length);
messages.addAll(out);
} catch (err) {
rethrow;
} finally {
isLoading = false;
notifyListeners();
}
}
@override
void dispose() {
_box?.close();
_wsSubscription?.cancel();
super.dispose();
}
}

View File

@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:mime/mime.dart'; import 'package:mime/mime.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/attachment.dart'; import 'package:surface/types/attachment.dart';
@ -86,7 +87,10 @@ class PostWriteMedia {
if (file != null) { if (file != null) {
return file!; return file!;
} else if (raw != null) { } else if (raw != null) {
return XFile.fromData(raw!, name: name); return XFile.fromData(
raw!,
name: name,
);
} }
return null; return null;
} }
@ -168,6 +172,7 @@ class PostWriteController extends ChangeNotifier {
SnPublisher? publisher; SnPublisher? publisher;
SnPost? editingPost, repostingPost, replyingPost; SnPost? editingPost, repostingPost, replyingPost;
List<String> tags = List.empty();
List<PostWriteMedia> attachments = List.empty(growable: true); List<PostWriteMedia> attachments = List.empty(growable: true);
DateTime? publishedAt, publishedUntil; DateTime? publishedAt, publishedUntil;
@ -177,53 +182,36 @@ class PostWriteController extends ChangeNotifier {
int? reposting, int? reposting,
int? replying, int? replying,
}) async { }) async {
final sn = context.read<SnNetworkProvider>(); final pt = context.read<SnPostContentProvider>();
final attach = context.read<SnAttachmentProvider>();
isLoading = true; isLoading = true;
notifyListeners(); notifyListeners();
try { try {
if (editing != null) { if (editing != null) {
final resp = await sn.client.get('/cgi/co/posts/$editing'); final post = await pt.getPost(editing);
final post = SnPost.fromJson(resp.data);
final alts = await attach
.getMultiple(post.body['attachments']?.cast<String>() ?? []);
publisher = post.publisher; publisher = post.publisher;
titleController.text = post.body['title'] ?? ''; titleController.text = post.body['title'] ?? '';
descriptionController.text = post.body['description'] ?? ''; descriptionController.text = post.body['description'] ?? '';
contentController.text = post.body['content'] ?? ''; contentController.text = post.body['content'] ?? '';
publishedAt = post.publishedAt; publishedAt = post.publishedAt;
publishedUntil = post.publishedUntil; publishedUntil = post.publishedUntil;
attachments.addAll(alts.map((ele) => PostWriteMedia(ele))); tags = List.from(post.tags.map((ele) => ele.alias));
attachments.addAll(
editingPost = post.copyWith( post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? [],
preload: SnPostPreload(
attachments: alts,
),
); );
editingPost = post;
} }
if (replying != null) { if (replying != null) {
final resp = await sn.client.get('/cgi/co/posts/$replying'); final post = await pt.getPost(replying);
final post = SnPost.fromJson(resp.data); replyingPost = post;
replyingPost = post.copyWith(
preload: SnPostPreload(
attachments: await attach
.getMultiple(post.body['attachments']?.cast<String>() ?? []),
),
);
} }
if (reposting != null) { if (reposting != null) {
final resp = await sn.client.get('/cgi/co/posts/$reposting'); final post = await pt.getPost(reposting);
final post = SnPost.fromJson(resp.data); replyingPost = post;
repostingPost = post.copyWith(
preload: SnPostPreload(
attachments: await attach
.getMultiple(post.body['attachments']?.cast<String>() ?? []),
),
);
} }
} catch (err) { } catch (err) {
if (!context.mounted) return; if (!context.mounted) return;
@ -256,6 +244,9 @@ class PostWriteController extends ChangeNotifier {
media.name, media.name,
'interactive', 'interactive',
null, null,
mimetype: media.raw != null && media.type == PostWriteMediaType.image
? 'image/png'
: null,
); );
final item = await attach.chunkedUploadParts( final item = await attach.chunkedUploadParts(
@ -301,6 +292,7 @@ class PostWriteController extends ChangeNotifier {
.where((e) => e.attachment != null) .where((e) => e.attachment != null)
.map((e) => e.attachment!.rid) .map((e) => e.attachment!.rid)
.toList(), .toList(),
'tags': tags.map((ele) => {'alias': ele}).toList(),
if (publishedAt != null) if (publishedAt != null)
'published_at': publishedAt!.toUtc().toIso8601String(), 'published_at': publishedAt!.toUtc().toIso8601String(),
if (publishedUntil != null) if (publishedUntil != null)
@ -362,6 +354,11 @@ class PostWriteController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void setTags(List<String> value) {
tags = value;
notifyListeners();
}
void setIsBusy(bool value) { void setIsBusy(bool value) {
isBusy = value; isBusy = value;
notifyListeners(); notifyListeners();

89
lib/firebase_options.dart Normal file
View File

@ -0,0 +1,89 @@
// File generated by FlutterFire CLI.
// ignore_for_file: type=lint
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
/// Default [FirebaseOptions] for use with your Firebase apps.
///
/// Example:
/// ```dart
/// import 'firebase_options.dart';
/// // ...
/// await Firebase.initializeApp(
/// options: DefaultFirebaseOptions.currentPlatform,
/// );
/// ```
class DefaultFirebaseOptions {
static FirebaseOptions get currentPlatform {
if (kIsWeb) {
return web;
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return android;
case TargetPlatform.iOS:
return ios;
case TargetPlatform.macOS:
return macos;
case TargetPlatform.windows:
return windows;
case TargetPlatform.linux:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for linux - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
default:
throw UnsupportedError(
'DefaultFirebaseOptions are not supported for this platform.',
);
}
}
static const FirebaseOptions web = FirebaseOptions(
apiKey: 'AIzaSyBKfIQpTouj5rXnlzkEieSlbAzepm4mgJE',
appId: '1:961776991058:web:b91d12f2892a5609f4188b',
messagingSenderId: '961776991058',
projectId: 'solian-0x001',
authDomain: 'solian-0x001.firebaseapp.com',
storageBucket: 'solian-0x001.firebasestorage.app',
measurementId: 'G-XY3HHKG0PE',
);
static const FirebaseOptions android = FirebaseOptions(
apiKey: 'AIzaSyDvFNudXYs29uDtcCv6pFR8h5tXBs90FYk',
appId: '1:961776991058:android:a8d3f7995b0b8e86f4188b',
messagingSenderId: '961776991058',
projectId: 'solian-0x001',
storageBucket: 'solian-0x001.firebasestorage.app',
);
static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'AIzaSyCzQIyiYKoYHTpGXhN-IjgMML8z797WVD8',
appId: '1:961776991058:ios:727229d368cc47e1f4188b',
messagingSenderId: '961776991058',
projectId: 'solian-0x001',
storageBucket: 'solian-0x001.firebasestorage.app',
iosBundleId: 'dev.solsynth.solian',
);
static const FirebaseOptions macos = FirebaseOptions(
apiKey: 'AIzaSyCzQIyiYKoYHTpGXhN-IjgMML8z797WVD8',
appId: '1:961776991058:ios:727229d368cc47e1f4188b',
messagingSenderId: '961776991058',
projectId: 'solian-0x001',
storageBucket: 'solian-0x001.firebasestorage.app',
iosBundleId: 'dev.solsynth.solian',
);
static const FirebaseOptions windows = FirebaseOptions(
apiKey: 'AIzaSyBKfIQpTouj5rXnlzkEieSlbAzepm4mgJE',
appId: '1:961776991058:web:f152fd119699e13ef4188b',
messagingSenderId: '961776991058',
projectId: 'solian-0x001',
authDomain: 'solian-0x001.firebaseapp.com',
storageBucket: 'solian-0x001.firebasestorage.app',
measurementId: 'G-19FCN0CD9X',
);
}

View File

@ -1,27 +1,64 @@
import 'package:croppy/croppy.dart'; import 'package:croppy/croppy.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:easy_localization_loader/easy_localization_loader.dart'; import 'package:easy_localization_loader/easy_localization_loader.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart'; import 'package:relative_time/relative_time.dart';
import 'package:responsive_framework/responsive_framework.dart'; import 'package:responsive_framework/responsive_framework.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:surface/firebase_options.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/chat_call.dart';
import 'package:surface/providers/navigation.dart'; import 'package:surface/providers/navigation.dart';
import 'package:surface/providers/notification.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/theme.dart'; import 'package:surface/providers/theme.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/websocket.dart';
import 'package:surface/router.dart'; import 'package:surface/router.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/types/realm.dart';
import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await EasyLocalization.ensureInitialized(); await EasyLocalization.ensureInitialized();
await Hive.initFlutter();
Hive.registerAdapter(SnChannelImplAdapter());
Hive.registerAdapter(SnRealmImplAdapter());
Hive.registerAdapter(SnChannelMemberImplAdapter());
Hive.registerAdapter(SnChatMessageImplAdapter());
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
if (!kReleaseMode) { if (!kReleaseMode) {
debugInvertOversizedImages = true; debugInvertOversizedImages = true;
} }
runApp(const SolianApp()); GoRouter.optionURLReflectsImperativeAPIs = true;
usePathUrlStrategy();
await SentryFlutter.init(
(options) {
options.dsn =
'https://c218d44126d59d69301e730498494def@o4506965897117696.ingest.us.sentry.io/4508346768228352';
options.tracesSampleRate = 1.0;
options.profilesSampleRate = 1.0;
options.experimental.replay.sessionSampleRate = 1.0;
options.experimental.replay.onErrorSampleRate = 1.0;
},
appRunner: () => runApp(const SolianApp()),
);
} }
class SolianApp extends StatelessWidget { class SolianApp extends StatelessWidget {
@ -35,14 +72,24 @@ class SolianApp extends StatelessWidget {
supportedLocales: [Locale('en', 'US'), Locale('zh', 'CN')], supportedLocales: [Locale('en', 'US'), Locale('zh', 'CN')],
fallbackLocale: Locale('en', 'US'), fallbackLocale: Locale('en', 'US'),
useFallbackTranslations: true, useFallbackTranslations: true,
useOnlyLangCode: true,
assetLoader: JsonAssetLoader(), assetLoader: JsonAssetLoader(),
child: MultiProvider( child: MultiProvider(
providers: [ providers: [
// Display layer
ChangeNotifierProvider(create: (_) => ThemeProvider()),
ChangeNotifierProvider(create: (ctx) => NavigationProvider()),
// Data layer
Provider(create: (_) => SnNetworkProvider()), Provider(create: (_) => SnNetworkProvider()),
Provider(create: (ctx) => SnAttachmentProvider(ctx)), Provider(create: (ctx) => SnAttachmentProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => NavigationProvider()), Provider(create: (ctx) => SnPostContentProvider(ctx)),
Provider(create: (ctx) => UserDirectoryProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)), ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)),
ChangeNotifierProvider(create: (_) => ThemeProvider()), ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
], ],
child: AppMainContent(), child: AppMainContent(),
), ),
@ -62,7 +109,9 @@ class AppMainContent extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
context.read<NavigationProvider>(); context.read<NavigationProvider>();
context.read<UserProvider>(); context.read<WebSocketProvider>();
context.read<ChatChannelProvider>();
context.read<NotificationProvider>();
final th = context.watch<ThemeProvider>(); final th = context.watch<ThemeProvider>();

View File

@ -1,12 +0,0 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:native_dio_adapter/native_dio_adapter.dart';
Dio addClientAdapter(Dio client) {
if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS) {
// Switch to native implementation if possible
client.httpClientAdapter = NativeAdapter();
}
return client;
}

View File

@ -1,2 +0,0 @@
export 'package:surface/providers/adapters/sn_network_web.dart'
if (dart.library.io) 'package:surface/providers/adapters/sn_network_native.dart';

View File

@ -1,5 +0,0 @@
import 'package:dio/dio.dart';
Dio addClientAdapter(Dio client) {
return client;
}

121
lib/providers/channel.dart Normal file
View File

@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/types/realm.dart';
class ChatChannelProvider extends ChangeNotifier {
static const kChatChannelBoxName = 'nex_chat_channels';
late final SnNetworkProvider _sn;
Box<SnChannel>? get _channelBox => Hive.box<SnChannel>(kChatChannelBoxName);
ChatChannelProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
_initializeLocalData();
}
Future<void> _initializeLocalData() async {
await Hive.openBox<SnChannel>(kChatChannelBoxName);
}
Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async {
if (_channelBox == null) return;
await _channelBox!.putAll({
for (final channel in channels) channel.key: channel,
});
}
Future<List<SnChannel>> _fetchChannelsFromServer({
String scope = 'global',
bool direct = false,
bool doNotSave = false,
}) async {
final resp = await _sn.client.get(
'/cgi/im/channels/$scope/me/available',
queryParameters: {
'direct': direct,
},
);
final out = List<SnChannel>.from(
resp.data?.map((e) => SnChannel.fromJson(e)) ?? [],
);
if (!doNotSave) _saveChannelToLocal(out);
return out;
}
/// The get channel method will return the channel with the given alias.
/// It will use the local storage as much as possible.
/// The alias should include the scope, formatted as `scope:alias`.
Future<SnChannel> getChannel(String key) async {
if (_channelBox != null) {
final local = _channelBox!.get(key);
if (local != null) return local;
}
var resp = await _sn.client.get('/cgi/im/channels/$key');
var out = SnChannel.fromJson(resp.data);
// Preload realm of the channel
if (out.realmId != null) {
resp = await _sn.client.get('/cgi/id/realms/${out.realmId}');
out = out.copyWith(realm: SnRealm.fromJson(resp.data));
}
_saveChannelToLocal([out]);
return out;
}
/// The fetch channel method return a stream, which will emit twice.
/// The first time is when the data was fetched from the local storage.
/// And the second time is when the data was fetched from the server.
/// But there is some exception that will only cause one of them to be emitted.
/// Like the local storage is broken or the server is down.
Stream<List<SnChannel>> fetchChannels() async* {
if (_channelBox != null) yield _channelBox!.values.toList();
var resp = await _sn.client.get('/cgi/id/realms/me/available');
final realms = List<SnRealm>.from(
resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
);
final realmMap = {
for (final realm in realms) realm.alias: realm,
};
final scopeToFetch = {'global', ...realms.map((e) => e.alias)};
final List<SnChannel> result = List.empty(growable: true);
final directMessages = await _fetchChannelsFromServer(
scope: scopeToFetch.first,
direct: true,
);
result.addAll(directMessages);
final nonBelongsChannels = await _fetchChannelsFromServer(
scope: scopeToFetch.first,
direct: false,
);
result.addAll(nonBelongsChannels);
for (final scope in scopeToFetch.skip(1)) {
final channel = await _fetchChannelsFromServer(
scope: scope,
direct: false,
doNotSave: true,
);
final out = channel.map((ele) => ele.copyWith(realm: realmMap[scope]));
_saveChannelToLocal(out);
result.addAll(out);
}
yield result;
}
@override
void dispose() {
_channelBox?.close();
super.dispose();
}
}

View File

@ -0,0 +1,459 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/chat.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
class ChatCallProvider extends ChangeNotifier {
late final SnNetworkProvider _sn;
ChatCallProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
}
SnChatCall? _current;
SnChannel? _channel;
bool _isReady = false;
bool _isMounted = false;
bool _isInitialized = false;
bool _isBusy = false;
String _lastDuration = '00:00:00';
Timer? _lastDurationUpdateTimer;
String? token;
String? endpoint;
StreamSubscription? hwSubscription;
List<MediaDevice> _audioInputs = [];
List<MediaDevice> _videoInputs = [];
bool _enableAudio = true;
bool _enableVideo = false;
LocalAudioTrack? _audioTrack;
LocalVideoTrack? _videoTrack;
MediaDevice? _videoDevice;
MediaDevice? _audioDevice;
late Room _room;
late EventsListener<RoomEvent> _listener;
List<ParticipantTrack> _participantTracks = [];
ParticipantTrack? _focusTrack;
// Getters for private fields
SnChatCall? get current => _current;
SnChannel? get channel => _channel;
bool get isReady => _isReady;
bool get isMounted => _isMounted;
bool get isInitialized => _isInitialized;
bool get isBusy => _isBusy;
String get lastDuration => _lastDuration;
List<MediaDevice> get audioInputs => _audioInputs;
List<MediaDevice> get videoInputs => _videoInputs;
bool get enableAudio => _enableAudio;
bool get enableVideo => _enableVideo;
LocalAudioTrack? get audioTrack => _audioTrack;
LocalVideoTrack? get videoTrack => _videoTrack;
MediaDevice? get videoDevice => _videoDevice;
MediaDevice? get audioDevice => _audioDevice;
List<ParticipantTrack> get participantTracks => _participantTracks;
ParticipantTrack? get focusTrack => _focusTrack;
Room get room => _room;
void _updateDuration() {
if (_current == null) {
_lastDuration = '00:00:00';
} else {
Duration duration = DateTime.now().difference(_current!.createdAt);
String twoDigits(int n) => n.toString().padLeft(2, '0');
_lastDuration = '${twoDigits(duration.inHours)}:'
'${twoDigits(duration.inMinutes.remainder(60))}:'
'${twoDigits(duration.inSeconds.remainder(60))}';
}
notifyListeners();
}
void enableDurationUpdater() {
_updateDuration();
_lastDurationUpdateTimer = Timer.periodic(
const Duration(seconds: 1),
(_) => _updateDuration(),
);
}
void disableDurationUpdater() {
_lastDurationUpdateTimer?.cancel();
_lastDurationUpdateTimer = null;
}
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();
}
void setCall(SnChatCall call, SnChannel related) {
_current = call;
_channel = related;
notifyListeners();
}
Future<(String, String)> getRoomToken() async {
final resp = await _sn.client.post(
'/cgi/im/channels/${_channel!.keyPath}/calls/ongoing/token',
);
token = resp.data['token'];
endpoint = 'wss://${resp.data['endpoint']}';
return (token!, endpoint!);
}
void initHardware() {
if (_isReady) return;
_isReady = true;
hwSubscription = Hardware.instance.onDeviceChange.stream.listen(
_revertDevices,
);
Hardware.instance.enumerateDevices().then(_revertDevices);
notifyListeners();
}
void initRoom() {
initHardware();
_room = Room(
roomOptions: const RoomOptions(
dynacast: true,
adaptiveStream: true,
defaultAudioPublishOptions: AudioPublishOptions(
name: 'call_voice',
stream: 'call_stream',
),
defaultVideoPublishOptions: VideoPublishOptions(
name: 'call_video',
stream: 'call_stream',
simulcast: true,
backupVideoCodec: BackupVideoCodec(enabled: true),
),
defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(
useiOSBroadcastExtension: true,
params: VideoParametersPresets.screenShareH1080FPS30,
),
defaultCameraCaptureOptions: CameraCaptureOptions(
maxFrameRate: 30,
params: VideoParametersPresets.h1080_169,
),
),
);
_listener = _room.createListener();
WakelockPlus.enable();
}
Future<void> joinRoom(String url, String token) async {
if (_isMounted) return;
try {
await _room.connect(
url,
token,
fastConnectOptions: FastConnectOptions(
microphone: TrackOption(track: _audioTrack),
camera: TrackOption(track: _videoTrack),
),
);
} finally {
_isMounted = true;
notifyListeners();
}
}
void setupRoom() {
if (isInitialized) return;
sortParticipants();
_room.addListener(_onRoomDidUpdate);
WidgetsBindingCompatible.instance?.addPostFrameCallback(
(_) => autoPublish(),
);
if (lkPlatformIsMobile()) {
Hardware.instance.setSpeakerphoneOn(true);
}
_isBusy = false;
_isInitialized = true;
notifyListeners();
}
void autoPublish() async {
try {
if (enableVideo) {
await _room.localParticipant?.setCameraEnabled(true);
}
if (enableAudio) {
await _room.localParticipant?.setMicrophoneEnabled(true);
}
} catch (error) {
rethrow;
}
}
Future<void> setEnableAudio(bool value) async {
_enableAudio = value;
if (!_enableAudio) {
await _audioTrack?.stop();
_audioTrack = null;
} else {
await _changeLocalAudioTrack();
}
notifyListeners();
}
Future<void> setEnableVideo(bool value) async {
_enableVideo = value;
if (!_enableVideo) {
await _videoTrack?.stop();
_videoTrack = null;
} else {
await _changeLocalVideoTrack();
}
notifyListeners();
}
void setupRoomListeners({
required Function(DisconnectReason?) onDisconnected,
}) {
_listener
..on<RoomDisconnectedEvent>((event) async {
onDisconnected(event.reason);
})
..on<ParticipantEvent>((event) => sortParticipants())
..on<LocalTrackPublishedEvent>((_) => sortParticipants())
..on<LocalTrackUnpublishedEvent>((_) => sortParticipants())
..on<TrackSubscribedEvent>((_) => sortParticipants())
..on<TrackUnsubscribedEvent>((_) => sortParticipants())
..on<ParticipantNameUpdatedEvent>((event) {
sortParticipants();
});
}
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 newTracks = List<ParticipantTrack>.empty(growable: true);
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;
});
newTracks.addAll(mediaTrackList);
if (_room.localParticipant != null) {
ParticipantTrack localTrack = ParticipantTrack(
participant: _room.localParticipant!,
videoTrack: null,
isScreenShare: false,
);
final localParticipantTracks =
_room.localParticipant?.videoTrackPublications;
if (localParticipantTracks != null) {
for (var t in localParticipantTracks) {
localTrack.videoTrack = t.track;
localTrack.isScreenShare = t.isScreenShare;
}
}
newTracks.add(localTrack);
}
_participantTracks = newTracks;
if (focusTrack != null) {
final idx = participantTracks
.indexWhere((x) => x.participant.sid == _focusTrack!.participant.sid);
if (idx == -1) {
_focusTrack = null;
}
}
if (focusTrack == null) {
_focusTrack = participantTracks.firstOrNull;
} else {
final idx = participantTracks.indexWhere(
(x) => _focusTrack!.participant.sid == x.participant.sid,
);
if (idx > -1) {
_focusTrack = participantTracks[idx];
}
}
notifyListeners();
}
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();
}
notifyListeners();
}
Future<void> _changeLocalVideoTrack() async {
if (_videoTrack != null) {
await _videoTrack!.stop();
_videoTrack = null;
}
if (_videoDevice != null) {
_videoTrack = await LocalVideoTrack.createCameraTrack(
CameraCaptureOptions(
deviceId: _videoDevice!.deviceId,
params: VideoParametersPresets.h1080_169,
),
);
await _videoTrack!.start();
}
notifyListeners();
}
void _revertDevices(List<MediaDevice> devices) {
_audioInputs = devices.where((d) => d.kind == 'audioinput').toList();
_videoInputs = devices.where((d) => d.kind == 'videoinput').toList();
notifyListeners();
}
void _onRoomDidUpdate() => sortParticipants();
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: VideoParametersPresets.h1080_169,
),
);
await videoTrack!.start();
}
}
void deactivateHardware() {
hwSubscription?.cancel();
}
void disposeRoom() {
_isBusy = false;
_isMounted = false;
_isInitialized = false;
_current = null;
_channel = null;
_room.removeListener(_onRoomDidUpdate);
_room.disconnect();
_room.dispose();
_listener.dispose();
WakelockPlus.disable();
}
void disposeHardware() {
_isReady = false;
_audioTrack?.stop();
_audioTrack = null;
_videoTrack?.stop();
_videoTrack = null;
}
void setVideoDevice(MediaDevice? value) {
_videoDevice = value;
notifyListeners();
}
void setAudioDevice(MediaDevice? value) {
_audioDevice = value;
notifyListeners();
}
void setFocusTrack(ParticipantTrack? value) {
_focusTrack = value;
notifyListeners();
}
void setIsBusy(bool value) {
_isBusy = value;
notifyListeners();
}
}

View File

@ -24,6 +24,14 @@ class NavigationProvider extends ChangeNotifier {
int? get currentIndex => _currentIndex; int? get currentIndex => _currentIndex;
static const List<String> kShowBottomNavScreen = [
'home',
'explore',
'account',
'album',
'chat',
];
static const List<AppNavDestination> kAllDestination = [ static const List<AppNavDestination> kAllDestination = [
AppNavDestination( AppNavDestination(
icon: Icon(Symbols.home, weight: 400, opticalSize: 20), icon: Icon(Symbols.home, weight: 400, opticalSize: 20),
@ -35,26 +43,37 @@ class NavigationProvider extends ChangeNotifier {
screen: 'explore', screen: 'explore',
label: 'screenExplore', label: 'screenExplore',
), ),
AppNavDestination(
icon: Icon(Symbols.chat, weight: 400, opticalSize: 20),
screen: 'chat',
label: 'screenChat',
),
AppNavDestination( AppNavDestination(
icon: Icon(Symbols.account_circle, weight: 400, opticalSize: 20), icon: Icon(Symbols.account_circle, weight: 400, opticalSize: 20),
screen: 'account', screen: 'account',
label: 'screenAccount', label: 'screenAccount',
), ),
AppNavDestination(
icon: Icon(Symbols.group, weight: 400, opticalSize: 20),
screen: 'realm',
label: 'screenRealm',
),
AppNavDestination( AppNavDestination(
icon: Icon(Symbols.album, weight: 400, opticalSize: 20), icon: Icon(Symbols.album, weight: 400, opticalSize: 20),
screen: 'album', screen: 'album',
label: 'screenAlbum', label: 'screenAlbum',
), ),
AppNavDestination( AppNavDestination(
icon: Icon(Symbols.chat, weight: 400, opticalSize: 20), icon: Icon(Symbols.notifications, weight: 400, opticalSize: 20),
screen: 'chat', screen: 'notification',
label: 'screenChat', label: 'screenNotification',
), ),
]; ];
static const List<String> kDefaultPinnedDestination = [ static const List<String> kDefaultPinnedDestination = [
'home', 'home',
'explore', 'explore',
'account' 'chat',
'account',
]; ];
List<AppNavDestination> destinations = []; List<AppNavDestination> destinations = [];

View File

@ -0,0 +1,66 @@
import 'dart:developer';
import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_udid/flutter_udid.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/websocket.dart';
class NotificationProvider extends ChangeNotifier {
late final SnNetworkProvider _sn;
late final UserProvider _ua;
late final WebSocketProvider _ws;
NotificationProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
_ua = context.read<UserProvider>();
_ws = context.read<WebSocketProvider>();
// Delay to wait user provider ready to use
Future.delayed(const Duration(milliseconds: 3000), () async {
if (!_ua.isAuthorized) return;
log("Registering push notifications...");
await registerPushNotifications();
log("Registered push notification subscriber successfully!");
});
}
Future<void> registerPushNotifications() async {
if (kIsWeb) return;
if (!_ua.isAuthorized) return;
late final String? token;
late final String provider;
var deviceUuid = await FlutterUdid.consistentUdid;
if (deviceUuid.isEmpty) {
log("Unable to active push notifications, couldn't get device uuid");
return;
} else {
log('Device UUID is $deviceUuid');
log('Registering device push notifications...');
}
if (Platform.isIOS || Platform.isMacOS) {
provider = 'apns';
token = await FirebaseMessaging.instance.getAPNSToken();
} else {
provider = 'fcm';
token = await FirebaseMessaging.instance.getToken();
}
log('Device Push Token is $token');
await _sn.client.post(
'/cgi/id/notifications/subscription',
data: {
'provider': provider,
'device_token': token,
'device_id': deviceUuid,
},
);
}
}

119
lib/providers/post.dart Normal file
View File

@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/post.dart';
class SnPostContentProvider {
late final SnNetworkProvider _sn;
late final SnAttachmentProvider _attach;
SnPostContentProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
_attach = context.read<SnAttachmentProvider>();
}
Future<List<SnPost>> _preloadRelatedDataInBatch(List<SnPost> out) async {
Set<String> rids = {};
for (var i = 0; i < out.length; i++) {
rids.addAll(out[i].body['attachments']?.cast<String>() ?? []);
if (out[i].body['thumbnail'] != null) {
rids.add(out[i].body['thumbnail']);
}
}
final attachments = await _attach.getMultiple(rids.toList());
for (var i = 0; i < out.length; i++) {
out[i] = out[i].copyWith(
preload: SnPostPreload(
thumbnail: attachments
.where((ele) => ele?.rid == out[i].body['thumbnail'])
.firstOrNull,
attachments: attachments
.where((ele) =>
out[i].body['attachments']?.contains(ele?.rid) ?? false)
.toList(),
),
);
}
return out;
}
Future<SnPost> _preloadRelatedDataSingle(SnPost out) async {
Set<String> rids = {};
rids.addAll(out.body['attachments']?.cast<String>() ?? []);
if (out.body['thumbnail'] != null) {
rids.add(out.body['thumbnail']);
}
final attachments = await _attach.getMultiple(rids.toList());
out = out.copyWith(
preload: SnPostPreload(
thumbnail: attachments
.where((ele) => ele?.rid == out.body['thumbnail'])
.firstOrNull,
attachments: attachments
.where(
(ele) => out.body['attachments']?.contains(ele?.rid) ?? false)
.toList(),
),
);
return out;
}
Future<(List<SnPost>, int)> listPosts({int take = 10, int offset = 0}) async {
final resp = await _sn.client.get('/cgi/co/posts', queryParameters: {
'take': take,
'offset': offset,
});
final List<SnPost> out = await _preloadRelatedDataInBatch(
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
);
return (out, resp.data['count'] as int);
}
Future<(List<SnPost>, int)> listPostReplies(
dynamic parentId, {
int take = 10,
int offset = 0,
}) async {
final resp = await _sn.client
.get('/cgi/co/posts/$parentId/replies', queryParameters: {
'take': take,
'offset': offset,
});
final List<SnPost> out = await _preloadRelatedDataInBatch(
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
);
return (out, resp.data['count'] as int);
}
Future<(List<SnPost>, int)> searchPosts(
String searchTerm, {
int take = 10,
int offset = 0,
}) async {
final resp = await _sn.client.get('/cgi/co/posts/search', queryParameters: {
'take': take,
'offset': offset,
'probe': searchTerm,
});
final List<SnPost> out = await _preloadRelatedDataInBatch(
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
);
return (out, resp.data['count'] as int);
}
Future<SnPost> getPost(dynamic id) async {
final resp = await _sn.client.get('/cgi/co/posts/$id');
final out = _preloadRelatedDataSingle(
SnPost.fromJson(resp.data),
);
return out;
}
}

View File

@ -19,6 +19,14 @@ class SnAttachmentProvider {
_sn = context.read<SnNetworkProvider>(); _sn = context.read<SnNetworkProvider>();
} }
void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) {
for (final item in items) {
if ((item.isAnalyzed && item.isUploaded) || noCheck) {
_cache[item.rid] = item;
}
}
}
Future<SnAttachment> getOne(String rid, {noCache = false}) async { Future<SnAttachment> getOne(String rid, {noCache = false}) async {
if (!noCache && _cache.containsKey(rid)) { if (!noCache && _cache.containsKey(rid)) {
return _cache[rid]!; return _cache[rid]!;
@ -26,37 +34,49 @@ class SnAttachmentProvider {
final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta'); final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta');
final out = SnAttachment.fromJson(resp.data); final out = SnAttachment.fromJson(resp.data);
_cache[rid] = out; if (out.isAnalyzed && out.isUploaded) {
_cache[rid] = out;
}
return out; return out;
} }
Future<List<SnAttachment>> getMultiple(List<String> rids, Future<List<SnAttachment?>> getMultiple(List<String> rids,
{noCache = false}) async { {noCache = false}) async {
final pendingFetch = final result = List<SnAttachment?>.filled(rids.length, null);
noCache ? rids : rids.where((rid) => !_cache.containsKey(rid)).toList(); final Map<String, int> randomMapping = {};
for (int i = 0; i < rids.length; i++) {
final rid = rids[i];
if (noCache || !_cache.containsKey(rid)) {
randomMapping[rid] = i;
} else {
result[i] = _cache[rid]!;
}
}
final pendingFetch = randomMapping.keys;
if (pendingFetch.isEmpty) { if (pendingFetch.isNotEmpty) {
return rids.map((rid) => _cache[rid]!).toList(); final resp = await _sn.client.get(
'/cgi/uc/attachments',
queryParameters: {
'take': pendingFetch.length,
'id': pendingFetch.join(','),
},
);
final out = resp.data['data']
.map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e))
.toList();
for (final item in out) {
if (item == null) continue;
if (item.isAnalyzed && item.isUploaded) {
_cache[item.rid] = item;
}
result[randomMapping[item.rid]!] = item;
}
} }
final resp = await _sn.client.get('/cgi/uc/attachments', queryParameters: { return result;
'take': pendingFetch.length,
'id': pendingFetch.join(','),
});
final out = resp.data['data']
.where((e) => e['id'] != 0)
.map((e) => SnAttachment.fromJson(e))
.toList();
for (final item in out) {
_cache[item.rid] = item;
}
return rids
.where((rid) => _cache.containsKey(rid))
.map((rid) => _cache[rid]!)
.toList();
} }
static Map<String, String> mimetypeOverrides = { static Map<String, String> mimetypeOverrides = {
@ -110,8 +130,9 @@ class SnAttachmentProvider {
int size, int size,
String filename, String filename,
String pool, String pool,
Map<String, dynamic>? metadata, Map<String, dynamic>? metadata, {
) async { String? mimetype,
}) async {
final fileAlt = filename.contains('.') final fileAlt = filename.contains('.')
? filename.substring(0, filename.lastIndexOf('.')) ? filename.substring(0, filename.lastIndexOf('.'))
: filename; : filename;
@ -119,8 +140,10 @@ class SnAttachmentProvider {
filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
String? mimetypeOverride; String? mimetypeOverride;
if (mimetypeOverrides.keys.contains(fileExt)) { if (mimetype == null && mimetypeOverrides.keys.contains(fileExt)) {
mimetypeOverride = mimetypeOverrides[fileExt]; mimetypeOverride = mimetypeOverrides[fileExt];
} else {
mimetypeOverride = mimetype;
} }
final resp = await _sn.client.post('/cgi/uc/attachments/multipart', data: { final resp = await _sn.client.post('/cgi/uc/attachments/multipart', data: {

View File

@ -1,11 +1,11 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:dio_smart_retry/dio_smart_retry.dart'; import 'package:dio_smart_retry/dio_smart_retry.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:surface/providers/adapters/sn_network_universal.dart'; import 'package:synchronized/synchronized.dart';
const kAtkStoreKey = 'nex_user_atk'; const kAtkStoreKey = 'nex_user_atk';
const kRtkStoreKey = 'nex_user_rtk'; const kRtkStoreKey = 'nex_user_rtk';
@ -20,10 +20,9 @@ const kNetworkServerDirectory = [
]; ];
class SnNetworkProvider { class SnNetworkProvider {
late Dio client; late final Dio client;
late final SharedPreferences _prefs; late final SharedPreferences _prefs;
late final FlutterSecureStorage _storage = FlutterSecureStorage();
SnNetworkProvider() { SnNetworkProvider() {
client = Dio(); client = Dio();
@ -44,54 +43,15 @@ class SnNetworkProvider {
RequestOptions options, RequestOptions options,
RequestInterceptorHandler handler, RequestInterceptorHandler handler,
) async { ) async {
try { final atk = await getFreshAtk();
var atk = await _storage.read(key: kAtkStoreKey); if (atk != null) {
if (atk != null) { options.headers['Authorization'] = 'Bearer $atk';
final atkParts = atk.split('.');
if (atkParts.length != 3) {
throw Exception('invalid format of access token');
}
var rawPayload =
atkParts[1].replaceAll('-', '+').replaceAll('_', '/');
switch (rawPayload.length % 4) {
case 0:
break;
case 2:
rawPayload += '==';
break;
case 3:
rawPayload += '=';
break;
default:
throw Exception('illegal format of access token payload');
}
final b64 = utf8.fuse(base64Url);
final payload = b64.decode(rawPayload);
final exp = jsonDecode(payload)['exp'];
if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) {
log('Access token need refresh, doing it at ${DateTime.now()}');
atk = await refreshToken();
}
if (atk != null) {
options.headers['Authorization'] = 'Bearer $atk';
} else {
log('Access token refresh failed...');
}
}
} catch (err) {
log('Failed to authenticate user: $err');
} finally {
handler.next(options);
} }
return handler.next(options);
}, },
), ),
); );
client = addClientAdapter(client);
SharedPreferences.getInstance().then((prefs) { SharedPreferences.getInstance().then((prefs) {
_prefs = prefs; _prefs = prefs;
client.options.baseUrl = client.options.baseUrl =
@ -99,27 +59,82 @@ class SnNetworkProvider {
}); });
} }
final tkLock = Lock();
Completer<String?>? _refreshCompleter;
Future<String?> getFreshAtk() async {
if (_refreshCompleter != null) {
return await _refreshCompleter!.future;
} else {
_refreshCompleter = Completer<String?>();
}
try {
var atk = _prefs.getString(kAtkStoreKey);
if (atk != null) {
final atkParts = atk.split('.');
if (atkParts.length != 3) {
throw Exception('invalid format of access token');
}
var rawPayload = atkParts[1].replaceAll('-', '+').replaceAll('_', '/');
switch (rawPayload.length % 4) {
case 0:
break;
case 2:
rawPayload += '==';
break;
case 3:
rawPayload += '=';
break;
default:
throw Exception('illegal format of access token payload');
}
final b64 = utf8.fuse(base64Url);
final payload = b64.decode(rawPayload);
final exp = jsonDecode(payload)['exp'];
if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) {
log('Access token need refresh, doing it at ${DateTime.now()}');
atk = await refreshToken();
}
if (atk != null) {
_refreshCompleter!.complete(atk);
return atk;
} else {
log('Access token refresh failed...');
_refreshCompleter!.complete(null);
}
}
} catch (err) {
log('Failed to authenticate user: $err');
_refreshCompleter!.completeError(err);
} finally {
_refreshCompleter = null;
}
return null;
}
String getAttachmentUrl(String ky) { String getAttachmentUrl(String ky) {
if (ky.startsWith("http")) return ky; if (ky.startsWith("http")) return ky;
return '${client.options.baseUrl}/cgi/uc/attachments/$ky'; return '${client.options.baseUrl}/cgi/uc/attachments/$ky';
} }
Future<void> setTokenPair(String atk, String rtk) async { void setTokenPair(String atk, String rtk) {
await Future.wait([ _prefs.setString(kAtkStoreKey, atk);
_storage.write(key: kAtkStoreKey, value: atk), _prefs.setString(kRtkStoreKey, rtk);
_storage.write(key: kRtkStoreKey, value: rtk),
]);
} }
Future<void> clearTokenPair() async { void clearTokenPair() {
await Future.wait([ _prefs.remove(kAtkStoreKey);
_storage.delete(key: kAtkStoreKey), _prefs.remove(kRtkStoreKey);
_storage.delete(key: kRtkStoreKey),
]);
} }
Future<String?> refreshToken() async { Future<String?> refreshToken() async {
final rtk = await _storage.read(key: kRtkStoreKey); final rtk = _prefs.getString(kRtkStoreKey);
if (rtk == null) return null; if (rtk == null) return null;
final dio = Dio(); final dio = Dio();
@ -132,7 +147,7 @@ class SnNetworkProvider {
final atk = resp.data['access_token']; final atk = resp.data['access_token'];
final nRtk = resp.data['refresh_token']; final nRtk = resp.data['refresh_token'];
await setTokenPair(atk, nRtk); setTokenPair(atk, nRtk);
return atk; return atk;
} }

View File

@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/account.dart';
class UserDirectoryProvider {
late final SnNetworkProvider _sn;
UserDirectoryProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
}
final Map<String, int> _idCache = {};
final Map<int, SnAccount> _cache = {};
Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async {
final out = await Future.wait(
id.map((e) => getAccount(e)),
);
return out;
}
Future<SnAccount?> getAccount(dynamic id) async {
if (id is String && _idCache.containsKey(id)) {
id = _idCache[id];
}
if (_cache.containsKey(id)) {
return _cache[id];
}
try {
final resp = await _sn.client.get('/cgi/id/users/$id');
final account = SnAccount.fromJson(
resp.data as Map<String, dynamic>,
);
_cache[account.id] = account;
if (id is String) _idCache[id] = account.id;
return account;
} catch (err) {
return null;
}
}
SnAccount? getAccountFromCache(dynamic id) {
if (id is String && _idCache.containsKey(id)) {
id = _idCache[id];
}
return _cache[id];
}
}

View File

@ -1,8 +1,8 @@
import 'dart:developer'; import 'dart:developer';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/account.dart'; import 'package:surface/types/account.dart';
@ -11,12 +11,17 @@ class UserProvider extends ChangeNotifier {
SnAccount? user; SnAccount? user;
late final SnNetworkProvider _sn; late final SnNetworkProvider _sn;
late final FlutterSecureStorage _storage = FlutterSecureStorage();
Future<String?> get atk async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(kAtkStoreKey);
}
UserProvider(BuildContext context) { UserProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>(); _sn = context.read<SnNetworkProvider>();
_storage.read(key: kAtkStoreKey).then((value) { SharedPreferences.getInstance().then((prefs) {
final value = prefs.getString(kAtkStoreKey);
isAuthorized = value != null; isAuthorized = value != null;
notifyListeners(); notifyListeners();
refreshUser().then((value) { refreshUser().then((value) {
@ -39,7 +44,7 @@ class UserProvider extends ChangeNotifier {
} }
void logoutUser() async { void logoutUser() async {
await _sn.clearTokenPair(); _sn.clearTokenPair();
isAuthorized = false; isAuthorized = false;
user = null; user = null;
notifyListeners(); notifyListeners();

View File

@ -0,0 +1,111 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/websocket.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
class WebSocketProvider extends ChangeNotifier {
bool isBusy = false;
bool isConnected = false;
WebSocketChannel? conn;
late final SnNetworkProvider _sn;
late final UserProvider _ua;
StreamController<WebSocketPackage> stream = StreamController.broadcast();
WebSocketProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
_ua = context.read<UserProvider>();
// Wait for the userinfo provide initialize authorization status
Future.delayed(const Duration(milliseconds: 250), () async {
if (_ua.isAuthorized) {
log('[WebSocket] Connecting to the server...');
await connect();
} else {
log('[WebSocket] Unable connect to the server, unauthorized.');
}
});
}
Future<void> connect({noRetry = false}) async {
if (!_ua.isAuthorized) return;
if (isConnected) {
disconnect();
}
final atk = await _sn.getFreshAtk();
final uri = Uri.parse(
'${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk',
);
isBusy = true;
notifyListeners();
try {
conn = WebSocketChannel.connect(uri);
await conn!.ready;
listen();
log('[WebSocket] Connected to server!');
isConnected = true;
} catch (err) {
if (err is WebSocketChannelException) {
log('Failed to connect to websocket: ${(err.inner as dynamic).message}');
} else {
log('Failed to connect to websocket: $err');
}
if (!noRetry) {
log('Retry connecting to websocket in 3 seconds...');
return Future.delayed(
const Duration(seconds: 3),
() => connect(noRetry: true),
);
}
} finally {
isBusy = false;
notifyListeners();
}
}
void disconnect() {
if (conn != null) {
conn!.sink.close();
}
isConnected = false;
notifyListeners();
}
void listen() {
conn?.stream.listen(
(event) {
final packet = WebSocketPackage.fromJson(jsonDecode(event));
log('Websocket incoming message: ${packet.method} ${packet.message}');
stream.sink.add(packet);
// TODO handle notification
// if (packet.method == 'notifications.new') {
// final NotificationProvider nty = Get.find();
// nty.notifications.add(Notification.fromJson(packet.payload!));
// nty.notificationUnread.value++;
// }
},
onDone: () {
isConnected = false;
notifyListeners();
Future.delayed(const Duration(seconds: 1), () => connect());
},
onError: (err) {
isConnected = false;
notifyListeners();
Future.delayed(const Duration(seconds: 11), () => connect());
},
);
}
}

View File

@ -1,3 +1,5 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:surface/screens/account.dart'; import 'package:surface/screens/account.dart';
import 'package:surface/screens/account/profile_edit.dart'; import 'package:surface/screens/account/profile_edit.dart';
@ -8,134 +10,253 @@ import 'package:surface/screens/album.dart';
import 'package:surface/screens/auth/login.dart'; import 'package:surface/screens/auth/login.dart';
import 'package:surface/screens/auth/register.dart'; import 'package:surface/screens/auth/register.dart';
import 'package:surface/screens/chat.dart'; import 'package:surface/screens/chat.dart';
import 'package:surface/screens/chat/call_room.dart';
import 'package:surface/screens/chat/channel_detail.dart';
import 'package:surface/screens/chat/manage.dart';
import 'package:surface/screens/chat/room.dart';
import 'package:surface/screens/explore.dart'; import 'package:surface/screens/explore.dart';
import 'package:surface/screens/home.dart'; import 'package:surface/screens/home.dart';
import 'package:surface/screens/notification.dart';
import 'package:surface/screens/post/post_detail.dart'; import 'package:surface/screens/post/post_detail.dart';
import 'package:surface/screens/post/post_editor.dart'; import 'package:surface/screens/post/post_editor.dart';
import 'package:surface/screens/post/post_search.dart';
import 'package:surface/screens/realm.dart';
import 'package:surface/screens/realm/manage.dart';
import 'package:surface/screens/settings.dart'; import 'package:surface/screens/settings.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/navigation/app_background.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart'; import 'package:surface/widgets/navigation/app_scaffold.dart';
final _appRoutes = [
ShellRoute(
builder: (context, state, child) => AppPageScaffold(
body: child,
showAppBar: false,
),
routes: [
GoRoute(
path: '/',
name: 'home',
pageBuilder: (context, state) => NoTransitionPage(
child: const HomeScreen(),
),
),
GoRoute(
path: '/posts',
name: 'explore',
pageBuilder: (context, state) => NoTransitionPage(
child: const ExploreScreen(),
),
routes: [
GoRoute(
path: '/write/:mode',
name: 'postEditor',
builder: (context, state) => AppBackground(
isLessOptimization: true,
child: PostEditorScreen(
mode: state.pathParameters['mode']!,
postEditId: int.tryParse(
state.uri.queryParameters['editing'] ?? '',
),
postReplyId: int.tryParse(
state.uri.queryParameters['replying'] ?? '',
),
postRepostId: int.tryParse(
state.uri.queryParameters['reposting'] ?? '',
),
),
),
),
GoRoute(
path: '/search',
name: 'postSearch',
builder: (context, state) => const AppBackground(
isLessOptimization: true,
child: PostSearchScreen(),
),
),
GoRoute(
path: '/:slug',
name: 'postDetail',
builder: (context, state) => AppBackground(
child: PostDetailScreen(
slug: state.pathParameters['slug']!,
preload: state.extra as SnPost?,
),
),
),
],
),
GoRoute(
path: '/account',
name: 'account',
pageBuilder: (context, state) => NoTransitionPage(
child: const AccountScreen(),
),
),
GoRoute(
path: '/chat',
name: 'chat',
pageBuilder: (context, state) => NoTransitionPage(
child: const ChatScreen(),
),
routes: [
GoRoute(
path: '/:scope/:alias',
name: 'chatRoom',
builder: (context, state) => AppBackground(
isLessOptimization: true,
child: ChatRoomScreen(
scope: state.pathParameters['scope']!,
alias: state.pathParameters['alias']!,
),
),
),
GoRoute(
path: '/:scope/:alias/call',
name: 'chatCallRoom',
builder: (context, state) => AppBackground(
child: CallRoomScreen(
scope: state.pathParameters['scope']!,
alias: state.pathParameters['alias']!,
),
),
),
GoRoute(
path: '/:scope/:alias/detail',
name: 'channelDetail',
builder: (context, state) => AppBackground(
child: ChannelDetailScreen(
scope: state.pathParameters['scope']!,
alias: state.pathParameters['alias']!,
),
),
),
GoRoute(
path: '/manage',
name: 'chatManage',
pageBuilder: (context, state) => CustomTransitionPage(
child: ChatManageScreen(
editingChannelAlias: state.uri.queryParameters['editing'],
),
transitionsBuilder:
(context, animation, secondaryAnimation, child) {
return FadeThroughTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
fillColor: Colors.transparent,
child: AppBackground(
isLessOptimization: true,
child: child,
),
);
},
),
),
],
),
GoRoute(
path: '/realm',
name: 'realm',
pageBuilder: (context, state) => NoTransitionPage(
child: const RealmScreen(),
),
routes: [
GoRoute(
path: '/manage',
name: 'realmManage',
pageBuilder: (context, state) => CustomTransitionPage(
child: RealmManageScreen(
editingRealmAlias: state.uri.queryParameters['editing'],
),
transitionsBuilder:
(context, animation, secondaryAnimation, child) {
return FadeThroughTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
fillColor: Colors.transparent,
child: AppBackground(
isLessOptimization: true,
child: child,
),
);
},
),
),
],
),
GoRoute(
path: '/album',
name: 'album',
pageBuilder: (context, state) => NoTransitionPage(
child: const AlbumScreen(),
),
),
GoRoute(
path: '/notification',
name: 'notification',
pageBuilder: (context, state) => NoTransitionPage(
child: const NotificationScreen(),
),
),
],
),
ShellRoute(
builder: (context, state, child) => AppPageScaffold(body: child),
routes: [
GoRoute(
path: '/auth/login',
name: 'authLogin',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/auth/register',
name: 'authRegister',
builder: (context, state) => const RegisterScreen(),
),
GoRoute(
path: '/account/profile/edit',
name: 'accountProfileEdit',
builder: (context, state) => const ProfileEditScreen(),
),
GoRoute(
path: '/account/publishers',
name: 'accountPublishers',
builder: (context, state) => const PublisherScreen(),
),
GoRoute(
path: '/account/publishers/new',
name: 'accountPublisherNew',
builder: (context, state) => const AccountPublisherNewScreen(),
),
GoRoute(
path: '/account/publishers/edit/:name',
name: 'accountPublisherEdit',
builder: (context, state) => AccountPublisherEditScreen(
name: state.pathParameters['name']!,
),
),
],
),
ShellRoute(
builder: (context, state, child) => AppPageScaffold(body: child),
routes: [
GoRoute(
path: '/settings',
name: 'settings',
builder: (context, state) => const AppBackground(
child: SettingsScreen(),
),
),
],
),
];
final appRouter = GoRouter( final appRouter = GoRouter(
routes: [ routes: [
ShellRoute( ShellRoute(
builder: (context, state, child) => AppScaffold( routes: _appRoutes,
body: child, builder: (context, state, child) => AppRootScaffold(body: child),
showBottomNavigation: true,
showDrawer: true,
),
routes: [
GoRoute(
path: '/',
name: 'home',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/posts',
name: 'explore',
builder: (context, state) => const ExploreScreen(),
),
GoRoute(
path: '/account',
name: 'account',
builder: (context, state) => const AccountScreen(),
),
GoRoute(
path: '/chat',
name: 'chat',
builder: (context, state) => const ChatScreen(),
),
GoRoute(
path: '/album',
name: 'album',
builder: (context, state) => const AlbumScreen(),
),
],
),
ShellRoute(
builder: (context, state, child) => AppScaffold(
body: child,
),
routes: [
GoRoute(
path: '/post/write/:mode',
name: 'postEditor',
builder: (context, state) => PostEditorScreen(
mode: state.pathParameters['mode']!,
postEditId: int.tryParse(
state.uri.queryParameters['editing'] ?? '',
),
postReplyId: int.tryParse(
state.uri.queryParameters['replying'] ?? '',
),
postRepostId: int.tryParse(
state.uri.queryParameters['reposting'] ?? '',
),
),
),
GoRoute(
path: '/post/:slug',
name: 'postDetail',
builder: (context, state) => PostDetailScreen(
slug: state.pathParameters['slug']!,
preload: state.extra as SnPost?,
),
)
],
),
ShellRoute(
builder: (context, state, child) => AppScaffold(
body: child,
autoImplyAppBar: true,
showDrawer: true,
),
routes: [
GoRoute(
path: '/auth/login',
name: 'authLogin',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/auth/register',
name: 'authRegister',
builder: (context, state) => const RegisterScreen(),
),
GoRoute(
path: '/account/profile/edit',
name: 'accountProfileEdit',
builder: (context, state) => const ProfileEditScreen(),
),
GoRoute(
path: '/account/publishers',
name: 'accountPublishers',
builder: (context, state) => const PublisherScreen(),
),
GoRoute(
path: '/account/publishers/new',
name: 'accountPublisherNew',
builder: (context, state) => const AccountPublisherNewScreen(),
),
GoRoute(
path: '/account/publishers/edit/:name',
name: 'accountPublisherEdit',
builder: (context, state) => AccountPublisherEditScreen(
name: state.pathParameters['name']!,
),
),
],
),
ShellRoute(
builder: (context, state, child) => AppScaffold(
body: child,
autoImplyAppBar: true,
),
routes: [
GoRoute(
path: '/settings',
name: 'settings',
builder: (context, state) => const SettingsScreen(),
),
],
), ),
], ],
); );

View File

@ -8,7 +8,6 @@ import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class AccountScreen extends StatelessWidget { class AccountScreen extends StatelessWidget {
const AccountScreen({super.key}); const AccountScreen({super.key});
@ -17,7 +16,7 @@ class AccountScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ua = context.watch<UserProvider>(); final ua = context.watch<UserProvider>();
return AppScaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text("screenAccount").tr(), title: Text("screenAccount").tr(),
actions: [ actions: [
@ -27,6 +26,7 @@ class AccountScreen extends StatelessWidget {
GoRouter.of(context).pushNamed('settings'); GoRouter.of(context).pushNamed('settings');
}, },
), ),
const Gap(8),
], ],
), ),
body: SingleChildScrollView( body: SingleChildScrollView(

View File

@ -18,7 +18,6 @@ import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart'; import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
class AccountPublisherEditScreen extends StatefulWidget { class AccountPublisherEditScreen extends StatefulWidget {
@ -149,20 +148,14 @@ class _AccountPublisherEditScreenState
mimetype: 'image/png', mimetype: 'image/png',
); );
if (!mounted) return; switch (place) {
final sn = context.read<SnNetworkProvider>(); case 'avatar':
await sn.client.put( _avatar = attachment.rid;
'/cgi/id/users/me/$place', break;
data: {'attachment': attachment.rid}, case 'banner':
); _banner = attachment.rid;
break;
if (!mounted) return; }
final ua = context.read<UserProvider>();
await ua.refreshUser();
if (!mounted) return;
context.showSnackbar('accountProfileEditApplied'.tr());
_syncWidget();
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -189,7 +182,7 @@ class _AccountPublisherEditScreenState
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
return AppScaffold( return Scaffold(
body: SingleChildScrollView( body: SingleChildScrollView(
child: Column( child: Column(
children: [ children: [
@ -287,7 +280,7 @@ class _AccountPublisherEditScreenState
], ],
) )
], ],
).padding(horizontal: 16, vertical: 12), ).padding(horizontal: 24, vertical: 12),
), ),
); );
} }

View File

@ -8,7 +8,6 @@ import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class AccountPublisherNewScreen extends StatefulWidget { class AccountPublisherNewScreen extends StatefulWidget {
const AccountPublisherNewScreen({super.key}); const AccountPublisherNewScreen({super.key});
@ -23,7 +22,7 @@ class _AccountPublisherNewScreenState extends State<AccountPublisherNewScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AppScaffold( return Scaffold(
body: SingleChildScrollView( body: SingleChildScrollView(
child: Column( child: Column(
children: [ children: [

View File

@ -1,5 +1,4 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -11,7 +10,6 @@ import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart'; import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class PublisherScreen extends StatefulWidget { class PublisherScreen extends StatefulWidget {
const PublisherScreen({super.key}); const PublisherScreen({super.key});
@ -55,7 +53,7 @@ class _PublisherScreenState extends State<PublisherScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AppScaffold( return Scaffold(
body: Column( body: Column(
children: [ children: [
ListTile( ListTile(

View File

@ -1,10 +1,125 @@
import 'package:dismissible_page/dismissible_page.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/attachment.dart';
import 'package:surface/widgets/attachment/attachment_detail.dart';
import 'package:surface/widgets/attachment/attachment_item.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:uuid/uuid.dart';
class AlbumScreen extends StatelessWidget { class AlbumScreen extends StatefulWidget {
const AlbumScreen({super.key}); const AlbumScreen({super.key});
@override
State<AlbumScreen> createState() => _AlbumScreenState();
}
class _AlbumScreenState extends State<AlbumScreen> {
final ScrollController _scrollController = ScrollController();
bool _isBusy = false;
int? _totalCount;
final List<SnAttachment> _attachments = List.empty(growable: true);
final List<String> _heroTags = List.empty(growable: true);
Future<void> _fetchAttachments() async {
setState(() => _isBusy = true);
const uuid = Uuid();
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/uc/attachments', queryParameters: {
'take': 10,
'offset': _attachments.length,
});
final attachments = List<SnAttachment>.from(
resp.data['data']?.map((e) => SnAttachment.fromJson(e)) ?? [],
).where((e) => e.mimetype.startsWith('image')).toList();
_attachments.addAll(attachments);
_heroTags.addAll(_attachments.map((_) => uuid.v4()));
_totalCount = resp.data['count'] as int?;
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchAttachments();
_scrollController.addListener(() {
if (_scrollController.position.atEdge) {
bool isTop = _scrollController.position.pixels == 0;
if (!isTop && !_isBusy) {
if (_totalCount == null || _attachments.length < _totalCount!) {
_fetchAttachments();
}
}
}
});
}
@override
void dispose() {
super.dispose();
_scrollController.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const Placeholder(); return Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
SliverAppBar(
title: Text('screenAlbum').tr(),
),
SliverMasonryGrid.extent(
childCount: _attachments.length,
maxCrossAxisExtent: 320,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
itemBuilder: (context, idx) {
final attachment = _attachments[idx];
return GestureDetector(
child: ClipRRect(
child: AspectRatio(
aspectRatio: attachment.metadata['ratio']?.toDouble() ?? 1,
child: AttachmentItem(
data: attachment,
heroTag: _heroTags[idx],
),
),
),
onTap: () {
context.pushTransparentRoute(
AttachmentZoomView(
data: [attachment],
heroTags: [_heroTags[idx]],
),
backgroundColor: Colors.black.withOpacity(0.7),
rootNavigator: true,
);
},
);
},
),
if (_isBusy)
SliverToBoxAdapter(
child: const CircularProgressIndicator().padding(all: 24),
),
],
),
);
} }
} }

View File

@ -151,7 +151,7 @@ class _LoginCheckScreenState extends State<_LoginCheckScreen> {
}); });
final atk = tokenResp.data['access_token']; final atk = tokenResp.data['access_token'];
final rtk = tokenResp.data['refresh_token']; final rtk = tokenResp.data['refresh_token'];
await sn.setTokenPair(atk, rtk); sn.setTokenPair(atk, rtk);
if (!mounted) return; if (!mounted) return;
final user = context.read<UserProvider>(); final user = context.read<UserProvider>();
final userinfo = await user.refreshUser(); final userinfo = await user.refreshUser();

View File

@ -1,10 +1,102 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
class ChatScreen extends StatelessWidget { class ChatScreen extends StatefulWidget {
const ChatScreen({super.key}); const ChatScreen({super.key});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
bool _isBusy = true;
List<SnChannel>? _channels;
void _refreshChannels() {
final chan = context.read<ChatChannelProvider>();
chan.fetchChannels().listen((channels) {
if (mounted) setState(() => _channels = channels);
})
..onError((err) {
if (!mounted) return;
context.showErrorDialog(err);
setState(() => _isBusy = false);
})
..onDone(() {
if (!mounted) return;
setState(() => _isBusy = false);
});
}
@override
void initState() {
super.initState();
_refreshChannels();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const Placeholder(); return Scaffold(
appBar: AppBar(
title: Text('screenChat').tr(),
),
floatingActionButton: FloatingActionButton(
child: const Icon(Symbols.chat_add_on),
onPressed: () {
GoRouter.of(context).pushNamed('chatManage').then((value) {
if (value != null && context.mounted) _refreshChannels();
});
},
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
Expanded(
child: RefreshIndicator(
onRefresh: () => Future.sync(() => _refreshChannels()),
child: ListView.builder(
itemCount: _channels?.length ?? 0,
itemBuilder: (context, idx) {
final channel = _channels![idx];
return ListTile(
title: Text(channel.name),
subtitle: Text(
channel.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
leading: AccountImage(
content: null,
fallbackWidget: const Icon(Symbols.chat, size: 20),
),
onTap: () {
GoRouter.of(context).pushNamed(
'chatRoom',
pathParameters: {
'scope': channel.realm?.alias ?? 'global',
'alias': channel.alias,
},
).then((value) {
if (value == true) _refreshChannels();
});
},
);
},
),
),
),
],
),
);
} }
} }

View File

@ -0,0 +1,324 @@
import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:livekit_client/livekit_client.dart' as livekit;
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/chat_call.dart';
import 'package:surface/widgets/chat/call/call_controls.dart';
import 'package:surface/widgets/chat/call/call_participant.dart';
class CallRoomScreen extends StatefulWidget {
final String scope;
final String alias;
const CallRoomScreen({super.key, required this.scope, required this.alias});
@override
State<CallRoomScreen> createState() => _CallRoomScreenState();
}
class _CallRoomScreenState extends State<CallRoomScreen> {
int _layoutMode = 0;
void _switchLayout() {
if (_layoutMode < 1) {
setState(() => _layoutMode++);
} else {
setState(() => _layoutMode = 0);
}
}
Widget _buildListLayout() {
final call = context.read<ChatCallProvider>();
return Stack(
children: [
Container(
color:
Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.75),
child: call.focusTrack != null
? InteractiveParticipantWidget(
isFixedAvatar: false,
participant: call.focusTrack!,
onTap: () {},
)
: const SizedBox.shrink(),
),
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(
isFixedAvatar: true,
width: 120,
height: 120,
color: Theme.of(context).cardColor,
participant: track,
onTap: () {
if (track.participant.sid !=
call.focusTrack?.participant.sid) {
call.setFocusTrack(track);
}
},
),
),
);
},
),
),
),
],
);
}
Widget _buildGridLayout() {
final call = context.read<ChatCallProvider>();
return LayoutBuilder(builder: (context, constraints) {
double screenWidth = constraints.maxWidth;
double screenHeight = constraints.maxHeight;
int columns = (math.sqrt(call.participantTracks.length)).ceil();
int rows = (call.participantTracks.length / columns).ceil();
double tileWidth = screenWidth / columns;
double tileHeight = screenHeight / rows;
return StyledWidget(GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
childAspectRatio: tileWidth / tileHeight,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: math.max(0, call.participantTracks.length),
itemBuilder: (BuildContext context, int index) {
final track = call.participantTracks[index];
return Card(
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: InteractiveParticipantWidget(
color: Theme.of(context)
.colorScheme
.surfaceContainerHigh
.withOpacity(0.75),
participant: track,
onTap: () {
if (track.participant.sid !=
call.focusTrack?.participant.sid) {
call.setFocusTrack(track);
}
},
),
),
);
},
)).padding(all: 8);
});
}
@override
void initState() {
super.initState();
final call = context.read<ChatCallProvider>();
Future.delayed(Duration.zero, () {
call
..setupRoom()
..enableDurationUpdater();
});
}
@override
Widget build(BuildContext context) {
final call = context.read<ChatCallProvider>();
return ListenableBuilder(
listenable: call,
builder: (context, _) {
return Scaffold(
appBar: AppBar(
title: RichText(
textAlign: TextAlign.center,
text: TextSpan(children: [
TextSpan(
text: 'call'.tr(),
style: Theme.of(context)
.textTheme
.titleLarge!
.copyWith(color: Colors.white),
),
const TextSpan(text: '\n'),
TextSpan(
text: call.lastDuration.toString(),
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(color: Colors.white),
),
]),
),
),
body: SafeArea(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
child: Column(
children: [
SizedBox(
width: MediaQuery.of(context).size.width,
height: 64,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Builder(builder: (context) {
final call = context.read<ChatCallProvider>();
final connectionQuality =
call.room.localParticipant?.connectionQuality ??
livekit.ConnectionQuality.unknown;
return Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
call.channel?.name ?? 'unknown'.tr(),
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
const Gap(6),
Text(call.lastDuration.toString())
],
),
Row(
children: [
Text(
{
livekit.ConnectionState.disconnected:
'callStatusDisconnected'.tr(),
livekit.ConnectionState.connected:
'callStatusConnected'.tr(),
livekit.ConnectionState.connecting:
'callStatusConnecting'.tr(),
livekit.ConnectionState.reconnecting:
'callStatusReconnecting'.tr(),
}[call.room.connectionState]!,
),
const Gap(6),
if (connectionQuality !=
livekit.ConnectionQuality.unknown)
Icon(
{
livekit.ConnectionQuality.excellent:
Icons.signal_cellular_alt,
livekit.ConnectionQuality.good:
Icons.signal_cellular_alt_2_bar,
livekit.ConnectionQuality.poor:
Icons.signal_cellular_alt_1_bar,
}[connectionQuality],
color: {
livekit.ConnectionQuality.excellent:
Colors.green,
livekit.ConnectionQuality.good:
Colors.orange,
livekit.ConnectionQuality.poor:
Colors.red,
}[connectionQuality],
size: 16,
)
else
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
).padding(all: 3),
],
),
],
),
);
}),
Row(
children: [
IconButton(
icon: _layoutMode == 0
? const Icon(Icons.view_list)
: const Icon(Icons.grid_view),
onPressed: () {
_switchLayout();
},
),
],
),
],
).padding(left: 20, right: 16),
),
Expanded(
child: Material(
color:
Theme.of(context).colorScheme.surfaceContainerLow,
child: Builder(
builder: (context) {
switch (_layoutMode) {
case 1:
return _buildGridLayout();
default:
return _buildListLayout();
}
},
),
),
),
if (call.room.localParticipant != null)
SizedBox(
width: MediaQuery.of(context).size.width,
child: ControlsWidget(
call.room,
call.room.localParticipant!,
),
),
],
),
onTap: () {},
),
),
);
});
}
@override
void deactivate() {
final call = context.read<ChatCallProvider>();
call.disableDurationUpdater();
super.deactivate();
}
@override
void activate() {
final call = context.read<ChatCallProvider>();
call.enableDurationUpdater();
super.activate();
}
}

View File

@ -0,0 +1,391 @@
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
class ChannelDetailScreen extends StatefulWidget {
final String scope;
final String alias;
const ChannelDetailScreen({
super.key,
required this.scope,
required this.alias,
});
@override
State<ChannelDetailScreen> createState() => _ChannelDetailScreenState();
}
class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
bool _isBusy = false;
SnChannel? _channel;
SnChannelMember? _profile;
Future<void> _fetchChannel() async {
setState(() => _isBusy = true);
try {
final chan = context.read<ChatChannelProvider>();
_channel = await chan.getChannel('${widget.scope}:${widget.alias}');
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _fetchChannelProfile() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client
.get('/cgi/im/channels/${_channel!.keyPath}/members/me');
_profile = SnChannelMember.fromJson(resp.data);
_notifyLevel = _profile!.notify;
if (!mounted) return;
final ud = context.read<UserDirectoryProvider>();
await ud.getAccount(_profile!.accountId);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _deleteChannel() async {
final confirm = await context.showConfirmDialog(
'channelDelete'.tr(args: [_channel!.name]),
'channelDeleteDescription'.tr(),
);
if (!confirm) return;
if (!mounted) return;
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.delete(
'/cgi/im/channels/${_channel!.realm?.alias ?? 'global'}/${_channel!.id}',
);
if (!mounted) return;
Navigator.pop(context, false);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
}
}
Future<void> _leaveChannel() async {
final confirm = await context.showConfirmDialog(
'channelLeave'.tr(args: [_channel!.name]),
'channelLeaveDescription'.tr(),
);
if (!confirm) return;
if (!mounted) return;
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.delete(
'/cgi/im/channels/${_channel!.realm?.alias ?? 'global'}/${_channel!.id}/members/me',
);
if (!mounted) return;
Navigator.pop(context, false);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
}
}
int _notifyLevel = 0;
bool _isUpdatingNotifyLevel = false;
final kNotifyLevels = {
0: 'channelNotifyLevelAll'.tr(),
1: 'channelNotifyLevelMentioned'.tr(),
2: 'channelNotifyLevelNone'.tr(),
};
Future<void> _updateNotifyLevel(int value) async {
if (_isUpdatingNotifyLevel) return;
setState(() => _isUpdatingNotifyLevel = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.put(
'/cgi/im/channels/${_channel!.keyPath}/members/me/notify',
data: {'notify_level': value},
);
_notifyLevel = value;
if (!mounted) return;
context.showSnackbar('channelNotifyLevelApplied'.tr());
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isUpdatingNotifyLevel = false);
}
}
void _showChannelProfileDetail() {
showDialog(
context: context,
builder: (context) => _ChannelProfileDetailDialog(
channel: _channel!,
current: _profile!,
),
).then((value) {
if (value != null && mounted) {
Navigator.pop(context, true);
}
});
}
@override
void initState() {
super.initState();
_fetchChannel().then((_) {
_fetchChannelProfile();
});
}
@override
Widget build(BuildContext context) {
final ud = context.read<UserDirectoryProvider>();
final ua = context.read<UserProvider>();
final isOwned = ua.isAuthorized && _channel?.accountId == ua.user?.id;
return Scaffold(
appBar: AppBar(
title: _channel != null ? Text(_channel!.name) : Text('loading').tr(),
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LoadingIndicator(isActive: _isBusy),
const Gap(24),
if (_channel != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_channel!.name,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
_channel!.description,
style: Theme.of(context).textTheme.bodyMedium,
),
],
).padding(horizontal: 24),
const Gap(16),
const Divider(),
const Gap(12),
if (_profile != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('channelDetailPersonalRegion')
.bold()
.fontSize(17)
.tr()
.padding(horizontal: 20, bottom: 4),
ListTile(
leading: const Icon(Symbols.notifications),
trailing: DropdownButtonHideUnderline(
child: DropdownButton2<int>(
isExpanded: true,
items: kNotifyLevels.entries
.map((item) => DropdownMenuItem<int>(
enabled: !_isUpdatingNotifyLevel,
value: item.key,
child: Text(
item.value,
style: const TextStyle(
fontSize: 14,
),
),
))
.toList(),
value: _notifyLevel,
onChanged: (int? value) {
if (value == null) return;
_updateNotifyLevel(value);
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.only(left: 16, right: 1),
height: 40,
width: 140,
),
menuItemStyleData: const MenuItemStyleData(
height: 40,
),
),
),
title: Text('channelNotifyLevel').tr(),
subtitle: Text('channelNotifyLevelDescription').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 20),
),
ListTile(
leading: AccountImage(
content:
ud.getAccountFromCache(_profile!.accountId)?.avatar,
radius: 18,
),
trailing: const Icon(Symbols.chevron_right),
title: Text('channelEditProfile').tr(),
subtitle: Text(
(_profile?.nick?.isEmpty ?? true)
? ud.getAccountFromCache(_profile!.accountId)!.nick
: _profile!.nick!,
),
contentPadding: const EdgeInsets.only(left: 20, right: 20),
onTap: _showChannelProfileDetail,
),
if (!isOwned)
ListTile(
leading: const Icon(Symbols.exit_to_app),
trailing: const Icon(Symbols.chevron_right),
title: Text('channelActionLeave').tr(),
subtitle: Text('channelActionLeaveDescription').tr(),
contentPadding:
const EdgeInsets.symmetric(horizontal: 24),
onTap: _leaveChannel,
),
],
).padding(bottom: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('channelDetailAdminRegion')
.bold()
.fontSize(17)
.tr()
.padding(horizontal: 20, bottom: 4),
ListTile(
leading: const Icon(Symbols.edit),
trailing: const Icon(Symbols.chevron_right),
title: Text('channelEdit').tr(),
subtitle: Text('channelEditDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
GoRouter.of(context).pushNamed(
'chatManage',
queryParameters: {'editing': _channel!.keyPath},
).then((value) {
if (value != null && context.mounted) {
Navigator.pop(context, value);
}
});
},
),
if (isOwned)
ListTile(
leading: const Icon(Symbols.delete),
trailing: const Icon(Symbols.chevron_right),
title: Text('channelActionDelete').tr(),
subtitle: Text('channelActionDeleteDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: _deleteChannel,
),
],
),
],
),
),
);
}
}
class _ChannelProfileDetailDialog extends StatefulWidget {
final SnChannel channel;
final SnChannelMember current;
const _ChannelProfileDetailDialog({
required this.channel,
required this.current,
});
@override
State<_ChannelProfileDetailDialog> createState() =>
_ChannelProfileDetailDialogState();
}
class _ChannelProfileDetailDialogState
extends State<_ChannelProfileDetailDialog> {
bool _isBusy = false;
final TextEditingController _nickController = TextEditingController();
Future<void> _updateProfile() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.put(
'/cgi/im/channels/${widget.channel.keyPath}/members/me',
data: {'nick': _nickController.text},
);
if (!mounted) return;
Navigator.pop(context, true);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_nickController.text = widget.current.nick ?? '';
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('channelProfileEdit').tr(),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _nickController,
decoration: InputDecoration(
labelText: 'fieldChannelProfileNick'.tr(),
helperText: 'fieldChannelProfileNickHint'.tr(),
helperMaxLines: 2,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
],
),
actions: [
TextButton(
onPressed: _isBusy ? null : () => Navigator.pop(context),
child: Text('dialogCancel').tr(),
),
TextButton(
onPressed: _isBusy ? null : _updateProfile,
child: Text('apply').tr(),
),
],
);
}
}

View File

@ -0,0 +1,295 @@
import 'package:dio/dio.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/types/realm.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:uuid/uuid.dart';
class ChatManageScreen extends StatefulWidget {
final String? editingChannelAlias;
const ChatManageScreen({super.key, this.editingChannelAlias});
@override
State<ChatManageScreen> createState() => _ChatManageScreenState();
}
class _ChatManageScreenState extends State<ChatManageScreen> {
bool _isBusy = false;
final _aliasController = TextEditingController();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
List<SnRealm>? _realms;
SnRealm? _belongToRealm;
Future<void> _fetchRealms() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/realms/me/available');
_realms = List<SnRealm>.from(
resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
);
} catch (err) {
if (mounted) context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
SnChannel? _editingChannel;
Future<void> _fetchChannel() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get(
'/cgi/im/channels/${widget.editingChannelAlias}',
);
_editingChannel = SnChannel.fromJson(resp.data);
_aliasController.text = _editingChannel!.alias;
_nameController.text = _editingChannel!.name;
_descriptionController.text = _editingChannel!.description;
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _performAction() async {
final uuid = const Uuid();
final sn = context.read<SnNetworkProvider>();
setState(() => _isBusy = true);
final scope = _belongToRealm != null ? _belongToRealm!.alias : 'global';
final payload = {
'alias': _aliasController.text.isNotEmpty
? _aliasController.text.toLowerCase()
: uuid.v4().replaceAll('-', '').substring(0, 12),
'name': _nameController.text,
'description': _descriptionController.text,
};
try {
final resp = await sn.client.request(
widget.editingChannelAlias != null
? '/cgi/im/channels/$scope/${widget.editingChannelAlias}'
: '/cgi/im/channels/$scope',
data: payload,
options: Options(
method: widget.editingChannelAlias != null ? 'PUT' : 'POST',
),
);
// ignore: use_build_context_synchronously
if (context.mounted) Navigator.pop(context, resp.data);
} catch (err) {
// ignore: use_build_context_synchronously
if (context.mounted) context.showErrorDialog(err);
}
setState(() => _isBusy = false);
}
@override
void initState() {
super.initState();
if (widget.editingChannelAlias != null) _fetchChannel();
_fetchRealms();
}
@override
void dispose() {
super.dispose();
_aliasController.dispose();
_nameController.dispose();
_descriptionController.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: widget.editingChannelAlias != null
? Text('screenChatManage').tr()
: Text('screenChatNew').tr(),
),
body: SingleChildScrollView(
child: Column(
children: [
LoadingIndicator(isActive: _isBusy),
if (_editingChannel != null)
MaterialBanner(
leading: const Icon(Symbols.edit),
leadingPadding: const EdgeInsets.only(left: 10, right: 20),
dividerColor: Colors.transparent,
content: Text(
'channelEditingNotice'
.tr(args: ['#${_editingChannel!.alias}']),
),
actions: [
TextButton(
child: Text('cancel').tr(),
onPressed: () {
Navigator.pop(context);
},
),
],
),
DropdownButtonHideUnderline(
child: DropdownButton2<SnRealm>(
isExpanded: true,
hint: Text(
'fieldChatBelongToRealm'.tr(),
style: TextStyle(
color: Theme.of(context).hintColor,
),
),
items: [
...(_realms?.map(
(SnRealm item) => DropdownMenuItem<SnRealm>(
value: item,
child: Row(
children: [
AccountImage(
content: item.avatar,
radius: 16,
fallbackWidget: const Icon(
Symbols.group,
size: 16,
),
),
const Gap(12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.name).textStyle(Theme.of(context)
.textTheme
.bodyMedium!),
Text(
item.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
).textStyle(
Theme.of(context).textTheme.bodySmall!),
],
),
),
],
),
),
) ??
[]),
DropdownMenuItem<SnRealm>(
value: null,
child: Row(
children: [
CircleAvatar(
radius: 16,
backgroundColor: Colors.transparent,
foregroundColor:
Theme.of(context).colorScheme.onSurface,
child: const Icon(Symbols.clear),
),
const Gap(12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('fieldChatBelongToRealmUnset')
.tr()
.textStyle(
Theme.of(context).textTheme.bodyMedium!,
),
],
),
),
],
),
),
],
value: _belongToRealm,
onChanged: (SnRealm? value) {
setState(() => _belongToRealm = value);
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.only(right: 16),
height: 60,
),
menuItemStyleData: const MenuItemStyleData(
height: 60,
),
),
),
const Divider(height: 1),
const Gap(12),
Column(
children: [
TextField(
controller: _aliasController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldChatAlias'.tr(),
helperText: 'fieldChatAliasHint'.tr(),
helperMaxLines: 2,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
TextField(
controller: _nameController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldChatName'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
TextField(
controller: _descriptionController,
maxLines: null,
minLines: 3,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldChatDescription'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ElevatedButton.icon(
onPressed: _isBusy ? null : _performAction,
icon: const Icon(Symbols.save),
label: Text('apply').tr(),
),
],
),
],
).padding(horizontal: 24),
],
),
),
);
}
}

316
lib/screens/chat/room.dart Normal file
View File

@ -0,0 +1,316 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/chat_message_controller.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/chat_call.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/websocket.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/widgets/chat/call/call_prejoin.dart';
import 'package:surface/widgets/chat/chat_message.dart';
import 'package:surface/widgets/chat/chat_message_input.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class ChatRoomScreen extends StatefulWidget {
final String scope;
final String alias;
const ChatRoomScreen({super.key, required this.scope, required this.alias});
@override
State<ChatRoomScreen> createState() => _ChatRoomScreenState();
}
class _ChatRoomScreenState extends State<ChatRoomScreen> {
bool _isBusy = false;
bool _isCalling = false;
SnChannel? _channel;
SnChatCall? _ongoingCall;
final GlobalKey<ChatMessageInputState> _inputGlobalKey = GlobalKey();
late final ChatMessageController _messageController;
StreamSubscription? _wsSubscription;
Future<void> _fetchChannel() async {
setState(() => _isBusy = true);
try {
final chan = context.read<ChatChannelProvider>();
_channel = await chan.getChannel('${widget.scope}:${widget.alias}');
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _fetchOngoingCall() async {
setState(() => _isCalling = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get(
'/cgi/im/channels/${_messageController.channel!.keyPath}/calls/ongoing',
options: Options(
validateStatus: (status) => status != null && status < 500,
),
);
if (resp.statusCode == 200) {
_ongoingCall = SnChatCall.fromJson(resp.data);
}
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isCalling = false);
}
}
Future<void> _makeCall() async {
setState(() => _isCalling = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.post(
'/cgi/im/channels/${_messageController.channel!.keyPath}/calls',
options: Options(
sendTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
),
);
log(jsonDecode(resp.data));
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isCalling = false);
}
}
Future<void> _endCall() async {
setState(() => _isCalling = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.delete(
'/cgi/im/channels/${_messageController.channel!.keyPath}/calls/ongoing',
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isCalling = false);
}
}
Future<void> _onCallJoin() async {
await showModalBottomSheet(
context: context,
builder: (context) => ChatCallPrejoinPopup(
ongoingCall: _ongoingCall!,
channel: _channel!,
onJoin: _onCallResume,
),
);
}
void _onCallResume() {
GoRouter.of(context).pushNamed(
'chatCallRoom',
pathParameters: {
'scope': _channel!.realm!.alias,
'alias': _channel!.alias,
},
);
}
bool _checkMessageMergeable(SnChatMessage? a, SnChatMessage? b) {
if (a == null || b == null) return false;
if (a.sender.accountId != b.sender.accountId) return false;
return a.createdAt.difference(b.createdAt).inMinutes <= 3;
}
@override
void initState() {
super.initState();
_messageController = ChatMessageController(context);
_fetchChannel().then((_) async {
await _messageController.initialize(_channel!);
await _messageController.checkUpdate();
await _fetchOngoingCall();
});
final ws = context.read<WebSocketProvider>();
_wsSubscription = ws.stream.stream.listen((event) {
switch (event.method) {
case 'calls.new':
final payload = SnChatCall.fromJson(event.payload!);
if (payload.channelId == _channel?.id) {
setState(() => _ongoingCall = payload);
}
break;
case 'calls.end':
final payload = SnChatCall.fromJson(event.payload!);
if (payload.channelId == _channel?.id) {
setState(() => _ongoingCall = null);
}
break;
}
});
}
@override
void dispose() {
_wsSubscription?.cancel();
_messageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final call = context.watch<ChatCallProvider>();
return Scaffold(
appBar: AppBar(
title: Text(_channel?.name ?? 'loading'.tr()),
actions: [
IconButton(
icon: _ongoingCall == null
? const Icon(Symbols.call)
: const Icon(Symbols.call_end),
onPressed: _isCalling
? null
: _ongoingCall == null
? _makeCall
: _endCall,
),
IconButton(
icon: const Icon(Symbols.more_vert),
onPressed: () {
GoRouter.of(context).pushNamed('channelDetail', pathParameters: {
'scope': widget.scope,
'alias': widget.alias,
}).then((value) {
if (value == false && context.mounted) {
Navigator.pop(context, true);
} else if (value != null && context.mounted) {
_fetchChannel();
}
});
},
),
const Gap(8),
],
),
body: ListenableBuilder(
listenable: _messageController,
builder: (context, _) {
return Column(
children: [
LoadingIndicator(isActive: _isBusy),
SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: MaterialBanner(
dividerColor: Colors.transparent,
leading: const Icon(Symbols.call_received),
content: Text('callOngoingNotice').tr().padding(top: 2),
actions: [
if (call.current == null)
TextButton(
onPressed: _onCallJoin,
child: Text('callJoin').tr(),
)
else if (call.current?.channelId == _channel?.id)
TextButton(
onPressed: _onCallResume,
child: Text('callResume').tr(),
)
],
),
).height(_ongoingCall != null ? 54 : 0, animate: true).animate(
const Duration(milliseconds: 300),
Curves.fastLinearToSlowEaseIn),
if (_messageController.isPending)
Expanded(
child: const CircularProgressIndicator().center(),
),
if (!_messageController.isPending)
Expanded(
child: InfiniteList(
reverse: true,
padding: const EdgeInsets.only(
left: 12,
right: 12,
top: 12,
),
hasReachedMax: _messageController.isAllLoaded,
itemCount: _messageController.messages.length,
isLoading: _messageController.isLoading,
onFetchData: () {
_messageController.loadMessages();
},
itemBuilder: (context, idx) {
final message = _messageController.messages[idx];
bool canMerge = false, canMergePrevious = false;
if (idx > 0) {
canMergePrevious = _checkMessageMergeable(
_messageController.messages[idx - 1],
_messageController.messages[idx],
);
}
if (idx + 1 < _messageController.messages.length) {
canMerge = _checkMessageMergeable(
_messageController.messages[idx],
_messageController.messages[idx + 1],
);
}
return ChatMessage(
data: message,
isMerged: canMerge,
hasMerged: canMergePrevious,
isPending: _messageController.unconfirmedMessages
.contains(message.uuid),
onReply: (value) {
_inputGlobalKey.currentState?.setReply(value);
},
onEdit: (value) {
_inputGlobalKey.currentState?.setEdit(value);
},
onDelete: (value) {
_inputGlobalKey.currentState?.deleteMessage(value);
},
);
},
),
),
if (!_messageController.isPending)
Material(
elevation: 2,
child: ChatMessageInput(
key: _inputGlobalKey,
controller: _messageController,
).padding(bottom: MediaQuery.of(context).padding.bottom),
),
],
);
},
),
);
}
}

View File

@ -5,10 +5,8 @@ import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
@ -32,35 +30,13 @@ class _ExploreScreenState extends State<ExploreScreen> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>(); final pt = context.read<SnPostContentProvider>();
final resp = await sn.client.get('/cgi/co/posts', queryParameters: { final result = await pt.listPosts(take: 10, offset: _posts.length);
'take': 10, final out = result.$1;
'offset': _posts.length,
});
final List<SnPost> out =
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []);
Set<String> rids = {};
for (var i = 0; i < out.length; i++) {
rids.addAll(out[i].body['attachments']?.cast<String>() ?? []);
}
if (!mounted) return; if (!mounted) return;
final attach = context.read<SnAttachmentProvider>();
final attachments = await attach.getMultiple(rids.toList());
for (var i = 0; i < out.length; i++) {
out[i] = out[i].copyWith(
preload: SnPostPreload(
attachments: attachments
.where(
(ele) => out[i].body['attachments']?.contains(ele.rid) ?? false,
)
.toList(),
),
);
}
_postCount = resp.data['count']; _postCount = result.$2;
_posts.addAll(out); _posts.addAll(out);
if (mounted) setState(() => _isBusy = false); if (mounted) setState(() => _isBusy = false);
@ -74,7 +50,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AppScaffold( return Scaffold(
floatingActionButtonLocation: ExpandableFab.location, floatingActionButtonLocation: ExpandableFab.location,
floatingActionButton: ExpandableFab( floatingActionButton: ExpandableFab(
key: _fabKey, key: _fabKey,
@ -164,6 +140,15 @@ class _ExploreScreenState extends State<ExploreScreen> {
title: Text('screenExplore').tr(), title: Text('screenExplore').tr(),
floating: true, floating: true,
snap: true, snap: true,
actions: [
IconButton(
icon: const Icon(Symbols.search),
onPressed: () {
GoRouter.of(context).pushNamed('postSearch');
},
),
const Gap(8),
],
), ),
SliverInfiniteList( SliverInfiniteList(
itemCount: _posts.length, itemCount: _posts.length,
@ -173,7 +158,17 @@ class _ExploreScreenState extends State<ExploreScreen> {
onFetchData: _fetchPosts, onFetchData: _fetchPosts,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
return GestureDetector( return GestureDetector(
child: PostItem(data: _posts[idx]), child: PostItem(
data: _posts[idx],
maxWidth: 640,
onChanged: (data) {
setState(() => _posts[idx] = data);
},
onDeleted: () {
_posts.clear();
_fetchPosts();
},
),
onTap: () { onTap: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
'postDetail', 'postDetail',

View File

@ -1,8 +1,30 @@
import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/check_in.dart';
import 'package:surface/widgets/dialog.dart';
class HomeScreenDashEntry {
final String name;
final Widget child;
final int rows, cols;
const HomeScreenDashEntry({
required this.name,
required this.child,
this.rows = 1,
this.cols = 1,
});
}
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@ -12,29 +34,363 @@ class HomeScreen extends StatefulWidget {
} }
class _HomeScreenState extends State<HomeScreen> { class _HomeScreenState extends State<HomeScreen> {
static const List<HomeScreenDashEntry> kCards = [
HomeScreenDashEntry(
name: 'dashEntryCheckIn',
child: _HomeDashCheckInWidget(),
),
];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AppScaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text("screenHome").tr(), title: Text("screenHome").tr(),
), ),
body: Column( body: LayoutBuilder(
children: [ builder: (context, constraints) {
MaterialBanner( return Align(
leading: const Icon(Symbols.construction), alignment: constraints.maxWidth > 640
content: Column( ? Alignment.center
crossAxisAlignment: CrossAxisAlignment.start, : Alignment.topCenter,
children: [ child: Container(
Text('nextVersionAlert').tr().bold(), constraints: const BoxConstraints(maxWidth: 640),
Text('nextVersionNotice').tr(), child: SingleChildScrollView(
], child: Column(
).padding(vertical: 16), mainAxisAlignment: constraints.maxWidth > 640
actions: [ ? MainAxisAlignment.center
const SizedBox(), : MainAxisAlignment.start,
], children: [
), if (constraints.maxWidth <= 640) const Gap(8),
], Card(
child: ListTile(
isThreeLine: true,
leading: const Icon(Symbols.construction),
title: Text('nextVersionAlert').tr(),
subtitle: Text('nextVersionNotice').tr(),
contentPadding: const EdgeInsets.symmetric(
vertical: 8, horizontal: 16),
),
).padding(horizontal: 8),
_HomeDashSpecialDayWidget().padding(top: 8, horizontal: 8),
StaggeredGrid.count(
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
children: kCards.map((card) {
return StaggeredGridTile.count(
crossAxisCellCount: card.cols,
mainAxisCellCount: card.rows,
child: card.child,
);
}).toList(),
).padding(horizontal: 8),
],
),
),
),
);
},
), ),
); );
} }
} }
class _HomeDashSpecialDayWidget extends StatelessWidget {
const _HomeDashSpecialDayWidget({super.key});
@override
Widget build(BuildContext context) {
final ua = context.watch<UserProvider>();
final today = DateTime.now();
final birthday = ua.user?.profile?.birthday?.toLocal();
final isBirthday = birthday != null &&
birthday.day == today.day &&
birthday.month == today.month;
return Column(
children: [
if (isBirthday)
Card(
child: ListTile(
leading: Text('🎂').fontSize(24),
title: Text('happyBirthday').tr(args: [ua.user?.nick ?? 'user']),
),
).padding(bottom: 8),
],
);
}
}
class _HomeDashCheckInWidget extends StatefulWidget {
const _HomeDashCheckInWidget({super.key});
@override
State<_HomeDashCheckInWidget> createState() => _HomeDashCheckInWidgetState();
}
class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
bool _isBusy = false;
SnCheckInRecord? _todayRecord;
static const int kSuggestionPositiveHintCount = 6;
static const int kSuggestionNegativeHintCount = 6;
Future<void> _pullCheckIn() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/check-in/today');
_todayRecord = SnCheckInRecord.fromJson(resp.data);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _doCheckIn() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.post('/cgi/id/check-in');
_todayRecord = SnCheckInRecord.fromJson(resp.data);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Widget _buildDetailChunk(int index, bool positive) {
final prefix =
positive ? 'dailyCheckPositiveHint' : 'dailyCheckNegativeHint';
final mod =
positive ? kSuggestionPositiveHintCount : kSuggestionNegativeHintCount;
final pos = math.max(1, _todayRecord!.resultModifiers[index] % mod);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
prefix.tr(args: ['$prefix$pos'.tr()]),
style: Theme.of(context)
.textTheme
.titleMedium!
.copyWith(fontWeight: FontWeight.bold),
).tr(),
Text(
'$prefix${pos}Description',
style: Theme.of(context).textTheme.bodyMedium,
).tr(),
],
);
}
void _showCheckInDetail() {
showDialog(
useRootNavigator: true,
context: context,
builder: (context) {
return AlertDialog(
title: Text('dailyCheckDetailTitle'.tr(args: [
DateFormat('MM/dd').format(DateTime.now().toUtc()),
])),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (_todayRecord?.resultTier != 0)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailChunk(0, true),
const Gap(8),
_buildDetailChunk(1, true),
],
)
else
Text(
'dailyCheckEverythingIsNegative',
style: Theme.of(context)
.textTheme
.titleMedium!
.copyWith(fontWeight: FontWeight.bold),
).tr(),
const Gap(8),
if (_todayRecord?.resultTier != 4)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildDetailChunk(2, false),
const Gap(8),
_buildDetailChunk(3, false),
],
)
else
Text(
'dailyCheckEverythingIsPositive',
style: Theme.of(context)
.textTheme
.titleMedium!
.copyWith(fontWeight: FontWeight.bold),
).tr(),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('dialogDismiss').tr(),
)
],
);
},
);
}
@override
void initState() {
super.initState();
final ua = context.read<UserProvider>();
Future.delayed(const Duration(milliseconds: 500), () async {
if (!ua.isAuthorized) return;
await _pullCheckIn();
});
}
@override
Widget build(BuildContext context) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: AnimatedSwitcher(
switchInCurve: Curves.fastOutSlowIn,
switchOutCurve: Curves.fastOutSlowIn,
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return ScaleTransition(
scale: animation,
child: child,
);
},
child: _todayRecord == null
? Column(
key: Key('daily-check-in-overview-none'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'dailyCheckIn',
style: Theme.of(context).textTheme.titleLarge,
).tr(),
Text(
'dailyCheckInNone',
style: Theme.of(context).textTheme.bodyLarge,
).tr(),
],
)
: Column(
key: Key('daily-check-in-overview-has'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_todayRecord!.symbol,
style: GoogleFonts.notoSerifHk(
textStyle: Theme.of(context).textTheme.titleLarge,
),
),
Text(
'+${_todayRecord!.resultExperience} EXP',
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
DateFormat('EEE\nMM/dd').format(DateTime.now().toUtc()),
).fontSize(13).opacity(0.75),
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: AnimatedSwitcher(
switchInCurve: Curves.fastOutSlowIn,
switchOutCurve: Curves.fastOutSlowIn,
duration: const Duration(milliseconds: 300),
child: _todayRecord == null
? IconButton(
key: UniqueKey(),
tooltip: 'dailyCheckAction'.tr(),
icon: const Icon(Symbols.local_fire_department),
onPressed: _isBusy ? null : _doCheckIn,
)
: IconButton(
key: UniqueKey(),
tooltip: 'dailyCheckDetail'.tr(),
icon: const Icon(Symbols.help),
onPressed: _showCheckInDetail,
),
),
),
],
),
],
).padding(all: 24),
);
}
}
class _HomeDashLinkWidget extends StatelessWidget {
final String title;
final String subtitle;
const _HomeDashLinkWidget({
super.key,
required this.title,
required this.subtitle,
});
@override
Widget build(BuildContext context) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleLarge,
),
Text(
subtitle,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
Align(
alignment: Alignment.centerRight,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: IconButton(
icon: const Icon(Symbols.arrow_right_alt),
onPressed: () {},
),
),
)
],
).padding(all: 24),
);
}
}

View File

@ -0,0 +1,258 @@
import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/notification.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class NotificationScreen extends StatefulWidget {
const NotificationScreen({super.key});
@override
State<NotificationScreen> createState() => _NotificationScreenState();
}
class _NotificationScreenState extends State<NotificationScreen> {
bool _isBusy = false;
bool _isFirstLoading = true;
bool _isSubmitting = false;
final List<SnNotification> _notifications = List.empty(growable: true);
int? _totalCount;
static const Map<String, IconData> kNotificationTopicIcons = {
'passport.security.alert': Symbols.gpp_maybe,
'interactive.subscription': Symbols.subscriptions,
'interactive.feedback': Symbols.add_reaction,
'messaging.callStart': Symbols.call_received,
};
Future<void> _fetchNotifications() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/notifications?take=10');
_totalCount = resp.data['count'];
_notifications.addAll(
resp.data['data']
?.map((e) => SnNotification.fromJson(e))
.cast<SnNotification>() ??
[],
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
_isFirstLoading = false;
setState(() => _isBusy = false);
}
}
void _markAllAsRead() async {
if (_notifications.isEmpty) return;
final confirm = await context.showConfirmDialog(
'notificationMarkAllRead'.tr(),
'notificationMarkAllReadDescription'.tr(),
);
if (!confirm) return;
if (!mounted) return;
setState(() => _isSubmitting = true);
List<int> markList = List.empty(growable: true);
for (final element in _notifications) {
if (element.id <= 0) continue;
if (element.readAt != null) continue;
markList.add(element.id);
}
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.put('/cgi/id/notifications/read', data: {
'messages': markList,
});
_notifications.clear();
_fetchNotifications();
if (!mounted) return;
context.showSnackbar(
'notificationMarkAllReadPrompt'.plural(markList.length),
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isSubmitting = false);
}
}
void _markOneAsRead(SnNotification notification) async {
if (notification.readAt != null) return;
setState(() => _isSubmitting = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.put('/cgi/id/notifications/read/${notification.id}');
_notifications.clear();
_fetchNotifications();
if (!mounted) return;
context.showSnackbar(
'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']),
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isSubmitting = false);
}
}
@override
void initState() {
super.initState();
_fetchNotifications();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('screenNotification').tr(),
actions: [
IconButton(
icon: const Icon(Symbols.checklist),
onPressed: _isSubmitting ? null : _markAllAsRead,
),
const Gap(8),
],
),
body: Column(
children: [
LoadingIndicator(isActive: _isFirstLoading),
Expanded(
child: RefreshIndicator(
onRefresh: () {
_notifications.clear();
return _fetchNotifications();
},
child: InfiniteList(
padding: EdgeInsets.only(
top: 16,
bottom: math.max(MediaQuery.of(context).padding.bottom, 16),
),
itemCount: _notifications.length,
onFetchData: () {
_fetchNotifications();
},
isLoading: _isBusy,
hasReachedMax: _totalCount != null &&
_notifications.length >= _totalCount!,
itemBuilder: (context, idx) {
final nty = _notifications[idx];
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(kNotificationTopicIcons[nty.topic]),
const Gap(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (nty.readAt == null)
StyledWidget(Badge(
label: Text('notificationUnread').tr(),
)).padding(bottom: 4),
Text(
nty.title,
style: Theme.of(context).textTheme.titleMedium,
),
if (nty.subtitle != null)
Text(
nty.subtitle!,
style: Theme.of(context).textTheme.titleSmall,
),
if (nty.subtitle != null) const Gap(4),
MarkdownTextContent(
content: nty.body,
isAutoWarp: true,
isSelectable: true,
),
if ([
'interactive.feedback',
'interactive.subscription'
].contains(nty.topic) &&
nty.metadata['related_post'] != null)
StyledWidget(Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(8)),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
),
child: PostItem(
data: SnPost.fromJson(
nty.metadata['related_post']!,
),
showComments: false,
showReactions: false,
showMenu: false,
),
)).padding(top: 8),
const Gap(8),
Row(
children: [
Text(
DateFormat('yy/MM/dd').format(nty.createdAt),
).fontSize(12),
const Gap(4),
Text(
'·',
style: TextStyle(fontSize: 12),
),
const Gap(4),
Text(
RelativeTime(context).format(nty.createdAt),
).fontSize(12),
],
).opacity(0.75),
],
),
),
const Gap(16),
IconButton(
icon: const Icon(Symbols.check),
padding: EdgeInsets.all(0),
visualDensity:
const VisualDensity(horizontal: -4, vertical: -4),
onPressed:
_isSubmitting ? null : () => _markOneAsRead(nty),
),
],
).padding(horizontal: 16);
},
separatorBuilder: (_, __) => const Divider(),
),
),
),
],
),
);
}
}

View File

@ -7,12 +7,11 @@ import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart'; import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_comment_list.dart'; import 'package:surface/widgets/post/post_comment_list.dart';
import 'package:surface/widgets/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/post/post_mini_editor.dart'; import 'package:surface/widgets/post/post_mini_editor.dart';
@ -39,19 +38,10 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
final sn = context.read<SnNetworkProvider>(); final pt = context.read<SnPostContentProvider>();
final attach = context.read<SnAttachmentProvider>(); final post = await pt.getPost(widget.slug);
final resp = await sn.client.get('/cgi/co/posts/${widget.slug}');
if (!mounted) return; if (!mounted) return;
final attachments = await attach.getMultiple( _data = post;
resp.data['body']['attachments']?.cast<String>() ?? [],
);
if (!mounted) return;
_data = SnPost.fromJson(resp.data).copyWith(
preload: SnPostPreload(
attachments: attachments,
),
);
} catch (err) { } catch (err) {
context.showErrorDialog(err); context.showErrorDialog(err);
} finally { } finally {
@ -72,34 +62,42 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ua = context.watch<UserProvider>();
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return AppScaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
leading: BackButton( leading: BackButton(
onPressed: () { onPressed: () {
if (GoRouter.of(context).canPop()) { if (GoRouter.of(context).canPop()) {
Navigator.pop(context); GoRouter.of(context).pop(context);
return;
} }
GoRouter.of(context).replaceNamed('explore'); GoRouter.of(context).replaceNamed('explore');
}, },
), ),
flexibleSpace: Column( title: _data?.body['title'] != null
mainAxisAlignment: MainAxisAlignment.center, ? RichText(
children: [ textAlign: TextAlign.center,
if (_data?.body['title'] != null) text: TextSpan(children: [
Text(_data?.body['title'] ?? 'postNoun'.tr()) TextSpan(
.textStyle(Theme.of(context).textTheme.titleLarge!) text: _data?.body['title'] ?? 'postNoun'.tr(),
.textColor(Colors.white), style: Theme.of(context)
if (_data?.body['title'] != null) .textTheme
Text('postDetail'.tr()) .titleLarge!
.textColor(Colors.white.withAlpha((255 * 0.9).round())) .copyWith(color: Colors.white),
else ),
Text('postDetail'.tr()) const TextSpan(text: '\n'),
.textStyle(Theme.of(context).textTheme.titleLarge!) TextSpan(
.textColor(Colors.white), text: 'postDetail'.tr(),
], style: Theme.of(context)
).padding(top: math.max(MediaQuery.of(context).padding.top, 8)), .textTheme
.bodySmall!
.copyWith(color: Colors.white),
),
]),
)
: Text('postDetail').tr(),
), ),
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
@ -110,27 +108,38 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
SliverToBoxAdapter( SliverToBoxAdapter(
child: PostItem( child: PostItem(
data: _data!, data: _data!,
maxWidth: 640,
showComments: false, showComments: false,
onChanged: (data) {
setState(() => _data = data);
},
onDeleted: () {
Navigator.pop(context);
},
), ),
), ),
const SliverToBoxAdapter(child: Divider(height: 1)), const SliverToBoxAdapter(child: Divider(height: 1)),
if (_data != null) if (_data != null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Row( child: Container(
crossAxisAlignment: CrossAxisAlignment.center, constraints: const BoxConstraints(maxWidth: 640),
children: [ child: Row(
const Icon(Symbols.comment, size: 24), crossAxisAlignment: CrossAxisAlignment.center,
const Gap(16), children: [
Text('postCommentsDetailed') const Icon(Symbols.comment, size: 24),
.plural(_data!.metric.replyCount) const Gap(16),
.textStyle(Theme.of(context).textTheme.titleLarge!), Text('postCommentsDetailed')
], .plural(_data!.metric.replyCount)
).padding(horizontal: 20, vertical: 12), .textStyle(Theme.of(context).textTheme.titleLarge!),
],
).padding(horizontal: 20, vertical: 12).center(),
),
), ),
if (_data != null) if (_data != null && ua.isAuthorized)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Container( child: Container(
height: 240, height: 240,
constraints: const BoxConstraints(maxWidth: 640),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.symmetric( border: Border.symmetric(
horizontal: BorderSide( horizontal: BorderSide(
@ -142,7 +151,6 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
child: PostMiniEditor( child: PostMiniEditor(
postReplyId: _data!.id, postReplyId: _data!.id,
onPost: () { onPost: () {
_childListKey.currentState!.refresh();
setState(() { setState(() {
_data = _data!.copyWith( _data = _data!.copyWith(
metric: _data!.metric.copyWith( metric: _data!.metric.copyWith(
@ -150,14 +158,16 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
), ),
); );
}); });
_childListKey.currentState!.refresh();
}, },
), ),
), ).center(),
), ),
if (_data != null) if (_data != null)
PostCommentSliverList( PostCommentSliverList(
key: _childListKey, key: _childListKey,
parentPostId: _data!.id, parentPostId: _data!.id,
maxWidth: 640,
), ),
SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)), SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)),
], ],

View File

@ -1,5 +1,3 @@
import 'dart:math' as math;
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@ -9,13 +7,13 @@ import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:pasteboard/pasteboard.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/post_write_controller.dart'; import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/loading_indicator.dart'; import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/post/post_media_pending_list.dart'; import 'package:surface/widgets/post/post_media_pending_list.dart';
import 'package:surface/widgets/post/post_meta_editor.dart'; import 'package:surface/widgets/post/post_meta_editor.dart';
@ -81,7 +79,18 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
_writeController.addAttachments( _writeController.addAttachments(
result.map((e) => PostWriteMedia.fromFile(e)), result.map((e) => PostWriteMedia.fromFile(e)),
); );
setState(() {}); }
void _pasteMedia() async {
final imageBytes = await Pasteboard.image;
if (imageBytes == null) return;
_writeController.addAttachments([
PostWriteMedia.fromBytes(
imageBytes,
'attachmentPastedImage'.tr(),
PostWriteMediaType.image,
),
]);
} }
@override @override
@ -111,30 +120,41 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
return ListenableBuilder( return ListenableBuilder(
listenable: _writeController, listenable: _writeController,
builder: (context, _) { builder: (context, _) {
return AppScaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
leading: BackButton( leading: BackButton(
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
}, },
), ),
flexibleSpace: Column( title: RichText(
children: [ textAlign: TextAlign.center,
Text(_writeController.title.isNotEmpty text: TextSpan(children: [
? _writeController.title TextSpan(
: 'untitled'.tr()) text: _writeController.title.isNotEmpty
.textStyle(Theme.of(context).textTheme.titleLarge!) ? _writeController.title
.textColor(Colors.white), : 'untitled'.tr(),
Text(PostWriteController.kTitleMap[widget.mode]!) style: Theme.of(context)
.tr() .textTheme
.textColor(Colors.white.withAlpha((255 * 0.9).round())), .titleLarge!
], .copyWith(color: Colors.white),
).padding(top: math.max(MediaQuery.of(context).padding.top, 8)), ),
const TextSpan(text: '\n'),
TextSpan(
text: PostWriteController.kTitleMap[widget.mode]!.tr(),
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(color: Colors.white),
),
]),
),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Symbols.tune), icon: const Icon(Symbols.tune),
onPressed: _writeController.isBusy ? null : _updateMeta, onPressed: _writeController.isBusy ? null : _updateMeta,
), ),
const Gap(8),
], ],
), ),
body: Column( body: Column(
@ -281,7 +301,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
]), ]),
children: <Widget>[ children: <Widget>[
PostItem( PostItem(
data: _writeController.repostingPost!) data: _writeController.repostingPost!,
)
], ],
), ),
), ),
@ -343,7 +364,25 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
), ),
if (_writeController.attachments.isNotEmpty) if (_writeController.attachments.isNotEmpty)
PostMediaPendingList( PostMediaPendingList(
controller: _writeController, attachments: _writeController.attachments,
isBusy: _writeController.isBusy,
onUpdate: (int idx, PostWriteMedia updatedMedia) async {
_writeController.setIsBusy(true);
try {
_writeController.setAttachmentAt(idx, updatedMedia);
} finally {
_writeController.setIsBusy(false);
}
},
onRemove: (int idx) async {
_writeController.setIsBusy(true);
try {
_writeController.removeAttachmentAt(idx);
} finally {
_writeController.setIsBusy(false);
}
},
onUpdateBusy: (state) => _writeController.setIsBusy(state),
).padding(bottom: 8), ).padding(bottom: 8),
Material( Material(
elevation: 2, elevation: 2,
@ -371,15 +410,39 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
scrollDirection: Axis.vertical, scrollDirection: Axis.vertical,
child: Row( child: Row(
children: [ children: [
IconButton( PopupMenuButton(
onPressed: _writeController.isBusy
? null
: _selectMedia,
icon: Icon( icon: Icon(
Symbols.add_photo_alternate, Symbols.add_photo_alternate,
color: color:
Theme.of(context).colorScheme.primary, Theme.of(context).colorScheme.primary,
), ),
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.photo_library),
const Gap(16),
Text('addAttachmentFromAlbum').tr(),
],
),
onTap: () {
_selectMedia();
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.content_paste),
const Gap(16),
Text('addAttachmentFromClipboard')
.tr(),
],
),
onTap: () {
_pasteMedia();
},
),
],
), ),
], ],
), ),

View File

@ -0,0 +1,188 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class PostSearchScreen extends StatefulWidget {
const PostSearchScreen({super.key});
@override
State<PostSearchScreen> createState() => _PostSearchScreenState();
}
class _PostSearchScreenState extends State<PostSearchScreen> {
bool _isBusy = false;
final List<SnPost> _posts = List.empty(growable: true);
int? _postCount;
String _searchTerm = '';
Duration? _lastTook;
Future<void> _fetchPosts() async {
if (_searchTerm.isEmpty) return;
if (_postCount != null && _posts.length >= _postCount!) return;
setState(() => _isBusy = true);
final stopwatch = Stopwatch()..start();
final pt = context.read<SnPostContentProvider>();
final result = await pt.searchPosts(
_searchTerm,
take: 10,
offset: _posts.length,
);
final List<SnPost> out = result.$1;
if (!mounted) return;
stopwatch.stop();
_lastTook = stopwatch.elapsed;
_postCount = result.$2;
_posts.addAll(out);
if (mounted) setState(() => _isBusy = false);
}
void _showAdvancedSearchTune() {
showModalBottomSheet(
context: context,
builder: (context) => Column(
children: [],
),
);
}
@override
Widget build(BuildContext context) {
const labelShadows = <Shadow>[
Shadow(
offset: Offset(1, 1),
blurRadius: 8.0,
color: Color.fromARGB(255, 0, 0, 0),
),
];
return Scaffold(
appBar: AppBar(
title: Text('screenPostSearch').tr(),
actions: [
IconButton(
icon: const Icon(Symbols.tune),
onPressed: _showAdvancedSearchTune,
),
const Gap(8),
],
),
body: Stack(
children: [
InfiniteList(
padding: const EdgeInsets.only(top: 100),
itemCount: _posts.length,
isLoading: _isBusy,
hasReachedMax: _postCount != null && _posts.length >= _postCount!,
onFetchData: () {
_fetchPosts();
},
itemBuilder: (context, idx) {
return GestureDetector(
child: PostItem(
data: _posts[idx],
maxWidth: 640,
onChanged: (data) {
setState(() => _posts[idx] = data);
},
onDeleted: () {
_posts.clear();
_fetchPosts();
},
),
onTap: () {
GoRouter.of(context).pushNamed(
'postDetail',
pathParameters: {'slug': _posts[idx].id.toString()},
extra: _posts[idx],
);
},
);
},
separatorBuilder: (context, index) => const Divider(height: 1),
),
Positioned(
top: 16,
left: 16,
right: 16,
child: Column(
children: [
SearchBar(
elevation: const WidgetStatePropertyAll(1),
leading: const Icon(Symbols.search),
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 24),
),
onChanged: (value) {
_searchTerm = value;
},
onSubmitted: (value) {
setState(() => _posts.clear());
_searchTerm = value;
_fetchPosts();
},
),
if (_lastTook != null)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Symbols.summarize,
color: Colors.white,
shadows: labelShadows,
size: 16,
),
const Gap(4),
Text(
'postSearchResult'.plural(_postCount ?? 0),
style: TextStyle(
color: Colors.white,
shadows: labelShadows,
fontSize: 13,
),
),
const Gap(8),
Icon(
Symbols.pace,
color: Colors.white,
shadows: labelShadows,
size: 16,
),
const Gap(4),
Text(
'postSearchTook'.tr(args: [
'${(_lastTook!.inMilliseconds / 1000).toStringAsFixed(3)}s',
]),
style: TextStyle(
color: Colors.white,
shadows: labelShadows,
fontSize: 13,
),
),
],
).padding(vertical: 8),
],
),
),
],
),
);
}
}

221
lib/screens/realm.dart Normal file
View File

@ -0,0 +1,221 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/realm.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/universal_image.dart';
class RealmScreen extends StatefulWidget {
const RealmScreen({super.key});
@override
State<RealmScreen> createState() => _RealmScreenState();
}
class _RealmScreenState extends State<RealmScreen> {
bool _isBusy = false;
bool _isCompactView = false;
List<SnRealm>? _realms;
Future<void> _fetchRealms() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/realms/me/available');
_realms = List<SnRealm>.from(
resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
);
} catch (err) {
if (mounted) context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _deleteRealm(SnRealm realm) async {
final confirm = await context.showConfirmDialog(
'realmDelete'.tr(args: ['#${realm.alias}']),
'realmDeleteDescription'.tr(),
);
if (!confirm) return;
if (!mounted) return;
final sn = context.read<SnNetworkProvider>();
setState(() => _isBusy = true);
try {
await sn.client.delete('/cgi/id/realms/${realm.alias}');
if (!mounted) return;
context.showSnackbar('realmDeleted'.tr(args: ['#${realm.alias}']));
_fetchRealms();
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchRealms();
}
@override
Widget build(BuildContext context) {
final sn = context.read<SnNetworkProvider>();
return Scaffold(
appBar: AppBar(
title: Text('screenRealm').tr(),
actions: [
IconButton(
icon: !_isCompactView
? const Icon(Symbols.view_list)
: const Icon(Symbols.view_module),
onPressed: () {
setState(() => _isCompactView = !_isCompactView);
},
),
const Gap(8),
],
),
floatingActionButton: FloatingActionButton(
child: const Icon(Symbols.group_add),
onPressed: () {
GoRouter.of(context).pushNamed('realmManage');
},
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
Expanded(
child: RefreshIndicator(
onRefresh: _fetchRealms,
child: ListView.builder(
itemCount: _realms?.length ?? 0,
itemBuilder: (context, idx) {
final realm = _realms![idx];
if (_isCompactView) {
return ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 16),
leading: AccountImage(
content: realm.avatar,
fallbackWidget: const Icon(Symbols.group, size: 20),
),
title: Text(realm.name),
subtitle: Text(
realm.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: PopupMenuButton(
itemBuilder: (BuildContext context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.edit),
const Gap(16),
Text('edit').tr(),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'realmManage',
queryParameters: {'editing': realm.alias},
).then((value) {
if (value != null) {
_fetchRealms();
}
});
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.delete),
const Gap(16),
Text('delete').tr(),
],
),
onTap: () {
_deleteRealm(realm);
},
),
],
),
);
}
return Card(
margin: const EdgeInsets.all(12),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 7,
child: Stack(
clipBehavior: Clip.none,
fit: StackFit.expand,
children: [
Container(
color: Theme.of(context)
.colorScheme
.surfaceContainer,
child: (realm.banner?.isEmpty ?? true)
? const SizedBox.shrink()
: AutoResizeUniversalImage(
sn.getAttachmentUrl(realm.banner!),
fit: BoxFit.cover,
),
),
Positioned(
bottom: -30,
left: 18,
child: AccountImage(
content: realm.avatar,
radius: 24,
fallbackWidget:
const Icon(Symbols.group, size: 24),
),
),
],
),
),
const Gap(20 + 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(realm.name).textStyle(
Theme.of(context).textTheme.titleMedium!),
Text(realm.description).textStyle(
Theme.of(context).textTheme.bodySmall!),
],
).padding(horizontal: 24, bottom: 14),
],
),
onTap: () {},
),
);
},
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,312 @@
import 'dart:io';
import 'dart:ui';
import 'package:croppy/croppy.dart';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:image_picker/image_picker.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' show basename;
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/realm.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/universal_image.dart';
import 'package:uuid/uuid.dart';
class RealmManageScreen extends StatefulWidget {
final String? editingRealmAlias;
const RealmManageScreen({super.key, this.editingRealmAlias});
@override
State<RealmManageScreen> createState() => _RealmManageScreenState();
}
class _RealmManageScreenState extends State<RealmManageScreen> {
bool _isBusy = false;
SnRealm? _editingRealm;
Future<void> _fetchRealm() async {
final sn = context.read<SnNetworkProvider>();
setState(() => _isBusy = true);
try {
final resp =
await sn.client.get('/cgi/id/realms/${widget.editingRealmAlias}');
final out = SnRealm.fromJson(resp.data);
_editingRealm = out;
_avatar = out.avatar;
_banner = out.banner;
_aliasController.text = out.alias;
_nameController.text = out.name;
_descriptionController.text = out.description;
} catch (err) {
// ignore: use_build_context_synchronously
if (context.mounted) context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
String? _avatar;
String? _banner;
final _aliasController = TextEditingController();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
final _imagePicker = ImagePicker();
Future<void> _updateImage(String place) async {
final image = await _imagePicker.pickImage(source: ImageSource.gallery);
if (image == null) return;
if (!mounted) return;
final ImageProvider imageProvider =
kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
final aspectRatios = place == 'banner'
? [CropAspectRatio(width: 16, height: 7)]
: [CropAspectRatio(width: 1, height: 1)];
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
)
: await showMaterialImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
);
if (result == null) return;
if (!mounted) return;
final attach = context.read<SnAttachmentProvider>();
setState(() => _isBusy = true);
final rawBytes =
(await result.uiImage.toByteData(format: ImageByteFormat.png))!
.buffer
.asUint8List();
try {
final attachment = await attach.directUploadOne(
rawBytes,
basename(image.path),
'avatar',
null,
mimetype: 'image/png',
);
switch (place) {
case 'avatar':
_avatar = attachment.rid;
break;
case 'banner':
_banner = attachment.rid;
break;
}
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _performAction() async {
final uuid = const Uuid();
final payload = {
'alias': _aliasController.text.isNotEmpty
? _aliasController.text.toLowerCase()
: uuid.v4().replaceAll('-', '').substring(0, 12),
'name': _nameController.text,
'description': _descriptionController.text,
'avatar': _avatar,
'banner': _banner,
};
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.request(
widget.editingRealmAlias != null
? '/cgi/id/realms/${widget.editingRealmAlias}'
: '/cgi/id/realms',
data: payload,
options: Options(
method: widget.editingRealmAlias != null ? 'PUT' : 'POST',
),
);
final out = SnRealm.fromJson(resp.data);
// ignore: use_build_context_synchronously
if (context.mounted) Navigator.pop(context, out);
} catch (err) {
// ignore: use_build_context_synchronously
if (context.mounted) context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
if (widget.editingRealmAlias != null) _fetchRealm();
}
@override
void dispose() {
_aliasController.dispose();
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final sn = context.read<SnNetworkProvider>();
return Scaffold(
appBar: AppBar(
title: widget.editingRealmAlias != null
? Text('screenRealmManage').tr()
: Text('screenRealmNew').tr(),
),
body: SingleChildScrollView(
child: Column(
children: [
LoadingIndicator(isActive: _isBusy),
if (_editingRealm != null)
MaterialBanner(
leading: const Icon(Symbols.edit),
leadingPadding: const EdgeInsets.only(left: 10, right: 20),
dividerColor: Colors.transparent,
content: Text(
'realmEditingNotice'.tr(args: ['#${_editingRealm!.alias}']),
),
actions: [
TextButton(
child: Text('cancel').tr(),
onPressed: () {
Navigator.pop(context);
},
),
],
),
const Gap(24),
Stack(
clipBehavior: Clip.none,
children: [
Material(
elevation: 0,
child: InkWell(
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Container(
color: Theme.of(context)
.colorScheme
.surfaceContainerHigh,
child: _banner != null
? AutoResizeUniversalImage(
sn.getAttachmentUrl(_banner!),
fit: BoxFit.cover,
)
: const SizedBox.shrink(),
),
),
),
onTap: () {
_updateImage('banner');
},
),
),
Positioned(
bottom: -28,
left: 16,
child: Material(
elevation: 2,
borderRadius: const BorderRadius.all(Radius.circular(40)),
child: InkWell(
child: AccountImage(
content: _avatar,
radius: 40,
fallbackWidget: const Icon(Symbols.group, size: 40),
),
onTap: () {
_updateImage('avatar');
},
),
),
),
],
).padding(horizontal: 24),
const Gap(8 + 28),
Column(
children: [
TextField(
controller: _aliasController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldRealmAlias'.tr(),
helperText: 'fieldRealmAliasHint'.tr(),
helperMaxLines: 2,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
TextField(
controller: _nameController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldRealmName'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
TextField(
controller: _descriptionController,
maxLines: null,
minLines: 3,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldRealmDescription'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ElevatedButton.icon(
onPressed: _isBusy ? null : _performAction,
icon: const Icon(Symbols.save),
label: Text('apply').tr(),
),
],
),
],
).padding(horizontal: 24 + 8),
],
),
),
);
}
}

View File

@ -15,7 +15,6 @@ import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/theme.dart'; import 'package:surface/providers/theme.dart';
import 'package:surface/theme.dart'; import 'package:surface/theme.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@ -58,7 +57,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
return AppScaffold( return Scaffold(
body: SingleChildScrollView( body: SingleChildScrollView(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -39,6 +39,9 @@ Future<ThemeData> createAppTheme(
opticalSize: 20, opticalSize: 20,
color: colorScheme.onSurface, color: colorScheme.onSurface,
), ),
appBarTheme: AppBarTheme(
centerTitle: true,
),
scaffoldBackgroundColor: Colors.transparent, scaffoldBackgroundColor: Colors.transparent,
); );
} }

View File

@ -1,29 +1,32 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hive_flutter/hive_flutter.dart';
part 'account.freezed.dart'; part 'account.freezed.dart';
part 'account.g.dart'; part 'account.g.dart';
@freezed @freezed
class SnAccount with _$SnAccount { class SnAccount with _$SnAccount {
const SnAccount._();
const factory SnAccount({ const factory SnAccount({
required int id, @HiveField(0) required int id,
required int? affiliatedId, required DateTime createdAt,
required int? affiliatedTo, required DateTime updatedAt,
required int? automatedBy, required DateTime? deletedAt,
required int? automatedId, required DateTime? confirmedAt,
required List<SnAccountContact>? contacts,
required String avatar, required String avatar,
required String banner, required String banner,
required DateTime? confirmedAt,
required List<SnAccountContact> contacts,
required DateTime createdAt,
required DateTime? deletedAt,
required String description, required String description,
required String name, required String name,
required String nick, required String nick,
required Map<String, dynamic> permNodes, required Map<String, dynamic> permNodes,
required SnAccountProfile? profile, required SnAccountProfile? profile,
required DateTime? suspendedAt, required DateTime? suspendedAt,
required DateTime updatedAt, required int? affiliatedId,
required int? affiliatedTo,
required int? automatedBy,
required int? automatedId,
}) = _SnAccount; }) = _SnAccount;
factory SnAccount.fromJson(Map<String, Object?> json) => factory SnAccount.fromJson(Map<String, Object?> json) =>

View File

@ -20,24 +20,25 @@ SnAccount _$SnAccountFromJson(Map<String, dynamic> json) {
/// @nodoc /// @nodoc
mixin _$SnAccount { mixin _$SnAccount {
@HiveField(0)
int get id => throw _privateConstructorUsedError; int get id => throw _privateConstructorUsedError;
int? get affiliatedId => throw _privateConstructorUsedError; DateTime get createdAt => throw _privateConstructorUsedError;
int? get affiliatedTo => throw _privateConstructorUsedError; DateTime get updatedAt => throw _privateConstructorUsedError;
int? get automatedBy => throw _privateConstructorUsedError; DateTime? get deletedAt => throw _privateConstructorUsedError;
int? get automatedId => throw _privateConstructorUsedError; DateTime? get confirmedAt => throw _privateConstructorUsedError;
List<SnAccountContact>? get contacts => throw _privateConstructorUsedError;
String get avatar => throw _privateConstructorUsedError; String get avatar => throw _privateConstructorUsedError;
String get banner => throw _privateConstructorUsedError; String get banner => throw _privateConstructorUsedError;
DateTime? get confirmedAt => throw _privateConstructorUsedError;
List<SnAccountContact> get contacts => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime? get deletedAt => throw _privateConstructorUsedError;
String get description => throw _privateConstructorUsedError; String get description => throw _privateConstructorUsedError;
String get name => throw _privateConstructorUsedError; String get name => throw _privateConstructorUsedError;
String get nick => throw _privateConstructorUsedError; String get nick => throw _privateConstructorUsedError;
Map<String, dynamic> get permNodes => throw _privateConstructorUsedError; Map<String, dynamic> get permNodes => throw _privateConstructorUsedError;
SnAccountProfile? get profile => throw _privateConstructorUsedError; SnAccountProfile? get profile => throw _privateConstructorUsedError;
DateTime? get suspendedAt => throw _privateConstructorUsedError; DateTime? get suspendedAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError; int? get affiliatedId => throw _privateConstructorUsedError;
int? get affiliatedTo => throw _privateConstructorUsedError;
int? get automatedBy => throw _privateConstructorUsedError;
int? get automatedId => throw _privateConstructorUsedError;
/// Serializes this SnAccount to a JSON map. /// Serializes this SnAccount to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError; Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@ -55,24 +56,24 @@ abstract class $SnAccountCopyWith<$Res> {
_$SnAccountCopyWithImpl<$Res, SnAccount>; _$SnAccountCopyWithImpl<$Res, SnAccount>;
@useResult @useResult
$Res call( $Res call(
{int id, {@HiveField(0) int id,
int? affiliatedId, DateTime createdAt,
int? affiliatedTo, DateTime updatedAt,
int? automatedBy, DateTime? deletedAt,
int? automatedId, DateTime? confirmedAt,
List<SnAccountContact>? contacts,
String avatar, String avatar,
String banner, String banner,
DateTime? confirmedAt,
List<SnAccountContact> contacts,
DateTime createdAt,
DateTime? deletedAt,
String description, String description,
String name, String name,
String nick, String nick,
Map<String, dynamic> permNodes, Map<String, dynamic> permNodes,
SnAccountProfile? profile, SnAccountProfile? profile,
DateTime? suspendedAt, DateTime? suspendedAt,
DateTime updatedAt}); int? affiliatedId,
int? affiliatedTo,
int? automatedBy,
int? automatedId});
$SnAccountProfileCopyWith<$Res>? get profile; $SnAccountProfileCopyWith<$Res>? get profile;
} }
@ -93,45 +94,49 @@ class _$SnAccountCopyWithImpl<$Res, $Val extends SnAccount>
@override @override
$Res call({ $Res call({
Object? id = null, Object? id = null,
Object? affiliatedId = freezed, Object? createdAt = null,
Object? affiliatedTo = freezed, Object? updatedAt = null,
Object? automatedBy = freezed, Object? deletedAt = freezed,
Object? automatedId = freezed, Object? confirmedAt = freezed,
Object? contacts = freezed,
Object? avatar = null, Object? avatar = null,
Object? banner = null, Object? banner = null,
Object? confirmedAt = freezed,
Object? contacts = null,
Object? createdAt = null,
Object? deletedAt = freezed,
Object? description = null, Object? description = null,
Object? name = null, Object? name = null,
Object? nick = null, Object? nick = null,
Object? permNodes = null, Object? permNodes = null,
Object? profile = freezed, Object? profile = freezed,
Object? suspendedAt = freezed, Object? suspendedAt = freezed,
Object? updatedAt = null, Object? affiliatedId = freezed,
Object? affiliatedTo = freezed,
Object? automatedBy = freezed,
Object? automatedId = freezed,
}) { }) {
return _then(_value.copyWith( return _then(_value.copyWith(
id: null == id id: null == id
? _value.id ? _value.id
: id // ignore: cast_nullable_to_non_nullable : id // ignore: cast_nullable_to_non_nullable
as int, as int,
affiliatedId: freezed == affiliatedId createdAt: null == createdAt
? _value.affiliatedId ? _value.createdAt
: affiliatedId // ignore: cast_nullable_to_non_nullable : createdAt // ignore: cast_nullable_to_non_nullable
as int?, as DateTime,
affiliatedTo: freezed == affiliatedTo updatedAt: null == updatedAt
? _value.affiliatedTo ? _value.updatedAt
: affiliatedTo // ignore: cast_nullable_to_non_nullable : updatedAt // ignore: cast_nullable_to_non_nullable
as int?, as DateTime,
automatedBy: freezed == automatedBy deletedAt: freezed == deletedAt
? _value.automatedBy ? _value.deletedAt
: automatedBy // ignore: cast_nullable_to_non_nullable : deletedAt // ignore: cast_nullable_to_non_nullable
as int?, as DateTime?,
automatedId: freezed == automatedId confirmedAt: freezed == confirmedAt
? _value.automatedId ? _value.confirmedAt
: automatedId // ignore: cast_nullable_to_non_nullable : confirmedAt // ignore: cast_nullable_to_non_nullable
as int?, as DateTime?,
contacts: freezed == contacts
? _value.contacts
: contacts // ignore: cast_nullable_to_non_nullable
as List<SnAccountContact>?,
avatar: null == avatar avatar: null == avatar
? _value.avatar ? _value.avatar
: avatar // ignore: cast_nullable_to_non_nullable : avatar // ignore: cast_nullable_to_non_nullable
@ -140,22 +145,6 @@ class _$SnAccountCopyWithImpl<$Res, $Val extends SnAccount>
? _value.banner ? _value.banner
: banner // ignore: cast_nullable_to_non_nullable : banner // ignore: cast_nullable_to_non_nullable
as String, as String,
confirmedAt: freezed == confirmedAt
? _value.confirmedAt
: confirmedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
contacts: null == contacts
? _value.contacts
: contacts // ignore: cast_nullable_to_non_nullable
as List<SnAccountContact>,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
description: null == description description: null == description
? _value.description ? _value.description
: description // ignore: cast_nullable_to_non_nullable : description // ignore: cast_nullable_to_non_nullable
@ -180,10 +169,22 @@ class _$SnAccountCopyWithImpl<$Res, $Val extends SnAccount>
? _value.suspendedAt ? _value.suspendedAt
: suspendedAt // ignore: cast_nullable_to_non_nullable : suspendedAt // ignore: cast_nullable_to_non_nullable
as DateTime?, as DateTime?,
updatedAt: null == updatedAt affiliatedId: freezed == affiliatedId
? _value.updatedAt ? _value.affiliatedId
: updatedAt // ignore: cast_nullable_to_non_nullable : affiliatedId // ignore: cast_nullable_to_non_nullable
as DateTime, as int?,
affiliatedTo: freezed == affiliatedTo
? _value.affiliatedTo
: affiliatedTo // ignore: cast_nullable_to_non_nullable
as int?,
automatedBy: freezed == automatedBy
? _value.automatedBy
: automatedBy // ignore: cast_nullable_to_non_nullable
as int?,
automatedId: freezed == automatedId
? _value.automatedId
: automatedId // ignore: cast_nullable_to_non_nullable
as int?,
) as $Val); ) as $Val);
} }
@ -211,24 +212,24 @@ abstract class _$$SnAccountImplCopyWith<$Res>
@override @override
@useResult @useResult
$Res call( $Res call(
{int id, {@HiveField(0) int id,
int? affiliatedId, DateTime createdAt,
int? affiliatedTo, DateTime updatedAt,
int? automatedBy, DateTime? deletedAt,
int? automatedId, DateTime? confirmedAt,
List<SnAccountContact>? contacts,
String avatar, String avatar,
String banner, String banner,
DateTime? confirmedAt,
List<SnAccountContact> contacts,
DateTime createdAt,
DateTime? deletedAt,
String description, String description,
String name, String name,
String nick, String nick,
Map<String, dynamic> permNodes, Map<String, dynamic> permNodes,
SnAccountProfile? profile, SnAccountProfile? profile,
DateTime? suspendedAt, DateTime? suspendedAt,
DateTime updatedAt}); int? affiliatedId,
int? affiliatedTo,
int? automatedBy,
int? automatedId});
@override @override
$SnAccountProfileCopyWith<$Res>? get profile; $SnAccountProfileCopyWith<$Res>? get profile;
@ -248,45 +249,49 @@ class __$$SnAccountImplCopyWithImpl<$Res>
@override @override
$Res call({ $Res call({
Object? id = null, Object? id = null,
Object? affiliatedId = freezed, Object? createdAt = null,
Object? affiliatedTo = freezed, Object? updatedAt = null,
Object? automatedBy = freezed, Object? deletedAt = freezed,
Object? automatedId = freezed, Object? confirmedAt = freezed,
Object? contacts = freezed,
Object? avatar = null, Object? avatar = null,
Object? banner = null, Object? banner = null,
Object? confirmedAt = freezed,
Object? contacts = null,
Object? createdAt = null,
Object? deletedAt = freezed,
Object? description = null, Object? description = null,
Object? name = null, Object? name = null,
Object? nick = null, Object? nick = null,
Object? permNodes = null, Object? permNodes = null,
Object? profile = freezed, Object? profile = freezed,
Object? suspendedAt = freezed, Object? suspendedAt = freezed,
Object? updatedAt = null, Object? affiliatedId = freezed,
Object? affiliatedTo = freezed,
Object? automatedBy = freezed,
Object? automatedId = freezed,
}) { }) {
return _then(_$SnAccountImpl( return _then(_$SnAccountImpl(
id: null == id id: null == id
? _value.id ? _value.id
: id // ignore: cast_nullable_to_non_nullable : id // ignore: cast_nullable_to_non_nullable
as int, as int,
affiliatedId: freezed == affiliatedId createdAt: null == createdAt
? _value.affiliatedId ? _value.createdAt
: affiliatedId // ignore: cast_nullable_to_non_nullable : createdAt // ignore: cast_nullable_to_non_nullable
as int?, as DateTime,
affiliatedTo: freezed == affiliatedTo updatedAt: null == updatedAt
? _value.affiliatedTo ? _value.updatedAt
: affiliatedTo // ignore: cast_nullable_to_non_nullable : updatedAt // ignore: cast_nullable_to_non_nullable
as int?, as DateTime,
automatedBy: freezed == automatedBy deletedAt: freezed == deletedAt
? _value.automatedBy ? _value.deletedAt
: automatedBy // ignore: cast_nullable_to_non_nullable : deletedAt // ignore: cast_nullable_to_non_nullable
as int?, as DateTime?,
automatedId: freezed == automatedId confirmedAt: freezed == confirmedAt
? _value.automatedId ? _value.confirmedAt
: automatedId // ignore: cast_nullable_to_non_nullable : confirmedAt // ignore: cast_nullable_to_non_nullable
as int?, as DateTime?,
contacts: freezed == contacts
? _value._contacts
: contacts // ignore: cast_nullable_to_non_nullable
as List<SnAccountContact>?,
avatar: null == avatar avatar: null == avatar
? _value.avatar ? _value.avatar
: avatar // ignore: cast_nullable_to_non_nullable : avatar // ignore: cast_nullable_to_non_nullable
@ -295,22 +300,6 @@ class __$$SnAccountImplCopyWithImpl<$Res>
? _value.banner ? _value.banner
: banner // ignore: cast_nullable_to_non_nullable : banner // ignore: cast_nullable_to_non_nullable
as String, as String,
confirmedAt: freezed == confirmedAt
? _value.confirmedAt
: confirmedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
contacts: null == contacts
? _value._contacts
: contacts // ignore: cast_nullable_to_non_nullable
as List<SnAccountContact>,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
description: null == description description: null == description
? _value.description ? _value.description
: description // ignore: cast_nullable_to_non_nullable : description // ignore: cast_nullable_to_non_nullable
@ -335,71 +324,81 @@ class __$$SnAccountImplCopyWithImpl<$Res>
? _value.suspendedAt ? _value.suspendedAt
: suspendedAt // ignore: cast_nullable_to_non_nullable : suspendedAt // ignore: cast_nullable_to_non_nullable
as DateTime?, as DateTime?,
updatedAt: null == updatedAt affiliatedId: freezed == affiliatedId
? _value.updatedAt ? _value.affiliatedId
: updatedAt // ignore: cast_nullable_to_non_nullable : affiliatedId // ignore: cast_nullable_to_non_nullable
as DateTime, as int?,
affiliatedTo: freezed == affiliatedTo
? _value.affiliatedTo
: affiliatedTo // ignore: cast_nullable_to_non_nullable
as int?,
automatedBy: freezed == automatedBy
? _value.automatedBy
: automatedBy // ignore: cast_nullable_to_non_nullable
as int?,
automatedId: freezed == automatedId
? _value.automatedId
: automatedId // ignore: cast_nullable_to_non_nullable
as int?,
)); ));
} }
} }
/// @nodoc /// @nodoc
@JsonSerializable() @JsonSerializable()
class _$SnAccountImpl implements _SnAccount { class _$SnAccountImpl extends _SnAccount {
const _$SnAccountImpl( const _$SnAccountImpl(
{required this.id, {@HiveField(0) required this.id,
required this.affiliatedId, required this.createdAt,
required this.affiliatedTo, required this.updatedAt,
required this.automatedBy, required this.deletedAt,
required this.automatedId, required this.confirmedAt,
required final List<SnAccountContact>? contacts,
required this.avatar, required this.avatar,
required this.banner, required this.banner,
required this.confirmedAt,
required final List<SnAccountContact> contacts,
required this.createdAt,
required this.deletedAt,
required this.description, required this.description,
required this.name, required this.name,
required this.nick, required this.nick,
required final Map<String, dynamic> permNodes, required final Map<String, dynamic> permNodes,
required this.profile, required this.profile,
required this.suspendedAt, required this.suspendedAt,
required this.updatedAt}) required this.affiliatedId,
required this.affiliatedTo,
required this.automatedBy,
required this.automatedId})
: _contacts = contacts, : _contacts = contacts,
_permNodes = permNodes; _permNodes = permNodes,
super._();
factory _$SnAccountImpl.fromJson(Map<String, dynamic> json) => factory _$SnAccountImpl.fromJson(Map<String, dynamic> json) =>
_$$SnAccountImplFromJson(json); _$$SnAccountImplFromJson(json);
@override @override
@HiveField(0)
final int id; final int id;
@override @override
final int? affiliatedId; final DateTime createdAt;
@override @override
final int? affiliatedTo; final DateTime updatedAt;
@override @override
final int? automatedBy; final DateTime? deletedAt;
@override @override
final int? automatedId; final DateTime? confirmedAt;
final List<SnAccountContact>? _contacts;
@override
List<SnAccountContact>? get contacts {
final value = _contacts;
if (value == null) return null;
if (_contacts is EqualUnmodifiableListView) return _contacts;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(value);
}
@override @override
final String avatar; final String avatar;
@override @override
final String banner; final String banner;
@override @override
final DateTime? confirmedAt;
final List<SnAccountContact> _contacts;
@override
List<SnAccountContact> get contacts {
if (_contacts is EqualUnmodifiableListView) return _contacts;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_contacts);
}
@override
final DateTime createdAt;
@override
final DateTime? deletedAt;
@override
final String description; final String description;
@override @override
final String name; final String name;
@ -418,11 +417,17 @@ class _$SnAccountImpl implements _SnAccount {
@override @override
final DateTime? suspendedAt; final DateTime? suspendedAt;
@override @override
final DateTime updatedAt; final int? affiliatedId;
@override
final int? affiliatedTo;
@override
final int? automatedBy;
@override
final int? automatedId;
@override @override
String toString() { String toString() {
return 'SnAccount(id: $id, affiliatedId: $affiliatedId, affiliatedTo: $affiliatedTo, automatedBy: $automatedBy, automatedId: $automatedId, avatar: $avatar, banner: $banner, confirmedAt: $confirmedAt, contacts: $contacts, createdAt: $createdAt, deletedAt: $deletedAt, description: $description, name: $name, nick: $nick, permNodes: $permNodes, profile: $profile, suspendedAt: $suspendedAt, updatedAt: $updatedAt)'; return 'SnAccount(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, confirmedAt: $confirmedAt, contacts: $contacts, avatar: $avatar, banner: $banner, description: $description, name: $name, nick: $nick, permNodes: $permNodes, profile: $profile, suspendedAt: $suspendedAt, affiliatedId: $affiliatedId, affiliatedTo: $affiliatedTo, automatedBy: $automatedBy, automatedId: $automatedId)';
} }
@override @override
@ -431,23 +436,17 @@ class _$SnAccountImpl implements _SnAccount {
(other.runtimeType == runtimeType && (other.runtimeType == runtimeType &&
other is _$SnAccountImpl && other is _$SnAccountImpl &&
(identical(other.id, id) || other.id == id) && (identical(other.id, id) || other.id == id) &&
(identical(other.affiliatedId, affiliatedId) || (identical(other.createdAt, createdAt) ||
other.affiliatedId == affiliatedId) && other.createdAt == createdAt) &&
(identical(other.affiliatedTo, affiliatedTo) || (identical(other.updatedAt, updatedAt) ||
other.affiliatedTo == affiliatedTo) && other.updatedAt == updatedAt) &&
(identical(other.automatedBy, automatedBy) || (identical(other.deletedAt, deletedAt) ||
other.automatedBy == automatedBy) && other.deletedAt == deletedAt) &&
(identical(other.automatedId, automatedId) ||
other.automatedId == automatedId) &&
(identical(other.avatar, avatar) || other.avatar == avatar) &&
(identical(other.banner, banner) || other.banner == banner) &&
(identical(other.confirmedAt, confirmedAt) || (identical(other.confirmedAt, confirmedAt) ||
other.confirmedAt == confirmedAt) && other.confirmedAt == confirmedAt) &&
const DeepCollectionEquality().equals(other._contacts, _contacts) && const DeepCollectionEquality().equals(other._contacts, _contacts) &&
(identical(other.createdAt, createdAt) || (identical(other.avatar, avatar) || other.avatar == avatar) &&
other.createdAt == createdAt) && (identical(other.banner, banner) || other.banner == banner) &&
(identical(other.deletedAt, deletedAt) ||
other.deletedAt == deletedAt) &&
(identical(other.description, description) || (identical(other.description, description) ||
other.description == description) && other.description == description) &&
(identical(other.name, name) || other.name == name) && (identical(other.name, name) || other.name == name) &&
@ -457,8 +456,14 @@ class _$SnAccountImpl implements _SnAccount {
(identical(other.profile, profile) || other.profile == profile) && (identical(other.profile, profile) || other.profile == profile) &&
(identical(other.suspendedAt, suspendedAt) || (identical(other.suspendedAt, suspendedAt) ||
other.suspendedAt == suspendedAt) && other.suspendedAt == suspendedAt) &&
(identical(other.updatedAt, updatedAt) || (identical(other.affiliatedId, affiliatedId) ||
other.updatedAt == updatedAt)); other.affiliatedId == affiliatedId) &&
(identical(other.affiliatedTo, affiliatedTo) ||
other.affiliatedTo == affiliatedTo) &&
(identical(other.automatedBy, automatedBy) ||
other.automatedBy == automatedBy) &&
(identical(other.automatedId, automatedId) ||
other.automatedId == automatedId));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@ -466,23 +471,23 @@ class _$SnAccountImpl implements _SnAccount {
int get hashCode => Object.hash( int get hashCode => Object.hash(
runtimeType, runtimeType,
id, id,
affiliatedId, createdAt,
affiliatedTo, updatedAt,
automatedBy, deletedAt,
automatedId,
avatar,
banner,
confirmedAt, confirmedAt,
const DeepCollectionEquality().hash(_contacts), const DeepCollectionEquality().hash(_contacts),
createdAt, avatar,
deletedAt, banner,
description, description,
name, name,
nick, nick,
const DeepCollectionEquality().hash(_permNodes), const DeepCollectionEquality().hash(_permNodes),
profile, profile,
suspendedAt, suspendedAt,
updatedAt); affiliatedId,
affiliatedTo,
automatedBy,
automatedId);
/// Create a copy of SnAccount /// Create a copy of SnAccount
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@ -500,53 +505,49 @@ class _$SnAccountImpl implements _SnAccount {
} }
} }
abstract class _SnAccount implements SnAccount { abstract class _SnAccount extends SnAccount {
const factory _SnAccount( const factory _SnAccount(
{required final int id, {@HiveField(0) required final int id,
required final int? affiliatedId, required final DateTime createdAt,
required final int? affiliatedTo, required final DateTime updatedAt,
required final int? automatedBy, required final DateTime? deletedAt,
required final int? automatedId, required final DateTime? confirmedAt,
required final List<SnAccountContact>? contacts,
required final String avatar, required final String avatar,
required final String banner, required final String banner,
required final DateTime? confirmedAt,
required final List<SnAccountContact> contacts,
required final DateTime createdAt,
required final DateTime? deletedAt,
required final String description, required final String description,
required final String name, required final String name,
required final String nick, required final String nick,
required final Map<String, dynamic> permNodes, required final Map<String, dynamic> permNodes,
required final SnAccountProfile? profile, required final SnAccountProfile? profile,
required final DateTime? suspendedAt, required final DateTime? suspendedAt,
required final DateTime updatedAt}) = _$SnAccountImpl; required final int? affiliatedId,
required final int? affiliatedTo,
required final int? automatedBy,
required final int? automatedId}) = _$SnAccountImpl;
const _SnAccount._() : super._();
factory _SnAccount.fromJson(Map<String, dynamic> json) = factory _SnAccount.fromJson(Map<String, dynamic> json) =
_$SnAccountImpl.fromJson; _$SnAccountImpl.fromJson;
@override @override
@HiveField(0)
int get id; int get id;
@override @override
int? get affiliatedId; DateTime get createdAt;
@override @override
int? get affiliatedTo; DateTime get updatedAt;
@override @override
int? get automatedBy; DateTime? get deletedAt;
@override @override
int? get automatedId; DateTime? get confirmedAt;
@override
List<SnAccountContact>? get contacts;
@override @override
String get avatar; String get avatar;
@override @override
String get banner; String get banner;
@override @override
DateTime? get confirmedAt;
@override
List<SnAccountContact> get contacts;
@override
DateTime get createdAt;
@override
DateTime? get deletedAt;
@override
String get description; String get description;
@override @override
String get name; String get name;
@ -559,7 +560,13 @@ abstract class _SnAccount implements SnAccount {
@override @override
DateTime? get suspendedAt; DateTime? get suspendedAt;
@override @override
DateTime get updatedAt; int? get affiliatedId;
@override
int? get affiliatedTo;
@override
int? get automatedBy;
@override
int? get automatedId;
/// Create a copy of SnAccount /// Create a copy of SnAccount
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.

View File

@ -9,22 +9,19 @@ part of 'account.dart';
_$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) => _$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
_$SnAccountImpl( _$SnAccountImpl(
id: (json['id'] as num).toInt(), id: (json['id'] as num).toInt(),
affiliatedId: (json['affiliated_id'] as num?)?.toInt(),
affiliatedTo: (json['affiliated_to'] as num?)?.toInt(),
automatedBy: (json['automated_by'] as num?)?.toInt(),
automatedId: (json['automated_id'] as num?)?.toInt(),
avatar: json['avatar'] as String,
banner: json['banner'] as String,
confirmedAt: json['confirmed_at'] == null
? null
: DateTime.parse(json['confirmed_at'] as String),
contacts: (json['contacts'] as List<dynamic>)
.map((e) => SnAccountContact.fromJson(e as Map<String, dynamic>))
.toList(),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null deletedAt: json['deleted_at'] == null
? null ? null
: DateTime.parse(json['deleted_at'] as String), : DateTime.parse(json['deleted_at'] as String),
confirmedAt: json['confirmed_at'] == null
? null
: DateTime.parse(json['confirmed_at'] as String),
contacts: (json['contacts'] as List<dynamic>?)
?.map((e) => SnAccountContact.fromJson(e as Map<String, dynamic>))
.toList(),
avatar: json['avatar'] as String,
banner: json['banner'] as String,
description: json['description'] as String, description: json['description'] as String,
name: json['name'] as String, name: json['name'] as String,
nick: json['nick'] as String, nick: json['nick'] as String,
@ -35,29 +32,32 @@ _$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
suspendedAt: json['suspended_at'] == null suspendedAt: json['suspended_at'] == null
? null ? null
: DateTime.parse(json['suspended_at'] as String), : DateTime.parse(json['suspended_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), affiliatedId: (json['affiliated_id'] as num?)?.toInt(),
affiliatedTo: (json['affiliated_to'] as num?)?.toInt(),
automatedBy: (json['automated_by'] as num?)?.toInt(),
automatedId: (json['automated_id'] as num?)?.toInt(),
); );
Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) => Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) =>
<String, dynamic>{ <String, dynamic>{
'id': instance.id, 'id': instance.id,
'affiliated_id': instance.affiliatedId, 'created_at': instance.createdAt.toIso8601String(),
'affiliated_to': instance.affiliatedTo, 'updated_at': instance.updatedAt.toIso8601String(),
'automated_by': instance.automatedBy, 'deleted_at': instance.deletedAt?.toIso8601String(),
'automated_id': instance.automatedId, 'confirmed_at': instance.confirmedAt?.toIso8601String(),
'contacts': instance.contacts?.map((e) => e.toJson()).toList(),
'avatar': instance.avatar, 'avatar': instance.avatar,
'banner': instance.banner, 'banner': instance.banner,
'confirmed_at': instance.confirmedAt?.toIso8601String(),
'contacts': instance.contacts.map((e) => e.toJson()).toList(),
'created_at': instance.createdAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'description': instance.description, 'description': instance.description,
'name': instance.name, 'name': instance.name,
'nick': instance.nick, 'nick': instance.nick,
'perm_nodes': instance.permNodes, 'perm_nodes': instance.permNodes,
'profile': instance.profile?.toJson(), 'profile': instance.profile?.toJson(),
'suspended_at': instance.suspendedAt?.toIso8601String(), 'suspended_at': instance.suspendedAt?.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(), 'affiliated_id': instance.affiliatedId,
'affiliated_to': instance.affiliatedTo,
'automated_by': instance.automatedBy,
'automated_id': instance.automatedId,
}; };
_$SnAccountContactImpl _$$SnAccountContactImplFromJson( _$SnAccountContactImpl _$$SnAccountContactImplFromJson(

143
lib/types/chat.dart Normal file
View File

@ -0,0 +1,143 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:surface/types/account.dart';
import 'package:surface/types/attachment.dart';
import 'package:surface/types/realm.dart';
part 'chat.freezed.dart';
part 'chat.g.dart';
@freezed
class SnChannel with _$SnChannel {
const SnChannel._();
@HiveType(typeId: 2)
const factory SnChannel({
@HiveField(0) required int id,
@HiveField(1) required DateTime createdAt,
@HiveField(2) required DateTime updatedAt,
@HiveField(3) required dynamic deletedAt,
@HiveField(4) required String alias,
@HiveField(5) required String name,
@HiveField(6) required String description,
@HiveField(7) required List<dynamic>? members,
List<SnChatMessage>? messages,
@HiveField(8) required int type,
@HiveField(9) required int accountId,
@HiveField(10) required SnRealm? realm,
@HiveField(11) required int? realmId,
@HiveField(12) required bool isPublic,
@HiveField(13) required bool isCommunity,
}) = _SnChannel;
factory SnChannel.fromJson(Map<String, dynamic> json) =>
_$SnChannelFromJson(json);
String get key => '${realm?.alias ?? 'global'}:$alias';
String get keyPath => '${realm?.alias ?? 'global'}/$alias';
}
@freezed
class SnChannelMember with _$SnChannelMember {
const SnChannelMember._();
@HiveType(typeId: 3)
const factory SnChannelMember({
@HiveField(0) required int id,
@HiveField(1) required DateTime createdAt,
@HiveField(2) required DateTime updatedAt,
@HiveField(3) required DateTime? deletedAt,
@HiveField(4) required int channelId,
@HiveField(5) required int accountId,
@HiveField(6) required String? nick,
@HiveField(7) required SnChannel? channel,
@HiveField(8) required SnAccount? account,
@Default(0) int notify,
@HiveField(9) required int powerLevel,
dynamic calls,
dynamic events,
}) = _SnChannelMember;
factory SnChannelMember.fromJson(Map<String, dynamic> json) =>
_$SnChannelMemberFromJson(json);
}
@freezed
class SnChatMessage with _$SnChatMessage {
const SnChatMessage._();
@HiveType(typeId: 4)
const factory SnChatMessage({
@HiveField(0) required int id,
@HiveField(1) required DateTime createdAt,
@HiveField(2) required DateTime updatedAt,
@HiveField(3) required DateTime? deletedAt,
@HiveField(4) required String uuid,
@HiveField(5) @Default({}) Map<String, dynamic> body,
@HiveField(6) required String type,
@HiveField(7) required SnChannel channel,
@HiveField(8) required SnChannelMember sender,
@HiveField(9) required int channelId,
@HiveField(10) required int senderId,
@HiveField(11) required int? quoteEventId,
@HiveField(12) required int? relatedEventId,
SnChatMessagePreload? preload,
}) = _SnChatMessage;
factory SnChatMessage.fromJson(Map<String, dynamic> json) =>
_$SnChatMessageFromJson(json);
}
@freezed
class SnChatMessagePreload with _$SnChatMessagePreload {
const SnChatMessagePreload._();
const factory SnChatMessagePreload({
List<SnAttachment?>? attachments,
SnChatMessage? quoteEvent,
}) = _SnChatMessagePreload;
factory SnChatMessagePreload.fromJson(Map<String, dynamic> json) =>
_$SnChatMessagePreloadFromJson(json);
}
@freezed
class SnChatCall with _$SnChatCall {
const factory SnChatCall({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required DateTime? endedAt,
required String externalId,
required int founderId,
required int channelId,
required SnChannelMember founder,
@Default([]) List<dynamic> participants,
}) = _SnChatCall;
factory SnChatCall.fromJson(Map<String, dynamic> json) =>
_$SnChatCallFromJson(json);
}
// Call stuff
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;
}

2163
lib/types/chat.freezed.dart Normal file

File diff suppressed because it is too large Load Diff

395
lib/types/chat.g.dart Normal file
View File

@ -0,0 +1,395 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class SnChannelImplAdapter extends TypeAdapter<_$SnChannelImpl> {
@override
final int typeId = 2;
@override
_$SnChannelImpl read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return _$SnChannelImpl(
id: fields[0] as int,
createdAt: fields[1] as DateTime,
updatedAt: fields[2] as DateTime,
deletedAt: fields[3] as dynamic,
alias: fields[4] as String,
name: fields[5] as String,
description: fields[6] as String,
members: (fields[7] as List?)?.cast<dynamic>(),
type: fields[8] as int,
accountId: fields[9] as int,
realm: fields[10] as SnRealm?,
realmId: fields[11] as int?,
isPublic: fields[12] as bool,
isCommunity: fields[13] as bool,
);
}
@override
void write(BinaryWriter writer, _$SnChannelImpl obj) {
writer
..writeByte(14)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.createdAt)
..writeByte(2)
..write(obj.updatedAt)
..writeByte(3)
..write(obj.deletedAt)
..writeByte(4)
..write(obj.alias)
..writeByte(5)
..write(obj.name)
..writeByte(6)
..write(obj.description)
..writeByte(8)
..write(obj.type)
..writeByte(9)
..write(obj.accountId)
..writeByte(10)
..write(obj.realm)
..writeByte(11)
..write(obj.realmId)
..writeByte(12)
..write(obj.isPublic)
..writeByte(13)
..write(obj.isCommunity)
..writeByte(7)
..write(obj.members);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SnChannelImplAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class SnChannelMemberImplAdapter extends TypeAdapter<_$SnChannelMemberImpl> {
@override
final int typeId = 3;
@override
_$SnChannelMemberImpl read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return _$SnChannelMemberImpl(
id: fields[0] as int,
createdAt: fields[1] as DateTime,
updatedAt: fields[2] as DateTime,
deletedAt: fields[3] as DateTime?,
channelId: fields[4] as int,
accountId: fields[5] as int,
nick: fields[6] as String?,
channel: fields[7] as SnChannel?,
account: fields[8] as SnAccount?,
powerLevel: fields[9] as int,
);
}
@override
void write(BinaryWriter writer, _$SnChannelMemberImpl obj) {
writer
..writeByte(10)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.createdAt)
..writeByte(2)
..write(obj.updatedAt)
..writeByte(3)
..write(obj.deletedAt)
..writeByte(4)
..write(obj.channelId)
..writeByte(5)
..write(obj.accountId)
..writeByte(6)
..write(obj.nick)
..writeByte(7)
..write(obj.channel)
..writeByte(8)
..write(obj.account)
..writeByte(9)
..write(obj.powerLevel);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SnChannelMemberImplAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class SnChatMessageImplAdapter extends TypeAdapter<_$SnChatMessageImpl> {
@override
final int typeId = 4;
@override
_$SnChatMessageImpl read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return _$SnChatMessageImpl(
id: fields[0] as int,
createdAt: fields[1] as DateTime,
updatedAt: fields[2] as DateTime,
deletedAt: fields[3] as DateTime?,
uuid: fields[4] as String,
body: (fields[5] as Map).cast<String, dynamic>(),
type: fields[6] as String,
channel: fields[7] as SnChannel,
sender: fields[8] as SnChannelMember,
channelId: fields[9] as int,
senderId: fields[10] as int,
quoteEventId: fields[11] as int?,
relatedEventId: fields[12] as int?,
);
}
@override
void write(BinaryWriter writer, _$SnChatMessageImpl obj) {
writer
..writeByte(13)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.createdAt)
..writeByte(2)
..write(obj.updatedAt)
..writeByte(3)
..write(obj.deletedAt)
..writeByte(4)
..write(obj.uuid)
..writeByte(6)
..write(obj.type)
..writeByte(7)
..write(obj.channel)
..writeByte(8)
..write(obj.sender)
..writeByte(9)
..write(obj.channelId)
..writeByte(10)
..write(obj.senderId)
..writeByte(11)
..write(obj.quoteEventId)
..writeByte(12)
..write(obj.relatedEventId)
..writeByte(5)
..write(obj.body);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SnChatMessageImplAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SnChannelImpl _$$SnChannelImplFromJson(Map<String, dynamic> json) =>
_$SnChannelImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'],
alias: json['alias'] as String,
name: json['name'] as String,
description: json['description'] as String,
members: json['members'] as List<dynamic>?,
messages: (json['messages'] as List<dynamic>?)
?.map((e) => SnChatMessage.fromJson(e as Map<String, dynamic>))
.toList(),
calls: json['calls'],
type: (json['type'] as num).toInt(),
accountId: (json['account_id'] as num).toInt(),
realm: json['realm'] == null
? null
: SnRealm.fromJson(json['realm'] as Map<String, dynamic>),
realmId: (json['realm_id'] as num?)?.toInt(),
isPublic: json['is_public'] as bool,
isCommunity: json['is_community'] as bool,
);
Map<String, dynamic> _$$SnChannelImplToJson(_$SnChannelImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt,
'alias': instance.alias,
'name': instance.name,
'description': instance.description,
'members': instance.members,
'messages': instance.messages?.map((e) => e.toJson()).toList(),
'calls': instance.calls,
'type': instance.type,
'account_id': instance.accountId,
'realm': instance.realm?.toJson(),
'realm_id': instance.realmId,
'is_public': instance.isPublic,
'is_community': instance.isCommunity,
};
_$SnChannelMemberImpl _$$SnChannelMemberImplFromJson(
Map<String, dynamic> json) =>
_$SnChannelMemberImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
channelId: (json['channel_id'] as num).toInt(),
accountId: (json['account_id'] as num).toInt(),
nick: json['nick'] as String?,
channel: json['channel'] == null
? null
: SnChannel.fromJson(json['channel'] as Map<String, dynamic>),
account: json['account'] == null
? null
: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
notify: (json['notify'] as num?)?.toInt() ?? 0,
powerLevel: (json['power_level'] as num).toInt(),
calls: json['calls'],
events: json['events'],
);
Map<String, dynamic> _$$SnChannelMemberImplToJson(
_$SnChannelMemberImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'channel_id': instance.channelId,
'account_id': instance.accountId,
'nick': instance.nick,
'channel': instance.channel?.toJson(),
'account': instance.account?.toJson(),
'notify': instance.notify,
'power_level': instance.powerLevel,
'calls': instance.calls,
'events': instance.events,
};
_$SnChatMessageImpl _$$SnChatMessageImplFromJson(Map<String, dynamic> json) =>
_$SnChatMessageImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
uuid: json['uuid'] as String,
body: json['body'] as Map<String, dynamic>? ?? const {},
type: json['type'] as String,
channel: SnChannel.fromJson(json['channel'] as Map<String, dynamic>),
sender: SnChannelMember.fromJson(json['sender'] as Map<String, dynamic>),
channelId: (json['channel_id'] as num).toInt(),
senderId: (json['sender_id'] as num).toInt(),
quoteEventId: (json['quote_event_id'] as num?)?.toInt(),
relatedEventId: (json['related_event_id'] as num?)?.toInt(),
preload: json['preload'] == null
? null
: SnChatMessagePreload.fromJson(
json['preload'] as Map<String, dynamic>),
);
Map<String, dynamic> _$$SnChatMessageImplToJson(_$SnChatMessageImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'uuid': instance.uuid,
'body': instance.body,
'type': instance.type,
'channel': instance.channel.toJson(),
'sender': instance.sender.toJson(),
'channel_id': instance.channelId,
'sender_id': instance.senderId,
'quote_event_id': instance.quoteEventId,
'related_event_id': instance.relatedEventId,
'preload': instance.preload?.toJson(),
};
_$SnChatMessagePreloadImpl _$$SnChatMessagePreloadImplFromJson(
Map<String, dynamic> json) =>
_$SnChatMessagePreloadImpl(
attachments: (json['attachments'] as List<dynamic>?)
?.map((e) => e == null
? null
: SnAttachment.fromJson(e as Map<String, dynamic>))
.toList(),
quoteEvent: json['quote_event'] == null
? null
: SnChatMessage.fromJson(json['quote_event'] as Map<String, dynamic>),
);
Map<String, dynamic> _$$SnChatMessagePreloadImplToJson(
_$SnChatMessagePreloadImpl instance) =>
<String, dynamic>{
'attachments': instance.attachments?.map((e) => e?.toJson()).toList(),
'quote_event': instance.quoteEvent?.toJson(),
};
_$SnChatCallImpl _$$SnChatCallImplFromJson(Map<String, dynamic> json) =>
_$SnChatCallImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
endedAt: json['ended_at'] == null
? null
: DateTime.parse(json['ended_at'] as String),
externalId: json['external_id'] as String,
founderId: (json['founder_id'] as num).toInt(),
channelId: (json['channel_id'] as num).toInt(),
founder:
SnChannelMember.fromJson(json['founder'] as Map<String, dynamic>),
participants: json['participants'] as List<dynamic>? ?? const [],
);
Map<String, dynamic> _$$SnChatCallImplToJson(_$SnChatCallImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'ended_at': instance.endedAt?.toIso8601String(),
'external_id': instance.externalId,
'founder_id': instance.founderId,
'channel_id': instance.channelId,
'founder': instance.founder.toJson(),
'participants': instance.participants,
};

31
lib/types/check_in.dart Normal file
View File

@ -0,0 +1,31 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'check_in.freezed.dart';
part 'check_in.g.dart';
@freezed
class SnCheckInRecord with _$SnCheckInRecord {
const SnCheckInRecord._();
const factory SnCheckInRecord({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required int resultTier,
required int resultExperience,
required List<int> resultModifiers,
required int accountId,
}) = _SnCheckInRecord;
factory SnCheckInRecord.fromJson(Map<String, dynamic> json) =>
_$SnCheckInRecordFromJson(json);
String get symbol => switch (resultTier) {
0 => '大凶',
1 => '',
2 => '中平',
3 => '',
_ => '大吉',
};
}

View File

@ -0,0 +1,334 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'check_in.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
SnCheckInRecord _$SnCheckInRecordFromJson(Map<String, dynamic> json) {
return _SnCheckInRecord.fromJson(json);
}
/// @nodoc
mixin _$SnCheckInRecord {
int get id => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
DateTime? get deletedAt => throw _privateConstructorUsedError;
int get resultTier => throw _privateConstructorUsedError;
int get resultExperience => throw _privateConstructorUsedError;
List<int> get resultModifiers => throw _privateConstructorUsedError;
int get accountId => throw _privateConstructorUsedError;
/// Serializes this SnCheckInRecord to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnCheckInRecord
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnCheckInRecordCopyWith<SnCheckInRecord> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnCheckInRecordCopyWith<$Res> {
factory $SnCheckInRecordCopyWith(
SnCheckInRecord value, $Res Function(SnCheckInRecord) then) =
_$SnCheckInRecordCopyWithImpl<$Res, SnCheckInRecord>;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
int resultTier,
int resultExperience,
List<int> resultModifiers,
int accountId});
}
/// @nodoc
class _$SnCheckInRecordCopyWithImpl<$Res, $Val extends SnCheckInRecord>
implements $SnCheckInRecordCopyWith<$Res> {
_$SnCheckInRecordCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnCheckInRecord
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? resultTier = null,
Object? resultExperience = null,
Object? resultModifiers = null,
Object? accountId = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
resultTier: null == resultTier
? _value.resultTier
: resultTier // ignore: cast_nullable_to_non_nullable
as int,
resultExperience: null == resultExperience
? _value.resultExperience
: resultExperience // ignore: cast_nullable_to_non_nullable
as int,
resultModifiers: null == resultModifiers
? _value.resultModifiers
: resultModifiers // ignore: cast_nullable_to_non_nullable
as List<int>,
accountId: null == accountId
? _value.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
) as $Val);
}
}
/// @nodoc
abstract class _$$SnCheckInRecordImplCopyWith<$Res>
implements $SnCheckInRecordCopyWith<$Res> {
factory _$$SnCheckInRecordImplCopyWith(_$SnCheckInRecordImpl value,
$Res Function(_$SnCheckInRecordImpl) then) =
__$$SnCheckInRecordImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
int resultTier,
int resultExperience,
List<int> resultModifiers,
int accountId});
}
/// @nodoc
class __$$SnCheckInRecordImplCopyWithImpl<$Res>
extends _$SnCheckInRecordCopyWithImpl<$Res, _$SnCheckInRecordImpl>
implements _$$SnCheckInRecordImplCopyWith<$Res> {
__$$SnCheckInRecordImplCopyWithImpl(
_$SnCheckInRecordImpl _value, $Res Function(_$SnCheckInRecordImpl) _then)
: super(_value, _then);
/// Create a copy of SnCheckInRecord
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? resultTier = null,
Object? resultExperience = null,
Object? resultModifiers = null,
Object? accountId = null,
}) {
return _then(_$SnCheckInRecordImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
resultTier: null == resultTier
? _value.resultTier
: resultTier // ignore: cast_nullable_to_non_nullable
as int,
resultExperience: null == resultExperience
? _value.resultExperience
: resultExperience // ignore: cast_nullable_to_non_nullable
as int,
resultModifiers: null == resultModifiers
? _value._resultModifiers
: resultModifiers // ignore: cast_nullable_to_non_nullable
as List<int>,
accountId: null == accountId
? _value.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnCheckInRecordImpl extends _SnCheckInRecord {
const _$SnCheckInRecordImpl(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.resultTier,
required this.resultExperience,
required final List<int> resultModifiers,
required this.accountId})
: _resultModifiers = resultModifiers,
super._();
factory _$SnCheckInRecordImpl.fromJson(Map<String, dynamic> json) =>
_$$SnCheckInRecordImplFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final DateTime? deletedAt;
@override
final int resultTier;
@override
final int resultExperience;
final List<int> _resultModifiers;
@override
List<int> get resultModifiers {
if (_resultModifiers is EqualUnmodifiableListView) return _resultModifiers;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_resultModifiers);
}
@override
final int accountId;
@override
String toString() {
return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultModifiers: $resultModifiers, accountId: $accountId)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnCheckInRecordImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
(identical(other.deletedAt, deletedAt) ||
other.deletedAt == deletedAt) &&
(identical(other.resultTier, resultTier) ||
other.resultTier == resultTier) &&
(identical(other.resultExperience, resultExperience) ||
other.resultExperience == resultExperience) &&
const DeepCollectionEquality()
.equals(other._resultModifiers, _resultModifiers) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
resultTier,
resultExperience,
const DeepCollectionEquality().hash(_resultModifiers),
accountId);
/// Create a copy of SnCheckInRecord
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnCheckInRecordImplCopyWith<_$SnCheckInRecordImpl> get copyWith =>
__$$SnCheckInRecordImplCopyWithImpl<_$SnCheckInRecordImpl>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnCheckInRecordImplToJson(
this,
);
}
}
abstract class _SnCheckInRecord extends SnCheckInRecord {
const factory _SnCheckInRecord(
{required final int id,
required final DateTime createdAt,
required final DateTime updatedAt,
required final DateTime? deletedAt,
required final int resultTier,
required final int resultExperience,
required final List<int> resultModifiers,
required final int accountId}) = _$SnCheckInRecordImpl;
const _SnCheckInRecord._() : super._();
factory _SnCheckInRecord.fromJson(Map<String, dynamic> json) =
_$SnCheckInRecordImpl.fromJson;
@override
int get id;
@override
DateTime get createdAt;
@override
DateTime get updatedAt;
@override
DateTime? get deletedAt;
@override
int get resultTier;
@override
int get resultExperience;
@override
List<int> get resultModifiers;
@override
int get accountId;
/// Create a copy of SnCheckInRecord
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnCheckInRecordImplCopyWith<_$SnCheckInRecordImpl> get copyWith =>
throw _privateConstructorUsedError;
}

37
lib/types/check_in.g.dart Normal file
View File

@ -0,0 +1,37 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'check_in.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SnCheckInRecordImpl _$$SnCheckInRecordImplFromJson(
Map<String, dynamic> json) =>
_$SnCheckInRecordImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
resultTier: (json['result_tier'] as num).toInt(),
resultExperience: (json['result_experience'] as num).toInt(),
resultModifiers: (json['result_modifiers'] as List<dynamic>)
.map((e) => (e as num).toInt())
.toList(),
accountId: (json['account_id'] as num).toInt(),
);
Map<String, dynamic> _$$SnCheckInRecordImplToJson(
_$SnCheckInRecordImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'result_tier': instance.resultTier,
'result_experience': instance.resultExperience,
'result_modifiers': instance.resultModifiers,
'account_id': instance.accountId,
};

View File

@ -0,0 +1,26 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'notification.freezed.dart';
part 'notification.g.dart';
@freezed
class SnNotification with _$SnNotification {
const factory SnNotification({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required String topic,
required String title,
required String? subtitle,
required String body,
@Default({}) Map<String, dynamic> metadata,
required int priority,
required int? senderId,
required int accountId,
required DateTime? readAt,
}) = _SnNotification;
factory SnNotification.fromJson(Map<String, dynamic> json) =>
_$SnNotificationFromJson(json);
}

View File

@ -0,0 +1,438 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'notification.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
SnNotification _$SnNotificationFromJson(Map<String, dynamic> json) {
return _SnNotification.fromJson(json);
}
/// @nodoc
mixin _$SnNotification {
int get id => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
DateTime? get deletedAt => throw _privateConstructorUsedError;
String get topic => throw _privateConstructorUsedError;
String get title => throw _privateConstructorUsedError;
String? get subtitle => throw _privateConstructorUsedError;
String get body => throw _privateConstructorUsedError;
Map<String, dynamic> get metadata => throw _privateConstructorUsedError;
int get priority => throw _privateConstructorUsedError;
int? get senderId => throw _privateConstructorUsedError;
int get accountId => throw _privateConstructorUsedError;
DateTime? get readAt => throw _privateConstructorUsedError;
/// Serializes this SnNotification to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnNotification
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnNotificationCopyWith<SnNotification> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnNotificationCopyWith<$Res> {
factory $SnNotificationCopyWith(
SnNotification value, $Res Function(SnNotification) then) =
_$SnNotificationCopyWithImpl<$Res, SnNotification>;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String topic,
String title,
String? subtitle,
String body,
Map<String, dynamic> metadata,
int priority,
int? senderId,
int accountId,
DateTime? readAt});
}
/// @nodoc
class _$SnNotificationCopyWithImpl<$Res, $Val extends SnNotification>
implements $SnNotificationCopyWith<$Res> {
_$SnNotificationCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnNotification
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? topic = null,
Object? title = null,
Object? subtitle = freezed,
Object? body = null,
Object? metadata = null,
Object? priority = null,
Object? senderId = freezed,
Object? accountId = null,
Object? readAt = freezed,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
topic: null == topic
? _value.topic
: topic // ignore: cast_nullable_to_non_nullable
as String,
title: null == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String,
subtitle: freezed == subtitle
? _value.subtitle
: subtitle // ignore: cast_nullable_to_non_nullable
as String?,
body: null == body
? _value.body
: body // ignore: cast_nullable_to_non_nullable
as String,
metadata: null == metadata
? _value.metadata
: metadata // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>,
priority: null == priority
? _value.priority
: priority // ignore: cast_nullable_to_non_nullable
as int,
senderId: freezed == senderId
? _value.senderId
: senderId // ignore: cast_nullable_to_non_nullable
as int?,
accountId: null == accountId
? _value.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
readAt: freezed == readAt
? _value.readAt
: readAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
) as $Val);
}
}
/// @nodoc
abstract class _$$SnNotificationImplCopyWith<$Res>
implements $SnNotificationCopyWith<$Res> {
factory _$$SnNotificationImplCopyWith(_$SnNotificationImpl value,
$Res Function(_$SnNotificationImpl) then) =
__$$SnNotificationImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String topic,
String title,
String? subtitle,
String body,
Map<String, dynamic> metadata,
int priority,
int? senderId,
int accountId,
DateTime? readAt});
}
/// @nodoc
class __$$SnNotificationImplCopyWithImpl<$Res>
extends _$SnNotificationCopyWithImpl<$Res, _$SnNotificationImpl>
implements _$$SnNotificationImplCopyWith<$Res> {
__$$SnNotificationImplCopyWithImpl(
_$SnNotificationImpl _value, $Res Function(_$SnNotificationImpl) _then)
: super(_value, _then);
/// Create a copy of SnNotification
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? topic = null,
Object? title = null,
Object? subtitle = freezed,
Object? body = null,
Object? metadata = null,
Object? priority = null,
Object? senderId = freezed,
Object? accountId = null,
Object? readAt = freezed,
}) {
return _then(_$SnNotificationImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
topic: null == topic
? _value.topic
: topic // ignore: cast_nullable_to_non_nullable
as String,
title: null == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String,
subtitle: freezed == subtitle
? _value.subtitle
: subtitle // ignore: cast_nullable_to_non_nullable
as String?,
body: null == body
? _value.body
: body // ignore: cast_nullable_to_non_nullable
as String,
metadata: null == metadata
? _value._metadata
: metadata // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>,
priority: null == priority
? _value.priority
: priority // ignore: cast_nullable_to_non_nullable
as int,
senderId: freezed == senderId
? _value.senderId
: senderId // ignore: cast_nullable_to_non_nullable
as int?,
accountId: null == accountId
? _value.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
readAt: freezed == readAt
? _value.readAt
: readAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnNotificationImpl implements _SnNotification {
const _$SnNotificationImpl(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.topic,
required this.title,
required this.subtitle,
required this.body,
final Map<String, dynamic> metadata = const {},
required this.priority,
required this.senderId,
required this.accountId,
required this.readAt})
: _metadata = metadata;
factory _$SnNotificationImpl.fromJson(Map<String, dynamic> json) =>
_$$SnNotificationImplFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final DateTime? deletedAt;
@override
final String topic;
@override
final String title;
@override
final String? subtitle;
@override
final String body;
final Map<String, dynamic> _metadata;
@override
@JsonKey()
Map<String, dynamic> get metadata {
if (_metadata is EqualUnmodifiableMapView) return _metadata;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_metadata);
}
@override
final int priority;
@override
final int? senderId;
@override
final int accountId;
@override
final DateTime? readAt;
@override
String toString() {
return 'SnNotification(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, topic: $topic, title: $title, subtitle: $subtitle, body: $body, metadata: $metadata, priority: $priority, senderId: $senderId, accountId: $accountId, readAt: $readAt)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnNotificationImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
(identical(other.deletedAt, deletedAt) ||
other.deletedAt == deletedAt) &&
(identical(other.topic, topic) || other.topic == topic) &&
(identical(other.title, title) || other.title == title) &&
(identical(other.subtitle, subtitle) ||
other.subtitle == subtitle) &&
(identical(other.body, body) || other.body == body) &&
const DeepCollectionEquality().equals(other._metadata, _metadata) &&
(identical(other.priority, priority) ||
other.priority == priority) &&
(identical(other.senderId, senderId) ||
other.senderId == senderId) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId) &&
(identical(other.readAt, readAt) || other.readAt == readAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
topic,
title,
subtitle,
body,
const DeepCollectionEquality().hash(_metadata),
priority,
senderId,
accountId,
readAt);
/// Create a copy of SnNotification
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnNotificationImplCopyWith<_$SnNotificationImpl> get copyWith =>
__$$SnNotificationImplCopyWithImpl<_$SnNotificationImpl>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnNotificationImplToJson(
this,
);
}
}
abstract class _SnNotification implements SnNotification {
const factory _SnNotification(
{required final int id,
required final DateTime createdAt,
required final DateTime updatedAt,
required final DateTime? deletedAt,
required final String topic,
required final String title,
required final String? subtitle,
required final String body,
final Map<String, dynamic> metadata,
required final int priority,
required final int? senderId,
required final int accountId,
required final DateTime? readAt}) = _$SnNotificationImpl;
factory _SnNotification.fromJson(Map<String, dynamic> json) =
_$SnNotificationImpl.fromJson;
@override
int get id;
@override
DateTime get createdAt;
@override
DateTime get updatedAt;
@override
DateTime? get deletedAt;
@override
String get topic;
@override
String get title;
@override
String? get subtitle;
@override
String get body;
@override
Map<String, dynamic> get metadata;
@override
int get priority;
@override
int? get senderId;
@override
int get accountId;
@override
DateTime? get readAt;
/// Create a copy of SnNotification
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnNotificationImplCopyWith<_$SnNotificationImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,46 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'notification.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SnNotificationImpl _$$SnNotificationImplFromJson(Map<String, dynamic> json) =>
_$SnNotificationImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
topic: json['topic'] as String,
title: json['title'] as String,
subtitle: json['subtitle'] as String?,
body: json['body'] as String,
metadata: json['metadata'] as Map<String, dynamic>? ?? const {},
priority: (json['priority'] as num).toInt(),
senderId: (json['sender_id'] as num?)?.toInt(),
accountId: (json['account_id'] as num).toInt(),
readAt: json['read_at'] == null
? null
: DateTime.parse(json['read_at'] as String),
);
Map<String, dynamic> _$$SnNotificationImplToJson(
_$SnNotificationImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'topic': instance.topic,
'title': instance.title,
'subtitle': instance.subtitle,
'body': instance.body,
'metadata': instance.metadata,
'priority': instance.priority,
'sender_id': instance.senderId,
'account_id': instance.accountId,
'read_at': instance.readAt?.toIso8601String(),
};

View File

@ -18,8 +18,8 @@ class SnPost with _$SnPost {
required String language, required String language,
required String? alias, required String? alias,
required String? aliasPrefix, required String? aliasPrefix,
required List<dynamic> tags, @Default([]) List<SnPostTag> tags,
required List<dynamic> categories, @Default([]) List<dynamic> categories,
required List<SnPost>? replies, required List<SnPost>? replies,
required int? replyId, required int? replyId,
required int? repostId, required int? repostId,
@ -50,10 +50,28 @@ class SnPost with _$SnPost {
}; };
} }
@freezed
class SnPostTag with _$SnPostTag {
const factory SnPostTag({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required dynamic deletedAt,
required String alias,
required String name,
required String description,
required dynamic posts,
}) = _SnPostTag;
factory SnPostTag.fromJson(Map<String, Object?> json) =>
_$SnPostTagFromJson(json);
}
@freezed @freezed
class SnPostPreload with _$SnPostPreload { class SnPostPreload with _$SnPostPreload {
const factory SnPostPreload({ const factory SnPostPreload({
required List<SnAttachment>? attachments, required SnAttachment? thumbnail,
required List<SnAttachment?>? attachments,
}) = _SnPostPreload; }) = _SnPostPreload;
factory SnPostPreload.fromJson(Map<String, Object?> json) => factory SnPostPreload.fromJson(Map<String, Object?> json) =>

View File

@ -29,7 +29,7 @@ mixin _$SnPost {
String get language => throw _privateConstructorUsedError; String get language => throw _privateConstructorUsedError;
String? get alias => throw _privateConstructorUsedError; String? get alias => throw _privateConstructorUsedError;
String? get aliasPrefix => throw _privateConstructorUsedError; String? get aliasPrefix => throw _privateConstructorUsedError;
List<dynamic> get tags => throw _privateConstructorUsedError; List<SnPostTag> get tags => throw _privateConstructorUsedError;
List<dynamic> get categories => throw _privateConstructorUsedError; List<dynamic> get categories => throw _privateConstructorUsedError;
List<SnPost>? get replies => throw _privateConstructorUsedError; List<SnPost>? get replies => throw _privateConstructorUsedError;
int? get replyId => throw _privateConstructorUsedError; int? get replyId => throw _privateConstructorUsedError;
@ -76,7 +76,7 @@ abstract class $SnPostCopyWith<$Res> {
String language, String language,
String? alias, String? alias,
String? aliasPrefix, String? aliasPrefix,
List<dynamic> tags, List<SnPostTag> tags,
List<dynamic> categories, List<dynamic> categories,
List<SnPost>? replies, List<SnPost>? replies,
int? replyId, int? replyId,
@ -193,7 +193,7 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost>
tags: null == tags tags: null == tags
? _value.tags ? _value.tags
: tags // ignore: cast_nullable_to_non_nullable : tags // ignore: cast_nullable_to_non_nullable
as List<dynamic>, as List<SnPostTag>,
categories: null == categories categories: null == categories
? _value.categories ? _value.categories
: categories // ignore: cast_nullable_to_non_nullable : categories // ignore: cast_nullable_to_non_nullable
@ -361,7 +361,7 @@ abstract class _$$SnPostImplCopyWith<$Res> implements $SnPostCopyWith<$Res> {
String language, String language,
String? alias, String? alias,
String? aliasPrefix, String? aliasPrefix,
List<dynamic> tags, List<SnPostTag> tags,
List<dynamic> categories, List<dynamic> categories,
List<SnPost>? replies, List<SnPost>? replies,
int? replyId, int? replyId,
@ -481,7 +481,7 @@ class __$$SnPostImplCopyWithImpl<$Res>
tags: null == tags tags: null == tags
? _value._tags ? _value._tags
: tags // ignore: cast_nullable_to_non_nullable : tags // ignore: cast_nullable_to_non_nullable
as List<dynamic>, as List<SnPostTag>,
categories: null == categories categories: null == categories
? _value._categories ? _value._categories
: categories // ignore: cast_nullable_to_non_nullable : categories // ignore: cast_nullable_to_non_nullable
@ -583,8 +583,8 @@ class _$SnPostImpl extends _SnPost {
required this.language, required this.language,
required this.alias, required this.alias,
required this.aliasPrefix, required this.aliasPrefix,
required final List<dynamic> tags, final List<SnPostTag> tags = const [],
required final List<dynamic> categories, final List<dynamic> categories = const [],
required final List<SnPost>? replies, required final List<SnPost>? replies,
required this.replyId, required this.replyId,
required this.repostId, required this.repostId,
@ -640,9 +640,10 @@ class _$SnPostImpl extends _SnPost {
final String? alias; final String? alias;
@override @override
final String? aliasPrefix; final String? aliasPrefix;
final List<dynamic> _tags; final List<SnPostTag> _tags;
@override @override
List<dynamic> get tags { @JsonKey()
List<SnPostTag> get tags {
if (_tags is EqualUnmodifiableListView) return _tags; if (_tags is EqualUnmodifiableListView) return _tags;
// ignore: implicit_dynamic_type // ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_tags); return EqualUnmodifiableListView(_tags);
@ -650,6 +651,7 @@ class _$SnPostImpl extends _SnPost {
final List<dynamic> _categories; final List<dynamic> _categories;
@override @override
@JsonKey()
List<dynamic> get categories { List<dynamic> get categories {
if (_categories is EqualUnmodifiableListView) return _categories; if (_categories is EqualUnmodifiableListView) return _categories;
// ignore: implicit_dynamic_type // ignore: implicit_dynamic_type
@ -850,8 +852,8 @@ abstract class _SnPost extends SnPost {
required final String language, required final String language,
required final String? alias, required final String? alias,
required final String? aliasPrefix, required final String? aliasPrefix,
required final List<dynamic> tags, final List<SnPostTag> tags,
required final List<dynamic> categories, final List<dynamic> categories,
required final List<SnPost>? replies, required final List<SnPost>? replies,
required final int? replyId, required final int? replyId,
required final int? repostId, required final int? repostId,
@ -895,7 +897,7 @@ abstract class _SnPost extends SnPost {
@override @override
String? get aliasPrefix; String? get aliasPrefix;
@override @override
List<dynamic> get tags; List<SnPostTag> get tags;
@override @override
List<dynamic> get categories; List<dynamic> get categories;
@override @override
@ -947,13 +949,318 @@ abstract class _SnPost extends SnPost {
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
} }
SnPostTag _$SnPostTagFromJson(Map<String, dynamic> json) {
return _SnPostTag.fromJson(json);
}
/// @nodoc
mixin _$SnPostTag {
int get id => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
dynamic get deletedAt => throw _privateConstructorUsedError;
String get alias => throw _privateConstructorUsedError;
String get name => throw _privateConstructorUsedError;
String get description => throw _privateConstructorUsedError;
dynamic get posts => throw _privateConstructorUsedError;
/// Serializes this SnPostTag to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnPostTag
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnPostTagCopyWith<SnPostTag> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnPostTagCopyWith<$Res> {
factory $SnPostTagCopyWith(SnPostTag value, $Res Function(SnPostTag) then) =
_$SnPostTagCopyWithImpl<$Res, SnPostTag>;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
dynamic deletedAt,
String alias,
String name,
String description,
dynamic posts});
}
/// @nodoc
class _$SnPostTagCopyWithImpl<$Res, $Val extends SnPostTag>
implements $SnPostTagCopyWith<$Res> {
_$SnPostTagCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnPostTag
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? alias = null,
Object? name = null,
Object? description = null,
Object? posts = freezed,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as dynamic,
alias: null == alias
? _value.alias
: alias // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
description: null == description
? _value.description
: description // ignore: cast_nullable_to_non_nullable
as String,
posts: freezed == posts
? _value.posts
: posts // ignore: cast_nullable_to_non_nullable
as dynamic,
) as $Val);
}
}
/// @nodoc
abstract class _$$SnPostTagImplCopyWith<$Res>
implements $SnPostTagCopyWith<$Res> {
factory _$$SnPostTagImplCopyWith(
_$SnPostTagImpl value, $Res Function(_$SnPostTagImpl) then) =
__$$SnPostTagImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
dynamic deletedAt,
String alias,
String name,
String description,
dynamic posts});
}
/// @nodoc
class __$$SnPostTagImplCopyWithImpl<$Res>
extends _$SnPostTagCopyWithImpl<$Res, _$SnPostTagImpl>
implements _$$SnPostTagImplCopyWith<$Res> {
__$$SnPostTagImplCopyWithImpl(
_$SnPostTagImpl _value, $Res Function(_$SnPostTagImpl) _then)
: super(_value, _then);
/// Create a copy of SnPostTag
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? alias = null,
Object? name = null,
Object? description = null,
Object? posts = freezed,
}) {
return _then(_$SnPostTagImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as dynamic,
alias: null == alias
? _value.alias
: alias // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
description: null == description
? _value.description
: description // ignore: cast_nullable_to_non_nullable
as String,
posts: freezed == posts
? _value.posts
: posts // ignore: cast_nullable_to_non_nullable
as dynamic,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnPostTagImpl implements _SnPostTag {
const _$SnPostTagImpl(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.alias,
required this.name,
required this.description,
required this.posts});
factory _$SnPostTagImpl.fromJson(Map<String, dynamic> json) =>
_$$SnPostTagImplFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final dynamic deletedAt;
@override
final String alias;
@override
final String name;
@override
final String description;
@override
final dynamic posts;
@override
String toString() {
return 'SnPostTag(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, alias: $alias, name: $name, description: $description, posts: $posts)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnPostTagImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
const DeepCollectionEquality().equals(other.deletedAt, deletedAt) &&
(identical(other.alias, alias) || other.alias == alias) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.description, description) ||
other.description == description) &&
const DeepCollectionEquality().equals(other.posts, posts));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
const DeepCollectionEquality().hash(deletedAt),
alias,
name,
description,
const DeepCollectionEquality().hash(posts));
/// Create a copy of SnPostTag
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnPostTagImplCopyWith<_$SnPostTagImpl> get copyWith =>
__$$SnPostTagImplCopyWithImpl<_$SnPostTagImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnPostTagImplToJson(
this,
);
}
}
abstract class _SnPostTag implements SnPostTag {
const factory _SnPostTag(
{required final int id,
required final DateTime createdAt,
required final DateTime updatedAt,
required final dynamic deletedAt,
required final String alias,
required final String name,
required final String description,
required final dynamic posts}) = _$SnPostTagImpl;
factory _SnPostTag.fromJson(Map<String, dynamic> json) =
_$SnPostTagImpl.fromJson;
@override
int get id;
@override
DateTime get createdAt;
@override
DateTime get updatedAt;
@override
dynamic get deletedAt;
@override
String get alias;
@override
String get name;
@override
String get description;
@override
dynamic get posts;
/// Create a copy of SnPostTag
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnPostTagImplCopyWith<_$SnPostTagImpl> get copyWith =>
throw _privateConstructorUsedError;
}
SnPostPreload _$SnPostPreloadFromJson(Map<String, dynamic> json) { SnPostPreload _$SnPostPreloadFromJson(Map<String, dynamic> json) {
return _SnPostPreload.fromJson(json); return _SnPostPreload.fromJson(json);
} }
/// @nodoc /// @nodoc
mixin _$SnPostPreload { mixin _$SnPostPreload {
List<SnAttachment>? get attachments => throw _privateConstructorUsedError; SnAttachment? get thumbnail => throw _privateConstructorUsedError;
List<SnAttachment?>? get attachments => throw _privateConstructorUsedError;
/// Serializes this SnPostPreload to a JSON map. /// Serializes this SnPostPreload to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError; Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@ -971,7 +1278,9 @@ abstract class $SnPostPreloadCopyWith<$Res> {
SnPostPreload value, $Res Function(SnPostPreload) then) = SnPostPreload value, $Res Function(SnPostPreload) then) =
_$SnPostPreloadCopyWithImpl<$Res, SnPostPreload>; _$SnPostPreloadCopyWithImpl<$Res, SnPostPreload>;
@useResult @useResult
$Res call({List<SnAttachment>? attachments}); $Res call({SnAttachment? thumbnail, List<SnAttachment?>? attachments});
$SnAttachmentCopyWith<$Res>? get thumbnail;
} }
/// @nodoc /// @nodoc
@ -989,15 +1298,34 @@ class _$SnPostPreloadCopyWithImpl<$Res, $Val extends SnPostPreload>
@pragma('vm:prefer-inline') @pragma('vm:prefer-inline')
@override @override
$Res call({ $Res call({
Object? thumbnail = freezed,
Object? attachments = freezed, Object? attachments = freezed,
}) { }) {
return _then(_value.copyWith( return _then(_value.copyWith(
thumbnail: freezed == thumbnail
? _value.thumbnail
: thumbnail // ignore: cast_nullable_to_non_nullable
as SnAttachment?,
attachments: freezed == attachments attachments: freezed == attachments
? _value.attachments ? _value.attachments
: attachments // ignore: cast_nullable_to_non_nullable : attachments // ignore: cast_nullable_to_non_nullable
as List<SnAttachment>?, as List<SnAttachment?>?,
) as $Val); ) as $Val);
} }
/// Create a copy of SnPostPreload
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAttachmentCopyWith<$Res>? get thumbnail {
if (_value.thumbnail == null) {
return null;
}
return $SnAttachmentCopyWith<$Res>(_value.thumbnail!, (value) {
return _then(_value.copyWith(thumbnail: value) as $Val);
});
}
} }
/// @nodoc /// @nodoc
@ -1008,7 +1336,10 @@ abstract class _$$SnPostPreloadImplCopyWith<$Res>
__$$SnPostPreloadImplCopyWithImpl<$Res>; __$$SnPostPreloadImplCopyWithImpl<$Res>;
@override @override
@useResult @useResult
$Res call({List<SnAttachment>? attachments}); $Res call({SnAttachment? thumbnail, List<SnAttachment?>? attachments});
@override
$SnAttachmentCopyWith<$Res>? get thumbnail;
} }
/// @nodoc /// @nodoc
@ -1024,13 +1355,18 @@ class __$$SnPostPreloadImplCopyWithImpl<$Res>
@pragma('vm:prefer-inline') @pragma('vm:prefer-inline')
@override @override
$Res call({ $Res call({
Object? thumbnail = freezed,
Object? attachments = freezed, Object? attachments = freezed,
}) { }) {
return _then(_$SnPostPreloadImpl( return _then(_$SnPostPreloadImpl(
thumbnail: freezed == thumbnail
? _value.thumbnail
: thumbnail // ignore: cast_nullable_to_non_nullable
as SnAttachment?,
attachments: freezed == attachments attachments: freezed == attachments
? _value._attachments ? _value._attachments
: attachments // ignore: cast_nullable_to_non_nullable : attachments // ignore: cast_nullable_to_non_nullable
as List<SnAttachment>?, as List<SnAttachment?>?,
)); ));
} }
} }
@ -1038,15 +1374,19 @@ class __$$SnPostPreloadImplCopyWithImpl<$Res>
/// @nodoc /// @nodoc
@JsonSerializable() @JsonSerializable()
class _$SnPostPreloadImpl implements _SnPostPreload { class _$SnPostPreloadImpl implements _SnPostPreload {
const _$SnPostPreloadImpl({required final List<SnAttachment>? attachments}) const _$SnPostPreloadImpl(
{required this.thumbnail,
required final List<SnAttachment?>? attachments})
: _attachments = attachments; : _attachments = attachments;
factory _$SnPostPreloadImpl.fromJson(Map<String, dynamic> json) => factory _$SnPostPreloadImpl.fromJson(Map<String, dynamic> json) =>
_$$SnPostPreloadImplFromJson(json); _$$SnPostPreloadImplFromJson(json);
final List<SnAttachment>? _attachments;
@override @override
List<SnAttachment>? get attachments { final SnAttachment? thumbnail;
final List<SnAttachment?>? _attachments;
@override
List<SnAttachment?>? get attachments {
final value = _attachments; final value = _attachments;
if (value == null) return null; if (value == null) return null;
if (_attachments is EqualUnmodifiableListView) return _attachments; if (_attachments is EqualUnmodifiableListView) return _attachments;
@ -1056,7 +1396,7 @@ class _$SnPostPreloadImpl implements _SnPostPreload {
@override @override
String toString() { String toString() {
return 'SnPostPreload(attachments: $attachments)'; return 'SnPostPreload(thumbnail: $thumbnail, attachments: $attachments)';
} }
@override @override
@ -1064,14 +1404,16 @@ class _$SnPostPreloadImpl implements _SnPostPreload {
return identical(this, other) || return identical(this, other) ||
(other.runtimeType == runtimeType && (other.runtimeType == runtimeType &&
other is _$SnPostPreloadImpl && other is _$SnPostPreloadImpl &&
(identical(other.thumbnail, thumbnail) ||
other.thumbnail == thumbnail) &&
const DeepCollectionEquality() const DeepCollectionEquality()
.equals(other._attachments, _attachments)); .equals(other._attachments, _attachments));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash( int get hashCode => Object.hash(runtimeType, thumbnail,
runtimeType, const DeepCollectionEquality().hash(_attachments)); const DeepCollectionEquality().hash(_attachments));
/// Create a copy of SnPostPreload /// Create a copy of SnPostPreload
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@ -1091,13 +1433,16 @@ class _$SnPostPreloadImpl implements _SnPostPreload {
abstract class _SnPostPreload implements SnPostPreload { abstract class _SnPostPreload implements SnPostPreload {
const factory _SnPostPreload( const factory _SnPostPreload(
{required final List<SnAttachment>? attachments}) = _$SnPostPreloadImpl; {required final SnAttachment? thumbnail,
required final List<SnAttachment?>? attachments}) = _$SnPostPreloadImpl;
factory _SnPostPreload.fromJson(Map<String, dynamic> json) = factory _SnPostPreload.fromJson(Map<String, dynamic> json) =
_$SnPostPreloadImpl.fromJson; _$SnPostPreloadImpl.fromJson;
@override @override
List<SnAttachment>? get attachments; SnAttachment? get thumbnail;
@override
List<SnAttachment?>? get attachments;
/// Create a copy of SnPostPreload /// Create a copy of SnPostPreload
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.

View File

@ -18,8 +18,11 @@ _$SnPostImpl _$$SnPostImplFromJson(Map<String, dynamic> json) => _$SnPostImpl(
language: json['language'] as String, language: json['language'] as String,
alias: json['alias'] as String?, alias: json['alias'] as String?,
aliasPrefix: json['alias_prefix'] as String?, aliasPrefix: json['alias_prefix'] as String?,
tags: json['tags'] as List<dynamic>, tags: (json['tags'] as List<dynamic>?)
categories: json['categories'] as List<dynamic>, ?.map((e) => SnPostTag.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
categories: json['categories'] as List<dynamic>? ?? const [],
replies: (json['replies'] as List<dynamic>?) replies: (json['replies'] as List<dynamic>?)
?.map((e) => SnPost.fromJson(e as Map<String, dynamic>)) ?.map((e) => SnPost.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
@ -76,7 +79,7 @@ Map<String, dynamic> _$$SnPostImplToJson(_$SnPostImpl instance) =>
'language': instance.language, 'language': instance.language,
'alias': instance.alias, 'alias': instance.alias,
'alias_prefix': instance.aliasPrefix, 'alias_prefix': instance.aliasPrefix,
'tags': instance.tags, 'tags': instance.tags.map((e) => e.toJson()).toList(),
'categories': instance.categories, 'categories': instance.categories,
'replies': instance.replies?.map((e) => e.toJson()).toList(), 'replies': instance.replies?.map((e) => e.toJson()).toList(),
'reply_id': instance.replyId, 'reply_id': instance.replyId,
@ -100,16 +103,46 @@ Map<String, dynamic> _$$SnPostImplToJson(_$SnPostImpl instance) =>
'preload': instance.preload?.toJson(), 'preload': instance.preload?.toJson(),
}; };
_$SnPostTagImpl _$$SnPostTagImplFromJson(Map<String, dynamic> json) =>
_$SnPostTagImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'],
alias: json['alias'] as String,
name: json['name'] as String,
description: json['description'] as String,
posts: json['posts'],
);
Map<String, dynamic> _$$SnPostTagImplToJson(_$SnPostTagImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt,
'alias': instance.alias,
'name': instance.name,
'description': instance.description,
'posts': instance.posts,
};
_$SnPostPreloadImpl _$$SnPostPreloadImplFromJson(Map<String, dynamic> json) => _$SnPostPreloadImpl _$$SnPostPreloadImplFromJson(Map<String, dynamic> json) =>
_$SnPostPreloadImpl( _$SnPostPreloadImpl(
thumbnail: json['thumbnail'] == null
? null
: SnAttachment.fromJson(json['thumbnail'] as Map<String, dynamic>),
attachments: (json['attachments'] as List<dynamic>?) attachments: (json['attachments'] as List<dynamic>?)
?.map((e) => SnAttachment.fromJson(e as Map<String, dynamic>)) ?.map((e) => e == null
? null
: SnAttachment.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
); );
Map<String, dynamic> _$$SnPostPreloadImplToJson(_$SnPostPreloadImpl instance) => Map<String, dynamic> _$$SnPostPreloadImplToJson(_$SnPostPreloadImpl instance) =>
<String, dynamic>{ <String, dynamic>{
'attachments': instance.attachments?.map((e) => e.toJson()).toList(), 'thumbnail': instance.thumbnail?.toJson(),
'attachments': instance.attachments?.map((e) => e?.toJson()).toList(),
}; };
_$SnBodyImpl _$$SnBodyImplFromJson(Map<String, dynamic> json) => _$SnBodyImpl( _$SnBodyImpl _$$SnBodyImplFromJson(Map<String, dynamic> json) => _$SnBodyImpl(

50
lib/types/realm.dart Normal file
View File

@ -0,0 +1,50 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:surface/types/account.dart';
part 'realm.freezed.dart';
part 'realm.g.dart';
@freezed
class SnRealmMember with _$SnRealmMember {
const factory SnRealmMember({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required int realmId,
required int accountId,
required SnRealm realm,
required SnAccount account,
required int powerLevel,
}) = _SnRealmMember;
factory SnRealmMember.fromJson(Map<String, dynamic> json) =>
_$SnRealmMemberFromJson(json);
}
@freezed
class SnRealm with _$SnRealm {
const SnRealm._();
@HiveType(typeId: 1)
const factory SnRealm({
@HiveField(0) required int id,
@HiveField(1) required DateTime createdAt,
@HiveField(2) required DateTime updatedAt,
@HiveField(3) required DateTime? deletedAt,
@HiveField(4) required String alias,
@HiveField(5) required String name,
@HiveField(6) required String description,
List<SnRealmMember>? members,
@HiveField(7) required String? avatar,
@HiveField(8) required String? banner,
@HiveField(9) required Map<String, dynamic>? accessPolicy,
@HiveField(10) required int accountId,
@HiveField(11) required bool isPublic,
@HiveField(12) required bool isCommunity,
}) = _SnRealm;
factory SnRealm.fromJson(Map<String, dynamic> json) =>
_$SnRealmFromJson(json);
}

View File

@ -0,0 +1,854 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'realm.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
SnRealmMember _$SnRealmMemberFromJson(Map<String, dynamic> json) {
return _SnRealmMember.fromJson(json);
}
/// @nodoc
mixin _$SnRealmMember {
int get id => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
DateTime? get deletedAt => throw _privateConstructorUsedError;
int get realmId => throw _privateConstructorUsedError;
int get accountId => throw _privateConstructorUsedError;
SnRealm get realm => throw _privateConstructorUsedError;
SnAccount get account => throw _privateConstructorUsedError;
int get powerLevel => throw _privateConstructorUsedError;
/// Serializes this SnRealmMember to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnRealmMember
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnRealmMemberCopyWith<SnRealmMember> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnRealmMemberCopyWith<$Res> {
factory $SnRealmMemberCopyWith(
SnRealmMember value, $Res Function(SnRealmMember) then) =
_$SnRealmMemberCopyWithImpl<$Res, SnRealmMember>;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
int realmId,
int accountId,
SnRealm realm,
SnAccount account,
int powerLevel});
$SnRealmCopyWith<$Res> get realm;
$SnAccountCopyWith<$Res> get account;
}
/// @nodoc
class _$SnRealmMemberCopyWithImpl<$Res, $Val extends SnRealmMember>
implements $SnRealmMemberCopyWith<$Res> {
_$SnRealmMemberCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnRealmMember
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? realmId = null,
Object? accountId = null,
Object? realm = null,
Object? account = null,
Object? powerLevel = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
realmId: null == realmId
? _value.realmId
: realmId // ignore: cast_nullable_to_non_nullable
as int,
accountId: null == accountId
? _value.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
realm: null == realm
? _value.realm
: realm // ignore: cast_nullable_to_non_nullable
as SnRealm,
account: null == account
? _value.account
: account // ignore: cast_nullable_to_non_nullable
as SnAccount,
powerLevel: null == powerLevel
? _value.powerLevel
: powerLevel // ignore: cast_nullable_to_non_nullable
as int,
) as $Val);
}
/// Create a copy of SnRealmMember
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnRealmCopyWith<$Res> get realm {
return $SnRealmCopyWith<$Res>(_value.realm, (value) {
return _then(_value.copyWith(realm: value) as $Val);
});
}
/// Create a copy of SnRealmMember
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res> get account {
return $SnAccountCopyWith<$Res>(_value.account, (value) {
return _then(_value.copyWith(account: value) as $Val);
});
}
}
/// @nodoc
abstract class _$$SnRealmMemberImplCopyWith<$Res>
implements $SnRealmMemberCopyWith<$Res> {
factory _$$SnRealmMemberImplCopyWith(
_$SnRealmMemberImpl value, $Res Function(_$SnRealmMemberImpl) then) =
__$$SnRealmMemberImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
int realmId,
int accountId,
SnRealm realm,
SnAccount account,
int powerLevel});
@override
$SnRealmCopyWith<$Res> get realm;
@override
$SnAccountCopyWith<$Res> get account;
}
/// @nodoc
class __$$SnRealmMemberImplCopyWithImpl<$Res>
extends _$SnRealmMemberCopyWithImpl<$Res, _$SnRealmMemberImpl>
implements _$$SnRealmMemberImplCopyWith<$Res> {
__$$SnRealmMemberImplCopyWithImpl(
_$SnRealmMemberImpl _value, $Res Function(_$SnRealmMemberImpl) _then)
: super(_value, _then);
/// Create a copy of SnRealmMember
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? realmId = null,
Object? accountId = null,
Object? realm = null,
Object? account = null,
Object? powerLevel = null,
}) {
return _then(_$SnRealmMemberImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
realmId: null == realmId
? _value.realmId
: realmId // ignore: cast_nullable_to_non_nullable
as int,
accountId: null == accountId
? _value.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
realm: null == realm
? _value.realm
: realm // ignore: cast_nullable_to_non_nullable
as SnRealm,
account: null == account
? _value.account
: account // ignore: cast_nullable_to_non_nullable
as SnAccount,
powerLevel: null == powerLevel
? _value.powerLevel
: powerLevel // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnRealmMemberImpl implements _SnRealmMember {
const _$SnRealmMemberImpl(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.realmId,
required this.accountId,
required this.realm,
required this.account,
required this.powerLevel});
factory _$SnRealmMemberImpl.fromJson(Map<String, dynamic> json) =>
_$$SnRealmMemberImplFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final DateTime? deletedAt;
@override
final int realmId;
@override
final int accountId;
@override
final SnRealm realm;
@override
final SnAccount account;
@override
final int powerLevel;
@override
String toString() {
return 'SnRealmMember(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, realmId: $realmId, accountId: $accountId, realm: $realm, account: $account, powerLevel: $powerLevel)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnRealmMemberImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
(identical(other.deletedAt, deletedAt) ||
other.deletedAt == deletedAt) &&
(identical(other.realmId, realmId) || other.realmId == realmId) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId) &&
(identical(other.realm, realm) || other.realm == realm) &&
(identical(other.account, account) || other.account == account) &&
(identical(other.powerLevel, powerLevel) ||
other.powerLevel == powerLevel));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt,
deletedAt, realmId, accountId, realm, account, powerLevel);
/// Create a copy of SnRealmMember
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnRealmMemberImplCopyWith<_$SnRealmMemberImpl> get copyWith =>
__$$SnRealmMemberImplCopyWithImpl<_$SnRealmMemberImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnRealmMemberImplToJson(
this,
);
}
}
abstract class _SnRealmMember implements SnRealmMember {
const factory _SnRealmMember(
{required final int id,
required final DateTime createdAt,
required final DateTime updatedAt,
required final DateTime? deletedAt,
required final int realmId,
required final int accountId,
required final SnRealm realm,
required final SnAccount account,
required final int powerLevel}) = _$SnRealmMemberImpl;
factory _SnRealmMember.fromJson(Map<String, dynamic> json) =
_$SnRealmMemberImpl.fromJson;
@override
int get id;
@override
DateTime get createdAt;
@override
DateTime get updatedAt;
@override
DateTime? get deletedAt;
@override
int get realmId;
@override
int get accountId;
@override
SnRealm get realm;
@override
SnAccount get account;
@override
int get powerLevel;
/// Create a copy of SnRealmMember
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnRealmMemberImplCopyWith<_$SnRealmMemberImpl> get copyWith =>
throw _privateConstructorUsedError;
}
SnRealm _$SnRealmFromJson(Map<String, dynamic> json) {
return _SnRealm.fromJson(json);
}
/// @nodoc
mixin _$SnRealm {
@HiveField(0)
int get id => throw _privateConstructorUsedError;
@HiveField(1)
DateTime get createdAt => throw _privateConstructorUsedError;
@HiveField(2)
DateTime get updatedAt => throw _privateConstructorUsedError;
@HiveField(3)
DateTime? get deletedAt => throw _privateConstructorUsedError;
@HiveField(4)
String get alias => throw _privateConstructorUsedError;
@HiveField(5)
String get name => throw _privateConstructorUsedError;
@HiveField(6)
String get description => throw _privateConstructorUsedError;
List<SnRealmMember>? get members => throw _privateConstructorUsedError;
@HiveField(7)
String? get avatar => throw _privateConstructorUsedError;
@HiveField(8)
String? get banner => throw _privateConstructorUsedError;
@HiveField(9)
Map<String, dynamic>? get accessPolicy => throw _privateConstructorUsedError;
@HiveField(10)
int get accountId => throw _privateConstructorUsedError;
@HiveField(11)
bool get isPublic => throw _privateConstructorUsedError;
@HiveField(12)
bool get isCommunity => throw _privateConstructorUsedError;
/// Serializes this SnRealm to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnRealm
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnRealmCopyWith<SnRealm> get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnRealmCopyWith<$Res> {
factory $SnRealmCopyWith(SnRealm value, $Res Function(SnRealm) then) =
_$SnRealmCopyWithImpl<$Res, SnRealm>;
@useResult
$Res call(
{@HiveField(0) int id,
@HiveField(1) DateTime createdAt,
@HiveField(2) DateTime updatedAt,
@HiveField(3) DateTime? deletedAt,
@HiveField(4) String alias,
@HiveField(5) String name,
@HiveField(6) String description,
List<SnRealmMember>? members,
@HiveField(7) String? avatar,
@HiveField(8) String? banner,
@HiveField(9) Map<String, dynamic>? accessPolicy,
@HiveField(10) int accountId,
@HiveField(11) bool isPublic,
@HiveField(12) bool isCommunity});
}
/// @nodoc
class _$SnRealmCopyWithImpl<$Res, $Val extends SnRealm>
implements $SnRealmCopyWith<$Res> {
_$SnRealmCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnRealm
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? alias = null,
Object? name = null,
Object? description = null,
Object? members = freezed,
Object? avatar = freezed,
Object? banner = freezed,
Object? accessPolicy = freezed,
Object? accountId = null,
Object? isPublic = null,
Object? isCommunity = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
alias: null == alias
? _value.alias
: alias // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
description: null == description
? _value.description
: description // ignore: cast_nullable_to_non_nullable
as String,
members: freezed == members
? _value.members
: members // ignore: cast_nullable_to_non_nullable
as List<SnRealmMember>?,
avatar: freezed == avatar
? _value.avatar
: avatar // ignore: cast_nullable_to_non_nullable
as String?,
banner: freezed == banner
? _value.banner
: banner // ignore: cast_nullable_to_non_nullable
as String?,
accessPolicy: freezed == accessPolicy
? _value.accessPolicy
: accessPolicy // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,
accountId: null == accountId
? _value.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
isPublic: null == isPublic
? _value.isPublic
: isPublic // ignore: cast_nullable_to_non_nullable
as bool,
isCommunity: null == isCommunity
? _value.isCommunity
: isCommunity // ignore: cast_nullable_to_non_nullable
as bool,
) as $Val);
}
}
/// @nodoc
abstract class _$$SnRealmImplCopyWith<$Res> implements $SnRealmCopyWith<$Res> {
factory _$$SnRealmImplCopyWith(
_$SnRealmImpl value, $Res Function(_$SnRealmImpl) then) =
__$$SnRealmImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{@HiveField(0) int id,
@HiveField(1) DateTime createdAt,
@HiveField(2) DateTime updatedAt,
@HiveField(3) DateTime? deletedAt,
@HiveField(4) String alias,
@HiveField(5) String name,
@HiveField(6) String description,
List<SnRealmMember>? members,
@HiveField(7) String? avatar,
@HiveField(8) String? banner,
@HiveField(9) Map<String, dynamic>? accessPolicy,
@HiveField(10) int accountId,
@HiveField(11) bool isPublic,
@HiveField(12) bool isCommunity});
}
/// @nodoc
class __$$SnRealmImplCopyWithImpl<$Res>
extends _$SnRealmCopyWithImpl<$Res, _$SnRealmImpl>
implements _$$SnRealmImplCopyWith<$Res> {
__$$SnRealmImplCopyWithImpl(
_$SnRealmImpl _value, $Res Function(_$SnRealmImpl) _then)
: super(_value, _then);
/// Create a copy of SnRealm
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? alias = null,
Object? name = null,
Object? description = null,
Object? members = freezed,
Object? avatar = freezed,
Object? banner = freezed,
Object? accessPolicy = freezed,
Object? accountId = null,
Object? isPublic = null,
Object? isCommunity = null,
}) {
return _then(_$SnRealmImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
alias: null == alias
? _value.alias
: alias // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
description: null == description
? _value.description
: description // ignore: cast_nullable_to_non_nullable
as String,
members: freezed == members
? _value._members
: members // ignore: cast_nullable_to_non_nullable
as List<SnRealmMember>?,
avatar: freezed == avatar
? _value.avatar
: avatar // ignore: cast_nullable_to_non_nullable
as String?,
banner: freezed == banner
? _value.banner
: banner // ignore: cast_nullable_to_non_nullable
as String?,
accessPolicy: freezed == accessPolicy
? _value._accessPolicy
: accessPolicy // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,
accountId: null == accountId
? _value.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
isPublic: null == isPublic
? _value.isPublic
: isPublic // ignore: cast_nullable_to_non_nullable
as bool,
isCommunity: null == isCommunity
? _value.isCommunity
: isCommunity // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// @nodoc
@JsonSerializable()
@HiveType(typeId: 1)
class _$SnRealmImpl extends _SnRealm {
const _$SnRealmImpl(
{@HiveField(0) required this.id,
@HiveField(1) required this.createdAt,
@HiveField(2) required this.updatedAt,
@HiveField(3) required this.deletedAt,
@HiveField(4) required this.alias,
@HiveField(5) required this.name,
@HiveField(6) required this.description,
final List<SnRealmMember>? members,
@HiveField(7) required this.avatar,
@HiveField(8) required this.banner,
@HiveField(9) required final Map<String, dynamic>? accessPolicy,
@HiveField(10) required this.accountId,
@HiveField(11) required this.isPublic,
@HiveField(12) required this.isCommunity})
: _members = members,
_accessPolicy = accessPolicy,
super._();
factory _$SnRealmImpl.fromJson(Map<String, dynamic> json) =>
_$$SnRealmImplFromJson(json);
@override
@HiveField(0)
final int id;
@override
@HiveField(1)
final DateTime createdAt;
@override
@HiveField(2)
final DateTime updatedAt;
@override
@HiveField(3)
final DateTime? deletedAt;
@override
@HiveField(4)
final String alias;
@override
@HiveField(5)
final String name;
@override
@HiveField(6)
final String description;
final List<SnRealmMember>? _members;
@override
List<SnRealmMember>? get members {
final value = _members;
if (value == null) return null;
if (_members is EqualUnmodifiableListView) return _members;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(value);
}
@override
@HiveField(7)
final String? avatar;
@override
@HiveField(8)
final String? banner;
final Map<String, dynamic>? _accessPolicy;
@override
@HiveField(9)
Map<String, dynamic>? get accessPolicy {
final value = _accessPolicy;
if (value == null) return null;
if (_accessPolicy is EqualUnmodifiableMapView) return _accessPolicy;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(value);
}
@override
@HiveField(10)
final int accountId;
@override
@HiveField(11)
final bool isPublic;
@override
@HiveField(12)
final bool isCommunity;
@override
String toString() {
return 'SnRealm(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, alias: $alias, name: $name, description: $description, members: $members, avatar: $avatar, banner: $banner, accessPolicy: $accessPolicy, accountId: $accountId, isPublic: $isPublic, isCommunity: $isCommunity)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnRealmImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
(identical(other.deletedAt, deletedAt) ||
other.deletedAt == deletedAt) &&
(identical(other.alias, alias) || other.alias == alias) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.description, description) ||
other.description == description) &&
const DeepCollectionEquality().equals(other._members, _members) &&
(identical(other.avatar, avatar) || other.avatar == avatar) &&
(identical(other.banner, banner) || other.banner == banner) &&
const DeepCollectionEquality()
.equals(other._accessPolicy, _accessPolicy) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId) &&
(identical(other.isPublic, isPublic) ||
other.isPublic == isPublic) &&
(identical(other.isCommunity, isCommunity) ||
other.isCommunity == isCommunity));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
alias,
name,
description,
const DeepCollectionEquality().hash(_members),
avatar,
banner,
const DeepCollectionEquality().hash(_accessPolicy),
accountId,
isPublic,
isCommunity);
/// Create a copy of SnRealm
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnRealmImplCopyWith<_$SnRealmImpl> get copyWith =>
__$$SnRealmImplCopyWithImpl<_$SnRealmImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnRealmImplToJson(
this,
);
}
}
abstract class _SnRealm extends SnRealm {
const factory _SnRealm(
{@HiveField(0) required final int id,
@HiveField(1) required final DateTime createdAt,
@HiveField(2) required final DateTime updatedAt,
@HiveField(3) required final DateTime? deletedAt,
@HiveField(4) required final String alias,
@HiveField(5) required final String name,
@HiveField(6) required final String description,
final List<SnRealmMember>? members,
@HiveField(7) required final String? avatar,
@HiveField(8) required final String? banner,
@HiveField(9) required final Map<String, dynamic>? accessPolicy,
@HiveField(10) required final int accountId,
@HiveField(11) required final bool isPublic,
@HiveField(12) required final bool isCommunity}) = _$SnRealmImpl;
const _SnRealm._() : super._();
factory _SnRealm.fromJson(Map<String, dynamic> json) = _$SnRealmImpl.fromJson;
@override
@HiveField(0)
int get id;
@override
@HiveField(1)
DateTime get createdAt;
@override
@HiveField(2)
DateTime get updatedAt;
@override
@HiveField(3)
DateTime? get deletedAt;
@override
@HiveField(4)
String get alias;
@override
@HiveField(5)
String get name;
@override
@HiveField(6)
String get description;
@override
List<SnRealmMember>? get members;
@override
@HiveField(7)
String? get avatar;
@override
@HiveField(8)
String? get banner;
@override
@HiveField(9)
Map<String, dynamic>? get accessPolicy;
@override
@HiveField(10)
int get accountId;
@override
@HiveField(11)
bool get isPublic;
@override
@HiveField(12)
bool get isCommunity;
/// Create a copy of SnRealm
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnRealmImplCopyWith<_$SnRealmImpl> get copyWith =>
throw _privateConstructorUsedError;
}

149
lib/types/realm.g.dart Normal file
View File

@ -0,0 +1,149 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'realm.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class SnRealmImplAdapter extends TypeAdapter<_$SnRealmImpl> {
@override
final int typeId = 1;
@override
_$SnRealmImpl read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return _$SnRealmImpl(
id: fields[0] as int,
createdAt: fields[1] as DateTime,
updatedAt: fields[2] as DateTime,
deletedAt: fields[3] as DateTime?,
alias: fields[4] as String,
name: fields[5] as String,
description: fields[6] as String,
avatar: fields[7] as String?,
banner: fields[8] as String?,
accessPolicy: (fields[9] as Map?)?.cast<String, dynamic>(),
accountId: fields[10] as int,
isPublic: fields[11] as bool,
isCommunity: fields[12] as bool,
);
}
@override
void write(BinaryWriter writer, _$SnRealmImpl obj) {
writer
..writeByte(13)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.createdAt)
..writeByte(2)
..write(obj.updatedAt)
..writeByte(3)
..write(obj.deletedAt)
..writeByte(4)
..write(obj.alias)
..writeByte(5)
..write(obj.name)
..writeByte(6)
..write(obj.description)
..writeByte(7)
..write(obj.avatar)
..writeByte(8)
..write(obj.banner)
..writeByte(10)
..write(obj.accountId)
..writeByte(11)
..write(obj.isPublic)
..writeByte(12)
..write(obj.isCommunity)
..writeByte(9)
..write(obj.accessPolicy);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SnRealmImplAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SnRealmMemberImpl _$$SnRealmMemberImplFromJson(Map<String, dynamic> json) =>
_$SnRealmMemberImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
realmId: (json['realm_id'] as num).toInt(),
accountId: (json['account_id'] as num).toInt(),
realm: SnRealm.fromJson(json['realm'] as Map<String, dynamic>),
account: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
powerLevel: (json['power_level'] as num).toInt(),
);
Map<String, dynamic> _$$SnRealmMemberImplToJson(_$SnRealmMemberImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'realm_id': instance.realmId,
'account_id': instance.accountId,
'realm': instance.realm.toJson(),
'account': instance.account.toJson(),
'power_level': instance.powerLevel,
};
_$SnRealmImpl _$$SnRealmImplFromJson(Map<String, dynamic> json) =>
_$SnRealmImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
alias: json['alias'] as String,
name: json['name'] as String,
description: json['description'] as String,
members: (json['members'] as List<dynamic>?)
?.map((e) => SnRealmMember.fromJson(e as Map<String, dynamic>))
.toList(),
avatar: json['avatar'] as String?,
banner: json['banner'] as String?,
accessPolicy: json['access_policy'] as Map<String, dynamic>?,
accountId: (json['account_id'] as num).toInt(),
isPublic: json['is_public'] as bool,
isCommunity: json['is_community'] as bool,
);
Map<String, dynamic> _$$SnRealmImplToJson(_$SnRealmImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'alias': instance.alias,
'name': instance.name,
'description': instance.description,
'members': instance.members?.map((e) => e.toJson()).toList(),
'avatar': instance.avatar,
'banner': instance.banner,
'access_policy': instance.accessPolicy,
'account_id': instance.accountId,
'is_public': instance.isPublic,
'is_community': instance.isCommunity,
};

17
lib/types/websocket.dart Normal file
View File

@ -0,0 +1,17 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'websocket.freezed.dart';
part 'websocket.g.dart';
@freezed
class WebSocketPackage with _$WebSocketPackage {
const factory WebSocketPackage({
@JsonKey(name: 'w') @Default('unknown') String method,
@JsonKey(name: 'e') String? endpoint,
@JsonKey(name: 'm') String? message,
@JsonKey(name: 'p') @Default({}) Map<String, dynamic>? payload,
}) = _WebSocketPackage;
factory WebSocketPackage.fromJson(Map<String, dynamic> json) =>
_$WebSocketPackageFromJson(json);
}

View File

@ -0,0 +1,252 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'websocket.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
WebSocketPackage _$WebSocketPackageFromJson(Map<String, dynamic> json) {
return _WebSocketPackage.fromJson(json);
}
/// @nodoc
mixin _$WebSocketPackage {
@JsonKey(name: 'w')
String get method => throw _privateConstructorUsedError;
@JsonKey(name: 'e')
String? get endpoint => throw _privateConstructorUsedError;
@JsonKey(name: 'm')
String? get message => throw _privateConstructorUsedError;
@JsonKey(name: 'p')
Map<String, dynamic>? get payload => throw _privateConstructorUsedError;
/// Serializes this WebSocketPackage to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of WebSocketPackage
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$WebSocketPackageCopyWith<WebSocketPackage> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $WebSocketPackageCopyWith<$Res> {
factory $WebSocketPackageCopyWith(
WebSocketPackage value, $Res Function(WebSocketPackage) then) =
_$WebSocketPackageCopyWithImpl<$Res, WebSocketPackage>;
@useResult
$Res call(
{@JsonKey(name: 'w') String method,
@JsonKey(name: 'e') String? endpoint,
@JsonKey(name: 'm') String? message,
@JsonKey(name: 'p') Map<String, dynamic>? payload});
}
/// @nodoc
class _$WebSocketPackageCopyWithImpl<$Res, $Val extends WebSocketPackage>
implements $WebSocketPackageCopyWith<$Res> {
_$WebSocketPackageCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of WebSocketPackage
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? method = null,
Object? endpoint = freezed,
Object? message = freezed,
Object? payload = freezed,
}) {
return _then(_value.copyWith(
method: null == method
? _value.method
: method // ignore: cast_nullable_to_non_nullable
as String,
endpoint: freezed == endpoint
? _value.endpoint
: endpoint // ignore: cast_nullable_to_non_nullable
as String?,
message: freezed == message
? _value.message
: message // ignore: cast_nullable_to_non_nullable
as String?,
payload: freezed == payload
? _value.payload
: payload // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,
) as $Val);
}
}
/// @nodoc
abstract class _$$WebSocketPackageImplCopyWith<$Res>
implements $WebSocketPackageCopyWith<$Res> {
factory _$$WebSocketPackageImplCopyWith(_$WebSocketPackageImpl value,
$Res Function(_$WebSocketPackageImpl) then) =
__$$WebSocketPackageImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{@JsonKey(name: 'w') String method,
@JsonKey(name: 'e') String? endpoint,
@JsonKey(name: 'm') String? message,
@JsonKey(name: 'p') Map<String, dynamic>? payload});
}
/// @nodoc
class __$$WebSocketPackageImplCopyWithImpl<$Res>
extends _$WebSocketPackageCopyWithImpl<$Res, _$WebSocketPackageImpl>
implements _$$WebSocketPackageImplCopyWith<$Res> {
__$$WebSocketPackageImplCopyWithImpl(_$WebSocketPackageImpl _value,
$Res Function(_$WebSocketPackageImpl) _then)
: super(_value, _then);
/// Create a copy of WebSocketPackage
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? method = null,
Object? endpoint = freezed,
Object? message = freezed,
Object? payload = freezed,
}) {
return _then(_$WebSocketPackageImpl(
method: null == method
? _value.method
: method // ignore: cast_nullable_to_non_nullable
as String,
endpoint: freezed == endpoint
? _value.endpoint
: endpoint // ignore: cast_nullable_to_non_nullable
as String?,
message: freezed == message
? _value.message
: message // ignore: cast_nullable_to_non_nullable
as String?,
payload: freezed == payload
? _value._payload
: payload // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,
));
}
}
/// @nodoc
@JsonSerializable()
class _$WebSocketPackageImpl implements _WebSocketPackage {
const _$WebSocketPackageImpl(
{@JsonKey(name: 'w') this.method = 'unknown',
@JsonKey(name: 'e') this.endpoint,
@JsonKey(name: 'm') this.message,
@JsonKey(name: 'p') final Map<String, dynamic>? payload = const {}})
: _payload = payload;
factory _$WebSocketPackageImpl.fromJson(Map<String, dynamic> json) =>
_$$WebSocketPackageImplFromJson(json);
@override
@JsonKey(name: 'w')
final String method;
@override
@JsonKey(name: 'e')
final String? endpoint;
@override
@JsonKey(name: 'm')
final String? message;
final Map<String, dynamic>? _payload;
@override
@JsonKey(name: 'p')
Map<String, dynamic>? get payload {
final value = _payload;
if (value == null) return null;
if (_payload is EqualUnmodifiableMapView) return _payload;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(value);
}
@override
String toString() {
return 'WebSocketPackage(method: $method, endpoint: $endpoint, message: $message, payload: $payload)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$WebSocketPackageImpl &&
(identical(other.method, method) || other.method == method) &&
(identical(other.endpoint, endpoint) ||
other.endpoint == endpoint) &&
(identical(other.message, message) || other.message == message) &&
const DeepCollectionEquality().equals(other._payload, _payload));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, method, endpoint, message,
const DeepCollectionEquality().hash(_payload));
/// Create a copy of WebSocketPackage
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$WebSocketPackageImplCopyWith<_$WebSocketPackageImpl> get copyWith =>
__$$WebSocketPackageImplCopyWithImpl<_$WebSocketPackageImpl>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$WebSocketPackageImplToJson(
this,
);
}
}
abstract class _WebSocketPackage implements WebSocketPackage {
const factory _WebSocketPackage(
{@JsonKey(name: 'w') final String method,
@JsonKey(name: 'e') final String? endpoint,
@JsonKey(name: 'm') final String? message,
@JsonKey(name: 'p') final Map<String, dynamic>? payload}) =
_$WebSocketPackageImpl;
factory _WebSocketPackage.fromJson(Map<String, dynamic> json) =
_$WebSocketPackageImpl.fromJson;
@override
@JsonKey(name: 'w')
String get method;
@override
@JsonKey(name: 'e')
String? get endpoint;
@override
@JsonKey(name: 'm')
String? get message;
@override
@JsonKey(name: 'p')
Map<String, dynamic>? get payload;
/// Create a copy of WebSocketPackage
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$WebSocketPackageImplCopyWith<_$WebSocketPackageImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'websocket.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$WebSocketPackageImpl _$$WebSocketPackageImplFromJson(
Map<String, dynamic> json) =>
_$WebSocketPackageImpl(
method: json['w'] as String? ?? 'unknown',
endpoint: json['e'] as String?,
message: json['m'] as String?,
payload: json['p'] as Map<String, dynamic>? ?? const {},
);
Map<String, dynamic> _$$WebSocketPackageImplToJson(
_$WebSocketPackageImpl instance) =>
<String, dynamic>{
'w': instance.method,
'e': instance.endpoint,
'm': instance.message,
'p': instance.payload,
};

View File

@ -1,16 +1,37 @@
import 'package:dismissible_page/dismissible_page.dart'; import 'package:dismissible_page/dismissible_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/attachment.dart'; import 'package:surface/types/attachment.dart';
import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class AttachmentDetailPopup extends StatelessWidget { class AttachmentZoomView extends StatefulWidget {
final SnAttachment data; final Iterable<SnAttachment> data;
final String? heroTag; final int? initialIndex;
const AttachmentDetailPopup({super.key, required this.data, this.heroTag}); final List<String?>? heroTags;
const AttachmentZoomView({
super.key,
required this.data,
this.initialIndex,
this.heroTags,
});
@override
State<AttachmentZoomView> createState() => _AttachmentZoomViewState();
}
class _AttachmentZoomViewState extends State<AttachmentZoomView> {
late final PageController _pageController =
PageController(initialPage: widget.initialIndex ?? 0);
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -24,18 +45,51 @@ class AttachmentDetailPopup extends StatelessWidget {
direction: DismissiblePageDismissDirection.down, direction: DismissiblePageDismissDirection.down,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
isFullScreen: true, isFullScreen: true,
child: Hero( child: Builder(builder: (context) {
tag: 'attachment-${data.rid}-${heroTag ?? uuid.v4()}', if (widget.data.length == 1) {
child: PhotoView( final heroTag = widget.heroTags?.first ?? uuid.v4();
key: Key('attachment-detail-${data.rid}-$heroTag'), return Hero(
backgroundDecoration: BoxDecoration( tag: 'attachment-${widget.data.first.rid}-$heroTag',
color: Colors.black.withOpacity(0.7), child: PhotoView(
key: Key('attachment-detail-${widget.data.first.rid}-$heroTag'),
backgroundDecoration: BoxDecoration(color: Colors.transparent),
imageProvider: UniversalImage.provider(
sn.getAttachmentUrl(widget.data.first.rid),
),
),
);
}
return PhotoViewGallery.builder(
pageController: _pageController,
scrollPhysics: const BouncingScrollPhysics(),
builder: (context, idx) {
final heroTag = widget.heroTags?.elementAt(idx) ?? uuid.v4();
return PhotoViewGalleryPageOptions(
imageProvider: UniversalImage.provider(
sn.getAttachmentUrl(widget.data.elementAt(idx).rid),
),
heroAttributes: PhotoViewHeroAttributes(
tag: 'attachment-${widget.data.first.rid}-$heroTag',
),
);
},
itemCount: widget.data.length,
loadingBuilder: (context, event) => Center(
child: SizedBox(
width: 20.0,
height: 20.0,
child: CircularProgressIndicator(
value: event == null
? 0
: event.cumulativeBytesLoaded /
(event.expectedTotalBytes ?? 1),
),
),
), ),
imageProvider: UniversalImage.provider( backgroundDecoration: BoxDecoration(color: Colors.transparent),
sn.getAttachmentUrl(data.rid), );
), }),
),
),
); );
} }
} }

View File

@ -1,40 +1,58 @@
import 'dart:ui'; import 'dart:ui';
import 'dart:math' as math;
import 'package:dismissible_page/dismissible_page.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/attachment.dart'; import 'package:surface/types/attachment.dart';
import 'package:surface/widgets/attachment/attachment_detail.dart';
import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class AttachmentItem extends StatelessWidget { class AttachmentItem extends StatelessWidget {
final SnAttachment data; final SnAttachment? data;
final bool isExpandable; final String? heroTag;
const AttachmentItem({ const AttachmentItem({
super.key, super.key,
required this.data, required this.data,
this.isExpandable = false, required this.heroTag,
}); });
Widget _buildContent(BuildContext context, String heroTag) { Widget _buildContent(BuildContext context) {
final tp = data.mimetype.split('/').firstOrNull; final tag = heroTag ?? Uuid().v4();
if (data == null) {
return const Icon(Symbols.cancel).center();
}
final tp = data!.mimetype.split('/').firstOrNull;
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
switch (tp) { switch (tp) {
case 'image': case 'image':
return Hero( return Hero(
tag: 'attachment-${data.rid}-$heroTag', tag: 'attachment-${data!.rid}-$tag',
child: AutoResizeUniversalImage( child: AutoResizeUniversalImage(
sn.getAttachmentUrl(data.rid), sn.getAttachmentUrl(data!.rid),
key: Key('attachment-${data.rid}-$heroTag'), key: Key('attachment-${data!.rid}-$tag'),
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
); );
case 'video':
return _AttachmentItemContentVideo(
data: data!,
isAutoload: false,
);
case 'audio':
return _AttachmentItemContentAudio(
data: data!,
isAutoload: false,
);
default: default:
return const Placeholder(); return const Placeholder();
} }
@ -42,28 +60,13 @@ class AttachmentItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final uuid = Uuid(); if (data!.isMature) {
final heroTag = uuid.v4();
if (data.isMature) {
return _AttachmentItemSensitiveBlur( return _AttachmentItemSensitiveBlur(
child: _buildContent(context, heroTag), child: _buildContent(context),
); );
} }
if (isExpandable) { return _buildContent(context);
return GestureDetector(
child: _buildContent(context, heroTag),
onTap: () {
context.pushTransparentRoute(
AttachmentDetailPopup(data: data, heroTag: heroTag),
rootNavigator: true,
);
},
);
}
return _buildContent(context, heroTag);
} }
} }
@ -91,35 +94,41 @@ class _AttachmentItemSensitiveBlurState
child: Container( child: Container(
color: Colors.black.withOpacity(0.5), color: Colors.black.withOpacity(0.5),
alignment: Alignment.center, alignment: Alignment.center,
child: Column( child: Container(
mainAxisAlignment: MainAxisAlignment.center, constraints: const BoxConstraints(maxWidth: 180),
children: [ child: Column(
const Icon( mainAxisAlignment: MainAxisAlignment.center,
Symbols.visibility_off, children: [
color: Colors.white, const Icon(
size: 32, Symbols.visibility_off,
), color: Colors.white,
const Gap(8), size: 32,
Text('sensitiveContent') ),
.tr() const Gap(8),
.fontSize(20) Text('sensitiveContent', textAlign: TextAlign.center)
.textColor(Colors.white)
.bold(),
Text('sensitiveContentDescription')
.tr()
.fontSize(14)
.textColor(Colors.white.withOpacity(0.8)),
const Gap(16),
InkWell(
child: Text('sensitiveContentReveal')
.tr() .tr()
.textColor(Colors.white), .fontSize(20)
onTap: () { .textColor(Colors.white)
setState(() => _doesShow = !_doesShow); .bold(),
}, Text(
), 'sensitiveContentDescription',
], textAlign: TextAlign.center,
), )
.tr()
.fontSize(14)
.textColor(Colors.white.withOpacity(0.8)),
const Gap(16),
InkWell(
child: Text('sensitiveContentReveal')
.tr()
.textColor(Colors.white),
onTap: () {
setState(() => _doesShow = !_doesShow);
},
),
],
),
).center(),
), ),
), ),
) )
@ -150,3 +159,431 @@ class _AttachmentItemSensitiveBlurState
); );
} }
} }
class _AttachmentItemContentVideo extends StatefulWidget {
final SnAttachment data;
final bool isAutoload;
const _AttachmentItemContentVideo({
super.key,
required this.data,
this.isAutoload = false,
});
@override
State<_AttachmentItemContentVideo> createState() =>
_AttachmentItemContentVideoState();
}
class _AttachmentItemContentVideoState
extends State<_AttachmentItemContentVideo> {
bool _showContent = false;
Player? _videoPlayer;
VideoController? _videoController;
Future<void> _startLoad() async {
setState(() => _showContent = true);
MediaKit.ensureInitialized();
final sn = context.read<SnNetworkProvider>();
final url = sn.getAttachmentUrl(widget.data.rid);
_videoPlayer = Player();
_videoController = VideoController(_videoPlayer!);
_videoPlayer!.open(Media(url), play: !widget.isAutoload);
}
@override
void initState() {
super.initState();
if (widget.isAutoload) _startLoad();
}
@override
Widget build(BuildContext context) {
const labelShadows = <Shadow>[
Shadow(
offset: Offset(1, 1),
blurRadius: 5.0,
color: Color.fromARGB(255, 0, 0, 0),
),
];
final ratio = widget.data.metadata['ratio'] ?? 16 / 9;
final sn = context.read<SnNetworkProvider>();
if (!_showContent) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
child: Stack(
children: [
if (widget.data.metadata['thumbnail'] != null)
AutoResizeUniversalImage(
sn.getAttachmentUrl(widget.data.metadata['thumbnail']),
fit: BoxFit.cover,
)
else
const Center(
child: Icon(Symbols.movie, size: 64),
),
Align(
alignment: Alignment.bottomCenter,
child: IgnorePointer(
child: Container(
height: 56,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Theme.of(context).colorScheme.surface,
Theme.of(context).colorScheme.surface.withOpacity(0),
],
),
),
),
),
),
Positioned(
bottom: 4,
left: 16,
right: 16,
child: SizedBox(
height: 45,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.data.alt,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
shadows: labelShadows,
color: Colors.white,
),
),
Text(
Duration(
milliseconds:
(widget.data.metadata['duration'] ?? 0)
.toInt() *
1000,
).toString(),
style: GoogleFonts.robotoMono(
fontSize: 12,
shadows: labelShadows,
color: Colors.white,
),
),
],
),
),
const Icon(
Symbols.play_arrow,
shadows: labelShadows,
color: Colors.white,
).padding(bottom: 4, right: 8),
],
),
),
),
],
),
onTap: () {
_startLoad();
},
);
} else if (_videoController == null) {
return const Center(
child: CircularProgressIndicator(),
);
}
return Video(
controller: _videoController!,
aspectRatio: ratio,
);
}
@override
void dispose() {
_videoPlayer?.dispose();
super.dispose();
}
}
class _AttachmentItemContentAudio extends StatefulWidget {
final SnAttachment data;
final bool isAutoload;
const _AttachmentItemContentAudio({
super.key,
required this.data,
this.isAutoload = false,
});
@override
State<_AttachmentItemContentAudio> createState() =>
_AttachmentItemContentAudioState();
}
class _AttachmentItemContentAudioState
extends State<_AttachmentItemContentAudio> {
bool _showContent = false;
double? _draggingValue;
bool _isPlaying = false;
Duration _duration = Duration.zero;
Duration _position = Duration.zero;
Duration _bufferedPosition = Duration.zero;
Player? _audioPlayer;
Future<void> _startLoad() async {
setState(() => _showContent = true);
MediaKit.ensureInitialized();
final sn = context.read<SnNetworkProvider>();
final url = sn.getAttachmentUrl(widget.data.rid);
_audioPlayer = Player();
await _audioPlayer!.open(Media(url), play: !widget.isAutoload);
_audioPlayer!.stream.playing.listen((v) => setState(() => _isPlaying = v));
_audioPlayer!.stream.position.listen((v) => setState(() => _position = v));
_audioPlayer!.stream.duration.listen((v) => setState(() => _duration = v));
_audioPlayer!.stream.buffer.listen(
(v) => setState(() => _bufferedPosition = v),
);
}
@override
void initState() {
super.initState();
if (widget.isAutoload) _startLoad();
}
@override
Widget build(BuildContext context) {
const labelShadows = <Shadow>[
Shadow(
offset: Offset(1, 1),
blurRadius: 5.0,
color: Color.fromARGB(255, 0, 0, 0),
),
];
final sn = context.read<SnNetworkProvider>();
if (!_showContent) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
child: Stack(
children: [
if (widget.data.metadata['thumbnail'] != null)
AspectRatio(
aspectRatio: 16 / 9,
child: AutoResizeUniversalImage(
sn.getAttachmentUrl(widget.data.metadata['thumbnail']),
fit: BoxFit.cover,
),
)
else
const Center(
child: Icon(Symbols.radio, size: 64),
),
Align(
alignment: Alignment.bottomCenter,
child: IgnorePointer(
child: Container(
height: 56,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Theme.of(context).colorScheme.surface,
Theme.of(context).colorScheme.surface.withOpacity(0),
],
),
),
),
),
),
Positioned(
bottom: 4,
left: 16,
right: 16,
child: SizedBox(
height: 45,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.data.alt,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
shadows: labelShadows,
color: Colors.white,
),
),
Text(
widget.data.size.toString(),
style: GoogleFonts.robotoMono(
fontSize: 12,
shadows: labelShadows,
color: Colors.white,
),
),
],
),
),
const Icon(
Symbols.play_arrow,
shadows: labelShadows,
color: Colors.white,
).padding(bottom: 4, right: 8),
],
),
),
),
],
),
onTap: () {
_startLoad();
},
);
} else if (_audioPlayer == null) {
return const Center(
child: CircularProgressIndicator(),
);
}
return Stack(
children: [
if (widget.data.metadata['thumbnail'] != null)
AspectRatio(
aspectRatio: 16 / 9,
child: AutoResizeUniversalImage(
sn.getAttachmentUrl(widget.data.metadata['thumbnail']),
fit: BoxFit.cover,
),
),
Container(
constraints: const BoxConstraints(maxWidth: 320),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Symbols.audio_file, size: 32),
const Gap(8),
Text(
widget.data.alt,
style: const TextStyle(fontSize: 13),
textAlign: TextAlign.center,
),
const Gap(12),
Row(
children: [
Expanded(
child: Column(
children: [
SliderTheme(
data: SliderThemeData(
trackHeight: 2,
trackShape: _PlayerProgressTrackShape(),
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 8,
),
overlayShape: SliderComponentShape.noOverlay,
),
child: Slider(
secondaryTrackValue: _bufferedPosition
.inMilliseconds
.abs()
.toDouble(),
value: _draggingValue?.abs() ??
_position.inMilliseconds.toDouble().abs(),
min: 0,
max: math
.max(
_bufferedPosition.inMilliseconds.abs(),
math.max(
_position.inMilliseconds.abs(),
_duration.inMilliseconds.abs(),
),
)
.toDouble(),
onChanged: (value) {
setState(() => _draggingValue = value);
},
onChangeEnd: (value) {
_audioPlayer!.seek(
Duration(milliseconds: value.toInt()),
);
setState(() => _draggingValue = null);
},
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_position.toString(),
style: GoogleFonts.robotoMono(fontSize: 12),
),
Text(
_duration.toString(),
style: GoogleFonts.robotoMono(fontSize: 12),
),
],
).padding(horizontal: 8, vertical: 4),
],
),
),
const Gap(16),
IconButton.filled(
icon: _isPlaying
? const Icon(Symbols.pause)
: const Icon(Symbols.play_arrow),
onPressed: () {
_audioPlayer!.playOrPause();
},
visualDensity: const VisualDensity(
horizontal: -4,
vertical: 0,
),
),
],
),
],
),
).center(),
],
);
}
@override
void dispose() {
_audioPlayer?.dispose();
super.dispose();
}
}
class _PlayerProgressTrackShape extends RoundedRectSliderTrackShape {
@override
Rect getPreferredRect({
required RenderBox parentBox,
Offset offset = Offset.zero,
required SliderThemeData sliderTheme,
bool isEnabled = false,
bool isDiscrete = false,
}) {
final trackHeight = sliderTheme.trackHeight;
final trackLeft = offset.dx;
final trackTop = offset.dy + (parentBox.size.height - trackHeight!) / 2;
final trackWidth = parentBox.size.width;
return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight);
}
}

View File

@ -1,100 +1,173 @@
import 'dart:math' as math; import 'package:dismissible_page/dismissible_page.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:responsive_framework/responsive_framework.dart'; import 'package:responsive_framework/responsive_framework.dart';
import 'package:surface/types/attachment.dart'; import 'package:surface/types/attachment.dart';
import 'package:surface/widgets/attachment/attachment_detail.dart';
import 'package:surface/widgets/attachment/attachment_item.dart'; import 'package:surface/widgets/attachment/attachment_item.dart';
import 'package:uuid/uuid.dart';
class AttachmentList extends StatelessWidget { class AttachmentList extends StatefulWidget {
final List<SnAttachment> data; final List<SnAttachment?> data;
final bool? bordered; final bool bordered;
final bool noGrow;
final double? maxHeight; final double? maxHeight;
final EdgeInsets? listPadding;
const AttachmentList({ const AttachmentList({
super.key, super.key,
required this.data, required this.data,
this.bordered, this.bordered = false,
this.noGrow = false,
this.maxHeight, this.maxHeight,
this.listPadding,
}); });
static const double kMaxItemWidth = 520;
static const BorderRadius kDefaultRadius = static const BorderRadius kDefaultRadius =
BorderRadius.all(Radius.circular(8)); BorderRadius.all(Radius.circular(8));
@override
State<AttachmentList> createState() => _AttachmentListState();
}
class _AttachmentListState extends State<AttachmentList> {
late final List<String> heroTags = List.generate(
widget.data.length,
(_) => const Uuid().v4(),
);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final borderSide = (bordered ?? false) final borderSide = widget.bordered
? BorderSide(width: 1, color: Theme.of(context).dividerColor) ? BorderSide(width: 1, color: Theme.of(context).dividerColor)
: BorderSide.none; : BorderSide.none;
final backgroundColor = Theme.of(context).colorScheme.surfaceContainer;
final constraints = BoxConstraints(
minWidth: 80,
maxHeight: widget.maxHeight ?? double.infinity,
);
if (data.isEmpty) return const SizedBox.shrink(); if (widget.data.isEmpty) return const SizedBox.shrink();
if (data.length == 1) { if (widget.data.length == 1) {
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE)) { return GestureDetector(
return Container( child: Builder(
constraints: BoxConstraints( builder: (context) {
maxHeight: maxHeight ?? double.infinity, if (ResponsiveBreakpoints.of(context).largerThan(MOBILE) ||
maxWidth: math.min( widget.noGrow) {
MediaQuery.of(context).size.width - 20, return Padding(
kMaxItemWidth, // Single child list-like displaying
), padding: widget.listPadding ?? EdgeInsets.zero,
), child: Container(
decoration: BoxDecoration( constraints: constraints,
border: Border(top: borderSide, bottom: borderSide), decoration: BoxDecoration(
borderRadius: kDefaultRadius, color: backgroundColor,
), border: Border(top: borderSide, bottom: borderSide),
child: AspectRatio( borderRadius: AttachmentList.kDefaultRadius,
aspectRatio: data[0].metadata['ratio']?.toDouble() ?? 1, ),
child: ClipRRect( child: AspectRatio(
borderRadius: kDefaultRadius, aspectRatio: widget.data[0]?.metadata['ratio']
child: AttachmentItem(data: data[0], isExpandable: true), ?.toDouble() ??
), switch (
), widget.data[0]?.mimetype.split('/').firstOrNull) {
); 'audio' => 16 / 9,
} 'video' => 16 / 9,
_ => 1,
return Container( },
decoration: BoxDecoration( child: ClipRRect(
border: Border(top: borderSide, bottom: borderSide), borderRadius: AttachmentList.kDefaultRadius,
), child: AttachmentItem(
child: AspectRatio( data: widget.data[0],
aspectRatio: data[0].metadata['ratio']?.toDouble() ?? 1, heroTag: heroTags[0],
child: AttachmentItem(data: data[0], isExpandable: true), ),
), ),
); ),
}
return Container(
constraints: BoxConstraints(maxHeight: maxHeight ?? 320),
child: ScrollConfiguration(
behavior: _AttachmentListScrollBehavior(),
child: ListView.separated(
shrinkWrap: true,
itemCount: data.length,
itemBuilder: (context, idx) {
return Container(
constraints: BoxConstraints(
maxHeight: maxHeight ?? double.infinity,
maxWidth: math.min(
MediaQuery.of(context).size.width - 20,
kMaxItemWidth,
), ),
), );
}
return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor,
border: Border(top: borderSide, bottom: borderSide), border: Border(top: borderSide, bottom: borderSide),
borderRadius: kDefaultRadius,
), ),
child: AspectRatio( child: AspectRatio(
aspectRatio: data[idx].metadata['ratio']?.toDouble() ?? 1, aspectRatio: widget.data[0]?.metadata['ratio']?.toDouble() ?? 1,
child: ClipRRect( child: AttachmentItem(
borderRadius: kDefaultRadius, data: widget.data[0],
child: AttachmentItem(data: data[idx], isExpandable: true), heroTag: heroTags.first,
), ),
), ),
); );
}, },
),
onTap: () {
context.pushTransparentRoute(
AttachmentZoomView(
data: widget.data.where((ele) => ele != null).cast(),
initialIndex: 0,
heroTags: heroTags,
),
backgroundColor: Colors.black.withOpacity(0.7),
rootNavigator: true,
);
},
);
}
return Container(
constraints: BoxConstraints(maxHeight: widget.maxHeight ?? 320),
child: ScrollConfiguration(
behavior: _AttachmentListScrollBehavior(),
child: ListView.separated(
shrinkWrap: true,
itemCount: widget.data.length,
itemBuilder: (context, idx) {
return GestureDetector(
onTap: () {
context.pushTransparentRoute(
AttachmentZoomView(
data: widget.data.where((ele) => ele != null).cast(),
initialIndex: idx,
heroTags: heroTags,
),
backgroundColor: Colors.black.withOpacity(0.7),
rootNavigator: true,
);
},
child: Stack(
children: [
Container(
constraints: constraints,
decoration: BoxDecoration(
color: backgroundColor,
border: Border(top: borderSide, bottom: borderSide),
borderRadius: AttachmentList.kDefaultRadius,
),
child: AspectRatio(
aspectRatio:
widget.data[idx]?.metadata['ratio']?.toDouble() ?? 1,
child: ClipRRect(
borderRadius: AttachmentList.kDefaultRadius,
child: AttachmentItem(
data: widget.data[idx],
heroTag: heroTags[idx],
),
),
),
),
Positioned(
right: 12,
bottom: 12,
child: Chip(
label: Text('${idx + 1}/${widget.data.length}'),
),
),
],
),
);
},
separatorBuilder: (context, index) => const Gap(8), separatorBuilder: (context, index) => const Gap(8),
padding: const EdgeInsets.symmetric(horizontal: 12), padding: widget.listPadding,
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
), ),

View File

@ -0,0 +1,369 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/chat_call.dart';
import 'package:surface/widgets/dialog.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;
Future<bool?> showDisconnectDialog() {
return showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('callDisconnect').tr(),
content: Text('callDisconnectDescription').tr(),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('cancel').tr(),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text('dialogConfirm').tr(),
),
],
),
);
}
void _disconnect() async {
if (await showDisconnectDialog() != true) return;
if (!mounted) return;
final call = context.read<ChatCallProvider>();
if (call.current != null) {
call.disposeRoom();
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
}
}
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 _toggleSpeakerphoneOn() {
_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(
captureScreenAudio: true,
sourceId: source.id,
maxFrameRate: 30.0,
),
);
await _participant.publishVideoTrack(track);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
}
return;
}
if (lkPlatformIs(PlatformType.iOS)) {
var track = await LocalVideoTrack.createScreenShareTrack(
const ScreenShareCaptureOptions(
useiOSBroadcastExtension: true,
captureScreenAudio: 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);
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 10,
),
child: Wrap(
alignment: WrapAlignment.center,
spacing: 5,
runSpacing: 5,
children: [
IconButton(
icon: const Icon(Symbols.exit_to_app),
color: Theme.of(context).colorScheme.onSurface,
onPressed: _disconnect,
),
if (_participant.isMicrophoneEnabled())
if (lkPlatformIs(PlatformType.android))
IconButton(
onPressed: _disableAudio,
icon: const Icon(Symbols.mic),
color: Theme.of(context).colorScheme.onSurface,
tooltip: 'callMicrophoneOff'.tr(),
)
else
PopupMenuButton<MediaDevice>(
icon: const Icon(Symbols.settings_voice),
itemBuilder: (BuildContext context) {
return [
PopupMenuItem<MediaDevice>(
value: null,
onTap: isMuted ? _enableAudio : _disableAudio,
child: ListTile(
leading: const Icon(Symbols.mic_off),
title: Text(isMuted
? 'callMicrophoneOn'.tr()
: 'callMicrophoneOff'.tr()),
),
),
if (_audioInputs != null)
..._audioInputs!.map((device) {
return PopupMenuItem<MediaDevice>(
value: device,
child: ListTile(
leading: (device.deviceId ==
widget.room.selectedAudioInputDeviceId)
? const Icon(Symbols.check_box)
: const Icon(Symbols.check_box_outline_blank),
title: Text(device.label),
),
onTap: () => _selectAudioInput(device),
);
})
];
},
)
else
IconButton(
onPressed: _enableAudio,
icon: const Icon(Symbols.mic_off),
color: Theme.of(context).colorScheme.onSurface,
tooltip: 'callMicrophoneOn'.tr(),
),
if (_participant.isCameraEnabled())
PopupMenuButton<MediaDevice>(
icon: const Icon(Symbols.videocam_sharp),
itemBuilder: (BuildContext context) {
return [
PopupMenuItem<MediaDevice>(
value: null,
onTap: _disableVideo,
child: ListTile(
leading: const Icon(Symbols.videocam_off),
title: Text('callCameraOff'.tr()),
),
),
if (_videoInputs != null)
..._videoInputs!.map((device) {
return PopupMenuItem<MediaDevice>(
value: device,
child: ListTile(
leading: (device.deviceId ==
widget.room.selectedVideoInputDeviceId)
? const Icon(Symbols.check_box)
: const Icon(Symbols.check_box_outline_blank),
title: Text(device.label),
),
onTap: () => _selectVideoInput(device),
);
})
];
},
)
else
IconButton(
onPressed: _enableVideo,
icon: const Icon(Symbols.videocam_off),
color: Theme.of(context).colorScheme.onSurface,
tooltip: 'callCameraOn'.tr(),
),
IconButton(
icon: Icon(_position == CameraPosition.back
? Symbols.video_camera_back
: Symbols.video_camera_front),
color: Theme.of(context).colorScheme.onSurface,
onPressed: () => _toggleCamera(),
tooltip: 'callVideoFlip'.tr(),
),
if (!lkPlatformIs(PlatformType.iOS))
PopupMenuButton<MediaDevice>(
icon: const Icon(Symbols.volume_up),
itemBuilder: (BuildContext context) {
return [
PopupMenuItem<MediaDevice>(
value: null,
child: ListTile(
leading: const Icon(Symbols.speaker),
title: Text('callSpeakerSelect').tr(),
),
),
if (_audioOutputs != null)
..._audioOutputs!.map((device) {
return PopupMenuItem<MediaDevice>(
value: device,
child: ListTile(
leading: (device.deviceId ==
widget.room.selectedAudioOutputDeviceId)
? const Icon(Symbols.check_box)
: const Icon(Symbols.check_box_outline_blank),
title: Text(device.label),
),
onTap: () => _selectAudioOutput(device),
);
})
];
},
),
if (!kIsWeb && Hardware.instance.canSwitchSpeakerphone)
IconButton(
onPressed: _toggleSpeakerphoneOn,
color: Theme.of(context).colorScheme.onSurface,
icon: _speakerphoneOn
? Icon(Symbols.volume_up)
: Icon(Symbols.volume_down),
tooltip: 'callSpeakerphoneToggle'.tr(),
),
if (_participant.isScreenShareEnabled())
IconButton(
icon: const Icon(Symbols.stop_screen_share),
color: Theme.of(context).colorScheme.onSurface,
onPressed: () => _disableScreenShare(),
tooltip: 'callScreenOff'.tr(),
)
else
IconButton(
icon: const Icon(Symbols.screen_share),
color: Theme.of(context).colorScheme.onSurface,
onPressed: () => _enableScreenShare(),
tooltip: 'callScreenOn'.tr(),
),
],
),
);
}
}

View File

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

View File

@ -0,0 +1,242 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:surface/types/account.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/widgets/chat/call/call_no_content.dart';
import 'package:surface/widgets/chat/call/call_participant_info.dart';
import 'package:surface/widgets/chat/call/call_participant_menu.dart';
import 'package:surface/widgets/chat/call/call_participant_stats.dart';
abstract class ParticipantWidget extends StatefulWidget {
static ParticipantWidget widgetFor(ParticipantTrack participantTrack,
{bool isFixed = false, bool showStatsLayer = false}) {
if (participantTrack.participant is LocalParticipant) {
return LocalParticipantWidget(
participantTrack.participant as LocalParticipant,
participantTrack.videoTrack,
isFixed,
participantTrack.isScreenShare,
showStatsLayer,
);
} else if (participantTrack.participant is RemoteParticipant) {
return RemoteParticipantWidget(
participantTrack.participant as RemoteParticipant,
participantTrack.videoTrack,
isFixed,
participantTrack.isScreenShare,
showStatsLayer,
);
}
throw UnimplementedError('Unknown participant type');
}
abstract final Participant participant;
abstract final VideoTrack? videoTrack;
abstract final bool isScreenShare;
abstract final bool isFixed;
abstract final bool showStatsLayer;
final VideoQuality quality;
const ParticipantWidget({
super.key,
this.quality = VideoQuality.MEDIUM,
});
}
class LocalParticipantWidget extends ParticipantWidget {
@override
final LocalParticipant participant;
@override
final VideoTrack? videoTrack;
@override
final bool isFixed;
@override
final bool isScreenShare;
@override
final bool showStatsLayer;
const LocalParticipantWidget(
this.participant,
this.videoTrack,
this.isFixed,
this.isScreenShare,
this.showStatsLayer, {
super.key,
});
@override
State<StatefulWidget> createState() => _LocalParticipantWidgetState();
}
class RemoteParticipantWidget extends ParticipantWidget {
@override
final RemoteParticipant participant;
@override
final VideoTrack? videoTrack;
@override
final bool isFixed;
@override
final bool isScreenShare;
@override
final bool showStatsLayer;
const RemoteParticipantWidget(
this.participant,
this.videoTrack,
this.isFixed,
this.isScreenShare,
this.showStatsLayer, {
super.key,
});
@override
State<StatefulWidget> createState() => _RemoteParticipantWidgetState();
}
abstract class _ParticipantWidgetState<T extends ParticipantWidget>
extends State<T> {
VideoTrack? get _activeVideoTrack;
TrackPublication? get _firstAudioPublication;
SnAccount? _userinfoMetadata;
@override
void initState() {
super.initState();
widget.participant.addListener(onParticipantChanged);
onParticipantChanged();
}
@override
void dispose() {
widget.participant.removeListener(onParticipantChanged);
super.dispose();
}
@override
void didUpdateWidget(covariant T oldWidget) {
oldWidget.participant.removeListener(onParticipantChanged);
widget.participant.addListener(onParticipantChanged);
onParticipantChanged();
super.didUpdateWidget(oldWidget);
}
void onParticipantChanged() {
setState(() {
if (widget.participant.metadata != null) {
_userinfoMetadata = SnAccount.fromJson(
jsonDecode(widget.participant.metadata!),
);
}
});
}
@override
Widget build(BuildContext ctx) {
return Stack(
children: [
_activeVideoTrack != null && !_activeVideoTrack!.muted
? VideoTrackRenderer(
_activeVideoTrack!,
fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
)
: NoContentWidget(
userinfo: _userinfoMetadata,
isFixed: widget.isFixed,
isSpeaking: widget.participant.isSpeaking,
),
if (widget.showStatsLayer)
Positioned(
top: 30,
right: 30,
child: ParticipantStatsWidget(participant: widget.participant),
),
Align(
alignment: Alignment.bottomCenter,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
ParticipantInfoWidget(
title: widget.participant.name.isNotEmpty
? widget.participant.name
: widget.participant.identity,
audioAvailable: _firstAudioPublication?.muted == false &&
_firstAudioPublication?.subscribed == true,
connectionQuality: widget.participant.connectionQuality,
isScreenShare: widget.isScreenShare,
),
],
),
),
],
);
}
}
class _LocalParticipantWidgetState
extends _ParticipantWidgetState<LocalParticipantWidget> {
@override
LocalTrackPublication<LocalAudioTrack>? get _firstAudioPublication =>
widget.participant.audioTrackPublications.firstOrNull;
@override
VideoTrack? get _activeVideoTrack => widget.videoTrack;
}
class _RemoteParticipantWidgetState
extends _ParticipantWidgetState<RemoteParticipantWidget> {
@override
RemoteTrackPublication<RemoteAudioTrack>? get _firstAudioPublication =>
widget.participant.audioTrackPublications.firstOrNull;
@override
VideoTrack? get _activeVideoTrack => widget.videoTrack;
}
class InteractiveParticipantWidget extends StatelessWidget {
final double? width;
final double? height;
final Color? color;
final bool isFixedAvatar;
final ParticipantTrack participant;
final Function() onTap;
const InteractiveParticipantWidget({
super.key,
this.width,
this.height,
this.color,
this.isFixedAvatar = false,
required this.participant,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
child: Container(
width: width,
height: height,
color: color,
child: ParticipantWidget.widgetFor(participant, isFixed: isFixedAvatar),
),
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,79 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class ParticipantInfoWidget extends StatelessWidget {
final String? title;
final bool audioAvailable;
final ConnectionQuality connectionQuality;
final bool isScreenShare;
const ParticipantInfoWidget({
super.key,
this.title,
this.audioAvailable = true,
this.connectionQuality = ConnectionQuality.unknown,
this.isScreenShare = false,
});
@override
Widget build(BuildContext context) => Container(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
padding: const EdgeInsets.symmetric(
vertical: 7,
horizontal: 10,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (title != null)
Flexible(
child: Text(
title!,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white),
),
),
const Gap(5),
isScreenShare
? const Icon(
Symbols.monitor,
color: Colors.white,
size: 16,
)
: Icon(
audioAvailable ? Symbols.mic : Symbols.mic_off,
color: audioAvailable ? Colors.white : Colors.red,
size: 16,
),
const Gap(3),
if (connectionQuality != ConnectionQuality.unknown)
Icon(
{
ConnectionQuality.excellent: Symbols.signal_cellular_alt,
ConnectionQuality.good: Symbols.signal_cellular_alt_2_bar,
ConnectionQuality.poor: Symbols.signal_cellular_alt_1_bar,
}[connectionQuality],
color: {
ConnectionQuality.excellent: Colors.green,
ConnectionQuality.good: Colors.orange,
ConnectionQuality.poor: Colors.red,
}[connectionQuality],
size: 16,
)
else
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
).padding(all: 3),
],
),
);
}

View File

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

View File

@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:surface/types/chat.dart';
class ParticipantStatsWidget extends StatefulWidget {
const ParticipantStatsWidget({super.key, required this.participant});
final Participant participant;
@override
State<StatefulWidget> createState() => _ParticipantStatsWidgetState();
}
class _ParticipantStatsWidgetState extends State<ParticipantStatsWidget> {
List<EventsListener<TrackEvent>> listeners = [];
ParticipantStatsType statsType = ParticipantStatsType.unknown;
Map<String, String> stats = {};
void _setUpListener(Track track) {
var listener = track.createListener();
listeners.add(listener);
if (track is LocalVideoTrack) {
statsType = ParticipantStatsType.localVideoSender;
listener.on<VideoSenderStatsEvent>((event) {
setState(() {
stats['video tx'] = 'total sent ${event.currentBitrate.toInt()} kpbs';
event.stats.forEach((key, value) {
stats['layer-$key'] =
'${value.frameWidth ?? 0}x${value.frameHeight ?? 0} ${value.framesPerSecond?.toDouble() ?? 0} fps, ${event.bitrateForLayers[key] ?? 0} kbps';
});
var firstStats =
event.stats['f'] ?? event.stats['h'] ?? event.stats['q'];
if (firstStats != null) {
stats['encoder'] = firstStats.encoderImplementation ?? '';
stats['video codec'] =
'${firstStats.mimeType}, ${firstStats.clockRate}hz, pt: ${firstStats.payloadType}';
stats['qualityLimitationReason'] =
firstStats.qualityLimitationReason ?? '';
}
});
});
} else if (track is RemoteVideoTrack) {
statsType = ParticipantStatsType.remoteVideoReceiver;
listener.on<VideoReceiverStatsEvent>((event) {
setState(() {
stats['video rx'] = '${event.currentBitrate.toInt()} kpbs';
stats['video codec'] =
'${event.stats.mimeType}, ${event.stats.clockRate}hz, pt: ${event.stats.payloadType}';
stats['video size'] =
'${event.stats.frameWidth}x${event.stats.frameHeight} ${event.stats.framesPerSecond?.toDouble()}fps';
stats['video jitter'] = '${event.stats.jitter} s';
stats['video decoder'] = '${event.stats.decoderImplementation}';
stats['video packets lost'] = '${event.stats.packetsLost}';
stats['video packets received'] = '${event.stats.packetsReceived}';
stats['video frames received'] = '${event.stats.framesReceived}';
stats['video frames decoded'] = '${event.stats.framesDecoded}';
stats['video frames dropped'] = '${event.stats.framesDropped}';
});
});
} else if (track is LocalAudioTrack) {
statsType = ParticipantStatsType.localAudioSender;
listener.on<AudioSenderStatsEvent>((event) {
setState(() {
stats['audio tx'] = '${event.currentBitrate.toInt()} kpbs';
stats['audio codec'] =
'${event.stats.mimeType}, ${event.stats.clockRate}hz, ${event.stats.channels}ch, pt: ${event.stats.payloadType}';
});
});
} else if (track is RemoteAudioTrack) {
statsType = ParticipantStatsType.remoteAudioReceiver;
listener.on<AudioReceiverStatsEvent>((event) {
setState(() {
stats['audio rx'] = '${event.currentBitrate.toInt()} kpbs';
stats['audio codec'] =
'${event.stats.mimeType}, ${event.stats.clockRate}hz, ${event.stats.channels}ch, pt: ${event.stats.payloadType}';
stats['audio jitter'] = '${event.stats.jitter} s';
stats['audio concealed samples'] =
'${event.stats.concealedSamples} / ${event.stats.concealmentEvents}';
stats['audio packets lost'] = '${event.stats.packetsLost}';
stats['audio packets received'] = '${event.stats.packetsReceived}';
});
});
}
}
onParticipantChanged() {
for (var element in listeners) {
element.dispose();
}
listeners.clear();
for (var track in [
...widget.participant.videoTrackPublications,
...widget.participant.audioTrackPublications
]) {
if (track.track != null) {
_setUpListener(track.track!);
}
}
}
@override
void initState() {
super.initState();
widget.participant.addListener(onParticipantChanged);
onParticipantChanged();
}
@override
void deactivate() {
for (var element in listeners) {
element.dispose();
}
widget.participant.removeListener(onParticipantChanged);
super.deactivate();
}
num sendBitrate = 0;
@override
Widget build(BuildContext context) {
return Container(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 8,
),
child: Column(
children:
stats.entries.map((e) => Text('${e.key}: ${e.value}')).toList(),
),
);
}
}

View File

@ -0,0 +1,191 @@
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/chat_call.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/widgets/dialog.dart';
class ChatCallPrejoinPopup extends StatefulWidget {
final SnChatCall ongoingCall;
final SnChannel channel;
final void Function() onJoin;
const ChatCallPrejoinPopup({
super.key,
required this.ongoingCall,
required this.channel,
required this.onJoin,
});
@override
State<ChatCallPrejoinPopup> createState() => _ChatCallPrejoinPopupState();
}
class _ChatCallPrejoinPopupState extends State<ChatCallPrejoinPopup> {
bool _isBusy = false;
late final ChatCallProvider _call = context.read<ChatCallProvider>();
void _performJoin() async {
setState(() => _isBusy = true);
_call.setCall(widget.ongoingCall, widget.channel);
_call.setIsBusy(true);
try {
final resp = await _call.getRoomToken();
final token = resp.$1;
final endpoint = resp.$2;
_call.initRoom();
_call.setupRoomListeners(
onDisconnected: (reason) {
context.showSnackbar(
'callDisconnected'.tr(args: [reason.toString()]),
);
},
);
await _call.joinRoom(endpoint, token);
widget.onJoin();
if (!mounted) return;
Navigator.pop(context);
} catch (e) {
if (!mounted) return;
context.showErrorDialog(e);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
final call = context.read<ChatCallProvider>();
call.checkPermissions().then((_) {
call.initHardware();
});
super.initState();
}
@override
Widget build(BuildContext context) {
final call = context.read<ChatCallProvider>();
return ListenableBuilder(
listenable: call,
builder: (context, _) {
return Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 320),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('callMicrophone').tr(),
Switch(
value: call.enableAudio,
onChanged: null,
),
],
).padding(bottom: 5),
DropdownButtonHideUnderline(
child: DropdownButton2<MediaDevice>(
isExpanded: true,
disabledHint: Text('callMicrophoneDisabled').tr(),
hint: Text('callMicrophoneSelect').tr(),
items: call.enableAudio
? call.audioInputs
.map(
(item) => DropdownMenuItem<MediaDevice>(
value: item,
child: Text(item.label),
),
)
.toList()
.cast<DropdownMenuItem<MediaDevice>>()
: [],
value: call.audioDevice,
onChanged: (MediaDevice? value) async {
if (value != null) {
call.setAudioDevice(value);
await call.changeLocalAudioTrack();
}
},
buttonStyleData: const ButtonStyleData(
height: 40,
width: 320,
),
),
).padding(bottom: 25),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('callCamera').tr(),
Switch(
value: call.enableVideo,
onChanged: (value) => call.setEnableAudio(value),
),
],
).padding(bottom: 5),
DropdownButtonHideUnderline(
child: DropdownButton2<MediaDevice>(
isExpanded: true,
disabledHint: Text('callCameraDisabled').tr(),
hint: Text('callCameraSelect').tr(),
items: call.enableVideo
? call.videoInputs
.map(
(item) => DropdownMenuItem<MediaDevice>(
value: item,
child: Text(item.label),
),
)
.toList()
.cast<DropdownMenuItem<MediaDevice>>()
: [],
value: call.videoDevice,
onChanged: (MediaDevice? value) async {
if (value != null) {
call.setVideoDevice(value);
await call.changeLocalVideoTrack();
}
},
buttonStyleData: const ButtonStyleData(
height: 40,
width: 320,
),
),
).padding(bottom: 25),
if (_isBusy)
const Center(child: CircularProgressIndicator())
else
ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize: const Size(320, 56),
),
onPressed: _isBusy ? null : _performJoin,
child: Text('callJoin').tr(),
),
],
),
),
);
},
);
}
@override
void dispose() {
_call
..deactivateHardware()
..disposeHardware();
super.dispose();
}
}

View File

@ -0,0 +1,280 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_context_menu/flutter_context_menu.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/attachment/attachment_list.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:swipe_to/swipe_to.dart';
class ChatMessage extends StatelessWidget {
final SnChatMessage data;
final bool isCompact;
final bool isMerged;
final bool hasMerged;
final bool isPending;
final Function(SnChatMessage)? onReply;
final Function(SnChatMessage)? onEdit;
final Function(SnChatMessage)? onDelete;
const ChatMessage({
super.key,
required this.data,
this.isCompact = false,
this.isMerged = false,
this.hasMerged = false,
this.isPending = false,
this.onReply,
this.onEdit,
this.onDelete,
});
@override
Widget build(BuildContext context) {
final ua = context.read<UserProvider>();
final ud = context.read<UserDirectoryProvider>();
final user = ud.getAccountFromCache(data.sender.accountId);
final isOwner = ua.isAuthorized && data.sender.accountId == ua.user?.id;
final dateFormatter = DateFormat('MM/dd HH:mm');
return SwipeTo(
key: Key('chat-message-${data.id}'),
iconOnLeftSwipe: Symbols.reply,
iconOnRightSwipe: Symbols.edit,
swipeSensitivity: 20,
onLeftSwipe: onReply != null ? (_) => onReply!(data) : null,
onRightSwipe: onEdit != null ? (_) => onEdit!(data) : null,
child: ContextMenuRegion(
contextMenu: ContextMenu(
entries: [
MenuHeader(text: "eventResourceTag".tr(args: ['#${data.id}'])),
if (onReply != null)
MenuItem(
label: 'reply'.tr(),
icon: Symbols.reply,
onSelected: () {
onReply!(data);
},
),
if (isOwner && onEdit != null)
MenuItem(
label: 'edit'.tr(),
icon: Symbols.edit,
onSelected: () {
onEdit!(data);
},
),
if (isOwner && onDelete != null)
MenuItem(
label: 'delete'.tr(),
icon: Symbols.delete,
onSelected: () {
onDelete!(data);
},
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isMerged && !isCompact)
AccountImage(
content: user?.avatar,
)
else if (isMerged)
const Gap(40),
const Gap(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isMerged)
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
if (isCompact)
AccountImage(
content: user?.avatar,
radius: 12,
).padding(right: 6),
Text(
(data.sender.nick?.isNotEmpty ?? false)
? data.sender.nick!
: user?.nick ?? 'unknown',
).bold(),
const Gap(6),
Text(
dateFormatter.format(data.createdAt.toLocal()),
).fontSize(13),
],
),
if (isCompact) const Gap(4),
if (data.preload?.quoteEvent != null)
StyledWidget(Container(
decoration: BoxDecoration(
borderRadius:
const BorderRadius.all(Radius.circular(8)),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
),
padding: const EdgeInsets.only(
left: 4,
right: 4,
top: 8,
bottom: 6,
),
child: ChatMessage(
data: data.preload!.quoteEvent!,
isCompact: true,
onReply: onReply,
onEdit: onEdit,
onDelete: onDelete,
),
)).padding(bottom: 4, top: isMerged ? 4 : 2),
switch (data.type) {
'messages.new' => _ChatMessageText(data: data),
_ => _ChatMessageSystemNotify(data: data),
},
],
),
)
],
).opacity(isPending ? 0.5 : 1),
if (data.preload?.attachments?.isNotEmpty ?? false)
AttachmentList(
data: data.preload!.attachments!,
bordered: true,
noGrow: true,
maxHeight: 520,
listPadding: const EdgeInsets.only(top: 8),
),
if (!hasMerged && !isCompact)
const Gap(12)
else if (!isCompact)
const Gap(6),
],
),
),
);
}
}
class _ChatMessageText extends StatelessWidget {
final SnChatMessage data;
const _ChatMessageText({super.key, required this.data});
@override
Widget build(BuildContext context) {
if (data.body['text'] != null && data.body['text'].isNotEmpty) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MarkdownTextContent(
content: data.body['text'],
isAutoWarp: true,
),
if (data.updatedAt != data.createdAt)
Text(
'messageEditedHint'.tr(),
).fontSize(13).opacity(0.75),
],
);
} else if (data.body['attachments']?.isNotEmpty) {
return Row(
children: [
const Icon(Symbols.file_present, size: 20),
const Gap(4),
Text(
'messageFileHint'.plural(
data.body['attachments']!.length,
),
),
],
).opacity(0.75);
}
return const SizedBox.shrink();
}
}
class _ChatMessageSystemNotify extends StatelessWidget {
final SnChatMessage data;
const _ChatMessageSystemNotify({super.key, required this.data});
String _formatDuration(Duration duration) {
String negativeSign = duration.isNegative ? '-' : '';
String twoDigits(int n) => n.toString().padLeft(2, '0');
String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60).abs());
String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60).abs());
return '$negativeSign${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds';
}
@override
Widget build(BuildContext context) {
switch (data.type) {
case 'messages.edit':
return Row(
children: [
const Icon(Symbols.edit, size: 20),
const Gap(4),
Text(
'messageEdited'.tr(args: ['#${data.relatedEventId}']),
),
],
).opacity(0.75);
case 'messages.delete':
return Row(
children: [
const Icon(Symbols.delete, size: 20),
const Gap(4),
Text(
'messageDeleted'.tr(args: ['#${data.relatedEventId}']),
),
],
).opacity(0.75);
case 'calls.start':
return Row(
children: [
const Icon(Symbols.call, size: 20),
const Gap(4),
Text(
'callMessageStarted'.tr(),
),
],
).opacity(0.75);
case 'calls.end':
return Row(
children: [
const Icon(Symbols.call_end, size: 20),
const Gap(4),
Text(
'callMessageEnded'.tr(args: [
_formatDuration(Duration(seconds: data.body['last'])),
]),
),
],
).opacity(0.75);
default:
return Row(
children: [
const Icon(Symbols.info, size: 20),
const Gap(4),
Text('messageUnsupported'.tr(args: [data.type])),
],
).opacity(0.75);
}
}
}

View File

@ -0,0 +1,338 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:image_picker/image_picker.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:pasteboard/pasteboard.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/chat_message_controller.dart';
import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:surface/widgets/post/post_media_pending_list.dart';
class ChatMessageInput extends StatefulWidget {
final ChatMessageController controller;
const ChatMessageInput({super.key, required this.controller});
@override
State<ChatMessageInput> createState() => ChatMessageInputState();
}
class ChatMessageInputState extends State<ChatMessageInput> {
bool _isBusy = false;
double? _progress;
SnChatMessage? _replyingMessage, _editingMessage;
final TextEditingController _contentController = TextEditingController();
final FocusNode _focusNode = FocusNode();
void setReply(SnChatMessage? value) {
setState(() => _replyingMessage = value);
}
void setEdit(SnChatMessage? value) {
_contentController.text = value?.body['text'] ?? '';
setState(() => _editingMessage = value);
}
Future<void> deleteMessage(SnChatMessage message) async {
final confirm = await context.showConfirmDialog(
'messageDelete'.tr(args: ['#${message.id}']),
'messageDeleteDescription'.tr(),
);
if (!confirm) return;
if (!mounted) return;
setState(() => _isBusy = true);
await widget.controller.deleteMessage(message);
if (!mounted) return;
setState(() => _isBusy = false);
}
Future<void> _sendMessage() async {
if (_isBusy) return;
final attach = context.read<SnAttachmentProvider>();
setState(() => _isBusy = true);
try {
for (int i = 0; i < _attachments.length; i++) {
final media = _attachments[i];
if (media.attachment != null) continue; // Already uploaded, skip
if (media.isEmpty) continue; // Nothing to do, skip
final place = await attach.chunkedUploadInitialize(
(await media.length())!,
media.name,
'interactive',
null,
mimetype: media.raw != null && media.type == PostWriteMediaType.image
? 'image/png'
: null,
);
final item = await attach.chunkedUploadParts(
media.toFile()!,
place.$1,
place.$2,
onProgress: (progress) {
// Calculate overall progress for attachments
setState(() {
progress = (i + progress) / _attachments.length;
});
},
);
_attachments[i] = PostWriteMedia(item);
}
} catch (err) {
if (!mounted) return;
setState(() => _isBusy = false);
context.showErrorDialog(err);
return;
}
attach.putCache(
_attachments.where((e) => e.attachment != null).map((e) => e.attachment!),
noCheck: true,
);
// Send the message
// NOTICE This future should not be awaited, so that the message can be sent in the background and the user can continue to type
widget.controller.sendMessage(
'messages.new',
_contentController.text,
attachments: _attachments
.where((e) => e.attachment != null)
.map((e) => e.attachment!.rid)
.toList(),
relatedId: _editingMessage?.id,
quoteId: _replyingMessage?.id,
editingMessage: _editingMessage,
);
_contentController.clear();
_attachments.clear();
_editingMessage = null;
_replyingMessage = null;
setState(() => _isBusy = false);
}
final List<PostWriteMedia> _attachments = List.empty(growable: true);
final _imagePicker = ImagePicker();
void _selectMedia() async {
final result = await _imagePicker.pickMultipleMedia();
if (result.isEmpty) return;
_attachments.addAll(
result.map((e) => PostWriteMedia.fromFile(e)),
);
setState(() {});
}
void _pasteMedia() async {
final imageBytes = await Pasteboard.image;
if (imageBytes == null) return;
_attachments.add(
PostWriteMedia.fromBytes(
imageBytes,
'attachmentPastedImage'.tr(),
PostWriteMediaType.image,
),
);
setState(() {});
}
@override
void dispose() {
_contentController.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_isBusy && _progress != null)
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: _progress),
duration: Duration(milliseconds: 300),
builder: (context, value, _) =>
LinearProgressIndicator(value: value, minHeight: 2),
)
else if (_isBusy)
const LinearProgressIndicator(value: null, minHeight: 2),
Padding(
padding: _attachments.isNotEmpty
? const EdgeInsets.only(top: 8)
: EdgeInsets.zero,
child: PostMediaPendingList(
attachments: _attachments,
isBusy: _isBusy,
onUpdate: (idx, updatedMedia) async {
setState(() => _attachments[idx] = updatedMedia);
},
onRemove: (idx) async {
setState(() => _attachments.removeAt(idx));
},
onUpdateBusy: (state) => setState(() => _isBusy = state),
),
).height(_attachments.isNotEmpty ? 80 + 8 : 0, animate: true).animate(
const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Padding(
padding: _replyingMessage != null
? const EdgeInsets.only(top: 8)
: EdgeInsets.zero,
child: _replyingMessage != null
? MaterialBanner(
padding: const EdgeInsets.only(left: 16.0),
leading: const Icon(Symbols.reply),
content: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_replyingMessage?.body['text'] != null)
MarkdownTextContent(
content: _replyingMessage?.body['text'],
),
],
),
),
actions: [
TextButton(
child: Text('cancel'.tr()),
onPressed: () {
setState(() => _replyingMessage = null);
},
),
],
)
: const SizedBox.shrink(),
),
).height(_replyingMessage != null ? 54 + 8 : 0, animate: true).animate(
const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Padding(
padding: _editingMessage != null
? const EdgeInsets.only(top: 8)
: EdgeInsets.zero,
child: _editingMessage != null
? MaterialBanner(
padding: const EdgeInsets.only(left: 16.0),
leading: const Icon(Symbols.edit),
content: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_editingMessage?.body['text'] != null)
MarkdownTextContent(
content: _editingMessage?.body['text'],
),
],
),
),
actions: [
TextButton(
child: Text('cancel'.tr()),
onPressed: () {
setState(() => _editingMessage = null);
},
),
],
)
: const SizedBox.shrink(),
),
).height(_editingMessage != null ? 54 + 8 : 0, animate: true).animate(
const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
SizedBox(
height: 56,
child: Row(
children: [
Expanded(
child: TextField(
focusNode: _focusNode,
controller: _contentController,
decoration: InputDecoration(
isCollapsed: true,
hintText: 'fieldChatMessage'.tr(args: [
widget.controller.channel?.name ?? 'loading'.tr()
]),
border: InputBorder.none,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (_) {
if (_isBusy) return;
_sendMessage();
_focusNode.requestFocus();
},
),
),
const Gap(8),
PopupMenuButton(
icon: Icon(
Symbols.add_photo_alternate,
color: Theme.of(context).colorScheme.primary,
),
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.photo_library),
const Gap(16),
Text('addAttachmentFromAlbum').tr(),
],
),
onTap: () {
_selectMedia();
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.content_paste),
const Gap(16),
Text('addAttachmentFromClipboard').tr(),
],
),
onTap: () {
_pasteMedia();
},
),
],
),
IconButton(
onPressed: _isBusy ? null : _sendMessage,
icon: Icon(
Symbols.send,
color: Theme.of(context).colorScheme.primary,
),
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
],
),
).padding(horizontal: 16),
],
);
}
}

View File

@ -0,0 +1,62 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/websocket.dart';
class ConnectionIndicator extends StatelessWidget {
const ConnectionIndicator({super.key});
@override
Widget build(BuildContext context) {
final ws = context.watch<WebSocketProvider>();
return ListenableBuilder(
listenable: ws,
builder: (context, _) {
final ua = context.read<UserProvider>();
return GestureDetector(
child: Container(
padding: EdgeInsets.only(
bottom: 8,
top: MediaQuery.of(context).padding.top + 8,
left: 24,
right: 24,
),
color: Theme.of(context).colorScheme.secondaryContainer,
child: ua.isAuthorized
? Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (ws.isBusy)
Text('serverConnecting').tr().textColor(
Theme.of(context).colorScheme.onSecondaryContainer)
else if (!ws.isConnected)
Text('serverDisconnected').tr().textColor(
Theme.of(context).colorScheme.onSecondaryContainer),
],
)
: const SizedBox.shrink(),
)
.height(
(ws.isBusy || !ws.isConnected) && ua.isAuthorized
? MediaQuery.of(context).padding.top + 36
: 0,
animate: true)
.animate(
const Duration(milliseconds: 300),
Curves.easeInOut,
),
onTap: () {
if (!ws.isConnected && !ws.isBusy) {
ws.connect();
}
},
);
},
);
}
}

View File

@ -3,55 +3,98 @@ import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:responsive_framework/responsive_framework.dart';
class AppBackground extends StatelessWidget { class AppBackground extends StatelessWidget {
final Widget child; final Widget child;
const AppBackground({super.key, required this.child}); final bool isLessOptimization;
final bool isRoot;
const AppBackground({
super.key,
required this.child,
this.isLessOptimization = false,
this.isRoot = false,
});
Widget _buildWithBackgroundImage(
BuildContext context,
File imageFile,
Widget child,
) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
if (isLessOptimization) {
final size = MediaQuery.of(context).size;
return Container(
color: Theme.of(context).colorScheme.surface,
child: Container(
decoration: BoxDecoration(
backgroundBlendMode: BlendMode.darken,
color: Theme.of(context).colorScheme.surface,
image: DecorationImage(
opacity: 0.2,
image: ResizeImage(
FileImage(imageFile),
width: (size.width * devicePixelRatio).round(),
height: (size.height * devicePixelRatio).round(),
policy: ResizeImagePolicy.fit,
),
fit: BoxFit.cover,
),
),
child: child,
),
);
}
return Container(
color: Theme.of(context).colorScheme.surface,
child: LayoutBuilder(
builder: (context, constraints) {
return Container(
decoration: BoxDecoration(
backgroundBlendMode: BlendMode.darken,
color: Theme.of(context).colorScheme.surface,
image: DecorationImage(
opacity: 0.2,
image: ResizeImage(
FileImage(imageFile),
width: (constraints.maxWidth * devicePixelRatio).round(),
height: (constraints.maxHeight * devicePixelRatio).round(),
policy: ResizeImagePolicy.fit,
),
fit: BoxFit.cover,
),
),
child: child,
);
},
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return ScaffoldMessenger( return ScaffoldMessenger(
child: FutureBuilder( child: FutureBuilder(
future: future:
kIsWeb ? Future.value(null) : getApplicationDocumentsDirectory(), kIsWeb ? Future.value(null) : getApplicationDocumentsDirectory(),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData) { if (isRoot ||
final path = '${snapshot.data!.path}/app_background_image'; ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)) {
final file = File(path); if (snapshot.hasData) {
if (file.existsSync()) { final path = '${snapshot.data!.path}/app_background_image';
return Container( final file = File(path);
color: Theme.of(context).colorScheme.surface, if (file.existsSync()) {
child: LayoutBuilder( return _buildWithBackgroundImage(context, file, child);
builder: (context, constraints) { }
return Container(
decoration: BoxDecoration(
backgroundBlendMode: BlendMode.darken,
color: Theme.of(context).colorScheme.surface,
image: DecorationImage(
opacity: 0.2,
image: ResizeImage(
FileImage(file),
width: (constraints.maxWidth * devicePixelRatio)
.round(),
height: (constraints.maxHeight * devicePixelRatio)
.round(),
policy: ResizeImagePolicy.fit,
),
fit: BoxFit.cover,
),
),
child: child,
);
},
),
);
} }
} }
return Material( return Material(
color: Theme.of(context).colorScheme.surface, color: isRoot
? Theme.of(context).colorScheme.surface
: Colors.transparent,
child: child, child: child,
); );
}, },

View File

@ -1,5 +1,3 @@
import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -9,7 +7,8 @@ import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/navigation.dart'; import 'package:surface/providers/navigation.dart';
class AppNavigationDrawer extends StatefulWidget { class AppNavigationDrawer extends StatefulWidget {
const AppNavigationDrawer({super.key}); final double? elevation;
const AppNavigationDrawer({super.key, this.elevation});
@override @override
State<AppNavigationDrawer> createState() => _AppNavigationDrawerState(); State<AppNavigationDrawer> createState() => _AppNavigationDrawerState();
@ -30,8 +29,8 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final nav = context.watch<NavigationProvider>(); final nav = context.watch<NavigationProvider>();
final backgroundColor = ResponsiveBreakpoints.of(context).largerThan(MOBILE) final backgroundColor = ResponsiveBreakpoints.of(context).largerThan(TABLET)
? Theme.of(context).colorScheme.surface ? Colors.transparent
: null; : null;
return ListenableBuilder( return ListenableBuilder(
@ -43,6 +42,7 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
]; ];
return NavigationDrawer( return NavigationDrawer(
elevation: widget.elevation,
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
selectedIndex: nav.currentIndex, selectedIndex: nav.currentIndex,
children: [ children: [
@ -51,13 +51,13 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Solar Network').bold(), Text('Solar Network').bold(),
Text('Solar Network 2.0α').fontSize(12).textColor( Text('Canary Preview 2.0α').fontSize(12).textColor(
Theme.of(context).colorScheme.onSurface.withOpacity(0.5)), Theme.of(context).colorScheme.onSurface.withOpacity(0.5)),
], ],
).padding( ).padding(
horizontal: 32, horizontal: 32,
top: math.max(MediaQuery.of(context).padding.top, 16), top: MediaQuery.of(context).padding.top > 16 ? 8 : 16,
bottom: 16, bottom: 8,
), ),
...destinations.where((ele) => ele.isPinned).map((ele) { ...destinations.where((ele) => ele.isPinned).map((ele) {
return NavigationDrawerDestination( return NavigationDrawerDestination(

View File

@ -0,0 +1,68 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/navigation.dart';
class AppRailNavigation extends StatefulWidget {
const AppRailNavigation({super.key});
@override
State<AppRailNavigation> createState() => _AppRailNavigationState();
}
class _AppRailNavigationState extends State<AppRailNavigation> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context
.read<NavigationProvider>()
.autoDetectIndex(GoRouter.maybeOf(context));
});
}
@override
Widget build(BuildContext context) {
final nav = context.watch<NavigationProvider>();
return ListenableBuilder(
listenable: nav,
builder: (context, _) {
final destinations =
nav.destinations.where((ele) => ele.isPinned).toList();
return NavigationRail(
selectedIndex: nav.currentIndex,
destinations: [
...destinations.where((ele) => ele.isPinned).map((ele) {
return NavigationRailDestination(
icon: ele.icon,
label: Text(ele.label).tr(),
);
}),
],
trailing: Expanded(
child: Align(
alignment: Alignment.bottomCenter,
child: StyledWidget(
IconButton(
icon: const Icon(Symbols.menu),
onPressed: () {
Scaffold.of(context).openDrawer();
},
),
).padding(bottom: 16),
),
),
onDestinationSelected: (idx) {
nav.setIndex(idx);
GoRouter.of(context).goNamed(destinations[idx].screen);
},
);
},
);
}
}

View File

@ -2,76 +2,104 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:responsive_framework/responsive_framework.dart'; import 'package:responsive_framework/responsive_framework.dart';
import 'package:surface/providers/navigation.dart';
import 'package:surface/widgets/connection_indicator.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_background.dart'; import 'package:surface/widgets/navigation/app_background.dart';
import 'package:surface/widgets/navigation/app_bottom_navigation.dart'; import 'package:surface/widgets/navigation/app_bottom_navigation.dart';
import 'package:surface/widgets/navigation/app_drawer_navigation.dart'; import 'package:surface/widgets/navigation/app_drawer_navigation.dart';
import 'package:surface/widgets/navigation/app_rail_navigation.dart';
class AppScaffold extends StatelessWidget { class AppPageScaffold extends StatelessWidget {
final PreferredSizeWidget? appBar;
final FloatingActionButtonLocation? floatingActionButtonLocation;
final Widget? floatingActionButton;
final String? title; final String? title;
final Widget? body; final Widget? body;
final bool autoImplyAppBar; final bool showAppBar;
final bool showBottomNavigation; final bool showBottomNavigation;
final bool showDrawer; const AppPageScaffold({
const AppScaffold({
super.key, super.key,
this.appBar,
this.floatingActionButton,
this.floatingActionButtonLocation,
this.title, this.title,
this.body, this.body,
this.autoImplyAppBar = false, this.showAppBar = true,
this.showBottomNavigation = false, this.showBottomNavigation = false,
this.showDrawer = false,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isShowDrawer = showDrawer
? ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)
: false;
final isShowBottomNavigation = (showBottomNavigation)
? ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)
: false;
final state = GoRouter.maybeOf(context); final state = GoRouter.maybeOf(context);
final routeName =
state?.routerDelegate.currentConfiguration.last.route.name;
final innerWidget = AppBackground( final autoTitle =
state != null ? 'screen${routeName?.capitalize()}' : 'screen';
return Scaffold(
appBar: showAppBar
? AppBar(
title: Text(title ?? autoTitle.tr()),
)
: null,
body: body,
);
}
}
class AppRootScaffold extends StatelessWidget {
final Widget body;
const AppRootScaffold({super.key, required this.body});
@override
Widget build(BuildContext context) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
final isCollapseDrawer =
ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE);
final isExpandDrawer = ResponsiveBreakpoints.of(context).largerThan(TABLET);
final routeName = GoRouter.of(context)
.routerDelegate
.currentConfiguration
.last
.route
.name;
final isShowBottomNavigation =
NavigationProvider.kShowBottomNavScreen.contains(routeName)
? ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)
: false;
final innerWidget = isCollapseDrawer
? body
: Row(
children: [
Container(
decoration: BoxDecoration(
border: Border(
right: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / devicePixelRatio,
),
),
),
child: isExpandDrawer
? AppNavigationDrawer(elevation: 0)
: AppRailNavigation(),
),
Expanded(child: body),
],
);
return AppBackground(
isRoot: true,
child: Scaffold( child: Scaffold(
appBar: appBar ?? body: Column(
(autoImplyAppBar children: [
? AppBar( ConnectionIndicator(),
title: title != null Expanded(child: innerWidget),
? Text(title!) ],
: state != null ),
? Text( drawer: !isExpandDrawer ? AppNavigationDrawer() : null,
('screen${state.routerDelegate.currentConfiguration.last.route.name?.capitalize()}')
.tr(),
)
: null)
: null),
body: body,
floatingActionButtonLocation: floatingActionButtonLocation,
floatingActionButton: floatingActionButton,
drawer: isShowDrawer ? AppNavigationDrawer() : null,
bottomNavigationBar: bottomNavigationBar:
isShowBottomNavigation ? AppBottomNavigationBar() : null, isShowBottomNavigation ? AppBottomNavigationBar() : null,
), ),
); );
if (showDrawer) {
return Row(
children: [
AppNavigationDrawer(),
VerticalDivider(width: 1, color: Theme.of(context).dividerColor),
Expanded(child: innerWidget),
],
);
}
return innerWidget;
} }
} }

View File

@ -5,8 +5,8 @@ import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/post/post_mini_editor.dart'; import 'package:surface/widgets/post/post_mini_editor.dart';
@ -14,7 +14,12 @@ import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class PostCommentSliverList extends StatefulWidget { class PostCommentSliverList extends StatefulWidget {
final int parentPostId; final int parentPostId;
const PostCommentSliverList({super.key, required this.parentPostId}); final double? maxWidth;
const PostCommentSliverList({
super.key,
required this.parentPostId,
this.maxWidth,
});
@override @override
State<PostCommentSliverList> createState() => PostCommentSliverListState(); State<PostCommentSliverList> createState() => PostCommentSliverListState();
@ -31,38 +36,13 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>(); final pt = context.read<SnPostContentProvider>();
final resp = await sn.client.get( final result = await pt.listPostReplies(widget.parentPostId);
'/cgi/co/posts/${widget.parentPostId}/replies', final List<SnPost> out = result.$1;
queryParameters: {
'take': 10,
'offset': _posts.length,
},
);
final List<SnPost> out =
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []);
Set<String> rids = {};
for (var i = 0; i < out.length; i++) {
rids.addAll(out[i].body['attachments']?.cast<String>() ?? []);
}
if (!mounted) return; if (!mounted) return;
final attach = context.read<SnAttachmentProvider>();
final attachments = await attach.getMultiple(rids.toList());
for (var i = 0; i < out.length; i++) {
out[i] = out[i].copyWith(
preload: SnPostPreload(
attachments: attachments
.where(
(ele) => out[i].body['attachments']?.contains(ele.rid) ?? false,
)
.toList(),
),
);
}
_postCount = resp.data['count']; _postCount = result.$2;
_posts.addAll(out); _posts.addAll(out);
if (mounted) setState(() => _isBusy = false); if (mounted) setState(() => _isBusy = false);
@ -88,7 +68,17 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
onFetchData: _fetchPosts, onFetchData: _fetchPosts,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
return GestureDetector( return GestureDetector(
child: PostItem(data: _posts[idx]), child: PostItem(
data: _posts[idx],
maxWidth: widget.maxWidth,
onChanged: (data) {
setState(() => _posts[idx] = data);
},
onDeleted: () {
_posts.clear();
_fetchPosts();
},
),
onTap: () { onTap: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
'postDetail', 'postDetail',
@ -121,6 +111,7 @@ class _PostCommentListPopupState extends State<PostCommentListPopup> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ua = context.watch<UserProvider>();
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return Column( return Column(
@ -139,25 +130,26 @@ class _PostCommentListPopupState extends State<PostCommentListPopup> {
Expanded( Expanded(
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
SliverToBoxAdapter( if (ua.isAuthorized)
child: Container( SliverToBoxAdapter(
height: 240, child: Container(
decoration: BoxDecoration( height: 240,
border: Border.symmetric( decoration: BoxDecoration(
horizontal: BorderSide( border: Border.symmetric(
color: Theme.of(context).dividerColor, horizontal: BorderSide(
width: 1 / devicePixelRatio, color: Theme.of(context).dividerColor,
width: 1 / devicePixelRatio,
),
), ),
), ),
), child: PostMiniEditor(
child: PostMiniEditor( postReplyId: widget.postId,
postReplyId: widget.postId, onPost: () {
onPost: () { _childListKey.currentState!.refresh();
_childListKey.currentState!.refresh(); },
}, ),
), ),
), ),
),
PostCommentSliverList( PostCommentSliverList(
key: _childListKey, key: _childListKey,
parentPostId: widget.postId, parentPostId: widget.postId,

View File

@ -4,12 +4,13 @@ import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart'; import 'package:relative_time/relative_time.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/attachment/attachment_list.dart'; import 'package:surface/widgets/attachment/attachment_list.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/markdown_content.dart'; import 'package:surface/widgets/markdown_content.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:surface/widgets/post/post_comment_list.dart'; import 'package:surface/widgets/post/post_comment_list.dart';
@ -19,13 +20,19 @@ class PostItem extends StatelessWidget {
final SnPost data; final SnPost data;
final bool showReactions; final bool showReactions;
final bool showComments; final bool showComments;
final bool showMenu;
final double? maxWidth;
final Function(SnPost data)? onChanged; final Function(SnPost data)? onChanged;
final Function()? onDeleted;
const PostItem({ const PostItem({
super.key, super.key,
required this.data, required this.data,
this.showReactions = true, this.showReactions = true,
this.showComments = true, this.showComments = true,
this.showMenu = true,
this.maxWidth,
this.onChanged, this.onChanged,
this.onDeleted,
}); });
void _onChanged(SnPost data) { void _onChanged(SnPost data) {
@ -34,31 +41,60 @@ class PostItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isListAttachments =
ResponsiveBreakpoints.of(context).largerThan(MOBILE) ||
(data.preload?.attachments?.length ?? 0) > 1;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
_PostContentHeader(data: data).padding(horizontal: 12, vertical: 8), Container(
_PostContentBody(data: data.body).padding(horizontal: 16, bottom: 6), constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
if (data.repostTo != null) child: Column(
_PostQuoteContent(child: data.repostTo!).padding( crossAxisAlignment: CrossAxisAlignment.start,
horizontal: 12, children: [
_PostContentHeader(
data: data,
showMenu: showMenu,
onDeleted: () {
if (onDeleted != null) onDeleted!();
},
).padding(horizontal: 12, vertical: 8),
if (data.body['title'] != null ||
data.body['description'] != null)
_PostHeadline(data: data).padding(horizontal: 16, bottom: 8),
_PostContentBody(data: data.body)
.padding(horizontal: 16, bottom: 6),
if (data.repostTo != null)
_PostQuoteContent(child: data.repostTo!).padding(
horizontal: 12,
),
if (data.body['content_truncated'] == true)
_PostTruncatedHint(data: data).padding(
horizontal: 16,
vertical: 4,
),
if (data.tags.isNotEmpty)
_PostTagsList(data: data).padding(horizontal: 16, bottom: 6),
],
), ),
if (data.preload?.attachments?.isNotEmpty ?? true) ),
if (data.preload?.attachments?.isNotEmpty ?? false)
AttachmentList( AttachmentList(
data: data.preload!.attachments!, data: data.preload!.attachments!,
bordered: true, bordered: true,
maxHeight: 520, maxHeight: 480,
).padding(horizontal: isListAttachments ? 12 : 0), listPadding: const EdgeInsets.symmetric(horizontal: 12),
_PostBottomAction( ),
data: data, Container(
showComments: showComments, constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
showReactions: showReactions, child: Column(
onChanged: _onChanged, children: [
).padding(left: 12, right: 18), _PostBottomAction(
data: data,
showComments: showComments,
showReactions: showReactions,
onChanged: _onChanged,
).padding(left: 8, right: 14),
],
),
),
], ],
); );
} }
@ -84,65 +120,71 @@ class _PostBottomAction extends StatelessWidget {
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Row( if (showReactions || showComments)
children: [ Row(
if (showReactions) children: [
InkWell( if (showReactions)
child: Row( InkWell(
children: [ child: Row(
Icon(Symbols.add_reaction, size: 20, color: iconColor), children: [
const Gap(8), Icon(Symbols.add_reaction, size: 20, color: iconColor),
if (data.totalDownvote > 0 || data.totalUpvote > 0) const Gap(8),
Text('postReactionPoints').plural( if (data.totalUpvote > 0 &&
data.totalUpvote - data.totalDownvote, data.totalUpvote >= data.totalDownvote)
) Text('postReactionUpvote').plural(
else data.totalUpvote,
Text('postReact').tr(), )
], else if (data.totalDownvote > 0)
).padding(horizontal: 8, vertical: 8), Text('postReactionDownvote').plural(
onTap: () { data.totalDownvote,
showModalBottomSheet( )
context: context, else
builder: (context) => PostReactionPopup( Text('postReact').tr(),
data: data, ],
onChanged: (value, isPositive, delta) { ).padding(horizontal: 8, vertical: 8),
onChanged(data.copyWith( onTap: () {
totalUpvote: isPositive showModalBottomSheet(
? data.totalUpvote + delta context: context,
: data.totalUpvote, builder: (context) => PostReactionPopup(
totalDownvote: !isPositive data: data,
? data.totalDownvote + delta onChanged: (value, attr, delta) {
: data.totalDownvote, onChanged(data.copyWith(
metric: data.metric.copyWith(reactionList: value), totalUpvote: attr == 1
)); ? data.totalUpvote + delta
}, : data.totalUpvote,
), totalDownvote: attr == 2
); ? data.totalDownvote + delta
}, : data.totalDownvote,
), metric: data.metric.copyWith(reactionList: value),
if (showComments) ));
InkWell( },
child: Row( ),
children: [ );
Icon(Symbols.comment, size: 20, color: iconColor), },
const Gap(8), ),
Text('postComments').plural(data.metric.replyCount), if (showComments)
], InkWell(
).padding(horizontal: 8, vertical: 8), child: Row(
onTap: () { children: [
showModalBottomSheet( Icon(Symbols.comment, size: 20, color: iconColor),
context: context, const Gap(8),
useRootNavigator: true, Text('postComments').plural(data.metric.replyCount),
builder: (context) => PostCommentListPopup( ],
postId: data.id, ).padding(horizontal: 8, vertical: 8),
commentCount: data.metric.replyCount, onTap: () {
), showModalBottomSheet(
); context: context,
}, useRootNavigator: true,
), builder: (context) => PostCommentListPopup(
].expand((ele) => [ele, const Gap(8)]).toList() postId: data.id,
..removeLast(), commentCount: data.metric.replyCount,
), ),
);
},
),
].expand((ele) => [ele, const Gap(8)]).toList()
..removeLast(),
),
InkWell( InkWell(
child: Icon( child: Icon(
Symbols.share, Symbols.share,
@ -156,16 +198,65 @@ class _PostBottomAction extends StatelessWidget {
} }
} }
class _PostHeadline extends StatelessWidget {
final SnPost data;
const _PostHeadline({super.key, required this.data});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (data.body['title'] != null)
Text(
data.body['title'],
style: Theme.of(context).textTheme.titleMedium,
),
if (data.body['description'] != null)
Text(
data.body['description'],
style: Theme.of(context).textTheme.bodyMedium,
),
],
);
}
}
class _PostContentHeader extends StatelessWidget { class _PostContentHeader extends StatelessWidget {
final SnPost data; final SnPost data;
final bool isCompact; final bool isCompact;
final bool showActions; final bool showMenu;
final Function onDeleted;
const _PostContentHeader({ const _PostContentHeader({
required this.data, required this.data,
this.isCompact = false, this.isCompact = false,
this.showActions = true, this.showMenu = true,
required this.onDeleted,
}); });
Future<void> _deletePost(BuildContext context) async {
final confirm = await context.showConfirmDialog(
'postDelete'.tr(args: ['#${data.id}']),
'postDeleteDescription'.tr(),
);
if (!confirm) return;
if (!context.mounted) return;
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.delete('/cgi/co/posts/${data.id}', queryParameters: {
'publisherId': data.publisherId,
});
if (!context.mounted) return;
context.showSnackbar('postDeleted'.tr(args: ['#${data.id}']));
} catch (err) {
if (!context.mounted) return;
context.showErrorDialog(err);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
@ -212,7 +303,7 @@ class _PostContentHeader extends StatelessWidget {
], ],
), ),
), ),
if (showActions) if (showMenu)
PopupMenuButton( PopupMenuButton(
icon: const Icon(Symbols.more_horiz), icon: const Icon(Symbols.more_horiz),
style: const ButtonStyle( style: const ButtonStyle(
@ -245,6 +336,7 @@ class _PostContentHeader extends StatelessWidget {
Text('delete').tr(), Text('delete').tr(),
], ],
), ),
onTap: () => _deletePost(context),
), ),
if (isAuthor) const PopupMenuDivider(), if (isAuthor) const PopupMenuDivider(),
PopupMenuItem( PopupMenuItem(
@ -252,7 +344,7 @@ class _PostContentHeader extends StatelessWidget {
children: [ children: [
const Icon(Symbols.reply), const Icon(Symbols.reply),
const Gap(16), const Gap(16),
Text('reply').tr(), Text('replyPost').tr(),
], ],
), ),
onTap: () { onTap: () {
@ -324,11 +416,87 @@ class _PostQuoteContent extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column( child: Column(
children: [ children: [
_PostContentHeader(data: child, isCompact: true, showActions: false) _PostContentHeader(
.padding(bottom: 4), data: child,
isCompact: true,
showMenu: false,
onDeleted: () {},
).padding(bottom: 4),
_PostContentBody(data: child.body), _PostContentBody(data: child.body),
], ],
), ),
); );
} }
} }
class _PostTagsList extends StatelessWidget {
final SnPost data;
const _PostTagsList({super.key, required this.data});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 4,
runSpacing: 4,
children: data.tags
.map(
(ele) => InkWell(
child: Text(
'#${ele.alias}',
style: TextStyle(
decoration: TextDecoration.underline,
),
).fontSize(13),
onTap: () {},
),
)
.toList(),
).opacity(0.8);
}
}
class _PostTruncatedHint extends StatelessWidget {
final SnPost data;
const _PostTruncatedHint({super.key, required this.data});
static const int kHumanReadSpeed = 238;
@override
Widget build(BuildContext context) {
return Row(
children: [
if (data.body['content_length'] != null)
Row(
children: [
const Icon(Symbols.timer, size: 20),
const Gap(4),
Text('postReadEstimate').tr(args: [
'${Duration(
seconds: ((data.body['content_length'] as num).toDouble() /
kHumanReadSpeed)
.round(),
).inSeconds}s',
]),
],
).padding(right: 12),
if (data.body['content_length'] != null)
Row(
children: [
const Icon(Symbols.height, size: 20),
const Gap(4),
Text(
'postTotalLength'.plural(data.body['content_length']),
).padding(right: 12)
],
),
Row(
children: [
const Icon(Symbols.unfold_more, size: 20),
const Gap(4),
Text('postReadMore').tr(),
],
)
],
).opacity(0.75);
}
}

View File

@ -17,11 +17,23 @@ import 'package:surface/widgets/attachment/attachment_detail.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
class PostMediaPendingList extends StatelessWidget { class PostMediaPendingList extends StatelessWidget {
final PostWriteController controller; final List<PostWriteMedia> attachments;
const PostMediaPendingList({super.key, required this.controller}); final bool isBusy;
final Future<void> Function(int idx, PostWriteMedia updatedMedia)? onUpdate;
final Future<void> Function(int idx)? onRemove;
final void Function(bool state)? onUpdateBusy;
void _cropImage(BuildContext context, int idx) async { const PostMediaPendingList({
final media = controller.attachments[idx]; super.key,
required this.attachments,
required this.isBusy,
this.onUpdate,
this.onRemove,
this.onUpdateBusy,
});
Future<void> _cropImage(BuildContext context, int idx) async {
final media = attachments[idx];
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper( ? await showCupertinoImageCropper(
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
@ -37,144 +49,138 @@ class PostMediaPendingList extends StatelessWidget {
); );
if (result == null) return; if (result == null) return;
if (!context.mounted) return;
controller.setIsBusy(true);
final rawBytes = final rawBytes =
(await result.uiImage.toByteData(format: ImageByteFormat.png))! (await result.uiImage.toByteData(format: ImageByteFormat.png))!
.buffer .buffer
.asUint8List(); .asUint8List();
controller.setAttachmentAt(
idx,
PostWriteMedia.fromBytes(rawBytes, media.name, media.type),
);
controller.setIsBusy(false); if (onUpdate != null) {
final updatedMedia = PostWriteMedia.fromBytes(
rawBytes,
media.name,
media.type,
);
await onUpdate!(idx, updatedMedia);
}
} }
void _deleteAttachment(BuildContext context, int idx) async { Future<void> _deleteAttachment(BuildContext context, int idx) async {
final media = controller.attachments[idx]; final media = attachments[idx];
if (media.attachment == null) return; if (media.attachment == null) return;
controller.setIsBusy(true);
try { try {
onUpdateBusy?.call(true);
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
await sn.client.delete('/cgi/uc/attachments/${media.attachment!.id}'); await sn.client.delete('/cgi/uc/attachments/${media.attachment!.id}');
controller.removeAttachmentAt(idx); onRemove!(idx);
} catch (err) { } catch (err) {
if (!context.mounted) return; if (!context.mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
} finally { } finally {
controller.setIsBusy(false); onUpdateBusy?.call(false);
} }
} }
ContextMenu _buildContextMenu(
BuildContext context, int idx, PostWriteMedia media) {
return ContextMenu(
entries: [
if (media.type == PostWriteMediaType.image && media.attachment != null)
MenuItem(
label: 'preview'.tr(),
icon: Symbols.preview,
onSelected: () {
context.pushTransparentRoute(
AttachmentZoomView(data: [media.attachment!]),
rootNavigator: true,
);
},
),
if (media.type == PostWriteMediaType.image && media.attachment == null)
MenuItem(
label: 'crop'.tr(),
icon: Symbols.crop,
onSelected: () => _cropImage(context, idx),
),
if (media.attachment != null && onRemove != null)
MenuItem(
label: 'delete'.tr(),
icon: Symbols.delete,
onSelected: isBusy ? null : () => _deleteAttachment(context, idx),
),
if (media.attachment == null && onRemove != null)
MenuItem(
label: 'delete'.tr(),
icon: Symbols.delete,
onSelected: () {
onRemove!(idx);
},
)
else if (onRemove != null)
MenuItem(
label: 'unlink'.tr(),
icon: Symbols.link_off,
onSelected: () {
onRemove!(idx);
},
),
],
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return ListenableBuilder( return Container(
listenable: controller, constraints: const BoxConstraints(maxHeight: 120),
builder: (context, _) { child: ListView.separated(
return Container( scrollDirection: Axis.horizontal,
constraints: const BoxConstraints(maxHeight: 120), padding: const EdgeInsets.symmetric(horizontal: 8),
child: ListView.separated( separatorBuilder: (context, index) => const Gap(8),
scrollDirection: Axis.horizontal, itemCount: attachments.length,
padding: const EdgeInsets.symmetric(horizontal: 8), itemBuilder: (context, idx) {
separatorBuilder: (context, index) => const Gap(8), final media = attachments[idx];
itemCount: controller.attachments.length, return ContextMenuRegion(
itemBuilder: (context, idx) { contextMenu: _buildContextMenu(context, idx, media),
final media = controller.attachments[idx]; child: Container(
return ContextMenuRegion( decoration: BoxDecoration(
contextMenu: ContextMenu( border: Border.all(
entries: [ color: Theme.of(context).dividerColor,
if (media.type == PostWriteMediaType.image && width: 1,
media.attachment != null)
MenuItem(
label: 'preview'.tr(),
icon: Symbols.preview,
onSelected: () {
context.pushTransparentRoute(
AttachmentDetailPopup(data: media.attachment!),
rootNavigator: true,
);
},
),
if (media.type == PostWriteMediaType.image &&
media.attachment == null)
MenuItem(
label: 'crop'.tr(),
icon: Symbols.crop,
onSelected: () => _cropImage(context, idx),
),
if (media.attachment != null)
MenuItem(
label: 'delete'.tr(),
icon: Symbols.delete,
onSelected: controller.isBusy
? null
: () => _deleteAttachment(context, idx),
),
if (media.attachment == null)
MenuItem(
label: 'delete'.tr(),
icon: Symbols.delete,
onSelected: () {
controller.removeAttachmentAt(idx);
},
)
else
MenuItem(
label: 'unlink'.tr(),
icon: Symbols.link_off,
onSelected: () {
controller.removeAttachmentAt(idx);
},
),
],
), ),
child: Container( borderRadius: BorderRadius.circular(8),
decoration: BoxDecoration( ),
border: Border.all( child: ClipRRect(
color: Theme.of(context).dividerColor, borderRadius: const BorderRadius.all(Radius.circular(8)),
width: 1, child: AspectRatio(
), aspectRatio: 1,
borderRadius: BorderRadius.circular(8), child: switch (media.type) {
), PostWriteMediaType.image =>
child: ClipRRect( LayoutBuilder(builder: (context, constraints) {
borderRadius: const BorderRadius.all(Radius.circular(8)), return Image(
child: AspectRatio( image: media.getImageProvider(
aspectRatio: 1, context,
child: switch (media.type) { width: (constraints.maxWidth * devicePixelRatio)
PostWriteMediaType.image => .round(),
LayoutBuilder(builder: (context, constraints) { height: (constraints.maxHeight * devicePixelRatio)
return Image( .round(),
image: media.getImageProvider( )!,
context, fit: BoxFit.cover,
width: (constraints.maxWidth * devicePixelRatio) );
.round(), }),
height: _ => Container(
(constraints.maxHeight * devicePixelRatio) color: Theme.of(context).colorScheme.surface,
.round(), child: const Icon(Symbols.docs).center(),
)!, ),
fit: BoxFit.cover, },
);
}),
_ => Container(
color: Theme.of(context).colorScheme.surface,
child: const Icon(Symbols.docs).center(),
),
},
),
),
), ),
); ),
}, ),
), );
); },
}, ),
); );
} }
} }

View File

@ -5,6 +5,7 @@ import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/post_write_controller.dart'; import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/widgets/post/post_tags_field.dart';
class PostMetaEditor extends StatelessWidget { class PostMetaEditor extends StatelessWidget {
final PostWriteController controller; final PostWriteController controller;
@ -69,6 +70,14 @@ class PostMetaEditor extends StatelessWidget {
onTapOutside: (_) => onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(), FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 24), ).padding(horizontal: 24),
const Gap(4),
PostTagsField(
initialTags: controller.tags,
labelText: 'fieldPostTags'.tr(),
onUpdate: (value) {
controller.setTags(value);
},
).padding(horizontal: 24),
const Gap(12), const Gap(12),
ListTile( ListTile(
leading: const Icon(Symbols.event_available), leading: const Icon(Symbols.event_available),

View File

@ -11,7 +11,7 @@ import 'package:surface/widgets/dialog.dart';
class PostReactionPopup extends StatefulWidget { class PostReactionPopup extends StatefulWidget {
final SnPost data; final SnPost data;
final Function(Map<String, int> value, bool isPositive, int delta)? onChanged; final Function(Map<String, int> value, int attr, int delta)? onChanged;
const PostReactionPopup({super.key, required this.data, this.onChanged}); const PostReactionPopup({super.key, required this.data, this.onChanged});
@override @override
@ -43,7 +43,7 @@ class _PostReactionPopupState extends State<PostReactionPopup> {
if (widget.onChanged != null) { if (widget.onChanged != null) {
widget.onChanged!( widget.onChanged!(
_reactions, _reactions,
kTemplateReactions[symbol]!.attitude == 1, kTemplateReactions[symbol]!.attitude,
1, 1,
); );
} }
@ -54,7 +54,7 @@ class _PostReactionPopupState extends State<PostReactionPopup> {
if (widget.onChanged != null) { if (widget.onChanged != null) {
widget.onChanged!( widget.onChanged!(
_reactions, _reactions,
kTemplateReactions[symbol]!.attitude == 1, kTemplateReactions[symbol]!.attitude,
-1, -1,
); );
} }
@ -89,11 +89,36 @@ class _PostReactionPopupState extends State<PostReactionPopup> {
.textStyle(Theme.of(context).textTheme.titleLarge!), .textStyle(Theme.of(context).textTheme.titleLarge!),
], ],
).padding(horizontal: 20, top: 16, bottom: 12), ).padding(horizontal: 20, top: 16, bottom: 12),
Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: Row(
children: [
const Icon(Symbols.thumb_up, size: 16),
const Gap(8),
Text('postReactionUpvote').plural(widget.data.totalUpvote),
const Gap(24),
const Icon(Symbols.thumb_down, size: 16),
const Gap(8),
Text('postReactionDownvote').plural(widget.data.totalDownvote),
const Gap(24),
Icon(
widget.data.totalUpvote >= widget.data.totalDownvote
? Symbols.trending_up
: Symbols.trending_down,
size: 16,
),
const Gap(8),
Text('postReactionSocialPoint').plural(
widget.data.totalUpvote - widget.data.totalDownvote,
),
],
).padding(vertical: 8, horizontal: 24),
),
Expanded( Expanded(
child: GridView.count( child: GridView.extent(
crossAxisSpacing: 4, crossAxisSpacing: 4,
mainAxisSpacing: 4, mainAxisSpacing: 4,
crossAxisCount: 4, maxCrossAxisExtent: 120,
children: kTemplateReactions.entries.map((e) { children: kTemplateReactions.entries.map((e) {
return InkWell( return InkWell(
onTap: () { onTap: () {

View File

@ -0,0 +1,204 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
class PostTagsField extends StatefulWidget {
final List<String>? initialTags;
final String labelText;
final Function(List<String>) onUpdate;
const PostTagsField({
super.key,
this.initialTags,
required this.labelText,
required this.onUpdate,
});
@override
State<PostTagsField> createState() => _PostTagsFieldState();
}
class _PostTagsFieldState extends State<PostTagsField> {
static const List<String> kTagsDividers = [' ', ','];
late final _Debounceable<List<String>?, String> _debouncedSearch;
final List<String> _currentTags = List.empty(growable: true);
String? _currentSearchProbe;
List<String> _lastAutocompleteResult = List.empty();
TextEditingController? _textEditingController;
Future<List<String>?> _searchTags(String probe) async {
_currentSearchProbe = probe;
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get(
'/cgi/co/tags?take=10&probe=$_currentSearchProbe',
);
if (_currentSearchProbe != probe) {
return null;
}
_currentSearchProbe = null;
return resp.data.map((x) => x['alias']).toList().cast<String>();
}
@override
void initState() {
super.initState();
_debouncedSearch = _debounce<List<String>?, String>(_searchTags);
if (widget.initialTags != null) {
_currentTags.addAll(widget.initialTags!);
}
}
@override
Widget build(BuildContext context) {
return Autocomplete<String>(
optionsBuilder: (TextEditingValue textEditingValue) async {
final result = await _debouncedSearch(textEditingValue.text);
if (result == null) {
return _lastAutocompleteResult;
}
_lastAutocompleteResult = result;
return result;
},
onSelected: (String value) {
if (value.isEmpty) return;
if (!_currentTags.contains(value)) {
setState(() => _currentTags.add(value));
}
_textEditingController?.clear();
widget.onUpdate(_currentTags);
},
fieldViewBuilder: (context, controller, focusNode, onSubmitted) {
_textEditingController = controller;
return TextField(
controller: controller,
focusNode: focusNode,
decoration: InputDecoration(
label: Text(widget.labelText),
border: const UnderlineInputBorder(),
prefixIconConstraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
prefixIcon: _currentTags.isNotEmpty
? SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _currentTags.map((String tag) {
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(20.0),
),
color: Theme.of(context).colorScheme.primary,
),
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(
horizontal: 10.0, vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
InkWell(
child: Text(
'#$tag',
style: const TextStyle(color: Colors.white),
),
),
const Gap(4),
InkWell(
child: const Icon(
Icons.cancel,
size: 14.0,
color: Color.fromARGB(255, 233, 233, 233),
),
onTap: () {
setState(() => _currentTags.remove(tag));
widget.onUpdate(_currentTags);
},
)
],
),
);
}).toList(),
),
)
: null,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onChanged: (value) {
for (final divider in kTagsDividers) {
if (value.endsWith(divider)) {
final tagValue = value.substring(0, value.length - 1);
if (tagValue.isEmpty) return;
if (!_currentTags.contains(tagValue)) {
setState(() => _currentTags.add(tagValue));
}
controller.clear();
widget.onUpdate(_currentTags);
break;
}
}
},
onSubmitted: (_) {
onSubmitted();
},
);
},
);
}
}
typedef _Debounceable<S, T> = Future<S?> Function(T parameter);
_Debounceable<S, T> _debounce<S, T>(_Debounceable<S?, T> function) {
_DebounceTimer? debounceTimer;
return (T parameter) async {
if (debounceTimer != null && !debounceTimer!.isCompleted) {
debounceTimer!.cancel();
}
debounceTimer = _DebounceTimer();
try {
await debounceTimer!.future;
} catch (error) {
if (error is _CancelException) {
return null;
}
rethrow;
}
return function(parameter);
};
}
class _DebounceTimer {
_DebounceTimer() {
_timer = Timer(const Duration(milliseconds: 500), _onComplete);
}
late final Timer _timer;
final Completer<void> _completer = Completer<void>();
void _onComplete() {
_completer.complete();
}
Future<void> get future => _completer.future;
bool get isCompleted => _completer.isCompleted;
void cancel() {
_timer.cancel();
_completer.completeError(const _CancelException());
}
}
class _CancelException implements Exception {
const _CancelException();
}

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