Compare commits

...

63 Commits

Author SHA1 Message Date
e1ddd22e4e 🚀 Launch 1.2.3+2 2024-09-23 23:34:40 +08:00
22b2ae32e9 Featured replies clickable 2024-09-23 23:34:25 +08:00
9d5c452eae 🐛 Fix overflow in content 2024-09-23 23:20:01 +08:00
0fdb1e4ead 💫 Improve loading image animation 2024-09-23 23:19:52 +08:00
724bd6592e 💄 Improvements and optimize UX 2024-09-23 22:43:13 +08:00
2d347e0d41 ♻️ Refactored post item widget 2024-09-23 22:43:02 +08:00
de39799301 🚀 Launch 1.2.3 2024-09-22 22:57:00 +08:00
4b921602a2 🐛 Bug fixes 2024-09-22 22:56:28 +08:00
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
160 changed files with 18920 additions and 2742 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

View File

@ -3,6 +3,7 @@
"hide": "Hide", "hide": "Hide",
"okay": "Okay", "okay": "Okay",
"next": "Next", "next": "Next",
"prev": "Previous",
"reset": "Reset", "reset": "Reset",
"page": "Page", "page": "Page",
"home": "Home", "home": "Home",
@ -21,9 +22,9 @@
"explore": "Explore", "explore": "Explore",
"posts": "Posts", "posts": "Posts",
"unlink": "Unlink", "unlink": "Unlink",
"feedSearch": "Search Feed", "postSearch": "Search Post",
"feedSearchWithTag": "Searching with tag #@key", "postSearchWithTag": "Searching with tag #@key",
"feedSearchWithCategory": "Searching in category @category", "postSearchWithCategory": "Searching in category @category",
"feedUnreadCount": "@count posts you may missed", "feedUnreadCount": "@count posts you may missed",
"messages": "Messages", "messages": "Messages",
"messagesUnreadCount": "@count messages unread", "messagesUnreadCount": "@count messages unread",
@ -54,6 +55,8 @@
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
"settings": "Settings", "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", "search": "Search",
"post": "Post", "post": "Post",
"article": "Article", "article": "Article",
@ -65,19 +68,28 @@
"notificationUnreadCount": "@count unread notifications", "notificationUnreadCount": "@count unread notifications",
"errorHappened": "An error occurred", "errorHappened": "An error occurred",
"errorHappenedUnauthorized": "Unauthorized request, please sign in or try resign in.", "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", "forgotPassword": "Forgot password",
"email": "Email", "email": "Email",
"username": "Username", "username": "Username",
"usernameInputHint": "Also supports email and phone number",
"nickname": "Nickname", "nickname": "Nickname",
"password": "Password", "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", "title": "Title",
"description": "Description", "description": "Description",
"birthday": "Birthday", "birthday": "Birthday",
"firstName": "First Name", "firstName": "First Name",
"lastName": "Last Name", "lastName": "Last Name",
"account": "Account", "account": "Account",
"accountPersonalize": "Personalize", "accountProfile": "Your profile",
"accountPersonalizeApplied": "Account personalize settings has been saved.", "accountProfileApplied": "Account profile has been saved.",
"accountStickers": "Stickers", "accountStickers": "Stickers",
"accountFriend": "Friend", "accountFriend": "Friend",
"accountFriendNew": "New friend", "accountFriendNew": "New friend",
@ -101,6 +113,11 @@
"signinRiskDetected": "Risk detected, click Next to open a webpage and signin through it to pass security check.", "signinRiskDetected": "Risk detected, click Next to open a webpage and signin through it to pass security check.",
"signinResetPasswordHint": "Please enter username to request reset password.", "signinResetPasswordHint": "Please enter username to request reset password.",
"signinResetPasswordSent": "Reset password request sent, check your inbox!", "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", "signup": "Sign up",
"signupGreeting": "Welcome onboard", "signupGreeting": "Welcome onboard",
"signupCaption": "Create an account on Solarpass and then get the access of entire Solar Network!", "signupCaption": "Create an account on Solarpass and then get the access of entire Solar Network!",
@ -145,6 +162,9 @@
"postListNews": "News", "postListNews": "News",
"postListFriends": "Friends", "postListFriends": "Friends",
"postListShuffle": "Random", "postListShuffle": "Random",
"attachmentThumbnail": "Thumbnail",
"attachmentThumbnailAttachmentNew": "Upload thumbnail",
"attachmentThumbnailAttachment": "Attachment serial number",
"postEditorModeStory": "Post a post", "postEditorModeStory": "Post a post",
"postEditorModeArticle": "Post an article", "postEditorModeArticle": "Post an article",
"postEditor": "Post editor", "postEditor": "Post editor",
@ -213,6 +233,8 @@
"realmDescription": "Description", "realmDescription": "Description",
"realmPublic": "Public Realm", "realmPublic": "Public Realm",
"realmCommunity": "Community Realm", "realmCommunity": "Community Realm",
"realmAvatar": "Realm avatar",
"realmBanner": "Realm banner",
"realmDetail": "Realm detail", "realmDetail": "Realm detail",
"realmMember": "Realm member", "realmMember": "Realm member",
"realmMembers": "Realm members", "realmMembers": "Realm members",
@ -238,7 +260,8 @@
"channelName": "Name", "channelName": "Name",
"channelDescription": "Description", "channelDescription": "Description",
"channelDirectDescription": "Direct message with @username", "channelDirectDescription": "Direct message with @username",
"channelEncrypted": "Encrypted Channel", "channelPublic": "Public channel",
"channelCommunity": "Community channel",
"channelMember": "Channel member", "channelMember": "Channel member",
"channelMembers": "Channel members", "channelMembers": "Channel members",
"channelMembersAdd": "Add channel members", "channelMembersAdd": "Add channel members",
@ -332,8 +355,7 @@
"bsCheckForUpdate": "Checking For Updates", "bsCheckForUpdate": "Checking For Updates",
"bsCheckForUpdateFailed": "Unable to Check Updates", "bsCheckForUpdateFailed": "Unable to Check Updates",
"bsCheckForUpdateNew": "Found New Version", "bsCheckForUpdateNew": "Found New Version",
"bsCheckForUpdateDescApple": "Please head to TestFlight and update your app to latest version to prevent error happens and get latest functions.", "bsCheckForUpdateDesc": "Please head to app store and update your app to latest version to prevent error happens and get latest functions.",
"bsCheckForUpdateDescCommon": "Please head to our website download and install latest version of application to prevent error happens and get latest functions.",
"bsCheckingServer": "Checking Server Status", "bsCheckingServer": "Checking Server Status",
"bsCheckingServerFail": "Unable connect to server, check your network connection", "bsCheckingServerFail": "Unable connect to server, check your network connection",
"bsCheckingServerDown": "Server currently unavailable, please retry later", "bsCheckingServerDown": "Server currently unavailable, please retry later",
@ -373,7 +395,8 @@
"callStatusReconnected": "Reconnecting", "callStatusReconnected": "Reconnecting",
"messageOutOfSync": "May Out of Sync with Server", "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.", "messageOutOfSyncCaption": "Since the App has entered the background, there may be a time difference between the message list and the server. Click to Refresh.",
"messageHistoryWipe": "Wipe local message history", "localDatabaseWipe": "Wipe local database",
"localDatabaseSize": "Overall database size: @size",
"unknown": "Unknown", "unknown": "Unknown",
"collapse": "Collapse", "collapse": "Collapse",
"expand": "Expand", "expand": "Expand",
@ -392,5 +415,43 @@
"userLevel11": "Legend", "userLevel11": "Legend",
"userLevel12": "Mythic", "userLevel12": "Mythic",
"userLevel13": "Immortal", "userLevel13": "Immortal",
"postBrowsingIn": "Browsing in @region" "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.",
"updateNow": "Update now",
"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"
} }

View File

@ -4,6 +4,7 @@
"okay": "确认", "okay": "确认",
"home": "首页", "home": "首页",
"next": "下一步", "next": "下一步",
"prev": "上一步",
"reset": "重置", "reset": "重置",
"cancel": "取消", "cancel": "取消",
"confirm": "确认", "confirm": "确认",
@ -14,6 +15,8 @@
"edit": "编辑", "edit": "编辑",
"delete": "删除", "delete": "删除",
"settings": "设置", "settings": "设置",
"settingsNotificationBgService": "常驻通知服务",
"settingsNotificationBgServiceDesc": "在设备常驻一个通知服务,使得部分不支持推送通知的设备可以在后台收到通知;启用该功能的情况下不会向服务器注册推送通知,并且你会始终在他人眼中成为在线(隐身除外);可能需要在设置中关闭电量与流量优化。",
"page": "页面", "page": "页面",
"draft": "草稿", "draft": "草稿",
"draftSave": "存为草稿", "draftSave": "存为草稿",
@ -29,9 +32,9 @@
"dashboard": "仪表盘", "dashboard": "仪表盘",
"today": "今日", "today": "今日",
"yesterday": "昨日", "yesterday": "昨日",
"feedSearch": "搜索资讯", "postSearch": "搜索帖子",
"feedSearchWithTag": "检索带有 #@key 标签的资讯", "postSearchWithTag": "检索带有 #@key 标签的资讯",
"feedSearchWithCategory": "检索位于分类 @category 的资讯", "postSearchWithCategory": "检索位于分类 @category 的资讯",
"feedUnreadCount": "@count 条你可能错过的帖子", "feedUnreadCount": "@count 条你可能错过的帖子",
"messages": "消息", "messages": "消息",
"messagesUnreadCount": "@count 条未读的消息", "messagesUnreadCount": "@count 条未读的消息",
@ -73,16 +76,20 @@
"forgotPassword": "忘记密码", "forgotPassword": "忘记密码",
"email": "邮件地址", "email": "邮件地址",
"username": "用户名", "username": "用户名",
"usernameInputHint": "同时支持邮箱 / 电话号码",
"nickname": "显示名", "nickname": "显示名",
"password": "密码", "password": "密码",
"passwordOneTime": "一次性验证码",
"passwordInputHint": "忘记密码了?回到第一步以重置密码",
"passwordOneTimeInputHint": "检查你的收件箱或是授权器获得以验证码",
"title": "标题", "title": "标题",
"description": "简介", "description": "简介",
"birthday": "生日", "birthday": "生日",
"firstName": "名称", "firstName": "名称",
"lastName": "姓氏", "lastName": "姓氏",
"account": "账号", "account": "账号",
"accountPersonalize": "个性化", "accountProfile": "个人资料",
"accountPersonalizeApplied": "账户的个性化设置已保存。", "accountProfileApplied": "账户的资料已保存。",
"accountStickers": "贴图", "accountStickers": "贴图",
"accountFriend": "好友", "accountFriend": "好友",
"accountFriendNew": "添加好友", "accountFriendNew": "添加好友",
@ -106,6 +113,11 @@
"signinRiskDetected": "检测到风险,点击下一步按钮来打开一个网页,并通过在其上面登录来通过安全检查。", "signinRiskDetected": "检测到风险,点击下一步按钮来打开一个网页,并通过在其上面登录来通过安全检查。",
"signinResetPasswordHint": "请先填写用户名以发送重置密码请求。", "signinResetPasswordHint": "请先填写用户名以发送重置密码请求。",
"signinResetPasswordSent": "重置密码请求已发送,在绑定邮件收件箱可收取一份包含重置密码链接的邮件。", "signinResetPasswordSent": "重置密码请求已发送,在绑定邮件收件箱可收取一份包含重置密码链接的邮件。",
"signinPickFactor": "选择一个\n验证方式",
"signinEnterPassword": "输入密码\n或验证码",
"signinMultiFactor": "@n 步验证",
"authFactorEmail": "邮箱一次性密码",
"authFactorPassword": "账户密码",
"signup": "注册", "signup": "注册",
"signupGreeting": "欢迎加入\nSolar Network", "signupGreeting": "欢迎加入\nSolar Network",
"signupCaption": "在 Solarpass 注册一个账号以获得整个 Solar Network 的存取权!", "signupCaption": "在 Solarpass 注册一个账号以获得整个 Solar Network 的存取权!",
@ -156,6 +168,9 @@
"postListNews": "新鲜事", "postListNews": "新鲜事",
"postListFriends": "好友圈", "postListFriends": "好友圈",
"postListShuffle": "打乱看", "postListShuffle": "打乱看",
"attachmentThumbnail": "附件缩略图",
"attachmentThumbnailAttachmentNew": "上传附件作为缩略图",
"attachmentThumbnailAttachment": "附件序列号",
"postNew": "创建新帖子", "postNew": "创建新帖子",
"postNewInRealmHint": "在领域 @realm 里发表新帖子", "postNewInRealmHint": "在领域 @realm 里发表新帖子",
"postAction": "发表", "postAction": "发表",
@ -214,6 +229,8 @@
"realmDescription": "领域简介", "realmDescription": "领域简介",
"realmPublic": "公开领域", "realmPublic": "公开领域",
"realmCommunity": "社区领域", "realmCommunity": "社区领域",
"realmAvatar": "领域头像",
"realmBanner": "领域横幅",
"realmDetail": "领域详情", "realmDetail": "领域详情",
"realmMember": "领域成员", "realmMember": "领域成员",
"realmMembers": "领域成员", "realmMembers": "领域成员",
@ -239,7 +256,8 @@
"channelName": "显示名称", "channelName": "显示名称",
"channelDescription": "频道简介", "channelDescription": "频道简介",
"channelDirectDescription": "与 @username 的私聊", "channelDirectDescription": "与 @username 的私聊",
"channelEncrypted": "加密频道", "channelPublic": "公开频道",
"channelCommunity": "社区频道",
"channelMember": "频道成员", "channelMember": "频道成员",
"channelMembers": "频道成员", "channelMembers": "频道成员",
"channelMembersAdd": "添加频道成员", "channelMembersAdd": "添加频道成员",
@ -333,8 +351,7 @@
"bsCheckForUpdate": "正在检查更新", "bsCheckForUpdate": "正在检查更新",
"bsCheckForUpdateFailed": "无法检查更新", "bsCheckForUpdateFailed": "无法检查更新",
"bsCheckForUpdateNew": "发现新版本", "bsCheckForUpdateNew": "发现新版本",
"bsCheckForUpdateDescApple": "请前往 TestFlight 并将您的应用程序更新到最新版本,以防止出现错误并获取最新功能。", "bsCheckForUpdateDesc": "请前往应用商店并将您的应用程序更新到最新版本,以防止出现错误并获取最新功能。",
"bsCheckForUpdateDescCommon": "请前往我们的网站下载并安装最新版本的应用程序,以防止出现错误并获取最新功能。",
"bsCheckingServer": "检查服务器状态中", "bsCheckingServer": "检查服务器状态中",
"bsCheckingServerFail": "无法连接至服务器,请检查你的网络连接状态", "bsCheckingServerFail": "无法连接至服务器,请检查你的网络连接状态",
"bsCheckingServerDown": "当前服务器不可用,请稍后重试", "bsCheckingServerDown": "当前服务器不可用,请稍后重试",
@ -374,7 +391,8 @@
"callStatusReconnected": "重连中", "callStatusReconnected": "重连中",
"messageOutOfSync": "消息可能与服务器脱节", "messageOutOfSync": "消息可能与服务器脱节",
"messageOutOfSyncCaption": "由于 App 进入后台,消息列表可能与服务器存在时差,点击刷新。", "messageOutOfSyncCaption": "由于 App 进入后台,消息列表可能与服务器存在时差,点击刷新。",
"messageHistoryWipe": "清除消息记录", "localDatabaseWipe": "清除本地数据库",
"localDatabaseSize": "本地数据库大小:@size",
"unknown": "未知", "unknown": "未知",
"collapse": "折叠", "collapse": "折叠",
"expand": "展开", "expand": "展开",
@ -393,5 +411,43 @@
"userLevel11": "名垂千古", "userLevel11": "名垂千古",
"userLevel12": "独占鳌头", "userLevel12": "独占鳌头",
"userLevel13": "万古流芳", "userLevel13": "万古流芳",
"postBrowsingIn": "浏览 @region 内的帖子中" "postBrowsingIn": "浏览 @region 内的帖子中",
"needRestartToApply": "需要重启应用来生效",
"holdToSeeDetail": "长按 / 鼠标悬浮来查看详情",
"subscribe": "订阅",
"subscribed": "已订阅",
"unsubscribe": "取消订阅",
"preferences": "偏好设置",
"notificationPreferences": "通知偏好设置",
"notificationTopicPostFeedback": "帖子反馈",
"notificationTopicPostSubscription": "订阅源",
"preferencesApplied": "偏好设置已应用",
"save": "保存",
"updateAvailable": "有可用更新",
"updateAvailableDesc": "有可用更新 (@from 到 @to) 你想现在下载安装吗?在等待下载期间你仍可以正常使用。",
"update": "更新",
"updateCheckStrictly": "严格模式",
"updateCheckStrictlyDesc": "如果启用,应用程序将会在本地版本与远程版本不同时询问更新,而不会检查版本号大小。",
"updateNow": "立即更新",
"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,87 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
int _periodCursor = 0; int _periodCursor = 0;
final Completer _bootCompleter = Completer();
void _updateNow(String localVersionString, String remoteVersionString) {
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);
}
});
}
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) {
_updateNow(localVersionString, remoteVersionString);
} else {
context.showInfoDialog(
'updateAvailable'.tr,
'bsCheckForUpdateDesc'.tr,
);
}
} else if (remoteVersionString != localVersionString) {
_bootCompleter.future.then((_) {
context.showSnackbar(
'updateMayAvailable'.trParams({
'version': remoteVersionString,
}),
action: PlatformInfo.isAndroid
? SnackBarAction(
label: 'updateNow'.tr,
onPressed: () {
_updateNow(localVersionString, remoteVersionString);
},
)
: null,
);
});
}
} 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 +128,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 +175,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 +215,9 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
} }
} finally { } finally {
setState(() => _isBusy = false); setState(() => _isBusy = false);
Future.delayed(const Duration(milliseconds: 100), () {
_bootCompleter.complete();
});
} }
} }
@ -163,6 +225,7 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
void initState() { void initState() {
super.initState(); super.initState();
_runPeriods(); _runPeriods();
_checkForUpdate();
} }
@override @override
@ -253,6 +316,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

@ -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,21 +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/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';
@ -39,10 +42,12 @@ 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()); Get.put(AppTranslations());
await AppTranslations.init(); await AppTranslations.init();
@ -61,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');
@ -112,9 +122,7 @@ class SolianApp extends StatelessWidget {
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(),
),
), ),
); );
}, },
@ -135,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';

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

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

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

@ -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';
@ -20,7 +23,7 @@ import 'package:solian/screens/realms.dart';
import 'package:solian/screens/realms/realm_detail.dart'; import 'package:solian/screens/realms/realm_detail.dart';
import 'package:solian/screens/realms/realm_organize.dart'; import 'package:solian/screens/realms/realm_organize.dart';
import 'package:solian/screens/realms/realm_view.dart'; import 'package:solian/screens/realms/realm_view.dart';
import 'package:solian/screens/feed.dart'; import 'package:solian/screens/explore.dart';
import 'package:solian/screens/posts/post_editor.dart'; import 'package:solian/screens/posts/post_editor.dart';
import 'package:solian/screens/settings.dart'; import 'package:solian/screens/settings.dart';
import 'package:solian/shells/root_shell.dart'; import 'package:solian/shells/root_shell.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(
@ -72,13 +78,18 @@ abstract class AppRouter {
builder: (context, state, child) => child, builder: (context, state, child) => child,
routes: [ routes: [
GoRoute( GoRoute(
path: '/feed', path: '/explore',
name: 'feed', name: 'explore',
builder: (context, state) => const FeedScreen(), builder: (context, state) => const ExploreScreen(),
), ),
GoRoute( GoRoute(
path: '/feed/search', path: '/drafts',
name: 'feedSearch', name: 'draftBox',
builder: (context, state) => const DraftBoxScreen(),
),
GoRoute(
path: '/posts/search',
name: 'postSearch',
builder: (context, state) => TitleShell( builder: (context, state) => TitleShell(
state: state, state: state,
child: FeedSearchScreen( child: FeedSearchScreen(
@ -87,11 +98,6 @@ abstract class AppRouter {
), ),
), ),
), ),
GoRoute(
path: '/drafts',
name: 'draftBox',
builder: (context, state) => const DraftBoxScreen(),
),
GoRoute( GoRoute(
path: '/posts/view/:id', path: '/posts/view/:id',
name: 'postDetail', name: 'postDetail',
@ -236,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',
@ -259,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,51 @@ 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,
alignment: WrapAlignment.center,
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

@ -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) {
@ -155,62 +193,99 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
toolbarHeight: AppTheme.toolbarHeight(context), toolbarHeight: AppTheme.toolbarHeight(context),
leadingWidth: 24, leadingWidth: 24,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
flexibleSpace: Row( flexibleSpace: SizedBox(
children: [ height: 56,
AppBarLeadingButton.adaptive(context) ?? const Gap(8), child: Row(
const Gap(8), children: [
if (_userinfo != null) AppBarLeadingButton.adaptive(context) ?? const Gap(8),
AccountAvatar(content: _userinfo!.avatar, radius: 16), const Gap(8),
const Gap(12), if (_userinfo != null)
Expanded( AccountAvatar(content: _userinfo!.avatar, radius: 16),
child: Column( const Gap(12),
mainAxisAlignment: MainAxisAlignment.center, Expanded(
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ mainAxisAlignment: MainAxisAlignment.center,
if (_userinfo != null) crossAxisAlignment: CrossAxisAlignment.start,
Text( children: [
_userinfo!.nick, if (_userinfo != null)
style: Theme.of(context).textTheme.bodyLarge, Text(
), _userinfo!.nick,
if (_userinfo != null) style: Theme.of(context).textTheme.bodyLarge,
Text( ),
'@${_userinfo!.name}', if (_userinfo != null)
style: Theme.of(context).textTheme.bodySmall, Text(
), '@${_userinfo!.name}',
], style: Theme.of(context).textTheme.bodySmall,
),
],
),
), ),
), if (_userinfo != null && _subscription == null)
if (_userinfo != null && OutlinedButton(
!_relationshipProvider.hasFriend(_userinfo!)) style: const ButtonStyle(
IconButton( visualDensity:
icon: const Icon(Icons.person_add), VisualDensity(horizontal: -4, vertical: -2),
onPressed: _isMakingFriend ),
? null onPressed: _isSubscribing
: () async { ? null
setState(() => _isMakingFriend = true); : () async {
try { setState(() => _isSubscribing = true);
await _relationshipProvider _subscription =
.makeFriend(widget.name); await Get.find<SubscriptionProvider>()
context.showSnackbar( .subscribeToUser(_userinfo!.id);
'accountFriendRequestSent'.tr, setState(() => _isSubscribing = false);
); },
} catch (e) { child: Text('subscribe'.tr),
context.showErrorDialog(e); )
} finally { else if (_userinfo != null)
setState(() => _isMakingFriend = false); OutlinedButton(
} style: const ButtonStyle(
}, visualDensity:
) VisualDensity(horizontal: -4, vertical: -2),
else ),
const IconButton( onPressed: _isSubscribing
icon: Icon(Icons.handshake), ? null
onPressed: null, : () async {
setState(() => _isSubscribing = true);
await Get.find<SubscriptionProvider>()
.unsubscribeFromUser(_userinfo!.id);
_subscription = null;
setState(() => _isSubscribing = false);
},
child: Text('unsubscribe'.tr),
),
if (_userinfo != null &&
!_relationshipProvider.hasFriend(_userinfo!))
IconButton(
icon: const Icon(Icons.person_add),
onPressed: _isMakingFriend
? null
: () async {
setState(() => _isMakingFriend = true);
try {
await _relationshipProvider
.makeFriend(widget.name);
context.showSnackbar(
'accountFriendRequestSent'.tr,
);
} catch (e) {
context.showErrorDialog(e);
} finally {
setState(() => _isMakingFriend = false);
}
},
)
else
const IconButton(
icon: Icon(Icons.handshake),
onPressed: null,
),
SizedBox(
width: AppTheme.isLargeScreen(context) ? 8 : 16,
), ),
SizedBox( ],
width: AppTheme.isLargeScreen(context) ? 8 : 16, ),
), ).paddingOnly(top: MediaQuery.of(context).padding.top),
],
),
bottom: TabBar( bottom: TabBar(
tabs: [ tabs: [
Tab(text: 'profilePage'.tr), Tab(text: 'profilePage'.tr),
@ -224,28 +299,139 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
body: TabBarView( body: TabBarView(
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
children: [ children: [
Column( ListView(
children: [ children: [
const Gap(16), const Gap(16),
AccountHeadingWidget( CenteredContainer(
name: _userinfo!.name, child: AccountHeadingWidget(
nick: _userinfo!.nick, name: _userinfo!.name,
desc: _userinfo!.description, nick: _userinfo!.nick,
badges: _userinfo!.badges, desc: _userinfo!.description,
banner: _userinfo!.banner, badges: _userinfo!.badges,
avatar: _userinfo!.avatar, banner: _userinfo!.banner,
status: Get.find<StatusProvider>() avatar: _userinfo!.avatar,
.getSomeoneStatus(_userinfo!.name), status: Get.find<StatusProvider>()
detail: _userinfo, .getSomeoneStatus(_userinfo!.name),
profile: _userinfo!.profile, detail: _userinfo,
extraWidgets: const [], profile: _userinfo!.profile,
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 +488,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 +512,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 +540,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

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

View File

@ -79,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', {
@ -114,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(
@ -173,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,
@ -206,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),

View File

@ -35,13 +35,14 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
final _nameController = TextEditingController(); final _nameController = TextEditingController();
final _descriptionController = TextEditingController(); final _descriptionController = TextEditingController();
bool _isEncrypted = false; bool _isPublic = false;
bool _isCommunity = false;
void applyChannel() async { void _applyChannel() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
if (_aliasController.value.text.isEmpty) randomizeAlias(); if (_aliasController.value.text.isEmpty) _randomizeAlias();
setState(() => _isBusy = true); setState(() => _isBusy = true);
@ -52,7 +53,7 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
'alias': _aliasController.value.text.toLowerCase(), 'alias': _aliasController.value.text.toLowerCase(),
'name': _nameController.value.text, 'name': _nameController.value.text,
'description': _descriptionController.value.text, 'description': _descriptionController.value.text,
'is_encrypted': _isEncrypted, 'is_encrypted': _isPublic,
}; };
Response? resp; Response? resp;
@ -71,35 +72,44 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
void randomizeAlias() { void _randomizeAlias() {
_aliasController.text = _aliasController.text =
const Uuid().v4().replaceAll('-', '').substring(0, 12); const Uuid().v4().replaceAll('-', '').substring(0, 12);
} }
void syncWidget() { void _syncWidget() {
if (widget.edit != null) { if (widget.edit != null) {
_aliasController.text = widget.edit!.alias; _aliasController.text = widget.edit!.alias;
_nameController.text = widget.edit!.name; _nameController.text = widget.edit!.name;
_descriptionController.text = widget.edit!.description; _descriptionController.text = widget.edit!.description;
_isEncrypted = widget.edit!.isEncrypted; _isPublic = widget.edit!.isPublic;
_isCommunity = widget.edit!.isCommunity;
} }
} }
void cancelAction() { void _cancelAction() {
AppRouter.instance.pop(); AppRouter.instance.pop();
} }
@override @override
void initState() { void initState() {
syncWidget(); _syncWidget();
super.initState(); super.initState();
} }
@override
void dispose() {
_aliasController.dispose();
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final notifyBannerActions = [ final notifyBannerActions = [
TextButton( TextButton(
onPressed: cancelAction, onPressed: _cancelAction,
child: Text('cancel'.tr), child: Text('cancel'.tr),
), ),
]; ];
@ -113,7 +123,7 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
toolbarHeight: AppTheme.toolbarHeight(context), toolbarHeight: AppTheme.toolbarHeight(context),
actions: [ actions: [
TextButton( TextButton(
onPressed: _isBusy ? null : () => applyChannel(), onPressed: _isBusy ? null : () => _applyChannel(),
child: Text('apply'.tr.toUpperCase()), child: Text('apply'.tr.toUpperCase()),
) )
], ],
@ -164,7 +174,7 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
visualDensity: visualDensity:
const VisualDensity(horizontal: -2, vertical: -2), const VisualDensity(horizontal: -2, vertical: -2),
), ),
onPressed: () => randomizeAlias(), onPressed: () => _randomizeAlias(),
child: const Icon(Icons.refresh), child: const Icon(Icons.refresh),
) )
], ],
@ -196,12 +206,17 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
), ),
const Divider(thickness: 0.3), const Divider(thickness: 0.3),
CheckboxListTile( CheckboxListTile(
title: Text('channelEncrypted'.tr), title: Text('channelPublic'.tr),
value: _isEncrypted, value: _isPublic,
onChanged: (widget.edit?.isEncrypted ?? false) onChanged: (value) =>
? null setState(() => _isPublic = value ?? false),
: (newValue) => controlAffinity: ListTileControlAffinity.leading,
setState(() => _isEncrypted = newValue ?? false), ),
CheckboxListTile(
title: Text('channelCommunity'.tr),
value: _isCommunity,
onChanged: (value) =>
setState(() => _isCommunity = value ?? false),
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
), ),
], ],

View File

@ -102,7 +102,7 @@ class _ChatScreenState extends State<ChatScreen> {
body: Obx(() { body: Obx(() {
if (auth.isAuthorized.isFalse) { if (auth.isAuthorized.isFalse) {
return SigninRequiredOverlay( return SigninRequiredOverlay(
onSignedIn: () => _channels.refreshAvailableChannel(), onDone: () => _channels.refreshAvailableChannel(),
); );
} }
@ -125,7 +125,11 @@ class _ChatScreenState extends State<ChatScreen> {
child: Obx( child: Obx(
() => ChannelListWidget( () => ChannelListWidget(
noCategory: true, noCategory: true,
channels: _channels.directChannels, channels: List.from([
..._channels.groupChannels
.where((x) => x.realmId == null),
..._channels.directChannels
]),
selfId: selfId, selfId: selfId,
useReplace: true, useReplace: true,
), ),

View File

@ -18,8 +18,8 @@ import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/posts.dart'; import 'package:solian/providers/content/posts.dart';
import 'package:solian/providers/daily_sign.dart'; import 'package:solian/providers/daily_sign.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/message/adaptor.dart';
import 'package:solian/providers/websocket.dart'; import 'package:solian/providers/websocket.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/screens/account/notification.dart'; import 'package:solian/screens/account/notification.dart';
@ -72,7 +72,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
Future<void> _pullMessages() async { Future<void> _pullMessages() async {
if (_lastRead.messagesLastReadAt == null) return; if (_lastRead.messagesLastReadAt == null) return;
log('[Dashboard] Pulling messages with pivot: ${_lastRead.messagesLastReadAt}'); log('[Dashboard] Pulling messages with pivot: ${_lastRead.messagesLastReadAt}');
final out = await getWhatsNewEvents(_lastRead.messagesLastReadAt!); final src = Get.find<MessagesFetchingProvider>();
final out = await src.getWhatsNewEvents(_lastRead.messagesLastReadAt!);
if (out == null) return; if (out == null) return;
setState(() { setState(() {
_currentMessages = out.$1; _currentMessages = out.$1;
@ -87,7 +88,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
Future<void> _pullDaily() async { Future<void> _pullDaily() async {
try { try {
_signRecord = await _dailySign.getToday(); _signRecord = await _dailySign.getToday();
_dailySign.listLastRecord(30).then((value) { _dailySign.listLastRecord(14).then((value) {
setState(() => _signRecordHistory = value); setState(() => _signRecordHistory = value);
}); });
} catch (e) { } catch (e) {
@ -102,7 +103,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
try { try {
_signRecord = await _dailySign.signToday(); _signRecord = await _dailySign.signToday();
_dailySign.listLastRecord(30).then((value) { _dailySign.listLastRecord(14).then((value) {
setState(() => _signRecordHistory = value); setState(() => _signRecordHistory = value);
}); });
} catch (e) { } catch (e) {
@ -378,6 +379,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
isClickable: true, isClickable: true,
isShowEmbed: true, isShowEmbed: true,
isNestedClickable: true, isNestedClickable: true,
showFeaturedReply: true,
onUpdate: (_) { onUpdate: (_) {
_pullPosts(); _pullPosts();
}, },

View File

@ -16,14 +16,14 @@ import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/posts/post_shuffle_swiper.dart'; import 'package:solian/widgets/posts/post_shuffle_swiper.dart';
import 'package:solian/widgets/posts/post_warped_list.dart'; import 'package:solian/widgets/posts/post_warped_list.dart';
class FeedScreen extends StatefulWidget { class ExploreScreen extends StatefulWidget {
const FeedScreen({super.key}); const ExploreScreen({super.key});
@override @override
State<FeedScreen> createState() => _FeedScreenState(); State<ExploreScreen> createState() => _ExploreScreenState();
} }
class _FeedScreenState extends State<FeedScreen> class _ExploreScreenState extends State<ExploreScreen>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late final PostListController _postController; late final PostListController _postController;
late final TabController _tabController; late final TabController _tabController;
@ -82,7 +82,7 @@ class _FeedScreenState extends State<FeedScreen>
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return [ return [
SliverAppBar( SliverAppBar(
title: AppBarTitle('feed'.tr), title: AppBarTitle('explore'.tr),
centerTitle: false, centerTitle: false,
floating: true, floating: true,
toolbarHeight: AppTheme.toolbarHeight(context), toolbarHeight: AppTheme.toolbarHeight(context),
@ -151,7 +151,7 @@ class _FeedScreenState extends State<FeedScreen>
); );
} else { } else {
return SigninRequiredOverlay( return SigninRequiredOverlay(
onSignedIn: () => _postController.reloadAllOver(), onDone: () => _postController.reloadAllOver(),
); );
} }
}), }),

View File

@ -63,13 +63,13 @@ class _FeedSearchScreenState extends State<FeedSearchScreen> {
ListTile( ListTile(
leading: const Icon(Icons.label), leading: const Icon(Icons.label),
tileColor: Theme.of(context).colorScheme.surfaceContainer, tileColor: Theme.of(context).colorScheme.surfaceContainer,
title: Text('feedSearchWithTag'.trParams({'key': widget.tag!})), title: Text('postSearchWithTag'.trParams({'key': widget.tag!})),
), ),
if (widget.category != null) if (widget.category != null)
ListTile( ListTile(
leading: const Icon(Icons.category), leading: const Icon(Icons.category),
tileColor: Theme.of(context).colorScheme.surfaceContainer, tileColor: Theme.of(context).colorScheme.surfaceContainer,
title: Text('feedSearchWithCategory' title: Text('postSearchWithCategory'
.trParams({'key': widget.category!})), .trParams({'key': widget.category!})),
), ),
Expanded( Expanded(

View File

@ -3,6 +3,7 @@ import 'package:get/get.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/providers/content/posts.dart'; import 'package:solian/providers/content/posts.dart';
import 'package:solian/providers/last_read.dart';
import 'package:solian/widgets/posts/post_item.dart'; import 'package:solian/widgets/posts/post_item.dart';
import 'package:solian/widgets/posts/post_replies.dart'; import 'package:solian/widgets/posts/post_replies.dart';
@ -26,6 +27,7 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
Future<Post?> getDetail() async { Future<Post?> getDetail() async {
if (widget.post != null) { if (widget.post != null) {
item = widget.post; item = widget.post;
Get.find<LastReadProvider>().feedLastReadAt = item?.id;
return widget.post; return widget.post;
} }
@ -38,6 +40,8 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
context.showErrorDialog(e).then((_) => Navigator.pop(context)); context.showErrorDialog(e).then((_) => Navigator.pop(context));
} }
Get.find<LastReadProvider>().feedLastReadAt = item?.id;
return item; return item;
} }

View File

@ -75,7 +75,7 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = auth.configureClient('interactive'); final client = await auth.configureClient('interactive');
Response resp; Response resp;
if (widget.edit != null) { if (widget.edit != null) {
@ -183,18 +183,18 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
children: [ children: [
ListTile( ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainerLow, tileColor: Theme.of(context).colorScheme.surfaceContainerLow,
title: Row( title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
_editorController.title ?? 'title'.tr, _editorController.title ?? 'title'.tr,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const Gap(6),
if (_editorController.aliasController.text.isNotEmpty) if (_editorController.aliasController.text.isNotEmpty)
Badge( Badge(
label: Text('#${_editorController.aliasController.text}'), label: Text('#${_editorController.aliasController.text}'),
), ).paddingOnly(bottom: 2),
], ],
), ),
subtitle: Text( subtitle: Text(

View File

@ -7,10 +7,13 @@ import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/realm.dart'; import 'package:solian/providers/content/realm.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/screens/account/notification.dart'; import 'package:solian/screens/account/notification.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/signin_required_overlay.dart'; import 'package:solian/widgets/account/signin_required_overlay.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/auto_cache_image.dart';
import 'package:solian/widgets/current_state_action.dart'; import 'package:solian/widgets/current_state_action.dart';
import 'package:solian/widgets/sized_container.dart'; import 'package:solian/widgets/sized_container.dart';
@ -84,7 +87,7 @@ class _RealmListScreenState extends State<RealmListScreen> {
body: Obx(() { body: Obx(() {
if (auth.isAuthorized.isFalse) { if (auth.isAuthorized.isFalse) {
return SigninRequiredOverlay( return SigninRequiredOverlay(
onSignedIn: () => _getRealms(), onDone: () => _getRealms(),
); );
} }
@ -128,19 +131,34 @@ class _RealmListScreenState extends State<RealmListScreen> {
children: [ children: [
Container( Container(
color: Theme.of(context).colorScheme.surfaceContainer, color: Theme.of(context).colorScheme.surfaceContainer,
child: (element.banner?.isEmpty ?? true)
? const SizedBox.shrink()
: AutoCacheImage(
ServiceFinder.buildUrl(
'uc',
'/attachments/${element.banner}',
),
fit: BoxFit.cover,
),
), ),
const Positioned( Positioned(
bottom: -30, bottom: -30,
left: 18, left: 18,
child: CircleAvatar( child: (element.avatar?.isEmpty ?? true)
radius: 24, ? CircleAvatar(
backgroundColor: Colors.indigo, radius: 24,
child: FaIcon( backgroundColor:
FontAwesomeIcons.globe, Theme.of(context).colorScheme.primary,
color: Colors.white, child: const FaIcon(
size: 18, FontAwesomeIcons.globe,
), color: Colors.white,
), size: 18,
),
)
: AccountAvatar(
content: element.avatar!,
bgColor: Theme.of(context).colorScheme.primary,
),
), ),
], ],
), ),

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