Compare commits

...

63 Commits

Author SHA1 Message Date
6cde218393 💄 Optimization of post item style 2024-09-21 23:28:14 +08:00
c896185af0 See other user recent fortune 2024-09-21 23:10:20 +08:00
4cbeafd447 Account deletion 2024-09-21 22:44:08 +08:00
91a32e6736 Report abuse 2024-09-21 22:10:59 +08:00
befc647b03 💄 Improved about page 2024-09-19 20:39:09 +08:00
16b2e3a0c7 Terms that show up let user accept 2024-09-19 20:34:04 +08:00
0cc842c030 🐛 Fix upgrade detection method 2024-09-18 20:27:13 +08:00
fb370a484d 🐛 Fix english localization update message placeholder issue 2024-09-18 19:55:42 +08:00
153c15e5c9 🚀 Launch 1.2.2+2 2024-09-18 13:05:08 +08:00
6a0f42cdc9 🐛 Fix realm view won't show channels 2024-09-18 13:03:40 +08:00
01aaa5455e 💄 Fix content padding mis-match 2024-09-18 00:14:16 +08:00
f3ceb5f967 🚀 Launch 1.2.2+1 2024-09-17 23:50:49 +08:00
b5e2fa4c25 🐛 Fix post editor alias overflow 2024-09-17 23:08:00 +08:00
8378024490 🚀 Launch 1.2.1+41 2024-09-17 22:31:37 +08:00
6d40d6bba3 💄 Optimize content 2024-09-17 21:48:20 +08:00
77075c8dab Optimize updater 2024-09-17 21:37:20 +08:00
dec34e297d 🐛 Bug fixes on attachments and related things 2024-09-17 20:59:01 +08:00
358677ade0 Android self-update 2024-09-17 20:40:44 +08:00
d2f37ae45d 🐛 Fix fileType render error 2024-09-17 18:28:53 +08:00
e4b741ff0c 🚀 Launch 1.2.1+40 2024-09-17 16:02:13 +08:00
e69abb7f9d Notification preferences 2024-09-17 15:59:17 +08:00
565a8e41cc Realm avatar, banner 2024-09-17 14:21:37 +08:00
c9fbe47337 Channel isPublic and isCommunity 2024-09-17 13:50:04 +08:00
01db63e297 🐛 Fix compability on iOS 18 and macOS 15 2024-09-17 13:39:08 +08:00
d87e67bd17 Subscriptions 2024-09-17 02:14:23 +08:00
06aa1fb359 🐛 Fix post last read at 2024-09-17 01:23:49 +08:00
62733bf29f 💄 Optimize featured reply style 2024-09-16 23:39:15 +08:00
ce16de9c71 Featured replies on post 2024-09-16 23:35:44 +08:00
47eb6cbc66 Chat list will also show wild group channel 2024-09-16 21:09:19 +08:00
029e72fb0b Improve sticker loading 2024-09-16 21:00:19 +08:00
152efd97a0 💄 Unified design of single attachment uploader 2024-09-16 20:33:34 +08:00
ad1dc064e6 🚀 Launch 1.2.1+39 2024-09-16 20:15:36 +08:00
675b5dea5d 💫 Optimize region animations 2024-09-16 20:06:15 +08:00
5941cb9fd5 🐛 Fix messages loading 2024-09-16 19:50:49 +08:00
e11bf204af 🐛 Fix web login error by the cors issue 2024-09-16 18:12:30 +08:00
8a2d94cedf 🚀 Launch 1.2.1+38 2024-09-16 12:04:21 +08:00
780f7c22bc 💄 Better user agent 2024-09-16 11:57:16 +08:00
c18ce88993 Brand new sign in flow 2024-09-16 02:37:20 +08:00
73456fcff6 ♻️ Full screen signin and signup 2024-09-15 23:32:15 +08:00
8e8be52658 🐛 Fix web uploading 2024-09-15 22:52:20 +08:00
df22b65777 💄 Fix style issue 2024-09-15 18:31:04 +08:00
1437414b7f Improve chat loading speed 2024-09-15 18:25:04 +08:00
c1ff317c66 🚑 Able to use database on web 2024-09-15 18:02:27 +08:00
f3375070a0 🚀 Launch 1.2.1+37 2024-09-15 17:46:48 +08:00
204df3306e 🐛 Fix notification services 2024-09-15 17:19:55 +08:00
aeaade9590 🐛 Fix unauthorized things 2024-09-15 16:54:07 +08:00
306ce9e2b4 Optimize notification background service 2024-09-15 16:02:56 +08:00
a487924300 Android background notification service 2024-09-15 15:55:14 +08:00
ad66c11593 ♻️ Implement delete (recreate) local database 2024-09-15 12:25:50 +08:00
40b885b27b 🔀 Merge pull request '♻️ 使用 Drift 作为本地数据库' (#3) from refactor/drift-as-local-db into master
Reviewed-on: #3
2024-09-15 02:56:59 +00:00
2183a2ca55 Improve loading of chat events 2024-09-15 10:55:27 +08:00
00449f3f83 🐛 Fix Too many elements 2024-09-14 00:42:17 +08:00
b14e55355f ♻️ Use drift instead for floor 2024-09-14 00:30:33 +08:00
db808650e3 💄 Optimized settings 2024-09-13 23:19:33 +08:00
c1cbcbe734 ⬆️ Upgrade flutter + deps 2024-09-13 22:51:17 +08:00
2c4040096f 🚀 Launch 1.2.1+36 2024-09-13 20:22:33 +08:00
b449735bf5 Better side navigation
🐛 Bug fixes and optimizations
2024-09-13 20:22:10 +08:00
dd01f964d4 Focused realm linked with feed stream 2024-09-13 00:17:56 +08:00
6daa04c208 Brand new app navigation region 2024-09-12 23:55:31 +08:00
19ec0a7ede 🚀 Launch 1.2.1+35 2024-09-12 20:29:08 +08:00
f3b2a2a0ac 👽 The removal of external id 2024-09-11 23:40:23 +08:00
bba38e6845 🌐 Localize daily sign history chart 2024-09-11 22:03:43 +08:00
408a2489e2 ♻️ Move translations out of dart code 2024-09-11 19:56:32 +08:00
173 changed files with 19385 additions and 3188 deletions

View File

@ -1,8 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="dev.solsynth.solian">
<uses-feature android:name="android.hardware.camera" /> <uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" /> <uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
@ -51,6 +52,14 @@
to determine the Window background behind the Flutter UI. --> to determine the Window background behind the Flutter UI. -->
<meta-data android:name="flutter_deeplinking_enabled" android:value="true" /> <meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="solink" />
</intent-filter>
<intent-filter android:autoVerify="true"> <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
@ -61,14 +70,6 @@
<data android:scheme="https" /> <data android:scheme="https" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="solink" />
</intent-filter>
<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"
@ -84,6 +85,11 @@
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:theme="@style/Theme.AppCompat.Light.NoActionBar" /> android:theme="@style/Theme.AppCompat.Light.NoActionBar" />
<service
android:name="id.flutter.flutter_background_service.BackgroundService"
android:foregroundServiceType="remoteMessaging"
/>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

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.6-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip

View File

@ -18,7 +18,7 @@ pluginManagement {
plugins { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.4.0' apply false id "com.android.application" version '8.6.0' apply false
id "com.google.gms.google-services" version "4.3.15" apply false id "com.google.gms.google-services" version "4.3.15" apply false
id "com.google.firebase.crashlytics" version "2.8.1" apply false id "com.google.firebase.crashlytics" version "2.8.1" apply false
id "org.jetbrains.kotlin.android" version '2.0.0' apply false id "org.jetbrains.kotlin.android" version '2.0.0' apply false

456
assets/locales/en_us.json Normal file
View File

@ -0,0 +1,456 @@
{
"done": "Done",
"hide": "Hide",
"okay": "Okay",
"next": "Next",
"prev": "Previous",
"reset": "Reset",
"page": "Page",
"home": "Home",
"guest": "Guest",
"draft": "Draft",
"dashboard": "Dashboard",
"today": "Today",
"yesterday": "Yesterday",
"draftSave": "Save",
"draftBox": "Draft Box",
"more": "More",
"share": "Share",
"shareNoUri": "Share text content",
"alias": "Alias",
"feed": "Feed",
"explore": "Explore",
"posts": "Posts",
"unlink": "Unlink",
"feedSearch": "Search Feed",
"feedSearchWithTag": "Searching with tag #@key",
"feedSearchWithCategory": "Searching in category @category",
"feedUnreadCount": "@count posts you may missed",
"messages": "Messages",
"messagesUnreadCount": "@count messages unread",
"dailySign": "Daily Sign",
"dailySignAction": "Sign Today",
"dailySignHistoryAction": "View History",
"dailySignNone": "You haven't sign today",
"dailySignTier0": "Everything may not be good",
"dailySignTier1": "Something may be wrong",
"dailySignTier2": "Just so so",
"dailySignTier3": "Something may goes well",
"dailySignTier4": "Everything will be awesome",
"dailySignHistoryTitle": "Fortune History",
"dailySignHistoryRecent": "Recent Fortune",
"dailySignHistoryReward": "Reward Trends",
"dashboardFooter": "Don't be serious, just for fun.",
"visitProfilePage": "Visit Profile Page",
"profilePage": "Page",
"profilePosts": "Posts",
"profileAlbum": "Album",
"chat": "Chat",
"apply": "Apply",
"cancel": "Cancel",
"confirm": "Confirm",
"leave": "Leave",
"loading": "Loading...",
"about": "About",
"edit": "Edit",
"delete": "Delete",
"settings": "Settings",
"settingsNotificationBgService": "Background notification service",
"settingsNotificationBgServiceDesc": "A notification service is always installed on the device, so that some devices that do not support push notifications can receive notifications in the background. When this feature is enabled, push notifications will not be registered with the server, and you will always appear to be online in the eyes of others (except for invisible). You may need to turn off power and traffic optimization in the settings.",
"search": "Search",
"post": "Post",
"article": "Article",
"reply": "Reply",
"repost": "Repost",
"openInAlbum": "Open in album",
"openInBrowser": "Open in browser",
"notification": "Notification",
"notificationUnreadCount": "@count unread notifications",
"errorHappened": "An error occurred",
"errorHappenedUnauthorized": "Unauthorized request, please sign in or try resign in.",
"errorHappenedRequestBad": "Request error, the server refused to process the request. Please check your request data.",
"errorHappenedRequestForbidden": "Request error, insufficient permissions.",
"errorHappenedRequestNotFound": "Request error, the requested data does not exist.",
"errorHappenedRequestConnection": "Network request failed. Please check the connection status and service status, then try again.",
"errorHappenedRequestUnknown": "Request error, unknown type. Please take a full screenshot of this message and submit feedback.",
"forgotPassword": "Forgot password",
"email": "Email",
"username": "Username",
"usernameInputHint": "Also supports email and phone number",
"nickname": "Nickname",
"password": "Password",
"passwordOneTime": "One-time-password",
"passwordInputHint": "Forgot your password? Go back to the first step to reset your password",
"passwordOneTimeInputHint": "Check your inbox or authorizer for a verification code",
"title": "Title",
"description": "Description",
"birthday": "Birthday",
"firstName": "First Name",
"lastName": "Last Name",
"account": "Account",
"accountProfile": "Your profile",
"accountProfileApplied": "Account profile has been saved.",
"accountStickers": "Stickers",
"accountFriend": "Friend",
"accountFriendNew": "New friend",
"accountFriendNewHint": "Use someone's username to send a request of making friends with them!",
"accountFriendPending": "Friend requests",
"accountFriendBlocked": "Friend blocklist",
"accountFriendListHint": "Swipe left to decline, right to approve",
"accountFriendRequestSent": "Friend request sent, waiting for processing...",
"accountSuspended": "Account was suspended",
"accountSuspendedAt": "Account was suspended since @date",
"aspectRatio": "Aspect Ratio",
"aspectRatioSquare": "Square",
"aspectRatioPortrait": "Portrait",
"aspectRatioLandscape": "Landscape",
"unsignedIn": "Unsigned in",
"signin": "Sign in",
"signinRequired": "Sign in",
"signinRequiredHint": "Sign in to get full access of Solar Network",
"signinGreeting": "Welcome back\nSolar Network",
"signinCaption": "Sign in to create post, start a realm, message your friend and more!",
"signinRiskDetected": "Risk detected, click Next to open a webpage and signin through it to pass security check.",
"signinResetPasswordHint": "Please enter username to request reset password.",
"signinResetPasswordSent": "Reset password request sent, check your inbox!",
"signinPickFactor": "Pick a way\nfor verification",
"signinEnterPassword": "Enter your\npassword",
"signinMultiFactor": "@n step(s) verifications",
"authFactorEmail": "Email One-time-password",
"authFactorPassword": "Password",
"signup": "Sign up",
"signupGreeting": "Welcome onboard",
"signupCaption": "Create an account on Solarpass and then get the access of entire Solar Network!",
"signupDone": "Sign up successfully.",
"signupDoneCaption": "You successfully created an account on Solar Network. Now go sign in!",
"signout": "Sign out",
"joinedAt": "Joined at @date",
"riskDetection": "Risk Detected",
"matureContent": "Mature Content",
"matureContentCaption": "The content is rated and may not suitable for everyone to view",
"notifyAllRead": "Mark all as read",
"notifyEmpty": "All notifications read",
"notifyEmptyCaption": "It seems like nothing happened recently",
"totalSocialCreditPoints": "Social Credit Points",
"totalPostCount": "Posts",
"totalUpvote": "Upvote",
"totalDownvote": "Downvote",
"clear": "Clear",
"pinPost": "Pin this post",
"unpinPost": "Unpin this post",
"postRestoreFromLocal": "Restore from local",
"postAutoSaveAt": "Auto saved at @date",
"postCategoriesAndTags": "Categories n' Tags",
"postPublishDate": "Publish Date",
"postPublishAt": "Publish At",
"postPublishedUntil": "Publish Until",
"postPublishZone": "Publish Zone",
"postPublishZoneNone": "None",
"postVisibility": "Visibility",
"postVisibilityAll": "Everyone",
"postVisibilityFriends": "Friends",
"postVisibilitySelected": "Selected visible",
"postVisibilityFiltered": "Selected invisible",
"postVisibilityNone": "Only me",
"postVisibleUsers": "Visible users",
"postInvisibleUsers": "Invisible users",
"postOverview": "Overview",
"postThumbnail": "Thumbnail",
"postThumbnailAttachmentNew": "Upload thumbnail",
"postThumbnailAttachment": "Attachment serial number",
"postPinned": "Pinned",
"postListNews": "News",
"postListFriends": "Friends",
"postListShuffle": "Random",
"attachmentThumbnail": "Thumbnail",
"attachmentThumbnailAttachmentNew": "Upload thumbnail",
"attachmentThumbnailAttachment": "Attachment serial number",
"postEditorModeStory": "Post a post",
"postEditorModeArticle": "Post an article",
"postEditor": "Post editor",
"articleEditor": "Create new article",
"articleDetail": "Article details",
"draftBoxOpen": "Open draft box",
"postNew": "Create a new post",
"postNewInRealmHint": "Add post in realm @realm",
"postAction": "Post",
"postEdited": "Edited at @date",
"postNewCreated": "Created at @date",
"attachmentHint": "@count attachment(s)",
"postInRealm": "In @realm",
"postDetail": "Post",
"postReplies": "Replies",
"postPublish": "Post a post",
"articlePublish": "Write an article",
"articleTitlePlaceholder": "Title",
"articleDescriptionPlaceholder": "Description",
"articleContentPlaceholder": "Content",
"postIdentityNotify": "You will post this post as",
"postContentPlaceholder": "What's happened?!",
"postTagsPlaceholder": "Tags",
"postReaction": "Reactions of the Post",
"postActionList": "Actions of Post",
"postReplyAction": "Make a reply",
"postRepliedNotify": "Replied a post from @username.",
"postRepostedNotify": "Reposted a post from @username.",
"postInRealmNotify": "You're posting in realm @realm.",
"postEditingNotify": "You're editing as post from you.",
"postReplyingNotify": "You're replying a post from @username.",
"postRepostingNotify": "You're reposting a post from @username.",
"postDeletionConfirm": "Confirm post deletion",
"postDeletionConfirmCaption": "Are your sure to delete post \"@content\"? This action cannot be undone!",
"reactAdd": "React",
"reactCompleted": "Your reaction has been added",
"reactUncompleted": "Your reaction has been removed",
"attachmentUploadBy": "Upload by",
"attachmentAutoUpload": "Auto Upload",
"attachmentUploadQueue": "Upload Queue",
"attachmentUploadQueueStart": "Start All",
"attachmentUploadInProgress": "There are attachments being uploaded. Please wait until all attachments have been uploaded before proceeding...",
"attachmentAttached": "Exists Files",
"attachmentUploadBlocked": "Upload blocked, there is currently a task in progress...",
"attachmentAdd": "Attach file",
"attachmentAddGalleryPhoto": "Gallery photo",
"attachmentAddGalleryVideo": "Gallery video",
"attachmentAddCameraPhoto": "Capture photo",
"attachmentAddCameraVideo": "Capture video",
"attachmentAddClipboard": "Paste file",
"attachmentAddFile": "Attach file",
"attachmentAddLink": "Link attachments",
"attachmentAddLinkHint": "Enter attachment serial number to link that attachment",
"attachmentAddLinkInput": "Serial number",
"attachmentSetting": "Adjust attachment",
"attachmentAlt": "Alternative text",
"attachmentLoadFailed": "Load Attachment Failed",
"attachmentLoadFailedCaption": "Something went wrong during loading the attachment metadata...",
"attachmentUploading": "Uploading @name...",
"attachmentUploadingWebMode": "Uploading @name... Due to browser's limitation, calculate attachment information may cause some lag...",
"realm": "Realm",
"realms": "Realms",
"realmOrganizing": "Organize a realm",
"realmAlias": "Alias (Identifier)",
"realmName": "Name",
"realmDescription": "Description",
"realmPublic": "Public Realm",
"realmCommunity": "Community Realm",
"realmAvatar": "Realm avatar",
"realmBanner": "Realm banner",
"realmDetail": "Realm detail",
"realmMember": "Realm member",
"realmMembers": "Realm members",
"realmMembersAdd": "Add realm members",
"realmMembersAddHint": "Into @realm",
"realmAdjust": "Realm adjustment",
"realmSettings": "Realm settings",
"realmEditingNotify": "You're editing realm @realm",
"realmLeaveConfirm": "Confirm realm quit",
"realmLeaveConfirmCaption": "Are you sure you want leave realm @realm? Your content published in this realm will not be deleted.",
"realmDeletionConfirm": "Confirm realm deletion",
"realmDeletionConfirmCaption": "Are you sure to delete realm @realm? This action cannot be undone!",
"channels": "Channels",
"channelNew": "Create a new channel",
"channelNewInRealmHint": "Create channel in realm @realm",
"channelOrganizing": "Organize a channel",
"channelOrganizeCommon": "Create regular channel",
"channelOrganizeDirect": "Create DM",
"channelOrganizeDirectHint": "Choose friend to create DM",
"channelInRealmNotify": "You're creating channel in realm @realm",
"channelEditingNotify": "You're editing channel @channel",
"channelAlias": "Alias (Identifier)",
"channelName": "Name",
"channelDescription": "Description",
"channelDirectDescription": "Direct message with @username",
"channelPublic": "Public channel",
"channelCommunity": "Community channel",
"channelMember": "Channel member",
"channelMembers": "Channel members",
"channelMembersAdd": "Add channel members",
"channelMembersAddHint": "Into @channel",
"channelType": "Channel type",
"channelTypeCommon": "Regular",
"channelTypeDirect": "DM",
"channelAdjust": "Channel adjustment",
"channelDetail": "Channel detail",
"channelSettings": "Channel settings",
"channelLeaveConfirm": "Confirm channel quit",
"channelLeaveConfirmCaption": "Are you sure to leave channel @channel? All your messages will be deleted!",
"channelDeletionConfirm": "Confirm channel deletion",
"channelDeletionConfirmCaption": "Are you sure to delete channel @channel? This action cannot be undone!",
"channelCategoryDirect": "DM",
"channelCategoryDirectHint": "Your direct messages",
"channelNotifyLevel": "Notify level",
"channelNotifyLevelAll": "All",
"channelNotifyLevelMentioned": "Only mentioned",
"channelNotifyLevelNone": "Ignore all",
"channelNotifyLevelApplied": "Your notification settings has been applied.",
"messageUnSync": "Messages Un-synced",
"messageUnSyncCaption": "@count message(s) still in un-synced.",
"messageSending": "Sending...",
"messageEditDesc": "Edited message @id",
"messageDeleteDesc": "Deleted message @id",
"messageCallStartDesc": "@user starts a call",
"messageCallEndDesc": "Call last for @duration",
"messageTypeUnsupported": "Unsupported Message: @type",
"messageInputPlaceholder": "Message @channel",
"messageActionList": "Actions of Message",
"messageDeletionConfirm": "Confirm message deletion",
"messageDeletionConfirmCaption": "Are your sure to delete message @id? This action cannot be undone!",
"call": "Call",
"callOngoing": "A call is ongoing...",
"callOngoingEmpty": "A call is on hold...",
"callOngoingParticipants": "@count people are calling...",
"callOngoingJoined": "Call last @duration",
"callJoin": "Join",
"callResume": "Resume",
"callMicrophone": "Microphone",
"callMicrophoneDisabled": "Microphone Disabled",
"callMicrophoneSelect": "Select Microphone",
"callCamera": "Camera",
"callCameraDisabled": "Camera Disabled",
"callCameraSelect": "Select Camera",
"callSpeakerSelect": "Select Speaker",
"callDisconnected": "Call Disconnected... @reason",
"callMicrophoneOn": "Turn Microphone On",
"callMicrophoneOff": "Turn Microphone Off",
"callCameraOn": "Turn Camera On",
"callCameraOff": "Turn Camera Off",
"callVideoFlip": "Flip Video Input",
"callSpeakerphoneToggle": "Toggle Speakerphone Mode",
"callScreenOn": "Start Screen Sharing",
"callScreenOff": "Stop Screen Sharing",
"callDisconnect": "Disconnect",
"callDisconnectCaption": "Are you sure you want to disconnect from this call? You can also just return to the page, and the call will continue in the background.",
"callParticipantAction": "Participant Actions",
"callParticipantMicrophoneOff": "Mute Participant",
"callParticipantMicrophoneOn": "Unmute Participant",
"callParticipantVideoOff": "Turn Off Participant Video",
"callParticipantVideoOn": "Turn On Participant Video",
"callAlreadyOngoing": "A call is already ongoing",
"badge": "Badge",
"badges": "Badges",
"badgeGrantAt": "Badge awarded on @date",
"badgeSolsynthStaff": "Solsynth Staff",
"badgeSolarOriginalCitizen": "Solar Network Natives",
"badgeGreatCommunityContributor": "Great Community Contributor",
"pushNotifyRegister": "Register Push Notification Device",
"pushNotifyRegisterCaption": "Activating push notifications allows you to get our latest notifications even when the app is completely closed. We use Apple's official push service on iOS/macOS devices; other devices provide push notifications through Google Firebase. To register a device for push notifications, you may need to connect to Google's servers and install the Google Framework on your device. Although you dismiss this dialog, this registration will be auto performed when you next time launch the app.",
"pushNotifyRegisterDone": "Push notifications has been activated.",
"pushNotifyRegisterFailed": "Unable to active push notification... @reason",
"accountChangeStatus": "Change Status",
"accountCustomStatus": "Set Custom Status",
"accountClearStatus": "Clear Status",
"accountStatusOnline": "Online",
"accountStatusSilent": "Do not Disturb",
"accountStatusSilentDesc": "The notification will stop popping up",
"accountStatusInvisible": "Invisible",
"accountStatusInvisibleDesc": "Will show as offline, but all features still remain normal",
"accountStatusOffline": "Offline",
"accountLastSeenAt": "@date ago online",
"accountStatusLabel": "Status Text",
"accountStatusClearAt": "Clear At",
"accountStatusNegative": "Negative",
"accountStatusNeutral": "Neutral",
"accountStatusPositive": "Positive",
"bsLoadingTheme": "Loading Theme",
"bsCheckForUpdate": "Checking For Updates",
"bsCheckForUpdateFailed": "Unable to Check Updates",
"bsCheckForUpdateNew": "Found New Version",
"bsCheckForUpdateDesc": "Please head to app store and update your app to latest version to prevent error happens and get latest functions.",
"bsCheckingServer": "Checking Server Status",
"bsCheckingServerFail": "Unable connect to server, check your network connection",
"bsCheckingServerDown": "Server currently unavailable, please retry later",
"bsAuthorizing": "Authorizing",
"bsEstablishingConn": "Establishing Connection",
"bsPreparingData": "Preparing User Data",
"bsRegisteringPushNotify": "Enabling Push Notifications",
"bsDismissibleErrorHint": "Click anywhere to ignore this error",
"postShareContent": "@content\n\n@username on the Solar Network\nCheck it out: @link",
"postShareSubject": "@username posted a post on the Solar Network",
"themeColor": "Global Theme Color",
"themeColorRed": "Modern Red",
"themeColorBlue": "Classic Blue",
"themeColorMiku": "Miku Blue",
"themeColorKagamine": "Kagamine Yellow",
"themeColorLuka": "Luka Pink",
"stickerDeletionConfirm": "Confirm sticker delete",
"stickerDeletionConfirmCaption": "Are you sure to delete sticker @name? This action cannot be undo.",
"themeColorApplied": "Global theme color has been applied.",
"attachmentSaved": "Attachment saved to your system album.",
"cropImage": "Crop Image",
"stickerUploader": "Upload sticker",
"stickerUploaderAttachmentNew": "Upload sticker",
"stickerUploaderAttachment": "Attachment serial number",
"stickerUploaderPack": "Sticker pack serial number",
"stickerUploaderPackHint": "Don't have pack id? Head to creator platform and create one!",
"stickerUploaderAlias": "Alias",
"stickerUploaderAliasHint": "Will be used as a placeholder with the sticker pack prefix when entered.",
"stickerUploaderName": "Name",
"stickerUploaderNameHint": "A human-friendly name given to the user in the sticker selection interface.",
"readMore": "Read more",
"attachmentUnload": "Not Loaded",
"attachmentUnloadCaption": "In order to save traffic, this attachment is not loaded automatically. Click it to start loading.",
"callStatusConnected": "Connected",
"callStatusDisconnected": "Disconnected",
"callStatusConnecting": "Connecting",
"callStatusReconnected": "Reconnecting",
"messageOutOfSync": "May Out of Sync with Server",
"messageOutOfSyncCaption": "Since the App has entered the background, there may be a time difference between the message list and the server. Click to Refresh.",
"localDatabaseWipe": "Wipe local database",
"localDatabaseSize": "Overall database size: @size",
"unknown": "Unknown",
"collapse": "Collapse",
"expand": "Expand",
"typingMessage": "@user are typing...",
"userLevel0": "Newbie",
"userLevel1": "Novice",
"userLevel2": "Apprentice",
"userLevel3": "Explorer",
"userLevel4": "Adventurer",
"userLevel5": "Warrior",
"userLevel6": "Knight",
"userLevel7": "Champion",
"userLevel8": "Hero",
"userLevel9": "Master",
"userLevel10": "Grandmaster",
"userLevel11": "Legend",
"userLevel12": "Mythic",
"userLevel13": "Immortal",
"postBrowsingIn": "Browsing in @region",
"needRestartToApply": "Restart the application to take effect",
"holdToSeeDetail": "Long press / Mouse hover to see detail",
"subscribe": "Subscribe",
"subscribed": "Subscribed",
"unsubscribe": "Unsubscribe",
"preferences": "Preferences",
"notificationPreferences": "Notification preferences",
"notificationTopicPostFeedback": "Post feedbacks",
"notificationTopicPostSubscription": "Post subscriptions",
"preferencesApplied": "Preferences has been applied.",
"save": "Save",
"updateAvailable": "Update available",
"updateAvailableDesc": "There is an update available (@from to @to). Do you want to download and install it now? You can still use the app normally while waiting for the download to complete.",
"update": "Update",
"updateCheckStrictly": "Strict mode",
"updateCheckStrictlyDesc": "If enabled, the app will ask for updating once the local version is different from remote one.",
"updateMayAvailable": "App version @version is available, you can update from app store or our website.",
"termAccept": "I've read and agree to Solar Network's Terms",
"termAcceptDesc": "Including but not limited to \"User Agreement\" and \"Privacy Policy\"",
"termAcceptLink": "View terms",
"termAcceptNextWithAgree": "By clicking the \"Next\", it means you agree to our terms and its updates. You should already agreed with them while you sign up.",
"termRelated": "Related Terms",
"appDetails": "App Details",
"projectWebsite": "Project Website",
"iAmNotRobot": "I'm not a Robot",
"report": "Report",
"reportAbuse": "Report abuse",
"reportAbuseDesc": "Report any violation of service terms",
"reportAbuseResource": "Resource identifier",
"reportAbuseReason": "Report reason",
"reportSubmitted": "Report submitted, thank you for your contribution. We will send a notification about the result of the report within 24 hours for you.",
"accountDeletion": "Request account deletion",
"accountDeletionDesc": "Delete the current account and all its data. Note that this action is irreversible!",
"accountDeletionConfirm": "Confirm request account deletion",
"accountDeletionConfirmDesc": "Are you sure to delete account @account? You will receive a confirmation email with a link to confirm the deletion of the account within 24 hours. Note that this action is irreversible, and all data associated with the account will be deleted, and you should be careful about it.",
"accountDeletionRequested": "Account deletion requested, check your inbox to confirm the request.",
"slideToConfirm": "Slide to confirm"
}

452
assets/locales/zh_cn.json Normal file
View File

@ -0,0 +1,452 @@
{
"done": "完成",
"hide": "隐藏",
"okay": "确认",
"home": "首页",
"next": "下一步",
"prev": "上一步",
"reset": "重置",
"cancel": "取消",
"confirm": "确认",
"leave": "离开",
"loading": "载入中…",
"guest": "游客",
"about": "关于",
"edit": "编辑",
"delete": "删除",
"settings": "设置",
"settingsNotificationBgService": "常驻通知服务",
"settingsNotificationBgServiceDesc": "在设备常驻一个通知服务,使得部分不支持推送通知的设备可以在后台收到通知;启用该功能的情况下不会向服务器注册推送通知,并且你会始终在他人眼中成为在线(隐身除外);可能需要在设置中关闭电量与流量优化。",
"page": "页面",
"draft": "草稿",
"draftSave": "存为草稿",
"draftBox": "草稿箱",
"more": "更多",
"share": "分享",
"shareNoUri": "分享文字内容",
"alias": "别名",
"feed": "资讯",
"explore": "探索",
"posts": "帖子",
"unlink": "移除链接",
"dashboard": "仪表盘",
"today": "今日",
"yesterday": "昨日",
"feedSearch": "搜索资讯",
"feedSearchWithTag": "检索带有 #@key 标签的资讯",
"feedSearchWithCategory": "检索位于分类 @category 的资讯",
"feedUnreadCount": "@count 条你可能错过的帖子",
"messages": "消息",
"messagesUnreadCount": "@count 条未读的消息",
"dailySign": "签到",
"dailySignAction": "烧香拜佛",
"dailySignHistoryAction": "查看运势历史",
"dailySignNone": "今日未拜访佛祖",
"dailySignTier0": "诸事不宜",
"dailySignTier1": "有些不宜",
"dailySignTier2": "平平淡淡",
"dailySignTier3": "有些事宜",
"dailySignTier4": "诸事皆宜",
"dailySignHistoryTitle": "运势历史",
"dailySignHistoryRecent": "近期运势",
"dailySignHistoryReward": "成果趋势",
"dashboardFooter": "占卜多少沾点玩,人生还得靠实力",
"visitProfilePage": "造访个人主页",
"profilePage": "主页",
"profilePosts": "帖子",
"profileAlbum": "相簿",
"chat": "聊天",
"apply": "应用",
"search": "搜索",
"post": "帖子",
"article": "文章",
"reply": "回复",
"repost": "转帖",
"openInAlbum": "在相簿中打开",
"openInBrowser": "在浏览器中打开",
"notification": "通知",
"notificationUnreadCount": "@count 条未读通知",
"errorHappened": "发生错误了",
"errorHappenedUnauthorized": "未经授权的请求,请登录或尝试重新登录。",
"errorHappenedRequestBad": "请求错误,服务器拒绝处理该请求,请检查您的请求数据。",
"errorHappenedRequestForbidden": "请求错误,权限不足。",
"errorHappenedRequestNotFound": "请求错误,请求的数据不存在。",
"errorHappenedRequestConnection": "网络请求失败,请检查连接状态与服务状态后再试。",
"errorHappenedRequestUnknown": "请求错误,类型未知,请将本提示完整截图提交反馈。",
"forgotPassword": "忘记密码",
"email": "邮件地址",
"username": "用户名",
"usernameInputHint": "同时支持邮箱 / 电话号码",
"nickname": "显示名",
"password": "密码",
"passwordOneTime": "一次性验证码",
"passwordInputHint": "忘记密码了?回到第一步以重置密码",
"passwordOneTimeInputHint": "检查你的收件箱或是授权器获得以验证码",
"title": "标题",
"description": "简介",
"birthday": "生日",
"firstName": "名称",
"lastName": "姓氏",
"account": "账号",
"accountProfile": "个人资料",
"accountProfileApplied": "账户的资料已保存。",
"accountStickers": "贴图",
"accountFriend": "好友",
"accountFriendNew": "添加好友",
"accountFriendNewHint": "使用他人的用户名来发送一个好友请求吧!",
"accountFriendPending": "好友请求",
"accountFriendBlocked": "好友黑名单",
"accountFriendListHint": "左滑来拒绝,右滑来接受",
"accountFriendRequestSent": "好友请求已发送,等待处理对方中……",
"accountSuspended": "帐号被停用",
"accountSuspendedAt": "该帐号自 @date 起被停用",
"aspectRatio": "纵横比",
"aspectRatioSquare": "方型",
"aspectRatioPortrait": "竖型",
"aspectRatioLandscape": "横型",
"unsignedIn": "未登录",
"signin": "登录",
"signinRequired": "需要登录",
"signinRequiredHint": "登陆以获得 Solar Network 的全部功能使用权。",
"signinGreeting": "欢迎回来\nSolar Network",
"signinCaption": "登录以发表帖子、文章、创建领域、和你的朋友聊天,以及获取更多功能!",
"signinRiskDetected": "检测到风险,点击下一步按钮来打开一个网页,并通过在其上面登录来通过安全检查。",
"signinResetPasswordHint": "请先填写用户名以发送重置密码请求。",
"signinResetPasswordSent": "重置密码请求已发送,在绑定邮件收件箱可收取一份包含重置密码链接的邮件。",
"signinPickFactor": "选择一个\n验证方式",
"signinEnterPassword": "输入密码\n或验证码",
"signinMultiFactor": "@n 步验证",
"authFactorEmail": "邮箱一次性密码",
"authFactorPassword": "账户密码",
"signup": "注册",
"signupGreeting": "欢迎加入\nSolar Network",
"signupCaption": "在 Solarpass 注册一个账号以获得整个 Solar Network 的存取权!",
"signupDone": "注册成功",
"signupDoneCaption": "你成功地注册了一个帐户,现在去尝试登陆吧!",
"signout": "登出",
"joinedAt": "加入于 @date",
"riskDetection": "检测到风险",
"matureContent": "评级内容",
"matureContentCaption": "该内容已被评级为家长指导级或以上,这可能说明内容包含一系列不友好的成分",
"notifyAllRead": "已读所有通知",
"notifyEmpty": "通知箱为空",
"notifyEmptyCaption": "看起来最近没发生什么呢",
"totalSocialCreditPoints": "社会信用点",
"totalPostCount": "总帖数",
"totalUpvote": "获顶数",
"totalDownvote": "获踩数",
"clear": "清除",
"pinPost": "置顶本帖",
"unpinPost": "取消置顶本帖",
"postRestoreFromLocal": "内容从本地暂存回复",
"postAutoSaveAt": "已自动保存于 @date",
"postCategoriesAndTags": "分类与标签",
"postPublishDate": "发布时间",
"postPublishAt": "发布帖子于",
"postPublishedUntil": "取消发布于",
"postPublishZone": "帖子发布区",
"postPublishZoneNone": "无所属领域",
"postVisibility": "帖子可见性",
"postVisibilityAll": "所有人可见",
"postVisibilityFriends": "仅好友可见",
"postVisibilitySelected": "选中者可见",
"postVisibilityFiltered": "选中者不可见",
"postVisibilityNone": "仅自己可见",
"postVisibleUsers": "可见帖子者",
"postInvisibleUsers": "隐藏帖子者",
"postOverview": "帖子概览",
"postThumbnail": "帖子缩略图",
"postThumbnailAttachmentNew": "上传附件作为缩略图",
"postThumbnailAttachment": "附件序列号",
"postPinned": "已置顶",
"postEditorModeStory": "发个帖子",
"postEditorModeArticle": "撰写文章",
"postEditor": "帖子编辑器",
"articleEditor": "撰写文章",
"articleDetail": "文章详情",
"draftBoxOpen": "打开草稿箱",
"postListNews": "新鲜事",
"postListFriends": "好友圈",
"postListShuffle": "打乱看",
"attachmentThumbnail": "附件缩略图",
"attachmentThumbnailAttachmentNew": "上传附件作为缩略图",
"attachmentThumbnailAttachment": "附件序列号",
"postNew": "创建新帖子",
"postNewInRealmHint": "在领域 @realm 里发表新帖子",
"postAction": "发表",
"postEdited": "编辑于 @date",
"postNewCreated": "创建于 @date",
"postInRealm": "发表于 @realm",
"attachmentHint": "@count 个附件",
"postDetail": "帖子详情",
"postReplies": "帖子回复",
"postPublish": "编辑帖子",
"postIdentityNotify": "你将会以本身份发表帖子",
"postContentPlaceholder": "发生什么事了?!",
"postTagsPlaceholder": "标签",
"postReaction": "帖子的反应",
"postActionList": "帖子的操作",
"postReplyAction": "发表一则回复",
"postRepliedNotify": "回了一个 @username 的帖子",
"postRepostedNotify": "转了一个 @username 的帖子",
"postInRealmNotify": "你正在领域 @realm 中发表帖子",
"postEditingNotify": "你正在编辑一个你发布的帖子",
"postReplyingNotify": "你正在回一个来自 @username 的帖子",
"postRepostingNotify": "你正在转一个来自 @username 的帖子",
"postDeletionConfirm": "确认删除帖子",
"postDeletionConfirmCaption": "你确定要删除帖子 “@content” 吗?该操作不可撤销。",
"reactAdd": "作出反应",
"reactCompleted": "你的反应已被添加",
"reactUncompleted": "你的反应已被移除",
"attachmentUploadBy": "由上传",
"attachmentAutoUpload": "自动上传",
"attachmentUploadQueue": "上传队列",
"attachmentUploadQueueStart": "整队上传",
"attachmentUploadInProgress": "有附件正在上传,请等待所有附件上传完毕后再进行操作……",
"attachmentAttached": "已附附件",
"attachmentUploadBlocked": "上传受阻,当前已有任务进行中……",
"attachmentAdd": "附加附件",
"attachmentAddGalleryPhoto": "相册照片",
"attachmentAddGalleryVideo": "相册视频",
"attachmentAddCameraPhoto": "拍摄图片",
"attachmentAddCameraVideo": "拍摄视频",
"attachmentAddClipboard": "粘贴文件",
"attachmentAddFile": "附加文件",
"attachmentAddLink": "链接附件",
"attachmentAddLinkHint": "输入附件的神秘代号来链接对应附件",
"attachmentAddLinkInput": "神秘代号",
"attachmentSetting": "调整附件",
"attachmentAlt": "替代文字",
"attachmentLoadFailed": "加载失败",
"attachmentLoadFailedCaption": "有错误发生于加载附件元数据的过程中了…",
"attachmentUploading": "上传附件 @name 中…",
"attachmentUploadingWebMode": "上传附件 @name 中… 由于浏览器单线程限制,计算所需资源可能会导致界面卡顿…",
"realm": "领域",
"realms": "领域",
"realmOrganizing": "组织领域",
"realmAlias": "别称(标识符)",
"realmName": "显示名称",
"realmDescription": "领域简介",
"realmPublic": "公开领域",
"realmCommunity": "社区领域",
"realmAvatar": "领域头像",
"realmBanner": "领域横幅",
"realmDetail": "领域详情",
"realmMember": "领域成员",
"realmMembers": "领域成员",
"realmMembersAdd": "添加领域成员",
"realmMembersAddHint": "到 @realm",
"realmAdjust": "调整领域",
"realmSettings": "领域设置",
"realmEditingNotify": "你正在编辑领域 @realm",
"realmLeaveConfirm": "确认离开领域",
"realmLeaveConfirmCaption": "你确认要离开领域 @realm 吗?你在该领域发表的内容不会被删除。",
"realmDeletionConfirm": "确认删除领域",
"realmDeletionConfirmCaption": "你确定要删除领域 @realm 嘛?该操作不可撤销。",
"channels": "频道",
"channelNew": "创建新频道",
"channelNewInRealmHint": "在领域 @realm 里创建新频道",
"channelOrganizing": "组织频道",
"channelOrganizeCommon": "创建普通频道",
"channelOrganizeDirect": "创建私信频道",
"channelOrganizeDirectHint": "选择好友来创建私信",
"channelInRealmNotify": "你正在领域 @realm 中创建频道",
"channelEditingNotify": "你正在编辑频道 @channel",
"channelAlias": "别称(标识符)",
"channelName": "显示名称",
"channelDescription": "频道简介",
"channelDirectDescription": "与 @username 的私聊",
"channelPublic": "公开频道",
"channelCommunity": "社区频道",
"channelMember": "频道成员",
"channelMembers": "频道成员",
"channelMembersAdd": "添加频道成员",
"channelMembersAddHint": "到 @channel",
"channelType": "频道类型",
"channelTypeCommon": "普通频道",
"channelTypeDirect": "私信聊天",
"channelAdjust": "调整频道",
"channelDetail": "频道详情",
"channelSettings": "频道设置",
"channelLeaveConfirm": "确认离开频道",
"channelLeaveConfirmCaption": "你确认要离开频道 @channel 吗?你在这个频道的消息将被删除。",
"channelDeletionConfirm": "确认删除频道",
"channelDeletionConfirmCaption": "你确认要删除频道 @channel 吗?该操作不可撤销。",
"channelCategoryDirect": "私聊频道",
"channelCategoryDirectHint": "你的所有私聊频道",
"channelNotifyLevel": "通知等级",
"channelNotifyLevelAll": "全部通知",
"channelNotifyLevelMentioned": "仅提及",
"channelNotifyLevelNone": "忽略一切",
"channelNotifyLevelApplied": "你的通知设置已经应用。",
"messageUnSync": "消息未同步",
"messageUnSyncCaption": "还有 @count 条消息未同步",
"messageSending": "消息发送中…",
"messageEditDesc": "修改了消息 @id",
"messageDeleteDesc": "删除了消息 @id",
"messageCallStartDesc": "@user 发起了一次通话",
"messageCallEndDesc": "通话持续了 @duration",
"messageTypeUnsupported": "不支持的消息类型 @type",
"messageInputPlaceholder": "发消息于 @channel",
"messageActionList": "消息的操作",
"messageDeletionConfirm": "确认删除消息",
"messageDeletionConfirmCaption": "你确定要删除消息 @id 吗?该操作不可撤销。",
"call": "通话",
"callOngoing": "一则通话正在进行中…",
"callOngoingEmpty": "一则通话待机中…",
"callOngoingParticipants": "@count 人正在进行通话…",
"callOngoingJoined": "通话进行 @duration",
"callJoin": "加入",
"callResume": "恢复",
"callMicrophone": "麦克风",
"callMicrophoneDisabled": "麦克风禁用",
"callMicrophoneSelect": "选择麦克风",
"callCamera": "摄像头",
"callCameraDisabled": "摄像头禁用",
"callCameraSelect": "选择摄像头",
"callSpeakerSelect": "选择扬声器",
"callDisconnected": "通话已断开… @reason",
"callMicrophoneOn": "开启麦克风",
"callMicrophoneOff": "关闭麦克风",
"callCameraOn": "开启摄像头",
"callCameraOff": "关闭摄像头",
"callVideoFlip": "翻转视频输入",
"callSpeakerphoneToggle": "切换扬声器模式",
"callScreenOn": "启动屏幕分享",
"callScreenOff": "关闭屏幕分享",
"callDisconnect": "断开连接",
"callDisconnectCaption": "你确定要断开与该则通话的连接吗?你也可以直接返回页面,通话将在后台继续。",
"callParticipantAction": "通话参与者的操作",
"callParticipantMicrophoneOff": "静音参与者",
"callParticipantMicrophoneOn": "解除静音参与者",
"callParticipantVideoOff": "静音参与者",
"callParticipantVideoOn": "解除静音参与者",
"callAlreadyOngoing": "当前正在进行一则通话",
"badge": "徽章",
"badges": "徽章",
"badgeGrantAt": "徽章颁发于 @date",
"badgeSolsynthStaff": "Solsynth 工作人员",
"badgeSolarOriginalCitizen": "Solar Network 原住民",
"badgeGreatCommunityContributor": "优秀社区贡献者",
"pushNotifyRegister": "注册推送通知设备",
"pushNotifyRegisterCaption": "激活推送通知便可以让你在应用程序完全关闭的时候仍然获取到我们最新的通知。在 iOS/macOS 设备上我们使用 Apple 官方的推送服务;其他设备则通过 Google Firebase 提供推送通知。要注册推送通知设备,您可能需要连接到 Google 的服务器(在中国大陆不可用)并在您的设备上安装 Google Framework。即使您关闭此对话框下次启动应用程序时仍会自动执行此注册。",
"pushNotifyRegisterDone": "推送通知已成功激活",
"pushNotifyRegisterFailed": "推送通知激活失败…… @reason",
"accountChangeStatus": "变更状态",
"accountCustomStatus": "自定义状态",
"accountClearStatus": "清除状态",
"accountStatusOnline": "在线",
"accountStatusSilent": "请勿打扰",
"accountStatusSilentDesc": "将会暂停所有通知推送",
"accountStatusInvisible": "隐身",
"accountStatusInvisibleDesc": "将会在他人界面显示离线,但不影响功能使用",
"accountStatusOffline": "离线",
"accountLastSeenAt": "最后上线于 @date 前",
"accountStatusLabel": "状态文字",
"accountStatusClearAt": "清除状态于",
"accountStatusNegative": "负面",
"accountStatusNeutral": "中性",
"accountStatusPositive": "积极",
"bsLoadingTheme": "正在装载主题",
"bsCheckForUpdate": "正在检查更新",
"bsCheckForUpdateFailed": "无法检查更新",
"bsCheckForUpdateNew": "发现新版本",
"bsCheckForUpdateDesc": "请前往应用商店并将您的应用程序更新到最新版本,以防止出现错误并获取最新功能。",
"bsCheckingServer": "检查服务器状态中",
"bsCheckingServerFail": "无法连接至服务器,请检查你的网络连接状态",
"bsCheckingServerDown": "当前服务器不可用,请稍后重试",
"bsAuthorizing": "正在授权中",
"bsEstablishingConn": "部署连接中",
"bsPreparingData": "正在准备用户资料",
"bsRegisteringPushNotify": "正在启用推送通知",
"bsDismissibleErrorHint": "点击任意地方忽略此错误",
"postShareContent": "@content\n\n@username 在 Solar Network\n原帖地址@link",
"postShareSubject": "@username 在 Solar Network 上发布了一篇帖子",
"themeColor": "全局主题色",
"themeColorRed": "现代红",
"themeColorBlue": "经典蓝",
"themeColorMiku": "未来蓝",
"themeColorKagamine": "镜音黄",
"themeColorLuka": "流音粉",
"themeColorApplied": "全局主题颜色已应用",
"stickerDeletionConfirm": "确认删除贴图",
"stickerDeletionConfirmCaption": "你确认要删除贴图 @name 吗?该操作不可撤销。",
"attachmentSaved": "附件已保存到系统相册",
"cropImage": "裁剪图片",
"stickerUploader": "上传贴图",
"stickerUploaderAttachmentNew": "上传附件作为贴图",
"stickerUploaderAttachment": "附件序列号",
"stickerUploaderPack": "贴图包序号",
"stickerUploaderPackHint": "没有该序号?请转到我们的创作者平台创建一个贴图包。",
"stickerUploaderAlias": "贴图别名",
"stickerUploaderAliasHint": "将会在输入时使用和贴图包前缀组成占位符。",
"stickerUploaderName": "贴图名称",
"stickerUploaderNameHint": "在贴图选择界面提供给用户的人类友好名称。",
"readMore": "阅读更多",
"attachmentUnload": "附件未加载",
"attachmentUnloadCaption": "为了节省流量,本附件未自动加载,点一下来开始加载。",
"callStatusConnected": "已连接",
"callStatusDisconnected": "已断开",
"callStatusConnecting": "连接中",
"callStatusReconnected": "重连中",
"messageOutOfSync": "消息可能与服务器脱节",
"messageOutOfSyncCaption": "由于 App 进入后台,消息列表可能与服务器存在时差,点击刷新。",
"localDatabaseWipe": "清除本地数据库",
"localDatabaseSize": "本地数据库大小:@size",
"unknown": "未知",
"collapse": "折叠",
"expand": "展开",
"typingMessage": "@user 正在输入中…",
"userLevel0": "不慕名利",
"userLevel1": "初出茅庐",
"userLevel2": "小试牛刀",
"userLevel3": "磨杵成针",
"userLevel4": "披荆斩棘",
"userLevel5": "力挽狂澜",
"userLevel6": "一骑当千",
"userLevel7": "所向披靡",
"userLevel8": "气吞山河",
"userLevel9": "登峰造极",
"userLevel10": "出神入化",
"userLevel11": "名垂千古",
"userLevel12": "独占鳌头",
"userLevel13": "万古流芳",
"postBrowsingIn": "浏览 @region 内的帖子中",
"needRestartToApply": "需要重启应用来生效",
"holdToSeeDetail": "长按 / 鼠标悬浮来查看详情",
"subscribe": "订阅",
"subscribed": "已订阅",
"unsubscribe": "取消订阅",
"preferences": "偏好设置",
"notificationPreferences": "通知偏好设置",
"notificationTopicPostFeedback": "帖子反馈",
"notificationTopicPostSubscription": "订阅源",
"preferencesApplied": "偏好设置已应用",
"save": "保存",
"updateAvailable": "有可用更新",
"updateAvailableDesc": "有可用更新 (@from 到 @to) 你想现在下载安装吗?在等待下载期间你仍可以正常使用。",
"update": "更新",
"updateCheckStrictly": "严格模式",
"updateCheckStrictlyDesc": "如果启用,应用程序将会在本地版本与远程版本不同时询问更新,而不会检查版本号大小。",
"updateMayAvailable": "版本 @version 现已可用,你可以前往应用商店或是我们的官网下载更新。",
"termAccept": "我已阅读并同意 Solar Network 各项条款",
"termAcceptDesc": "包括但不限于《用户守则》和《隐私政策》",
"termAcceptLink": "浏览条款",
"termAcceptNextWithAgree": "点击 “下一步”,即表示你同意我们的各项条款,包括其之后的更新。你应该在注册时已经同意过了。",
"termRelated": "相关条款",
"projectWebsite": "项目网站",
"appDetails": "应用详情",
"iAmNotRobot": "我不是机器人",
"report": "举报",
"reportAbuse": "举报滥用",
"reportAbuseDesc": "举报任何违反服务条款的行为",
"reportAbuseResource": "举报的资源",
"reportAbuseReason": "举报的原因",
"reportSubmitted": "举报已提交,感谢你的贡献。我们将通过通知在 24 小时内通知该举报的处理结果。",
"accountDeletion": "请求删除账号",
"accountDeletionDesc": "删除目前登陆的账号,及其所有的数据。注意,该操作不可撤销!",
"accountDeletionConfirm": "确认账号删除请求",
"accountDeletionConfirmDesc": "你确定要删除账号 @account 吗?你将会在其绑定的主要邮件地址收到一封包含着确认删除账号连接的邮件,在二十四小时内使用该连接即可完成删除账号。注意,本操作不可撤销,并且账号创建或关联的所有数据都将被删除,请三思而后行。",
"accountDeletionRequested": "已请求删除账号,检查你的收件箱来确认请求。",
"slideToConfirm": "滑动来确认"
}

View File

@ -1,4 +1,5 @@
description: This file stores settings for Dart & Flutter DevTools. description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions: extensions:
- provider: true - provider: true
- drift: true

View File

@ -54,26 +54,26 @@ PODS:
- Firebase/Performance (11.0.0): - Firebase/Performance (11.0.0):
- Firebase/CoreOnly - Firebase/CoreOnly
- FirebasePerformance (~> 11.0.0) - FirebasePerformance (~> 11.0.0)
- firebase_analytics (11.3.0): - firebase_analytics (11.3.2):
- Firebase/Analytics (= 11.0.0) - Firebase/Analytics (= 11.0.0)
- firebase_core - firebase_core
- Flutter - Flutter
- firebase_core (3.4.0): - firebase_core (3.5.0):
- Firebase/CoreOnly (= 11.0.0) - Firebase/CoreOnly (= 11.0.0)
- Flutter - Flutter
- firebase_crashlytics (4.1.0): - firebase_crashlytics (4.1.2):
- Firebase/Crashlytics (= 11.0.0) - Firebase/Crashlytics (= 11.0.0)
- firebase_core - firebase_core
- Flutter - Flutter
- firebase_messaging (15.1.0): - firebase_messaging (15.1.2):
- Firebase/Messaging (= 11.0.0) - Firebase/Messaging (= 11.0.0)
- firebase_core - firebase_core
- Flutter - Flutter
- firebase_performance (0.10.0-5): - firebase_performance (0.10.0-7):
- Firebase/Performance (= 11.0.0) - Firebase/Performance (= 11.0.0)
- firebase_core - firebase_core
- Flutter - Flutter
- FirebaseABTesting (11.1.0): - FirebaseABTesting (11.2.0):
- FirebaseCore (~> 11.0) - FirebaseCore (~> 11.0)
- FirebaseAnalytics (11.0.0): - FirebaseAnalytics (11.0.0):
- FirebaseAnalytics/AdIdSupport (= 11.0.0) - FirebaseAnalytics/AdIdSupport (= 11.0.0)
@ -97,9 +97,9 @@ PODS:
- FirebaseCoreInternal (~> 11.0) - FirebaseCoreInternal (~> 11.0)
- GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/Logger (~> 8.0) - GoogleUtilities/Logger (~> 8.0)
- FirebaseCoreExtension (11.1.0): - FirebaseCoreExtension (11.2.0):
- FirebaseCore (~> 11.0) - FirebaseCore (~> 11.0)
- FirebaseCoreInternal (11.1.0): - FirebaseCoreInternal (11.2.0):
- "GoogleUtilities/NSData+zlib (~> 8.0)" - "GoogleUtilities/NSData+zlib (~> 8.0)"
- FirebaseCrashlytics (11.0.0): - FirebaseCrashlytics (11.0.0):
- FirebaseCore (~> 11.0) - FirebaseCore (~> 11.0)
@ -110,7 +110,7 @@ PODS:
- GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/Environment (~> 8.0)
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4) - PromisesObjC (~> 2.4)
- FirebaseInstallations (11.1.0): - FirebaseInstallations (11.2.0):
- FirebaseCore (~> 11.0) - FirebaseCore (~> 11.0)
- GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0) - GoogleUtilities/UserDefaults (~> 8.0)
@ -134,7 +134,7 @@ PODS:
- GoogleUtilities/MethodSwizzler (~> 8.0) - GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0) - GoogleUtilities/UserDefaults (~> 8.0)
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- FirebaseRemoteConfig (11.1.0): - FirebaseRemoteConfig (11.2.0):
- FirebaseABTesting (~> 11.0) - FirebaseABTesting (~> 11.0)
- FirebaseCore (~> 11.0) - FirebaseCore (~> 11.0)
- FirebaseInstallations (~> 11.0) - FirebaseInstallations (~> 11.0)
@ -142,8 +142,8 @@ PODS:
- FirebaseSharedSwift (~> 11.0) - FirebaseSharedSwift (~> 11.0)
- GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/Environment (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)" - "GoogleUtilities/NSData+zlib (~> 8.0)"
- FirebaseRemoteConfigInterop (11.1.0) - FirebaseRemoteConfigInterop (11.2.0)
- FirebaseSessions (11.1.0): - FirebaseSessions (11.2.0):
- FirebaseCore (~> 11.0) - FirebaseCore (~> 11.0)
- FirebaseCoreExtension (~> 11.0) - FirebaseCoreExtension (~> 11.0)
- FirebaseInstallations (~> 11.0) - FirebaseInstallations (~> 11.0)
@ -152,10 +152,16 @@ PODS:
- GoogleUtilities/UserDefaults (~> 8.0) - GoogleUtilities/UserDefaults (~> 8.0)
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- PromisesSwift (~> 2.1) - PromisesSwift (~> 2.1)
- FirebaseSharedSwift (11.1.0) - FirebaseSharedSwift (11.2.0)
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_app_update (0.0.1):
- Flutter
- flutter_background_service_ios (0.0.3):
- Flutter
- flutter_keyboard_visibility (0.0.1): - flutter_keyboard_visibility (0.0.1):
- Flutter - Flutter
- flutter_local_notifications (0.0.1):
- Flutter
- flutter_native_splash (0.0.1): - flutter_native_splash (0.0.1):
- Flutter - Flutter
- flutter_secure_storage (6.0.0): - flutter_secure_storage (6.0.0):
@ -221,7 +227,7 @@ PODS:
- TOCropViewController (~> 2.7.4) - TOCropViewController (~> 2.7.4)
- image_picker_ios (0.0.1): - image_picker_ios (0.0.1):
- Flutter - Flutter
- livekit_client (2.2.4): - livekit_client (2.2.6):
- Flutter - Flutter
- WebRTC-SDK (= 125.6422.04) - WebRTC-SDK (= 125.6422.04)
- media_kit_libs_ios_video (1.0.4): - media_kit_libs_ios_video (1.0.4):
@ -264,6 +270,24 @@ PODS:
- sqflite (0.0.3): - sqflite (0.0.3):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- "sqlite3 (3.46.1+1)":
- "sqlite3/common (= 3.46.1+1)"
- "sqlite3/common (3.46.1+1)"
- "sqlite3/dbstatvtab (3.46.1+1)":
- sqlite3/common
- "sqlite3/fts5 (3.46.1+1)":
- sqlite3/common
- "sqlite3/perf-threadsafe (3.46.1+1)":
- sqlite3/common
- "sqlite3/rtree (3.46.1+1)":
- sqlite3/common
- sqlite3_flutter_libs (0.0.1):
- Flutter
- "sqlite3 (~> 3.46.0+1)"
- sqlite3/dbstatvtab
- sqlite3/fts5
- sqlite3/perf-threadsafe
- sqlite3/rtree
- SwiftyGif (5.4.5) - SwiftyGif (5.4.5)
- TOCropViewController (2.7.4) - TOCropViewController (2.7.4)
- url_launcher_ios (0.0.1): - url_launcher_ios (0.0.1):
@ -284,7 +308,10 @@ DEPENDENCIES:
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- firebase_performance (from `.symlinks/plugins/firebase_performance/ios`) - firebase_performance (from `.symlinks/plugins/firebase_performance/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`)
- flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`)
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- 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_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
@ -305,6 +332,7 @@ DEPENDENCIES:
- share_plus (from `.symlinks/plugins/share_plus/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`) - sqflite (from `.symlinks/plugins/sqflite/darwin`)
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- volume_controller (from `.symlinks/plugins/volume_controller/ios`) - volume_controller (from `.symlinks/plugins/volume_controller/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
@ -334,6 +362,7 @@ SPEC REPOS:
- PromisesObjC - PromisesObjC
- PromisesSwift - PromisesSwift
- SDWebImage - SDWebImage
- sqlite3
- SwiftyGif - SwiftyGif
- TOCropViewController - TOCropViewController
- WebRTC-SDK - WebRTC-SDK
@ -357,8 +386,14 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/firebase_performance/ios" :path: ".symlinks/plugins/firebase_performance/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
flutter_app_update:
:path: ".symlinks/plugins/flutter_app_update/ios"
flutter_background_service_ios:
:path: ".symlinks/plugins/flutter_background_service_ios/ios"
flutter_keyboard_visibility: flutter_keyboard_visibility:
:path: ".symlinks/plugins/flutter_keyboard_visibility/ios" :path: ".symlinks/plugins/flutter_keyboard_visibility/ios"
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_native_splash: flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios" :path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage: flutter_secure_storage:
@ -399,6 +434,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin" :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite: sqflite:
:path: ".symlinks/plugins/sqflite/darwin" :path: ".symlinks/plugins/sqflite/darwin"
sqlite3_flutter_libs:
:path: ".symlinks/plugins/sqlite3_flutter_libs/ios"
url_launcher_ios: url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios" :path: ".symlinks/plugins/url_launcher_ios/ios"
volume_controller: volume_controller:
@ -413,26 +450,29 @@ SPEC CHECKSUMS:
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
Firebase: 9f574c08c2396885b5e7e100ed4293d956218af9 Firebase: 9f574c08c2396885b5e7e100ed4293d956218af9
firebase_analytics: 1a66fe8d4375eccff44671ea37897683a78b2675 firebase_analytics: 4fd10182fd08bb8358f26ac8aca8dad7b6d0f592
firebase_core: ceec591a66629daaee82d3321551692c4a871493 firebase_core: 2ec6b789859c7c24766344ec71fdf78639402d56
firebase_crashlytics: e4f04180f443d5a8b56fbc0685bdbd7d90dd26f0 firebase_crashlytics: 60630a0f91ee432275fa1660fd8593079761448a
firebase_messaging: 15d8b557010f3bb7b98d0302e1c7c8fbcd244425 firebase_messaging: a18e1e02b2e8e69097c8173e0c851be223b21c50
firebase_performance: d373c742649e2d85d92cc223b4511c3d132887ef firebase_performance: 12d45fdf120992fa879d990929bf73d4a5ced053
FirebaseABTesting: c2e22c3aab99afa81d0561708b2c1c356c556976 FirebaseABTesting: 2104d957ce33888a3d6f3bde298cdee376dde8f1
FirebaseAnalytics: 27eb78b97880ea4a004839b9bac0b58880f5a92a FirebaseAnalytics: 27eb78b97880ea4a004839b9bac0b58880f5a92a
FirebaseCore: 3cf438f431f18c12cdf2aaf64434648b63f7e383 FirebaseCore: 3cf438f431f18c12cdf2aaf64434648b63f7e383
FirebaseCoreExtension: aa5c9779c2d0d39d83f1ceb3fdbafe80c4feecfa FirebaseCoreExtension: cda74ddfb001224bd8fd1d6e74698b4ed07803de
FirebaseCoreInternal: adefedc9a88dbe393c4884640a73ec9e8e790f8c FirebaseCoreInternal: 0c569513412da9f3b31bd0b340013bbee8f295c5
FirebaseCrashlytics: 745d8f0221fe49c62865391d1bf56f5a12eeec0b FirebaseCrashlytics: 745d8f0221fe49c62865391d1bf56f5a12eeec0b
FirebaseInstallations: d0a8fea5a6fa91abc661591cf57c0f0d70863e57 FirebaseInstallations: 771177d89d6c451dc6e50085ec82e2fc77ed0a4a
FirebaseMessaging: d2d1d9c62c46dd2db49a952f7deb5b16ad2c9742 FirebaseMessaging: d2d1d9c62c46dd2db49a952f7deb5b16ad2c9742
FirebasePerformance: efdc02bacb1b4710588c9f867011605c081cdf79 FirebasePerformance: efdc02bacb1b4710588c9f867011605c081cdf79
FirebaseRemoteConfig: 05521e937b72e01847a7128da5a492327364c705 FirebaseRemoteConfig: fca0b2d017fc1de52b28a4e5bcf2007c1a840457
FirebaseRemoteConfigInterop: abf8b1bbc0bf1b84abd22b66746926410bf91a87 FirebaseRemoteConfigInterop: 477b26fdeb8fb5fbaf22fa9db5343b42289dc7db
FirebaseSessions: 78f137e68dc01ca71606169ba4ac73b98c13752a FirebaseSessions: adcec8b72d0066a385e3affcd1bcb1ebb3908ce6
FirebaseSharedSwift: 260a35e08943ec810d820a70bc0359136351d0c5 FirebaseSharedSwift: 7a0d78d155ede78407f0fdc89fbc914014c7c540
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc
flutter_background_service_ios: e30e0d3ee69e4cee66272d0c78eacd48c2e94aac
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
flutter_webrtc: 75b868e4f9e817c7a9a42ca4b6169063de4eec9f flutter_webrtc: 75b868e4f9e817c7a9a42ca4b6169063de4eec9f
@ -442,7 +482,7 @@ SPEC CHECKSUMS:
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
image_cropper: 37d40f62177c101ff4c164906d259ea2c3aa70cf image_cropper: 37d40f62177c101ff4c164906d259ea2c3aa70cf
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
livekit_client: d079c5f040d4bf2b80440ff0ae997725a183e4bc livekit_client: 20e01637431bc108dad451c8a11c1d206e1dd2cd
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1 media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
@ -460,6 +500,8 @@ SPEC CHECKSUMS:
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb
sqlite3_flutter_libs: c00457ebd31e59fa6bb830380ddba24d44fbcd3b
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654 TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe

View File

@ -616,6 +616,7 @@
buildSettings = { buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
@ -920,6 +921,7 @@
buildSettings = { buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
@ -947,6 +949,7 @@
buildSettings = { buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";

109
lib/background.dart Normal file
View File

@ -0,0 +1,109 @@
import 'dart:ui';
import 'package:flutter/material.dart' hide Notification;
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:solian/models/notification.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/websocket.dart';
FlutterBackgroundService? bgNotificationService;
void autoConfigureBackgroundNotificationService() async {
if (bgNotificationService != null) return;
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool('service_background_notification') != true) return;
bgNotificationService = FlutterBackgroundService();
await bgNotificationService!.configure(
androidConfiguration: AndroidConfiguration(
onStart: onBackgroundNotificationServiceStart,
autoStart: true,
autoStartOnBoot: true,
isForegroundMode: false,
),
// This feature won't be able to use on iOS
// We got APNs support covered
iosConfiguration: IosConfiguration(
autoStart: false,
),
);
}
void autoStartBackgroundNotificationService() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool('service_background_notification') != true) return;
if (bgNotificationService == null) return;
bgNotificationService!.startService();
}
void autoStopBackgroundNotificationService() async {
if (bgNotificationService == null) return;
if (await bgNotificationService!.isRunning()) {
bgNotificationService?.invoke('stopService');
}
}
@pragma('vm:entry-point')
void onBackgroundNotificationServiceStart(ServiceInstance service) async {
WidgetsFlutterBinding.ensureInitialized();
DartPluginRegistrant.ensureInitialized();
Get.put(AuthProvider());
Get.put(WebSocketProvider());
service.on('stopService').listen((event) {
service.stopSelf();
});
final auth = Get.find<AuthProvider>();
await auth.refreshAuthorizeStatus();
await auth.ensureCredentials();
if (!auth.isAuthorized.value) {
debugPrint(
'Background notification do nothing due to user didn\'t sign in.',
);
return;
}
const notificationChannelId = 'solian_notification_service';
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
final ws = Get.find<WebSocketProvider>();
await ws.connect();
debugPrint('Background notification has been started');
ws.stream.stream.listen(
(event) {
debugPrint(
'Background notification service incoming message: ${event.method} ${event.message}',
);
if (event.method == 'notifications.new' && event.payload != null) {
final data = Notification.fromJson(event.payload!);
debugPrint(
'Background notification service got a notification id=${data.id}',
);
flutterLocalNotificationsPlugin.show(
data.id,
data.title,
[data.subtitle, data.body].where((x) => x != null).join('\n'),
const NotificationDetails(
android: AndroidNotificationDetails(
notificationChannelId,
'Solian Notification Service',
channelDescription: 'Notifications that sent via Solar Network',
importance: Importance.high,
icon: 'mipmap/ic_launcher',
),
),
);
}
},
);
}

View File

@ -1,19 +1,24 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/platform.dart'; import 'package:solian/platform.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/channel.dart'; import 'package:solian/providers/content/channel.dart';
import 'package:solian/providers/content/realm.dart'; import 'package:solian/providers/content/realm.dart';
import 'package:solian/providers/relation.dart'; import 'package:solian/providers/relation.dart';
import 'package:solian/providers/stickers.dart';
import 'package:solian/providers/theme_switcher.dart'; import 'package:solian/providers/theme_switcher.dart';
import 'package:solian/providers/websocket.dart'; import 'package:solian/providers/websocket.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:solian/widgets/sized_container.dart'; import 'package:solian/widgets/sized_container.dart';
import 'package:flutter_app_update/flutter_app_update.dart';
import 'package:version/version.dart';
class BootstrapperShell extends StatefulWidget { class BootstrapperShell extends StatefulWidget {
final Widget child; final Widget child;
@ -35,6 +40,73 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
int _periodCursor = 0; int _periodCursor = 0;
final Completer _bootCompleter = Completer();
Future<void> _checkForUpdate() async {
if (PlatformInfo.isWeb) return;
try {
final prefs = await SharedPreferences.getInstance();
final info = await PackageInfo.fromPlatform();
final localVersionString = '${info.version}+${info.buildNumber}';
final resp = await GetConnect(
timeout: const Duration(seconds: 60),
).get(
'https://git.solsynth.dev/api/v1/repos/hydrogen/solian/tags?page=1&limit=1',
);
if (resp.statusCode != 200) {
throw RequestException(resp);
}
final remoteVersionString =
(resp.body as List).firstOrNull?['name'] ?? '0.0.0+0';
final remoteVersion = Version.parse(remoteVersionString.split('+').first);
final localVersion = Version.parse(localVersionString.split('+').first);
final remoteBuildNumber =
int.tryParse(remoteVersionString.split('+').last) ?? 0;
final localBuildNumber =
int.tryParse(localVersionString.split('+').last) ?? 0;
final strictUpdate = prefs.getBool('check_update_strictly') ?? false;
if (remoteVersion > localVersion ||
(remoteVersion == localVersion &&
remoteBuildNumber > localBuildNumber) ||
(remoteVersionString != localVersionString && strictUpdate)) {
if (PlatformInfo.isAndroid) {
context
.showConfirmDialog(
'updateAvailable'.tr,
'updateAvailableDesc'.trParams({
'from': localVersionString,
'to': remoteVersionString,
}),
)
.then((result) {
if (result) {
final model = UpdateModel(
'https://files.solsynth.dev/d/production01/solian/app-arm64-v8a-release.apk',
'solian-app-arm64-v8a-release.apk',
'ic_launcher',
'https://testflight.apple.com/join/YJ0lmN6O',
);
AzhonAppUpdate.update(model);
}
});
} else {
context.showInfoDialog(
'updateAvailable'.tr,
'bsCheckForUpdateDesc'.tr,
);
}
} else if (remoteVersionString != localVersionString) {
_bootCompleter.future.then((_) {
context.showSnackbar('updateMayAvailable'.trParams({
'version': remoteVersionString,
}));
});
}
} catch (e) {
context.showErrorDialog('Unable to check update: $e');
}
}
late final List<({String label, Future<void> Function() action})> _periods = [ late final List<({String label, Future<void> Function() action})> _periods = [
( (
label: 'bsLoadingTheme', label: 'bsLoadingTheme',
@ -42,36 +114,10 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
await context.read<ThemeSwitcher>().restoreTheme(); await context.read<ThemeSwitcher>().restoreTheme();
}, },
), ),
(
label: 'bsCheckForUpdate',
action: () async {
if (PlatformInfo.isWeb) return;
try {
final info = await PackageInfo.fromPlatform();
final localVersionString = '${info.version}+${info.buildNumber}';
final resp = await GetConnect().get(
'https://git.solsynth.dev/api/v1/repos/hydrogen/solian/tags?limit=1',
);
if (resp.body[0]['name'] != localVersionString) {
setState(() {
_isErrored = true;
_subtitle = PlatformInfo.isIOS || PlatformInfo.isMacOS
? 'bsCheckForUpdateDescApple'.tr
: 'bsCheckForUpdateDescCommon'.tr;
});
}
} catch (e) {
setState(() {
_isErrored = true;
_subtitle = 'bsCheckForUpdateFailed'.tr;
});
}
},
),
( (
label: 'bsCheckingServer', label: 'bsCheckingServer',
action: () async { action: () async {
final client = ServiceFinder.configureClient('dealer'); final client = await ServiceFinder.configureClient('dealer');
final resp = await client.get('/.well-known'); final resp = await client.get('/.well-known');
if (resp.statusCode != null && resp.statusCode != 200) { if (resp.statusCode != null && resp.statusCode != 200) {
setState(() { setState(() {
@ -115,7 +161,6 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
try { try {
await Future.wait([ await Future.wait([
Get.find<StickerProvider>().refreshAvailableStickers(),
if (auth.isAuthorized.isTrue) if (auth.isAuthorized.isTrue)
Get.find<ChannelProvider>().refreshAvailableChannel(), Get.find<ChannelProvider>().refreshAvailableChannel(),
if (auth.isAuthorized.isTrue) if (auth.isAuthorized.isTrue)
@ -156,6 +201,9 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
} }
} finally { } finally {
setState(() => _isBusy = false); setState(() => _isBusy = false);
Future.delayed(const Duration(milliseconds: 100), () {
_bootCompleter.complete();
});
} }
} }
@ -163,6 +211,7 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
void initState() { void initState() {
super.initState(); super.initState();
_runPeriods(); _runPeriods();
_checkForUpdate();
} }
@override @override
@ -253,6 +302,9 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
_isBusy = false; _isBusy = false;
_isErrored = false; _isErrored = false;
}); });
Future.delayed(const Duration(milliseconds: 100), () {
_bootCompleter.complete();
});
} else { } else {
setState(() { setState(() {
_isBusy = true; _isBusy = true;

View File

@ -1,112 +1,87 @@
import 'dart:math' as math;
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/models/event.dart'; import 'package:solian/models/event.dart';
import 'package:solian/platform.dart'; import 'package:solian/providers/database/database.dart';
import 'package:solian/providers/message/adaptor.dart'; import 'package:solian/providers/database/services/messages.dart';
import 'package:solian/providers/message/events.dart';
class ChatEventController { class ChatEventController {
late final MessageHistoryDb database; late final MessagesFetchingProvider src;
final RxList<LocalEvent> currentEvents = RxList.empty(growable: true); final RxList<LocalMessageEventTableData> currentEvents =
RxList.empty(growable: true);
final RxInt totalEvents = 0.obs; final RxInt totalEvents = 0.obs;
final RxBool isLoading = false.obs; final RxBool isLoading = true.obs;
Channel? channel; Channel? channel;
String? scope; String? scope;
Future<void> initialize() async { Future<void> initialize() async {
if (!PlatformInfo.isWeb) { src = Get.find();
database = await createHistoryDb();
}
currentEvents.clear(); currentEvents.clear();
} }
Future<LocalEvent?> getEvent(int id) async { Future<LocalMessageEventTableData?> getEvent(int id) async {
if (channel == null || scope == null) return null; if (channel == null || scope == null) return null;
return await src.getEvent(id, channel!, scope: scope!);
if (PlatformInfo.isWeb) {
final remoteRecord = await getRemoteEvent(id, channel!, scope!);
if (remoteRecord == null) return null;
return LocalEvent(
remoteRecord.id,
remoteRecord,
remoteRecord.channelId,
remoteRecord.createdAt,
);
} else {
return await database.getEvent(id, channel!, scope: scope!);
}
} }
Future<void> getEvents(Channel channel, String scope) async { Future<void> getInitialEvents(Channel channel, String scope) async {
this.channel = channel; this.channel = channel;
this.scope = scope; this.scope = scope;
syncLocal(channel); const firstTake = 20;
const furtherTake = 100;
isLoading.value = true; isLoading.value = true;
if (PlatformInfo.isWeb) { await syncLocal(channel, take: firstTake);
final result = await getRemoteEvents(
channel,
scope,
remainDepth: 3,
offset: 0,
);
totalEvents.value = result?.$2 ?? 0;
if (result != null) {
for (final x in result.$1.reversed) {
final entry = LocalEvent(x.id, x, x.channelId, x.createdAt);
insertEvent(entry);
applyEvent(entry);
}
}
} else {
final result = await database.syncRemoteEvents(
channel,
scope: scope,
);
totalEvents.value = result?.$2 ?? 0;
await syncLocal(channel);
}
isLoading.value = false; isLoading.value = false;
// Take a small range of messages to check is local database up to date
var isUpToDate = true;
final result =
await src.pullRemoteEvents(channel, scope: scope, take: firstTake);
totalEvents.value = result?.$2 ?? 0;
if ((result?.$1.length ?? 0) > 0) {
final minId = result!.$1.map((x) => x.id).reduce(math.min);
isUpToDate = await src.getEventFromLocal(minId) != null;
}
syncLocal(channel, take: firstTake);
if (!isUpToDate) {
// Loading more content due to isn't up to date
final result =
await src.pullRemoteEvents(channel, scope: scope, take: furtherTake);
totalEvents.value = result?.$2 ?? 0;
syncLocal(channel, take: furtherTake);
}
} }
Future<void> loadEvents(Channel channel, String scope) async { Future<void> loadEvents(Channel channel, String scope) async {
const take = 20;
final offset = currentEvents.length;
isLoading.value = true; isLoading.value = true;
if (PlatformInfo.isWeb) { await syncLocal(channel, take: take, offset: offset);
final result = await getRemoteEvents( src
channel, .pullRemoteEvents(channel, scope: scope, take: take, offset: offset)
scope, .then((result) {
remainDepth: 3,
offset: currentEvents.length,
);
if (result != null) {
totalEvents.value = result.$2;
for (final x in result.$1.reversed) {
final entry = LocalEvent(x.id, x, x.channelId, x.createdAt);
currentEvents.add(entry);
applyEvent(entry);
}
}
} else {
final result = await database.syncRemoteEvents(
channel,
depth: 3,
scope: scope,
offset: currentEvents.length,
);
totalEvents.value = result?.$2 ?? 0; totalEvents.value = result?.$2 ?? 0;
await syncLocal(channel); syncLocal(channel, take: take, offset: offset);
} });
isLoading.value = false; isLoading.value = false;
} }
Future<bool> syncLocal(Channel channel) async { Future<bool> syncLocal(Channel channel,
if (PlatformInfo.isWeb) return false; {required int take, int offset = 0}) async {
final data = await database.localEvents.findAllByChannel(channel.id); final data = await src.listEvents(channel, take: take, offset: offset);
currentEvents.replaceRange(0, currentEvents.length, data); if (currentEvents.length >= offset + take) {
currentEvents.replaceRange(offset, offset + take, data);
} else {
currentEvents.insertAll(currentEvents.length, data);
}
for (final x in data.reversed) { for (final x in data.reversed) {
applyEvent(x); applyEvent(x);
} }
@ -114,26 +89,20 @@ class ChatEventController {
} }
receiveEvent(Event remote) async { receiveEvent(Event remote) async {
LocalEvent entry; LocalMessageEventTableData entry;
if (PlatformInfo.isWeb) { entry = await src.receiveEvent(remote);
entry = LocalEvent(
remote.id,
remote,
remote.channelId,
remote.createdAt,
);
} else {
entry = await database.receiveEvent(remote);
}
totalEvents.value++;
insertEvent(entry); insertEvent(entry);
applyEvent(entry); applyEvent(entry);
} }
insertEvent(LocalEvent entry) { void insertEvent(LocalMessageEventTableData entry) {
if (entry.channelId != channel?.id) return; if (entry.channelId != channel?.id) return;
final idx = currentEvents.indexWhere((x) => x.data.uuid == entry.data.uuid); final idx = currentEvents.indexWhere(
(x) => x.data!.uuid == entry.data!.uuid,
);
if (idx != -1) { if (idx != -1) {
currentEvents[idx] = entry; currentEvents[idx] = entry;
} else { } else {
@ -141,36 +110,36 @@ class ChatEventController {
} }
} }
applyEvent(LocalEvent entry) { void applyEvent(LocalMessageEventTableData entry) {
if (entry.channelId != channel?.id) return; if (entry.channelId != channel?.id) return;
switch (entry.data.type) { switch (entry.data!.type) {
case 'messages.edit': case 'messages.edit':
final body = EventMessageBody.fromJson(entry.data.body); final body = EventMessageBody.fromJson(entry.data!.body);
if (body.relatedEvent != null) { if (body.relatedEvent != null) {
final idx = final idx =
currentEvents.indexWhere((x) => x.data.id == body.relatedEvent); currentEvents.indexWhere((x) => x.data!.id == body.relatedEvent);
if (idx != -1) { if (idx != -1) {
currentEvents[idx].data.body = entry.data.body; currentEvents[idx].data!.body = entry.data!.body;
currentEvents[idx].data.updatedAt = entry.data.updatedAt; currentEvents[idx].data!.updatedAt = entry.data!.updatedAt;
} }
} }
case 'messages.delete': case 'messages.delete':
final body = EventMessageBody.fromJson(entry.data.body); final body = EventMessageBody.fromJson(entry.data!.body);
if (body.relatedEvent != null) { if (body.relatedEvent != null) {
currentEvents.removeWhere((x) => x.id == body.relatedEvent); currentEvents.removeWhere((x) => x.id == body.relatedEvent);
} }
} }
} }
addPendingEvent(Event info) async { Future<void> addPendingEvent(Event info) async {
currentEvents.insert( currentEvents.insert(
0, 0,
LocalEvent( LocalMessageEventTableData(
info.id, id: info.id,
info, channelId: info.channelId,
info.channelId, createdAt: DateTime.now(),
DateTime.now(), data: info,
), ),
); );
} }

View File

@ -155,13 +155,14 @@ class PostEditorController extends GetxController {
); );
} }
void localRead() { Future<bool> localRead() async {
SharedPreferences.getInstance().then((inst) { final inst = await SharedPreferences.getInstance();
if (inst.containsKey('post_editor_local_save')) { if (inst.containsKey('post_editor_local_save')) {
isRestoreFromLocal.value = true; isRestoreFromLocal.value = true;
payload = jsonDecode(inst.getString('post_editor_local_save')!); payload = jsonDecode(inst.getString('post_editor_local_save')!);
} return true;
}); }
return false;
} }
void localClear() { void localClear() {

View File

@ -9,6 +9,7 @@ import 'package:solian/providers/last_read.dart';
class PostListController extends GetxController { class PostListController extends GetxController {
String? author; String? author;
String? realm;
/// The polling source modifier. /// The polling source modifier.
/// - `0`: default recommendations /// - `0`: default recommendations
@ -99,8 +100,10 @@ class PostListController extends GetxController {
final idx = <dynamic>{}; final idx = <dynamic>{};
postList.retainWhere((x) => idx.add(x.id)); postList.retainWhere((x) => idx.add(x.id));
var lastId = postList.map((x) => x.id).reduce(max); if (postList.isNotEmpty) {
Get.find<LastReadProvider>().feedLastReadAt = lastId; var lastId = postList.map((x) => x.id).reduce(max);
Get.find<LastReadProvider>().feedLastReadAt = lastId;
}
return result; return result;
} }
@ -123,16 +126,21 @@ class PostListController extends GetxController {
resp = await provider.listRecommendations( resp = await provider.listRecommendations(
pageKey, pageKey,
channel: 'shuffle', channel: 'shuffle',
realm: realm,
); );
break; break;
case 1: case 1:
resp = await provider.listRecommendations( resp = await provider.listRecommendations(
pageKey, pageKey,
channel: 'friends', channel: 'friends',
realm: realm,
); );
break; break;
default: default:
resp = await provider.listRecommendations(pageKey); resp = await provider.listRecommendations(
pageKey,
realm: realm,
);
break; break;
} }
} }

View File

@ -1,9 +1,13 @@
import 'dart:math' as math;
import 'package:action_slider/action_slider.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart'; import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart'; import 'package:solian/exceptions/unauthorized.dart';
extension SolianExtenions on BuildContext { extension AppExtensions on BuildContext {
void showSnackbar(String content, {SnackBarAction? action}) { void showSnackbar(String content, {SnackBarAction? action}) {
ScaffoldMessenger.of(this).showSnackBar(SnackBar( ScaffoldMessenger.of(this).showSnackBar(SnackBar(
content: Text(content), content: Text(content),
@ -49,6 +53,69 @@ extension SolianExtenions on BuildContext {
); );
} }
Future<bool> showConfirmDialog(String title, body) async {
return await showDialog<bool>(
useRootNavigator: true,
context: this,
builder: (ctx) => AlertDialog(
title: Text(title),
content: Text(body),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('cancel'.tr),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text('okay'.tr),
)
],
),
) ??
false;
}
Future<bool> showSlideToConfirmDialog(String title, body) async {
return await showDialog<bool>(
useRootNavigator: true,
context: this,
builder: (ctx) => AlertDialog(
title: Text(title, textAlign: TextAlign.center),
content: SizedBox(
width: double.maxFinite,
child: ListView(
shrinkWrap: true,
children: [
Text(body, textAlign: TextAlign.center),
const Gap(28),
ActionSlider.standard(
icon: const Icon(Icons.send),
iconAlignment: Alignment.center,
sliderBehavior: SliderBehavior.move,
actionThresholdType: ThresholdType.release,
toggleColor: Colors.red,
action: (controller) async {
controller.success();
await Future.delayed(const Duration(milliseconds: 500));
Navigator.pop(ctx, true);
},
child: Text('slideToConfirm'.tr),
),
],
),
),
actionsAlignment: MainAxisAlignment.center,
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('cancel'.tr),
)
],
),
) ??
false;
}
Future<void> showErrorDialog(dynamic exception) { Future<void> showErrorDialog(dynamic exception) {
Widget content = Text(exception.toString().capitalize!); Widget content = Text(exception.toString().capitalize!);
if (exception is UnauthorizedException) { if (exception is UnauthorizedException) {
@ -102,3 +169,24 @@ extension SolianExtenions on BuildContext {
); );
} }
} }
extension ByteFormatter on int {
String formatBytes({int decimals = 2}) {
if (this == 0) return '0 Bytes';
const k = 1024;
final dm = decimals < 0 ? 0 : decimals;
final sizes = [
'Bytes',
'KiB',
'MiB',
'GiB',
'TiB',
'PiB',
'EiB',
'ZiB',
'YiB'
];
final i = (math.log(this) / math.log(k)).floor().toInt();
return '${(this / math.pow(k, i)).toStringAsFixed(dm)} ${sizes[i]}';
}
}

View File

@ -2,20 +2,24 @@ import 'dart:ui';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart' hide Notification;
import 'package:flutter_acrylic/flutter_acrylic.dart'; import 'package:flutter_acrylic/flutter_acrylic.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:protocol_handler/protocol_handler.dart'; import 'package:protocol_handler/protocol_handler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:solian/bootstrapper.dart'; import 'package:solian/background.dart';
import 'package:solian/firebase_options.dart'; import 'package:solian/firebase_options.dart';
import 'package:solian/platform.dart'; import 'package:solian/platform.dart';
import 'package:solian/providers/attachment_uploader.dart'; import 'package:solian/providers/attachment_uploader.dart';
import 'package:solian/providers/daily_sign.dart'; import 'package:solian/providers/daily_sign.dart';
import 'package:solian/providers/database/database.dart';
import 'package:solian/providers/database/services/messages.dart';
import 'package:solian/providers/last_read.dart'; import 'package:solian/providers/last_read.dart';
import 'package:solian/providers/link_expander.dart'; import 'package:solian/providers/link_expander.dart';
import 'package:solian/providers/navigation.dart';
import 'package:solian/providers/stickers.dart'; import 'package:solian/providers/stickers.dart';
import 'package:solian/providers/subscription.dart';
import 'package:solian/providers/theme_switcher.dart'; import 'package:solian/providers/theme_switcher.dart';
import 'package:solian/providers/websocket.dart'; import 'package:solian/providers/websocket.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
@ -38,10 +42,15 @@ void main() async {
await Future.wait([ await Future.wait([
_initializeFirebase(), _initializeFirebase(),
_initializePlatformComponents(), _initializePlatformComponents(),
_initializeBackgroundNotificationService(),
]); ]);
GoRouter.optionURLReflectsImperativeAPIs = true; GoRouter.optionURLReflectsImperativeAPIs = true;
Get.put(DatabaseProvider());
Get.put(AppTranslations());
await AppTranslations.init();
usePathUrlStrategy(); usePathUrlStrategy();
runApp(const SolianApp()); runApp(const SolianApp());
} }
@ -57,6 +66,11 @@ Future<void> _initializeFirebase() async {
}; };
} }
Future<void> _initializeBackgroundNotificationService() async {
autoConfigureBackgroundNotificationService();
autoStartBackgroundNotificationService();
}
Future<void> _initializePlatformComponents() async { Future<void> _initializePlatformComponents() async {
if (!PlatformInfo.isWeb) { if (!PlatformInfo.isWeb) {
await protocolHandler.register('solink'); await protocolHandler.register('solink');
@ -76,8 +90,8 @@ Future<void> _initializePlatformComponents() async {
} }
final themeSwitcher = ThemeSwitcher( final themeSwitcher = ThemeSwitcher(
lightThemeData: SolianTheme.build(Brightness.light), lightThemeData: AppTheme.build(Brightness.light),
darkThemeData: SolianTheme.build(Brightness.dark), darkThemeData: AppTheme.build(Brightness.dark),
); );
class SolianApp extends StatelessWidget { class SolianApp extends StatelessWidget {
@ -99,16 +113,16 @@ class SolianApp extends StatelessWidget {
routeInformationParser: AppRouter.instance.routeInformationParser, routeInformationParser: AppRouter.instance.routeInformationParser,
routeInformationProvider: AppRouter.instance.routeInformationProvider, routeInformationProvider: AppRouter.instance.routeInformationProvider,
backButtonDispatcher: AppRouter.instance.backButtonDispatcher, backButtonDispatcher: AppRouter.instance.backButtonDispatcher,
translations: SolianMessages(), translations: Get.find<AppTranslations>(),
locale: Get.deviceLocale, locale: Get.deviceLocale,
fallbackLocale: const Locale('en', 'US'), fallbackLocale: const Locale('en', 'US'),
onInit: () => _initializeProviders(context), onInit: () {
_initializeProviders(context);
},
builder: (context, child) { builder: (context, child) {
return SystemShell( return SystemShell(
child: ScaffoldMessenger( child: ScaffoldMessenger(
child: BootstrapperShell( child: child ?? const SizedBox.shrink(),
child: child ?? const SizedBox.shrink(),
),
), ),
); );
}, },
@ -118,6 +132,8 @@ class SolianApp extends StatelessWidget {
} }
void _initializeProviders(BuildContext context) async { void _initializeProviders(BuildContext context) async {
Get.put(NavigationStateProvider());
Get.lazyPut(() => AuthProvider()); Get.lazyPut(() => AuthProvider());
Get.lazyPut(() => RelationshipProvider()); Get.lazyPut(() => RelationshipProvider());
Get.lazyPut(() => PostProvider()); Get.lazyPut(() => PostProvider());
@ -127,10 +143,14 @@ class SolianApp extends StatelessWidget {
Get.lazyPut(() => StatusProvider()); Get.lazyPut(() => StatusProvider());
Get.lazyPut(() => ChannelProvider()); Get.lazyPut(() => ChannelProvider());
Get.lazyPut(() => RealmProvider()); Get.lazyPut(() => RealmProvider());
Get.lazyPut(() => MessagesFetchingProvider());
Get.lazyPut(() => ChatCallProvider()); Get.lazyPut(() => ChatCallProvider());
Get.lazyPut(() => AttachmentUploaderController()); Get.lazyPut(() => AttachmentUploaderController());
Get.lazyPut(() => LinkExpandProvider()); Get.lazyPut(() => LinkExpandProvider());
Get.lazyPut(() => DailySignProvider()); Get.lazyPut(() => DailySignProvider());
Get.lazyPut(() => LastReadProvider()); Get.lazyPut(() => LastReadProvider());
Get.lazyPut(() => SubscriptionProvider());
Get.find<WebSocketProvider>().requestPermissions();
} }
} }

View File

@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
part 'account.g.dart'; part 'account.g.dart';
@ -18,7 +18,6 @@ class Account {
AccountProfile? profile; AccountProfile? profile;
List<AccountBadge>? badges; List<AccountBadge>? badges;
String? emailAddress; String? emailAddress;
int? externalId;
Account({ Account({
required this.id, required this.id,
@ -35,7 +34,6 @@ class Account {
required this.profile, required this.profile,
required this.badges, required this.badges,
required this.emailAddress, required this.emailAddress,
this.externalId,
}); });
factory Account.fromJson(Map<String, dynamic> json) => factory Account.fromJson(Map<String, dynamic> json) =>

View File

@ -31,7 +31,6 @@ Account _$AccountFromJson(Map<String, dynamic> json) => Account(
?.map((e) => AccountBadge.fromJson(e as Map<String, dynamic>)) ?.map((e) => AccountBadge.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
emailAddress: json['email_address'] as String?, emailAddress: json['email_address'] as String?,
externalId: (json['external_id'] as num?)?.toInt(),
); );
Map<String, dynamic> _$AccountToJson(Account instance) => <String, dynamic>{ Map<String, dynamic> _$AccountToJson(Account instance) => <String, dynamic>{
@ -49,7 +48,6 @@ Map<String, dynamic> _$AccountToJson(Account instance) => <String, dynamic>{
'profile': instance.profile?.toJson(), 'profile': instance.profile?.toJson(),
'badges': instance.badges?.map((e) => e.toJson()).toList(), 'badges': instance.badges?.map((e) => e.toJson()).toList(),
'email_address': instance.emailAddress, 'email_address': instance.emailAddress,
'external_id': instance.externalId,
}; };
AccountBadge _$AccountBadgeFromJson(Map<String, dynamic> json) => AccountBadge( AccountBadge _$AccountBadgeFromJson(Map<String, dynamic> json) => AccountBadge(

View File

@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
part 'account_status.g.dart'; part 'account_status.g.dart';

View File

@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:solian/models/account.dart'; import 'package:solian/models/account.dart';
part 'attachment.g.dart'; part 'attachment.g.dart';

103
lib/models/auth.dart Normal file
View File

@ -0,0 +1,103 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:solian/models/account.dart';
part 'auth.g.dart';
@JsonSerializable()
class AuthResult {
bool isFinished;
AuthTicket ticket;
AuthResult({
required this.isFinished,
required this.ticket,
});
factory AuthResult.fromJson(Map<String, dynamic> json) =>
_$AuthResultFromJson(json);
Map<String, dynamic> toJson() => _$AuthResultToJson(this);
}
@JsonSerializable()
class AuthTicket {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String location;
String ipAddress;
String userAgent;
int stepRemain;
List<String> claims;
List<String> audiences;
@JsonKey(defaultValue: [])
List<int> factorTrail;
String? grantToken;
String? accessToken;
String? refreshToken;
DateTime? expiredAt;
DateTime? availableAt;
DateTime? lastGrantAt;
String? nonce;
int? clientId;
Account account;
int accountId;
AuthTicket({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.location,
required this.ipAddress,
required this.userAgent,
required this.stepRemain,
required this.claims,
required this.audiences,
required this.factorTrail,
required this.grantToken,
required this.accessToken,
required this.refreshToken,
required this.expiredAt,
required this.availableAt,
required this.lastGrantAt,
required this.nonce,
required this.clientId,
required this.account,
required this.accountId,
});
factory AuthTicket.fromJson(Map<String, dynamic> json) =>
_$AuthTicketFromJson(json);
Map<String, dynamic> toJson() => _$AuthTicketToJson(this);
}
@JsonSerializable()
class AuthFactor {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
int type;
Map<String, dynamic>? config;
Account account;
int accountId;
AuthFactor({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.type,
required this.config,
required this.account,
required this.accountId,
});
factory AuthFactor.fromJson(Map<String, dynamic> json) =>
_$AuthFactorFromJson(json);
Map<String, dynamic> toJson() => _$AuthFactorToJson(this);
}

105
lib/models/auth.g.dart Normal file
View File

@ -0,0 +1,105 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AuthResult _$AuthResultFromJson(Map<String, dynamic> json) => AuthResult(
isFinished: json['is_finished'] as bool,
ticket: AuthTicket.fromJson(json['ticket'] as Map<String, dynamic>),
);
Map<String, dynamic> _$AuthResultToJson(AuthResult instance) =>
<String, dynamic>{
'is_finished': instance.isFinished,
'ticket': instance.ticket.toJson(),
};
AuthTicket _$AuthTicketFromJson(Map<String, dynamic> json) => AuthTicket(
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),
location: json['location'] as String,
ipAddress: json['ip_address'] as String,
userAgent: json['user_agent'] as String,
stepRemain: (json['step_remain'] as num).toInt(),
claims:
(json['claims'] as List<dynamic>).map((e) => e as String).toList(),
audiences:
(json['audiences'] as List<dynamic>).map((e) => e as String).toList(),
factorTrail: (json['factor_trail'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList() ??
[],
grantToken: json['grant_token'] as String?,
accessToken: json['access_token'] as String?,
refreshToken: json['refresh_token'] as String?,
expiredAt: json['expired_at'] == null
? null
: DateTime.parse(json['expired_at'] as String),
availableAt: json['available_at'] == null
? null
: DateTime.parse(json['available_at'] as String),
lastGrantAt: json['last_grant_at'] == null
? null
: DateTime.parse(json['last_grant_at'] as String),
nonce: json['nonce'] as String?,
clientId: (json['client_id'] as num?)?.toInt(),
account: Account.fromJson(json['account'] as Map<String, dynamic>),
accountId: (json['account_id'] as num).toInt(),
);
Map<String, dynamic> _$AuthTicketToJson(AuthTicket instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'location': instance.location,
'ip_address': instance.ipAddress,
'user_agent': instance.userAgent,
'step_remain': instance.stepRemain,
'claims': instance.claims,
'audiences': instance.audiences,
'factor_trail': instance.factorTrail,
'grant_token': instance.grantToken,
'access_token': instance.accessToken,
'refresh_token': instance.refreshToken,
'expired_at': instance.expiredAt?.toIso8601String(),
'available_at': instance.availableAt?.toIso8601String(),
'last_grant_at': instance.lastGrantAt?.toIso8601String(),
'nonce': instance.nonce,
'client_id': instance.clientId,
'account': instance.account.toJson(),
'account_id': instance.accountId,
};
AuthFactor _$AuthFactorFromJson(Map<String, dynamic> json) => AuthFactor(
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),
type: (json['type'] as num).toInt(),
config: json['config'] as Map<String, dynamic>?,
account: Account.fromJson(json['account'] as Map<String, dynamic>),
accountId: (json['account_id'] as num).toInt(),
);
Map<String, dynamic> _$AuthFactorToJson(AuthFactor instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'type': instance.type,
'config': instance.config,
'account': instance.account.toJson(),
'account_id': instance.accountId,
};

View File

@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';

View File

@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:solian/models/account.dart'; import 'package:solian/models/account.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
@ -19,7 +19,8 @@ class Channel {
int accountId; int accountId;
Realm? realm; Realm? realm;
int? realmId; int? realmId;
bool isEncrypted; bool isPublic;
bool isCommunity;
@JsonKey(includeFromJson: false, includeToJson: true) @JsonKey(includeFromJson: false, includeToJson: true)
bool isAvailable = false; bool isAvailable = false;
@ -36,7 +37,8 @@ class Channel {
required this.members, required this.members,
required this.account, required this.account,
required this.accountId, required this.accountId,
required this.isEncrypted, required this.isPublic,
required this.isCommunity,
required this.realm, required this.realm,
required this.realmId, required this.realmId,
}); });

View File

@ -22,7 +22,8 @@ Channel _$ChannelFromJson(Map<String, dynamic> json) => Channel(
.toList(), .toList(),
account: Account.fromJson(json['account'] as Map<String, dynamic>), account: Account.fromJson(json['account'] as Map<String, dynamic>),
accountId: (json['account_id'] as num).toInt(), accountId: (json['account_id'] as num).toInt(),
isEncrypted: json['is_encrypted'] as bool, isPublic: json['is_public'] as bool,
isCommunity: json['is_community'] as bool,
realm: json['realm'] == null realm: json['realm'] == null
? null ? null
: Realm.fromJson(json['realm'] as Map<String, dynamic>), : Realm.fromJson(json['realm'] as Map<String, dynamic>),
@ -43,7 +44,8 @@ Map<String, dynamic> _$ChannelToJson(Channel instance) => <String, dynamic>{
'account_id': instance.accountId, 'account_id': instance.accountId,
'realm': instance.realm?.toJson(), 'realm': instance.realm?.toJson(),
'realm_id': instance.realmId, 'realm_id': instance.realmId,
'is_encrypted': instance.isEncrypted, 'is_public': instance.isPublic,
'is_community': instance.isCommunity,
'is_available': instance.isAvailable, 'is_available': instance.isAvailable,
}; };

View File

@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/models/account.dart'; import 'package:solian/models/account.dart';

View File

@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
part 'event.g.dart'; part 'event.g.dart';

View File

@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
part 'link.g.dart'; part 'link.g.dart';

View File

@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
part 'notification.g.dart'; part 'notification.g.dart';

View File

@ -1,10 +1,10 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
part 'packet.g.dart'; part 'packet.g.dart';
@JsonSerializable() @JsonSerializable()
class NetworkPackage { class NetworkPackage {
@JsonKey(name: 'w') @JsonKey(name: 'w', defaultValue: 'unknown')
String method; String method;
@JsonKey(name: 'e') @JsonKey(name: 'e')
String? endpoint; String? endpoint;

View File

@ -8,7 +8,7 @@ part of 'packet.dart';
NetworkPackage _$NetworkPackageFromJson(Map<String, dynamic> json) => NetworkPackage _$NetworkPackageFromJson(Map<String, dynamic> json) =>
NetworkPackage( NetworkPackage(
method: json['w'] as String, method: json['w'] as String? ?? 'unknown',
endpoint: json['e'] as String?, endpoint: json['e'] as String?,
message: json['m'] as String?, message: json['m'] as String?,
payload: json['p'] as Map<String, dynamic>?, payload: json['p'] as Map<String, dynamic>?,

View File

@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
part 'pagination.g.dart'; part 'pagination.g.dart';

View File

@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:solian/models/account.dart'; import 'package:solian/models/account.dart';
import 'package:solian/models/post_categories.dart'; import 'package:solian/models/post_categories.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';

View File

@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
part 'post_categories.g.dart'; part 'post_categories.g.dart';

View File

@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:solian/models/account.dart'; import 'package:solian/models/account.dart';
part 'realm.g.dart'; part 'realm.g.dart';
@ -12,9 +12,12 @@ class Realm {
String alias; String alias;
String name; String name;
String description; String description;
String? avatar;
String? banner;
bool isPublic; bool isPublic;
bool isCommunity; bool isCommunity;
int? accountId; int? accountId;
int? externalId;
Realm({ Realm({
required this.id, required this.id,
@ -24,9 +27,12 @@ class Realm {
required this.alias, required this.alias,
required this.name, required this.name,
required this.description, required this.description,
required this.avatar,
required this.banner,
required this.isPublic, required this.isPublic,
required this.isCommunity, required this.isCommunity,
this.accountId, this.accountId,
this.externalId,
}); });
factory Realm.fromJson(Map<String, dynamic> json) => _$RealmFromJson(json); factory Realm.fromJson(Map<String, dynamic> json) => _$RealmFromJson(json);

View File

@ -16,9 +16,12 @@ Realm _$RealmFromJson(Map<String, dynamic> json) => Realm(
alias: json['alias'] as String, alias: json['alias'] as String,
name: json['name'] as String, name: json['name'] as String,
description: json['description'] as String, description: json['description'] as String,
avatar: json['avatar'] as String?,
banner: json['banner'] as String?,
isPublic: json['is_public'] as bool, isPublic: json['is_public'] as bool,
isCommunity: json['is_community'] as bool, isCommunity: json['is_community'] as bool,
accountId: (json['account_id'] as num?)?.toInt(), accountId: (json['account_id'] as num?)?.toInt(),
externalId: (json['external_id'] as num?)?.toInt(),
); );
Map<String, dynamic> _$RealmToJson(Realm instance) => <String, dynamic>{ Map<String, dynamic> _$RealmToJson(Realm instance) => <String, dynamic>{
@ -29,9 +32,12 @@ Map<String, dynamic> _$RealmToJson(Realm instance) => <String, dynamic>{
'alias': instance.alias, 'alias': instance.alias,
'name': instance.name, 'name': instance.name,
'description': instance.description, 'description': instance.description,
'avatar': instance.avatar,
'banner': instance.banner,
'is_public': instance.isPublic, 'is_public': instance.isPublic,
'is_community': instance.isCommunity, 'is_community': instance.isCommunity,
'account_id': instance.accountId, 'account_id': instance.accountId,
'external_id': instance.externalId,
}; };
RealmMember _$RealmMemberFromJson(Map<String, dynamic> json) => RealmMember( RealmMember _$RealmMemberFromJson(Map<String, dynamic> json) => RealmMember(

View File

@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:solian/models/account.dart'; import 'package:solian/models/account.dart';
part 'relations.g.dart'; part 'relations.g.dart';

View File

@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:solian/models/account.dart'; import 'package:solian/models/account.dart';
import 'package:solian/models/attachment.dart'; import 'package:solian/models/attachment.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';

View File

@ -0,0 +1,41 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/post_categories.dart';
part 'subscription.g.dart';
@JsonSerializable()
class Subscription {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
int followerId;
Account follower;
int? accountId;
Account? account;
int? tagId;
Tag? tag;
int? categoryId;
Category? category;
Subscription({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.followerId,
required this.follower,
required this.accountId,
required this.account,
required this.tagId,
required this.tag,
required this.categoryId,
required this.category,
});
factory Subscription.fromJson(Map<String, dynamic> json) =>
_$SubscriptionFromJson(json);
Map<String, dynamic> toJson() => _$SubscriptionToJson(this);
}

View File

@ -0,0 +1,46 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'subscription.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Subscription _$SubscriptionFromJson(Map<String, dynamic> json) => Subscription(
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),
followerId: (json['follower_id'] as num).toInt(),
follower: Account.fromJson(json['follower'] as Map<String, dynamic>),
accountId: (json['account_id'] as num?)?.toInt(),
account: json['account'] == null
? null
: Account.fromJson(json['account'] as Map<String, dynamic>),
tagId: (json['tag_id'] as num?)?.toInt(),
tag: json['tag'] == null
? null
: Tag.fromJson(json['tag'] as Map<String, dynamic>),
categoryId: (json['category_id'] as num?)?.toInt(),
category: json['category'] == null
? null
: Category.fromJson(json['category'] as Map<String, dynamic>),
);
Map<String, dynamic> _$SubscriptionToJson(Subscription instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'follower_id': instance.followerId,
'follower': instance.follower.toJson(),
'account_id': instance.accountId,
'account': instance.account?.toJson(),
'tag_id': instance.tagId,
'tag': instance.tag?.toJson(),
'category_id': instance.categoryId,
'category': instance.category?.toJson(),
};

View File

@ -37,7 +37,7 @@ class StatusProvider extends GetConnect {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('auth'); final client = await auth.configureClient('auth');
return await client.get('/users/me/status'); return await client.get('/users/me/status');
} }
@ -56,7 +56,7 @@ class StatusProvider extends GetConnect {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('auth'); final client = await auth.configureClient('auth');
final payload = { final payload = {
'type': type, 'type': type,
@ -85,7 +85,7 @@ class StatusProvider extends GetConnect {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('auth'); final client = await auth.configureClient('auth');
final resp = await client.delete('/users/me/status'); final resp = await client.delete('/users/me/status');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {

View File

@ -6,9 +6,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get/get_connect/http/src/request/request.dart'; import 'package:get/get_connect/http/src/request/request.dart';
import 'package:solian/controllers/chat_events_controller.dart'; import 'package:solian/background.dart';
import 'package:solian/exceptions/request.dart'; import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart'; import 'package:solian/exceptions/unauthorized.dart';
import 'package:solian/models/auth.dart';
import 'package:solian/providers/database/database.dart';
import 'package:solian/providers/websocket.dart'; import 'package:solian/providers/websocket.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
@ -113,14 +115,14 @@ class AuthProvider extends GetConnect {
return request; return request;
} }
GetConnect configureClient( Future<GetConnect> configureClient(
String service, { String service, {
timeout = const Duration(seconds: 5), timeout = const Duration(seconds: 5),
}) { }) async {
final client = GetConnect( final client = GetConnect(
maxAuthRetries: 3, maxAuthRetries: 3,
timeout: timeout, timeout: timeout,
userAgent: 'Solian/1.1', userAgent: await ServiceFinder.getUserAgent(),
sendUserAgent: true, sendUserAgent: true,
); );
client.httpClient.addAuthenticator(requestAuthenticator); client.httpClient.addAuthenticator(requestAuthenticator);
@ -147,27 +149,13 @@ class AuthProvider extends GetConnect {
Future<TokenSet> signin( Future<TokenSet> signin(
BuildContext context, BuildContext context,
String username, AuthTicket ticket,
String password,
) async { ) async {
userProfile.value = null; userProfile.value = null;
final client = ServiceFinder.configureClient('auth');
// Create ticket
final resp = await client.post('/auth', {
'username': username,
'password': password,
});
if (resp.statusCode != 200) {
throw RequestException(resp);
} else if (resp.body['is_finished'] == false) {
throw RiskyAuthenticateException(resp.body['ticket']['id']);
}
// Assign token // Assign token
final tokenResp = await post('/auth/token', { final tokenResp = await post('/auth/token', {
'code': resp.body['ticket']['grant_token'], 'code': ticket.grantToken!,
'grant_type': 'grant_token', 'grant_type': 'grant_token',
}); });
if (tokenResp.statusCode != 200) { if (tokenResp.statusCode != 200) {
@ -199,10 +187,8 @@ class AuthProvider extends GetConnect {
Get.find<WebSocketProvider>().notifications.clear(); Get.find<WebSocketProvider>().notifications.clear();
Get.find<WebSocketProvider>().notificationUnread.value = 0; Get.find<WebSocketProvider>().notificationUnread.value = 0;
final chatHistory = ChatEventController(); AppDatabase.removeDatabase();
chatHistory.initialize().then((_) async { autoStopBackgroundNotificationService();
await chatHistory.database.localEvents.wipeLocalEvents();
});
storage.deleteAll(); storage.deleteAll();
} }
@ -217,7 +203,8 @@ class AuthProvider extends GetConnect {
} }
Future<void> refreshUserProfile() async { Future<void> refreshUserProfile() async {
final client = configureClient('auth'); if (!isAuthorized.value) return;
final client = await configureClient('auth');
final resp = await client.get('/users/me'); final resp = await client.get('/users/me');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw RequestException(resp); throw RequestException(resp);

View File

@ -92,7 +92,7 @@ class ChatCallProvider extends GetxController {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
final resp = await client.post( final resp = await client.post(
'/channels/global/${channel.value!.alias}/calls/ongoing/token', '/channels/global/${channel.value!.alias}/calls/ongoing/token',

View File

@ -93,7 +93,7 @@ class AttachmentProvider extends GetConnect {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient( final client = await auth.configureClient(
'uc', 'uc',
timeout: const Duration(minutes: 3), timeout: const Duration(minutes: 3),
); );
@ -135,7 +135,7 @@ class AttachmentProvider extends GetConnect {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('uc'); final client = await auth.configureClient('uc');
final fileAlt = basename(path).contains('.') final fileAlt = basename(path).contains('.')
? basename(path).substring(0, basename(path).lastIndexOf('.')) ? basename(path).substring(0, basename(path).lastIndexOf('.'))
@ -173,7 +173,7 @@ class AttachmentProvider extends GetConnect {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient( final client = await auth.configureClient(
'uc', 'uc',
timeout: const Duration(minutes: 3), timeout: const Duration(minutes: 3),
); );
@ -198,7 +198,7 @@ class AttachmentProvider extends GetConnect {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('files'); final client = await auth.configureClient('files');
var resp = await client.put('/attachments/$id', { var resp = await client.put('/attachments/$id', {
'alt': alt, 'alt': alt,
@ -217,7 +217,7 @@ class AttachmentProvider extends GetConnect {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('files'); final client = await auth.configureClient('files');
var resp = await client.delete('/attachments/$id'); var resp = await client.delete('/attachments/$id');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {

View File

@ -33,7 +33,7 @@ class ChannelProvider extends GetxController {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
final resp = await client.get('/channels/$realm/$alias'); final resp = await client.get('/channels/$realm/$alias');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
@ -48,7 +48,7 @@ class ChannelProvider extends GetxController {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
final resp = await client.get('/channels/$realm/$alias/me'); final resp = await client.get('/channels/$realm/$alias/me');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
@ -63,7 +63,7 @@ class ChannelProvider extends GetxController {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
final resp = await client.get('/channels/$realm/$alias/calls/ongoing'); final resp = await client.get('/channels/$realm/$alias/calls/ongoing');
if (resp.statusCode == 404) { if (resp.statusCode == 404) {
@ -79,7 +79,7 @@ class ChannelProvider extends GetxController {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
final resp = await client.get('/channels/$scope'); final resp = await client.get('/channels/$scope');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
@ -89,13 +89,13 @@ class ChannelProvider extends GetxController {
return resp; return resp;
} }
Future<Response> listAvailableChannel({String realm = 'global'}) async { Future<Response> listAvailableChannel({String scope = 'global'}) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
final resp = await client.get('/channels/$realm/me/available'); final resp = await client.get('/channels/$scope/me/available');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw RequestException(resp); throw RequestException(resp);
} }
@ -107,7 +107,7 @@ class ChannelProvider extends GetxController {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
final resp = await client.post('/channels/$scope', payload); final resp = await client.post('/channels/$scope', payload);
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
@ -132,7 +132,7 @@ class ChannelProvider extends GetxController {
if (related == null) return null; if (related == null) return null;
final prof = auth.userProfile.value!; final prof = auth.userProfile.value!;
final client = auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
final resp = await client.post('/channels/$scope/dm', { final resp = await client.post('/channels/$scope/dm', {
'alias': const Uuid().v4().replaceAll('-', '').substring(0, 12), 'alias': const Uuid().v4().replaceAll('-', '').substring(0, 12),
@ -153,7 +153,7 @@ class ChannelProvider extends GetxController {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
final resp = await client.put('/channels/$scope/$id', payload); final resp = await client.put('/channels/$scope/$id', payload);
if (resp.statusCode != 200) { if (resp.statusCode != 200) {

View File

@ -1,6 +1,7 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart'; import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart'; import 'package:solian/exceptions/unauthorized.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
@ -14,9 +15,9 @@ class PostProvider extends GetConnect {
GetConnect client; GetConnect client;
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.value) { if (auth.isAuthorized.value) {
client = auth.configureClient('co'); client = await auth.configureClient('co');
} else { } else {
client = ServiceFinder.configureClient('co'); client = await ServiceFinder.configureClient('co');
} }
final resp = await client.get('/whats-new?pivot=$pivot'); final resp = await client.get('/whats-new?pivot=$pivot');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
@ -36,9 +37,9 @@ class PostProvider extends GetConnect {
if (realm != null) 'realm=$realm', if (realm != null) 'realm=$realm',
]; ];
if (auth.isAuthorized.value) { if (auth.isAuthorized.value) {
client = auth.configureClient('co'); client = await auth.configureClient('co');
} else { } else {
client = ServiceFinder.configureClient('co'); client = await ServiceFinder.configureClient('co');
} }
final resp = await client.get( final resp = await client.get(
channel == null channel == null
@ -60,7 +61,7 @@ class PostProvider extends GetConnect {
'take=${10}', 'take=${10}',
'offset=$page', 'offset=$page',
]; ];
final client = auth.configureClient('interactive'); final client = await auth.configureClient('interactive');
final resp = await client.get('/posts/drafts?${queries.join('&')}'); final resp = await client.get('/posts/drafts?${queries.join('&')}');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw RequestException(resp); throw RequestException(resp);
@ -96,6 +97,15 @@ class PostProvider extends GetConnect {
return resp; return resp;
} }
Future<List<Post>> listPostFeaturedReply(String alias, {int take = 1}) async {
final resp = await get('/posts/$alias/replies/featured?take=$take');
if (resp.statusCode != 200) {
throw RequestException(resp);
}
return List<Post>.from(resp.body.map((x) => Post.fromJson(x)));
}
Future<Response> getPost(String alias) async { Future<Response> getPost(String alias) async {
final resp = await get('/posts/$alias'); final resp = await get('/posts/$alias');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {

View File

@ -25,7 +25,7 @@ class RealmProvider extends GetxController {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('auth'); final client = await auth.configureClient('auth');
final resp = await client.get('/realms/$alias'); final resp = await client.get('/realms/$alias');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
@ -39,7 +39,7 @@ class RealmProvider extends GetxController {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('auth'); final client = await auth.configureClient('auth');
final resp = await client.get('/realms/me/available'); final resp = await client.get('/realms/me/available');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {

View File

@ -10,7 +10,7 @@ class DailySignProvider extends GetxController {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('id'); final client = await auth.configureClient('id');
final resp = await client.get('/daily?take=$take'); final resp = await client.get('/daily?take=$take');
if (resp.statusCode != 200 && resp.statusCode != 404) { if (resp.statusCode != 200 && resp.statusCode != 404) {
@ -30,7 +30,7 @@ class DailySignProvider extends GetxController {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('id'); final client = await auth.configureClient('id');
final resp = await client.get('/daily/today'); final resp = await client.get('/daily/today');
if (resp.statusCode != 200 && resp.statusCode != 404) { if (resp.statusCode != 200 && resp.statusCode != 404) {
@ -46,7 +46,7 @@ class DailySignProvider extends GetxController {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('id'); final client = await auth.configureClient('id');
final resp = await client.post('/daily', {}); final resp = await client.post('/daily', {});
if (resp.statusCode != 200) { if (resp.statusCode != 200) {

View File

@ -0,0 +1,51 @@
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:get/get.dart' hide Value;
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/database/tables/messages.dart';
import 'package:solian/models/event.dart';
part 'database.g.dart';
@DriftDatabase(tables: [LocalMessageEventTable])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 1;
static QueryExecutor _openConnection() {
return driftDatabase(
name: 'solar_network_local_db',
web: DriftWebOptions(
sqlite3Wasm: Uri.parse('sqlite3.wasm'),
driftWorker: Uri.parse('drift_worker.dart.js'),
),
);
}
static Future<int> getDatabaseSize() async {
if (PlatformInfo.isWeb) return 0;
final basepath = await getApplicationDocumentsDirectory();
return await File(join(basepath.path, 'solar_network_local_db.sqlite'))
.length();
}
static Future<void> removeDatabase() async {
if (PlatformInfo.isWeb) return;
final basepath = await getApplicationDocumentsDirectory();
final file = File(join(basepath.path, 'solar_network_local_db.sqlite'));
await Get.find<DatabaseProvider>().database.close();
await file.delete();
Get.find<DatabaseProvider>().database = AppDatabase();
}
}
class DatabaseProvider extends GetxController {
var database = AppDatabase();
}

View File

@ -0,0 +1,429 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'database.dart';
// ignore_for_file: type=lint
class $LocalMessageEventTableTable extends LocalMessageEventTable
with TableInfo<$LocalMessageEventTableTable, LocalMessageEventTableData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$LocalMessageEventTableTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _idMeta = const VerificationMeta('id');
@override
late final GeneratedColumn<int> id = GeneratedColumn<int>(
'id', aliasedName, false,
hasAutoIncrement: true,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultConstraints:
GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
static const VerificationMeta _channelIdMeta =
const VerificationMeta('channelId');
@override
late final GeneratedColumn<int> channelId = GeneratedColumn<int>(
'channel_id', aliasedName, false,
type: DriftSqlType.int, requiredDuringInsert: true);
static const VerificationMeta _dataMeta = const VerificationMeta('data');
@override
late final GeneratedColumnWithTypeConverter<Event?, String> data =
GeneratedColumn<String>('data', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true)
.withConverter<Event?>($LocalMessageEventTableTable.$converterdata);
static const VerificationMeta _createdAtMeta =
const VerificationMeta('createdAt');
@override
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at', aliasedName, false,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: Constant(DateTime.now()));
@override
List<GeneratedColumn> get $columns => [id, channelId, data, createdAt];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'local_message_event_table';
@override
VerificationContext validateIntegrity(
Insertable<LocalMessageEventTableData> instance,
{bool isInserting = false}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
}
if (data.containsKey('channel_id')) {
context.handle(_channelIdMeta,
channelId.isAcceptableOrUnknown(data['channel_id']!, _channelIdMeta));
} else if (isInserting) {
context.missing(_channelIdMeta);
}
context.handle(_dataMeta, const VerificationResult.success());
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
LocalMessageEventTableData map(Map<String, dynamic> data,
{String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return LocalMessageEventTableData(
id: attachedDatabase.typeMapping
.read(DriftSqlType.int, data['${effectivePrefix}id'])!,
channelId: attachedDatabase.typeMapping
.read(DriftSqlType.int, data['${effectivePrefix}channel_id'])!,
data: $LocalMessageEventTableTable.$converterdata.fromSql(attachedDatabase
.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}data'])!),
createdAt: attachedDatabase.typeMapping
.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!,
);
}
@override
$LocalMessageEventTableTable createAlias(String alias) {
return $LocalMessageEventTableTable(attachedDatabase, alias);
}
static TypeConverter<Event?, String> $converterdata =
const MessageEventConverter();
}
class LocalMessageEventTableData extends DataClass
implements Insertable<LocalMessageEventTableData> {
final int id;
final int channelId;
final Event? data;
final DateTime createdAt;
const LocalMessageEventTableData(
{required this.id,
required this.channelId,
this.data,
required this.createdAt});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<int>(id);
map['channel_id'] = Variable<int>(channelId);
if (!nullToAbsent || data != null) {
map['data'] = Variable<String>(
$LocalMessageEventTableTable.$converterdata.toSql(data));
}
map['created_at'] = Variable<DateTime>(createdAt);
return map;
}
LocalMessageEventTableCompanion toCompanion(bool nullToAbsent) {
return LocalMessageEventTableCompanion(
id: Value(id),
channelId: Value(channelId),
data: data == null && nullToAbsent ? const Value.absent() : Value(data),
createdAt: Value(createdAt),
);
}
factory LocalMessageEventTableData.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return LocalMessageEventTableData(
id: serializer.fromJson<int>(json['id']),
channelId: serializer.fromJson<int>(json['channelId']),
data: serializer.fromJson<Event?>(json['data']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'channelId': serializer.toJson<int>(channelId),
'data': serializer.toJson<Event?>(data),
'createdAt': serializer.toJson<DateTime>(createdAt),
};
}
LocalMessageEventTableData copyWith(
{int? id,
int? channelId,
Value<Event?> data = const Value.absent(),
DateTime? createdAt}) =>
LocalMessageEventTableData(
id: id ?? this.id,
channelId: channelId ?? this.channelId,
data: data.present ? data.value : this.data,
createdAt: createdAt ?? this.createdAt,
);
LocalMessageEventTableData copyWithCompanion(
LocalMessageEventTableCompanion data) {
return LocalMessageEventTableData(
id: data.id.present ? data.id.value : this.id,
channelId: data.channelId.present ? data.channelId.value : this.channelId,
data: data.data.present ? data.data.value : this.data,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
);
}
@override
String toString() {
return (StringBuffer('LocalMessageEventTableData(')
..write('id: $id, ')
..write('channelId: $channelId, ')
..write('data: $data, ')
..write('createdAt: $createdAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, channelId, data, createdAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is LocalMessageEventTableData &&
other.id == this.id &&
other.channelId == this.channelId &&
other.data == this.data &&
other.createdAt == this.createdAt);
}
class LocalMessageEventTableCompanion
extends UpdateCompanion<LocalMessageEventTableData> {
final Value<int> id;
final Value<int> channelId;
final Value<Event?> data;
final Value<DateTime> createdAt;
const LocalMessageEventTableCompanion({
this.id = const Value.absent(),
this.channelId = const Value.absent(),
this.data = const Value.absent(),
this.createdAt = const Value.absent(),
});
LocalMessageEventTableCompanion.insert({
this.id = const Value.absent(),
required int channelId,
required Event? data,
this.createdAt = const Value.absent(),
}) : channelId = Value(channelId),
data = Value(data);
static Insertable<LocalMessageEventTableData> custom({
Expression<int>? id,
Expression<int>? channelId,
Expression<String>? data,
Expression<DateTime>? createdAt,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (channelId != null) 'channel_id': channelId,
if (data != null) 'data': data,
if (createdAt != null) 'created_at': createdAt,
});
}
LocalMessageEventTableCompanion copyWith(
{Value<int>? id,
Value<int>? channelId,
Value<Event?>? data,
Value<DateTime>? createdAt}) {
return LocalMessageEventTableCompanion(
id: id ?? this.id,
channelId: channelId ?? this.channelId,
data: data ?? this.data,
createdAt: createdAt ?? this.createdAt,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<int>(id.value);
}
if (channelId.present) {
map['channel_id'] = Variable<int>(channelId.value);
}
if (data.present) {
map['data'] = Variable<String>(
$LocalMessageEventTableTable.$converterdata.toSql(data.value));
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('LocalMessageEventTableCompanion(')
..write('id: $id, ')
..write('channelId: $channelId, ')
..write('data: $data, ')
..write('createdAt: $createdAt')
..write(')'))
.toString();
}
}
abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this);
late final $LocalMessageEventTableTable localMessageEventTable =
$LocalMessageEventTableTable(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [localMessageEventTable];
}
typedef $$LocalMessageEventTableTableCreateCompanionBuilder
= LocalMessageEventTableCompanion Function({
Value<int> id,
required int channelId,
required Event? data,
Value<DateTime> createdAt,
});
typedef $$LocalMessageEventTableTableUpdateCompanionBuilder
= LocalMessageEventTableCompanion Function({
Value<int> id,
Value<int> channelId,
Value<Event?> data,
Value<DateTime> createdAt,
});
class $$LocalMessageEventTableTableFilterComposer
extends FilterComposer<_$AppDatabase, $LocalMessageEventTableTable> {
$$LocalMessageEventTableTableFilterComposer(super.$state);
ColumnFilters<int> get id => $state.composableBuilder(
column: $state.table.id,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
ColumnFilters<int> get channelId => $state.composableBuilder(
column: $state.table.channelId,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
ColumnWithTypeConverterFilters<Event?, Event, String> get data =>
$state.composableBuilder(
column: $state.table.data,
builder: (column, joinBuilders) => ColumnWithTypeConverterFilters(
column,
joinBuilders: joinBuilders));
ColumnFilters<DateTime> get createdAt => $state.composableBuilder(
column: $state.table.createdAt,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
}
class $$LocalMessageEventTableTableOrderingComposer
extends OrderingComposer<_$AppDatabase, $LocalMessageEventTableTable> {
$$LocalMessageEventTableTableOrderingComposer(super.$state);
ColumnOrderings<int> get id => $state.composableBuilder(
column: $state.table.id,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<int> get channelId => $state.composableBuilder(
column: $state.table.channelId,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<String> get data => $state.composableBuilder(
column: $state.table.data,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<DateTime> get createdAt => $state.composableBuilder(
column: $state.table.createdAt,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
}
class $$LocalMessageEventTableTableTableManager extends RootTableManager<
_$AppDatabase,
$LocalMessageEventTableTable,
LocalMessageEventTableData,
$$LocalMessageEventTableTableFilterComposer,
$$LocalMessageEventTableTableOrderingComposer,
$$LocalMessageEventTableTableCreateCompanionBuilder,
$$LocalMessageEventTableTableUpdateCompanionBuilder,
(
LocalMessageEventTableData,
BaseReferences<_$AppDatabase, $LocalMessageEventTableTable,
LocalMessageEventTableData>
),
LocalMessageEventTableData,
PrefetchHooks Function()> {
$$LocalMessageEventTableTableTableManager(
_$AppDatabase db, $LocalMessageEventTableTable table)
: super(TableManagerState(
db: db,
table: table,
filteringComposer: $$LocalMessageEventTableTableFilterComposer(
ComposerState(db, table)),
orderingComposer: $$LocalMessageEventTableTableOrderingComposer(
ComposerState(db, table)),
updateCompanionCallback: ({
Value<int> id = const Value.absent(),
Value<int> channelId = const Value.absent(),
Value<Event?> data = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
}) =>
LocalMessageEventTableCompanion(
id: id,
channelId: channelId,
data: data,
createdAt: createdAt,
),
createCompanionCallback: ({
Value<int> id = const Value.absent(),
required int channelId,
required Event? data,
Value<DateTime> createdAt = const Value.absent(),
}) =>
LocalMessageEventTableCompanion.insert(
id: id,
channelId: channelId,
data: data,
createdAt: createdAt,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
));
}
typedef $$LocalMessageEventTableTableProcessedTableManager
= ProcessedTableManager<
_$AppDatabase,
$LocalMessageEventTableTable,
LocalMessageEventTableData,
$$LocalMessageEventTableTableFilterComposer,
$$LocalMessageEventTableTableOrderingComposer,
$$LocalMessageEventTableTableCreateCompanionBuilder,
$$LocalMessageEventTableTableUpdateCompanionBuilder,
(
LocalMessageEventTableData,
BaseReferences<_$AppDatabase, $LocalMessageEventTableTable,
LocalMessageEventTableData>
),
LocalMessageEventTableData,
PrefetchHooks Function()>;
class $AppDatabaseManager {
final _$AppDatabase _db;
$AppDatabaseManager(this._db);
$$LocalMessageEventTableTableTableManager get localMessageEventTable =>
$$LocalMessageEventTableTableTableManager(
_db, _db.localMessageEventTable);
}

View File

@ -0,0 +1,185 @@
import 'package:drift/drift.dart';
import 'package:get/get.dart' hide Value;
import 'package:solian/exceptions/request.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/event.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/database/database.dart';
class MessagesFetchingProvider extends GetxController {
Future<(List<Event>, int)?> getWhatsNewEvents(int pivot, {take = 10}) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return null;
final client = await auth.configureClient('messaging');
final resp = await client.get(
'/whats-new?pivot=$pivot&take=$take',
);
if (resp.statusCode != 200) {
throw RequestException(resp);
}
final PaginationResult response = PaginationResult.fromJson(resp.body);
final result =
response.data?.map((e) => Event.fromJson(e)).toList() ?? List.empty();
return (result, response.count);
}
Future<Event?> fetchRemoteEvent(int id, Channel channel, String scope) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return null;
final client = await auth.configureClient('messaging');
final resp = await client.get(
'/channels/$scope/${channel.alias}/events/$id',
);
if (resp.statusCode == 404) {
return null;
} else if (resp.statusCode != 200) {
throw RequestException(resp);
}
return Event.fromJson(resp.body);
}
Future<(List<Event>, int)?> fetchRemoteEvents(
Channel channel,
String scope, {
take = 10,
offset = 0,
}) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return null;
final client = await auth.configureClient('messaging');
final resp = await client.get(
'/channels/$scope/${channel.alias}/events?take=$take&offset=$offset',
);
if (resp.statusCode != 200) {
throw RequestException(resp);
}
final PaginationResult response = PaginationResult.fromJson(resp.body);
final result =
response.data?.map((e) => Event.fromJson(e)).toList() ?? List.empty();
return (result, response.count);
}
Future<LocalMessageEventTableData> receiveEvent(Event remote) async {
// Insert record
final database = Get.find<DatabaseProvider>().database;
final entry = await database
.into(database.localMessageEventTable)
.insertReturning(LocalMessageEventTableCompanion.insert(
id: Value(remote.id),
channelId: remote.channelId,
data: remote,
createdAt: Value(remote.createdAt),
));
// Handle side-effect like editing and deleting
switch (remote.type) {
case 'messages.edit':
final body = EventMessageBody.fromJson(remote.body);
if (body.relatedEvent != null) {
final target = await (database.select(database.localMessageEventTable)
..where((x) => x.id.equals(body.relatedEvent!)))
.getSingleOrNull();
if (target != null) {
target.data!.body = remote.body;
target.data!.updatedAt = remote.updatedAt;
await (database.update(database.localMessageEventTable)
..where((x) => x.id.equals(target.id)))
.write(
LocalMessageEventTableCompanion(data: Value(target.data)),
);
}
}
case 'messages.delete':
final body = EventMessageBody.fromJson(remote.body);
if (body.relatedEvent != null) {
await (database.delete(database.localMessageEventTable)
..where((x) => x.id.equals(body.relatedEvent!)))
.go();
}
}
return entry;
}
Future<LocalMessageEventTableData?> getEvent(int id, Channel channel,
{String scope = 'global'}) async {
final database = Get.find<DatabaseProvider>().database;
final localRecord = await (database.select(database.localMessageEventTable)
..where((x) => x.id.equals(id)))
.getSingleOrNull();
if (localRecord != null) return localRecord;
final remoteRecord = await fetchRemoteEvent(id, channel, scope);
if (remoteRecord == null) return null;
return await receiveEvent(remoteRecord);
}
Future<LocalMessageEventTableData?> getEventFromLocal(int id) async {
final database = Get.find<DatabaseProvider>().database;
final localRecord = await (database.select(database.localMessageEventTable)
..where((x) => x.id.equals(id)))
.getSingleOrNull();
return localRecord;
}
/// Pull the remote events to local database
Future<(List<Event>, int)?> pullRemoteEvents(Channel channel,
{String scope = 'global', take = 10, offset = 0}) async {
final database = Get.find<DatabaseProvider>().database;
final data = await fetchRemoteEvents(
channel,
scope,
offset: offset,
take: take,
);
if (data != null) {
await database.batch((batch) {
batch.insertAllOnConflictUpdate(
database.localMessageEventTable,
data.$1.map((x) => LocalMessageEventTableCompanion(
id: Value(x.id),
channelId: Value(x.channelId),
data: Value(x),
createdAt: Value(x.createdAt),
)),
);
});
}
return data;
}
Future<List<LocalMessageEventTableData>> listEvents(Channel channel,
{required int take, int offset = 0}) async {
final database = Get.find<DatabaseProvider>().database;
return await (database.select(database.localMessageEventTable)
..where((x) => x.channelId.equals(channel.id))
..orderBy([(t) => OrderingTerm.desc(t.id)])
..limit(take, offset: offset))
.get();
}
Future<LocalMessageEventTableData?> getLastInChannel(Channel channel) async {
final database = Get.find<DatabaseProvider>().database;
return await (database.select(database.localMessageEventTable)
..where((x) => x.channelId.equals(channel.id))
..orderBy([(t) => OrderingTerm.desc(t.id)]))
.getSingleOrNull();
}
}

View File

@ -0,0 +1,13 @@
import 'dart:convert';
import 'package:drift/drift.dart';
class JsonConverter extends TypeConverter<Object?, String> {
const JsonConverter();
@override
Object? fromSql(String fromDb) => jsonDecode(fromDb);
@override
String toSql(Object? value) => jsonEncode(value);
}

View File

@ -0,0 +1,22 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:solian/models/event.dart';
class LocalMessageEventTable extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get channelId => integer()();
TextColumn get data => text().map(const MessageEventConverter())();
DateTimeColumn get createdAt =>
dateTime().withDefault(Constant(DateTime.now()))();
}
class MessageEventConverter extends TypeConverter<Event?, String> {
const MessageEventConverter();
@override
Event? fromSql(String fromDb) => Event.fromJson(jsonDecode(fromDb));
@override
String toSql(Event? value) => jsonEncode(value?.toJson());
}

View File

@ -12,7 +12,7 @@ class LinkExpandProvider extends GetxController {
log('[LinkExpander] Expanding link... $url'); log('[LinkExpander] Expanding link... $url');
final target = utf8.fuse(base64).encode(url); final target = utf8.fuse(base64).encode(url);
if (_cachedResponse.containsKey(target)) return _cachedResponse[target]; if (_cachedResponse.containsKey(target)) return _cachedResponse[target];
final client = ServiceFinder.configureClient('dealer'); final client = await ServiceFinder.configureClient('dealer');
final resp = await client.get('/api/links/$target'); final resp = await client.get('/api/links/$target');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
log('Unable to expand link ($url), status: ${resp.statusCode}, response: ${resp.body}'); log('Unable to expand link ($url), status: ${resp.statusCode}, response: ${resp.body}');

View File

@ -1,173 +0,0 @@
import 'package:floor/floor.dart';
import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/event.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/message/events.dart';
Future<MessageHistoryDb> createHistoryDb() async {
final migration1to2 = Migration(1, 2, (database) async {
await database.execute('DROP TABLE IF EXISTS LocalMessage');
});
return await $FloorMessageHistoryDb
.databaseBuilder('messaging_data.dart')
.addMigrations([migration1to2]).build();
}
Future<(List<Event>, int)?> getWhatsNewEvents(int pivot, {take = 10}) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return null;
final client = auth.configureClient('messaging');
final resp = await client.get(
'/whats-new?pivot=$pivot&take=$take',
);
if (resp.statusCode != 200) {
throw RequestException(resp);
}
final PaginationResult response = PaginationResult.fromJson(resp.body);
final result =
response.data?.map((e) => Event.fromJson(e)).toList() ?? List.empty();
return (result, response.count);
}
Future<Event?> getRemoteEvent(int id, Channel channel, String scope) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return null;
final client = auth.configureClient('messaging');
final resp = await client.get(
'/channels/$scope/${channel.alias}/events/$id',
);
if (resp.statusCode == 404) {
return null;
} else if (resp.statusCode != 200) {
throw RequestException(resp);
}
return Event.fromJson(resp.body);
}
Future<(List<Event>, int)?> getRemoteEvents(
Channel channel,
String scope, {
required int remainDepth,
bool Function(List<Event> items)? onBrake,
take = 10,
offset = 0,
}) async {
if (remainDepth <= 0) {
return null;
}
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return null;
final client = auth.configureClient('messaging');
final resp = await client.get(
'/channels/$scope/${channel.alias}/events?take=$take&offset=$offset',
);
if (resp.statusCode != 200) {
throw RequestException(resp);
}
final PaginationResult response = PaginationResult.fromJson(resp.body);
final result =
response.data?.map((e) => Event.fromJson(e)).toList() ?? List.empty();
if (onBrake != null && onBrake(result)) {
return (result, response.count);
}
final expandResult = (await getRemoteEvents(
channel,
scope,
remainDepth: remainDepth - 1,
take: take,
offset: offset + result.length,
))
?.$1 ??
List.empty();
return ([...result, ...expandResult], response.count);
}
extension MessageHistoryAdaptor on MessageHistoryDb {
Future<LocalEvent> receiveEvent(Event remote) async {
final entry = LocalEvent(
remote.id,
remote,
remote.channelId,
remote.createdAt,
);
await localEvents.insert(entry);
switch (remote.type) {
case 'messages.edit':
final body = EventMessageBody.fromJson(remote.body);
if (body.relatedEvent != null) {
final target = await localEvents.findById(body.relatedEvent!);
if (target != null) {
target.data.body = remote.body;
target.data.updatedAt = remote.updatedAt;
await localEvents.update(target);
}
}
case 'messages.delete':
final body = EventMessageBody.fromJson(remote.body);
if (body.relatedEvent != null) {
await localEvents.delete(body.relatedEvent!);
}
}
return entry;
}
Future<LocalEvent?> getEvent(int id, Channel channel,
{String scope = 'global'}) async {
final localRecord = await localEvents.findById(id);
if (localRecord != null) return localRecord;
final remoteRecord = await getRemoteEvent(id, channel, scope);
if (remoteRecord == null) return null;
return await receiveEvent(remoteRecord);
}
Future<(List<Event>, int)?> syncRemoteEvents(Channel channel,
{String scope = 'global', depth = 10, offset = 0}) async {
final lastOne = await localEvents.findLastByChannel(channel.id);
final data = await getRemoteEvents(
channel,
scope,
remainDepth: depth,
offset: offset,
onBrake: (items) {
return items.any((x) => x.id == lastOne?.id);
},
);
if (data != null) {
await localEvents.insertBulk(
data.$1
.map((x) => LocalEvent(x.id, x, x.channelId, x.createdAt))
.toList(),
);
}
return data;
}
Future<List<LocalEvent>> listEvents(Channel channel) async {
return await localEvents.findAllByChannel(channel.id);
}
}

View File

@ -1,83 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:floor/floor.dart';
import 'package:solian/models/event.dart';
import 'package:sqflite/sqflite.dart' as sqflite;
part 'events.g.dart';
@entity
class LocalEvent {
@primaryKey
final int id;
final Event data;
final int channelId;
final DateTime createdAt;
LocalEvent(this.id, this.data, this.channelId, this.createdAt);
}
class DateTimeConverter extends TypeConverter<DateTime, int> {
@override
DateTime decode(int databaseValue) {
return DateTime.fromMillisecondsSinceEpoch(databaseValue);
}
@override
int encode(DateTime value) {
return value.millisecondsSinceEpoch;
}
}
class RemoteEventConverter extends TypeConverter<Event, String> {
@override
Event decode(String databaseValue) {
return Event.fromJson(jsonDecode(databaseValue));
}
@override
String encode(Event value) {
return jsonEncode(value.toJson());
}
}
@dao
abstract class LocalEventDao {
@Query('SELECT COUNT(id) FROM LocalEvent WHERE channelId = :channelId')
Future<int?> countByChannel(int channelId);
@Query('SELECT * FROM LocalEvent WHERE id = :id')
Future<LocalEvent?> findById(int id);
@Query('SELECT * FROM LocalEvent WHERE channelId = :channelId ORDER BY createdAt DESC')
Future<List<LocalEvent>> findAllByChannel(int channelId);
@Query('SELECT * FROM LocalEvent WHERE channelId = :channelId ORDER BY createdAt DESC LIMIT 1')
Future<LocalEvent?> findLastByChannel(int channelId);
@Insert(onConflict: OnConflictStrategy.replace)
Future<void> insert(LocalEvent m);
@Insert(onConflict: OnConflictStrategy.replace)
Future<void> insertBulk(List<LocalEvent> m);
@Update(onConflict: OnConflictStrategy.replace)
Future<void> update(LocalEvent m);
@Query('DELETE FROM LocalEvent WHERE id = :id')
Future<void> delete(int id);
@Query('DELETE FROM LocalEvent WHERE channelId = :channelId')
Future<List<LocalEvent>> deleteByChannel(int channelId);
@Query('DELETE FROM LocalEvent')
Future<void> wipeLocalEvents();
}
@TypeConverters([DateTimeConverter, RemoteEventConverter])
@Database(version: 2, entities: [LocalEvent])
abstract class MessageHistoryDb extends FloorDatabase {
LocalEventDao get localEvents;
}

View File

@ -1,228 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'events.dart';
// **************************************************************************
// FloorGenerator
// **************************************************************************
abstract class $MessageHistoryDbBuilderContract {
/// Adds migrations to the builder.
$MessageHistoryDbBuilderContract addMigrations(List<Migration> migrations);
/// Adds a database [Callback] to the builder.
$MessageHistoryDbBuilderContract addCallback(Callback callback);
/// Creates the database and initializes it.
Future<MessageHistoryDb> build();
}
// ignore: avoid_classes_with_only_static_members
class $FloorMessageHistoryDb {
/// Creates a database builder for a persistent database.
/// Once a database is built, you should keep a reference to it and re-use it.
static $MessageHistoryDbBuilderContract databaseBuilder(String name) =>
_$MessageHistoryDbBuilder(name);
/// Creates a database builder for an in memory database.
/// Information stored in an in memory database disappears when the process is killed.
/// Once a database is built, you should keep a reference to it and re-use it.
static $MessageHistoryDbBuilderContract inMemoryDatabaseBuilder() =>
_$MessageHistoryDbBuilder(null);
}
class _$MessageHistoryDbBuilder implements $MessageHistoryDbBuilderContract {
_$MessageHistoryDbBuilder(this.name);
final String? name;
final List<Migration> _migrations = [];
Callback? _callback;
@override
$MessageHistoryDbBuilderContract addMigrations(List<Migration> migrations) {
_migrations.addAll(migrations);
return this;
}
@override
$MessageHistoryDbBuilderContract addCallback(Callback callback) {
_callback = callback;
return this;
}
@override
Future<MessageHistoryDb> build() async {
final path = name != null
? await sqfliteDatabaseFactory.getDatabasePath(name!)
: ':memory:';
final database = _$MessageHistoryDb();
database.database = await database.open(
path,
_migrations,
_callback,
);
return database;
}
}
class _$MessageHistoryDb extends MessageHistoryDb {
_$MessageHistoryDb([StreamController<String>? listener]) {
changeListener = listener ?? StreamController<String>.broadcast();
}
LocalEventDao? _localEventsInstance;
Future<sqflite.Database> open(
String path,
List<Migration> migrations, [
Callback? callback,
]) async {
final databaseOptions = sqflite.OpenDatabaseOptions(
version: 2,
onConfigure: (database) async {
await database.execute('PRAGMA foreign_keys = ON');
await callback?.onConfigure?.call(database);
},
onOpen: (database) async {
await callback?.onOpen?.call(database);
},
onUpgrade: (database, startVersion, endVersion) async {
await MigrationAdapter.runMigrations(
database, startVersion, endVersion, migrations);
await callback?.onUpgrade?.call(database, startVersion, endVersion);
},
onCreate: (database, version) async {
await database.execute(
'CREATE TABLE IF NOT EXISTS `LocalEvent` (`id` INTEGER NOT NULL, `data` TEXT NOT NULL, `channelId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY (`id`))');
await callback?.onCreate?.call(database, version);
},
);
return sqfliteDatabaseFactory.openDatabase(path, options: databaseOptions);
}
@override
LocalEventDao get localEvents {
return _localEventsInstance ??= _$LocalEventDao(database, changeListener);
}
}
class _$LocalEventDao extends LocalEventDao {
_$LocalEventDao(
this.database,
this.changeListener,
) : _queryAdapter = QueryAdapter(database),
_localEventInsertionAdapter = InsertionAdapter(
database,
'LocalEvent',
(LocalEvent item) => <String, Object?>{
'id': item.id,
'data': _remoteEventConverter.encode(item.data),
'channelId': item.channelId,
'createdAt': _dateTimeConverter.encode(item.createdAt)
}),
_localEventUpdateAdapter = UpdateAdapter(
database,
'LocalEvent',
['id'],
(LocalEvent item) => <String, Object?>{
'id': item.id,
'data': _remoteEventConverter.encode(item.data),
'channelId': item.channelId,
'createdAt': _dateTimeConverter.encode(item.createdAt)
});
final sqflite.DatabaseExecutor database;
final StreamController<String> changeListener;
final QueryAdapter _queryAdapter;
final InsertionAdapter<LocalEvent> _localEventInsertionAdapter;
final UpdateAdapter<LocalEvent> _localEventUpdateAdapter;
@override
Future<int?> countByChannel(int channelId) async {
return _queryAdapter.query(
'SELECT COUNT(id) FROM LocalEvent WHERE channelId = ?1',
mapper: (Map<String, Object?> row) => row.values.first as int,
arguments: [channelId]);
}
@override
Future<LocalEvent?> findById(int id) async {
return _queryAdapter.query('SELECT * FROM LocalEvent WHERE id = ?1',
mapper: (Map<String, Object?> row) => LocalEvent(
row['id'] as int,
_remoteEventConverter.decode(row['data'] as String),
row['channelId'] as int,
_dateTimeConverter.decode(row['createdAt'] as int)),
arguments: [id]);
}
@override
Future<List<LocalEvent>> findAllByChannel(int channelId) async {
return _queryAdapter.queryList(
'SELECT * FROM LocalEvent WHERE channelId = ?1 ORDER BY createdAt DESC',
mapper: (Map<String, Object?> row) => LocalEvent(
row['id'] as int,
_remoteEventConverter.decode(row['data'] as String),
row['channelId'] as int,
_dateTimeConverter.decode(row['createdAt'] as int)),
arguments: [channelId]);
}
@override
Future<LocalEvent?> findLastByChannel(int channelId) async {
return _queryAdapter.query(
'SELECT * FROM LocalEvent WHERE channelId = ?1 ORDER BY createdAt DESC LIMIT 1',
mapper: (Map<String, Object?> row) => LocalEvent(row['id'] as int, _remoteEventConverter.decode(row['data'] as String), row['channelId'] as int, _dateTimeConverter.decode(row['createdAt'] as int)),
arguments: [channelId]);
}
@override
Future<void> delete(int id) async {
await _queryAdapter
.queryNoReturn('DELETE FROM LocalEvent WHERE id = ?1', arguments: [id]);
}
@override
Future<List<LocalEvent>> deleteByChannel(int channelId) async {
return _queryAdapter.queryList(
'DELETE FROM LocalEvent WHERE channelId = ?1',
mapper: (Map<String, Object?> row) => LocalEvent(
row['id'] as int,
_remoteEventConverter.decode(row['data'] as String),
row['channelId'] as int,
_dateTimeConverter.decode(row['createdAt'] as int)),
arguments: [channelId]);
}
@override
Future<void> wipeLocalEvents() async {
await _queryAdapter.queryNoReturn('DELETE FROM LocalEvent');
}
@override
Future<void> insert(LocalEvent m) async {
await _localEventInsertionAdapter.insert(m, OnConflictStrategy.replace);
}
@override
Future<void> insertBulk(List<LocalEvent> m) async {
await _localEventInsertionAdapter.insertList(m, OnConflictStrategy.replace);
}
@override
Future<void> update(LocalEvent m) async {
await _localEventUpdateAdapter.update(m, OnConflictStrategy.replace);
}
}
// ignore_for_file: unused_element
final _dateTimeConverter = DateTimeConverter();
final _remoteEventConverter = RemoteEventConverter();

View File

@ -0,0 +1,6 @@
import 'package:get/get.dart';
import 'package:solian/models/realm.dart';
class NavigationStateProvider extends GetxController {
final Rx<Realm?> focusedRealm = Rx(null);
}

View File

@ -26,21 +26,21 @@ class RelationshipProvider extends GetxController {
return _friends.any((x) => x.relatedId == account.id); return _friends.any((x) => x.relatedId == account.id);
} }
Future<Response> listRelation() { Future<Response> listRelation() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
final client = auth.configureClient('auth'); final client = await auth.configureClient('auth');
return client.get('/users/me/relations'); return client.get('/users/me/relations');
} }
Future<Response> listRelationWithStatus(int status) { Future<Response> listRelationWithStatus(int status) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
final client = auth.configureClient('auth'); final client = await auth.configureClient('auth');
return client.get('/users/me/relations?status=$status'); return client.get('/users/me/relations?status=$status');
} }
Future<Response> makeFriend(String username) async { Future<Response> makeFriend(String username) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
final client = auth.configureClient('auth'); final client = await auth.configureClient('auth');
final resp = await client.post('/users/me/relations?related=$username', {}); final resp = await client.post('/users/me/relations?related=$username', {});
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw RequestException(resp); throw RequestException(resp);
@ -52,7 +52,7 @@ class RelationshipProvider extends GetxController {
Future<Response> handleRelation( Future<Response> handleRelation(
Relationship relationship, bool doAccept) async { Relationship relationship, bool doAccept) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
final client = auth.configureClient('auth'); final client = await auth.configureClient('auth');
final resp = await client.post( final resp = await client.post(
'/users/me/relations/${relationship.relatedId}/${doAccept ? 'accept' : 'decline'}', '/users/me/relations/${relationship.relatedId}/${doAccept ? 'accept' : 'decline'}',
{}, {},
@ -66,7 +66,7 @@ class RelationshipProvider extends GetxController {
Future<Response> editRelation(Relationship relationship, int status) async { Future<Response> editRelation(Relationship relationship, int status) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
final client = auth.configureClient('auth'); final client = await auth.configureClient('auth');
final resp = await client.patch( final resp = await client.patch(
'/users/me/relations/${relationship.relatedId}', '/users/me/relations/${relationship.relatedId}',
{'status': status}, {'status': status},

View File

@ -1,34 +1,48 @@
import 'dart:async';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/models/pagination.dart'; import 'package:solian/exceptions/request.dart';
import 'package:solian/models/stickers.dart'; import 'package:solian/models/stickers.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
class StickerProvider extends GetxController { class StickerProvider extends GetxController {
final RxMap<String, String> aliasImageMapping = RxMap(); final RxMap<String, FutureOr<Sticker?>> stickerCache = RxMap();
final RxList<Sticker> availableStickers = RxList.empty(growable: true);
Future<void> refreshAvailableStickers() async { Future<Sticker?> getStickerByAlias(String alias) {
availableStickers.clear(); if (stickerCache.containsKey(alias)) {
aliasImageMapping.clear(); return Future.value(stickerCache[alias]);
final client = ServiceFinder.configureClient('files');
final resp = await client.get(
'/stickers/manifest?take=100',
);
if (resp.statusCode == 200) {
final result = PaginationResult.fromJson(resp.body);
final out = result.data?.map((e) => StickerPack.fromJson(e)).toList();
if (out == null) return;
for (final pack in out) {
for (final sticker in (pack.stickers ?? List<Sticker>.empty())) {
sticker.pack = pack;
aliasImageMapping[sticker.textPlaceholder.toUpperCase()] =
sticker.imageUrl;
availableStickers.add(sticker);
}
}
} }
availableStickers.refresh();
stickerCache[alias] = Future(() async {
final client = await ServiceFinder.configureClient('files');
final resp = await client.get(
'/stickers/lookup/$alias',
);
if (resp.statusCode != 200) {
if (resp.statusCode == 404) {
stickerCache[alias] = null;
}
throw RequestException(resp);
}
return Sticker.fromJson(resp.body);
}).then((result) {
stickerCache[alias] = result;
return result;
});
return Future.value(stickerCache[alias]);
}
Future<List<Sticker>> searchStickerByAlias(String alias) async {
final client = await ServiceFinder.configureClient('files');
final resp = await client.get(
'/stickers/lookup?probe=$alias',
);
if (resp.statusCode != 200) {
throw RequestException(resp);
}
return List<Sticker>.from(resp.body.map((x) => Sticker.fromJson(x)));
} }
} }

View File

@ -0,0 +1,46 @@
import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart';
import 'package:solian/models/subscription.dart';
import 'package:solian/providers/auth.dart';
class SubscriptionProvider extends GetxController {
Future<Subscription?> getSubscriptionOnUser(int userId) async {
final auth = Get.find<AuthProvider>();
if (!auth.isAuthorized.value) throw const UnauthorizedException();
final client = await auth.configureClient('co');
final resp = await client.get('/subscriptions/users/$userId');
if (resp.statusCode == 404) {
return null;
} else if (resp.statusCode != 200) {
throw RequestException(resp);
}
return Subscription.fromJson(resp.body);
}
Future<Subscription> subscribeToUser(int userId) async {
final auth = Get.find<AuthProvider>();
if (!auth.isAuthorized.value) throw const UnauthorizedException();
final client = await auth.configureClient('co');
final resp = await client.post('/subscriptions/users/$userId', {});
if (resp.statusCode != 200) {
throw RequestException(resp);
}
return Subscription.fromJson(resp.body);
}
Future<void> unsubscribeFromUser(int userId) async {
final auth = Get.find<AuthProvider>();
if (!auth.isAuthorized.value) throw const UnauthorizedException();
final client = await auth.configureClient('co');
final resp = await client.delete('/subscriptions/users/$userId');
if (resp.statusCode != 200) {
throw RequestException(resp);
}
}
}

View File

@ -16,8 +16,8 @@ class ThemeSwitcher extends ChangeNotifier {
if (prefs.containsKey('global_theme_color')) { if (prefs.containsKey('global_theme_color')) {
final value = prefs.getInt('global_theme_color')!; final value = prefs.getInt('global_theme_color')!;
final color = Color(value); final color = Color(value);
lightThemeData = SolianTheme.build(Brightness.light, seedColor: color); lightThemeData = AppTheme.build(Brightness.light, seedColor: color);
darkThemeData = SolianTheme.build(Brightness.dark, seedColor: color); darkThemeData = AppTheme.build(Brightness.dark, seedColor: color);
notifyListeners(); notifyListeners();
} }
} }

View File

@ -5,7 +5,9 @@ import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:solian/exceptions/request.dart'; import 'package:solian/exceptions/request.dart';
import 'package:solian/models/notification.dart'; import 'package:solian/models/notification.dart';
import 'package:solian/models/packet.dart'; import 'package:solian/models/packet.dart';
@ -29,20 +31,46 @@ class WebSocketProvider extends GetxController {
@override @override
onInit() { onInit() {
FirebaseMessaging.instance notifyPrefetch();
.requestPermission(
alert: true,
announcement: true,
carPlay: true,
badge: true,
sound: true)
.then((status) {
notifyPrefetch();
});
super.onInit(); super.onInit();
} }
void requestPermissions() {
try {
FirebaseMessaging.instance.requestPermission(
alert: true,
announcement: true,
carPlay: true,
badge: true,
sound: true);
} catch (_) {
// When firebase isn't initialized (background service)
FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(
alert: true,
badge: true,
sound: true,
);
flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(
alert: true,
badge: true,
sound: true,
);
}
}
Future<void> connect({noRetry = false}) async { Future<void> connect({noRetry = false}) async {
if (isConnected.value) { if (isConnected.value) {
return; return;
@ -90,6 +118,10 @@ class WebSocketProvider extends GetxController {
final packet = NetworkPackage.fromJson(jsonDecode(event)); final packet = NetworkPackage.fromJson(jsonDecode(event));
log('Websocket incoming message: ${packet.method} ${packet.message}'); log('Websocket incoming message: ${packet.method} ${packet.message}');
stream.sink.add(packet); stream.sink.add(packet);
if (packet.method == 'notifications.new') {
notifications.add(Notification.fromJson(packet.payload!));
notificationUnread.value++;
}
}, },
onDone: () { onDone: () {
isConnected.value = false; isConnected.value = false;
@ -106,7 +138,7 @@ class WebSocketProvider extends GetxController {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
final client = auth.configureClient('auth'); final client = await auth.configureClient('auth');
final resp = await client.get('/notifications?skip=0&take=100'); final resp = await client.get('/notifications?skip=0&take=100');
if (resp.statusCode == 200) { if (resp.statusCode == 200) {
@ -120,6 +152,14 @@ class WebSocketProvider extends GetxController {
} }
Future<void> registerPushNotifications() async { Future<void> registerPushNotifications() async {
if (PlatformInfo.isWeb) return;
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool('service_background_notification') == true) {
log('Background notification service has been enabled, skip register push notifications');
return;
}
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
@ -142,7 +182,7 @@ class WebSocketProvider extends GetxController {
} }
log('Device Push Token is $token'); log('Device Push Token is $token');
final client = auth.configureClient('auth'); final client = await auth.configureClient('auth');
final resp = await client.post('/notifications/subscribe', { final resp = await client.post('/notifications/subscribe', {
'provider': provider, 'provider': provider,

View File

@ -1,13 +1,16 @@
import 'package:animations/animations.dart'; import 'package:animations/animations.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:solian/bootstrapper.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
import 'package:solian/screens/about.dart'; import 'package:solian/screens/about.dart';
import 'package:solian/screens/account.dart'; import 'package:solian/screens/account.dart';
import 'package:solian/screens/account/friend.dart'; import 'package:solian/screens/account/friend.dart';
import 'package:solian/screens/account/personalize.dart'; import 'package:solian/screens/account/preferences/notifications.dart';
import 'package:solian/screens/account/profile_edit.dart';
import 'package:solian/screens/account/profile_page.dart'; import 'package:solian/screens/account/profile_page.dart';
import 'package:solian/screens/account/stickers.dart'; import 'package:solian/screens/auth/signin.dart';
import 'package:solian/screens/auth/signup.dart';
import 'package:solian/screens/channel/channel_chat.dart'; import 'package:solian/screens/channel/channel_chat.dart';
import 'package:solian/screens/channel/channel_detail.dart'; import 'package:solian/screens/channel/channel_detail.dart';
import 'package:solian/screens/channel/channel_organize.dart'; import 'package:solian/screens/channel/channel_organize.dart';
@ -30,9 +33,12 @@ abstract class AppRouter {
static GoRouter instance = GoRouter( static GoRouter instance = GoRouter(
routes: [ routes: [
ShellRoute( ShellRoute(
builder: (context, state, child) => RootShell( builder: (context, state, child) => BootstrapperShell(
state: state, key: const Key('global-bootstrapper'),
child: child, child: RootShell(
state: state,
child: child,
),
), ),
routes: [ routes: [
GoRoute( GoRoute(
@ -154,6 +160,7 @@ abstract class AppRouter {
name: 'channelChat', name: 'channelChat',
builder: (context, state) { builder: (context, state) {
return ChannelChatScreen( return ChannelChatScreen(
key: UniqueKey(),
alias: state.pathParameters['alias']!, alias: state.pathParameters['alias']!,
realm: state.uri.queryParameters['realm'] ?? 'global', realm: state.uri.queryParameters['realm'] ?? 'global',
); );
@ -235,22 +242,22 @@ abstract class AppRouter {
name: 'accountFriend', name: 'accountFriend',
builder: (context, state) => const FriendScreen(), builder: (context, state) => const FriendScreen(),
), ),
GoRoute(
path: '/account/stickers',
name: 'accountStickers',
builder: (context, state) => TitleShell(
state: state,
child: const StickerScreen(),
),
),
GoRoute( GoRoute(
path: '/account/personalize', path: '/account/personalize',
name: 'accountPersonalize', name: 'accountProfile',
builder: (context, state) => TitleShell( builder: (context, state) => TitleShell(
state: state, state: state,
child: const PersonalizeScreen(), child: const PersonalizeScreen(),
), ),
), ),
GoRoute(
path: '/account/preferences/notifications',
name: 'notificationPreferences',
builder: (context, state) => TitleShell(
state: state,
child: const NotificationPreferencesScreen(),
),
),
GoRoute( GoRoute(
path: '/account/view/:name', path: '/account/view/:name',
name: 'accountProfilePage', name: 'accountProfilePage',
@ -258,6 +265,24 @@ abstract class AppRouter {
name: state.pathParameters['name']!, name: state.pathParameters['name']!,
), ),
), ),
GoRoute(
path: '/auth/sign-in',
name: 'signin',
builder: (context, state) => TitleShell(
state: state,
isCenteredTitle: true,
child: const SignInScreen(),
),
),
GoRoute(
path: '/auth/sign-up',
name: 'signup',
builder: (context, state) => TitleShell(
state: state,
isCenteredTitle: true,
child: const SignUpScreen(),
),
),
], ],
); );
} }

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:get/get.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:solian/widgets/sized_container.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
class AboutScreen extends StatelessWidget { class AboutScreen extends StatelessWidget {
@ -47,31 +49,50 @@ class AboutScreen extends StatelessWidget {
), ),
Text('Copyright © ${DateTime.now().year} Solsynth LLC'), Text('Copyright © ${DateTime.now().year} Solsynth LLC'),
const Gap(16), const Gap(16),
TextButton( CenteredContainer(
style: denseButtonStyle, maxWidth: 280,
child: const Text('App Details'), child: Wrap(
onPressed: () async { spacing: 8,
final info = await PackageInfo.fromPlatform(); runSpacing: 8,
children: [
TextButton(
style: denseButtonStyle,
child: Text('appDetails'.tr),
onPressed: () async {
final info = await PackageInfo.fromPlatform();
showAboutDialog( showAboutDialog(
context: context, context: context,
applicationVersion: '${info.version} (${info.buildNumber})', applicationVersion:
applicationLegalese: '${info.version} (${info.buildNumber})',
'The Solar Network App is an intuitive and self-hostable social network and computing platform. Experience the freedom of a user-friendly design that empowers you to create and connect with communities on your own terms. Embrace the future of social networking with a platform that prioritizes your independence and privacy.', applicationLegalese:
applicationIcon: ClipRRect( 'The Solar Network App is an intuitive and open-source social network and computing platform. Experience the freedom of a user-friendly design that empowers you to create and connect with communities on your own terms. Embrace the future of social networking with a platform that prioritizes your independence and privacy.',
borderRadius: const BorderRadius.all(Radius.circular(16)), applicationIcon: ClipRRect(
child: borderRadius:
Image.asset('assets/logo.png', width: 60, height: 60), const BorderRadius.all(Radius.circular(16)),
child: Image.asset('assets/logo.png',
width: 60, height: 60),
),
);
},
), ),
); TextButton(
}, style: denseButtonStyle,
), child: Text('projectWebsite'.tr),
TextButton( onPressed: () {
style: denseButtonStyle, launchUrlString(
child: const Text('Project Website'), 'https://solsynth.dev/products/solar-network');
onPressed: () { },
launchUrlString('https://solsynth.dev/products/solar-network'); ),
}, TextButton(
style: denseButtonStyle,
child: Text('termRelated'.tr),
onPressed: () {
launchUrlString('https://solsynth.dev/terms');
},
),
],
),
), ),
const Gap(16), const Gap(16),
const Text( const Text(

View File

@ -1,12 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/models/account.dart'; import 'package:solian/models/account.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/account_status.dart'; import 'package:solian/providers/account_status.dart';
import 'package:solian/providers/relation.dart'; import 'package:solian/providers/relation.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/screens/auth/signin.dart';
import 'package:solian/screens/auth/signup.dart';
import 'package:solian/widgets/account/account_heading.dart'; import 'package:solian/widgets/account/account_heading.dart';
import 'package:solian/widgets/sized_container.dart'; import 'package:solian/widgets/sized_container.dart';
import 'package:badges/badges.dart' as badges; import 'package:badges/badges.dart' as badges;
@ -23,9 +22,9 @@ class _AccountScreenState extends State<AccountScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final actionItems = [ final actionItems = [
( (
const Icon(Icons.color_lens), const Icon(Icons.face),
'accountPersonalize'.tr, 'accountProfile'.tr,
'accountPersonalize', 'accountProfile',
), ),
( (
Obx(() { Obx(() {
@ -46,11 +45,6 @@ class _AccountScreenState extends State<AccountScreen> {
'accountFriend'.tr, 'accountFriend'.tr,
'accountFriend', 'accountFriend',
), ),
(
const Icon(Icons.emoji_symbols),
'accountStickers'.tr,
'accountStickers',
),
]; ];
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
@ -64,7 +58,7 @@ class _AccountScreenState extends State<AccountScreen> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ActionCard( _ActionCard(
icon: Icon( icon: Icon(
Icons.login, Icons.login,
color: Theme.of(context).colorScheme.onPrimary, color: Theme.of(context).colorScheme.onPrimary,
@ -72,20 +66,14 @@ class _AccountScreenState extends State<AccountScreen> {
title: 'signin'.tr, title: 'signin'.tr,
caption: 'signinCaption'.tr, caption: 'signinCaption'.tr,
onTap: () { onTap: () {
showModalBottomSheet( AppRouter.instance.pushNamed('signin').then((val) async {
useRootNavigator: true,
isDismissible: false,
isScrollControlled: true,
context: context,
builder: (context) => const SignInPopup(),
).then((val) async {
if (val == true) { if (val == true) {
await auth.refreshUserProfile(); await auth.refreshUserProfile();
} }
}); });
}, },
), ),
ActionCard( _ActionCard(
icon: Icon( icon: Icon(
Icons.add, Icons.add,
color: Theme.of(context).colorScheme.onPrimary, color: Theme.of(context).colorScheme.onPrimary,
@ -93,17 +81,24 @@ class _AccountScreenState extends State<AccountScreen> {
title: 'signup'.tr, title: 'signup'.tr,
caption: 'signupCaption'.tr, caption: 'signupCaption'.tr,
onTap: () { onTap: () {
showModalBottomSheet( AppRouter.instance.pushNamed('signup').then((_) {
useRootNavigator: true,
isDismissible: false,
isScrollControlled: true,
context: context,
builder: (context) => const SignUpPopup(),
).then((_) {
setState(() {}); setState(() {});
}); });
}, },
), ),
const Gap(4),
TextButton(
style: const ButtonStyle(
visualDensity: VisualDensity(
horizontal: -4,
vertical: -2,
),
),
onPressed: () {
AppRouter.instance.pushNamed('settings');
},
child: Text('settings'.tr),
),
], ],
), ),
); );
@ -126,6 +121,27 @@ class _AccountScreenState extends State<AccountScreen> {
}, },
), ),
)), )),
const Divider(thickness: 0.3, height: 1)
.paddingSymmetric(vertical: 4),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.settings),
title: Text('settings'.tr),
onTap: () {
AppRouter.instance.pushNamed('settings');
},
),
if (auth.isAuthorized.value)
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.edit_notifications),
title: Text('notificationPreferences'.tr),
onTap: () {
AppRouter.instance.pushNamed('notificationPreferences');
},
),
const Divider(thickness: 0.3, height: 1)
.paddingSymmetric(vertical: 4),
ListTile( ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34), contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.logout), leading: const Icon(Icons.logout),
@ -186,14 +202,13 @@ class _AccountHeadingState extends State<AccountHeading> {
} }
} }
class ActionCard extends StatelessWidget { class _ActionCard extends StatelessWidget {
final Widget icon; final Widget icon;
final String title; final String title;
final String caption; final String caption;
final Function onTap; final Function onTap;
const ActionCard({ const _ActionCard({
super.key,
required this.onTap, required this.onTap,
required this.title, required this.title,
required this.caption, required this.caption,

View File

@ -133,7 +133,7 @@ class _FriendScreenState extends State<FriendScreen>
).paddingAll(14), ).paddingAll(14),
), ),
SizedBox( SizedBox(
width: SolianTheme.isLargeScreen(context) ? 8 : 16, width: AppTheme.isLargeScreen(context) ? 8 : 16,
), ),
], ],
bottom: TabBar( bottom: TabBar(

View File

@ -31,7 +31,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
} }
if (markList.isNotEmpty) { if (markList.isNotEmpty) {
final client = auth.configureClient('auth'); final client = await auth.configureClient('auth');
await client.put('/notifications/read', {'messages': markList}); await client.put('/notifications/read', {'messages': markList});
} }
@ -53,7 +53,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = auth.configureClient('auth'); final client = await auth.configureClient('auth');
await client.put('/notifications/read/${element.id}', {}); await client.put('/notifications/read/${element.id}', {});

View File

@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart';
import 'package:get/get_connect/http/src/exceptions/exceptions.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart';
import 'package:solian/providers/auth.dart';
class NotificationPreferencesScreen extends StatefulWidget {
const NotificationPreferencesScreen({super.key});
@override
State<NotificationPreferencesScreen> createState() =>
_NotificationPreferencesScreenState();
}
class _NotificationPreferencesScreenState
extends State<NotificationPreferencesScreen> {
bool _isBusy = true;
Map<String, bool> _config = {};
final Map<String, String> _topicMap = {
'interactive.feedback': 'notificationTopicPostFeedback'.tr,
'interactive.subscription': 'notificationTopicPostSubscription'.tr,
};
Future<void> _getPreferences() async {
setState(() => _isBusy = true);
final auth = Get.find<AuthProvider>();
if (!auth.isAuthorized.value) throw UnauthorizedException();
final client = await auth.configureClient('id');
final resp = await client.get('/preferences/notifications');
if (resp.statusCode != 200 && resp.statusCode != 404) {
context.showErrorDialog(RequestException(resp));
}
if (resp.statusCode == 200) {
_config = resp.body['config']
.map((k, v) => MapEntry(k, v as bool))
.cast<String, bool>();
}
setState(() => _isBusy = false);
}
Future<void> _savePreferences() async {
setState(() => _isBusy = true);
final auth = Get.find<AuthProvider>();
if (!auth.isAuthorized.value) throw UnauthorizedException();
final client = await auth.configureClient('id');
final resp = await client.put('/preferences/notifications', {
'config': _config,
});
if (resp.statusCode != 200) {
context.showErrorDialog(RequestException(resp));
}
context.showSnackbar('preferencesApplied'.tr);
setState(() => _isBusy = false);
}
@override
void initState() {
super.initState();
_getPreferences();
}
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.surface,
child: Column(
children: [
if (_isBusy) const LinearProgressIndicator().animate().scaleX(),
ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainer,
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.save),
title: Text('save'.tr),
enabled: !_isBusy,
onTap: () {
_savePreferences();
},
),
Expanded(
child: ListView.builder(
itemCount: _topicMap.length,
itemBuilder: (context, index) {
final element = _topicMap.entries.elementAt(index);
return CheckboxListTile(
title: Text(element.value),
subtitle: Text(
element.key,
style: GoogleFonts.robotoMono(fontSize: 12),
),
value: _config[element.key] ?? true,
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onChanged: (value) {
setState(() {
_config[element.key] = value ?? false;
});
},
);
},
),
),
],
),
);
}
}

View File

@ -126,7 +126,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
return; return;
} }
final client = auth.configureClient('auth'); final client = await auth.configureClient('auth');
final resp = await client.put( final resp = await client.put(
'/users/me/$position', '/users/me/$position',
@ -134,7 +134,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
); );
if (resp.statusCode == 200) { if (resp.statusCode == 200) {
_syncWidget(); _syncWidget();
context.showSnackbar('accountPersonalizeApplied'.tr); context.showSnackbar('accountProfileApplied'.tr);
} else { } else {
context.showErrorDialog(resp.bodyString); context.showErrorDialog(resp.bodyString);
} }
@ -148,7 +148,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = auth.configureClient('auth'); final client = await auth.configureClient('auth');
_birthday?.toIso8601String(); _birthday?.toIso8601String();
final resp = await client.put( final resp = await client.put(
@ -163,7 +163,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
); );
if (resp.statusCode == 200) { if (resp.statusCode == 200) {
_syncWidget(); _syncWidget();
context.showSnackbar('accountPersonalizeApplied'.tr); context.showSnackbar('accountProfileApplied'.tr);
} else { } else {
context.showErrorDialog(resp.bodyString); context.showErrorDialog(resp.bodyString);
} }

View File

@ -1,21 +1,29 @@
import 'dart:math';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:intl/intl.dart';
import 'package:solian/controllers/post_list_controller.dart'; import 'package:solian/controllers/post_list_controller.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/account.dart'; import 'package:solian/models/account.dart';
import 'package:solian/models/attachment.dart'; import 'package:solian/models/attachment.dart';
import 'package:solian/models/daily_sign.dart';
import 'package:solian/models/pagination.dart'; import 'package:solian/models/pagination.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/models/subscription.dart';
import 'package:solian/providers/account_status.dart'; import 'package:solian/providers/account_status.dart';
import 'package:solian/providers/relation.dart'; import 'package:solian/providers/relation.dart';
import 'package:solian/providers/subscription.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/account_heading.dart'; import 'package:solian/widgets/account/account_heading.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/attachments/attachment_list.dart'; import 'package:solian/widgets/attachments/attachment_list.dart';
import 'package:solian/widgets/daily_sign/history_chart.dart';
import 'package:solian/widgets/posts/post_list.dart'; import 'package:solian/widgets/posts/post_list.dart';
import 'package:solian/widgets/posts/post_warped_list.dart'; import 'package:solian/widgets/posts/post_warped_list.dart';
import 'package:solian/widgets/sized_container.dart'; import 'package:solian/widgets/sized_container.dart';
@ -37,16 +45,26 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
bool _isBusy = true; bool _isBusy = true;
bool _isMakingFriend = false; bool _isMakingFriend = false;
bool _isSubscribing = false;
bool _showMature = false; bool _showMature = false;
Account? _userinfo; Account? _userinfo;
Subscription? _subscription;
List<Post> _pinnedPosts = List.empty(); List<Post> _pinnedPosts = List.empty();
List<DailySignRecord> _dailySignRecords = List.empty();
int _totalUpvote = 0, _totalDownvote = 0; int _totalUpvote = 0, _totalDownvote = 0;
Future<void> _getSubscription() async {
setState(() => _isSubscribing = true);
_subscription = await Get.find<SubscriptionProvider>()
.getSubscriptionOnUser(_userinfo!.id);
setState(() => _isSubscribing = false);
}
Future<void> _getUserinfo() async { Future<void> _getUserinfo() async {
setState(() => _isBusy = true); setState(() => _isBusy = true);
var client = ServiceFinder.configureClient('auth'); var client = await ServiceFinder.configureClient('id');
var resp = await client.get('/users/${widget.name}'); var resp = await client.get('/users/${widget.name}');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString).then((_) { context.showErrorDialog(resp.bodyString).then((_) {
@ -56,7 +74,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
_userinfo = Account.fromJson(resp.body); _userinfo = Account.fromJson(resp.body);
} }
client = ServiceFinder.configureClient('interactive'); client = await ServiceFinder.configureClient('co');
resp = await client.get('/users/${widget.name}'); resp = await client.get('/users/${widget.name}');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString).then((_) { context.showErrorDialog(resp.bodyString).then((_) {
@ -70,8 +88,8 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
Future<void> getPinnedPosts() async { Future<void> _getPinnedPosts() async {
final client = ServiceFinder.configureClient('interactive'); final client = await ServiceFinder.configureClient('co');
final resp = await client.get('/users/${widget.name}/pin'); final resp = await client.get('/users/${widget.name}/pin');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString).then((_) { context.showErrorDialog(resp.bodyString).then((_) {
@ -85,6 +103,23 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
} }
} }
Future<void> _getDailySignRecords() async {
final client = await ServiceFinder.configureClient('id');
final resp = await client.get('/users/${widget.name}/daily?take=14');
if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString).then((_) {
Navigator.pop(context);
});
} else {
final result = PaginationResult.fromJson(resp.body);
setState(() {
_dailySignRecords = List.from(
result.data?.map((x) => DailySignRecord.fromJson(x)) ?? [],
);
});
}
}
int get _userSocialCreditPoints { int get _userSocialCreditPoints {
return _totalUpvote * 2 - _totalDownvote + _postController.postTotal.value; return _totalUpvote * 2 - _totalDownvote + _postController.postTotal.value;
} }
@ -95,7 +130,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
_relationshipProvider = Get.find(); _relationshipProvider = Get.find();
_postController = PostListController(author: widget.name); _postController = PostListController(author: widget.name);
_albumPagingController.addPageRequestListener((pageKey) async { _albumPagingController.addPageRequestListener((pageKey) async {
final client = ServiceFinder.configureClient('files'); final client = await ServiceFinder.configureClient('files');
final resp = await client.get( final resp = await client.get(
'/attachments?take=10&offset=$pageKey&author=${widget.name}&original=true', '/attachments?take=10&offset=$pageKey&author=${widget.name}&original=true',
); );
@ -115,8 +150,11 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
} }
}); });
_getUserinfo(); _getUserinfo().then((_) {
getPinnedPosts(); _getSubscription();
_getPinnedPosts();
_getDailySignRecords();
});
} }
Widget _buildStatisticsEntry(String label, String content) { Widget _buildStatisticsEntry(String label, String content) {
@ -152,7 +190,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
SliverAppBar( SliverAppBar(
centerTitle: false, centerTitle: false,
floating: true, floating: true,
toolbarHeight: SolianTheme.toolbarHeight(context), toolbarHeight: AppTheme.toolbarHeight(context),
leadingWidth: 24, leadingWidth: 24,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
flexibleSpace: Row( flexibleSpace: Row(
@ -180,6 +218,40 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
], ],
), ),
), ),
if (_userinfo != null && _subscription == null)
OutlinedButton(
style: const ButtonStyle(
visualDensity:
VisualDensity(horizontal: -4, vertical: -2),
),
onPressed: _isSubscribing
? null
: () async {
setState(() => _isSubscribing = true);
_subscription =
await Get.find<SubscriptionProvider>()
.subscribeToUser(_userinfo!.id);
setState(() => _isSubscribing = false);
},
child: Text('subscribe'.tr),
)
else if (_userinfo != null)
OutlinedButton(
style: const ButtonStyle(
visualDensity:
VisualDensity(horizontal: -4, vertical: -2),
),
onPressed: _isSubscribing
? null
: () async {
setState(() => _isSubscribing = true);
await Get.find<SubscriptionProvider>()
.unsubscribeFromUser(_userinfo!.id);
_subscription = null;
setState(() => _isSubscribing = false);
},
child: Text('unsubscribe'.tr),
),
if (_userinfo != null && if (_userinfo != null &&
!_relationshipProvider.hasFriend(_userinfo!)) !_relationshipProvider.hasFriend(_userinfo!))
IconButton( IconButton(
@ -207,7 +279,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
onPressed: null, onPressed: null,
), ),
SizedBox( SizedBox(
width: SolianTheme.isLargeScreen(context) ? 8 : 16, width: AppTheme.isLargeScreen(context) ? 8 : 16,
), ),
], ],
), ),
@ -238,14 +310,121 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
.getSomeoneStatus(_userinfo!.name), .getSomeoneStatus(_userinfo!.name),
detail: _userinfo, detail: _userinfo,
profile: _userinfo!.profile, profile: _userinfo!.profile,
extraWidgets: const [], extraWidgets: [
if (_dailySignRecords.isNotEmpty)
Card(
child: SizedBox(
height: 180,
width: max(640, MediaQuery.of(context).size.width),
child: LineChart(
LineChartData(
lineBarsData: [
LineChartBarData(
isCurved: true,
isStrokeCapRound: true,
isStrokeJoinRound: true,
color:
Theme.of(context).colorScheme.primary,
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: List.filled(
_dailySignRecords.length,
Theme.of(context)
.colorScheme
.primary
.withOpacity(0.3),
).toList(),
),
),
spots: _dailySignRecords
.map(
(x) => FlSpot(
x.createdAt
.copyWith(
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
microsecond: 0,
)
.millisecondsSinceEpoch
.toDouble(),
x.resultTier.toDouble(),
),
)
.toList(),
)
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (spots) => spots
.map((spot) => LineTooltipItem(
'${DailySignHistoryChartDialog.signSymbols[spot.y.toInt()]}\n${DateFormat('MM/dd').format(DateTime.fromMillisecondsSinceEpoch(spot.x.toInt()))}',
TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface,
),
))
.toList(),
getTooltipColor: (_) => Theme.of(context)
.colorScheme
.surfaceContainerHigh,
),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: 1,
getTitlesWidget: (value, _) => Align(
alignment: Alignment.centerRight,
child: Text(
DailySignHistoryChartDialog
.signSymbols[value.toInt()],
textAlign: TextAlign.right,
).paddingOnly(right: 8),
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: 86400000,
getTitlesWidget: (value, _) => Text(
DateFormat('dd').format(
DateTime.fromMillisecondsSinceEpoch(
value.toInt(),
),
),
textAlign: TextAlign.center,
).paddingOnly(top: 8),
),
),
),
gridData: const FlGridData(show: false),
borderData: FlBorderData(show: false),
),
),
).marginOnly(right: 24, left: 12, bottom: 8, top: 24),
)
],
), ),
], ],
), ),
RefreshIndicator( RefreshIndicator(
onRefresh: () => Future.wait([ onRefresh: () => Future.wait([
_postController.reloadAllOver(), _postController.reloadAllOver(),
getPinnedPosts(), _getPinnedPosts(),
]), ]),
child: CustomScrollView(slivers: [ child: CustomScrollView(slivers: [
SliverToBoxAdapter( SliverToBoxAdapter(
@ -302,6 +481,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
isClickable: true, isClickable: true,
isNestedClickable: true, isNestedClickable: true,
isShowEmbed: true, isShowEmbed: true,
showFeaturedReply: true,
onUpdate: () { onUpdate: () {
_postController.reloadAllOver(); _postController.reloadAllOver();
}, },
@ -325,8 +505,9 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
), ),
CenteredContainer( CenteredContainer(
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () => onRefresh: () => Future.sync(
Future.sync(() => _albumPagingController.refresh()), () => _albumPagingController.refresh(),
),
child: PagedGridView<int, Attachment>( child: PagedGridView<int, Attachment>(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
pagingController: _albumPagingController, pagingController: _albumPagingController,
@ -352,7 +533,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
child: AttachmentListEntry( child: AttachmentListEntry(
item: item, item: item,
isDense: true, isDense: true,
parentId: 'album', parentId: 'album-$index',
showMature: _showMature, showMature: _showMature,
onReveal: (value) { onReveal: (value) {
setState(() => _showMature = value); setState(() => _showMature = value);

View File

@ -1,186 +0,0 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/models/stickers.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/stickers.dart';
import 'package:solian/services.dart';
import 'package:solian/widgets/auto_cache_image.dart';
import 'package:solian/widgets/stickers/sticker_uploader.dart';
class StickerScreen extends StatefulWidget {
const StickerScreen({super.key});
@override
State<StickerScreen> createState() => _StickerScreenState();
}
class _StickerScreenState extends State<StickerScreen> {
final PagingController<int, StickerPack> _pagingController =
PagingController(firstPageKey: 0);
Future<bool> _promptDelete(Sticker item, String prefix) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return false;
final confirm = await showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('stickerDeletionConfirm'.tr),
content: Text(
'stickerDeletionConfirmCaption'.trParams({
'name': ':${'$prefix${item.alias}'.camelCase}:',
}),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('cancel'.tr),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('confirm'.tr),
),
],
),
);
if (confirm != true) return false;
final client = auth.configureClient('files');
final resp = await client.delete('/stickers/${item.id}');
return resp.statusCode == 200;
}
Future<bool?> _promptUploadSticker({Sticker? edit}) {
return showDialog(
context: context,
builder: (context) => StickerUploadDialog(
edit: edit,
),
);
}
Widget _buildEmoteEntry(Sticker item, String prefix) {
final imageUrl = ServiceFinder.buildUrl(
'files',
'/attachments/${item.attachment.rid}',
);
return ListTile(
title: Text(item.name),
subtitle: Text(item.textWarpedPlaceholder),
contentPadding: const EdgeInsets.only(left: 16, right: 14),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit_square),
onPressed: () {
_promptUploadSticker(edit: item).then((value) {
if (value == true) _pagingController.refresh();
});
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
_promptDelete(item, prefix).then((value) {
if (value == true) _pagingController.refresh();
});
},
),
],
),
leading: AutoCacheImage(
imageUrl,
width: 28,
height: 28,
noErrorWidget: true,
),
);
}
@override
void initState() {
final AuthProvider auth = Get.find();
final name = auth.userProfile.value!['name'];
_pagingController.addPageRequestListener((pageKey) async {
final client = ServiceFinder.configureClient('files');
final resp = await client.get(
'/stickers/manifest?take=10&offset=$pageKey&author=$name',
);
if (resp.statusCode == 200) {
final result = PaginationResult.fromJson(resp.body);
final out = result.data?.map((e) => StickerPack.fromJson(e)).toList();
if (out != null && result.data!.length >= 10) {
_pagingController.appendPage(out, pageKey + out.length);
} else if (out != null) {
_pagingController.appendLastPage(out);
}
} else {
_pagingController.error = resp.bodyString;
}
});
super.initState();
}
@override
void dispose() {
final StickerProvider sticker = Get.find();
sticker.refreshAvailableStickers();
_pagingController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () {
_promptUploadSticker().then((value) {
if (value == true) _pagingController.refresh();
});
},
),
body: RefreshIndicator(
onRefresh: () => Future.sync(() => _pagingController.refresh()),
child: CustomScrollView(
slivers: [
PagedSliverList<int, StickerPack>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate(
itemBuilder: (BuildContext context, item, int index) {
return ExpansionTile(
title: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(item.name),
const Gap(6),
Badge(
label: Text('#${item.id}'),
)
],
),
subtitle: Text(
item.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
children: item.stickers?.map((x) {
x.pack = item;
return _buildEmoteEntry(x, item.prefix);
}).toList() ??
List.empty(),
);
},
),
),
],
),
),
);
}
}

View File

@ -1,28 +1,50 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:protocol_handler/protocol_handler.dart'; import 'package:solian/background.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/providers/websocket.dart'; import 'package:solian/models/auth.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/channel.dart';
import 'package:solian/providers/content/realm.dart';
import 'package:solian/providers/relation.dart';
import 'package:solian/providers/websocket.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:solian/widgets/sized_container.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
class SignInPopup extends StatefulWidget { class SignInScreen extends StatefulWidget {
const SignInPopup({super.key}); const SignInScreen({super.key});
@override @override
State<SignInPopup> createState() => _SignInPopupState(); State<SignInScreen> createState() => _SignInScreenState();
} }
class _SignInPopupState extends State<SignInPopup> with ProtocolListener { class _SignInScreenState extends State<SignInScreen> {
bool _isBusy = false; bool _isBusy = false;
AuthTicket? _currentTicket;
List<AuthFactor>? _factors;
int? _factorPicked;
int? _factorPickedType;
int _period = 0;
final _usernameController = TextEditingController(); final _usernameController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
void requestResetPassword() async { final Map<int, (String label, IconData icon, bool isOtp)> _factorLabelMap = {
0: ('authFactorPassword'.tr, Icons.password, false),
1: ('authFactorEmail'.tr, Icons.email, true),
};
Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
void _requestResetPassword() async {
final username = _usernameController.value.text; final username = _usernameController.value.text;
if (username.isEmpty) { if (username.isEmpty) {
context.showErrorDialog('signinResetPasswordHint'.tr); context.showErrorDialog('signinResetPasswordHint'.tr);
@ -31,7 +53,7 @@ class _SignInPopupState extends State<SignInPopup> with ProtocolListener {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = ServiceFinder.configureClient('auth'); final client = await ServiceFinder.configureClient('auth');
final lookupResp = await client.get('/users/lookup?probe=$username'); final lookupResp = await client.get('/users/lookup?probe=$username');
if (lookupResp.statusCode != 200) { if (lookupResp.statusCode != 200) {
context.showErrorDialog(lookupResp.bodyString); context.showErrorDialog(lookupResp.bodyString);
@ -52,156 +74,434 @@ class _SignInPopupState extends State<SignInPopup> with ProtocolListener {
context.showModalDialog('done'.tr, 'signinResetPasswordSent'.tr); context.showModalDialog('done'.tr, 'signinResetPasswordSent'.tr);
} }
void performAction() async { void _performNewTicket() async {
final AuthProvider auth = Get.find();
final username = _usernameController.value.text; final username = _usernameController.value.text;
final password = _passwordController.value.text; if (username.isEmpty) return;
if (username.isEmpty || password.isEmpty) return;
final client = await ServiceFinder.configureClient('auth');
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
await auth.signin(context, username, password); // Create ticket
await Future.delayed(const Duration(milliseconds: 250), () async { final resp = await client.post('/auth', {
await auth.refreshAuthorizeStatus(); 'username': username,
await auth.refreshUserProfile();
}); });
} on RiskyAuthenticateException catch (e) { if (resp.statusCode != 200) {
showDialog( throw RequestException(resp);
context: context, } else {
builder: (context) { final result = AuthResult.fromJson(resp.body);
return AlertDialog( _currentTicket = result.ticket;
title: Text('riskDetection'.tr), }
content: Text('signinRiskDetected'.tr),
actions: [ // Pull factors
TextButton( final factorResp = await client.get('/auth/factors',
child: Text('next'.tr), query: {'ticketId': _currentTicket!.id.toString()});
onPressed: () { if (factorResp.statusCode != 200) {
const redirect = 'solink://auth?status=done'; throw RequestException(factorResp);
launchUrlString( } else {
ServiceFinder.buildUrl('capital', final result = List<AuthFactor>.from(
'/auth/mfa?redirect_uri=$redirect&ticketId=${e.ticketId}'), factorResp.body.map((x) => AuthFactor.fromJson(x)),
mode: LaunchMode.inAppWebView, );
); _factors = result;
Navigator.pop(context); }
},
) setState(() => _period++);
],
);
},
);
return;
} catch (e) { } catch (e) {
context.showErrorDialog(e); context.showErrorDialog(e);
return; return;
} finally { } finally {
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
Get.find<WebSocketProvider>().registerPushNotifications();
Navigator.pop(context, true);
} }
@override void _performGetFactorCode() async {
void initState() { if (_factorPicked == null) return;
protocolHandler.addListener(this);
super.initState();
}
@override final client = await ServiceFinder.configureClient('auth');
void dispose() {
protocolHandler.removeListener(this);
super.dispose();
}
@override setState(() => _isBusy = true);
void onProtocolUrlReceived(String url) {
final uri = url.replaceFirst('solink://', ''); try {
if (uri == 'auth?status=done') { // Request one-time-password code
closeInAppWebView(); final resp = await client.post('/auth/factors/$_factorPicked', {});
performAction(); if (resp.statusCode != 200 && resp.statusCode != 204) {
throw RequestException(resp);
} else {
_factorPickedType = _factors!
.where(
(x) => x.id == _factorPicked,
)
.first
.type;
}
setState(() => _period++);
} catch (e) {
context.showErrorDialog(e);
return;
} finally {
setState(() => _isBusy = false);
} }
} }
void _performCheckTicket() async {
final AuthProvider auth = Get.find();
final password = _passwordController.value.text;
if (password.isEmpty) return;
final client = await ServiceFinder.configureClient('auth');
setState(() => _isBusy = true);
try {
// Check ticket
final resp = await client.request('/auth', 'PATCH', body: {
'ticket_id': _currentTicket!.id,
'factor_id': _factorPicked!,
'code': password,
});
if (resp.statusCode != 200) {
throw RequestException(resp);
}
final result = AuthResult.fromJson(resp.body);
_currentTicket = result.ticket;
// Finish sign in if possible
if (result.isFinished) {
await auth.signin(context, _currentTicket!);
await Future.delayed(const Duration(milliseconds: 250), () async {
await auth.refreshAuthorizeStatus();
await auth.refreshUserProfile();
Get.find<ChannelProvider>().refreshAvailableChannel();
Get.find<RealmProvider>().refreshAvailableRealms();
Get.find<RelationshipProvider>().refreshRelativeList();
Get.find<WebSocketProvider>().registerPushNotifications();
autoConfigureBackgroundNotificationService();
autoStartBackgroundNotificationService();
Navigator.pop(context, true);
_passwordController.clear();
});
} else {
// Skip the first step
_factorPicked = null;
_factorPickedType = null;
_passwordController.clear();
setState(() => _period += 2);
}
} catch (e) {
context.showErrorDialog(e);
return;
} finally {
setState(() => _isBusy = false);
}
}
void _previousStep() {
assert(_period > 0);
switch (_period % 3) {
case 1:
_currentTicket = null;
_factors = null;
_factorPicked = null;
case 2:
_passwordController.clear();
_factorPickedType = null;
}
setState(() => _period--);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return Material(
height: MediaQuery.of(context).size.height * 0.9, color: Theme.of(context).colorScheme.surface,
child: Center( child: CenteredContainer(
child: Container( maxWidth: 360,
width: MediaQuery.of(context).size.width * 0.6, child: PageTransitionSwitcher(
constraints: const BoxConstraints(maxWidth: 360), transitionBuilder: (
child: Column( Widget child,
mainAxisSize: MainAxisSize.min, Animation<double> primaryAnimation,
crossAxisAlignment: CrossAxisAlignment.start, Animation<double> secondaryAnimation,
children: [ ) {
ClipRRect( return SharedAxisTransition(
borderRadius: const BorderRadius.all(Radius.circular(8)), animation: primaryAnimation,
child: Image.asset('assets/logo.png', width: 64, height: 64), secondaryAnimation: secondaryAnimation,
).paddingOnly(bottom: 4), transitionType: SharedAxisTransitionType.horizontal,
Text( child: child,
'signinGreeting'.tr, );
style: const TextStyle( },
fontSize: 28, child: switch (_period % 3) {
fontWeight: FontWeight.w900, 1 => ListView(
), shrinkWrap: true,
).paddingOnly(left: 4, bottom: 16), key: const ValueKey<int>(1),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _usernameController,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'username'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextField(
obscureText: true,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.password],
controller: _passwordController,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'password'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (_) => performAction(),
),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
TextButton( Align(
onPressed: _isBusy ? null : () => requestResetPassword(), alignment: Alignment.centerLeft,
style: TextButton.styleFrom(foregroundColor: Colors.grey), child: ClipRRect(
child: Text('forgotPassword'.tr), borderRadius: const BorderRadius.all(Radius.circular(8)),
child:
Image.asset('assets/logo.png', width: 64, height: 64),
).paddingOnly(bottom: 8, left: 4),
), ),
TextButton( Text(
onPressed: _isBusy ? null : () => performAction(), 'signinPickFactor'.tr,
child: Row( style: const TextStyle(
mainAxisSize: MainAxisSize.min, fontSize: 28,
children: [ fontWeight: FontWeight.w900,
Text('next'.tr),
const Icon(Icons.chevron_right),
],
), ),
).paddingOnly(left: 4, bottom: 16),
Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: Column(
children: _factors
?.map(
(x) => CheckboxListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(8),
),
),
secondary: Icon(
_factorLabelMap[x.type]?.$2 ??
Icons.question_mark,
),
title: Text(
_factorLabelMap[x.type]?.$1 ?? 'unknown'.tr,
),
enabled: !_currentTicket!.factorTrail
.contains(x.id),
value: _factorPicked == x.id,
onChanged: (value) {
if (value == true) {
setState(() => _factorPicked = x.id);
}
},
),
)
.toList() ??
List.empty(),
),
),
Text(
'signinMultiFactor'.trParams(
{'n': _currentTicket!.stepRemain.toString()},
),
style: TextStyle(color: _unFocusColor, fontSize: 12),
).paddingOnly(left: 16, right: 16),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: (_isBusy || _period > 1)
? null
: () => _previousStep(),
style:
TextButton.styleFrom(foregroundColor: Colors.grey),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.chevron_left),
Text('prev'.tr),
],
),
),
TextButton(
onPressed:
_isBusy ? null : () => _performGetFactorCode(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next'.tr),
const Icon(Icons.chevron_right),
],
),
),
],
), ),
], ],
), ),
], 2 => ListView(
), key: const ValueKey<int>(2),
shrinkWrap: true,
children: [
Align(
alignment: Alignment.centerLeft,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child:
Image.asset('assets/logo.png', width: 64, height: 64),
).paddingOnly(bottom: 8, left: 4),
),
Text(
'signinEnterPassword'.tr,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
),
).paddingOnly(left: 4, bottom: 16),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _passwordController,
obscureText: true,
autofillHints: [
(_factorLabelMap[_factorPickedType]?.$3 ?? true)
? AutofillHints.password
: AutofillHints.oneTimeCode
],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText:
(_factorLabelMap[_factorPickedType]?.$3 ?? true)
? 'passwordOneTime'.tr
: 'password'.tr,
helperText:
(_factorLabelMap[_factorPickedType]?.$3 ?? true)
? 'passwordOneTimeInputHint'.tr
: 'passwordInputHint'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: _isBusy ? null : (_) => _performCheckTicket(),
),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: _isBusy ? null : () => _previousStep(),
style:
TextButton.styleFrom(foregroundColor: Colors.grey),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.chevron_left),
Text('prev'.tr),
],
),
),
TextButton(
onPressed: _isBusy ? null : () => _performCheckTicket(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next'.tr),
const Icon(Icons.chevron_right),
],
),
),
],
),
],
),
_ => ListView(
key: const ValueKey<int>(0),
shrinkWrap: true,
children: [
Align(
alignment: Alignment.centerLeft,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child:
Image.asset('assets/logo.png', width: 64, height: 64),
).paddingOnly(bottom: 8, left: 4),
),
Text(
'signinGreeting'.tr,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
),
).paddingOnly(left: 4, bottom: 16),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _usernameController,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'username'.tr,
helperText: 'usernameInputHint'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: _isBusy ? null : (_) => _performNewTicket(),
),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed:
_isBusy ? null : () => _requestResetPassword(),
style:
TextButton.styleFrom(foregroundColor: Colors.grey),
child: Text('forgotPassword'.tr),
),
TextButton(
onPressed: _isBusy ? null : () => _performNewTicket(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next'.tr),
const Icon(Icons.chevron_right),
],
),
),
],
),
const Gap(12),
Align(
alignment: Alignment.centerRight,
child: Container(
constraints: const BoxConstraints(maxWidth: 290),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'termAcceptNextWithAgree'.tr,
textAlign: TextAlign.end,
style:
Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.75),
),
),
Material(
color: Colors.transparent,
child: InkWell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('termAcceptLink'.tr),
const Gap(4),
const Icon(Icons.launch, size: 14),
],
),
onTap: () {
launchUrlString('https://solsynth.dev/terms');
},
),
),
],
),
).paddingSymmetric(horizontal: 16),
),
],
),
},
), ),
), ).paddingAll(24),
); );
} }
} }

View File

@ -3,21 +3,23 @@ import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:solian/widgets/sized_container.dart';
import 'package:url_launcher/url_launcher_string.dart';
class SignUpPopup extends StatefulWidget { class SignUpScreen extends StatefulWidget {
const SignUpPopup({super.key}); const SignUpScreen({super.key});
@override @override
State<SignUpPopup> createState() => _SignUpPopupState(); State<SignUpScreen> createState() => _SignUpScreenState();
} }
class _SignUpPopupState extends State<SignUpPopup> { class _SignUpScreenState extends State<SignUpScreen> {
final _emailController = TextEditingController(); final _emailController = TextEditingController();
final _usernameController = TextEditingController(); final _usernameController = TextEditingController();
final _nicknameController = TextEditingController(); final _nicknameController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
void performAction(BuildContext context) async { void _performAction(BuildContext context) async {
final email = _emailController.value.text; final email = _emailController.value.text;
final username = _usernameController.value.text; final username = _usernameController.value.text;
final nickname = _nicknameController.value.text; final nickname = _nicknameController.value.text;
@ -27,7 +29,7 @@ class _SignUpPopupState extends State<SignUpPopup> {
nickname.isEmpty || nickname.isEmpty ||
password.isEmpty) return; password.isEmpty) return;
final client = ServiceFinder.configureClient('auth'); final client = await ServiceFinder.configureClient('auth');
final resp = await client.post('/users', { final resp = await client.post('/users', {
'name': username, 'name': username,
@ -59,104 +61,153 @@ class _SignUpPopupState extends State<SignUpPopup> {
} }
} }
bool _isTermAccepted = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return Material(
height: MediaQuery.of(context).size.height * 0.9, color: Theme.of(context).colorScheme.surface,
child: Center( child: CenteredContainer(
child: Container( maxWidth: 360,
width: MediaQuery.of(context).size.width * 0.6, child: ListView(
constraints: const BoxConstraints(maxWidth: 360), shrinkWrap: true,
child: Column( children: [
mainAxisSize: MainAxisSize.min, Align(
crossAxisAlignment: CrossAxisAlignment.start, alignment: Alignment.centerLeft,
children: [ child: ClipRRect(
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Image.asset('assets/logo.png', width: 64, height: 64), child: Image.asset('assets/logo.png', width: 64, height: 64),
).paddingOnly(bottom: 8, left: 4),
),
Text(
'signupGreeting'.tr,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
),
).paddingOnly(left: 4, bottom: 16),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _usernameController,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'username'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _nicknameController,
autofillHints: const [AutofillHints.nickname],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'nickname'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _emailController,
autofillHints: const [AutofillHints.email],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'email'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextField(
obscureText: true,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.password],
controller: _passwordController,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'password'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (_) => _performAction(context),
),
const Gap(8),
CheckboxListTile(
value: _isTermAccepted,
title: Text(
'termAccept'.tr,
style: const TextStyle(height: 1.2),
).paddingOnly(bottom: 4), ).paddingOnly(bottom: 4),
Text( shape: const RoundedRectangleBorder(
'signupGreeting'.tr, borderRadius: BorderRadius.all(
style: const TextStyle( Radius.circular(8),
fontSize: 28,
fontWeight: FontWeight.w900,
), ),
).paddingOnly(left: 4, bottom: 16),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _usernameController,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'username'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(12), subtitle: RichText(
TextField( text: TextSpan(
autocorrect: false, style: Theme.of(context).textTheme.bodySmall!.copyWith(
enableSuggestions: false, color: Theme.of(context)
controller: _nicknameController, .colorScheme
autofillHints: const [AutofillHints.nickname], .onSurface
decoration: InputDecoration( .withOpacity(0.75),
isDense: true, ),
border: const OutlineInputBorder(), children: [
labelText: 'nickname'.tr, TextSpan(text: 'termAcceptDesc'.tr),
WidgetSpan(
child: Material(
color: Colors.transparent,
child: InkWell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('termAcceptLink'.tr),
const Gap(4),
const Icon(Icons.launch, size: 14),
],
),
onTap: () {
launchUrlString('https://solsynth.dev/terms');
},
),
),
),
],
), ),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(12), onChanged: (value) {
TextField( setState(() => _isTermAccepted = value ?? false);
autocorrect: false, },
enableSuggestions: false, ),
controller: _emailController, const Gap(16),
autofillHints: const [AutofillHints.email], Align(
decoration: InputDecoration( alignment: Alignment.centerRight,
isDense: true, child: TextButton(
border: const OutlineInputBorder(), onPressed:
labelText: 'email'.tr, !_isTermAccepted ? null : () => _performAction(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next'.tr),
const Icon(Icons.chevron_right),
],
), ),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(12), )
TextField( ],
obscureText: true,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.password],
controller: _passwordController,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'password'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (_) => performAction(context),
),
const Gap(16),
Align(
alignment: Alignment.centerRight,
child: TextButton(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next'.tr),
const Icon(Icons.chevron_right),
],
),
onPressed: () => performAction(context),
),
)
],
),
), ),
), ).paddingAll(24),
); );
} }
} }

View File

@ -205,7 +205,7 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
: AppBar( : AppBar(
leading: AppBarLeadingButton.adaptive(context), leading: AppBarLeadingButton.adaptive(context),
centerTitle: true, centerTitle: true,
toolbarHeight: SolianTheme.toolbarHeight(context), toolbarHeight: AppTheme.toolbarHeight(context),
title: Obx( title: Obx(
() => RichText( () => RichText(
textAlign: TextAlign.center, textAlign: TextAlign.center,

View File

@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/controllers/chat_events_controller.dart'; import 'package:solian/controllers/chat_events_controller.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
@ -98,7 +97,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
setState(() => _ongoingCall = Call.fromJson(resp.body)); setState(() => _ongoingCall = Call.fromJson(resp.body));
} }
} catch (e) { } catch (e) {
print((e as dynamic).stackTrace);
context.showErrorDialog(e); context.showErrorDialog(e);
} }
@ -156,7 +154,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
void _keepUpdateWithServer() { void _keepUpdateWithServer() {
_getOngoingCall(); _getOngoingCall();
_chatController.getEvents(_channel!, widget.realm); _chatController.getInitialEvents(_channel!, widget.realm);
setState(() => _isOutOfSyncSince = null); setState(() => _isOutOfSyncSince = null);
} }
@ -193,7 +191,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
_getOngoingCall(); _getOngoingCall();
_getChannel().then((_) { _getChannel().then((_) {
_chatController.getEvents(_channel!, widget.realm); _chatController.getInitialEvents(_channel!, widget.realm);
_listenMessages(); _listenMessages();
}); });
} }
@ -204,9 +202,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
String? placeholder; String? placeholder;
if (_channel?.type == 1) { if (_channel?.type == 1) {
final otherside = _channel!.members! final otherside =
.where((e) => e.account.externalId != _accountId) _channel!.members!.where((e) => e.account.id != _accountId).first;
.first;
title = otherside.account.nick; title = otherside.account.nick;
placeholder = 'messageInputPlaceholder'.trParams( placeholder = 'messageInputPlaceholder'.trParams(
{'channel': '@${otherside.account.name}'}, {'channel': '@${otherside.account.name}'},
@ -218,8 +215,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
leading: AppBarLeadingButton.adaptive(context), leading: AppBarLeadingButton.adaptive(context),
title: AppBarTitle(title), title: AppBarTitle(title),
centerTitle: false, centerTitle: false,
titleSpacing: SolianTheme.titleSpacing(context), titleSpacing: AppTheme.titleSpacing(context),
toolbarHeight: SolianTheme.toolbarHeight(context), toolbarHeight: AppTheme.toolbarHeight(context),
actions: [ actions: [
const BackgroundStateWidget(), const BackgroundStateWidget(),
Builder(builder: (context) { Builder(builder: (context) {
@ -256,7 +253,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
}, },
), ),
SizedBox( SizedBox(
width: SolianTheme.isLargeScreen(context) ? 8 : 16, width: AppTheme.isLargeScreen(context) ? 8 : 16,
), ),
], ],
), ),
@ -277,7 +274,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
channel: _channel!, channel: _channel!,
ongoingCall: _ongoingCall!, ongoingCall: _ongoingCall!,
onJoin: () { onJoin: () {
if (!SolianTheme.isLargeScreen(context)) { if (!AppTheme.isLargeScreen(context)) {
final ChatCallProvider call = Get.find(); final ChatCallProvider call = Get.find();
call.gotoScreen(context); call.gotoScreen(context);
} }
@ -296,13 +293,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
}, },
), ),
), ),
Obx(() {
if (_chatController.isLoading.isTrue) {
return const LinearProgressIndicator().animate().slideY();
} else {
return const SizedBox.shrink();
}
}),
ClipRect( ClipRect(
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50), filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50),
@ -338,7 +328,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
), ),
Obx(() { Obx(() {
final ChatCallProvider call = Get.find(); final ChatCallProvider call = Get.find();
if (call.isMounted.value && SolianTheme.isLargeScreen(context)) { if (call.isMounted.value && AppTheme.isLargeScreen(context)) {
return const Expanded( return const Expanded(
child: Row(children: [ child: Row(children: [
VerticalDivider(width: 0.3, thickness: 0.3), VerticalDivider(width: 0.3, thickness: 0.3),

View File

@ -43,8 +43,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
void checkOwner() async { void checkOwner() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
setState(() { setState(() {
_isOwned = _isOwned = auth.userProfile.value!['id'] == widget.channel.account.id;
auth.userProfile.value!['id'] == widget.channel.account.externalId;
}); });
} }
@ -80,7 +79,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
final resp = await client final resp = await client
.put('/channels/${widget.realm}/${widget.channel.alias}/members/me', { .put('/channels/${widget.realm}/${widget.channel.alias}/members/me', {
@ -115,7 +114,8 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
ListTile( ListTile(
leading: const Icon(Icons.settings), leading: const Icon(Icons.settings),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
title: Text('channelSettings'.tr.capitalize!), title: Text('channelSettings'.tr),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () async { onTap: () async {
AppRouter.instance AppRouter.instance
.pushNamed( .pushNamed(
@ -174,7 +174,8 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
children: [ children: [
ListTile( ListTile(
leading: const Icon(Icons.notifications_active), leading: const Icon(Icons.notifications_active),
title: Text('channelNotifyLevel'.tr.capitalize!), title: Text('channelNotifyLevel'.tr),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
trailing: DropdownButtonHideUnderline( trailing: DropdownButtonHideUnderline(
child: DropdownButton2<int>( child: DropdownButton2<int>(
isExpanded: true, isExpanded: true,
@ -207,14 +208,16 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
), ),
), ),
ListTile( ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.supervisor_account), leading: const Icon(Icons.supervisor_account),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
title: Text('channelMembers'.tr.capitalize!), title: Text('channelMembers'.tr),
onTap: () => showMemberList(), onTap: () => showMemberList(),
), ),
...(_isOwned ? ownerActions : List.empty()), ...(_isOwned ? ownerActions : List.empty()),
const Divider(thickness: 0.3), const Divider(thickness: 0.3),
ListTile( ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: _isOwned leading: _isOwned
? const Icon(Icons.delete) ? const Icon(Icons.delete)
: const Icon(Icons.exit_to_app), : const Icon(Icons.exit_to_app),

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